All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m20s
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)
269 lines
11 KiB
Python
269 lines
11 KiB
Python
"""
|
|
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
|