Files
VoiceStudio/backend/services/translation_engines.py
T
8bbab3fcc3 fix(translate): Dub LLM engine runs on the configured LLM provider (new dub_translation skill) (#944)
* fix(translate): the Dub LLM engine now runs on the configured LLM provider

Picking "LLM (OpenAI-compatible)" in the Dub tab read only the raw
TRANSLATE_* env vars — completely bypassing Settings → LLM Providers, so a
provider the user had configured AND tested in-app silently didn't power
the engine (empty key → raw 401 per segment). The Cinematic refiner was
already rewired through LLM Skills (#910/#912); this closes the gap for
direct LLM translation:

* new "dub_translation" LLM skill (Settings → LLM Skills) — per-skill
  provider override → global active provider, same resolution as every
  other skill; disabled == unconfigured, no new degradation modes
* the provider=openai branch resolves through resolve_skill_client();
  TRANSLATE_BASE_URL/TRANSLATE_API_KEY/TRANSLATE_MODEL stay working as
  the power-user override (env-only setups see zero behavior change,
  except the stale gpt-3.5-turbo default is now gpt-4o-mini, matching
  the cinematic path)
* per-segment calls are now bounded by the LLM timeout (45s default via
  OMNIVOICE_LLM_TIMEOUT) instead of the SDK's 600s default
* fully unconfigured → an up-front actionable 400 naming Settings → LLM
  Providers / LLM Skills instead of a per-segment 401
* provider-store keys are resolved into the error scrubber so a provider
  echoing the key can't leak it (parity with the env-key scrub)
* translation_engines registry: honest notes + a configured/configured_via
  stamp on LLM entries so the Engine dropdown can show ready-vs-needs-setup
  before the user clicks Translate

Tests: 4 new (skills-resolved client wins with its model+timeout; 400s
name the right settings page for no_provider vs disabled; env fallback
keeps working incl. TRANSLATE_MODEL); skills registry coverage updated;
existing openai-branch tests routed deterministically through the env
branch via the shared fake helper.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(changelog): add the dub-translation provider wiring under [Unreleased] (#944)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 19:22:12 +05:30

285 lines
11 KiB
Python

"""
Translation engine registry + UI-driven install/uninstall.
This is the single source of truth for which translation providers we know
about, what pip package they need, and whether that package is importable
right now. The Engine dropdown in the Dub tab reads list_engines() to
decide which options are ready-to-use vs. "needs install".
Why a registry rather than inline probes in dub_translate.py? The UI wants
to render the availability table BEFORE the user clicks Translate, so we
don't surface a cryptic ModuleNotFoundError for every segment. Having the
registry live next to the dub_translate dispatch also means adding a new
engine is one entry here + one branch in _build_translator.
"""
from __future__ import annotations
import asyncio
import importlib
import logging
import os
import shutil
import subprocess
import sys
logger = logging.getLogger("omnivoice.translation_engines")
# Engine ID → registry entry. Keyed by the `provider` string sent from the
# frontend (must match the values of `translateProvider` in the store).
REGISTRY: dict[str, dict] = {
"argos": {
"id": "argos",
"display_name": "Argos (Local, Fast)",
"pip_package": "argostranslate",
"probe_module": "argostranslate",
"category": "offline",
"needs_key": False,
"builtin": True,
"notes": "Pure-CPU offline translator. Downloads a ~50MB language pack on first use per pair.",
},
"nllb": {
"id": "nllb",
"display_name": "NLLB-200 (Local, Heavy)",
"pip_package": None, # uses HF transformers — already a core dep
"probe_module": "transformers",
"category": "offline",
"needs_key": False,
"builtin": True,
"notes": "Meta's 200-language NMT model. Large download (~2.4GB), best offline quality.",
},
"google": {
"id": "google",
"display_name": "Google Translate (Online, Free)",
"pip_package": "deep_translator",
"probe_module": "deep_translator",
"category": "online",
"needs_key": False,
"notes": "Free web endpoint via deep_translator. Rate-limited by Google; no API key required.",
},
"deepl": {
"id": "deepl",
"display_name": "DeepL (Online, Key)",
"pip_package": "deep_translator",
"probe_module": "deep_translator",
"category": "online",
"needs_key": True,
"notes": "High-quality EU MT. Free tier: 500K chars/month. Set DEEPL_API_KEY.",
},
"microsoft": {
"id": "microsoft",
"display_name": "Microsoft Translator (Online, Key)",
"pip_package": "deep_translator",
"probe_module": "deep_translator",
"category": "online",
"needs_key": True,
"notes": "Azure Cognitive Services. Free tier: 2M chars/month. Set MICROSOFT_API_KEY.",
},
"mymemory": {
"id": "mymemory",
"display_name": "MyMemory (Online, No Key)",
"pip_package": "deep_translator",
"probe_module": "deep_translator",
"category": "online",
"needs_key": False,
"notes": "Crowdsourced MT. Free, 5K chars/day anonymous; more with an email param.",
},
"openai": {
"id": "openai",
"display_name": "LLM (OpenAI-compatible)",
"pip_package": "openai",
"probe_module": "openai",
"category": "llm",
"needs_key": True,
"notes": (
"Uses the LLM provider you configure in Settings → LLM Providers "
"(route it via the 'Dub translation' skill in Settings → LLM Skills): "
"GPT (OpenAI), Claude (via OpenRouter), Gemini, DeepSeek, Qwen, "
"Ollama, LM Studio. Power-user env override: TRANSLATE_BASE_URL + "
"TRANSLATE_API_KEY + TRANSLATE_MODEL."
),
},
}
def is_frozen() -> bool:
"""True when running inside a packaged Tauri / PyInstaller bundle.
In that case the Python site-packages is read-only and signed, so we
refuse install/uninstall requests instead of corrupting the bundle.
"""
return bool(getattr(sys, "frozen", False) or os.environ.get("OMNIVOICE_FROZEN"))
def _probe(entry: dict) -> tuple[bool, str]:
mod = entry.get("probe_module")
if not mod:
return True, "no module required"
try:
importlib.import_module(mod)
return True, "ready"
except ImportError as e:
return False, f"import {mod!r} failed: {e}"
def install_command(engine: "str | dict | None") -> str | None:
"""The exact shell command that makes this engine importable, or None.
Single source of truth for the install string. BOTH the proactive Install
affordance in the Engine selector (via list_engines' ``install_command``
field) AND the translate-time 400 error (dub_translate.py) read from here,
so the command a user is told to run can never drift between the two
surfaces. Returns None when the engine needs no separate install — either
it's unknown or its dependency is a core dep already pinned in
``pyproject.toml`` (e.g. NLLB → transformers), in which case a
``uv pip install`` line would be misleading.
"""
entry = engine if isinstance(engine, dict) else REGISTRY.get(engine) if engine else None
pkg = entry.get("pip_package") if entry else None
return f"uv pip install {pkg}" if pkg else None
def _llm_configured() -> tuple[bool, "str | None"]:
"""Whether the LLM translation engine has something to call, and via what.
Resolution mirrors the translate-time path in dub_translate.py: the
"dub_translation" LLM skill (per-skill override → active provider from
Settings → LLM Providers) first, then the TRANSLATE_* env override. Lets
the Engine dropdown say "ready via <provider>" / "needs setup" up front
instead of a per-segment failure after the user clicks Translate.
"""
try:
from services import llm_skills
res = llm_skills.resolve_skill("dub_translation")
if res.ready and res.provider is not None:
return True, res.provider.display_name
except Exception: # noqa: BLE001 — a probe must never break list_engines()
logger.debug("dub_translation skill probe failed", exc_info=True)
if os.environ.get("TRANSLATE_BASE_URL") or os.environ.get("TRANSLATE_API_KEY"):
return True, "env"
return False, None
def list_engines() -> list[dict]:
"""Return a UI-ready list with per-engine availability stamped in."""
out = []
for e in REGISTRY.values():
installed, reason = _probe(e)
entry = {
**e,
"installed": installed,
"availability_reason": reason,
"install_command": install_command(e),
}
# LLM engines additionally need a provider/key — surface configured-ness
# so the UI can distinguish "importable" from "actually ready to call".
if e.get("category") == "llm":
configured, via = _llm_configured()
entry["configured"] = configured
entry["configured_via"] = via
out.append(entry)
return out
def get_engine(engine_id: str) -> dict | None:
return REGISTRY.get(engine_id)
def is_installed(engine_id: str) -> bool:
entry = REGISTRY.get(engine_id)
if not entry:
return False
ok, _ = _probe(entry)
return ok
def _in_virtualenv() -> bool:
"""True if the current interpreter is inside a venv/virtualenv."""
return getattr(sys, "base_prefix", sys.prefix) != sys.prefix or hasattr(sys, "real_prefix")
def _installer_cmd() -> list[str]:
"""Prefer `uv pip` (the dev install's default), fall back to `python -m pip`.
`python -m pip` ensures we target the same interpreter the server is
running under — avoids the classic "pip installed into the wrong venv"
footgun.
"""
if shutil.which("uv"):
return ["uv", "pip"]
return [sys.executable, "-m", "pip"]
async def run_pip(args: list[str], timeout: float = 600.0) -> tuple[int, str]:
"""Run a pip command async and return (rc, combined_output).
Combines stdout + stderr so the UI can surface a useful tail on failure
(pip's "ERROR: ..." lines go to stderr).
When using `uv pip` and running outside a venv (e.g. inside the Docker
image where Python runs as system), inject `--system` after the
install/uninstall subcommand. Without it, uv refuses to write to system
Python with: "No virtual environment found; run `uv venv` to create an
environment, or pass `--system`...". The `UV_SYSTEM_PYTHON` env var only
affects `uv venv`, not `uv pip install`.
"""
base = _installer_cmd()
using_uv = base[:1] == ["uv"]
# Pin `uv pip` to the interpreter the backend ACTUALLY runs under. The desktop
# spawns `<venv>/bin/python -m uvicorn` WITHOUT exporting VIRTUAL_ENV, so bare
# `uv pip install` finds no venv and 500s with "No virtual environment found"
# (#529/#527) — and the `--system` branch below never fires, because the
# running interpreter genuinely IS in a venv (uv just can't auto-discover it).
# `--python sys.executable` targets the same interpreter _probe()/is_installed()
# import from, and takes precedence when both flags are present, so the Docker
# `--system` path is unaffected.
if using_uv and args and args[0] in ("install", "uninstall") and "--python" not in args:
args = [args[0], "--python", sys.executable, *args[1:]]
if using_uv and not _in_virtualenv() and args and args[0] in ("install", "uninstall") and "--system" not in args:
args = [args[0], "--system", *args[1:]]
cmd = base + args
logger.info("pip: %s", " ".join(cmd))
try:
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
except NotImplementedError:
logger.debug("asyncio subprocess not supported, falling back to thread-based subprocess")
return await _run_pip_thread(cmd, timeout)
except FileNotFoundError as e:
return 1, f"installer not found: {e}"
try:
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout)
except asyncio.TimeoutError:
try:
proc.kill()
except ProcessLookupError:
pass
return 1, f"pip timed out after {timeout:.0f}s"
out = stdout.decode(errors="replace") if stdout else ""
return proc.returncode or 0, out
async def _run_pip_thread(cmd: list[str], timeout: float) -> tuple[int, str]:
"""Fallback: run pip in a thread via subprocess.Popen (Windows compat)."""
loop = asyncio.get_running_loop()
def _run():
proc = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
)
try:
stdout, _ = proc.communicate(timeout=timeout)
except subprocess.TimeoutExpired:
proc.kill()
stdout, _ = proc.communicate()
return 1, f"pip timed out after {timeout:.0f}s"
out = stdout.decode(errors="replace") if stdout else ""
return proc.returncode or 0, out
return await loop.run_in_executor(None, _run)