Files
VoiceStudio/backend/services/engine_routing.py
T
Palash DebnathandClaude Opus 4.8 2a1c3eee3d feat(routing): synth-time no-silent-fallback gating at all TTS entry points (#21 follow-up) (#440)
Closes the last #21 gap: a per-request engine=/model= override bypasses the
/engines/select host-gate, so an engine that can't use this host's GPU could
still be triggered at synth time and silently fall back to CPU (or die mid-
synth). Now enforced at every TTS synth entry point, reusing the SAME probe +
resolver — never re-deriving routing.

Shared helpers (services/engine_routing.py):
- `routing_notice(result)` → (status, reason) to surface, or None. Fires for
  cpu_fallback (always) and accelerated-with-caveat (driver/arch); silent for
  cpu_only / clean-accelerated / n/a.
- `header_safe_reason(reason)` → scrubbed + ASCII-sanitized (headers are
  latin-1; a non-ASCII device name would 500 otherwise) + ≤256 chars. No regex.

Entry points:
- REST `POST /generate` (generation.py): after engine resolution, resolve
  routing once; `unavailable` → 400; cpu_fallback / accelerated-caveat → 200 +
  `X-OmniVoice-Routing` + `X-OmniVoice-Routing-Reason` headers on the WAV
  StreamingResponse; benign → no headers. Covers OmniVoice + adapter branches.
- OpenAI-compat `POST /v1/audio/speech` (openai_compat.py): same gate + same
  headers; the tts-1/tts-1-hd alias inherits the active engine's routing.
- WebSocket `/ws/tts` (tts_stream.py): no headers → frames. `unavailable` →
  `{"type":"error",...}` + skip stream; cpu_fallback / caveat → one
  `{"type":"routing","status","reason"}` frame before any audio.
- `select_engine` response now echoes routing_status / effective_device /
  routing_reason (PR #432 added the gate; this adds the fields so the UI can
  warn on a cpu_fallback pick). New fields on SelectEngineResponse.

Frontend: `useTTS` reads the X-OmniVoice-Routing header and shows a one-time,
non-blocking toast (in-memory de-dup by status — a 50-clip batch fires once,
no localStorage). i18n keys `tts.routingFallback`/`tts.routingCaveat`.

Tests: routing_notice + header_safe_reason (ASCII/length/scrub) unit tests;
REST synth gate (unavailable→400, cpu_fallback→headers, cpu_only→none) via the
fake-engine harness with a mocked host; select response routing fields.

Deferred (small follow-up): dub-pipeline ASR routing note on the preflight_error
SSE channel — separate path, not a TTS synth entry point. No frontend /ws/tts
client exists today (the routing frame serves external API consumers).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 11:18:37 +05:30

157 lines
6.2 KiB
Python

"""Pure, host-aware routing resolver — maps an engine's declared ``gpu_compat``
against the cached host capabilities to "where will this engine *actually* run
on this machine, and is that a problem the user should hear about?"
No model load, no probe (the caller passes the cached ``HostCaps``), no I/O.
Deterministic and byte-identical for a given ``(gpu_compat, HostCaps)`` across
macOS/Windows/Linux — that cross-OS determinism is the whole point of the
no-silent-fallback contract.
Reason strings are author-controlled English (interpolating only family/device
names) but are **still** scrubbed by the caller (``core.scrub.scrub_text``)
before serialization, because an interpolated ``device_name`` or probe note can
carry a home path.
"""
from __future__ import annotations
from typing import Literal, TypedDict
from core.device_caps import (
DIRECTML_MARKER,
KERNEL_RISK_MARKER,
HostCaps,
)
RoutingStatus = Literal["accelerated", "cpu_fallback", "cpu_only", "unavailable", "n/a"]
class RoutingResult(TypedDict):
effective_device: str # a DeviceFamily value or "cpu"
routing_status: RoutingStatus # resolve_routing never emits "n/a" (LLM-only)
routing_reason: str | None # raw, pre-scrub
def _caveat(caps: HostCaps) -> str | None:
"""A kernel-risk caveat string for an otherwise-accelerated host, or None.
Advisory notes (multi-GPU, VRAM-query-failed, DirectML) never qualify."""
for note in caps.notes:
if KERNEL_RISK_MARKER in note:
return f"{caps.family.upper()} selected, but: {note}"
return None
def resolve_routing(gpu_compat: tuple[str, ...], caps: HostCaps) -> RoutingResult:
"""Resolve the effective device + status for an engine on this host.
Rules are evaluated in order; the first match wins (see spec §2)."""
targets = tuple(gpu_compat or ())
fam = caps.family
# 1. Empty compat — reserved for LLM (which never calls this). Defensive.
if not targets:
return {
"effective_device": "cpu",
"routing_status": "cpu_only",
"routing_reason": "engine declares no compute targets",
}
# 2. Host accelerator is one the engine supports → accelerated.
if fam != "cpu" and fam in targets:
return {
"effective_device": fam,
"routing_status": "accelerated",
"routing_reason": _caveat(caps),
}
# 3. Host has an accelerator the engine lacks, but engine supports cpu
# → the no-silent-fallback signal.
if fam != "cpu" and "cpu" in targets:
if fam == "rocm" and "cuda" in targets and "rocm" not in targets:
reason = "declares CUDA only; ROCm not in its compat set"
else:
reason = f"engine has no {fam.upper()} path; running on CPU"
return {
"effective_device": "cpu",
"routing_status": "cpu_fallback",
"routing_reason": reason,
}
# 4. Genuine CPU-only host (or DirectML, which the probe reports as cpu)
# and engine supports cpu → benign; must not warn or block.
if fam == "cpu" and "cpu" in targets:
reason = None
for note in caps.notes:
if DIRECTML_MARKER in note:
reason = (
"DirectML GPU present; engine routes via torch CPU path "
"(DirectML acceleration not wired into routing)"
)
break
return {
"effective_device": "cpu",
"routing_status": "cpu_only",
"routing_reason": reason,
}
# 5. Engine needs an accelerator this host lacks and has no cpu path.
first = targets[0]
return {
"effective_device": first,
"routing_status": "unavailable",
"routing_reason": f"requires {', '.join(targets)}; this host has {fam}",
}
def routing_notice(result: RoutingResult) -> tuple[str, str | None] | None:
"""`(status, reason)` when a synth-time notice SHOULD be surfaced to the
user, else `None`. Surfaced for `cpu_fallback` (always) and for
`accelerated` ONLY when it carries a driver/arch caveat reason — everything
else (`cpu_only`, clean `accelerated`, `n/a`) is benign and stays silent."""
st = result["routing_status"]
if st == "cpu_fallback" or (st == "accelerated" and result["routing_reason"]):
return (st, result["routing_reason"])
return None
def header_safe_reason(reason: str | None) -> str | None:
"""A routing reason made safe for an HTTP header value: scrubbed, then
ASCII-sanitized (headers are latin-1; a non-ASCII device name would 500 the
response otherwise), **control characters stripped** (a CR/LF could split
the header / inject a new one), and length-capped at 256. Returns None for
an empty reason. No regex — `.encode`/membership only (CodeQL-clean)."""
if not reason:
return None
from core.scrub import scrub_text
ascii_only = scrub_text(reason).encode("ascii", "ignore").decode("ascii")
# Drop ASCII control chars (0x00-0x1F + DEL 0x7F) — incl. CR/LF, so the
# value can never break out of its header line.
cleaned = "".join(c for c in ascii_only if 0x20 <= ord(c) < 0x7F)
return cleaned[:256] or None
def routing_fields(gpu_compat: tuple[str, ...], caps: HostCaps) -> dict:
"""The three serialization-ready routing keys for a ``list_backends`` entry.
Resolves routing and applies the redaction contract: ``routing_reason`` is
scrubbed via ``core.scrub.scrub_text`` only when truthy, so a ``None`` reason
serializes as JSON ``null`` (NOT ``""`` — ``scrub_text(None)`` would coerce
to ``""``). Used by tts/asr ``list_backends`` so the scrub rule lives in one
place. (LLM emits its own literal ``network``/``n/a``/``null`` fields and
does NOT call this.)
"""
from core.scrub import scrub_text
r = resolve_routing(tuple(gpu_compat or ()), caps)
reason = r["routing_reason"]
return {
"effective_device": r["effective_device"],
"routing_status": r["routing_status"],
"routing_reason": scrub_text(reason) if reason else None,
}
__all__ = [
"RoutingStatus", "RoutingResult", "resolve_routing", "routing_fields",
"routing_notice", "header_safe_reason",
]