Files
VoiceStudio/tests/backend/test_compute_device_settings.py
T
Palash DebnathandClaude Fable 5 48c9a3b1f8 feat(settings): compute-device override (auto / CUDA / ROCm / XPU / MPS / CPU) (#1557)
* feat(settings): compute-device override — auto | CUDA | ROCm | XPU | MPS | CPU

Auto-detect stays the default; the override kills the 'auto-detect picked
wrong' issue class. Applied at the single choke point (_probe()'s family
selection) so routing, get_best_device(), and every badge inherit it.
Resolution: OMNIVOICE_DEVICE env > Settings pick (prefs.json) > auto (#981
pattern). An override can steer, never invent hardware: a family the host
lacks is noted and ignored; cpu is always honorable. Applies at next
backend start (host caps are immutable per process — same restart contract
as the rest of the Performance tab, RestartBadge shown).

GET/PUT /api/settings/compute-device (admin-gated) reports resolved vs
applied so the panel shows restart-required truthfully and disables itself
under an env pin instead of pretending.

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

* docs(changelog): entry for the compute-device override (#1557)

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

* fix(device-override): harvest — the override reaches CT2 ASR, full i18n, honest edge states

- _ctranslate2_cuda_ok() and the ASR sidecar now gate on the probe's family,
  so a cpu pin (or ROCm host) can never hand CTranslate2 a CUDA device —
  the override reaches every CT2 loader through one shared gate
- override_ignored exposed by the API and shown by the panel (env pin naming
  a device this machine lacks: auto is in effect, restart won't change it)
- all 8 panel strings + 5 device-family labels translated into all 21
  locales; failed saves keep their error visible through the re-sync
- test isolation: cleanup drops OMNIVOICE_DEVICE before re-probing so no
  overridden caps leak into later tests; panel tests wait for loaded state
- xpu/intel search keywords; oxfmt formatting

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

* fix(device-override): round 2 — fail-safe probe fallbacks, complete i18n, combined pin state

- a broken capability probe now means CPU everywhere (CT2 gate + ASR
  sidecar) — never a torch-derived guess that would bypass a cpu pin or
  re-open #1529 on ROCm; regression test added
- env-pinned AND not-detected shows both facts in one subtitle
- device_load_failed/perf_save_failed translated into all 21 locales;
  CJK/th/vi/ar strings no longer say literal 'Auto'
- test_ctranslate2_never_gets_cuda_on_a_rocm_build pins the probe family
  (it was order-dependent on the lru_cache before)

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

* fix(test): pin the probe family in the faster-whisper OOM-fallback test

Same class as the rocm-build test: it mocked torch but not the probe the
new override gate consults first, so on a cpu-family CI host the CUDA
fallback chain under test was unreachable.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 05:08:51 +00:00

112 lines
4.0 KiB
Python

"""Tests for the compute-device override endpoints (Settings → Performance).
Covers the API contract of GET/PUT /api/settings/compute-device:
- GET reports the resolved pick, what this process applied, and the host's
available families (the UI renders only those + Auto).
- PUT persists a valid pick to prefs.json and echoes the new state with
restart_required=True (caps are immutable per process).
- PUT rejects unknown values and accelerators this host doesn't have —
a silent no-op pick would read as "the setting doesn't work".
- An OMNIVOICE_DEVICE env pin is reported (env_pinned) and wins over PUT.
"""
from __future__ import annotations
import sys
import pytest
@pytest.fixture
def fresh_app(monkeypatch, tmp_path):
"""Same isolation pattern as tests/backend/test_perf_settings.py."""
monkeypatch.setenv("OMNIVOICE_DATA_DIR", str(tmp_path))
monkeypatch.delenv("OMNIVOICE_DEVICE", raising=False)
monkeypatch.delenv("HF_TOKEN", raising=False)
monkeypatch.delenv("HUGGING_FACE_HUB_TOKEN", raising=False)
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 settings as settings_router
app = FastAPI()
app.include_router(settings_router.router)
return app
def _client(app):
from fastapi.testclient import TestClient
return TestClient(app, client=("127.0.0.1", 12345))
def test_get_reports_state_and_families(fresh_app):
c = _client(fresh_app)
r = c.get("/api/settings/compute-device")
assert r.status_code == 200, r.text
body = r.json()
assert body["value"] in body["choices"]
assert "cpu" in body["available_families"] # invariant: cpu always present
assert body["effective_family"] in body["available_families"]
assert isinstance(body["env_pinned"], bool)
def test_put_persists_and_flags_restart(fresh_app):
c = _client(fresh_app)
r = c.put("/api/settings/compute-device", json={"value": "cpu"})
assert r.status_code == 200, r.text
body = r.json()
assert body["value"] == "cpu"
# Caps are cached per process: the pick applies at next start, and the
# endpoint must say so instead of pretending it already did.
assert body["restart_required"] is True
from core import prefs
assert prefs.resolve("compute_device", default="auto") == "cpu"
# auto round-trips back
r = c.put("/api/settings/compute-device", json={"value": "auto"})
assert r.status_code == 200
assert r.json()["value"] == "auto"
def test_put_rejects_unknown_and_unavailable_values(fresh_app):
c = _client(fresh_app)
r = c.put("/api/settings/compute-device", json={"value": "quantum"})
assert r.status_code == 400
assert "Valid:" in r.json()["detail"]
# Find an accelerator this host does NOT have (CI hosts are cpu-only,
# but don't assume — pick from the full choice list minus available).
state = c.get("/api/settings/compute-device").json()
missing = [
f for f in state["choices"]
if f not in ("auto", "cpu") and f not in state["available_families"]
]
if missing:
r = c.put("/api/settings/compute-device", json={"value": missing[0]})
assert r.status_code == 400
assert "not available" in r.json()["detail"]
def test_env_pin_is_reported_and_wins(fresh_app, monkeypatch):
monkeypatch.setenv("OMNIVOICE_DEVICE", "cpu")
c = _client(fresh_app)
state = c.get("/api/settings/compute-device").json()
assert state["env_pinned"] is True
assert state["value"] == "cpu"
# A PUT still persists (for after the pin is removed) but the resolved
# value stays the env's — the UI disables the control and shows the pin.
r = c.put("/api/settings/compute-device", json={"value": "auto"})
assert r.status_code == 200
assert r.json()["value"] == "cpu"