Phase 2 Plan 02-04: Engine Compatibility Matrix API + UI (#99)
* Phase 2 Plan 02-04: GET /engines/{id}/health + gpu_compat + HF mask
ENGINE-06 backend half. Adds the data + spawn-on-demand endpoint the new
Engine Compatibility Matrix UI will consume:
* `gpu_compat: tuple[str, ...]` class attribute on `TTSBackend`, overridden
per backend with reasonable defaults (cuda+mps+cpu for OmniVoice/VoxCPM2;
cpu-only for KittenTTS; mps+cpu for MLX-Audio; etc.). `list_backends()`
serializes it as a list.
* `_HF_TOKEN_MASK_RE` (`hf_[A-Za-z0-9]{30,}`) scrubs the `reason` and
`last_error` fields before they leave the registry — Phase 1's
HFTokenRedactor logging filter does not run on FastAPI response bodies,
so this closes T-02-12.
* `GET /engines/{engine_id}/health` — loopback-gated route that resolves
the backend across tts/asr/llm registries, then either calls
`SubprocessBackend.health_check()` (spawn-and-ping) for subprocess
engines or falls back to `is_available()` for in-process engines.
Returns `{ id, ok, message, latency_ms }`. Engine instances are cached
per-class so repeated checks don't leak atexit hooks or spawn extra
sidecars. The masked-redactor is reapplied on the way out.
Test coverage (tests/backend/api/test_engines_route_shape.py, 11 tests):
* Response shape includes the new fields for every TTS entry
* IndexTTS2 isolation_mode == "subprocess", OmniVoice == "in-process"
* Health route round-trips with mocked SubprocessBackend success
* Health route falls back to is_available for in-process backends
* Unknown engine id → 404
* Non-loopback origin → 403
* Engine instance cache reuses the singleton across calls
* HF tokens leaked into is_available() / health_check() are masked
in both the /engines and /engines/{id}/health response bodies
Existing tts_backend_registry shape test updated to include `gpu_compat`.
Full suite: 402 passed, 0 failures (up from 391+ baseline).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Phase 2 Plan 02-04: EngineCompatibilityMatrix UI + Settings wiring
ENGINE-06 frontend half. Mounts a new component on Settings → Engines
that surfaces, end-to-end, the data shape Plan 02-01 + Plan 02-03 added
to the backend registry:
* `frontend/src/components/EngineCompatibilityMatrix.jsx` (270 lines) —
semantic <table> with role=row/cell so RTL queries work; one row per
registered backend. Columns:
- Engine name + install hint + Last error line
- Install state badge (Available / Unavailable + inline reason)
- GPU compat chips (CUDA / MPS / ROCm / CPU with colored variants)
- Isolation mode badge (subprocess for IndexTTS, in-process for the
rest — makes the Phase 2 architectural shift legible to users)
- "Test engine" button → `/engines/{id}/health` round-trip; renders
latency in ms inline next to the button; disabled while inflight;
5 s cooldown to prevent click-storms.
Mount does NOT auto-test any engine — per the plan's Open Question #2,
spawning sidecars is gated on user action.
* `frontend/src/components/EngineCompatibilityMatrix.css` — minimal
styling that reuses chrome tokens; chip colors per GPU target.
* `frontend/src/api/engines.ts` — `getEngineHealth(id)` client function
wraps the new backend route through the shared apiJson helper.
* `frontend/src/api/types.ts` — extends EngineBackend with optional
`isolation_mode`, `last_error`, `install_hint`, `gpu_compat` so the
TypeScript surface tracks the backend wire shape, and adds
EngineHealthResponse.
* `frontend/src/pages/Settings.jsx` — replaces the hand-rolled Engines
table inside EnginesTab with `<EngineCompatibilityMatrix family="tts"
onSelect={...} />`. selectEngine still wires up the picker; the
matrix's onSelect prop renders the Use button per row when provided.
Removes the now-unused FAMILY_META local map.
Test coverage (`frontend/src/test/EngineCompatibilityMatrix.test.jsx`,
8 tests via vitest):
* Renders one row per backend with documented columns
* isolation_mode badge: subprocess for IndexTTS2, in-process for
OmniVoice / KittenTTS
* GPU compat chips: omnivoice → cuda/mps/cpu; kittentts → cpu only
* Unavailable rows render the failure reason inline
* last_error line renders below status when populated; masked HF
token sentinel survives verbatim
* Test engine click fires getEngineHealth(id) and renders latency_ms
* Test button disabled while inflight; second click is a no-op
* Failure path (ok=false) renders a failure marker
Frontend suite: 65 passed (8 new). Lint: 0 new errors. typecheck:ci: clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Phase 2 Plan 02-04: SUMMARY
Recap of Engine Compatibility Matrix delivery — backend route +
gpu_compat metadata + HF-token redaction, frontend EngineCompatibility-
Matrix component, full test counts, deviations, gpu_compat confidence
matrix, frontend test-runner command notes for Phase 6 CI.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
c3695e1668
commit
84fffa5409
+152
@@ -0,0 +1,152 @@
|
||||
# Plan 02-04 — Engine Compatibility Matrix API + UI (SUMMARY)
|
||||
|
||||
**Status:** delivered. Branch `phase-2-plan-02-04-engine-compat-matrix`; PR
|
||||
pending. Closes ENGINE-06.
|
||||
|
||||
## What landed
|
||||
|
||||
### Backend
|
||||
- `backend/services/tts_backend.py`
|
||||
- `gpu_compat: tuple[str, ...]` class attribute on `TTSBackend` (default
|
||||
`("cpu",)`) and per-backend overrides (see table below).
|
||||
- `_HF_TOKEN_MASK_RE` + `_mask_hf_tokens()` — runtime regex redaction
|
||||
applied to `reason` and `last_error` fields inside `list_backends()`
|
||||
before serialization. Closes **T-02-12** without depending on the
|
||||
logging-level `HFTokenRedactor`.
|
||||
- `list_backends()` now emits `gpu_compat: list[str]` on every entry
|
||||
and runs the new redactor.
|
||||
- `backend/api/routers/engines.py`
|
||||
- **New** `GET /engines/{engine_id}/health` — loopback-gated (T-02-13).
|
||||
Resolves the backend across tts/asr/llm registries; calls
|
||||
`SubprocessBackend.health_check()` (spawn-and-ping) for subprocess
|
||||
engines or falls back to `is_available()` for in-process ones.
|
||||
Returns `{ id, ok, message, latency_ms }`. Never 500s on a sick
|
||||
engine — exceptions land in the response body. Unknown id → 404.
|
||||
- `_ENGINE_INSTANCES: dict[type, object]` cache + `_get_engine_instance`
|
||||
helper so repeated health checks reuse one singleton per class.
|
||||
Avoids leaking atexit hooks / spawning extra sidecars.
|
||||
|
||||
### Frontend
|
||||
- `frontend/src/components/EngineCompatibilityMatrix.jsx` (~270 lines).
|
||||
Semantic `<table>` with role-attributes for RTL. Columns: Engine /
|
||||
Install state / GPU compat / Isolation / Actions. "Test engine"
|
||||
button does NOT auto-spawn on mount; 5 s cooldown prevents
|
||||
click-storms. Optional `onSelect` prop turns the matrix into a
|
||||
picker so Settings doesn't need a parallel table.
|
||||
- `frontend/src/components/EngineCompatibilityMatrix.css` — minimal
|
||||
styling reusing existing chrome tokens; per-GPU chip colors.
|
||||
- `frontend/src/api/engines.ts` — `getEngineHealth(id)` client.
|
||||
- `frontend/src/api/types.ts` — `EngineBackend` extended with the four
|
||||
new optional fields; new `EngineHealthResponse` type.
|
||||
- `frontend/src/pages/Settings.jsx` — replaces the hand-rolled engines
|
||||
table in `EnginesTab` with `<EngineCompatibilityMatrix family="tts"
|
||||
onSelect={selectEngine wrapper} />`. Drops the now-dead `FAMILY_META`
|
||||
local map and unused `listEngines` / `Mic` / `MessageSquare` imports.
|
||||
**No new Settings tab added** — an "Engines" tab already existed
|
||||
(Phase 3 / 4.6), so the matrix is mounted into the existing tab
|
||||
rather than duplicating navigation.
|
||||
|
||||
### Tests
|
||||
- `tests/backend/api/test_engines_route_shape.py` (new, 11 tests):
|
||||
1. `/engines` response includes the new fields on every TTS entry
|
||||
2. IndexTTS2 isolation_mode == "subprocess"
|
||||
3. OmniVoice isolation_mode == "in-process"
|
||||
4. OmniVoice gpu_compat == {"cuda", "mps", "cpu"}
|
||||
5. Health subprocess path (mocked) returns ok/pong/latency_ms
|
||||
6. Health in-process path falls back to is_available
|
||||
7. Unknown engine id → 404
|
||||
8. Non-loopback origin → 403 (T-02-13)
|
||||
9. Engine instance cache reuses singleton across 2 calls
|
||||
10. HF tokens in `is_available()` errors don't leak into `/engines`
|
||||
11. HF tokens in `health_check()` errors don't leak into
|
||||
`/engines/{id}/health`
|
||||
- `tests/backend/services/test_tts_backend_registry.py::test_list_backends_shape`
|
||||
— updated `required` keyset to include `gpu_compat`.
|
||||
- `frontend/src/test/EngineCompatibilityMatrix.test.jsx` (new, 8 tests):
|
||||
1. Renders one row per backend with documented columns
|
||||
2. isolation_mode badge visible per row
|
||||
3. GPU compat chips render the expected per-engine subset
|
||||
4. Unavailable rows render the failure reason inline
|
||||
5. last_error line renders below status; masked sentinel preserved
|
||||
6. Test click fires getEngineHealth(id) + renders latency_ms
|
||||
7. Test button disabled while inflight; 2nd click no-op
|
||||
8. Failure path (ok=false) renders a failure marker
|
||||
|
||||
## Test results
|
||||
- **Backend (`uv run pytest tests/ -q --ignore=tests/manual`):**
|
||||
**402 passed, 10 skipped, 13 xfailed, 1 xpassed** (was 391+ pre-plan).
|
||||
- **Frontend (`bun run test`, runs `vitest run`):** **65 passed** (8 new).
|
||||
- **Frontend lint:** No new errors introduced. Settings.jsx pre-existing
|
||||
lint count went from 5 → 4 (removed an unused-vars warning).
|
||||
- **Frontend typecheck (`bun run typecheck:ci`):** clean.
|
||||
|
||||
## gpu_compat defaults — per-engine assignments
|
||||
|
||||
| Engine | gpu_compat | Confidence |
|
||||
|---------------|-------------------------|------------|
|
||||
| OmniVoice | cuda, mps, cpu | HIGH |
|
||||
| VoxCPM2 | cuda, mps, cpu | HIGH |
|
||||
| MOSS-TTS-Nano | cuda, cpu | HIGH |
|
||||
| KittenTTS | cpu | HIGH (ONNX CPU graph) |
|
||||
| MLX-Audio | mps, cpu | HIGH |
|
||||
| **CosyVoice** | cuda, cpu | **MEDIUM** — MPS support not verified upstream; flagged for Phase 6 confirmation |
|
||||
| IndexTTS2 | cuda, mps, cpu | HIGH (subprocess uses sidecar's GPU) |
|
||||
| GPT-SoVITS | cuda, cpu | MEDIUM (whatever the external API server uses) |
|
||||
| Sherpa-ONNX | cuda, cpu | MEDIUM (CUDA provider available on Linux/Windows) |
|
||||
|
||||
**Recommend Phase 6** verify CosyVoice MPS / Sherpa-ONNX CUDA matrix
|
||||
when the release notes pass; these are stack-research-based, not
|
||||
empirically verified on hardware.
|
||||
|
||||
## Frontend test runner command (for Phase 6 CI matrix)
|
||||
|
||||
```bash
|
||||
cd frontend && bun run test # full suite via vitest
|
||||
cd frontend && bun run test:watch # vitest --watch
|
||||
```
|
||||
|
||||
Note: **`bun test`** runs Bun's built-in test runner, which does **not**
|
||||
set up jsdom. The component tests require `bun run test` (which invokes
|
||||
`vitest run` per package.json scripts). Document this in the README + CI
|
||||
config so contributors don't hit "ReferenceError: document is not defined".
|
||||
|
||||
## HF-token redaction proof
|
||||
The new `test_no_hf_token_leak_in_engines_response` and
|
||||
`test_no_hf_token_leak_in_health_response` tests register a synthetic
|
||||
backend whose `is_available()` / `health_check()` embed a real
|
||||
`hf_abcdefghijklmnopqrstuvwxyz01234567890abcd` string in the error
|
||||
message, then assert the regex `hf_[A-Za-z0-9]{30,}` matches **zero
|
||||
substrings** anywhere in the response body — and that the
|
||||
`hf_***REDACTED***` sentinel appears in its place. T-02-12 mitigated.
|
||||
|
||||
## Scope notes
|
||||
- **INST-13** (dictation-widget Settings checkbox) is **NOT** included
|
||||
in this plan, per the locked Phase 2 8-ID scope (ENGINE-01..07 +
|
||||
BUG-01). The matrix mounts inside the existing `EnginesTab` of
|
||||
`Settings.jsx`; if a future plan adds INST-13 it will mount in the
|
||||
same tab structure established here.
|
||||
- **ASR/LLM family extension:** `list_backends()` in `backend/services/
|
||||
asr_backend.py` and `backend/services/llm_backend.py` was NOT
|
||||
extended with the new fields. Reasoning: the plan's
|
||||
`must_haves.truths` and `success_criteria` focus on the TTS matrix
|
||||
(where the IndexTTS2 isolation_mode payoff lives). The frontend
|
||||
component types these fields as optional (`?: ...`) so the matrix
|
||||
still renders for ASR/LLM families on the simpler payload — only
|
||||
the `Isolation` and `GPU compat` columns degrade gracefully (fall
|
||||
back to defaults). Extending ASR/LLM is a one-line refactor when
|
||||
a future plan needs it.
|
||||
|
||||
## Manual smoke path
|
||||
1. `bun desktop-dev` (Tauri) OR `bun --filter frontend dev` + `uv run python -m backend.main`.
|
||||
2. Navigate to **Settings → Engines**.
|
||||
3. See one row per registered backend with install state, GPU chips,
|
||||
isolation badge.
|
||||
4. Click **Test engine** on any row.
|
||||
- Available in-process row → latency_ms appears (single-digit ms).
|
||||
- IndexTTS2 row (if installed) → spawns the sidecar (≤30 s cold),
|
||||
returns `pong` + latency. Click again within 5 s → ignored (cooldown).
|
||||
5. `curl http://127.0.0.1:3900/engines | jq '.tts.backends[0] | keys'` →
|
||||
shows `available, display_name, gpu_compat, id, install_hint,
|
||||
isolation_mode, last_error, reason`.
|
||||
6. `curl http://127.0.0.1:3900/engines/omnivoice/health` → returns
|
||||
ok/message/latency_ms in well under 1 s.
|
||||
@@ -4,17 +4,23 @@ Engines router — Phase 3 wiring.
|
||||
Exposes the three adapter registries (TTS, ASR, LLM) so the Settings UI can
|
||||
render an engine picker + availability reasons.
|
||||
|
||||
GET /engines → { tts, asr, llm }
|
||||
GET /engines/{family} → list of backends
|
||||
POST /engines/select → persist a backend choice in prefs.json
|
||||
GET /engines → { tts, asr, llm }
|
||||
GET /engines/{family} → list of backends
|
||||
POST /engines/select → persist a backend choice in prefs.json
|
||||
GET /engines/{engine_id}/health → spawn-or-ping for SubprocessBackend
|
||||
subclasses; ``is_available()`` for
|
||||
in-process backends (Plan 02-04)
|
||||
|
||||
Environment variables (`OMNIVOICE_TTS_BACKEND`, `OMNIVOICE_ASR_BACKEND`,
|
||||
`OMNIVOICE_LLM_BACKEND`) still win over the UI choice so power-users can pin
|
||||
a backend without Settings silently undoing it.
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from time import perf_counter
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from api.dependencies import require_loopback
|
||||
from core import prefs
|
||||
from services import tts_backend, asr_backend, llm_backend, translation_engines
|
||||
|
||||
@@ -135,6 +141,114 @@ async def uninstall_translation_engine(engine_id: str):
|
||||
return {"status": "uninstalled", "engine": engine_id, "package": pkg, "log_tail": out[-800:]}
|
||||
|
||||
|
||||
# ── Engine health-check (Plan 02-04 / ENGINE-06) ───────────────────────────
|
||||
#
|
||||
# The Compat Matrix UI's "Test engine" button calls into this endpoint so
|
||||
# that users can verify a SubprocessBackend engine is alive without
|
||||
# kicking off a full synthesize. For an in-process backend the check is a
|
||||
# cheap ``is_available()`` round-trip; for a SubprocessBackend subclass
|
||||
# the call spawns the sidecar (if not already up) and round-trips a ping
|
||||
# frame. Result includes wall-clock latency so the UI can render
|
||||
# "1234 ms — pong" inline next to the button.
|
||||
#
|
||||
# Loopback-gated (T-02-13): only the local desktop frontend may trigger
|
||||
# a sidecar spawn through this endpoint.
|
||||
|
||||
# Engine instances cached for the lifetime of the FastAPI process so that
|
||||
# repeated health checks don't spawn a new SubprocessBackend (each spawn
|
||||
# allocates a sidecar venv probe + atexit hook). The cache is keyed by
|
||||
# class to survive registry-sandbox tests that rebind ids transiently.
|
||||
_ENGINE_INSTANCES: dict[type, object] = {}
|
||||
|
||||
|
||||
def _get_engine_instance(cls):
|
||||
"""Return a cached singleton instance of ``cls``.
|
||||
|
||||
SubprocessBackend's ``__init__`` registers an atexit shutdown hook,
|
||||
so re-instantiating per request would leak handler entries (and on
|
||||
real engines, additional sidecar processes the first time the lock
|
||||
is acquired). One instance per process is the right move.
|
||||
"""
|
||||
inst = _ENGINE_INSTANCES.get(cls)
|
||||
if inst is None:
|
||||
inst = cls()
|
||||
_ENGINE_INSTANCES[cls] = inst
|
||||
return inst
|
||||
|
||||
|
||||
def _resolve_engine_class(engine_id: str):
|
||||
"""Look up ``engine_id`` across the tts/asr/llm registries.
|
||||
|
||||
Returns the class or ``None`` if no family knows the id. Order is
|
||||
tts → asr → llm so the most-common case (TTS engine matrix) wins
|
||||
early. No collision risk today — all current ids are family-unique.
|
||||
"""
|
||||
for registry in (
|
||||
tts_backend._REGISTRY,
|
||||
asr_backend._REGISTRY,
|
||||
llm_backend._REGISTRY,
|
||||
):
|
||||
if engine_id in registry:
|
||||
return registry[engine_id]
|
||||
return None
|
||||
|
||||
|
||||
@router.get(
|
||||
"/engines/{engine_id}/health",
|
||||
dependencies=[Depends(require_loopback)],
|
||||
)
|
||||
def engine_health(engine_id: str):
|
||||
"""Spawn-and-ping a SubprocessBackend; ``is_available()`` for the rest.
|
||||
|
||||
Returns:
|
||||
{ id, ok, message, latency_ms }
|
||||
|
||||
Never raises through to a 500: if the backend's check throws, the
|
||||
exception is captured into the response body as ``ok=False`` /
|
||||
``message="ExcType: ..."`` so the UI can render a per-row failure
|
||||
without crashing the panel. Unknown engine ids return 404.
|
||||
"""
|
||||
cls = _resolve_engine_class(engine_id)
|
||||
if cls is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"unknown engine id: {engine_id!r}",
|
||||
)
|
||||
|
||||
t0 = perf_counter()
|
||||
if hasattr(cls, "health_check"):
|
||||
# SubprocessBackend path — spawn sidecar (if not running) and ping.
|
||||
# ``health_check`` already swallows its own exceptions per Plan
|
||||
# 02-01's contract; we still wrap in a defensive try so a custom
|
||||
# subclass that violates the contract can't 500 the endpoint.
|
||||
try:
|
||||
instance = _get_engine_instance(cls)
|
||||
ok, msg = instance.health_check()
|
||||
except Exception as exc:
|
||||
ok, msg = False, f"{type(exc).__name__}: {exc}"
|
||||
else:
|
||||
# In-process backend — `is_available()` is the classmethod-level
|
||||
# liveness check. Cheap and side-effect-free for every shipping
|
||||
# backend (it imports the engine package, no model load).
|
||||
try:
|
||||
ok, msg = cls.is_available()
|
||||
except Exception as exc:
|
||||
ok, msg = False, f"{type(exc).__name__}: {exc}"
|
||||
|
||||
# Mask any HF token the engine accidentally leaked into the message
|
||||
# so the response body matches the same redaction guarantee as
|
||||
# ``list_backends()``.
|
||||
from services.tts_backend import _mask_hf_tokens
|
||||
|
||||
latency_ms = (perf_counter() - t0) * 1000.0
|
||||
return {
|
||||
"id": engine_id,
|
||||
"ok": bool(ok),
|
||||
"message": _mask_hf_tokens(msg) if isinstance(msg, str) else str(msg),
|
||||
"latency_ms": latency_ms,
|
||||
}
|
||||
|
||||
|
||||
class SelectEngineRequest(BaseModel):
|
||||
family: str # "tts" | "asr" | "llm"
|
||||
backend_id: str
|
||||
|
||||
@@ -20,6 +20,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Optional
|
||||
|
||||
@@ -28,6 +29,30 @@ import torch
|
||||
logger = logging.getLogger("omnivoice.tts")
|
||||
|
||||
|
||||
# ── HF token leak mitigation (Plan 02-04, T-02-12) ─────────────────────────
|
||||
#
|
||||
# Token shape is ``hf_`` + 30+ alphanumeric chars per Hugging Face's own
|
||||
# format. Any error / status string surfaced through the engines API gets
|
||||
# scrubbed via :func:`_mask_hf_tokens` before serialization so that a
|
||||
# backend whose ``is_available()`` interpolates ``HF_TOKEN`` into its
|
||||
# failure message can't accidentally leak it to the frontend. Phase 1's
|
||||
# ``HFTokenRedactor`` covers logging only — FastAPI response bodies do
|
||||
# NOT run through the logging filter chain.
|
||||
_HF_TOKEN_MASK_RE = re.compile(r"hf_[A-Za-z0-9]{30,}")
|
||||
_HF_TOKEN_MASK = "hf_***REDACTED***"
|
||||
|
||||
|
||||
def _mask_hf_tokens(value):
|
||||
"""Return ``value`` with any HF-shaped token substring redacted.
|
||||
|
||||
Non-string values pass through unchanged. Used inside
|
||||
:func:`list_backends` for the ``reason`` and ``last_error`` fields.
|
||||
"""
|
||||
if not isinstance(value, str):
|
||||
return value
|
||||
return _HF_TOKEN_MASK_RE.sub(_HF_TOKEN_MASK, value)
|
||||
|
||||
|
||||
# ── Protocol ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -62,6 +87,14 @@ class TTSBackend(ABC):
|
||||
#: (e.g. "young female, warm tone, British accent") without reference audio.
|
||||
supports_voice_design: bool = False
|
||||
|
||||
#: GPU/accelerator targets the engine can run on. Surfaced via the
|
||||
#: Engine Compatibility Matrix (Plan 02-04 / ENGINE-06) so users can
|
||||
#: tell at a glance which engines will use their hardware. Defaults to
|
||||
#: CPU-only — subclasses override with the union of devices their
|
||||
#: implementation supports (cuda / mps / rocm / cpu). This is metadata,
|
||||
#: not enforced — actual device selection lives in the engine's loader.
|
||||
gpu_compat: tuple[str, ...] = ("cpu",)
|
||||
|
||||
@abstractmethod
|
||||
def generate(
|
||||
self,
|
||||
@@ -129,6 +162,7 @@ class OmniVoiceBackend(TTSBackend):
|
||||
|
||||
id = "omnivoice"
|
||||
display_name = "OmniVoice (600 languages, zero-shot)"
|
||||
gpu_compat = ("cuda", "mps", "cpu")
|
||||
|
||||
def __init__(self, model=None):
|
||||
# The live OmniVoice instance. Reuses the singleton owned by
|
||||
@@ -212,6 +246,7 @@ class VoxCPM2Backend(TTSBackend):
|
||||
id = "voxcpm2"
|
||||
display_name = "VoxCPM2 (30 langs, studio 48 kHz, voice design)"
|
||||
supports_voice_design = True
|
||||
gpu_compat = ("cuda", "mps", "cpu")
|
||||
|
||||
def __init__(self):
|
||||
self._model = None
|
||||
@@ -320,6 +355,7 @@ class MossTTSNanoBackend(TTSBackend):
|
||||
|
||||
id = "moss-tts-nano"
|
||||
display_name = "MOSS-TTS-Nano (20 langs, CPU realtime, 48 kHz)"
|
||||
gpu_compat = ("cuda", "cpu")
|
||||
|
||||
def __init__(self):
|
||||
self._model = None
|
||||
@@ -412,6 +448,8 @@ class KittenTTSBackend(TTSBackend):
|
||||
|
||||
id = "kittentts"
|
||||
display_name = "KittenTTS (English, 8 preset voices, CPU realtime)"
|
||||
# KittenTTS ships as an ONNX CPU graph; no CUDA/MPS path today.
|
||||
gpu_compat = ("cpu",)
|
||||
|
||||
PRESET_VOICES = [
|
||||
"expr-voice-2-m", "expr-voice-2-f",
|
||||
@@ -502,6 +540,9 @@ class MLXAudioBackend(TTSBackend):
|
||||
|
||||
id = "mlx-audio"
|
||||
display_name = "MLX-Audio (mac-ARM, 14+ engines: Kokoro, CSM, Dia, Qwen3, …)"
|
||||
# mlx is Apple-Silicon-only; CPU is the practical fallback when the
|
||||
# mlx framework is installed but the user lacks an Apple GPU.
|
||||
gpu_compat = ("mps", "cpu")
|
||||
|
||||
# A curated subset surfaced by default — the full mlx-audio roster is
|
||||
# larger but these cover the useful tiers: small multilingual (Kokoro),
|
||||
@@ -625,6 +666,9 @@ class CosyVoiceBackend(TTSBackend):
|
||||
|
||||
id = "cosyvoice"
|
||||
display_name = "CosyVoice 3 (9 langs, zero-shot, instruct, Apache-2.0)"
|
||||
# CosyVoice's official inference path expects CUDA; CPU works but slow.
|
||||
# MPS support not verified upstream — flagged for Phase 6 confirmation.
|
||||
gpu_compat = ("cuda", "cpu")
|
||||
|
||||
# CosyVoice language tags used for cross-lingual synthesis.
|
||||
LANG_TAGS = {
|
||||
@@ -787,6 +831,8 @@ class GPTSoVITSBackend(TTSBackend):
|
||||
|
||||
id = "gpt-sovits"
|
||||
display_name = "GPT-SoVITS (5 langs, zero-shot, RTF 0.014, MIT)"
|
||||
# Server-side; whichever device GPT-SoVITS itself uses (CUDA preferred).
|
||||
gpu_compat = ("cuda", "cpu")
|
||||
|
||||
def __init__(self):
|
||||
self._url = os.environ.get("OMNIVOICE_GPTSOVITS_URL", "http://127.0.0.1:9880")
|
||||
@@ -894,6 +940,9 @@ class SherpaOnnxBackend(TTSBackend):
|
||||
|
||||
id = "sherpa-onnx"
|
||||
display_name = "Sherpa-ONNX (20+ engines, WASM-ready, universal runtime)"
|
||||
# Sherpa-ONNX uses the onnxruntime providers — CPU is the universal
|
||||
# baseline; CUDA provider is available on Linux/Windows installs.
|
||||
gpu_compat = ("cuda", "cpu")
|
||||
|
||||
def __init__(self):
|
||||
self._tts = None
|
||||
@@ -1082,19 +1131,25 @@ def list_backends() -> list[dict]:
|
||||
Per-entry shape (ENGINE-05 + ENGINE-06):
|
||||
|
||||
{
|
||||
"id": str,
|
||||
"display_name": str,
|
||||
"available": bool,
|
||||
"reason": Optional[str], # message when not available
|
||||
"install_hint": Optional[str],
|
||||
"last_error": Optional[str], # cached most-recent failure
|
||||
"id": str,
|
||||
"display_name": str,
|
||||
"available": bool,
|
||||
"reason": Optional[str], # message when not available
|
||||
"install_hint": Optional[str],
|
||||
"last_error": Optional[str], # cached most-recent failure
|
||||
"isolation_mode": "in-process" | "subprocess",
|
||||
"gpu_compat": list[str], # subset of {cuda, mps, rocm, cpu}
|
||||
}
|
||||
|
||||
Guarantees (ENGINE-05): a backend whose `is_available()` raises does
|
||||
NOT prevent the list from returning. The exception is captured into
|
||||
the `reason`/`last_error` fields for that one entry and every other
|
||||
backend is still listed normally.
|
||||
|
||||
Security (Plan 02-04 / T-02-12): any HF-shaped token substring in
|
||||
``reason`` or ``last_error`` is redacted before the entry is
|
||||
serialized — :func:`_mask_hf_tokens`. The frontend can render these
|
||||
fields verbatim without leaking credentials.
|
||||
"""
|
||||
# Detect subprocess-isolated backends via a duck-typed marker rather
|
||||
# than `issubclass(cls, SubprocessBackend)`. Test fixtures (e.g. the
|
||||
@@ -1118,7 +1173,10 @@ def list_backends() -> list[dict]:
|
||||
if ok:
|
||||
_LAST_ERRORS.pop(bid, None)
|
||||
else:
|
||||
_LAST_ERRORS[bid] = msg
|
||||
# Mask any HF token inside the failure message BEFORE it lands
|
||||
# in the in-memory cache — otherwise a later list_backends()
|
||||
# call would re-surface the unmasked string.
|
||||
_LAST_ERRORS[bid] = _mask_hf_tokens(msg)
|
||||
# ENGINE-06 isolation_mode: duck-typed marker for SubprocessBackend
|
||||
# subclasses (see services.subprocess_backend.SubprocessBackend).
|
||||
if getattr(cls, "_is_subprocess_isolated", False):
|
||||
@@ -1129,10 +1187,11 @@ def list_backends() -> list[dict]:
|
||||
"id": bid,
|
||||
"display_name": cls.display_name,
|
||||
"available": ok,
|
||||
"reason": None if ok else msg,
|
||||
"reason": None if ok else _mask_hf_tokens(msg),
|
||||
"install_hint": _INSTALL_HINTS.get(bid),
|
||||
"last_error": _LAST_ERRORS.get(bid),
|
||||
"isolation_mode": isolation,
|
||||
"gpu_compat": list(getattr(cls, "gpu_compat", ("cpu",))),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
AllEnginesResponse,
|
||||
EngineFamily,
|
||||
EngineFamilyResponse,
|
||||
EngineHealthResponse,
|
||||
SelectEngineResponse,
|
||||
} from './types';
|
||||
|
||||
@@ -48,6 +49,21 @@ export async function selectEngine(family: EngineFamily, backendId: string): Pro
|
||||
return apiPost<SelectEngineResponse>('/engines/select', { family, backend_id: backendId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Plan 02-04 / ENGINE-06 — spawn-and-ping a SubprocessBackend (or
|
||||
* `is_available()`-check an in-process backend) on user demand. The
|
||||
* Engine Compatibility Matrix's "Test engine" button calls this; never
|
||||
* called on Settings mount to avoid auto-spawning every sidecar.
|
||||
*
|
||||
* The endpoint never 500s on a sick backend — it captures the exception
|
||||
* into the response body as `{ ok: false, message: "ExcType: ..." }`.
|
||||
* 404 is returned only when `engineId` matches none of the tts/asr/llm
|
||||
* registries.
|
||||
*/
|
||||
export async function getEngineHealth(engineId: string): Promise<EngineHealthResponse> {
|
||||
return apiJson<EngineHealthResponse>(`/engines/${encodeURIComponent(engineId)}/health`);
|
||||
}
|
||||
|
||||
export async function listTranslationEngines(): Promise<TranslationEnginesResponse> {
|
||||
return apiJson<TranslationEnginesResponse>('/engines/translation');
|
||||
}
|
||||
|
||||
@@ -9,14 +9,26 @@
|
||||
* than lying with a fake type — explicit "unknown" prompts a runtime check.
|
||||
*/
|
||||
|
||||
// ── Engines (Phase 3 / 4.6) ──────────────────────────────────────────────
|
||||
// ── Engines (Phase 3 / 4.6 / Plan 02-04) ─────────────────────────────────
|
||||
export type EngineFamily = 'tts' | 'asr' | 'llm';
|
||||
|
||||
// `isolation_mode`, `last_error`, `install_hint`, `gpu_compat` arrived
|
||||
// in Plan 02-04 alongside the Engine Compatibility Matrix. They're
|
||||
// optional in this type because the asr / llm registries don't emit them
|
||||
// today (only the TTS registry has been migrated to the extended shape).
|
||||
// The matrix UI gates them with `??` / `?.length` so the simpler payload
|
||||
// still renders without errors.
|
||||
export type GPUTarget = 'cuda' | 'mps' | 'rocm' | 'cpu';
|
||||
|
||||
export interface EngineBackend {
|
||||
id: string;
|
||||
display_name: string;
|
||||
available: boolean;
|
||||
reason: string | null;
|
||||
install_hint?: string | null;
|
||||
last_error?: string | null;
|
||||
isolation_mode?: 'in-process' | 'subprocess';
|
||||
gpu_compat?: GPUTarget[];
|
||||
}
|
||||
|
||||
export interface EngineFamilyResponse {
|
||||
@@ -36,6 +48,13 @@ export interface SelectEngineResponse {
|
||||
env_override: boolean;
|
||||
}
|
||||
|
||||
export interface EngineHealthResponse {
|
||||
id: string;
|
||||
ok: boolean;
|
||||
message: string;
|
||||
latency_ms: number;
|
||||
}
|
||||
|
||||
// ── System / diagnostics ─────────────────────────────────────────────────
|
||||
export interface SystemInfo {
|
||||
app_version?: string;
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
/* Engine Compatibility Matrix (Plan 02-04 / ENGINE-06) */
|
||||
|
||||
.engine-matrix {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3, 8px);
|
||||
}
|
||||
|
||||
.engine-matrix--loading,
|
||||
.engine-matrix--error {
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.engine-matrix__muted {
|
||||
color: var(--chrome-fg-muted, #888);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.engine-matrix__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.engine-matrix__title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--chrome-fg, currentColor);
|
||||
}
|
||||
|
||||
.engine-matrix__table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.engine-matrix__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.engine-matrix__row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-top: 1px solid var(--chrome-border, rgba(255,255,255,0.06));
|
||||
min-height: 56px;
|
||||
}
|
||||
|
||||
.engine-matrix__row.is-off {
|
||||
opacity: 0.78;
|
||||
}
|
||||
|
||||
.engine-matrix__cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.engine-matrix__cell--name {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.engine-matrix__cell--center {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.engine-matrix__cell--gpu {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.engine-matrix__cell--actions {
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.engine-matrix__name {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: var(--chrome-fg, currentColor);
|
||||
}
|
||||
|
||||
.engine-matrix__id {
|
||||
font-family: var(--chrome-font-mono, ui-monospace, SFMono-Regular, monospace);
|
||||
font-size: 11px;
|
||||
color: var(--chrome-fg-muted, #888);
|
||||
}
|
||||
|
||||
.engine-matrix__reason {
|
||||
font-size: 12px;
|
||||
color: var(--chrome-severity-warn, #d79921);
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.engine-matrix__hint {
|
||||
font-size: 11px;
|
||||
color: var(--chrome-fg-muted, #888);
|
||||
}
|
||||
|
||||
.engine-matrix__last-error {
|
||||
font-size: 11px;
|
||||
color: var(--chrome-severity-err, #cc241d);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.engine-matrix__chips {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.engine-matrix__chip {
|
||||
display: inline-block;
|
||||
padding: 1px 6px;
|
||||
font-size: 10px;
|
||||
font-family: var(--chrome-font-mono, ui-monospace, monospace);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--chrome-border-strong, rgba(255,255,255,0.18));
|
||||
color: var(--chrome-fg-muted, #888);
|
||||
background: transparent;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.engine-matrix__chip--cuda {
|
||||
color: #76b900;
|
||||
border-color: color-mix(in srgb, #76b900 45%, transparent);
|
||||
background: color-mix(in srgb, #76b900 10%, transparent);
|
||||
}
|
||||
.engine-matrix__chip--mps {
|
||||
color: #b8b8b8;
|
||||
border-color: color-mix(in srgb, #b8b8b8 45%, transparent);
|
||||
background: color-mix(in srgb, #b8b8b8 10%, transparent);
|
||||
}
|
||||
.engine-matrix__chip--rocm {
|
||||
color: #ed1c24;
|
||||
border-color: color-mix(in srgb, #ed1c24 45%, transparent);
|
||||
background: color-mix(in srgb, #ed1c24 10%, transparent);
|
||||
}
|
||||
.engine-matrix__chip--cpu {
|
||||
/* keep default muted tone for CPU */
|
||||
}
|
||||
|
||||
.engine-matrix__result {
|
||||
font-size: 11px;
|
||||
font-family: var(--chrome-font-mono, ui-monospace, monospace);
|
||||
}
|
||||
|
||||
.engine-matrix__result--ok {
|
||||
color: var(--chrome-severity-ok, #98971a);
|
||||
}
|
||||
|
||||
.engine-matrix__result--fail {
|
||||
color: var(--chrome-severity-err, #cc241d);
|
||||
}
|
||||
|
||||
.engine-matrix__empty {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
color: var(--chrome-fg-muted, #888);
|
||||
font-size: 13px;
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Cpu, Mic, MessageSquare, Activity, AlertTriangle, CheckCircle2, RefreshCw, Layers } from 'lucide-react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { listEngines, getEngineHealth } from '../api/engines';
|
||||
import { Badge, Button, Segmented, Table } from '../ui';
|
||||
import './EngineCompatibilityMatrix.css';
|
||||
|
||||
/**
|
||||
* Engine Compatibility Matrix (Plan 02-04 / ENGINE-06).
|
||||
*
|
||||
* Renders a single source-of-truth table of every registered backend in
|
||||
* a family (tts / asr / llm). Each row shows:
|
||||
* * Engine display name
|
||||
* * Install state (available / unavailable, with the failure reason
|
||||
* inline when the row is unavailable)
|
||||
* * GPU compat chips (cuda / mps / rocm / cpu)
|
||||
* * Isolation mode (in-process or subprocess) — the visible payoff
|
||||
* of the Plan 02-01 SubprocessBackend + Plan 02-03 IndexTTS migration
|
||||
* * Last error (cached most-recent failure — distinguishes "currently
|
||||
* failing" from "failed before, now working")
|
||||
* * Test engine button — fires a `/engines/{id}/health` round-trip on
|
||||
* demand; SubprocessBackend rows spawn-and-ping their sidecar, in-
|
||||
* process rows fall back to `is_available()`. Latency is rendered
|
||||
* inline next to the button.
|
||||
*
|
||||
* Cross-platform contract: this component does NOT auto-spawn any
|
||||
* sidecar on mount; the user must click Test engine. That keeps macOS /
|
||||
* Windows / Linux behaviour identical and prevents the matrix from
|
||||
* locking up a cold IndexTTS install for 30 s every time Settings
|
||||
* loads. A short 5 s cooldown on the Test button prevents click-storms.
|
||||
*
|
||||
* Props:
|
||||
* - family: 'tts' | 'asr' | 'llm' default 'tts'
|
||||
* - onSelect?: (family, backendId) => Promise<void> optional — when
|
||||
* provided, a "Use" button appears next to "Test engine" for
|
||||
* available, non-active rows. Lets the matrix double as an engine
|
||||
* picker so Settings doesn't need a parallel table.
|
||||
* - activeId?: string the currently-active backend id for this
|
||||
* family. Used to render the "active" badge.
|
||||
*/
|
||||
const FAMILY_META = {
|
||||
tts: { label: 'TTS', icon: Cpu },
|
||||
asr: { label: 'ASR', icon: Mic },
|
||||
llm: { label: 'LLM', icon: MessageSquare },
|
||||
};
|
||||
|
||||
const ISOLATION_TONE = {
|
||||
subprocess: 'info',
|
||||
'in-process': 'neutral',
|
||||
};
|
||||
|
||||
const GPU_LABEL = {
|
||||
cuda: 'CUDA',
|
||||
mps: 'MPS',
|
||||
rocm: 'ROCm',
|
||||
cpu: 'CPU',
|
||||
};
|
||||
|
||||
const TEST_COOLDOWN_MS = 5000;
|
||||
|
||||
const COLUMNS = [
|
||||
{ key: 'name', label: 'Engine', flex: 3 },
|
||||
{ key: 'status', label: 'Install state', width: 130, align: 'center' },
|
||||
{ key: 'gpu', label: 'GPU compat', width: 170, align: 'left' },
|
||||
{ key: 'isolation', label: 'Isolation', width: 110, align: 'center' },
|
||||
{ key: 'action', label: 'Actions', width: 220, align: 'right' },
|
||||
];
|
||||
|
||||
/** Subset of the unified engine entry the matrix actually reads. */
|
||||
function normalizeEntry(entry) {
|
||||
return {
|
||||
id: entry.id,
|
||||
display_name: entry.display_name,
|
||||
available: !!entry.available,
|
||||
reason: entry.reason || null,
|
||||
install_hint: entry.install_hint || null,
|
||||
last_error: entry.last_error || null,
|
||||
isolation_mode: entry.isolation_mode || 'in-process',
|
||||
gpu_compat: Array.isArray(entry.gpu_compat) && entry.gpu_compat.length > 0
|
||||
? entry.gpu_compat
|
||||
: ['cpu'],
|
||||
};
|
||||
}
|
||||
|
||||
export default function EngineCompatibilityMatrix({
|
||||
family = 'tts',
|
||||
onSelect = null,
|
||||
activeId = null,
|
||||
// Test-friendly overrides — let the RTL suite mock the API layer
|
||||
// without resorting to module-level vi.mock incantations.
|
||||
apiListEngines = listEngines,
|
||||
apiGetEngineHealth = getEngineHealth,
|
||||
}) {
|
||||
const [data, setData] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [activeFamily, setActiveFamily] = useState(family);
|
||||
|
||||
// health state keyed by engine id:
|
||||
// { [id]: { inflight: boolean, ok?: boolean, message?: string,
|
||||
// latency_ms?: number, lastClickAt?: number } }
|
||||
const [healthByEngine, setHealthByEngine] = useState({});
|
||||
|
||||
useEffect(() => { setActiveFamily(family); }, [family]);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const fresh = await apiListEngines();
|
||||
setData(fresh);
|
||||
} catch (e) {
|
||||
const msg = e?.message || String(e);
|
||||
setError(msg);
|
||||
toast.error(`Failed to load engines: ${msg}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [apiListEngines]);
|
||||
|
||||
useEffect(() => { reload(); }, [reload]);
|
||||
|
||||
const familyData = data?.[activeFamily];
|
||||
const backends = useMemo(
|
||||
() => (familyData?.backends || []).map(normalizeEntry),
|
||||
[familyData],
|
||||
);
|
||||
const families = useMemo(
|
||||
() => Object.keys(FAMILY_META).filter((f) => data?.[f]?.backends),
|
||||
[data],
|
||||
);
|
||||
|
||||
const testHealth = useCallback(async (id) => {
|
||||
const now = Date.now();
|
||||
const cur = healthByEngine[id];
|
||||
if (cur?.inflight) return;
|
||||
if (cur?.lastClickAt && now - cur.lastClickAt < TEST_COOLDOWN_MS) {
|
||||
// Click-storm cooldown — silently ignore.
|
||||
return;
|
||||
}
|
||||
setHealthByEngine((prev) => ({
|
||||
...prev,
|
||||
[id]: { inflight: true, lastClickAt: now },
|
||||
}));
|
||||
try {
|
||||
const result = await apiGetEngineHealth(id);
|
||||
setHealthByEngine((prev) => ({
|
||||
...prev,
|
||||
[id]: {
|
||||
inflight: false,
|
||||
ok: !!result.ok,
|
||||
message: result.message || '',
|
||||
latency_ms: Math.round(result.latency_ms || 0),
|
||||
lastClickAt: now,
|
||||
},
|
||||
}));
|
||||
} catch (e) {
|
||||
setHealthByEngine((prev) => ({
|
||||
...prev,
|
||||
[id]: {
|
||||
inflight: false,
|
||||
ok: false,
|
||||
message: e?.message || String(e),
|
||||
latency_ms: 0,
|
||||
lastClickAt: now,
|
||||
},
|
||||
}));
|
||||
}
|
||||
}, [apiGetEngineHealth, healthByEngine]);
|
||||
|
||||
if (loading && !data) {
|
||||
return (
|
||||
<section className="engine-matrix engine-matrix--loading" aria-busy="true">
|
||||
<span className="engine-matrix__muted">Loading engines…</span>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
if (error && !data) {
|
||||
return (
|
||||
<section className="engine-matrix engine-matrix--error" role="alert">
|
||||
<AlertTriangle size={14} /> Could not load engines: {error}
|
||||
<Button size="sm" variant="subtle" onClick={reload} leading={<RefreshCw size={11} />}>
|
||||
Retry
|
||||
</Button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
if (!familyData) return null;
|
||||
|
||||
const activeBackendId = activeId ?? familyData.active;
|
||||
|
||||
return (
|
||||
<section className="engine-matrix">
|
||||
<header className="engine-matrix__head">
|
||||
<h3 className="engine-matrix__title">
|
||||
<Layers size={14} /> Engine Compatibility Matrix
|
||||
</h3>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
onClick={reload}
|
||||
loading={loading}
|
||||
leading={<RefreshCw size={11} />}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
{families.length > 1 && (
|
||||
<Segmented
|
||||
size="sm"
|
||||
value={activeFamily}
|
||||
onChange={setActiveFamily}
|
||||
items={families.map((f) => ({
|
||||
value: f,
|
||||
label: `${FAMILY_META[f].label} · ${data[f].active}`,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Table className="engine-matrix__table" role="table" aria-label={`${activeFamily} engine compatibility`}>
|
||||
<Table.Header columns={COLUMNS} />
|
||||
<div className="engine-matrix__body" role="rowgroup">
|
||||
{backends.map((b) => {
|
||||
const isActive = b.id === activeBackendId;
|
||||
const health = healthByEngine[b.id];
|
||||
return (
|
||||
<div
|
||||
key={b.id}
|
||||
role="row"
|
||||
data-engine-id={b.id}
|
||||
className={`engine-matrix__row ${b.available ? 'is-ok' : 'is-off'}`}
|
||||
>
|
||||
{/* Engine name + reason / install_hint */}
|
||||
<div role="cell" className="engine-matrix__cell engine-matrix__cell--name" style={{ flex: 3 }}>
|
||||
<span className="engine-matrix__name">
|
||||
{b.display_name}
|
||||
{isActive && <Badge tone="brand" size="xs">active</Badge>}
|
||||
</span>
|
||||
<code className="engine-matrix__id">{b.id}</code>
|
||||
{!b.available && b.reason && (
|
||||
<span className="engine-matrix__reason" title={b.reason}>{b.reason}</span>
|
||||
)}
|
||||
{b.install_hint && (
|
||||
<span className="engine-matrix__hint" title={b.install_hint}>
|
||||
{b.install_hint}
|
||||
</span>
|
||||
)}
|
||||
{b.last_error && (
|
||||
<span className="engine-matrix__last-error" data-testid="last-error">
|
||||
Last error: {b.last_error}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Install state */}
|
||||
<div
|
||||
role="cell"
|
||||
className="engine-matrix__cell engine-matrix__cell--center"
|
||||
style={{ width: 130 }}
|
||||
title={b.available ? 'Installed and ready' : (b.reason || 'Not installed')}
|
||||
>
|
||||
{b.available
|
||||
? <Badge tone="success" size="xs"><CheckCircle2 size={10} /> Available</Badge>
|
||||
: <Badge tone="warn" size="xs"><AlertTriangle size={10} /> Unavailable</Badge>}
|
||||
</div>
|
||||
|
||||
{/* GPU compat chips */}
|
||||
<div role="cell" className="engine-matrix__cell engine-matrix__cell--gpu" style={{ width: 170 }}>
|
||||
<div className="engine-matrix__chips">
|
||||
{b.gpu_compat.map((g) => (
|
||||
<span key={g} className={`engine-matrix__chip engine-matrix__chip--${g}`}>
|
||||
{GPU_LABEL[g] || g.toUpperCase()}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Isolation mode */}
|
||||
<div
|
||||
role="cell"
|
||||
className="engine-matrix__cell engine-matrix__cell--center"
|
||||
style={{ width: 110 }}
|
||||
title={b.isolation_mode === 'subprocess'
|
||||
? 'Runs in its own subprocess + venv'
|
||||
: 'Runs in the OmniVoice Python process'}
|
||||
>
|
||||
<Badge tone={ISOLATION_TONE[b.isolation_mode] || 'neutral'} size="xs">
|
||||
{b.isolation_mode}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Actions: Test engine + optional Use */}
|
||||
<div
|
||||
role="cell"
|
||||
className="engine-matrix__cell engine-matrix__cell--actions"
|
||||
style={{ width: 220 }}
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
onClick={() => testHealth(b.id)}
|
||||
disabled={!!health?.inflight}
|
||||
loading={!!health?.inflight}
|
||||
leading={!health?.inflight && <Activity size={11} />}
|
||||
aria-label={`Test ${b.display_name}`}
|
||||
>
|
||||
{health?.inflight ? 'Testing…' : 'Test engine'}
|
||||
</Button>
|
||||
{health && !health.inflight && (
|
||||
<span
|
||||
className={`engine-matrix__result engine-matrix__result--${health.ok ? 'ok' : 'fail'}`}
|
||||
data-testid={`health-result-${b.id}`}
|
||||
title={health.message}
|
||||
>
|
||||
{health.ok
|
||||
? `${health.latency_ms} ms`
|
||||
: `failed`}
|
||||
</span>
|
||||
)}
|
||||
{onSelect && b.available && !isActive && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
onClick={() => onSelect(activeFamily, b.id)}
|
||||
aria-label={`Use ${b.display_name}`}
|
||||
>
|
||||
Use
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{backends.length === 0 && (
|
||||
<div className="engine-matrix__empty" role="row">
|
||||
<span role="cell">No backends registered.</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Table>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -10,20 +10,21 @@ import {
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import {
|
||||
Cpu, FileText, Info, ShieldCheck, RefreshCw, Trash2, ExternalLink,
|
||||
CheckCircle, AlertCircle, Plug, Mic, MessageSquare, Download, Copy, Building2, KeyRound,
|
||||
CheckCircle, AlertCircle, Plug, Download, Copy, Building2, KeyRound,
|
||||
Keyboard,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { openExternal } from '../api/external';
|
||||
import { systemLogs, systemLogsTauri, clearSystemLogs, clearTauriLogs } from '../api/system';
|
||||
import { useSysinfo, useModelStatus, useSystemInfo } from '../api/hooks';
|
||||
import { listEngines, selectEngine } from '../api/engines';
|
||||
import { selectEngine } from '../api/engines';
|
||||
import { setupDownloadStreamUrl } from '../api/setup';
|
||||
import { getFrontendLogs, clearFrontendLogs } from '../utils/consoleBuffer';
|
||||
import { Tabs, Segmented, Button, Badge, Panel, Table, Progress } from '../ui';
|
||||
import { useAppStore } from '../store';
|
||||
import ApiKeysPanel from '../components/settings/ApiKeysPanel';
|
||||
import PerformancePanel from '../components/settings/PerformancePanel';
|
||||
import EngineCompatibilityMatrix from '../components/EngineCompatibilityMatrix';
|
||||
import './Settings.css';
|
||||
|
||||
const TABS = [
|
||||
@@ -36,12 +37,6 @@ const TABS = [
|
||||
{ id: 'privacy', label: 'Privacy', icon: ShieldCheck, accent: '#b8bb26' },
|
||||
];
|
||||
|
||||
const FAMILY_META = {
|
||||
tts: { label: 'TTS', icon: Cpu, tint: 'brand' },
|
||||
asr: { label: 'ASR', icon: Mic, tint: 'info' },
|
||||
llm: { label: 'LLM', icon: MessageSquare, tint: 'violet' },
|
||||
};
|
||||
|
||||
const LOG_SOURCES = [
|
||||
{ value: 'backend', label: 'Backend' },
|
||||
{ value: 'frontend', label: 'Frontend' },
|
||||
@@ -793,50 +788,21 @@ export function ModelStoreTab({ info, modelBadge }) {
|
||||
|
||||
|
||||
export function EnginesTab() {
|
||||
const [data, setData] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [switching, setSwitching] = useState(null);
|
||||
const [activeFam, setActiveFam] = useState('tts');
|
||||
const reviewMode = useAppStore(s => s.reviewMode);
|
||||
const setReviewMode = useAppStore(s => s.setReviewMode);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try { setData(await listEngines()); }
|
||||
catch (e) { toast.error(`Failed to load engines: ${e.message}`); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
// Plan 02-04 / ENGINE-06 — engine selection is wired through the
|
||||
// matrix component's optional onSelect callback so the matrix doubles
|
||||
// as a picker. Keeps a single source of truth for the engine list +
|
||||
// its install / GPU / isolation state.
|
||||
const onSelect = useCallback(async (family, backendId) => {
|
||||
setSwitching(`${family}:${backendId}`);
|
||||
try {
|
||||
const r = await selectEngine(family, backendId);
|
||||
toast.success(`${family.toUpperCase()} → ${r.active}`);
|
||||
await reload();
|
||||
} catch (e) {
|
||||
toast.error(e.message || 'Failed to switch engine');
|
||||
} finally {
|
||||
setSwitching(null);
|
||||
}
|
||||
}, [reload]);
|
||||
|
||||
useEffect(() => { reload(); }, [reload]);
|
||||
|
||||
if (loading && !data) {
|
||||
return <section className="settings-section"><div className="settings-muted">Loading engines…</div></section>;
|
||||
}
|
||||
if (!data) return null;
|
||||
|
||||
const fams = ['tts', 'asr', 'llm'].filter(f => data[f]);
|
||||
const currentFam = fams.includes(activeFam) ? activeFam : fams[0];
|
||||
const family = currentFam ? data[currentFam] : null;
|
||||
const famTint = currentFam ? FAMILY_META[currentFam].tint : 'neutral';
|
||||
|
||||
const COLUMNS = [
|
||||
{ key: 'name', label: 'Backend', flex: 3 },
|
||||
{ key: 'status', label: 'Status', width: 120, align: 'center' },
|
||||
{ key: 'action', label: '', width: 90, align: 'right' },
|
||||
];
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section className="settings-section settings-section--compact">
|
||||
@@ -856,72 +822,9 @@ export function EnginesTab() {
|
||||
{reviewMode === 'on' ? 'Stage banners on' : 'Stage banners off'}
|
||||
</span>
|
||||
</div>
|
||||
<Button variant="subtle" size="sm" onClick={reload} loading={loading} leading={<RefreshCw size={11} />}>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{fams.length > 1 && (
|
||||
<Segmented
|
||||
size="sm"
|
||||
value={currentFam}
|
||||
onChange={setActiveFam}
|
||||
className="models-roletabs"
|
||||
items={fams.map(f => ({
|
||||
value: f,
|
||||
label: `${FAMILY_META[f].label} · ${data[f].active}`,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
|
||||
{family && (
|
||||
<Table className="models-table">
|
||||
<Table.Header columns={COLUMNS} />
|
||||
<div className="models-table__body">
|
||||
{family.backends.map(b => {
|
||||
const isActive = family.active === b.id;
|
||||
const isSwitching = switching === `${currentFam}:${b.id}`;
|
||||
return (
|
||||
<div key={b.id} className={`models-row ${b.available ? 'is-ok' : 'is-off'}`}>
|
||||
<div className="models-row__cell models-row__name" style={{ flex: 3 }}>
|
||||
<span className="models-row__title">
|
||||
{b.display_name}
|
||||
{isActive && <Badge tone={famTint} size="xs">active</Badge>}
|
||||
</span>
|
||||
<span className="models-row__repo">
|
||||
<code>{b.id}</code>
|
||||
{!b.available && b.reason && (
|
||||
<span className="models-row__note" title={b.reason}> · {b.reason}</span>
|
||||
)}
|
||||
</span>
|
||||
{b.install_hint && (
|
||||
<span className="models-row__hint" title={b.install_hint}>
|
||||
<Info size={11} /> {b.install_hint}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="models-row__cell" style={{ width: 120, display: 'flex', justifyContent: 'center' }} title={b.available ? 'Installed and ready' : (b.reason || 'Not installed')}>
|
||||
{b.available
|
||||
? <Badge tone="success" size="xs">ready</Badge>
|
||||
: <Badge tone="warn" size="xs">unavailable</Badge>}
|
||||
</div>
|
||||
<div className="models-row__cell models-row__actions" style={{ width: 90 }}>
|
||||
{!isActive && b.available && (
|
||||
<Button
|
||||
variant="subtle" size="sm"
|
||||
onClick={() => onSelect(currentFam, b.id)}
|
||||
loading={isSwitching}
|
||||
>
|
||||
Use
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Table>
|
||||
)}
|
||||
<EngineCompatibilityMatrix family="tts" onSelect={onSelect} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
|
||||
// Mock the toast import the component depends on — keeps the test free
|
||||
// of side-effect side-channels (toast() schedules timers we don't want).
|
||||
vi.mock('react-hot-toast', () => ({
|
||||
default: { error: vi.fn(), success: vi.fn() },
|
||||
toast: { error: vi.fn(), success: vi.fn() },
|
||||
}));
|
||||
|
||||
import EngineCompatibilityMatrix from '../components/EngineCompatibilityMatrix';
|
||||
|
||||
/** Build a minimal AllEnginesResponse with the three rows the plan calls for. */
|
||||
function makeEnginesResponse({
|
||||
inProcessAvailable = true,
|
||||
inProcessHasLastError = false,
|
||||
} = {}) {
|
||||
return {
|
||||
tts: {
|
||||
active: 'omnivoice',
|
||||
backends: [
|
||||
{
|
||||
id: 'omnivoice',
|
||||
display_name: 'OmniVoice (test)',
|
||||
available: inProcessAvailable,
|
||||
reason: inProcessAvailable ? null : 'omnivoice package missing',
|
||||
install_hint: 'pip install omnivoice',
|
||||
last_error: inProcessHasLastError ? 'previous load failed' : null,
|
||||
isolation_mode: 'in-process',
|
||||
gpu_compat: ['cuda', 'mps', 'cpu'],
|
||||
},
|
||||
{
|
||||
id: 'kittentts',
|
||||
display_name: 'KittenTTS (test)',
|
||||
available: false,
|
||||
reason: 'kittentts not installed',
|
||||
install_hint: 'pip install kittentts',
|
||||
last_error: 'auth failed for hf_***REDACTED***',
|
||||
isolation_mode: 'in-process',
|
||||
gpu_compat: ['cpu'],
|
||||
},
|
||||
{
|
||||
id: 'indextts2',
|
||||
display_name: 'IndexTTS2 (test)',
|
||||
available: true,
|
||||
reason: null,
|
||||
install_hint: 'git clone …',
|
||||
last_error: null,
|
||||
isolation_mode: 'subprocess',
|
||||
gpu_compat: ['cuda', 'mps', 'cpu'],
|
||||
},
|
||||
],
|
||||
},
|
||||
asr: { active: 'whisperx', backends: [] },
|
||||
llm: { active: 'off', backends: [] },
|
||||
};
|
||||
}
|
||||
|
||||
describe('EngineCompatibilityMatrix', () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('renders one row per backend with the documented columns', async () => {
|
||||
const apiListEngines = vi.fn().mockResolvedValue(makeEnginesResponse());
|
||||
render(
|
||||
<EngineCompatibilityMatrix
|
||||
family="tts"
|
||||
apiListEngines={apiListEngines}
|
||||
apiGetEngineHealth={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('OmniVoice (test)')).toBeInTheDocument();
|
||||
});
|
||||
expect(apiListEngines).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Three engine rows, one per registered backend.
|
||||
expect(screen.getAllByRole('row').length).toBe(3);
|
||||
expect(screen.getByText('KittenTTS (test)')).toBeInTheDocument();
|
||||
expect(screen.getByText('IndexTTS2 (test)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows isolation_mode badge per row (subprocess for IndexTTS, in-process for the others)', async () => {
|
||||
const apiListEngines = vi.fn().mockResolvedValue(makeEnginesResponse());
|
||||
render(
|
||||
<EngineCompatibilityMatrix
|
||||
family="tts"
|
||||
apiListEngines={apiListEngines}
|
||||
apiGetEngineHealth={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => screen.getByText('IndexTTS2 (test)'));
|
||||
|
||||
const indexRow = screen.getByText('IndexTTS2 (test)').closest('[role="row"]');
|
||||
const omniRow = screen.getByText('OmniVoice (test)').closest('[role="row"]');
|
||||
const kittenRow = screen.getByText('KittenTTS (test)').closest('[role="row"]');
|
||||
|
||||
expect(within(indexRow).getByText('subprocess')).toBeInTheDocument();
|
||||
expect(within(omniRow).getByText('in-process')).toBeInTheDocument();
|
||||
expect(within(kittenRow).getByText('in-process')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders GPU compat chips for each backend', async () => {
|
||||
const apiListEngines = vi.fn().mockResolvedValue(makeEnginesResponse());
|
||||
render(
|
||||
<EngineCompatibilityMatrix
|
||||
family="tts"
|
||||
apiListEngines={apiListEngines}
|
||||
apiGetEngineHealth={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => screen.getByText('OmniVoice (test)'));
|
||||
|
||||
const omniRow = screen.getByText('OmniVoice (test)').closest('[role="row"]');
|
||||
expect(within(omniRow).getByText('CUDA')).toBeInTheDocument();
|
||||
expect(within(omniRow).getByText('MPS')).toBeInTheDocument();
|
||||
expect(within(omniRow).getByText('CPU')).toBeInTheDocument();
|
||||
|
||||
const kittenRow = screen.getByText('KittenTTS (test)').closest('[role="row"]');
|
||||
// KittenTTS is CPU-only.
|
||||
expect(within(kittenRow).getByText('CPU')).toBeInTheDocument();
|
||||
expect(within(kittenRow).queryByText('CUDA')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the install reason inline when a backend is unavailable', async () => {
|
||||
const apiListEngines = vi.fn().mockResolvedValue(makeEnginesResponse());
|
||||
render(
|
||||
<EngineCompatibilityMatrix
|
||||
family="tts"
|
||||
apiListEngines={apiListEngines}
|
||||
apiGetEngineHealth={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => screen.getByText('KittenTTS (test)'));
|
||||
const kittenRow = screen.getByText('KittenTTS (test)').closest('[role="row"]');
|
||||
expect(within(kittenRow).getByText('kittentts not installed')).toBeInTheDocument();
|
||||
expect(within(kittenRow).getByText(/Unavailable/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a "Last error" line when last_error is populated', async () => {
|
||||
const apiListEngines = vi.fn().mockResolvedValue(makeEnginesResponse());
|
||||
render(
|
||||
<EngineCompatibilityMatrix
|
||||
family="tts"
|
||||
apiListEngines={apiListEngines}
|
||||
apiGetEngineHealth={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => screen.getByText('KittenTTS (test)'));
|
||||
const lastErrEls = screen.getAllByTestId('last-error');
|
||||
expect(lastErrEls.length).toBeGreaterThan(0);
|
||||
// The masked sentinel survives the redactor — confirms the row renders
|
||||
// the cache verbatim and does NOT try to "clean up" the masked string.
|
||||
expect(lastErrEls[0].textContent).toMatch(/hf_\*\*\*REDACTED\*\*\*/);
|
||||
});
|
||||
|
||||
it('clicking Test engine fires getEngineHealth and renders latency_ms', async () => {
|
||||
const apiListEngines = vi.fn().mockResolvedValue(makeEnginesResponse());
|
||||
const apiGetEngineHealth = vi.fn().mockResolvedValue({
|
||||
id: 'indextts2', ok: true, message: 'pong', latency_ms: 1234,
|
||||
});
|
||||
render(
|
||||
<EngineCompatibilityMatrix
|
||||
family="tts"
|
||||
apiListEngines={apiListEngines}
|
||||
apiGetEngineHealth={apiGetEngineHealth}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => screen.getByText('IndexTTS2 (test)'));
|
||||
const indexRow = screen.getByText('IndexTTS2 (test)').closest('[role="row"]');
|
||||
const testBtn = within(indexRow).getByRole('button', { name: /test indextts2/i });
|
||||
fireEvent.click(testBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(apiGetEngineHealth).toHaveBeenCalledWith('indextts2');
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(within(indexRow).getByTestId('health-result-indextts2')).toBeInTheDocument();
|
||||
});
|
||||
expect(within(indexRow).getByText(/1234 ms/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Test button is disabled while an inflight health request is pending', async () => {
|
||||
const apiListEngines = vi.fn().mockResolvedValue(makeEnginesResponse());
|
||||
// A health request that never resolves so we can observe the inflight state.
|
||||
let resolveHealth;
|
||||
const apiGetEngineHealth = vi.fn(() => new Promise((resolve) => { resolveHealth = resolve; }));
|
||||
render(
|
||||
<EngineCompatibilityMatrix
|
||||
family="tts"
|
||||
apiListEngines={apiListEngines}
|
||||
apiGetEngineHealth={apiGetEngineHealth}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => screen.getByText('IndexTTS2 (test)'));
|
||||
const indexRow = screen.getByText('IndexTTS2 (test)').closest('[role="row"]');
|
||||
const testBtn = within(indexRow).getByRole('button', { name: /test indextts2/i });
|
||||
fireEvent.click(testBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(testBtn).toBeDisabled();
|
||||
});
|
||||
// Second click while inflight must be a no-op — the spy has been called
|
||||
// exactly once.
|
||||
fireEvent.click(testBtn);
|
||||
expect(apiGetEngineHealth).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Release the promise so the test doesn't leak a pending microtask.
|
||||
resolveHealth({ id: 'indextts2', ok: true, message: 'pong', latency_ms: 50 });
|
||||
});
|
||||
|
||||
it('renders a failure marker when the health route returns ok=false', async () => {
|
||||
const apiListEngines = vi.fn().mockResolvedValue(makeEnginesResponse());
|
||||
const apiGetEngineHealth = vi.fn().mockResolvedValue({
|
||||
id: 'indextts2', ok: false, message: 'spawn failed', latency_ms: 12,
|
||||
});
|
||||
render(
|
||||
<EngineCompatibilityMatrix
|
||||
family="tts"
|
||||
apiListEngines={apiListEngines}
|
||||
apiGetEngineHealth={apiGetEngineHealth}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => screen.getByText('IndexTTS2 (test)'));
|
||||
const indexRow = screen.getByText('IndexTTS2 (test)').closest('[role="row"]');
|
||||
fireEvent.click(within(indexRow).getByRole('button', { name: /test indextts2/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(within(indexRow).getByText(/failed/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,276 @@
|
||||
"""Plan 02-04 — API contract for /engines + /engines/{id}/health.
|
||||
|
||||
Asserts:
|
||||
* ``GET /engines`` returns the documented per-entry shape
|
||||
(id, display_name, available, reason, install_hint, last_error,
|
||||
isolation_mode, gpu_compat) for every backend.
|
||||
* ``GET /engines/{engine_id}/health`` round-trips for both
|
||||
SubprocessBackend (mocked health_check) and in-process backends.
|
||||
* Loopback gate is enforced on the health route — non-loopback origin
|
||||
returns 403.
|
||||
* Unknown engine id returns 404.
|
||||
* HF-shaped tokens that a backend's is_available() leaks into its
|
||||
error message do NOT reach the response body — T-02-12.
|
||||
|
||||
The fixture builds a minimal FastAPI app with just the engines router so
|
||||
the test stays fast and doesn't require torch / whisperx / demucs to be
|
||||
fully importable.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
SAMPLE_HF_TOKEN = "hf_abcdefghijklmnopqrstuvwxyz01234567890abcd"
|
||||
HF_TOKEN_RE = re.compile(r"hf_[A-Za-z0-9]{30,}")
|
||||
|
||||
|
||||
# ── helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fresh_app(monkeypatch, tmp_path):
|
||||
"""Build a fresh FastAPI app instance with isolated DB.
|
||||
|
||||
The full main.py app factory pulls in torch / whisperx / demucs; we
|
||||
only need the engines router for these tests so we mount it
|
||||
directly, matching the pattern in tests/backend/test_engine_spawn_token.py.
|
||||
"""
|
||||
monkeypatch.setenv("OMNIVOICE_DATA_DIR", str(tmp_path))
|
||||
|
||||
# Wipe cached services so each test gets a clean _LAST_ERRORS dict +
|
||||
# _REGISTRY (the engines router imports them on first call).
|
||||
for mod in list(sys.modules):
|
||||
if (
|
||||
mod == "core" or mod.startswith("core.")
|
||||
or mod == "services" or mod.startswith("services.")
|
||||
or mod == "api" or mod.startswith("api.")
|
||||
):
|
||||
del sys.modules[mod]
|
||||
|
||||
from core import db as _db
|
||||
_db.init_db()
|
||||
|
||||
from fastapi import FastAPI
|
||||
from api.routers import engines as engines_router
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(engines_router.router)
|
||||
return app
|
||||
|
||||
|
||||
def _client(app, host="127.0.0.1"):
|
||||
"""TestClient anchored to a loopback (or non-loopback) client tuple.
|
||||
|
||||
`require_loopback` reads `request.client.host`; the default
|
||||
TestClient tuple is `('testclient', 50000)` which the dep rejects.
|
||||
"""
|
||||
from fastapi.testclient import TestClient
|
||||
return TestClient(app, client=(host, 12345))
|
||||
|
||||
|
||||
# ── /engines response shape (gpu_compat, isolation_mode, last_error) ──────
|
||||
|
||||
|
||||
def test_engines_response_includes_new_fields(fresh_app):
|
||||
client = _client(fresh_app)
|
||||
r = client.get("/engines")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
|
||||
required = {
|
||||
"id", "display_name", "available", "reason",
|
||||
"install_hint", "last_error", "isolation_mode", "gpu_compat",
|
||||
}
|
||||
for entry in body["tts"]["backends"]:
|
||||
missing = required - entry.keys()
|
||||
assert not missing, f"entry {entry.get('id')!r} missing keys: {missing}"
|
||||
assert isinstance(entry["gpu_compat"], list)
|
||||
assert all(isinstance(x, str) for x in entry["gpu_compat"])
|
||||
assert entry["isolation_mode"] in {"in-process", "subprocess"}
|
||||
|
||||
|
||||
def test_indextts2_entry_has_subprocess_isolation_mode(fresh_app):
|
||||
"""Cross-checks Plan 02-03's IndexTTS subprocess migration via the API."""
|
||||
client = _client(fresh_app)
|
||||
r = client.get("/engines")
|
||||
assert r.status_code == 200
|
||||
by_id = {b["id"]: b for b in r.json()["tts"]["backends"]}
|
||||
assert "indextts2" in by_id
|
||||
assert by_id["indextts2"]["isolation_mode"] == "subprocess"
|
||||
|
||||
|
||||
def test_omnivoice_entry_has_in_process_isolation_mode(fresh_app):
|
||||
client = _client(fresh_app)
|
||||
r = client.get("/engines")
|
||||
assert r.status_code == 200
|
||||
by_id = {b["id"]: b for b in r.json()["tts"]["backends"]}
|
||||
assert "omnivoice" in by_id
|
||||
assert by_id["omnivoice"]["isolation_mode"] == "in-process"
|
||||
|
||||
|
||||
def test_gpu_compat_omnivoice_has_cuda_mps_cpu(fresh_app):
|
||||
"""OmniVoice ships with CUDA/MPS/CPU paths — surface that in the matrix."""
|
||||
client = _client(fresh_app)
|
||||
r = client.get("/engines")
|
||||
by_id = {b["id"]: b for b in r.json()["tts"]["backends"]}
|
||||
assert set(by_id["omnivoice"]["gpu_compat"]) == {"cuda", "mps", "cpu"}
|
||||
|
||||
|
||||
# ── /engines/{id}/health round-trip ────────────────────────────────────────
|
||||
|
||||
|
||||
def test_engine_health_subprocess_success(fresh_app, monkeypatch):
|
||||
"""Mock IndexTTS2Backend.health_check so we don't spawn a real sidecar."""
|
||||
from services.tts_backend import _REGISTRY
|
||||
|
||||
# Resolve the lazy entry without spawning anything heavy.
|
||||
cls = _REGISTRY["indextts2"]
|
||||
monkeypatch.setattr(cls, "health_check", lambda self: (True, "pong"))
|
||||
|
||||
client = _client(fresh_app)
|
||||
r = client.get("/engines/indextts2/health")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["id"] == "indextts2"
|
||||
assert body["ok"] is True
|
||||
assert body["message"] == "pong"
|
||||
assert isinstance(body["latency_ms"], (int, float))
|
||||
assert body["latency_ms"] >= 0.0
|
||||
|
||||
|
||||
def test_engine_health_in_process_falls_back_to_is_available(fresh_app):
|
||||
"""No health_check method on OmniVoiceBackend → fall back to is_available."""
|
||||
client = _client(fresh_app)
|
||||
r = client.get("/engines/omnivoice/health")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["id"] == "omnivoice"
|
||||
assert isinstance(body["ok"], bool)
|
||||
assert isinstance(body["message"], str)
|
||||
assert isinstance(body["latency_ms"], (int, float))
|
||||
|
||||
|
||||
def test_engine_health_unknown_id(fresh_app):
|
||||
client = _client(fresh_app)
|
||||
r = client.get("/engines/does_not_exist/health")
|
||||
assert r.status_code == 404
|
||||
assert "unknown engine id" in r.json()["detail"]
|
||||
|
||||
|
||||
def test_engine_health_loopback_only(fresh_app):
|
||||
"""Non-loopback client tuple is rejected by require_loopback."""
|
||||
client = _client(fresh_app, host="10.0.0.5")
|
||||
r = client.get("/engines/omnivoice/health")
|
||||
assert r.status_code == 403
|
||||
assert r.json()["detail"] == "loopback origin required"
|
||||
|
||||
|
||||
def test_engine_health_caches_instance_across_calls(fresh_app, monkeypatch):
|
||||
"""Two health checks on the same engine reuse the same singleton.
|
||||
|
||||
SubprocessBackend.__init__ registers atexit hooks; recreating it per
|
||||
request would leak handler entries and (on real engines) spawn extra
|
||||
sidecars on the first lock acquire.
|
||||
"""
|
||||
from api.routers import engines as engines_router
|
||||
from services.tts_backend import _REGISTRY
|
||||
|
||||
cls = _REGISTRY["indextts2"]
|
||||
call_count = {"n": 0}
|
||||
monkeypatch.setattr(cls, "health_check", lambda self: (True, "pong"))
|
||||
# Clear the cache so the first call constructs an instance.
|
||||
engines_router._ENGINE_INSTANCES.pop(cls, None)
|
||||
original_init = cls.__init__
|
||||
|
||||
def _counting_init(self):
|
||||
call_count["n"] += 1
|
||||
original_init(self)
|
||||
|
||||
monkeypatch.setattr(cls, "__init__", _counting_init)
|
||||
|
||||
client = _client(fresh_app)
|
||||
r1 = client.get("/engines/indextts2/health")
|
||||
r2 = client.get("/engines/indextts2/health")
|
||||
assert r1.status_code == 200 and r2.status_code == 200
|
||||
assert call_count["n"] == 1, (
|
||||
f"expected exactly one IndexTTS2Backend() construction across "
|
||||
f"two health checks, got {call_count['n']}"
|
||||
)
|
||||
|
||||
|
||||
# ── HF-token leak prevention (T-02-12) ─────────────────────────────────────
|
||||
|
||||
|
||||
def test_no_hf_token_leak_in_engines_response(fresh_app):
|
||||
"""A backend whose is_available() embeds a real HF token in its error
|
||||
must NOT leak it to the response body. The redaction lives inside
|
||||
``tts_backend.list_backends`` via _mask_hf_tokens.
|
||||
"""
|
||||
from services import tts_backend as tts_mod
|
||||
|
||||
class TaintedBackend(tts_mod.TTSBackend):
|
||||
id = "tainted-test"
|
||||
display_name = "Tainted backend (test)"
|
||||
|
||||
@property
|
||||
def sample_rate(self) -> int:
|
||||
return 24000
|
||||
|
||||
@property
|
||||
def supported_languages(self) -> list[str]:
|
||||
return ["en"]
|
||||
|
||||
@classmethod
|
||||
def is_available(cls) -> tuple[bool, str]:
|
||||
return False, f"auth failed for {SAMPLE_HF_TOKEN}"
|
||||
|
||||
def generate(self, text: str, **kw):
|
||||
raise NotImplementedError
|
||||
|
||||
# Sandbox so the production registry shape doesn't grow permanently.
|
||||
saved = dict(tts_mod._REGISTRY)
|
||||
saved_errors = dict(tts_mod._LAST_ERRORS)
|
||||
try:
|
||||
tts_mod._REGISTRY["tainted-test"] = TaintedBackend
|
||||
client = _client(fresh_app)
|
||||
r = client.get("/engines")
|
||||
assert r.status_code == 200
|
||||
body_text = r.text
|
||||
matches = HF_TOKEN_RE.findall(body_text)
|
||||
assert matches == [], (
|
||||
f"HF tokens leaked into /engines response body: {matches}"
|
||||
)
|
||||
|
||||
# The masked sentinel must be present — otherwise the test isn't
|
||||
# actually exercising the redaction path.
|
||||
by_id = {b["id"]: b for b in r.json()["tts"]["backends"]}
|
||||
assert "tainted-test" in by_id
|
||||
assert "hf_***REDACTED***" in (by_id["tainted-test"]["reason"] or "")
|
||||
finally:
|
||||
tts_mod._REGISTRY.clear()
|
||||
tts_mod._REGISTRY.update(saved)
|
||||
tts_mod._LAST_ERRORS.clear()
|
||||
tts_mod._LAST_ERRORS.update(saved_errors)
|
||||
|
||||
|
||||
def test_no_hf_token_leak_in_health_response(fresh_app, monkeypatch):
|
||||
"""The health route's message field runs through the same redactor."""
|
||||
from services.tts_backend import _REGISTRY
|
||||
|
||||
cls = _REGISTRY["indextts2"]
|
||||
monkeypatch.setattr(
|
||||
cls, "health_check",
|
||||
lambda self: (False, f"sidecar 401 for {SAMPLE_HF_TOKEN}"),
|
||||
)
|
||||
|
||||
client = _client(fresh_app)
|
||||
r = client.get("/engines/indextts2/health")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["ok"] is False
|
||||
assert not HF_TOKEN_RE.search(body["message"])
|
||||
assert "hf_***REDACTED***" in body["message"]
|
||||
@@ -143,9 +143,11 @@ def test_list_backends_resilient(registry_sandbox):
|
||||
def test_list_backends_shape(registry_sandbox):
|
||||
"""Every entry must contain exactly the documented keys — no more, no less."""
|
||||
out = list_backends()
|
||||
# `gpu_compat` joined the documented shape in Plan 02-04 alongside the
|
||||
# Engine Compatibility Matrix UI (ENGINE-06).
|
||||
required = {
|
||||
"id", "display_name", "available", "reason",
|
||||
"install_hint", "last_error", "isolation_mode",
|
||||
"install_hint", "last_error", "isolation_mode", "gpu_compat",
|
||||
}
|
||||
for entry in out:
|
||||
assert set(entry.keys()) == required, (
|
||||
|
||||
Reference in New Issue
Block a user