TTS-Slots (Piper CPU, Qwen3-TTS auf Ada), M4000 außer Betrieb, Engine-Grenzen im Placement (ADR-0006)
ci / test (push) Successful in 24s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 02:12:30 +02:00
co-authored by Claude Opus 5.5
parent 0533f224f1
commit f90b7df31c
25 changed files with 586 additions and 23 deletions
+1 -1
View File
@@ -165,7 +165,7 @@ def test_proxmox_site_consistent_with_registry_and_prometheus():
assert vm["driver_generation"] in {"legacy", "modern"}
gpus = [vm["gpu"] for vm in vms.values()]
assert len(gpus) == len(set(gpus)) # keine GPU doppelt durchgereicht
by_vmid = {str(vm["vmid"]): (name, vm) for name, vm in vms.items()}
by_vmid = {str(vm["vmid"]): (name, vm) for name, vm in {**vms, **site["containers"]}.items()}
for slot, spec in reg["slots"].items():
ops = spec.get("ops")
if not ops:
+32 -2
View File
@@ -41,7 +41,14 @@ def test_placement_knaster():
assert all("P2000" in a.gpu_name for a in by_role["gpu-embed"] + by_role["gpu-small"])
assert by_role["infra"][0].mode == "cpu"
assert not p.unplaced
assert len(p.free_gpus) == 4 and all("M4000" in g for g in p.free_gpus)
# M4000 bewusst außer Betrieb (ADR-0006): weder frei noch belegt
assert p.free_gpus == []
assert len(p.disabled_gpus) == 4 and all("M4000" in g for g in p.disabled_gpus)
tts = {a.role: a for a in p.assignments if a.role.startswith("tts")}
assert tts["tts-hq"].mode == "shared" and tts["tts-hq"].node == "pve-ada"
assert "qwen3-tts-1.7b" in tts["tts-hq"].models_fitting
assert tts["tts-cpu"].node == by_role["infra"][0].node
assert tts["tts-cpu"].models_fitting == ["piper-de-thorsten-medium"]
def test_placement_other_cluster():
@@ -49,7 +56,30 @@ def test_placement_other_cluster():
p = plan(load_nodes(REPO_ROOT / "sites/example-single-gpu/inventory.yaml"), roles, models)
modes = {(a.role, a.mode) for a in p.assignments}
assert ("gpu-main", "gpu") in modes and ("gpu-embed", "cpu_fallback") in modes
assert [u.role for u in p.unplaced] == ["gpu-flex"] and p.unplaced[0].optional
assert [u.role for u in p.unplaced] == ["gpu-flex", "tts-hq"] and all(u.optional for u in p.unplaced)
def test_pytorch_never_on_legacy_gpus():
"""Engine-Grenze: PyTorch-Modelle (TTS) landen nie auf Maxwell/Pascal, llama.cpp schon (ADR-0006)."""
from knaster.inventory.placement import RoleSpec
_, models = load_catalog(REPO_ROOT / "catalog")
assert {m.name: m.min_cc for m in models}["qwen3-tts-0.6b"] >= 7.5
role = RoleSpec(name="tts-gpu", needs={"gpu": True, "engine": "pytorch"}, kinds=["tts"], optional=True)
legacy = [NodeProfile(name="old", memory_gb=32, gpus=[
{"index": 0, "name": "Quadro M4000", "memory_gb": 8, "compute_cap": 5.2},
{"index": 1, "name": "Quadro P2000", "memory_gb": 5, "compute_cap": 6.1}])]
p = plan(legacy, [role], models)
assert [u.role for u in p.unplaced] == ["tts-gpu"]
def test_disabled_gpus_are_ignored():
roles, models = load_catalog(REPO_ROOT / "catalog")
nodes = [NodeProfile(name="n", memory_gb=32, gpus=[
{"index": 0, "name": "Quadro P2000", "memory_gb": 5, "compute_cap": 6.1, "disabled": True}])]
p = plan(nodes, roles, models)
assert all(a.mode != "gpu" for a in p.assignments)
assert p.disabled_gpus == ["n/gpu0 Quadro P2000"]
def test_placement_maxwell_never_gets_vllm():
+116
View File
@@ -0,0 +1,116 @@
import io
import json
import struct
import sys
import wave
import httpx
import pytest
from fastapi.testclient import TestClient
from knaster.audio import rtf, wav_duration
from knaster.bench import bench_tts
from knaster.canary import CanaryProber
from knaster.llm import OpenAIChatClient
from knaster.tts_server import CommandBackend, create_tts_app
def make_wav(seconds: float, rate: int = 22050) -> bytes:
buf = io.BytesIO()
with wave.open(buf, "wb") as w:
w.setnchannels(1)
w.setsampwidth(2)
w.setframerate(rate)
w.writeframes(struct.pack("<h", 0) * int(seconds * rate))
return buf.getvalue()
FAKE_PIPER = """
import sys, wave, struct
args = sys.argv[1:]
out = args[args.index("-f") + 1]
model = args[args.index("-m") + 1]
text = sys.stdin.read()
if "FEHLER" in text:
print("kaputt", file=sys.stderr); sys.exit(3)
with wave.open(out, "wb") as w:
w.setnchannels(1); w.setsampwidth(2); w.setframerate(16000)
w.writeframes(struct.pack("<h", 0) * (160 * len(text))) # 10 ms pro Zeichen
"""
@pytest.fixture
def backend(tmp_path):
script = tmp_path / "fake_piper.py"
script.write_text(FAKE_PIPER)
return CommandBackend({"thorsten": "/voices/t.onnx", "kerstin": "/voices/k.onnx"},
cmd=f"{sys.executable} {script} -m {{model}} -f {{out}}")
def test_wav_helpers():
assert wav_duration(make_wav(1.5)) == pytest.approx(1.5)
assert wav_duration(b"kein wav") == 0.0
assert rtf(3.0, 1.5) == 2.0 and rtf(1.0, 0) is None
def test_tts_server(backend):
client = TestClient(create_tts_app(backend, token="t"))
assert client.get("/health").json()["default"] == "thorsten"
H = {"Authorization": "Bearer t"}
r = client.post("/v1/audio/speech", json={"input": "Hallo Welt"}, headers=H)
assert r.status_code == 200 and r.headers["content-type"] == "audio/wav"
assert wav_duration(r.content) == pytest.approx(0.1)
assert client.post("/v1/audio/speech", json={"input": "x", "voice": "kerstin"}, headers=H).status_code == 200
assert client.post("/v1/audio/speech", json={"input": "x", "voice": "nope"}, headers=H).status_code == 400
assert client.post("/v1/audio/speech", json={"input": "FEHLER"}, headers=H).status_code == 502
assert client.post("/v1/audio/speech", json={"input": "x", "response_format": "mp3"},
headers=H).status_code == 400
assert client.post("/v1/audio/speech", json={"input": "x"}).status_code == 401
def test_backend_requires_voice():
with pytest.raises(ValueError):
CommandBackend({})
def tts_transport(seconds: float = 2.0, fail: bool = False):
def handler(request: httpx.Request) -> httpx.Response:
assert request.url.path == "/v1/audio/speech"
body = json.loads(request.content)
if fail or body["model"] == "kaputt":
return httpx.Response(503, text="down")
return httpx.Response(200, content=make_wav(seconds), headers={"content-type": "audio/wav"})
return httpx.Client(transport=httpx.MockTransport(handler))
def test_bench_tts():
client = OpenAIChatClient("http://x", client=tts_transport(2.0))
result = bench_tts("http://x", "tts-de", runs=2, client=client)
assert len(result["runs"]) == 2 and result["errors"] == []
assert result["runs"][0]["audio_seconds"] == 2.0 and result["rtf_median"] is not None
bad = bench_tts("http://x", "kaputt", runs=1, client=client)
assert bad["runs"] == [] and bad["errors"]
def test_canary_probes_tts_slots(registry):
from prometheus_client import CollectorRegistry
from knaster.registry import Registry
from knaster.store import Store
from .conftest import REGISTRY
data = {"slots": {**REGISTRY["slots"], "tts-de": {
"tier": 1, "device": "cpu", "engine": "piper", "litellm_model": "tts-de", "base_model": "piper",
"max_context": 5000, "kind": "tts"}}}
reg = Registry.from_dict(data, Store("sqlite://"))
chat = OpenAIChatClient("http://x", client=tts_transport(0.8))
prober = CanaryProber(reg, chat, metrics_registry=CollectorRegistry())
tts = next(prober.probe(s) for s in reg.slots() if s.name == "tts-de")
assert tts.up and tts.ok
silent = OpenAIChatClient("http://x", client=tts_transport(0.05))
assert not CanaryProber(reg, silent, metrics_registry=CollectorRegistry()).probe(reg.slot("tts-de")).ok
down = OpenAIChatClient("http://x", client=tts_transport(fail=True))
assert not CanaryProber(reg, down, metrics_registry=CollectorRegistry()).probe(reg.slot("tts-de")).up