"""OpenRouter translation backend. Used only when an OpenRouter API key is available (user-supplied or OPENROUTER_API_KEY env var) — otherwise engine.translate falls back to the local CPU model.""" import re import time import requests BASE_URL = "https://openrouter.ai/api/v1" # Small hand-picked set of models known to work well for this kind of # instruction-following translation task. These are pinned to the top of the # UI dropdown (ahead of the full live-fetched catalog) and also used as # OpenRouter's server-side model fallback chain for a given request. CURATED_MODELS = [ "anthropic/claude-sonnet-4.5", "openai/gpt-4o", "openai/gpt-4o-mini", "google/gemini-2.5-flash", "google/gemini-2.5-pro", "deepseek/deepseek-chat", "qwen/qwen-2.5-72b-instruct", "meta-llama/llama-3.3-70b-instruct", ] DEFAULT_MODEL = CURATED_MODELS[0] _TIMEOUT = 120 _MODELS_TIMEOUT = 10 _BACKOFF_BASE = 2 # Kept small (rather than e.g. one batch per page) so the UI can show translated # segments as each batch finishes instead of the whole page appearing at once. _BATCH_SIZE = 5 _RETRYABLE_STATUS = {408, 429, 500, 502, 503, 504} def _is_retryable(exc) -> bool: msg = str(exc).lower() return any(x in msg for x in ("429", "rate limit", "timed out", "timeout", "connection")) def fetch_available_models(timeout: int = _MODELS_TIMEOUT) -> list[dict]: """Raw model list from OpenRouter's public /models endpoint (no key required).""" resp = requests.get(f"{BASE_URL}/models", timeout=timeout) resp.raise_for_status() return resp.json().get("data", []) def _is_text_model(model: dict) -> bool: arch = model.get("architecture") or {} modality = arch.get("modality") or "" output_modalities = arch.get("output_modalities") or [] return modality.endswith("->text") or "text" in output_modalities def list_model_choices(timeout: int = _MODELS_TIMEOUT) -> list[str]: """Curated models first (in preferred order), then the rest of OpenRouter's text-output catalog alphabetically. Falls back to just the curated list if the live fetch fails (offline, OpenRouter down, etc.).""" try: models = fetch_available_models(timeout=timeout) except Exception as e: print(f"[WARN] Could not fetch OpenRouter model list, using curated defaults only: {e}") return list(CURATED_MODELS) ids = {m["id"] for m in models if _is_text_model(m)} curated_present = [m for m in CURATED_MODELS if m in ids] rest = sorted(ids.difference(curated_present)) return curated_present + rest def _call_openrouter(api_key: str, model: str, prompt: str) -> str: headers = { "Authorization": f"Bearer {api_key}", "Content-Type": "application/json", } # OpenRouter tries "models" in order server-side if the primary errors out, # so we don't need to re-implement model-swapping client-side. fallback_models = [model] + [m for m in CURATED_MODELS if m != model] body = { "models": fallback_models, "messages": [{"role": "user", "content": prompt}], } last_exc = None for attempt in range(4): try: resp = requests.post(f"{BASE_URL}/chat/completions", headers=headers, json=body, timeout=_TIMEOUT) except requests.RequestException as e: last_exc = e print(f"[WARN] OpenRouter request attempt {attempt + 1}/4 failed: {e}") else: if resp.status_code == 200: data = resp.json() return data["choices"][0]["message"]["content"] last_exc = RuntimeError(f"OpenRouter HTTP {resp.status_code}: {resp.text[:300]}") if resp.status_code not in _RETRYABLE_STATUS: raise last_exc print(f"[WARN] OpenRouter attempt {attempt + 1}/4 failed: {last_exc}") if attempt < 3: time.sleep(_BACKOFF_BASE ** attempt) raise last_exc def _build_batch_prompt(template: str, texts: list[str], preceding: list[str] | None = None) -> str: numbered = "\n".join(f"{i + 1}. {text}" for i, text in enumerate(texts)) context_block = "" if preceding: context_block = ( "\n\nThe following lines were translated immediately before this batch. " "Do not begin any line in this batch with the same opening word as any of these:\n" + "\n".join(f"- {c}" for c in preceding) + "\n" ) return ( template.rstrip() + context_block + "\n\nTranslate each of the following numbered source lines into English, " "following all instructions above. Output ONLY a numbered list, one line per " 'input, format ". ". No other text.\n\n' "--- SOURCE LINES TO TRANSLATE ---\n" + numbered ) def _parse_batch_response(response_text: str, expected: int) -> list[str] | None: results = [] for line in response_text.strip().splitlines(): m = re.match(r"^\d+\.\s*(.*)", line.strip()) if m: results.append(m.group(1).strip()) return results if len(results) == expected else None def translate_one(text: str, api_key: str, model: str, template: str) -> str: prompt = ( template.rstrip() + f"\n\nTranslate the following into English, following all instructions " f"above. Output ONLY the translation, no other text.\n\n{text}" ) return _call_openrouter(api_key, model, prompt).strip() def translate_batch( texts: list[str], api_key: str, model: str, template: str, preceding: list[str] | None = None, ) -> list[str] | None: if not texts: return [] prompt = _build_batch_prompt(template, texts, preceding=preceding) try: content = _call_openrouter(api_key, model, prompt) return _parse_batch_response(content.strip(), len(texts)) except Exception as e: print(f"[WARN] OpenRouter batch translation failed: {e}") return None