core: Inventar/Placement, Konfig-Validierung, CLI, HTTP-API, LiteLLM-Konfiguration, Beispiel-Skills
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from knaster.api import create_app
|
||||
|
||||
from .test_router import RECHNUNG
|
||||
|
||||
GOOD = json.dumps({"nummer": "4711", "betrag": 99})
|
||||
H = {"Authorization": "Bearer secret"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(make_system):
|
||||
sysm = make_system()
|
||||
sysm.chat.handlers.update({"tier1-general/rechnungen-v1": lambda m: '{"nummer": "1"}',
|
||||
"tier0-strong": lambda m: GOOD})
|
||||
return TestClient(create_app(sysm))
|
||||
|
||||
|
||||
def test_auth(client):
|
||||
assert client.post("/v1/route", json={"input": "x"}).status_code == 401
|
||||
assert client.get("/health").status_code == 200
|
||||
|
||||
|
||||
def test_route_and_run(client):
|
||||
r = client.post("/v1/route", json={"input": RECHNUNG}, headers=H).json()
|
||||
assert r["stage"] == 1 and r["adapter"] == "rechnungen-v1"
|
||||
r = client.post("/v1/run", json={"input": RECHNUNG}, headers=H).json()
|
||||
assert r["status"] == "ok" and r["stage"] == 0 and r["escalations"] == 1
|
||||
assert r["attempts"][0]["verification"]["passed"] is False
|
||||
assert client.post("/v1/route", json={"input": "x", "skill": "gibtsnicht"}, headers=H).status_code == 404
|
||||
|
||||
|
||||
def test_metrics(client):
|
||||
client.post("/v1/run", json={"input": RECHNUNG}, headers=H)
|
||||
text = client.get("/metrics").text
|
||||
assert 'knaster_runs_total{stage="0",status="ok"} 1.0' in text
|
||||
assert 'knaster_escalations_total{signal="verifier_failed"} 1.0' in text
|
||||
assert 'knaster_slot_available{slot="tier1-general"} 1.0' in text
|
||||
|
||||
|
||||
def test_registry_and_slot_state(client):
|
||||
reg = client.get("/v1/registry", headers=H).json()
|
||||
assert {s["name"] for s in reg["slots"]} >= {"tier1-general", "tier0-strong"}
|
||||
r = client.post("/v1/registry/slots/tier1-general/state", json={"status": "degraded", "reason": "x"},
|
||||
headers=H)
|
||||
assert r.json()["status"] == "degraded"
|
||||
r = client.post("/v1/registry/slots/tier1-general/state", json={"status": "ok", "set_by": "n8n"}, headers=H)
|
||||
assert "proposal" in r.json()
|
||||
assert client.post("/v1/registry/slots/nope/state", json={"status": "ok"}, headers=H).status_code == 404
|
||||
|
||||
|
||||
def test_maintenance_flow(client):
|
||||
r = client.post("/v1/maintenance/command", json={"text": "markiere tier1-flex als degraded", "user": "robert"},
|
||||
headers=H).json()
|
||||
pid = r["proposals"][0]["id"]
|
||||
seq = r["proposals"][0]["seq"]
|
||||
open_ = client.get("/v1/maintenance/proposals", headers=H).json()
|
||||
assert [p["id"] for p in open_] == [pid]
|
||||
r = client.post(f"/v1/maintenance/proposals/{seq}/approve", json={"user": "robert"}, headers=H).json()
|
||||
assert r["state"] == "executed"
|
||||
assert client.post(f"/v1/maintenance/proposals/{seq}/approve", json={}, headers=H).status_code == 409
|
||||
rep = client.post("/v1/maintenance/routine", headers=H).json()
|
||||
assert "Service-Routine" in rep["text"]
|
||||
|
||||
|
||||
def test_healer_endpoint(client):
|
||||
payload = {"version": "4", "status": "firing", "alerts": [
|
||||
{"status": "firing", "labels": {"alertname": "GpuTemperatureHigh", "slot": "tier1-flex"},
|
||||
"fingerprint": "abc"}]}
|
||||
r = client.post("/v1/healer/alertmanager", json=payload, headers=H).json()
|
||||
assert r["outcomes"][0]["status"] == "executed"
|
||||
reg = client.get("/v1/registry", headers=H).json()
|
||||
assert next(s for s in reg["slots"] if s["name"] == "tier1-flex")["status"] == "degraded"
|
||||
|
||||
|
||||
def test_candidate_via_api_needs_approval_then_reports(client):
|
||||
r = client.post("/v1/lifecycle/candidates", json={"kind": "adapter", "slot": "tier1-general",
|
||||
"target": {"cluster": "rechnungen"}}, headers=H).json()
|
||||
assert r["state"] == "open"
|
||||
client.post(f"/v1/maintenance/proposals/{r['seq']}/approve", json={}, headers=H)
|
||||
cands = client.get("/v1/lifecycle/candidates", headers=H).json()
|
||||
cid = cands[0]["id"]
|
||||
assert cands[0]["state"] == "building"
|
||||
r = client.post(f"/v1/lifecycle/candidates/{cid}/report", json={"step": "build", "payload": {"model": "rv2"}},
|
||||
headers=H).json()
|
||||
assert r["state"] == "evaluating"
|
||||
assert client.post(f"/v1/lifecycle/candidates/{cid}/report", json={"step": "canary"},
|
||||
headers=H).status_code == 409
|
||||
|
||||
|
||||
def test_graduation_endpoint(client):
|
||||
app_sys = client.app.state.system
|
||||
app_sys.chat.handlers["tier1-general/rechnungen-v1"] = lambda m: GOOD
|
||||
variants = [f"{RECHNUNG} Variante {i}" for i in range(10)]
|
||||
r = client.post("/v1/skills/rechnung-extrahieren/graduate", json={"variants": variants}, headers=H).json()
|
||||
assert r["report"]["graduated"] and "proposal" in r
|
||||
cases = app_sys.store.list_cases()
|
||||
assert len(cases) == 10 and all(c["origin"] == "graduation" for c in cases)
|
||||
|
||||
app_sys.chat.handlers["tier1-general/rechnungen-v1"] = lambda m: "kaputt"
|
||||
r = client.post("/v1/skills/rechnung-extrahieren/graduate", json={"variants": variants}, headers=H).json()
|
||||
assert not r["report"]["graduated"] and r["report"]["failures"]
|
||||
assert client.post("/v1/skills/nope/graduate", json={"variants": []}, headers=H).status_code == 404
|
||||
|
||||
|
||||
def test_reload_skills(client):
|
||||
assert client.post("/v1/skills/reload", headers=H).json()["skills"] == 5
|
||||
@@ -41,7 +41,7 @@ def make_runner(registry, library, embedder, store, fake_chat, classifiers):
|
||||
|
||||
|
||||
def test_weak_success_records_case(make_runner, store):
|
||||
runner = make_runner(**{"rechnungen-v1": lambda m: GOOD})
|
||||
runner = make_runner(**{"tier1-general/rechnungen-v1": lambda m: GOOD})
|
||||
res = runner.run(RECHNUNG)
|
||||
assert res.status == "ok" and res.stage == 1 and res.escalations == 0
|
||||
cases = store.list_cases()
|
||||
@@ -51,7 +51,7 @@ def test_weak_success_records_case(make_runner, store):
|
||||
|
||||
def test_schema_failure_escalates_and_creates_pair(make_runner, store):
|
||||
runner = make_runner(**{
|
||||
"rechnungen-v1": lambda m: json.dumps({"nummer": "4711"}), # betrag fehlt
|
||||
"tier1-general/rechnungen-v1": lambda m: json.dumps({"nummer": "4711"}), # betrag fehlt
|
||||
"tier0-strong": lambda m: GOOD,
|
||||
})
|
||||
res = runner.run(RECHNUNG)
|
||||
@@ -63,7 +63,7 @@ def test_schema_failure_escalates_and_creates_pair(make_runner, store):
|
||||
|
||||
|
||||
def test_invalid_json_signal(make_runner, store):
|
||||
runner = make_runner(**{"rechnungen-v1": lambda m: "kein json", "tier0-strong": lambda m: GOOD})
|
||||
runner = make_runner(**{"tier1-general/rechnungen-v1": lambda m: "kein json", "tier0-strong": lambda m: GOOD})
|
||||
res = runner.run(RECHNUNG)
|
||||
assert Signal.INVALID_FORMAT in res.attempts[0].execution.signals
|
||||
assert res.stage == 0
|
||||
@@ -74,7 +74,7 @@ def test_endpoint_error_uses_fallback_slot_without_case(make_runner, store):
|
||||
def boom(m):
|
||||
raise EndpointError("down")
|
||||
|
||||
runner = make_runner(**{"rechnungen-v1": boom})
|
||||
runner = make_runner(**{"tier1-general/rechnungen-v1": boom})
|
||||
# Fallback-Slot tier1-flex hat dieselbe Basis → gleicher Adapter-Name; daher Fehler nur einmal
|
||||
calls = {"n": 0}
|
||||
|
||||
@@ -84,7 +84,8 @@ def test_endpoint_error_uses_fallback_slot_without_case(make_runner, store):
|
||||
raise EndpointError("down")
|
||||
return GOOD
|
||||
|
||||
runner.executor.chat.handlers["rechnungen-v1"] = flaky
|
||||
runner.executor.chat.handlers["tier1-general/rechnungen-v1"] = flaky
|
||||
runner.executor.chat.handlers["tier1-flex/rechnungen-v1"] = lambda m: GOOD
|
||||
res = runner.run(RECHNUNG)
|
||||
assert res.status == "ok"
|
||||
assert [a.decision.slot for a in res.attempts] == ["tier1-general", "tier1-flex"]
|
||||
@@ -96,7 +97,7 @@ def test_context_overflow_detected(make_runner):
|
||||
def overflow(m):
|
||||
raise ContextOverflowError("maximum context length")
|
||||
|
||||
runner = make_runner(**{"rechnungen-v1": overflow, "tier0-strong": lambda m: GOOD})
|
||||
runner = make_runner(**{"tier1-general/rechnungen-v1": overflow, "tier0-strong": lambda m: GOOD})
|
||||
res = runner.run(RECHNUNG)
|
||||
assert res.attempts[0].execution.signals == [Signal.CONTEXT_OVERFLOW]
|
||||
assert res.status == "ok"
|
||||
@@ -105,7 +106,7 @@ def test_context_overflow_detected(make_runner):
|
||||
def test_truncated_and_degenerate(make_runner):
|
||||
loop = " ".join(["immer wieder das gleiche"] * 30)
|
||||
runner = make_runner(**{
|
||||
"rechnungen-v1": lambda m: ChatResult(content=loop, finish_reason="length"),
|
||||
"tier1-general/rechnungen-v1": lambda m: ChatResult(content=loop, finish_reason="length"),
|
||||
"tier0-strong": lambda m: GOOD,
|
||||
})
|
||||
res = runner.run(RECHNUNG)
|
||||
@@ -114,7 +115,7 @@ def test_truncated_and_degenerate(make_runner):
|
||||
|
||||
|
||||
def test_total_failure(make_runner):
|
||||
runner = make_runner(**{"rechnungen-v1": lambda m: "x", "tier0-strong": lambda m: "y"})
|
||||
runner = make_runner(**{"tier1-general/rechnungen-v1": lambda m: "x", "tier0-strong": lambda m: "y"})
|
||||
res = runner.run(RECHNUNG)
|
||||
assert res.status == "failed"
|
||||
|
||||
@@ -193,7 +194,7 @@ def test_split_is_deterministic():
|
||||
|
||||
|
||||
def test_exports(make_runner, store):
|
||||
runner = make_runner(**{"rechnungen-v1": lambda m: '{"nummer": "1"}', "tier0-strong": lambda m: GOOD})
|
||||
runner = make_runner(**{"tier1-general/rechnungen-v1": lambda m: '{"nummer": "1"}', "tier0-strong": lambda m: GOOD})
|
||||
for i in range(30):
|
||||
runner.run(f"{RECHNUNG} Nr {i}")
|
||||
train = list(export_sft(store, cluster="rechnungen", split="train"))
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import json
|
||||
|
||||
from knaster.cli import main
|
||||
from knaster.inventory.placement import load_catalog, load_nodes, plan
|
||||
from knaster.inventory.profile import NodeProfile, parse_lsblk, parse_lscpu, parse_meminfo, parse_nvidia_smi
|
||||
from knaster.settings import REPO_ROOT
|
||||
from knaster.validate import validate_repo
|
||||
|
||||
SMI = """0, NVIDIA RTX 4000 Ada Generation, 20475, 8.9, 570.86
|
||||
1, Quadro P2000, 5120, 6.1, 535.183
|
||||
"""
|
||||
LSCPU = """Architecture: x86_64
|
||||
CPU(s): 8
|
||||
Model name: Intel(R) Core(TM) i7-7700 CPU @ 3.60GHz
|
||||
Core(s) per socket: 4
|
||||
Socket(s): 1
|
||||
"""
|
||||
|
||||
|
||||
def test_parsers():
|
||||
gpus = parse_nvidia_smi(SMI)
|
||||
assert [(g.name, g.memory_gb, g.compute_cap) for g in gpus] == [
|
||||
("NVIDIA RTX 4000 Ada Generation", 20.0, 8.9), ("Quadro P2000", 5.0, 6.1)]
|
||||
assert parse_meminfo("MemTotal: 32768000 kB\nMemFree: 1 kB") == 31.2
|
||||
cpu = parse_lscpu(LSCPU)
|
||||
assert (cpu.cores, cpu.threads) == (4, 8) and "i7-7700" in cpu.model
|
||||
disks = parse_lsblk(json.dumps({"blockdevices": [{"name": "sda", "size": 256060514304, "rota": False}]}))
|
||||
assert disks[0].size_gb == 256.1 and not disks[0].rotational
|
||||
|
||||
|
||||
def test_placement_knaster():
|
||||
roles, models = load_catalog(REPO_ROOT / "catalog")
|
||||
p = plan(load_nodes(REPO_ROOT / "sites/knaster/inventory.yaml"), roles, models)
|
||||
by_role = {}
|
||||
for a in p.assignments:
|
||||
by_role.setdefault(a.role, []).append(a)
|
||||
assert by_role["gpu-main"][0].gpu_name.startswith("NVIDIA RTX 4000 Ada")
|
||||
assert "qwen3-14b-awq" in by_role["gpu-main"][0].models_fitting
|
||||
embed_nodes = {a.node for a in by_role["gpu-embed"]}
|
||||
assert len(embed_nodes) == 2 # Anti-Affinität
|
||||
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)
|
||||
|
||||
|
||||
def test_placement_other_cluster():
|
||||
roles, models = load_catalog(REPO_ROOT / "catalog")
|
||||
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
|
||||
|
||||
|
||||
def test_placement_maxwell_never_gets_vllm():
|
||||
roles, models = load_catalog(REPO_ROOT / "catalog")
|
||||
nodes = [NodeProfile(name="old", memory_gb=32, gpus=[
|
||||
{"index": 0, "name": "Quadro M6000", "memory_gb": 24, "compute_cap": 5.2}])]
|
||||
p = plan(nodes, roles, models)
|
||||
assert "gpu-main" in [u.role for u in p.unplaced]
|
||||
small = next(a for a in p.assignments if a.role == "gpu-embed" and a.mode == "gpu")
|
||||
assert small.node == "old"
|
||||
|
||||
|
||||
def test_repo_validates_without_errors():
|
||||
issues = validate_repo(REPO_ROOT)
|
||||
assert [i for i in issues if i[0] == "error"] == [], issues
|
||||
|
||||
|
||||
def test_validate_detects_problems(tmp_path):
|
||||
import shutil
|
||||
|
||||
root = tmp_path / "repo"
|
||||
for d in ("sites", "skills", "config", "catalog", "deploy"):
|
||||
shutil.copytree(REPO_ROOT / d, root / d)
|
||||
skill = root / "skills" / "zusammenfassung" / "SKILL.md"
|
||||
skill.write_text(skill.read_text().replace("slot: tier1-general", "slot: gibts-nicht"))
|
||||
lite = root / "deploy" / "litellm" / "config.yaml"
|
||||
lite.write_text(lite.read_text().replace("model_name: verifier-small", "model_name: verifier-x"))
|
||||
errors = [m for lvl, m in validate_repo(root) if lvl == "error"]
|
||||
assert any("gibts-nicht" in e for e in errors)
|
||||
assert any("verifier-small" in e and "LiteLLM" in e for e in errors)
|
||||
|
||||
|
||||
def test_cli(capsys):
|
||||
assert main(["validate", "--root", str(REPO_ROOT)]) == 0
|
||||
assert main(["placement", "--inventory", str(REPO_ROOT / "sites/knaster/inventory.yaml"),
|
||||
"--catalog", str(REPO_ROOT / "catalog")]) == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "gpu-main" in out
|
||||
@@ -96,9 +96,10 @@ def test_canary_metrics_from_cases(life, store):
|
||||
life.report(cid, "eval", {"pass_rate": 0.95, "n": 40})
|
||||
life.report(cid, "shadow", {"agreement": 0.9, "n": 20})
|
||||
for i in range(4):
|
||||
store.add_case(input=f"x{i}", stage=1, slot="tier1-general", model="rv2", passed=i < 3, split="train",
|
||||
store.add_case(input=f"x{i}", stage=1, slot="tier1-general", adapter="rv2",
|
||||
model="tier1-general/rv2", passed=i < 3, split="train",
|
||||
origin="route", ts=life.get(cid)["updated_at"] + 1)
|
||||
store.add_case(input="y", stage=1, slot="tier1-general", model="other", passed=False, split="train",
|
||||
store.add_case(input="y", stage=1, slot="tier1-general", adapter="other", passed=False, split="train",
|
||||
origin="route")
|
||||
assert life.canary_metrics(cid) == {"pass_rate": 0.75, "n": 4}
|
||||
|
||||
@@ -113,7 +114,7 @@ def test_router_uses_canary_model(registry, library, embedder):
|
||||
registry.set_canary("tier1-general", "rechnungen-v2", 1.0, "rechnungen")
|
||||
r = Router(registry, library, embedder, rng=random.Random(1))
|
||||
d = r.route(RECHNUNG)
|
||||
assert d.canary and d.model == d.adapter == "rechnungen-v2"
|
||||
assert d.canary and d.model == "tier1-general/rechnungen-v2" and d.adapter == "rechnungen-v2"
|
||||
registry.set_canary("tier1-general", "rechnungen-v2", 0.0, "rechnungen")
|
||||
assert not r.route(RECHNUNG).canary
|
||||
registry.set_adapter(AdapterSpec(name="x", base_model="base-14b", cluster="andere"))
|
||||
|
||||
@@ -190,7 +190,8 @@ def test_routine_evaluates_canary_and_proposes_promotion(make_system):
|
||||
life.report(cid, "eval", {"pass_rate": 0.95, "n": 40})
|
||||
life.report(cid, "shadow", {"agreement": 0.9, "n": 20})
|
||||
for i in range(6):
|
||||
sysm.store.add_case(input=f"c{i}", stage=1, slot="tier1-general", model="rv2", passed=True,
|
||||
sysm.store.add_case(input=f"c{i}", stage=1, slot="tier1-general", adapter="rv2",
|
||||
model="tier1-general/rv2", passed=True,
|
||||
split="train", origin="route")
|
||||
report = sysm.maintenance.run_routine()
|
||||
assert any("Canary" in a for a in report.auto_executed)
|
||||
|
||||
@@ -15,7 +15,7 @@ def test_routes_to_weak_slot_with_adapter(router):
|
||||
d = router.route(RECHNUNG)
|
||||
assert d.status == "routed"
|
||||
assert (d.stage, d.slot, d.skill) == (1, "tier1-general", "rechnung-extrahieren")
|
||||
assert d.model == d.adapter == "rechnungen-v1"
|
||||
assert d.model == "tier1-general/rechnungen-v1" and d.adapter == "rechnungen-v1"
|
||||
assert d.prompt_variant == "full"
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user