* feat(routing): wire effective-device + routing_status into /engines (#21 PR 3/5) Surfaces the PR-1 probe + resolver through the engine registries so the matrix UI (PR 5) and the no-silent-fallback gates can consume it. - `engine_routing.routing_fields()`: shared helper returning the three serialization-ready keys, centralizing the scrub rule — routing_reason is scrubbed via `core.scrub.scrub_text` only when truthy, so a None reason stays JSON `null` (never coerced to ""). - TTS/ASR `list_backends()` each gain `effective_device` / `routing_status` / `routing_reason`, computed from a SINGLE `detect_host_caps()` call per request (host caps are constant per process). ASR is brought to full TTS parity: it now also carries `install_hint` / `last_error` / `isolation_mode` and a SCRUBBED `reason` (closing a pre-existing ASR token-leak gap) — an identical 11-key shape across families. ASR also gains the same is_available()-raises resilience TTS has (degrade to available:false, never 500). - LLM `list_backends()` reaches 11-key parity too but emits literal `effective_device:"network"` / `routing_status:"n/a"` / `routing_reason:null` (NOT via resolve_routing — LLM runs no local GPU model). `LLMBackend.gpu_compat = ()`. "network" is a label, not a probe — nothing here touches the network. - `select_engine` host-routing gate: refuses a pick whose `routing_status` is `unavailable` on this host (400 with an actionable detail), while ALLOWING `cpu_fallback` (it runs, just slower). LLM is never gated. Defensive `.get` so legacy payloads still select. New typed `SelectEngineResponse`. Tests: 11-key shape across all 3 families, well-formed tts/asr routing keys (+ None-not-"" contract), LLM network/n/a labels, select gate (block unavailable / allow cpu_fallback / never-gate LLM). Updated the registry exact-shape test for the 3 new keys. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(cjk): allowlist docs/specs/ in the hardcoded-CJK guard PR #429 merged the longform design specs, which legitimately quote functional CJK (test-fixture descriptions, CosyVoice speaker IDs, multilingual sample text). The CJK guard scans every tracked file, so those docs turned main red. Specs are documentation, not shipped UI strings — allowlist the docs/specs/ prefix, matching the individually-allowlisted docs already in the set. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
243 lines
8.3 KiB
Python
243 lines
8.3 KiB
Python
"""
|
|
LLM adapter interface — Phase 3.4 (ROADMAP.md).
|
|
|
|
The translator (Phase 1.1) already speaks the OpenAI chat-completions shape.
|
|
This module formalises that surface into an `LLMBackend` protocol so other
|
|
call sites (glossary auto-extract, directorial AI in Phase 4, reflection
|
|
passes) can depend on the interface instead of duplicating the client
|
|
construction logic.
|
|
|
|
Today we ship:
|
|
|
|
• OpenAICompatBackend — wraps the `openai` package pointing at whatever
|
|
TRANSLATE_BASE_URL + TRANSLATE_API_KEY say. Works with real OpenAI,
|
|
Ollama (`base_url=http://localhost:11434/v1`), LM Studio, Together,
|
|
Anyscale, Claude-via-OpenAI-compat proxies.
|
|
• OffBackend — explicit no-op. Gets returned when no LLM is configured
|
|
so callers fail fast with a clear message instead of a KeyError.
|
|
|
|
Selection: auto — if env is configured, return OpenAICompatBackend; else
|
|
OffBackend. Callers can override with `OMNIVOICE_LLM_BACKEND`.
|
|
|
|
NOTE: cloud providers stay **opt-in** per the ROADMAP's privacy policy.
|
|
Even with `TRANSLATE_API_KEY` set, the flag only turns on this backend;
|
|
individual features (Cinematic translate, glossary auto-extract) still
|
|
check their own `quality="cinematic"` gate / user action before calling.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
from abc import ABC, abstractmethod
|
|
from typing import Optional
|
|
|
|
logger = logging.getLogger("omnivoice.llm")
|
|
|
|
|
|
class LLMBackend(ABC):
|
|
id: str = "base"
|
|
display_name: str = "Base LLM"
|
|
# LLM backends call out over the network (OpenAI/Ollama/LM Studio) or are a
|
|
# no-op — none run a model on the user's GPU. So `gpu_compat` is empty and
|
|
# list_backends() labels the family `effective_device:"network"` /
|
|
# routing_status:"n/a" rather than asserting a false GPU claim. Routing is
|
|
# never gated for LLM (see engines.select_engine + diagnose).
|
|
gpu_compat: tuple[str, ...] = ()
|
|
|
|
@classmethod
|
|
@abstractmethod
|
|
def is_available(cls) -> tuple[bool, str]:
|
|
...
|
|
|
|
@property
|
|
@abstractmethod
|
|
def model_name(self) -> str: ...
|
|
|
|
@abstractmethod
|
|
def chat(self, *, system: str, user: str, timeout: Optional[float] = None) -> str:
|
|
"""One-shot chat completion. Returns the assistant content string.
|
|
Raises on failure — callers decide whether to fallback gracefully.
|
|
"""
|
|
|
|
|
|
# ── OpenAI-compatible (the only backend that actually calls out today) ─────
|
|
|
|
|
|
class OpenAICompatBackend(LLMBackend):
|
|
id = "openai-compat"
|
|
display_name = "OpenAI-compatible (real OpenAI, Ollama, LM Studio, …)"
|
|
|
|
def __init__(self):
|
|
self._client = None
|
|
|
|
@classmethod
|
|
def is_available(cls) -> tuple[bool, str]:
|
|
try:
|
|
import openai # noqa: F401
|
|
except ImportError:
|
|
return False, "openai package missing (install with `pip install openai`)."
|
|
base_url = os.environ.get("TRANSLATE_BASE_URL")
|
|
api_key = (
|
|
os.environ.get("TRANSLATE_API_KEY")
|
|
or os.environ.get("OPENAI_API_KEY")
|
|
or ("local" if base_url else None)
|
|
)
|
|
if not api_key:
|
|
return False, (
|
|
"No LLM configured. Set TRANSLATE_BASE_URL (+ TRANSLATE_API_KEY) to "
|
|
"point at OpenAI, Ollama (http://localhost:11434/v1), or any compatible host."
|
|
)
|
|
return True, "ready"
|
|
|
|
@property
|
|
def model_name(self) -> str:
|
|
return os.environ.get("TRANSLATE_MODEL", "gpt-4o-mini")
|
|
|
|
def _get_client(self):
|
|
if self._client is not None:
|
|
return self._client
|
|
from openai import OpenAI
|
|
base_url = os.environ.get("TRANSLATE_BASE_URL")
|
|
api_key = (
|
|
os.environ.get("TRANSLATE_API_KEY")
|
|
or os.environ.get("OPENAI_API_KEY")
|
|
or ("local" if base_url else None)
|
|
)
|
|
if not api_key:
|
|
raise RuntimeError("LLM not configured. See `is_available()` for the hint.")
|
|
kw = {"api_key": api_key}
|
|
if base_url:
|
|
kw["base_url"] = base_url
|
|
self._client = OpenAI(**kw)
|
|
return self._client
|
|
|
|
def chat(self, *, system: str, user: str, timeout: Optional[float] = None) -> str:
|
|
return self.chat_messages(
|
|
messages=[
|
|
{"role": "system", "content": system},
|
|
{"role": "user", "content": user},
|
|
],
|
|
timeout=timeout,
|
|
)
|
|
|
|
def chat_messages(self, *, messages: list[dict], timeout: Optional[float] = None) -> str:
|
|
"""One-shot completion over a full message list.
|
|
|
|
Additive surface for callers that need structured few-shot turns
|
|
(dictation refinement, Wave 2.1) — small local models pattern-match
|
|
and echo inline examples, so examples must arrive as prior chat
|
|
turns, not inside the system prompt.
|
|
"""
|
|
if timeout is None:
|
|
try:
|
|
timeout = float(os.environ.get("OMNIVOICE_LLM_TIMEOUT", "45"))
|
|
except ValueError:
|
|
timeout = 45.0
|
|
res = self._get_client().chat.completions.create(
|
|
model=self.model_name,
|
|
timeout=timeout,
|
|
messages=messages,
|
|
)
|
|
return (res.choices[0].message.content or "").strip()
|
|
|
|
|
|
# ── Off — explicit no-LLM path ────────────────────────────────────────────
|
|
|
|
|
|
class OffBackend(LLMBackend):
|
|
id = "off"
|
|
display_name = "Off (no LLM)"
|
|
|
|
@classmethod
|
|
def is_available(cls) -> tuple[bool, str]:
|
|
return True, "ready"
|
|
|
|
@property
|
|
def model_name(self) -> str:
|
|
return "none"
|
|
|
|
def chat(self, **kw) -> str:
|
|
raise RuntimeError(
|
|
"No LLM backend configured. Set TRANSLATE_BASE_URL (+ TRANSLATE_API_KEY) "
|
|
"to use features that need one (Cinematic translate, glossary auto-extract)."
|
|
)
|
|
|
|
def chat_messages(self, **kw) -> str:
|
|
return self.chat(**kw)
|
|
|
|
|
|
_REGISTRY: dict[str, type[LLMBackend]] = {
|
|
"openai-compat": OpenAICompatBackend,
|
|
"off": OffBackend,
|
|
}
|
|
|
|
|
|
# Most-recent failure per backend (parity with tts/asr list_backends).
|
|
_LAST_ERRORS: dict[str, str] = {}
|
|
|
|
_INSTALL_HINTS: dict[str, str] = {
|
|
"openai-compat": "Set TRANSLATE_BASE_URL (+ TRANSLATE_API_KEY) — OpenAI, "
|
|
"Ollama (http://localhost:11434/v1), or any compatible host.",
|
|
}
|
|
|
|
|
|
def list_backends() -> list[dict]:
|
|
"""Same 11-key shape as tts/asr so the matrix renders families uniformly.
|
|
|
|
LLM is NOT a GPU family: every entry carries literal
|
|
``effective_device:"network"`` / ``routing_status:"n/a"`` /
|
|
``routing_reason:null`` (NOT via resolve_routing — that would be a false
|
|
GPU claim). ``effective_device:"network"`` is a label, not a probe: nothing
|
|
here touches the network (local-first).
|
|
"""
|
|
from core.scrub import scrub_text
|
|
|
|
out: list[dict] = []
|
|
for bid, cls in _REGISTRY.items():
|
|
try:
|
|
ok, msg = cls.is_available()
|
|
except Exception as exc:
|
|
ok = False
|
|
msg = f"{type(exc).__name__}: {exc}"
|
|
logger.warning(
|
|
"llm list_backends: %s.is_available() raised — degrading "
|
|
"gracefully so the picker still renders: %s", bid, msg,
|
|
)
|
|
if ok:
|
|
_LAST_ERRORS.pop(bid, None)
|
|
else:
|
|
_LAST_ERRORS[bid] = scrub_text(msg)
|
|
out.append({
|
|
"id": bid,
|
|
"display_name": cls.display_name,
|
|
"available": ok,
|
|
"reason": None if ok else scrub_text(msg),
|
|
"install_hint": _INSTALL_HINTS.get(bid),
|
|
"last_error": _LAST_ERRORS.get(bid),
|
|
"isolation_mode": "in-process",
|
|
"gpu_compat": list(getattr(cls, "gpu_compat", ())),
|
|
"effective_device": "network",
|
|
"routing_status": "n/a",
|
|
"routing_reason": None,
|
|
})
|
|
return out
|
|
|
|
|
|
def active_backend_id() -> str:
|
|
explicit = os.environ.get("OMNIVOICE_LLM_BACKEND")
|
|
if explicit:
|
|
return explicit
|
|
from core import prefs
|
|
picked = prefs.get("llm_backend")
|
|
if picked:
|
|
return picked
|
|
ok, _ = OpenAICompatBackend.is_available()
|
|
return "openai-compat" if ok else "off"
|
|
|
|
|
|
def get_active_llm_backend() -> LLMBackend:
|
|
bid = active_backend_id()
|
|
if bid not in _REGISTRY:
|
|
raise ValueError(f"Unknown LLM backend: {bid!r}. Known: {list(_REGISTRY)}")
|
|
return _REGISTRY[bid]()
|