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
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m21s
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user