Files
office_translator/services/providers/config.py
sepehr 526c87348f
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m20s
feat(translation): quality pipeline overhaul + new features (audit 2026-08-29)
Translation quality & format preservation:
- Word: merge adjacent same-format runs into one unit (sentence-level
  coherence like inline-tag handling); translate comments/balloons;
  dedupe textbox collection (was translated twice); RTL no longer
  overrides center/justify alignment; CJK/Arabic font hints (eastAsia/cs)
- PPTX: chart translations now actually reach the output file
  (ChartPart.blob is read-only — rewrite chart XML in the saved ZIP);
  CJK typeface hints (a:ea)
- Excel: sheet renames no longer break references — rewrite cell
  formulas (3D/quoted), defined names, data validations, cond. formats
- PDF: bold/italic honored (hebo/heit/hebi); table cells never merge;
  unchanged blocks left untouched (typography preserved, fixes duplicate
  hyperlinks); attempted/changed stats + route gate now cover PDF;
  CJK font paths; scanned PDFs via Mistral OCR (detection + admin settings)

Features:
- formality param (formal/informal) + automatic regional-variant prompts
- output_mode=bilingual docx (source above translation)
- per-user translation memory on Redis (falls back to LRU), context-hashed
- QA report + 0-100 confidence score in job status; L0 on by default
- OpenAI-compatible providers: whole chunk in ONE numbered-JSON request
  (~15x fewer calls) with per-item fallback; base prompt always present
  (custom prompt no longer replaces translation instructions)

Infra & marketing alignment:
- plan-based engine gating + vision gating (closes paid-engine leak);
  /providers/available filtered per plan; 107 languages exposed
- zh-CN/zh-TW validation fixed; libmagic disabled on Windows (native crash)
- admin: Mistral OCR settings + engine status dashboard; httpx<0.28 pin
  (TestClient breakage); Prometheus test fixture fixed
- marketing docs aligned with code (PDF+OCR, retention, engines, pricing)
- security: .env.ionos/.env.production/provider_settings.json removed

Tests: 1173 passed / 0 failed (6 network tests deselected: free Google
endpoint temporarily blocked from this machine)
2026-08-29 18:38:09 +02:00

253 lines
9.5 KiB
Python

"""
Provider Configuration - Environment-based settings for translation providers.
Loads API keys, URLs, and enable/disable flags from environment variables.
"""
import os
from typing import List, Optional
from pydantic import BaseModel
def _ensure_dotenv_loaded() -> None:
"""Load .env file if not already loaded."""
from dotenv import load_dotenv
load_dotenv()
_ensure_dotenv_loaded()
class ProviderSettings(BaseModel):
"""Settings for a single translation provider."""
enabled: bool = False
api_key: Optional[str] = None
base_url: Optional[str] = None
model: Optional[str] = None
class ProvidersConfig:
"""
Configuration for all translation providers.
Loads settings from environment variables with sensible defaults.
"""
# Google Translate (no API key required via deep_translator — accès web non officiel)
GOOGLE_ENABLED: bool = (
os.getenv("GOOGLE_TRANSLATE_ENABLED", "true").lower() == "true"
)
GOOGLE_TRANSLATE_TIMEOUT: int = int(os.getenv("GOOGLE_TRANSLATE_TIMEOUT", "30"))
GOOGLE_TRANSLATE_MAX_RETRIES: int = int(
os.getenv("GOOGLE_TRANSLATE_MAX_RETRIES", "3")
)
GOOGLE_TRANSLATE_RETRY_DELAY: float = float(
os.getenv("GOOGLE_TRANSLATE_RETRY_DELAY", "1.0")
)
# Google Cloud Translation API v2 (clé API officielle, facturable)
# Obtenir la clé : https://console.cloud.google.com → APIs & Services → Credentials
# Activer : Cloud Translation API (Basic v2) + Facturation sur le projet
GOOGLE_CLOUD_ENABLED: bool = (
os.getenv("GOOGLE_CLOUD_ENABLED", "false").lower() == "true"
)
GOOGLE_CLOUD_API_KEY: str = os.getenv("GOOGLE_CLOUD_API_KEY", "")
GOOGLE_CLOUD_TIMEOUT: int = int(os.getenv("GOOGLE_CLOUD_TIMEOUT", "30"))
GOOGLE_CLOUD_MAX_RETRIES: int = int(os.getenv("GOOGLE_CLOUD_MAX_RETRIES", "3"))
GOOGLE_CLOUD_RETRY_DELAY: float = float(
os.getenv("GOOGLE_CLOUD_RETRY_DELAY", "1.0")
)
# DeepL
DEEPL_ENABLED: bool = os.getenv("DEEPL_ENABLED", "false").lower() == "true"
DEEPL_API_KEY: str = os.getenv("DEEPL_API_KEY", "")
DEEPL_TIMEOUT: int = int(os.getenv("DEEPL_TIMEOUT", "30"))
DEEPL_MAX_RETRIES: int = int(os.getenv("DEEPL_MAX_RETRIES", "3"))
DEEPL_RETRY_DELAY: float = float(os.getenv("DEEPL_RETRY_DELAY", "1.0"))
# OpenAI
OPENAI_ENABLED: bool = os.getenv("OPENAI_ENABLED", "false").lower() == "true"
OPENAI_API_KEY: str = os.getenv("OPENAI_API_KEY", "")
OPENAI_MODEL: str = os.getenv("OPENAI_MODEL", "gpt-4o-mini")
OPENAI_TIMEOUT: int = int(os.getenv("OPENAI_TIMEOUT", "60"))
OPENAI_MAX_RETRIES: int = int(os.getenv("OPENAI_MAX_RETRIES", "3"))
OPENAI_RETRY_DELAY: float = float(os.getenv("OPENAI_RETRY_DELAY", "1.0"))
OPENAI_BASE_URL: str = os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1")
OPENAI_HEALTH_CHECK_TIMEOUT: int = int(
os.getenv("OPENAI_HEALTH_CHECK_TIMEOUT", "5")
)
# OpenRouter (multi-model API)
OPENROUTER_ENABLED: bool = (
os.getenv("OPENROUTER_ENABLED", "false").lower() == "true"
)
OPENROUTER_API_KEY: str = os.getenv("OPENROUTER_API_KEY", "")
OPENROUTER_MODEL: str = os.getenv("OPENROUTER_MODEL", "deepseek/deepseek-chat")
# DeepSeek (direct API)
DEEPSEEK_ENABLED: bool = os.getenv("DEEPSEEK_ENABLED", "false").lower() == "true"
DEEPSEEK_API_KEY: str = os.getenv("DEEPSEEK_API_KEY", "")
DEEPSEEK_MODEL: str = os.getenv("DEEPSEEK_MODEL", "deepseek-chat")
DEEPSEEK_BASE_URL: str = os.getenv("DEEPSEEK_BASE_URL", "https://api.deepseek.com/v1")
DEEPSEEK_TIMEOUT: int = int(os.getenv("DEEPSEEK_TIMEOUT", "60"))
DEEPSEEK_MAX_RETRIES: int = int(os.getenv("DEEPSEEK_MAX_RETRIES", "3"))
DEEPSEEK_RETRY_DELAY: float = float(os.getenv("DEEPSEEK_RETRY_DELAY", "1.0"))
# Minimax (public OpenAI-compatible API - https://api.minimax.io)
MINIMAX_ENABLED: bool = os.getenv("MINIMAX_ENABLED", "false").lower() == "true"
MINIMAX_API_KEY: str = os.getenv("MINIMAX_API_KEY", "")
MINIMAX_MODEL: str = os.getenv("MINIMAX_MODEL", "MiniMax-M3")
MINIMAX_BASE_URL: str = os.getenv("MINIMAX_BASE_URL", "https://api.minimax.io/v1")
MINIMAX_GROUP_ID: str = os.getenv("MINIMAX_GROUP_ID", "")
MINIMAX_TIMEOUT: int = int(os.getenv("MINIMAX_TIMEOUT", "60"))
MINIMAX_MAX_RETRIES: int = int(os.getenv("MINIMAX_MAX_RETRIES", "3"))
MINIMAX_RETRY_DELAY: float = float(os.getenv("MINIMAX_RETRY_DELAY", "1.0"))
# Fallback chain configuration
#
# IMPORTANT: the registry-based fallback (translate_with_fallback) only
# ever sees providers that _auto_register_providers() registers, i.e.
# google, deepl, openai, deepseek and minimax. The OpenAI-compatible
# shims (openrouter, openrouter_premium, zai) and google_cloud are wired
# directly in routes/translate_routes.py and are intentionally NOT part of
# the registry fallback chain — listing them here would make
# translate_with_fallback silently skip them with a "provider not
# registered" log on every call. Override via env if you know what you
# are doing.
FALLBACK_CHAIN: List[str] = [
name.strip()
for name in os.getenv(
"PROVIDER_FALLBACK_CHAIN", "google,deepl,openai,deepseek,minimax"
).split(",")
if name.strip()
]
# Mode-specific fallback chains
# Classic mode: Google Translate -> DeepL
FALLBACK_CHAIN_CLASSIC: List[str] = [
name.strip()
for name in os.getenv("FALLBACK_CHAIN_CLASSIC", "google,deepl").split(",")
if name.strip()
]
# LLM mode: cloud providers in order of cost/quality (registry-registered only)
FALLBACK_CHAIN_LLM: List[str] = [
name.strip()
for name in os.getenv("FALLBACK_CHAIN_LLM", "openai,deepseek,minimax").split(",")
if name.strip()
]
@classmethod
def get_fallback_chain(cls, mode: str = "auto") -> List[str]:
"""
Get the fallback chain for a specific mode.
Args:
mode: "classic" for Classic providers, "llm" for LLM providers,
"auto" or any other value for general fallback chain
Returns:
List of provider names in fallback order
"""
mode = mode.lower()
if mode == "classic":
return cls.FALLBACK_CHAIN_CLASSIC
elif mode == "llm":
return cls.FALLBACK_CHAIN_LLM
else:
return cls.FALLBACK_CHAIN
@classmethod
def get_provider_settings(cls, provider_name: str) -> ProviderSettings:
"""
Get settings for a specific provider.
Args:
provider_name: Name of the provider (e.g., "google", "deepl")
Returns:
ProviderSettings for the requested provider
"""
settings_map = {
"google": ProviderSettings(
enabled=cls.GOOGLE_ENABLED, api_key=None, base_url=None, model=None
),
"google_cloud": ProviderSettings(
enabled=cls.GOOGLE_CLOUD_ENABLED,
api_key=cls.GOOGLE_CLOUD_API_KEY if cls.GOOGLE_CLOUD_API_KEY else None,
base_url=None,
model=None,
),
"deepl": ProviderSettings(
enabled=cls.DEEPL_ENABLED,
api_key=cls.DEEPL_API_KEY if cls.DEEPL_API_KEY else None,
base_url=None,
model=None,
),
"openai": ProviderSettings(
enabled=cls.OPENAI_ENABLED,
api_key=cls.OPENAI_API_KEY if cls.OPENAI_API_KEY else None,
base_url=cls.OPENAI_BASE_URL or None,
model=cls.OPENAI_MODEL,
),
"openrouter": ProviderSettings(
enabled=cls.OPENROUTER_ENABLED,
api_key=cls.OPENROUTER_API_KEY if cls.OPENROUTER_API_KEY else None,
base_url="https://openrouter.ai/api/v1",
model=cls.OPENROUTER_MODEL,
),
"deepseek": ProviderSettings(
enabled=cls.DEEPSEEK_ENABLED,
api_key=cls.DEEPSEEK_API_KEY if cls.DEEPSEEK_API_KEY else None,
base_url=cls.DEEPSEEK_BASE_URL,
model=cls.DEEPSEEK_MODEL,
),
"minimax": ProviderSettings(
enabled=cls.MINIMAX_ENABLED,
api_key=cls.MINIMAX_API_KEY if cls.MINIMAX_API_KEY else None,
base_url=cls.MINIMAX_BASE_URL,
model=cls.MINIMAX_MODEL,
),
}
return settings_map.get(provider_name.lower(), ProviderSettings())
@classmethod
def is_provider_configured(cls, provider_name: str) -> bool:
"""
Check if a provider is properly configured.
Args:
provider_name: Name of the provider
Returns:
True if the provider is enabled and has required configuration
"""
settings = cls.get_provider_settings(provider_name)
if not settings.enabled:
return False
# Providers requiring API keys
providers_requiring_key = {"deepl", "openai", "openrouter", "google_cloud", "deepseek", "minimax"}
if provider_name.lower() in providers_requiring_key:
return bool(settings.api_key)
return True
@classmethod
def get_available_providers(cls) -> List[str]:
"""
Get list of configured and available providers.
Returns:
List of provider names that are ready to use
"""
return [name for name in cls.FALLBACK_CHAIN if cls.is_provider_configured(name)]
providers_config = ProvidersConfig()