""" 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__) _HAS_STRUCTLOG = True 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 - For technical terms, use the standard translation in the target language - If the text contains proper nouns or brand names, keep them unchanged unless there's a well-known translation""" def _build_system_prompt( source_lang: str, target_lang: str, custom_prompt: Optional[str] = None ) -> str: """Build system prompt for translation.""" if custom_prompt: return custom_prompt return DEFAULT_TRANSLATION_PROMPT.format( source_lang=source_lang, target_lang=target_lang ) def _get_language_name(code: str) -> str: """Convert language code to full name for better LLM understanding.""" language_names = { "en": "English", "fr": "French", "es": "Spanish", "de": "German", "it": "Italian", "pt": "Portuguese", "nl": "Dutch", "ru": "Russian", "zh": "Chinese", "ja": "Japanese", "ko": "Korean", "ar": "Arabic", "hi": "Hindi", "tr": "Turkish", "pl": "Polish", "vi": "Vietnamese", "th": "Thai", "id": "Indonesian", "ms": "Malay", "uk": "Ukrainian", "cs": "Czech", "sv": "Swedish", "da": "Danish", "fi": "Finnish", "no": "Norwegian", "el": "Greek", "he": "Hebrew", "ro": "Romanian", "hu": "Hungarian", "bg": "Bulgarian", "sk": "Slovak", "hr": "Croatian", "sl": "Slovenian", "lt": "Lithuanian", "lv": "Latvian", "et": "Estonian", } base_code = code.split("-")[0].lower() return language_names.get(base_code, 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 translate_batch( self, requests: List[TranslationRequest] ) -> List[TranslationResponse]: """ Translate multiple texts. Args: requests: List of TranslationRequest objects Returns: List of TranslationResponse objects """ if not requests: return [] 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