""" OpenAI Provider - Cloud LLM translation provider. Extends TranslationProvider base class with robust error handling, retry logic, and health monitoring for OpenAI API. Features: - Cloud LLM translation via OpenAI Chat Completions API - Custom system prompt support - Specific error codes for all OpenAI API errors - Retry logic with exponential backoff for transient errors - Timeout configuration (faster than local Ollama) - Health check with caching - Structlog-compatible logging (no document content in logs) """ import threading import time from datetime import datetime, timezone from typing import Any, Dict, List, Optional from core.logging import get_logger logger = get_logger(__name__) # structlog is the project's logger backend (see core/logging.py), but detect # it rather than hardcoding True so the stdlib-logging fallback branches below # are reachable if structlog is ever absent. try: import structlog # noqa: F401 _HAS_STRUCTLOG = True except ImportError: # pragma: no cover - structlog is a hard project dep _HAS_STRUCTLOG = False def _log_info(event: str, **kwargs): """Log info with structlog or standard logging compatibility.""" if _HAS_STRUCTLOG: logger.info(event, **kwargs) else: msg = f"{event} " + " ".join(f"{k}={v}" for k, v in kwargs.items()) logger.info(msg) def _log_warning(event: str, **kwargs): """Log warning with structlog or standard logging compatibility.""" if _HAS_STRUCTLOG: logger.warning(event, **kwargs) else: msg = f"{event} " + " ".join(f"{k}={v}" for k, v in kwargs.items()) logger.warning(msg) def _log_error(event: str, **kwargs): """Log error with structlog or standard logging compatibility.""" if _HAS_STRUCTLOG: logger.error(event, **kwargs) else: msg = f"{event} " + " ".join(f"{k}={v}" for k, v in kwargs.items()) logger.error(msg) import requests from requests.exceptions import Timeout, ConnectionError as RequestsConnectionError from .base import TranslationProvider from .schemas import ( ProviderHealthStatus, TranslationRequest, TranslationResponse, ) # Error codes OPENAI_RATE_LIMITED = "OPENAI_RATE_LIMITED" OPENAI_INVALID_KEY = "OPENAI_INVALID_KEY" OPENAI_QUOTA_EXCEEDED = "OPENAI_QUOTA_EXCEEDED" OPENAI_TIMEOUT = "OPENAI_TIMEOUT" OPENAI_SERVICE_ERROR = "OPENAI_SERVICE_ERROR" OPENAI_CONTEXT_TOO_LONG = "OPENAI_CONTEXT_TOO_LONG" _RETRYABLE_ERRORS = {OPENAI_RATE_LIMITED, OPENAI_TIMEOUT, OPENAI_SERVICE_ERROR} class OpenAIProviderError(Exception): """Exception raised for OpenAI API errors.""" def __init__( self, code: str, message: str, details: Optional[Dict[str, Any]] = None ): self.code = code self.message = message self.details = details or {} super().__init__(message) def to_dict(self) -> Dict[str, Any]: """Convert error to dictionary format.""" result = { "error": self.code, "message": self.message, } if self.details: result["details"] = self.details return result DEFAULT_TRANSLATION_PROMPT = """You are a professional translator. Translate the following text from {source_lang} to {target_lang}. Rules: - Translate ONLY the text, do not add explanations or notes - Preserve the original formatting, line breaks, and structure - Maintain the original tone and style - Translate technical terms, jargon, and labels using the standard target-language equivalent - Chart elements (titles, axis labels, legend entries, category labels, series names) MUST always be translated, even when they look like a title or a proper noun - Translate month and weekday abbreviations (Jan → janvier, Mon → lundi) to the target language - Keep ONLY real proper nouns unchanged: people's names, place names, company names (Google, Microsoft), and product names (GitHub, HuggingFace) - Do not invent content; if a term is an acronym with no translation (API, URL, HTTP, JSON), keep it as-is""" def _build_system_prompt( source_lang: str, target_lang: str, custom_prompt: Optional[str] = None ) -> str: """Build system prompt for translation. The base translation instructions are ALWAYS present — a custom prompt (glossary, tone, context) is appended as additional directives, never a replacement. Previously a glossary-only custom prompt produced a system prompt with no translation instruction at all. """ base = DEFAULT_TRANSLATION_PROMPT.format( source_lang=source_lang, target_lang=target_lang ) if custom_prompt and custom_prompt.strip(): return f"{base}\n\nADDITIONAL CONTEXT AND INSTRUCTIONS:\n{custom_prompt.strip()}" return base def _get_language_name(code: str) -> str: """Convert language code to full name for better LLM understanding.""" from core.languages import language_name return language_name(code) class OpenAITranslationProvider(TranslationProvider): """ OpenAI LLM implementation for cloud translation. Features: - Uses OpenAI Chat Completions API - Custom system prompt support for translation context - Thread-safe HTTP client - Robust error handling with specific error codes - Retry logic with exponential backoff - Configurable timeout (default 60s for cloud API) - Health check with result caching """ def __init__( self, api_key: str, model: str = "gpt-4o-mini", timeout: int = 60, max_retries: int = 3, retry_delay: float = 1.0, base_url: str = "https://api.openai.com/v1", health_check_timeout: int = 5, ): """ Initialize OpenAI provider. Args: api_key: OpenAI API key model: Model name to use (default: gpt-4o-mini) timeout: Request timeout in seconds (default: 60) max_retries: Maximum retry attempts for transient errors (default: 3) retry_delay: Initial retry delay in seconds (default: 1.0) base_url: OpenAI API base URL (default: https://api.openai.com/v1) health_check_timeout: Timeout for health check requests in seconds (default: 5) """ if not api_key or not api_key.strip(): raise ValueError("OpenAI API key cannot be empty") self._api_key = api_key self._model = model self._base_url = base_url.rstrip("/") self._provider_name = "openai" self._timeout = timeout self._max_retries = max_retries self._retry_delay = retry_delay self._health_check_timeout = health_check_timeout self._health_cache: Dict[str, Any] = {} self._health_cache_ttl = 60 self._health_cache_lock = threading.Lock() def _make_api_request(self, text: str, system_prompt: str) -> tuple: """ Make API request to OpenAI. Returns: Tuple of (translated_content, usage_dict). usage_dict may be empty. Raises: OpenAIProviderError: For any API errors with specific codes """ if not text or not text.strip(): return text, {} # Check text length (rough estimate: 1 token ~= 4 chars) if len(text) > 16000: # ~4000 tokens raise OpenAIProviderError( code=OPENAI_CONTEXT_TOO_LONG, message="Texte trop long pour le modèle (max ~4000 tokens).", details={"text_length": len(text), "max_tokens": 4000}, ) url = f"{self._base_url}/chat/completions" headers = { "Authorization": f"Bearer {self._api_key}", "Content-Type": "application/json", } payload = { "model": self._model, "messages": [ {"role": "system", "content": system_prompt}, {"role": "user", "content": text}, ], "temperature": 0.3, "max_tokens": 4096, } try: response = requests.post( url, headers=headers, json=payload, timeout=self._timeout, ) # Handle specific HTTP status codes if response.status_code == 401: raise OpenAIProviderError( code=OPENAI_INVALID_KEY, message="Clé API OpenAI invalide. Vérifiez votre configuration.", details={"status_code": 401}, ) if response.status_code == 429: try: error_data = response.json().get("error", {}) or {} except Exception: error_data = {} error_code = error_data.get("code", "") # Check for rate limit vs quota exceeded if error_code == "insufficient_quota": raise OpenAIProviderError( code=OPENAI_QUOTA_EXCEEDED, message="Quota OpenAI épuisé. Vérifiez votre facturation.", details={"status_code": 429, "error_code": error_code}, ) else: # Rate limit retry_after = response.headers.get("retry-after", "20") raise OpenAIProviderError( code=OPENAI_RATE_LIMITED, message=f"Limite de requêtes OpenAI atteinte. Réessayez dans {retry_after}s.", details={ "status_code": 429, "retry_after_seconds": int(retry_after) if retry_after.isdigit() else 20, }, ) if response.status_code == 400: try: error_data = response.json().get("error", {}) or {} except Exception: error_data = {} error_code = error_data.get("code", "") if error_code == "context_length_exceeded": raise OpenAIProviderError( code=OPENAI_CONTEXT_TOO_LONG, message="Texte trop long pour le modèle (max ~4000 tokens).", details={"status_code": 400, "error_code": error_code}, ) if response.status_code >= 500: raise OpenAIProviderError( code=OPENAI_SERVICE_ERROR, message="Service OpenAI temporairement indisponible.", details={"status_code": response.status_code}, ) if response.status_code != 200: error_text = response.text[:200] if response.text else "Unknown error" raise OpenAIProviderError( code=OPENAI_SERVICE_ERROR, message=f"Erreur OpenAI: {error_text}", details={"status_code": response.status_code}, ) data = response.json() choices = data.get("choices", []) if not choices: raise OpenAIProviderError( code=OPENAI_SERVICE_ERROR, message="Erreur OpenAI: réponse vide", details={"response": str(data)[:200]}, ) content = choices[0].get("message", {}).get("content", "") if not content: raise OpenAIProviderError( code=OPENAI_SERVICE_ERROR, message="Erreur OpenAI: réponse vide", details={"response": str(data)[:200]}, ) usage = data.get("usage", {}) return content.strip(), usage except Timeout: raise OpenAIProviderError( code=OPENAI_TIMEOUT, message="Délai d'attente OpenAI dépassé. Le service est lent.", details={"timeout_seconds": self._timeout}, ) except RequestsConnectionError: raise OpenAIProviderError( code=OPENAI_SERVICE_ERROR, message="Service OpenAI temporairement indisponible.", details={"error": "Connection failed"}, ) except OpenAIProviderError: raise except Exception as e: error_str = str(e).lower() if "connection" in error_str or "refused" in error_str: raise OpenAIProviderError( code=OPENAI_SERVICE_ERROR, message="Service OpenAI temporairement indisponible.", details={"original_error": str(e)[:100]}, ) raise OpenAIProviderError( code=OPENAI_SERVICE_ERROR, message=f"Erreur OpenAI: {str(e)[:100]}", details={"original_error": str(e)[:100]}, ) def get_name(self) -> str: """Return provider name.""" return self._provider_name def is_available(self) -> bool: """ Check if OpenAI API is available. Uses cached result if available and not expired. """ current_time = time.time() with self._health_cache_lock: if "is_available" in self._health_cache: cached = self._health_cache["is_available"] if current_time - cached["timestamp"] < self._health_cache_ttl: return cached["value"] try: url = f"{self._base_url}/models" headers = {"Authorization": f"Bearer {self._api_key}"} response = requests.get( url, headers=headers, timeout=self._health_check_timeout ) available = response.status_code == 200 except Exception as e: _log_warning("openai_availability_check_failed", error=str(e)[:100]) available = False with self._health_cache_lock: self._health_cache["is_available"] = { "value": available, "timestamp": current_time, } return available def translate_text(self, request: TranslationRequest) -> TranslationResponse: """ Translate a single text string using OpenAI LLM. Supports custom system prompt via request.metadata["custom_prompt"]. Args: request: TranslationRequest with text and language info Returns: TranslationResponse with translated text """ text = request.text target_language = request.target_language source_language = request.source_language or "auto" if not text or not text.strip(): return TranslationResponse( translated_text=text, provider_name=self._provider_name, from_cache=False, ) source_lang_name = _get_language_name(source_language) target_lang_name = _get_language_name(target_language) custom_prompt = None if request.metadata: custom_prompt = request.metadata.get("custom_prompt") system_prompt = _build_system_prompt( source_lang_name, target_lang_name, custom_prompt ) last_error: Optional[OpenAIProviderError] = None retries = 0 while retries <= self._max_retries: try: start_time = time.time() result, usage = self._make_api_request(text, system_prompt) latency = time.time() - start_time log_kw: Dict[str, Any] = { "chars": len(text), "source_lang": source_language, "target_lang": target_language, "model": self._model, "latency_ms": round(latency * 1000, 2), "retries": retries, } if usage and isinstance(usage.get("total_tokens"), (int, float)): log_kw["tokens_used"] = usage.get("total_tokens") _log_info("openai_translation_success", **log_kw) return TranslationResponse( translated_text=result, provider_name=self._provider_name, from_cache=False, source_language=source_language, ) except OpenAIProviderError as e: last_error = e if e.code not in _RETRYABLE_ERRORS: break retries += 1 if retries <= self._max_retries: delay = self._retry_delay * (2 ** (retries - 1)) _log_info( "openai_translation_retry", attempt=retries, delay_s=round(delay, 2), error_code=e.code, text_length=len(text), source_lang=source_language, target_lang=target_language, ) time.sleep(delay) except Exception as e: last_error = OpenAIProviderError( code=OPENAI_SERVICE_ERROR, message=f"Erreur OpenAI: {str(e)[:100]}", details={"original_error": str(e)[:100]}, ) retries += 1 if retries <= self._max_retries: delay = self._retry_delay * (2 ** (retries - 1)) time.sleep(delay) if last_error: _log_error( "openai_translation_failed", error_code=last_error.code, text_length=len(text), source_lang=source_language, target_lang=target_language, retries=retries, ) return TranslationResponse( translated_text=text, provider_name=self._provider_name, from_cache=False, error=last_error.message, error_code=last_error.code, error_details=last_error.details, ) return TranslationResponse( translated_text=text, provider_name=self._provider_name, from_cache=False, error="Unknown error", error_code=OPENAI_SERVICE_ERROR, ) def _make_batch_api_request( self, texts: List[str], system_prompt: str ) -> Optional[List[str]]: """Translate a whole chunk in ONE request via a numbered JSON list. The user message is a JSON array; the model must answer with a JSON array of the same length. Returns None when the answer cannot be parsed confidently — callers then fall back to per-item calls (correctness over latency). """ import json as _json numbered = _json.dumps( [{"id": i, "text": t} for i, t in enumerate(texts)], ensure_ascii=False, ) batch_system = ( system_prompt + "\n\nBATCH MODE: the user message is a JSON array of items with " "unique ids. Answer with ONLY a JSON array of objects " '[{"id": , "translation": ""}], same ' "length and same ids, in the same order. Translate every item; " "keep ids unchanged; no comments, no markdown fence." ) try: content, _usage = self._make_api_request(numbered, batch_system) raw = content.strip() # Strip an optional markdown fence if raw.startswith("```"): raw = raw.strip("`") if raw.lower().startswith("json"): raw = raw[4:] raw = raw.strip() parsed = _json.loads(raw) if not isinstance(parsed, list) or len(parsed) != len(texts): return None out: List[str] = [""] * len(texts) for item in parsed: if not isinstance(item, dict): return None idx = item.get("id") translation = item.get("translation") if not isinstance(idx, int) or not 0 <= idx < len(texts): return None if not isinstance(translation, str) or not translation.strip(): return None out[idx] = translation.strip() return out except OpenAIProviderError: raise except Exception: return None def translate_batch( self, requests: List[TranslationRequest] ) -> List[TranslationResponse]: """ Translate multiple texts. Chunks arrive from the translators as ~15 texts. When every request shares the same language pair and metadata, they are sent in ONE call (numbered JSON list — ~15× fewer requests, better contextual consistency across neighbouring segments). Any parse/API doubt falls back to the per-item path so a batch failure never corrupts output. """ if not requests: return [] same_pair = len({(r.source_language, r.target_language) for r in requests}) == 1 same_meta = len( {tuple(sorted((r.metadata or {}).items())) for r in requests} ) == 1 if same_pair and same_meta and len(requests) > 1: try: source_lang_name = _get_language_name( requests[0].source_language or "auto" ) or "the source language (auto-detect)" target_lang_name = _get_language_name(requests[0].target_language) custom_prompt = None if requests[0].metadata: custom_prompt = requests[0].metadata.get("custom_prompt") system_prompt = _build_system_prompt( source_lang_name, target_lang_name, custom_prompt ) texts = [r.text for r in requests] translations = self._make_batch_api_request(texts, system_prompt) if translations is not None: _log_info( "openai_batch_translation_success", items=len(requests), model=self._model, ) return [ TranslationResponse( translated_text=t, provider_name=self._provider_name, from_cache=False, ) for t in translations ] _log_warning( "openai_batch_translation_fallback", reason="unparseable_response", items=len(requests), ) except Exception as e: _log_warning( "openai_batch_translation_fallback", reason=type(e).__name__, items=len(requests), ) return [self.translate_text(req) for req in requests] def health_check(self) -> ProviderHealthStatus: """ Return health status details for the provider. Includes cached result for efficiency. Returns: ProviderHealthStatus with availability, latency, and model information """ current_time = time.time() with self._health_cache_lock: if "health_check" in self._health_cache: cached = self._health_cache["health_check"] if current_time - cached["timestamp"] < self._health_cache_ttl: return cached["value"] start_time = time.time() last_check_iso = datetime.now(timezone.utc).isoformat() try: url = f"{self._base_url}/models" headers = {"Authorization": f"Bearer {self._api_key}"} response = requests.get( url, headers=headers, timeout=self._health_check_timeout ) latency_ms = (time.time() - start_time) * 1000 available = response.status_code == 200 error_msg = None model_available = None if available: try: models_data = response.json().get("data", []) model_ids = [m.get("id", "") for m in models_data] model_available = self._model in model_ids or any( self._model in mid for mid in model_ids ) except Exception: model_available = None else: if response.status_code == 401: error_msg = "Invalid API key" else: error_msg = f"OpenAI API returned {response.status_code}" status = ProviderHealthStatus( name=self._provider_name, available=available, latency_ms=round(latency_ms, 2), error=error_msg, last_check=last_check_iso, model=self._model, model_available=model_available, ) except Exception as e: latency_ms = (time.time() - start_time) * 1000 status = ProviderHealthStatus( name=self._provider_name, available=False, latency_ms=round(latency_ms, 2), error=str(e)[:100], last_check=last_check_iso, model=self._model, model_available=False, ) with self._health_cache_lock: self._health_cache["health_check"] = { "value": status, "timestamp": current_time, } return status def register_openai_provider(): """ Register the OpenAI provider in the global registry. This function should be called during module initialization to make the provider available through the registry. """ from .registry import registry provider = get_openai_provider() registry.register("openai", provider) return provider _provider_instance: Optional[OpenAITranslationProvider] = None _provider_lock = threading.Lock() def get_openai_provider() -> OpenAITranslationProvider: """Get or create the OpenAI provider instance (reads config from env).""" global _provider_instance if _provider_instance is None: with _provider_lock: if _provider_instance is None: from .config import ProvidersConfig _provider_instance = OpenAITranslationProvider( api_key=ProvidersConfig.OPENAI_API_KEY, model=ProvidersConfig.OPENAI_MODEL, timeout=ProvidersConfig.OPENAI_TIMEOUT, max_retries=ProvidersConfig.OPENAI_MAX_RETRIES, retry_delay=ProvidersConfig.OPENAI_RETRY_DELAY, base_url=ProvidersConfig.OPENAI_BASE_URL, health_check_timeout=ProvidersConfig.OPENAI_HEALTH_CHECK_TIMEOUT, ) return _provider_instance def reset_openai_provider() -> None: """Reset the OpenAI provider singleton (useful when config changes).""" global _provider_instance with _provider_lock: _provider_instance = None