""" Minimax Provider - Cloud LLM translation via the Minimax public API. Minimax exposes an OpenAI-compatible Chat Completions API at ``https://api.minimax.io/v1/chat/completions`` (default model ``MiniMax-M3``). Note: ``api.minimax.chat`` is NOT a reachable public host. """ import threading import time from datetime import datetime, timezone from typing import Any, Dict, List, Optional import requests from requests.exceptions import Timeout, ConnectionError as RequestsConnectionError from core.logging import get_logger from .base import TranslationProvider from .schemas import ProviderHealthStatus, TranslationRequest, TranslationResponse logger = get_logger(__name__) MINIMAX_RATE_LIMITED = "MINIMAX_RATE_LIMITED" MINIMAX_INVALID_KEY = "MINIMAX_INVALID_KEY" MINIMAX_TIMEOUT = "MINIMAX_TIMEOUT" MINIMAX_SERVICE_ERROR = "MINIMAX_SERVICE_ERROR" _RETRYABLE_ERRORS = {MINIMAX_RATE_LIMITED, MINIMAX_TIMEOUT, MINIMAX_SERVICE_ERROR} 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 _get_language_name(code: str) -> str: """Convert language code to full name (all supported languages).""" from core.languages import language_name return language_name(code) class MinimaxProviderError(Exception): 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) class MinimaxTranslationProvider(TranslationProvider): """ Minimax translation provider using OpenAI-compatible API. Default model: MiniMax-M3 (latest public OpenAI-compatible model). The public endpoint is https://api.minimax.io/v1 (NOT api.minimax.chat, which is not a reachable host on the public API). """ def __init__( self, api_key: str, model: str = "MiniMax-M3", timeout: int = 60, max_retries: int = 3, retry_delay: float = 1.0, base_url: str = "https://api.minimax.io/v1", group_id: str = "", ): if not api_key or not api_key.strip(): raise ValueError("Minimax API key cannot be empty") self._api_key = api_key self._model = model self._base_url = base_url.rstrip("/") self._group_id = group_id self._provider_name = "minimax" self._timeout = timeout self._max_retries = max_retries self._retry_delay = retry_delay 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: if not text or not text.strip(): return text, {} 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) if response.status_code == 401: raise MinimaxProviderError(MINIMAX_INVALID_KEY, "Cle API Minimax invalide.") if response.status_code == 429: raise MinimaxProviderError(MINIMAX_RATE_LIMITED, "Limite de requetes Minimax atteinte.") if response.status_code >= 500: raise MinimaxProviderError(MINIMAX_SERVICE_ERROR, "Service Minimax temporairement indisponible.") if response.status_code != 200: raise MinimaxProviderError(MINIMAX_SERVICE_ERROR, f"Erreur Minimax: {response.text[:200]}") data = response.json() choices = data.get("choices", []) if not choices: raise MinimaxProviderError(MINIMAX_SERVICE_ERROR, "Reponse Minimax vide") content = choices[0].get("message", {}).get("content", "") if not content: raise MinimaxProviderError(MINIMAX_SERVICE_ERROR, "Reponse Minimax vide") return content.strip(), data.get("usage", {}) except Timeout: raise MinimaxProviderError(MINIMAX_TIMEOUT, "Delai d'attente Minimax depasse.") except RequestsConnectionError: raise MinimaxProviderError(MINIMAX_SERVICE_ERROR, "Service Minimax indisponible.") except MinimaxProviderError: raise except Exception as e: raise MinimaxProviderError(MINIMAX_SERVICE_ERROR, f"Erreur Minimax: {str(e)[:100]}") def get_name(self) -> str: return self._provider_name def _probe_available(self) -> tuple[bool, int]: """Probe the Minimax API. Returns (available, status_code). Minimax does not document a public ``GET /models`` endpoint, so a 404/405 on that path does NOT mean the provider is down — it only means the path is absent. We treat any non-401 response as "available": the host is reachable and the API key was not rejected. Only 401 (and network errors) mark the provider unavailable. """ try: headers = {"Authorization": f"Bearer {self._api_key}"} response = requests.get(f"{self._base_url}/models", headers=headers, timeout=5) return response.status_code != 401, response.status_code except Exception: return False, 0 def is_available(self) -> bool: return self._probe_available()[0] def translate_text(self, request: TranslationRequest) -> TranslationResponse: 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 = request.metadata.get("custom_prompt") if request.metadata else None _base_prompt = DEFAULT_TRANSLATION_PROMPT.format( source_lang=source_lang_name, target_lang=target_lang_name ) # Base translation instructions always present; the custom prompt # (glossary/tone/context) is appended, never a replacement. if custom_prompt and custom_prompt.strip(): system_prompt = ( _base_prompt + "\n\nADDITIONAL CONTEXT AND INSTRUCTIONS:\n" + custom_prompt.strip() ) else: system_prompt = _base_prompt last_error = None for attempt in range(self._max_retries + 1): try: start_time = time.time() result, usage = self._make_api_request(text, system_prompt) latency = time.time() - start_time logger.info("minimax_translation_success", chars=len(text), source_lang=source_language, target_lang=target_language, model=self._model, latency_ms=round(latency * 1000, 2), retries=attempt) return TranslationResponse( translated_text=result, provider_name=self._provider_name, from_cache=False, source_language=source_language) except MinimaxProviderError as e: last_error = e if e.code not in _RETRYABLE_ERRORS or attempt >= self._max_retries: break delay = self._retry_delay * (2 ** attempt) time.sleep(delay) return TranslationResponse( translated_text=text, provider_name=self._provider_name, from_cache=False, error=last_error.message if last_error else "Unknown error", error_code=last_error.code if last_error else MINIMAX_SERVICE_ERROR) def translate_batch(self, requests: List[TranslationRequest]) -> List[TranslationResponse]: return [self.translate_text(req) for req in requests] def health_check(self) -> ProviderHealthStatus: start_time = time.time() available, status_code = self._probe_available() latency_ms = (time.time() - start_time) * 1000 if available: return ProviderHealthStatus( name=self._provider_name, available=True, latency_ms=round(latency_ms, 2), last_check=datetime.now(timezone.utc).isoformat(), model=self._model) return ProviderHealthStatus( name=self._provider_name, available=False, latency_ms=round(latency_ms, 2), error=f"probe failed (status={status_code})"[:100], last_check=datetime.now(timezone.utc).isoformat(), model=self._model) _provider_instance: Optional[MinimaxTranslationProvider] = None _provider_lock = threading.Lock() def get_minimax_provider() -> MinimaxTranslationProvider: global _provider_instance if _provider_instance is None: with _provider_lock: if _provider_instance is None: from .config import ProvidersConfig _provider_instance = MinimaxTranslationProvider( api_key=ProvidersConfig.MINIMAX_API_KEY, model=ProvidersConfig.MINIMAX_MODEL, timeout=ProvidersConfig.MINIMAX_TIMEOUT, max_retries=ProvidersConfig.MINIMAX_MAX_RETRIES, retry_delay=ProvidersConfig.MINIMAX_RETRY_DELAY, base_url=ProvidersConfig.MINIMAX_BASE_URL, group_id=ProvidersConfig.MINIMAX_GROUP_ID, ) return _provider_instance def register_minimax_provider(): from .registry import registry provider = get_minimax_provider() registry.register("minimax", provider) return provider def reset_minimax_provider(): global _provider_instance with _provider_lock: _provider_instance = None