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

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