feat: unify multimodels translation providers, remove self-hosting (Ollama/LibreTranslate), and fix local SQLite configuration
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m21s

This commit is contained in:
2026-06-14 10:44:46 +02:00
parent feea02033b
commit 5fd087979b
21 changed files with 157 additions and 1942 deletions

View File

@@ -646,10 +646,8 @@ async def update_default_provider(
"google",
"deepl",
"openai",
"ollama",
"openrouter",
"zai",
"libre",
"classic",
"llm",
]
@@ -848,7 +846,7 @@ class SettingsConfig(BaseModel):
google_cloud: ProviderSettings = ProviderSettings() # Cloud Translation API v2 (clé API)
deepl: ProviderSettings = ProviderSettings()
openai: ProviderSettings = ProviderSettings()
ollama: ProviderSettings = ProviderSettings() # dev-only in UI
openrouter: ProviderSettings = ProviderSettings() # "Traduction IA Essentielle"
openrouter_premium: ProviderSettings = ProviderSettings() # "Traduction IA Premium"
deepseek: ProviderSettings = ProviderSettings()
@@ -924,7 +922,7 @@ async def get_settings(admin_id: str = Depends(require_admin)):
payload["deepseek"] = _merge_env(settings.deepseek, key_env="DEEPSEEK_API_KEY", model_env="DEEPSEEK_MODEL", default_model="deepseek-chat")
payload["minimax"] = _merge_env(settings.minimax, key_env="MINIMAX_API_KEY", model_env="MINIMAX_MODEL", default_model="abab6.5s-chat")
payload["zai"] = _merge_env(settings.zai, key_env="ZAI_API_KEY", model_env="ZAI_MODEL", url_env="ZAI_BASE_URL", default_model="grok-2-1212", default_url="https://api.x.ai/v1")
payload["ollama"] = _merge_env(settings.ollama, url_env="OLLAMA_BASE_URL", model_env="OLLAMA_MODEL", default_url="http://localhost:11434", default_model="llama3")
payload["google_cloud"] = _merge_env(settings.google_cloud, key_env="GOOGLE_CLOUD_API_KEY")
# SMTP: merge from env vars, but never expose password
@@ -958,7 +956,7 @@ async def get_settings(admin_id: str = Depends(require_admin)):
"deepseek": bool(os.getenv("DEEPSEEK_API_KEY", "").strip()),
"minimax": bool(os.getenv("MINIMAX_API_KEY", "").strip()),
"zai": bool(os.getenv("ZAI_API_KEY", "").strip()),
"ollama": bool(os.getenv("OLLAMA_BASE_URL", "").strip()),
"google_cloud": bool(os.getenv("GOOGLE_CLOUD_API_KEY", "").strip()),
"smtp": bool(os.getenv("SMTP_HOST", "").strip()),
}
@@ -1116,22 +1114,6 @@ async def test_provider(
content={"available": True, "models_count": len(models)},
)
elif provider == "ollama":
import requests as _requests
base_url = (provider_config.base_url or "").strip() or os.getenv("OLLAMA_BASE_URL", "http://localhost:11434")
resp = _requests.get(f"{base_url}/api/tags", timeout=5)
if resp.ok:
return JSONResponse(
status_code=200,
content={
"available": True,
"models": resp.json().get("models", []),
},
)
return JSONResponse(
status_code=500, content={"available": False, "error": str(resp.text)}
)
elif provider in ("openrouter", "openrouter_premium"):
api_key = _key(provider_config.api_key, "OPENROUTER_API_KEY")
@@ -1325,59 +1307,6 @@ async def test_send_email(
)
@router.get("/providers/ollama/models")
async def list_ollama_models(
base_url: Optional[str] = Query(None),
admin_id: str = Depends(require_admin),
):
"""List available models from Ollama server. Accepts optional base_url query param
so the frontend can pass the URL currently being edited (before save)."""
import requests
from config import config as app_config
settings = load_settings()
resolved = (
base_url
or settings.ollama.base_url
or app_config.OLLAMA_BASE_URL
or "http://localhost:11434"
)
try:
response = requests.get(f"{resolved}/api/tags", timeout=5)
if response.ok:
data = response.json()
models = []
for model in data.get("models", []):
models.append(
{
"name": model.get("name", ""),
"size": model.get("size", 0),
"modified_at": model.get("modified_at", ""),
}
)
return JSONResponse(status_code=200, content={"data": models, "meta": {}})
return JSONResponse(
status_code=500,
content={
"error": "OLLAMA_UNAVAILABLE",
"message": f"Ollama returned: {response.text}",
},
)
except requests.exceptions.ConnectionError:
return JSONResponse(
status_code=503,
content={
"error": "OLLAMA_CONNECTION_ERROR",
"message": f"Cannot connect to Ollama at {resolved}",
},
)
except Exception as e:
logger.error(f"List Ollama models failed: {e}")
return JSONResponse(
status_code=500,
content={"error": "INTERNAL_ERROR", "message": str(e)},
)
@router.get("/providers/openai/models")

View File

@@ -66,8 +66,7 @@ async def get_available_providers():
from routes.admin_routes import load_settings
settings = load_settings()
is_dev = os.getenv("APP_ENV", "production").lower() == "development" or \
os.getenv("SHOW_OLLAMA", "false").lower() == "true"
is_dev = os.getenv("APP_ENV", "production").lower() == "development"
def _key_ready(key_var: str) -> bool:
return bool(os.getenv(key_var, "").strip())
@@ -208,22 +207,7 @@ async def get_available_providers():
"model": model,
})
# Ollama — dev only
if is_dev and _is_enabled("ollama", url_var="OLLAMA_BASE_URL"):
oll_cfg = getattr(settings, "ollama", None)
model = _resolve_model(
oll_cfg.model if oll_cfg else None,
"OLLAMA_MODEL",
"llama3",
)
available.append({
"id": "ollama",
"label": "Ollama (Local)",
"description": "Modèle LLM local — développement uniquement",
"mode": "llm",
"tier": "dev",
"model": model,
})
return JSONResponse(
status_code=200,
@@ -640,29 +624,7 @@ async def reconstruct_document(
)
@router.get("/ollama/models")
async def list_ollama_models(base_url: Optional[str] = None):
"""List available Ollama models"""
from services.translation_service import OllamaTranslationProvider
url = base_url or config.OLLAMA_BASE_URL
models = OllamaTranslationProvider.list_models(url)
return {"ollama_url": url, "models": models, "count": len(models)}
@router.post("/ollama/configure")
async def configure_ollama(base_url: str = Form(...), model: str = Form(...)):
"""Configure Ollama settings"""
config.OLLAMA_BASE_URL = base_url
config.OLLAMA_MODEL = model
return {
"status": "success",
"message": "Ollama configuration updated",
"ollama_url": base_url,
"model": model,
}
@router.get("/metrics")

View File

@@ -1030,6 +1030,13 @@ async def _run_translation_job(
source_lang=glossary_source_lang, target_lang=target_lang,
)
from services.providers.google_provider import GoogleTranslationProvider
from services.providers.google_cloud_provider import GoogleCloudTranslationProvider
from services.providers.deepl_provider import DeepLTranslationProvider
from services.providers.openai_provider import OpenAITranslationProvider
from services.providers.deepseek_provider import DeepSeekTranslationProvider
from services.providers.minimax_provider import MinimaxTranslationProvider
translation_provider = None
_p = provider.lower()
@@ -1037,24 +1044,34 @@ async def _run_translation_job(
# If the Cloud API key is invalid or the API is not enabled, fall back
# to the free legacy Google Translate (deep_translator) instead of failing.
if _p == "google":
# the user might have set GOOGLE_API_KEY instead of GOOGLE_CLOUD_API_KEY
gc_key = _cfg(
getattr(_admin_cfg.google_cloud, "api_key", None),
"GOOGLE_CLOUD_API_KEY",
) or os.getenv("GOOGLE_API_KEY", "").strip()
if gc_key and _google_cloud_key_valid(gc_key, job_id):
from services.providers.google_cloud_provider import LegacyGoogleCloudAdapter
translation_provider = LegacyGoogleCloudAdapter(gc_key)
translation_provider = GoogleCloudTranslationProvider(
api_key=gc_key,
timeout=int(os.getenv("GOOGLE_CLOUD_TIMEOUT", "30")),
max_retries=int(os.getenv("GOOGLE_CLOUD_MAX_RETRIES", "3")),
retry_delay=float(os.getenv("GOOGLE_CLOUD_RETRY_DELAY", "1.0")),
)
logger.info("google_provider_using_cloud_api", extra={"job_id": job_id})
else:
from services.translation_service import GoogleTranslationProvider
translation_provider = GoogleTranslationProvider()
translation_provider = GoogleTranslationProvider(
use_cache=True,
timeout=int(os.getenv("GOOGLE_TRANSLATE_TIMEOUT", "30")),
max_retries=int(os.getenv("GOOGLE_TRANSLATE_MAX_RETRIES", "3")),
retry_delay=float(os.getenv("GOOGLE_TRANSLATE_RETRY_DELAY", "1.0")),
)
logger.info("google_provider_using_legacy", extra={"job_id": job_id})
elif _p in ("openrouter", "llm") and api_key:
translation_provider = OpenRouterTranslationProvider(
api_key, model, full_prompt
translation_provider = OpenAITranslationProvider(
api_key=api_key,
model=model,
base_url="https://openrouter.ai/api/v1",
timeout=int(os.getenv("OPENROUTER_TIMEOUT", "60")),
)
elif _p == "openrouter_premium":
premium_key = _cfg(_admin_cfg.openrouter_premium.api_key, "OPENROUTER_API_KEY")
@@ -1062,71 +1079,58 @@ async def _run_translation_job(
if not premium_key:
premium_key = api_key # fall back to main openrouter key
if premium_key:
translation_provider = OpenRouterTranslationProvider(
premium_key, premium_model, full_prompt
translation_provider = OpenAITranslationProvider(
api_key=premium_key,
model=premium_model,
base_url="https://openrouter.ai/api/v1",
timeout=int(os.getenv("OPENROUTER_TIMEOUT", "60")),
)
elif _p == "openai":
from services.translation_service import OpenAITranslationProvider
openai_key = _cfg(_admin_cfg.openai.api_key, "OPENAI_API_KEY")
openai_model = _cfg(_admin_cfg.openai.model, "OPENAI_MODEL", "gpt-4o-mini")
if openai_key:
translation_provider = OpenAITranslationProvider(
api_key=openai_key,
model=openai_model,
system_prompt=full_prompt,
timeout=int(os.getenv("OPENAI_TIMEOUT", "60")),
)
elif _p == "deepseek":
from services.translation_service import OpenAITranslationProvider as _OAI
ds_key = _cfg(getattr(_admin_cfg, "deepseek", None) and _admin_cfg.deepseek.api_key, "DEEPSEEK_API_KEY")
ds_model = _cfg(getattr(_admin_cfg, "deepseek", None) and _admin_cfg.deepseek.model, "DEEPSEEK_MODEL", "deepseek-chat")
ds_url = "https://api.deepseek.com/v1"
if ds_key:
translation_provider = _OAI(
translation_provider = DeepSeekTranslationProvider(
api_key=ds_key,
model=ds_model,
base_url=ds_url,
system_prompt=full_prompt,
timeout=int(os.getenv("DEEPSEEK_TIMEOUT", "60")),
)
elif _p == "minimax":
from services.translation_service import OpenAITranslationProvider as _OAI
mm_key = _cfg(getattr(_admin_cfg, "minimax", None) and _admin_cfg.minimax.api_key, "MINIMAX_API_KEY")
mm_model = _cfg(getattr(_admin_cfg, "minimax", None) and _admin_cfg.minimax.model, "MINIMAX_MODEL", "abab6.5s-chat")
mm_url = "https://api.minimax.chat/v1"
mm_model = _cfg(getattr(_admin_cfg, "minimax", None) and _admin_cfg.minimax.model, "MINIMAX_MODEL", "MiniMax-M1")
if mm_key:
translation_provider = _OAI(
translation_provider = MinimaxTranslationProvider(
api_key=mm_key,
model=mm_model,
base_url=mm_url,
system_prompt=full_prompt,
timeout=int(os.getenv("MINIMAX_TIMEOUT", "60")),
)
elif _p == "deepl":
deepl_key = _cfg(_admin_cfg.deepl.api_key, "DEEPL_API_KEY")
if deepl_key:
from services.translation_service import DeepLTranslationProvider
translation_provider = DeepLTranslationProvider(deepl_key)
translation_provider = DeepLTranslationProvider(
api_key=deepl_key,
timeout=int(os.getenv("DEEPL_TIMEOUT", "30")),
)
elif _p == "zai":
from services.translation_service import OpenAITranslationProvider as _OAI
zai_key = _cfg(_admin_cfg.zai.api_key, "ZAI_API_KEY")
zai_model = _cfg(_admin_cfg.zai.model, "ZAI_MODEL", "grok-2-1212")
zai_url = _cfg(_admin_cfg.zai.base_url, "ZAI_BASE_URL", "https://api.x.ai/v1")
if zai_key:
translation_provider = _OAI(
translation_provider = OpenAITranslationProvider(
api_key=zai_key,
model=zai_model,
base_url=zai_url,
system_prompt=full_prompt,
timeout=int(os.getenv("ZAI_TIMEOUT", "60")),
)
elif _p == "ollama":
ollama_url = _cfg(_admin_cfg.ollama.base_url, "OLLAMA_BASE_URL", "http://localhost:11434")
ollama_model = _cfg(_admin_cfg.ollama.model, "OLLAMA_MODEL", "llama3")
translation_provider = OllamaTranslationProvider(
ollama_url,
ollama_model,
ollama_model,
full_prompt,
)
elif _p == "google_cloud":
from services.providers.google_cloud_provider import GoogleCloudTranslationProvider
gc_key = _cfg(
getattr(_admin_cfg.google_cloud, "api_key", None),
"GOOGLE_CLOUD_API_KEY",
@@ -1147,7 +1151,6 @@ async def _run_translation_job(
"google_cloud_key_missing_fallback_to_google",
extra={"job_id": job_id},
)
# translation_provider reste None → legacy Google gratuit
tracker.update(20, "Preparing translation")
@@ -1194,6 +1197,8 @@ async def _run_translation_job(
if file_extension == ".xlsx":
logger.info(f"DEBUG: ExcelTranslator class is {ExcelTranslator} and translate_file is {ExcelTranslator.translate_file}")
job_translator = ExcelTranslator(provider=translation_provider)
if hasattr(job_translator, "set_custom_prompt"):
job_translator.set_custom_prompt(full_prompt)
await asyncio.to_thread(
job_translator.translate_file,
input_path,
@@ -1205,6 +1210,8 @@ async def _run_translation_job(
)
elif file_extension == ".docx":
job_translator = WordTranslator(provider=translation_provider)
if hasattr(job_translator, "set_custom_prompt"):
job_translator.set_custom_prompt(full_prompt)
await asyncio.to_thread(
job_translator.translate_file,
input_path,
@@ -1216,6 +1223,8 @@ async def _run_translation_job(
)
elif file_extension == ".pptx":
job_translator = PowerPointTranslator(provider=translation_provider)
if hasattr(job_translator, "set_custom_prompt"):
job_translator.set_custom_prompt(full_prompt)
await asyncio.to_thread(
job_translator.translate_file,
input_path,
@@ -1228,6 +1237,8 @@ async def _run_translation_job(
elif file_extension == ".pdf":
from translators.pdf_translator import PDFTranslator
job_translator = PDFTranslator(provider=translation_provider)
if hasattr(job_translator, "set_custom_prompt"):
job_translator.set_custom_prompt(full_prompt)
actual_output = await asyncio.to_thread(
job_translator.translate_file,
input_path,