feat(menu): nicht nutzbare TTS-Provider ausblenden + GPU-Erkennung

Menue zeigt nur tatsaechlich nutzbare Vorlese-Optionen, um Verwirrung durch
folgenlose Eintraege zu vermeiden:
- Backend ist alleinige Quelle: /api/config liefert fertiges tts_menu
  (Chatterbox nur wenn erreichbar, Cloud nur mit OpenRouter-TTS, Cartesia nur
  mit Key). piper bleibt garantierter, immer sichtbarer Default.
- Frontend blendet aus statt auszugrauen; 'Im Geraet' nur auf Mobilgeraeten
  (isMobile). Aktive/gespeicherte Auswahl auf einen sichtbaren Provider
  geklemmt -> kein Server-422.
- 4x refresh*Preset + 3x /api/config-Fetch -> 1x refreshTtsMenu; /api/config
  serverseitig 30s gecacht (spart die bisher kostenpflichtige OpenRouter-Probe
  pro Seitenladen).

GPU-Erkennung zentralisiert (keine Dopplung):
- app/capabilities.py: detect_physical_gpus/resolve_gpus (nvidia-smi).
- Neues Setting GPUS (auto|none|0,1,2), beim Start an CUDA_VISIBLE_DEVICES
  gekoppelt -> angezeigt = real nutzbar. admin_llm nutzt dieselbe Erkennung.

Tests: tests/test_capabilities.py (Maske, Kopplung, Menue-Sichtbarkeit, GPUs).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-06-27 04:16:21 +02:00
commit 5e128a74ce
7 changed files with 333 additions and 114 deletions

View file

@ -66,6 +66,13 @@ LOCAL_LLM_MODEL=va_llm
# LOCAL_LLM_TEMPERATURE=0.3
# LOCAL_LLM_SYSTEM_PROMPT=Du bist ein gesprochener Sprachassistent. Antworte kurz ...
# --- GPU-Sichtbarkeit --------------------------------------------------------
# auto = erkennen (respektiert vorhandenes CUDA_VISIBLE_DEVICES)
# none = keine GPU erzwingen (CPU); 0,1,2 = nur diese Indizes
# Wird beim Start an CUDA_VISIBLE_DEVICES gekoppelt -> steuert auch, welche
# GPU-only-Menuepunkte (z. B. "Hohe Qualitaet"/Chatterbox) eingeblendet werden.
GPUS=auto
# --- Lokales STT (faster-whisper; nur mit pip install -e .[local] ) ---------
FASTER_WHISPER_MODEL=base # tiny|base|small|medium|large-v3
FASTER_WHISPER_DEVICE=auto # auto|cpu|cuda

View file

@ -14,6 +14,7 @@ import subprocess
from pathlib import Path
from app.config import settings
from app.capabilities import detect_physical_gpus
ALLOWED_BACKENDS = {"ollama", "llamacpp"}
_SCRIPT = Path(__file__).resolve().parent.parent / "scripts" / "llm-server" / "switch-llm.sh"
@ -74,28 +75,6 @@ async def _ollama_loaded() -> tuple[bool, list[dict]]:
return True, models
async def _gpus() -> list[dict]:
out = await _run([
"nvidia-smi",
"--query-gpu=index,memory.used,memory.total",
"--format=csv,noheader,nounits",
])
if out is None:
return []
gpus: list[dict] = []
for ln in out.splitlines():
parts = [p.strip() for p in ln.split(",")]
if len(parts) == 3 and parts[0].isdigit():
used, total = int(parts[1]), int(parts[2])
gpus.append({
"index": int(parts[0]),
"used_mib": used,
"total_mib": total,
"percent": round(used / total * 100) if total else 0,
})
return gpus
async def _gateway_service_active() -> bool:
out = await _run(["systemctl", "is-active", "voice-assistant.service"], timeout=4.0)
return bool(out and out.strip() == "active")
@ -108,7 +87,7 @@ async def llm_status() -> dict:
llamacpp, (ollama_reachable, ollama_models), gpus, gw = await asyncio.gather(
_llamacpp_running(),
_ollama_loaded(),
_gpus(),
detect_physical_gpus(),
_gateway_service_active(),
)
return {

View file

@ -1,7 +1,11 @@
import asyncio
import time
import httpx
from fastapi import APIRouter
from app.config import settings, active_profile
from app.capabilities import resolve_gpus
from app.providers.tts.cartesia import _UNSUPPORTED_LANGS as _CARTESIA_UNSUPPORTED
from app.dependencies import (
resolve_route,
@ -13,6 +17,13 @@ from app.dependencies import (
router = APIRouter()
# /api/config probt externe Dienste (u. a. einen KOSTENPFLICHTIGEN OpenRouter-TTS-Aufruf).
# Daher serverseitig kurz cachen: das Frontend ruft den Endpunkt mehrfach (Laden + Menü-Öffnen).
_CACHE: dict | None = None
_CACHE_TS: float = 0.0
_CACHE_TTL = 30.0
_CACHE_LOCK = asyncio.Lock()
async def _chatterbox_reachable() -> bool:
try:
@ -62,16 +73,31 @@ async def _openrouter_tts_available() -> bool:
return False
@router.get("/config")
async def get_config():
"""Zeigt aktives Profil, aufgeloeste Default-Route und verfuegbare Bausteine."""
async def _build_config() -> dict:
"""Baut die /api/config-Antwort (eine Quelle der Wahrheit für die Menü-Sichtbarkeit)."""
route = resolve_route()
audio_router = get_audio_router()
chatterbox_ok, openrouter_tts_ok, cartesia_ok = await _chatterbox_reachable(), await _openrouter_tts_available(), await _cartesia_available()
chatterbox_ok, openrouter_tts_ok, cartesia_ok, gpus = (
await _chatterbox_reachable(),
await _openrouter_tts_available(),
await _cartesia_available(),
await resolve_gpus(),
)
# Fertige Sichtbarkeits-Entscheidung je TTS-Provider -> Frontend dupliziert keine Regeln.
# "device" ist client_only: nur das Frontend kann "Mobilgerät?" beantworten (isMobile()).
# "piper" bleibt der garantierte, immer sichtbare Default (Menü nie leer).
tts_menu = [
{"provider": "piper", "visible": True, "client_only": False},
{"provider": "chatterbox", "visible": chatterbox_ok, "client_only": False},
{"provider": "cartesia", "visible": cartesia_ok, "client_only": False},
{"provider": "openrouter", "visible": openrouter_tts_ok, "client_only": False},
{"provider": "device", "visible": True, "client_only": True},
]
return {
"profile": active_profile(),
"app_env": settings.app_env,
"default_route": route.as_dict(),
"tts_menu": tts_menu,
"available": {
"stt_providers": sorted(STT_REGISTRY),
"llm_providers": sorted(LLM_REGISTRY),
@ -82,8 +108,23 @@ async def get_config():
"openrouter_tts_available": openrouter_tts_ok,
"cartesia_available": cartesia_ok,
"cartesia_unsupported_languages": sorted(_CARTESIA_UNSUPPORTED),
"gpus": gpus,
},
"secrets": {
"openrouter_api_key_set": bool(settings.openrouter_api_key.strip()),
},
}
@router.get("/config")
async def get_config():
"""Zeigt aktives Profil, aufgeloeste Default-Route und verfuegbare Bausteine (gecacht)."""
global _CACHE, _CACHE_TS
if _CACHE is not None and (time.monotonic() - _CACHE_TS) < _CACHE_TTL:
return _CACHE
async with _CACHE_LOCK:
if _CACHE is not None and (time.monotonic() - _CACHE_TS) < _CACHE_TTL:
return _CACHE
_CACHE = await _build_config()
_CACHE_TS = time.monotonic()
return _CACHE

80
app/capabilities.py Normal file
View file

@ -0,0 +1,80 @@
"""Laufzeit-Fähigkeiten der Hardware (GPU-Erkennung) — eine Quelle für Admin + /api/config.
Die GPU-Maske `settings.gpus` wird beim Start an `CUDA_VISIBLE_DEVICES` gekoppelt
(`app/config.py::_apply_gpu_visibility`). Hier wird nur **erkannt** und nach Maske
**aufgelöst**; CUDA_VISIBLE_DEVICES wird zusätzlich respektiert, damit angezeigt" =
real nutzbar" gilt.
"""
from __future__ import annotations
import asyncio
import os
import shutil
from app.config import settings, _parse_gpu_indices
async def detect_physical_gpus() -> list[dict]:
"""Physisch vorhandene NVIDIA-GPUs via nvidia-smi. [] wenn kein Tool/keine GPU/Fehler."""
if not shutil.which("nvidia-smi"):
return []
try:
proc = await asyncio.create_subprocess_exec(
"nvidia-smi",
"--query-gpu=index,name,memory.used,memory.total",
"--format=csv,noheader,nounits",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.DEVNULL,
)
out, _ = await asyncio.wait_for(proc.communicate(), timeout=6.0)
if proc.returncode != 0:
return []
except (asyncio.TimeoutError, OSError):
return []
gpus: list[dict] = []
for ln in out.decode("utf-8", "replace").splitlines():
parts = [p.strip() for p in ln.split(",")]
if len(parts) == 4 and parts[0].isdigit():
used, total = int(parts[2]), int(parts[3])
gpus.append({
"index": int(parts[0]),
"name": parts[1],
"used_mib": used,
"total_mib": total,
"percent": round(used / total * 100) if total else 0,
})
return gpus
def _cuda_visible_mask() -> set[int] | None:
"""Indizes aus CUDA_VISIBLE_DEVICES; None = keine Einschränkung, set() = explizit keine."""
raw = os.environ.get("CUDA_VISIBLE_DEVICES")
if raw is None:
return None
if raw.strip() == "":
return set() # leer = keine GPU für CUDA-Anwendungen
return set(_parse_gpu_indices(raw))
async def resolve_gpus() -> list[dict]:
"""Sichtbare/nutzbare GPUs = physisch ∩ settings.gpus-Maske ∩ CUDA_VISIBLE_DEVICES."""
mode = (settings.gpus or "auto").strip().lower()
if mode in ("none", "off", "false", "-1", ""):
return []
physical = await detect_physical_gpus()
explicit = None if mode == "auto" else set(_parse_gpu_indices(settings.gpus))
cuda = _cuda_visible_mask()
visible: list[dict] = []
for g in physical:
idx = g["index"]
if explicit is not None and idx not in explicit:
continue
if cuda is not None and idx not in cuda:
continue
visible.append(g)
return visible
async def gpu_available() -> bool:
return len(await resolve_gpus()) > 0

View file

@ -135,6 +135,11 @@ class Settings(BaseSettings):
faster_whisper_device: str = "auto" # auto|cpu|cuda
faster_whisper_compute_type: str = "int8" # default|int8|float16|int8_float16
stt_force_language: bool = True # STT mit fester Zielsprache (robust); False = Auto-Detect + Übersetzung
# GPU-Sichtbarkeit: "auto" = erkennen (respektiert vorhandenes CUDA_VISIBLE_DEVICES),
# "none"/leer = keine GPU erzwingen, "0,1,2" = nur diese Indizes. Wird beim Start an
# CUDA_VISIBLE_DEVICES gekoppelt (siehe _apply_gpu_visibility), damit das, was das Menü
# anzeigt, auch real von torch/faster-whisper genutzt wird.
gpus: str = "auto"
# --- Lokales TTS (piper) -------------------------------------------------
piper_bin: str = "piper" # Pfad/Name des piper-Binaries
piper_voices_dir: str = str(Path.home() / ".local" / "share" / "piper" / "voices")
@ -234,3 +239,29 @@ class Settings(BaseSettings):
settings = Settings()
def _parse_gpu_indices(value: str) -> list[int]:
"""'0,2' -> [0, 2]; leer/ungültig -> []."""
return sorted({int(t) for t in (value or "").split(",") if t.strip().isdigit()})
def _apply_gpu_visibility() -> None:
"""Koppelt settings.gpus an CUDA_VISIBLE_DEVICES, BEVOR torch/CUDA initialisiert.
- "auto": nichts erzwingen (ein bereits gesetztes CUDA_VISIBLE_DEVICES bleibt gültig).
- "none"/leer/"-1": CUDA_VISIBLE_DEVICES="" -> faster-whisper "auto" fällt auf CPU.
- "0,1,...": genau diese Indizes sichtbar machen (explizit gewinnt).
"""
mode = (settings.gpus or "auto").strip().lower()
if mode == "auto":
return
if mode in ("none", "off", "false", "-1", ""):
os.environ["CUDA_VISIBLE_DEVICES"] = ""
return
indices = _parse_gpu_indices(settings.gpus)
if indices:
os.environ["CUDA_VISIBLE_DEVICES"] = ",".join(str(i) for i in indices)
_apply_gpu_visibility()

View file

@ -84,8 +84,8 @@ function loadVoices() { _voices = TTS_SUPPORTED ? speechSynthesis.getVoices() :
if (TTS_SUPPORTED) {
loadVoices();
// Stimmen laden asynchron -> bei Eintreffen Preset-Zustand neu bewerten (entgrauen).
speechSynthesis.addEventListener("voiceschanged", () => { loadVoices(); refreshDevicePreset(); });
setTimeout(() => refreshDevicePreset(), 1500); // Backstop, falls kein voiceschanged kommt
speechSynthesis.addEventListener("voiceschanged", () => { loadVoices(); applyTtsVisibility(); });
setTimeout(() => applyTtsVisibility(), 1500); // Backstop, falls kein voiceschanged kommt
}
// Die Web Speech API kennt KEIN Standard-Geschlechtsfeld an einer Stimme -> Heuristik
@ -237,75 +237,61 @@ function applyPlaybackChoice(serverProvider) {
} else if (!ttsSel.value) {
ttsSel.value = "piper"; // Fallback: Server
}
refreshDevicePreset(); // graut „Im Gerät" aus, wenn keine Stimmen + syncPresetUI
applyTtsVisibility(); // blendet Provider passend ein/aus + syncPresetUI
}
// Graut „Im Gerät" aus, wenn keine Sprachstimmen vorhanden sind; entgraut automatisch,
// sobald welche da sind. Kein Aufzwingen: device wird nur (wieder) gewählt, wenn es die
// gespeicherte Präferenz ist. Ohne Stimmen NICHT auf der deaktivierten Option sitzen bleiben.
function refreshDevicePreset() {
const btn = document.querySelector('.tts-preset[data-tts="device"]');
if (!btn || !ttsSel) return;
const ready = deviceVoicesReady();
btn.disabled = !ready;
btn.classList.toggle("opacity-50", !ready);
btn.classList.toggle("cursor-not-allowed", !ready);
const sub = btn.querySelector(".tts-sub");
if (sub) sub.textContent = ready
// ── TTS-Menü: Sichtbarkeit der Provider (eine Quelle: Backend /api/config) ──
// Regeln liegen im Backend (tts_menu); hier nur rendern + die client-only-Regel
// „Im Gerät nur auf Mobilgeräten" (isMobile). Ausgeblendet, nicht ausgegraut.
let _ttsMenu = null; // zuletzt geladenes tts_menu von /api/config
const TTS_DEFAULT = "piper"; // garantiert sichtbarer Fallback -> Menü nie leer
function _ttsVisible(provider) {
if (provider === "device") return TTS_SUPPORTED && isMobile(); // nur mobil
if (provider === TTS_DEFAULT) return true; // garantierter Default
if (!_ttsMenu) return true; // vor dem Laden nichts verstecken
const e = _ttsMenu.find((m) => m.provider === provider);
return e ? e.visible !== false : true;
}
// Blendet Provider-Buttons (und die zugehörigen <option>) ein/aus und klemmt die
// aktive Auswahl auf einen sichtbaren Provider (sonst spielt nichts / Server-422).
function applyTtsVisibility() {
if (!ttsSel) return;
document.querySelectorAll(".tts-preset").forEach((btn) => {
const p = btn.dataset.tts;
const vis = _ttsVisible(p);
btn.classList.toggle("hidden", !vis);
const opt = ttsSel.querySelector(`option[value="${p}"]`);
if (opt) { opt.hidden = !vis; opt.disabled = !vis; }
});
const dsub = document.querySelector('.tts-preset[data-tts="device"] .tts-sub');
if (dsub) dsub.textContent = deviceVoicesReady()
? "Das Gerät liest vor · spart Daten"
: "keine Sprachstimmen im Browser";
if (ready) {
if (localStorage.getItem(PLAYBACK_KEY) === "device" && ttsSel.value !== "device") {
ttsSel.value = "device"; // gespeicherte Präferenz wieder aktivieren
: "Liest über den Server vor";
if (!_ttsVisible(ttsSel.value)) {
const wasServer = ttsSel.value && ttsSel.value !== "device";
ttsSel.value = TTS_DEFAULT;
if (wasServer) {
savePrefs({ tts_provider: TTS_DEFAULT }); // verhindert Server-422 beim nächsten Turn
if (statusEl) statusEl.textContent = "Stimme nicht verfügbar auf „Schnell“ umgestellt";
}
} else if (ttsSel.value === "device") {
ttsSel.value = "piper"; // Server statt deaktivierter Option (Präferenz bleibt)
} else if (localStorage.getItem(PLAYBACK_KEY) === "device"
&& _ttsVisible("device") && ttsSel.value !== "device") {
ttsSel.value = "device"; // gespeicherte Geräte-Präferenz wieder aktivieren
}
syncPresetUI();
updateCartesiaLangHint();
}
// Graut "Hohe Qualität" (Chatterbox) aus, wenn kein GPU-Backend erreichbar ist.
async function refreshChatterboxPreset() {
const btn = document.querySelector('.tts-preset[data-tts="chatterbox"]');
if (!btn || !ttsSel) return;
// Holt die Sichtbarkeiten einmal vom (gecachten) Backend und wendet sie an.
async function refreshTtsMenu() {
try {
const cfg = await fetch("/api/config", { cache: "no-store" }).then(r => r.json());
const available = cfg.available?.chatterbox_reachable === true;
btn.disabled = !available;
btn.classList.toggle("opacity-50", !available);
btn.classList.toggle("cursor-not-allowed", !available);
const sub = btn.querySelector(".tts-sub");
if (sub) sub.textContent = available
? "Chatterbox · natürliche Stimme"
: "Chatterbox · kein GPU-Backend erreichbar";
if (!available && ttsSel.value === "chatterbox") {
ttsSel.value = "piper";
syncPresetUI();
}
} catch (e) { /* ignorieren */ }
}
// Graut "Cartesia" aus, wenn API-Key oder Voice-ID fehlt.
async function refreshCartesiaPreset() {
const btn = document.querySelector('.tts-preset[data-tts="cartesia"]');
if (!btn || !ttsSel) return;
try {
const cfg = await fetch("/api/config", { cache: "no-store" }).then(r => r.json());
const available = cfg.available?.cartesia_available === true;
const cfg = await fetch("/api/config", { cache: "no-store" }).then((r) => r.json());
_ttsMenu = cfg.tts_menu || null;
cartesiaUnsupportedLangs = cfg.available?.cartesia_unsupported_languages || [];
btn.disabled = !available;
btn.classList.toggle("opacity-50", !available);
btn.classList.toggle("cursor-not-allowed", !available);
const sub = btn.querySelector(".tts-sub");
if (sub) sub.textContent = available
? "Cartesia Sonic · natürlich & schnell"
: "Cartesia · kein API-Key konfiguriert";
if (!available && ttsSel.value === "cartesia") {
ttsSel.value = "piper";
syncPresetUI();
}
updateCartesiaLangHint();
} catch (e) { /* ignorieren */ }
} catch (e) { /* offline -> nichts verstecken */ }
applyTtsVisibility();
}
// Zeigt einen Hinweis, wenn Cartesia gewählt ist, die feste Sprache aber nicht
@ -326,27 +312,6 @@ function updateCartesiaLangHint() {
}
}
// Graut "Cloud" (OpenRouter TTS) aus, wenn das TTS-Modell nicht verfügbar ist.
async function refreshOpenrouterTtsPreset() {
const btn = document.querySelector('.tts-preset[data-tts="openrouter"]');
if (!btn || !ttsSel) return;
try {
const cfg = await fetch("/api/config", { cache: "no-store" }).then(r => r.json());
const available = cfg.available?.openrouter_tts_available === true;
btn.disabled = !available;
btn.classList.toggle("opacity-50", !available);
btn.classList.toggle("cursor-not-allowed", !available);
const sub = btn.querySelector(".tts-sub");
if (sub) sub.textContent = available
? "OpenRouter · benötigt Internet"
: "OpenRouter · kein TTS-Modell verfügbar";
if (!available && ttsSel.value === "openrouter") {
ttsSel.value = "piper";
syncPresetUI();
}
} catch (e) { /* ignorieren */ }
}
// Markiert das aktive Ton-Preset (Häkchen + Rahmen) passend zu #tts.
function syncPresetUI() {
const val = ttsSel ? ttsSel.value : "";
@ -364,6 +329,7 @@ function syncPresetUI() {
function openMenu() {
$("#settings-sheet").classList.remove("hidden");
$("#settings-backdrop").classList.remove("hidden");
refreshTtsMenu(); // beim Öffnen neu bewerten (Backend kann zur Laufzeit gewechselt haben)
}
function closeMenu() {
$("#settings-sheet").classList.add("hidden");
@ -963,9 +929,7 @@ document.addEventListener("DOMContentLoaded", () => {
});
loadMe();
refreshChatterboxPreset();
refreshCartesiaPreset();
refreshOpenrouterTtsPreset();
refreshTtsMenu();
// ═══════════════════════════════════════════════════════════════
// ADMIN PANEL

117
tests/test_capabilities.py Normal file
View file

@ -0,0 +1,117 @@
"""GPU-Erkennung/-Maske und das daraus abgeleitete TTS-Menü in /api/config (offline)."""
import asyncio
import os
from fastapi.testclient import TestClient
import app.capabilities as cap
import app.api.config as cfgmod
from app.config import settings, _parse_gpu_indices, _apply_gpu_visibility
from app.main import app
client = TestClient(app)
def _gpus(*indices):
async def fake():
return [
{"index": i, "name": f"GPU{i}", "used_mib": 0, "total_mib": 1, "percent": 0}
for i in indices
]
return fake
# ── Maske/Parsing ────────────────────────────────────────────────────────────
def test_parse_gpu_indices():
assert _parse_gpu_indices("0, 2 ,1") == [0, 1, 2]
assert _parse_gpu_indices("") == []
assert _parse_gpu_indices("auto") == []
def test_apply_gpu_visibility_none(monkeypatch):
monkeypatch.setattr(settings, "gpus", "none")
monkeypatch.delenv("CUDA_VISIBLE_DEVICES", raising=False)
_apply_gpu_visibility()
assert os.environ["CUDA_VISIBLE_DEVICES"] == ""
def test_apply_gpu_visibility_explicit_sets_cuda(monkeypatch):
monkeypatch.setattr(settings, "gpus", "2,0")
monkeypatch.delenv("CUDA_VISIBLE_DEVICES", raising=False)
_apply_gpu_visibility()
assert os.environ["CUDA_VISIBLE_DEVICES"] == "0,2"
def test_apply_gpu_visibility_auto_keeps_existing(monkeypatch):
monkeypatch.setattr(settings, "gpus", "auto")
monkeypatch.setenv("CUDA_VISIBLE_DEVICES", "1")
_apply_gpu_visibility()
assert os.environ["CUDA_VISIBLE_DEVICES"] == "1" # unveraendert
# ── resolve_gpus ─────────────────────────────────────────────────────────────
def test_resolve_gpus_none(monkeypatch):
monkeypatch.setattr(settings, "gpus", "none")
assert asyncio.run(cap.resolve_gpus()) == []
def test_resolve_gpus_auto_all(monkeypatch):
monkeypatch.setattr(settings, "gpus", "auto")
monkeypatch.delenv("CUDA_VISIBLE_DEVICES", raising=False)
monkeypatch.setattr(cap, "detect_physical_gpus", _gpus(0, 1))
assert [g["index"] for g in asyncio.run(cap.resolve_gpus())] == [0, 1]
def test_resolve_gpus_explicit_subset(monkeypatch):
monkeypatch.setattr(settings, "gpus", "0,2")
monkeypatch.delenv("CUDA_VISIBLE_DEVICES", raising=False)
monkeypatch.setattr(cap, "detect_physical_gpus", _gpus(0, 1, 2))
assert [g["index"] for g in asyncio.run(cap.resolve_gpus())] == [0, 2]
def test_resolve_gpus_intersects_cuda_mask(monkeypatch):
monkeypatch.setattr(settings, "gpus", "auto")
monkeypatch.setenv("CUDA_VISIBLE_DEVICES", "1")
monkeypatch.setattr(cap, "detect_physical_gpus", _gpus(0, 1))
assert [g["index"] for g in asyncio.run(cap.resolve_gpus())] == [1]
# ── /api/config: Menü-Sichtbarkeit + gpus ────────────────────────────────────
def _patch_probes(monkeypatch, *, chatterbox, openrouter, cartesia, gpus):
async def _t():
return True
async def _f():
return False
async def _gpu():
return [{"index": i, "name": f"GPU{i}"} for i in gpus]
monkeypatch.setattr(cfgmod, "_chatterbox_reachable", _t if chatterbox else _f)
monkeypatch.setattr(cfgmod, "_openrouter_tts_available", _t if openrouter else _f)
monkeypatch.setattr(cfgmod, "_cartesia_available", _t if cartesia else _f)
monkeypatch.setattr(cfgmod, "resolve_gpus", _gpu)
monkeypatch.setattr(cfgmod, "_CACHE", None) # Cache je Test zuruecksetzen
monkeypatch.setattr(cfgmod, "_CACHE_TS", 0.0)
def test_config_tts_menu_hides_unavailable(monkeypatch):
_patch_probes(monkeypatch, chatterbox=False, openrouter=False, cartesia=True, gpus=[])
data = client.get("/api/config").json()
menu = {m["provider"]: m for m in data["tts_menu"]}
assert menu["piper"]["visible"] is True # garantierter Default
assert menu["chatterbox"]["visible"] is False # nicht erreichbar -> aus
assert menu["openrouter"]["visible"] is False # kein TTS-Modell -> aus
assert menu["cartesia"]["visible"] is True
assert menu["device"]["client_only"] is True # Mobil-Regel macht das Frontend
assert data["available"]["gpus"] == []
def test_config_reports_gpus(monkeypatch):
_patch_probes(monkeypatch, chatterbox=True, openrouter=True, cartesia=True, gpus=[0, 1])
data = client.get("/api/config").json()
assert [g["index"] for g in data["available"]["gpus"]] == [0, 1]
assert {m["provider"] for m in data["tts_menu"]} == {
"piper", "chatterbox", "cartesia", "openrouter", "device"
}