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,204 @@
|
||||
"""HTTP-API des Router-Service (SPEC-core §8)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import Depends, FastAPI, Header, HTTPException, Response
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .graduation import graduate
|
||||
from .lifecycle import LifecycleError
|
||||
from .settings import Settings
|
||||
from .system import System, build_system
|
||||
|
||||
|
||||
class RouteIn(BaseModel):
|
||||
input: str
|
||||
skill: str | None = None
|
||||
|
||||
|
||||
class SlotStateIn(BaseModel):
|
||||
status: str = Field(pattern="^(ok|degraded|unavailable)$")
|
||||
reason: str = ""
|
||||
set_by: str = "human"
|
||||
|
||||
|
||||
class CommandIn(BaseModel):
|
||||
text: str
|
||||
user: str = "human"
|
||||
session: str = "default"
|
||||
|
||||
|
||||
class DecisionIn(BaseModel):
|
||||
user: str = "human"
|
||||
reason: str = ""
|
||||
|
||||
|
||||
class CandidateIn(BaseModel):
|
||||
kind: str = Field(pattern="^(base_model|adapter|quantization|embedding_model|engine)$")
|
||||
slot: str
|
||||
target: dict[str, Any]
|
||||
requested_by: str = "api"
|
||||
|
||||
|
||||
class ReportIn(BaseModel):
|
||||
step: str
|
||||
payload: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class GraduateIn(BaseModel):
|
||||
variants: list[str]
|
||||
threshold: float = 0.9
|
||||
min_variants: int = 10
|
||||
|
||||
|
||||
def create_app(system: System | None = None) -> FastAPI:
|
||||
system = system or build_system(Settings.from_env())
|
||||
app = FastAPI(title="knaster", version="0.1.0")
|
||||
app.state.system = system
|
||||
|
||||
def auth(authorization: str | None = Header(default=None)) -> None:
|
||||
token = system.settings.api_token
|
||||
if token and authorization != f"Bearer {token}":
|
||||
raise HTTPException(status_code=401, detail="unauthorized")
|
||||
|
||||
v1 = [Depends(auth)]
|
||||
|
||||
@app.get("/health")
|
||||
def health() -> dict[str, Any]:
|
||||
return {"status": "ok", "skills": len(system.library.all()), "dry_run": system.settings.dry_run}
|
||||
|
||||
@app.get("/metrics")
|
||||
def metrics() -> Response:
|
||||
for slot in system.registry.slots():
|
||||
system.metrics.slot_status.labels(slot.name).set(1 if slot.available else 0)
|
||||
system.metrics.open_proposals.set(len(system.maintenance.proposals("open")))
|
||||
return Response(system.metrics.render(), media_type="text/plain; version=0.0.4")
|
||||
|
||||
# --- Routing ----------------------------------------------------------------------------
|
||||
@app.post("/v1/route", dependencies=v1)
|
||||
def route(body: RouteIn) -> dict[str, Any]:
|
||||
try:
|
||||
return system.router.route(body.input, hint_skill=body.skill).model_dump(mode="json")
|
||||
except KeyError as exc:
|
||||
raise HTTPException(404, str(exc)) from exc
|
||||
|
||||
@app.post("/v1/run", dependencies=v1)
|
||||
def run(body: RouteIn) -> dict[str, Any]:
|
||||
try:
|
||||
result = system.runner.run(body.input, hint_skill=body.skill)
|
||||
except KeyError as exc:
|
||||
raise HTTPException(404, str(exc)) from exc
|
||||
data = result.model_dump(mode="json")
|
||||
data["escalations"] = result.escalations
|
||||
return data
|
||||
|
||||
# --- Registry ---------------------------------------------------------------------------
|
||||
@app.get("/v1/registry", dependencies=v1)
|
||||
def registry() -> dict[str, Any]:
|
||||
return {
|
||||
"slots": [{**s.spec.model_dump(), "status": s.status, "reason": s.reason, "set_by": s.set_by,
|
||||
"effective_context": s.effective_context, "base_model": s.base_model,
|
||||
"canary_model": s.canary_model, "canary_share": s.canary_share}
|
||||
for s in system.registry.slots()],
|
||||
"adapters": [a.model_dump() for a in system.registry.adapters()],
|
||||
"skills": [{"name": s.name, "status": s.meta.status, "stage": s.meta.stage, "slot": s.meta.slot,
|
||||
"cluster": s.cluster, "version": s.meta.version} for s in system.library.all()],
|
||||
}
|
||||
|
||||
@app.post("/v1/registry/slots/{slot}/state", dependencies=v1)
|
||||
def slot_state(slot: str, body: SlotStateIn) -> dict[str, Any]:
|
||||
if not system.registry.has_slot(slot):
|
||||
raise HTTPException(404, f"Slot {slot} unbekannt")
|
||||
if body.status == "ok" and body.set_by != "human":
|
||||
# Wieder-Freischalten nur über Vorschlag/Freigabe, außer ein Mensch setzt es direkt
|
||||
p = system.maintenance.propose("mark_slot", {"slot": slot, "status": "ok", "reason": body.reason},
|
||||
f"Slot {slot} wieder auf ok setzen", source="api")
|
||||
return {"proposal": p.id}
|
||||
system.registry.set_status(slot, body.status, reason=body.reason, set_by=body.set_by) # type: ignore
|
||||
return {"slot": slot, "status": body.status}
|
||||
|
||||
# --- Pflege -----------------------------------------------------------------------------
|
||||
@app.post("/v1/maintenance/routine", dependencies=v1)
|
||||
def routine() -> dict[str, Any]:
|
||||
report = system.maintenance.run_routine()
|
||||
return {**report.model_dump(mode="json"), "proposal_ids": [p.id for p in report.proposals]}
|
||||
|
||||
@app.post("/v1/maintenance/command", dependencies=v1)
|
||||
def command(body: CommandIn) -> dict[str, Any]:
|
||||
reply = system.maintenance.handle_command(body.text, user=body.user, session=body.session)
|
||||
return {"text": reply.text, "proposals": [{**p.model_dump(mode="json"), "id": p.id}
|
||||
for p in reply.proposals]}
|
||||
|
||||
@app.get("/v1/maintenance/proposals", dependencies=v1)
|
||||
def proposals(state: str | None = "open") -> list[dict[str, Any]]:
|
||||
return [{**p.model_dump(mode="json"), "id": p.id} for p in system.maintenance.proposals(state)]
|
||||
|
||||
@app.post("/v1/maintenance/proposals/{seq}/{decision}", dependencies=v1)
|
||||
def decide(seq: int, decision: str, body: DecisionIn) -> dict[str, Any]:
|
||||
try:
|
||||
if decision == "approve":
|
||||
p = system.maintenance.approve(seq, by=body.user)
|
||||
elif decision == "reject":
|
||||
p = system.maintenance.reject(seq, by=body.user, reason=body.reason)
|
||||
else:
|
||||
raise HTTPException(404, "approve oder reject")
|
||||
except KeyError as exc:
|
||||
raise HTTPException(404, str(exc)) from exc
|
||||
except ValueError as exc:
|
||||
raise HTTPException(409, str(exc)) from exc
|
||||
return {**p.model_dump(mode="json"), "id": p.id}
|
||||
|
||||
# --- Healer -----------------------------------------------------------------------------
|
||||
@app.post("/v1/healer/alertmanager", dependencies=v1)
|
||||
def alertmanager(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
outcomes = system.healer.handle_alertmanager(payload)
|
||||
return {"outcomes": [o.model_dump() for o in outcomes]}
|
||||
|
||||
# --- Lifecycle --------------------------------------------------------------------------
|
||||
@app.get("/v1/lifecycle/candidates", dependencies=v1)
|
||||
def candidates(state: str | None = None) -> list[dict[str, Any]]:
|
||||
return system.store.list_candidates(state=state)
|
||||
|
||||
@app.post("/v1/lifecycle/candidates", dependencies=v1)
|
||||
def new_candidate(body: CandidateIn) -> dict[str, Any]:
|
||||
if not system.registry.has_slot(body.slot):
|
||||
raise HTTPException(404, f"Slot {body.slot} unbekannt")
|
||||
p = system.maintenance.propose("create_candidate",
|
||||
{"kind": body.kind, "slot": body.slot, "target": body.target},
|
||||
f"Kandidat {body.kind} für {body.slot}: {body.target}",
|
||||
reason=f"angefragt von {body.requested_by}", source="api")
|
||||
return {**p.model_dump(mode="json"), "id": p.id}
|
||||
|
||||
@app.post("/v1/lifecycle/candidates/{cid}/report", dependencies=v1)
|
||||
def report(cid: str, body: ReportIn) -> dict[str, Any]:
|
||||
try:
|
||||
return system.lifecycle.report(cid, body.step, body.payload)
|
||||
except LifecycleError as exc:
|
||||
raise HTTPException(409, str(exc)) from exc
|
||||
|
||||
# --- Skills -----------------------------------------------------------------------------
|
||||
@app.post("/v1/skills/{name}/graduate", dependencies=v1)
|
||||
def graduate_skill(name: str, body: GraduateIn) -> dict[str, Any]:
|
||||
if not system.library.has(name):
|
||||
raise HTTPException(404, f"Skill {name} unbekannt")
|
||||
rep = graduate(name, body.variants, router=system.router, executor=system.executor,
|
||||
verifier=system.verifier, library=system.library, store=system.store,
|
||||
threshold=body.threshold, min_variants=body.min_variants)
|
||||
out: dict[str, Any] = {"report": rep.model_dump()}
|
||||
if rep.graduated:
|
||||
p = system.maintenance.propose("promote_skill", {"skill": name, "stage": 1},
|
||||
f"Skill {name} für Stufe 1 freigeben",
|
||||
reason=f"Graduierung {rep.passed_n}/{rep.n} ({rep.pass_rate:.0%})",
|
||||
source="graduation")
|
||||
out["proposal"] = p.id
|
||||
return out
|
||||
|
||||
@app.post("/v1/skills/reload", dependencies=v1)
|
||||
def reload_skills() -> dict[str, Any]:
|
||||
system.library.reload(system.settings.skills_dir)
|
||||
system.router.refresh()
|
||||
return {"skills": len(system.library.all())}
|
||||
|
||||
return app
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Kommandozeile: serve, validate, inventory, placement, registry export."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(prog="knaster")
|
||||
sub = parser.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
p_serve = sub.add_parser("serve", help="Router-Service starten")
|
||||
p_serve.add_argument("--host", default="0.0.0.0")
|
||||
p_serve.add_argument("--port", type=int, default=8080)
|
||||
|
||||
p_val = sub.add_parser("validate", help="Konfiguration prüfen")
|
||||
p_val.add_argument("--root", default=".")
|
||||
p_val.add_argument("--site", default="knaster")
|
||||
|
||||
p_inv = sub.add_parser("inventory", help="Inventar")
|
||||
inv_sub = p_inv.add_subparsers(dest="inv_cmd", required=True)
|
||||
p_col = inv_sub.add_parser("collect", help="lokales System auslesen")
|
||||
p_col.add_argument("--name")
|
||||
|
||||
p_plan = sub.add_parser("placement", help="Placement-Plan")
|
||||
p_plan.add_argument("--inventory", required=True)
|
||||
p_plan.add_argument("--catalog", default="catalog")
|
||||
|
||||
p_reg = sub.add_parser("registry", help="Registry")
|
||||
reg_sub = p_reg.add_subparsers(dest="reg_cmd", required=True)
|
||||
reg_sub.add_parser("export", help="effektiven Stand (inkl. Laufzeitzustand) als YAML ausgeben")
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.cmd == "serve":
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run("knaster.api:create_app", factory=True, host=args.host, port=args.port)
|
||||
return 0
|
||||
|
||||
if args.cmd == "validate":
|
||||
from .validate import validate_repo
|
||||
|
||||
issues = validate_repo(Path(args.root), args.site)
|
||||
for level, msg in issues:
|
||||
print(f"{level.upper():8} {msg}")
|
||||
errors = [i for i in issues if i[0] == "error"]
|
||||
print(f"{len(errors)} Fehler, {len(issues) - len(errors)} Warnungen")
|
||||
return 1 if errors else 0
|
||||
|
||||
if args.cmd == "inventory":
|
||||
from .inventory.profile import collect
|
||||
|
||||
print(yaml.safe_dump({"nodes": [collect(args.name).model_dump()]}, sort_keys=False, allow_unicode=True))
|
||||
return 0
|
||||
|
||||
if args.cmd == "placement":
|
||||
from .inventory.placement import load_catalog, load_nodes, plan
|
||||
|
||||
roles, models = load_catalog(args.catalog)
|
||||
result = plan(load_nodes(args.inventory), roles, models)
|
||||
print(yaml.safe_dump(result.model_dump(), sort_keys=False, allow_unicode=True))
|
||||
return 1 if any(not u.optional for u in result.unplaced) else 0
|
||||
|
||||
if args.cmd == "registry":
|
||||
from .registry import Registry
|
||||
from .settings import Settings
|
||||
from .store import Store
|
||||
|
||||
settings = Settings.from_env()
|
||||
registry = Registry.from_file(settings.site_dir / "registry.yaml", Store(settings.db_url))
|
||||
print(yaml.safe_dump(registry.export(), sort_keys=False, allow_unicode=True))
|
||||
return 0
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1 @@
|
||||
"""Inventar, Modell-Katalog und Placement (Übertragbarkeit auf andere Cluster)."""
|
||||
@@ -0,0 +1,153 @@
|
||||
"""Deterministisches Placement: Rollen → GPUs/Nodes anhand von Katalog-Anforderungen."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ..util import load_yaml
|
||||
from .profile import GPU, NodeProfile
|
||||
|
||||
|
||||
class ModelSpec(BaseModel):
|
||||
name: str
|
||||
kind: Literal["llm", "embedding", "reranker"]
|
||||
vram_gb: float
|
||||
min_cc: float = 5.0
|
||||
engines: list[str]
|
||||
context: int = 8192
|
||||
lora: bool = False
|
||||
cpu_ok: bool = False
|
||||
note: str = ""
|
||||
|
||||
|
||||
class Needs(BaseModel):
|
||||
gpu: bool = True
|
||||
engine: str | None = None
|
||||
min_vram_gb: float = 0
|
||||
min_cc: float = 0
|
||||
max_cc: float | None = None
|
||||
min_ram_gb: float = 0
|
||||
|
||||
|
||||
class RoleSpec(BaseModel):
|
||||
name: str
|
||||
priority: int = 100
|
||||
count: int = 1
|
||||
needs: Needs = Field(default_factory=Needs)
|
||||
prefer: Literal["largest", "smallest_fitting"] = "largest"
|
||||
kinds: list[str] = Field(default_factory=list)
|
||||
cpu_fallback: bool = False
|
||||
optional: bool = False
|
||||
slots: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class Assignment(BaseModel):
|
||||
role: str
|
||||
node: str
|
||||
gpu_index: int | None = None
|
||||
gpu_name: str | None = None
|
||||
vram_gb: float | None = None
|
||||
mode: Literal["gpu", "cpu", "cpu_fallback"]
|
||||
models_fitting: list[str] = Field(default_factory=list)
|
||||
slots: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class Unplaced(BaseModel):
|
||||
role: str
|
||||
reason: str
|
||||
optional: bool = False
|
||||
|
||||
|
||||
class PlacementPlan(BaseModel):
|
||||
assignments: list[Assignment] = Field(default_factory=list)
|
||||
unplaced: list[Unplaced] = Field(default_factory=list)
|
||||
free_gpus: list[str] = Field(default_factory=list)
|
||||
|
||||
def roles_on(self, node: str) -> list[str]:
|
||||
return [a.role for a in self.assignments if a.node == node]
|
||||
|
||||
|
||||
def load_catalog(directory: str | Path) -> tuple[list[RoleSpec], list[ModelSpec]]:
|
||||
d = Path(directory)
|
||||
roles = [RoleSpec(name=k, **v) for k, v in (load_yaml(d / "roles.yaml").get("roles") or {}).items()]
|
||||
models = [ModelSpec(name=k, **v) for k, v in (load_yaml(d / "models.yaml").get("models") or {}).items()]
|
||||
return roles, models
|
||||
|
||||
|
||||
def load_nodes(path: str | Path) -> list[NodeProfile]:
|
||||
return [NodeProfile(**n) for n in load_yaml(path).get("nodes") or []]
|
||||
|
||||
|
||||
def _gpu_ok(gpu: GPU, needs: Needs) -> bool:
|
||||
if gpu.memory_gb < needs.min_vram_gb or gpu.compute_cap < needs.min_cc:
|
||||
return False
|
||||
return needs.max_cc is None or gpu.compute_cap <= needs.max_cc
|
||||
|
||||
|
||||
def _fitting_models(role: RoleSpec, models: list[ModelSpec], gpu: GPU | None) -> list[str]:
|
||||
out = []
|
||||
for m in models:
|
||||
if m.kind not in role.kinds:
|
||||
continue
|
||||
if gpu is None:
|
||||
if m.cpu_ok:
|
||||
out.append(m.name)
|
||||
continue
|
||||
if role.needs.engine and role.needs.engine not in m.engines:
|
||||
continue
|
||||
if m.vram_gb <= gpu.memory_gb and m.min_cc <= gpu.compute_cap:
|
||||
out.append(m.name)
|
||||
return out
|
||||
|
||||
|
||||
def plan(nodes: list[NodeProfile], roles: list[RoleSpec], models: list[ModelSpec]) -> PlacementPlan:
|
||||
result = PlacementPlan()
|
||||
free: list[tuple[NodeProfile, GPU]] = [(n, g) for n in nodes for g in n.gpus]
|
||||
cpu_load: dict[str, int] = {n.name: 0 for n in nodes}
|
||||
|
||||
for role in sorted(roles, key=lambda r: (r.priority, r.name)):
|
||||
placed = 0
|
||||
if role.needs.gpu:
|
||||
used_nodes: set[str] = set()
|
||||
for _ in range(role.count):
|
||||
options = [(n, g) for n, g in free if _gpu_ok(g, role.needs)
|
||||
and _fitting_models(role, models, g)]
|
||||
if not options:
|
||||
break
|
||||
# Anti-Affinität: Replikate einer Rolle möglichst auf verschiedene Hosts
|
||||
if role.prefer == "largest":
|
||||
options.sort(key=lambda x: (x[0].name in used_nodes, -x[1].memory_gb, -x[1].compute_cap,
|
||||
x[0].name, x[1].index))
|
||||
else:
|
||||
options.sort(key=lambda x: (x[0].name in used_nodes, x[1].memory_gb, -x[1].compute_cap,
|
||||
x[0].name, x[1].index))
|
||||
node, gpu = options[0]
|
||||
free.remove((node, gpu))
|
||||
used_nodes.add(node.name)
|
||||
result.assignments.append(Assignment(
|
||||
role=role.name, node=node.name, gpu_index=gpu.index, gpu_name=gpu.name,
|
||||
vram_gb=gpu.memory_gb, mode="gpu", models_fitting=_fitting_models(role, models, gpu),
|
||||
slots=role.slots))
|
||||
placed += 1
|
||||
missing = role.count - placed
|
||||
if missing and (not role.needs.gpu or role.cpu_fallback):
|
||||
mode: Literal["cpu", "cpu_fallback"] = "cpu" if not role.needs.gpu else "cpu_fallback"
|
||||
candidates = [n for n in nodes if n.memory_gb >= role.needs.min_ram_gb]
|
||||
# bevorzugt Nodes ohne GPU-Rollen, dann wenig belastete, dann viel RAM
|
||||
gpu_roles = {a.node for a in result.assignments if a.mode == "gpu"}
|
||||
candidates.sort(key=lambda n: (n.name in gpu_roles, cpu_load[n.name], -n.memory_gb, n.name))
|
||||
for node in candidates[:missing]:
|
||||
cpu_load[node.name] += 1
|
||||
result.assignments.append(Assignment(role=role.name, node=node.name, mode=mode,
|
||||
models_fitting=_fitting_models(role, models, None),
|
||||
slots=role.slots))
|
||||
missing -= 1
|
||||
if missing:
|
||||
reason = "keine passende GPU frei" if role.needs.gpu else "kein Node mit genug RAM"
|
||||
result.unplaced.append(Unplaced(role=role.name, reason=f"{reason} ({missing} fehlen)",
|
||||
optional=role.optional))
|
||||
result.free_gpus = [f"{n.name}/gpu{g.index} {g.name} ({g.memory_gb} GB)" for n, g in free]
|
||||
return result
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Node-Profile und Parser für Systemausgaben (reine Funktionen, testbar)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class GPU(BaseModel):
|
||||
index: int
|
||||
name: str
|
||||
memory_gb: float
|
||||
compute_cap: float
|
||||
driver: str | None = None
|
||||
|
||||
|
||||
class Disk(BaseModel):
|
||||
name: str
|
||||
size_gb: float
|
||||
rotational: bool = False
|
||||
|
||||
|
||||
class CPU(BaseModel):
|
||||
model: str = ""
|
||||
cores: int = 0
|
||||
threads: int = 0
|
||||
|
||||
|
||||
class NodeProfile(BaseModel):
|
||||
name: str
|
||||
cpu: CPU = Field(default_factory=CPU)
|
||||
memory_gb: float = 0
|
||||
disks: list[Disk] = Field(default_factory=list)
|
||||
gpus: list[GPU] = Field(default_factory=list)
|
||||
labels: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
|
||||
NVIDIA_SMI_QUERY = ["nvidia-smi", "--query-gpu=index,name,memory.total,compute_cap,driver_version",
|
||||
"--format=csv,noheader,nounits"]
|
||||
|
||||
|
||||
def parse_nvidia_smi(text: str) -> list[GPU]:
|
||||
gpus = []
|
||||
for row in csv.reader(io.StringIO(text.strip())):
|
||||
if not row or not row[0].strip():
|
||||
continue
|
||||
idx, name, mem_mib, cc, driver = (c.strip() for c in row[:5])
|
||||
gpus.append(GPU(index=int(idx), name=name, memory_gb=round(float(mem_mib) / 1024, 1),
|
||||
compute_cap=float(cc), driver=driver))
|
||||
return gpus
|
||||
|
||||
|
||||
def parse_meminfo(text: str) -> float:
|
||||
m = re.search(r"^MemTotal:\s+(\d+)\s+kB", text, re.MULTILINE)
|
||||
return round(int(m.group(1)) / 1024 / 1024, 1) if m else 0.0
|
||||
|
||||
|
||||
def parse_lscpu(text: str) -> CPU:
|
||||
fields = {}
|
||||
for line in text.splitlines():
|
||||
if ":" in line:
|
||||
k, v = line.split(":", 1)
|
||||
fields[k.strip()] = v.strip()
|
||||
threads = int(fields.get("CPU(s)", "0") or 0)
|
||||
cores = int(fields.get("Core(s) per socket", "0") or 0) * int(fields.get("Socket(s)", "1") or 1)
|
||||
return CPU(model=fields.get("Model name", ""), cores=cores, threads=threads)
|
||||
|
||||
|
||||
def parse_lsblk(text: str) -> list[Disk]:
|
||||
data = json.loads(text)
|
||||
disks = []
|
||||
for dev in data.get("blockdevices", []):
|
||||
rota = dev.get("rota")
|
||||
disks.append(Disk(name=dev["name"], size_gb=round(int(dev["size"]) / 1e9, 1),
|
||||
rotational=rota in (True, "1", 1)))
|
||||
return disks
|
||||
|
||||
|
||||
def _run(cmd: list[str]) -> str | None:
|
||||
if shutil.which(cmd[0]) is None:
|
||||
return None
|
||||
try:
|
||||
return subprocess.run(cmd, capture_output=True, text=True, timeout=20, check=True).stdout
|
||||
except (subprocess.SubprocessError, OSError):
|
||||
return None
|
||||
|
||||
|
||||
def collect(name: str | None = None) -> NodeProfile:
|
||||
"""Liest das lokale System aus (Linux). Fehlende Werkzeuge ergeben leere Felder."""
|
||||
smi = _run(NVIDIA_SMI_QUERY)
|
||||
lscpu = _run(["lscpu"])
|
||||
lsblk = _run(["lsblk", "-J", "-b", "-d", "-o", "NAME,SIZE,ROTA"])
|
||||
meminfo = Path("/proc/meminfo").read_text() if Path("/proc/meminfo").exists() else ""
|
||||
return NodeProfile(
|
||||
name=name or socket.gethostname(),
|
||||
cpu=parse_lscpu(lscpu) if lscpu else CPU(),
|
||||
memory_gb=parse_meminfo(meminfo),
|
||||
disks=parse_lsblk(lsblk) if lsblk else [],
|
||||
gpus=parse_nvidia_smi(smi) if smi else [],
|
||||
)
|
||||
@@ -176,7 +176,8 @@ class Lifecycle:
|
||||
cand = self.get(cid)
|
||||
model = cand["target"].get("model")
|
||||
since = next((h["ts"] for h in cand.get("history") or [] if h["to"] == "canary"), None)
|
||||
rows = [r for r in self.store.list_cases(stage=1, since=since) if r["model"] == model]
|
||||
column = "adapter" if cand["kind"] == "adapter" else "model"
|
||||
rows = [r for r in self.store.list_cases(stage=1, since=since) if r[column] == model]
|
||||
return {"pass_rate": pass_rate(rows), "n": len([r for r in rows if r["passed"] is not None])}
|
||||
|
||||
# --- Promote / Rollback -----------------------------------------------------------------
|
||||
|
||||
@@ -46,6 +46,11 @@ class RouterConfig:
|
||||
default_max_output_tokens: int = 1024
|
||||
|
||||
|
||||
def adapter_model(slot_alias: str, adapter: str) -> str:
|
||||
"""LiteLLM-Name eines Adapters: '<slot>/<adapter>' (Wildcard-Route auf den vLLM des Slots)."""
|
||||
return f"{slot_alias}/{adapter}"
|
||||
|
||||
|
||||
def system_prompt(skill: Skill | None, variant: PromptVariant) -> str:
|
||||
parts: list[str] = []
|
||||
if skill is not None and variant != "none":
|
||||
@@ -211,15 +216,16 @@ class Router:
|
||||
if slot.spec.supports_lora and skill.meta.adapter == "auto":
|
||||
found = self.registry.active_adapter(slot.base_model, skill.cluster)
|
||||
if found:
|
||||
model, adapter = found.name, found.name
|
||||
model, adapter = adapter_model(slot.spec.litellm_model, found.name), found.name
|
||||
reasons.append(f"Adapter {found.name}")
|
||||
canary = False
|
||||
if (slot.canary_model and slot.canary_cluster in (None, skill.cluster)
|
||||
and self.rng.random() < slot.canary_share):
|
||||
canary = True
|
||||
model = slot.canary_model
|
||||
if slot.canary_cluster:
|
||||
adapter = slot.canary_model
|
||||
if slot.canary_cluster: # Adapter-Kandidat
|
||||
model, adapter = adapter_model(slot.spec.litellm_model, slot.canary_model), slot.canary_model
|
||||
else: # Basis-/Engine-Kandidat unter eigenem LiteLLM-Alias
|
||||
model = slot.canary_model
|
||||
reasons.append(f"Canary {slot.canary_model}")
|
||||
return self._decision(stage=1, slot=slot.name, model=model, adapter=adapter, skill=skill, canary=canary,
|
||||
prompt_variant=variant, similarity=similarity,
|
||||
|
||||
@@ -116,6 +116,10 @@ class SkillLibrary:
|
||||
skills.append(parse_skill(file.read_text(encoding="utf-8"), str(file)))
|
||||
return cls(skills, store)
|
||||
|
||||
def reload(self, directory: str | Path) -> None:
|
||||
"""Lädt die Dateien neu; das Objekt bleibt, damit alle Referenzen (Router, Agent) gültig bleiben."""
|
||||
self._skills = SkillLibrary.from_dir(directory)._skills
|
||||
|
||||
def _states(self) -> dict[str, dict[str, Any]]:
|
||||
if not self.store:
|
||||
return {}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Konsistenzprüfung der Konfiguration (CI und `knaster validate`)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import ValidationError
|
||||
|
||||
from . import programs
|
||||
from .healer.engine import HealerConfig
|
||||
from .inventory.placement import load_catalog
|
||||
from .maintenance.actions import ActionExecutor
|
||||
from .policy import Policy
|
||||
from .registry import Registry
|
||||
from .skills import SkillLibrary
|
||||
from .store import Store
|
||||
from .util import load_yaml
|
||||
|
||||
|
||||
def _known_actions() -> set[str]:
|
||||
return {name.removeprefix("_do_") for name in dir(ActionExecutor) if name.startswith("_do_")}
|
||||
|
||||
|
||||
def validate_repo(root: str | Path, site: str = "knaster") -> list[tuple[str, str]]:
|
||||
root = Path(root)
|
||||
issues: list[tuple[str, str]] = []
|
||||
|
||||
def err(msg: str) -> None:
|
||||
issues.append(("error", msg))
|
||||
|
||||
def warn(msg: str) -> None:
|
||||
issues.append(("warning", msg))
|
||||
|
||||
store = Store("sqlite://")
|
||||
try:
|
||||
registry = Registry.from_file(root / "sites" / site / "registry.yaml", store)
|
||||
except (ValidationError, ValueError, OSError) as exc:
|
||||
return [("error", f"Registry: {exc}")]
|
||||
slots = {s.name: s for s in registry.slots()}
|
||||
if not any(s.spec.tier == 0 and s.spec.kind == "chat" for s in slots.values()):
|
||||
err("Registry: kein Stufe-0-Slot (tier 0, kind chat)")
|
||||
|
||||
try:
|
||||
library = SkillLibrary.from_dir(root / "skills")
|
||||
except (ValidationError, ValueError) as exc:
|
||||
return issues + [("error", f"Skills: {exc}")]
|
||||
for skill in library.all():
|
||||
m = skill.meta
|
||||
if m.slot not in slots:
|
||||
err(f"Skill {skill.name}: Slot {m.slot} unbekannt")
|
||||
elif slots[m.slot].spec.kind != "chat":
|
||||
err(f"Skill {skill.name}: Slot {m.slot} ist kein Chat-Slot")
|
||||
for check in m.verifier:
|
||||
if check.get("type") == "judge" and check.get("slot", "verifier-small") not in slots:
|
||||
err(f"Skill {skill.name}: Judge-Slot {check.get('slot', 'verifier-small')} unbekannt")
|
||||
if m.execution == "program" and m.program and m.program not in programs.names():
|
||||
(err if m.stage >= 2 else warn)(f"Skill {skill.name}: Programm {m.program} nicht registriert")
|
||||
if m.execution == "classifier" and m.classifier:
|
||||
path = root / "config" / "classifiers" / f"{m.classifier}.json"
|
||||
if not path.exists():
|
||||
(err if m.stage >= 3 else warn)(f"Skill {skill.name}: Classifier {path.name} fehlt")
|
||||
if m.output.format == "json" and not m.output.schema_:
|
||||
warn(f"Skill {skill.name}: JSON-Ausgabe ohne Schema")
|
||||
|
||||
litellm = root / "deploy" / "litellm" / "config.yaml"
|
||||
if litellm.exists():
|
||||
names = {m["model_name"] for m in load_yaml(litellm).get("model_list") or []}
|
||||
for s in slots.values():
|
||||
if s.spec.litellm_model not in names:
|
||||
err(f"LiteLLM: Alias {s.spec.litellm_model} (Slot {s.name}) fehlt in {litellm.name}")
|
||||
else:
|
||||
warn("LiteLLM-Konfiguration fehlt")
|
||||
|
||||
known = _known_actions()
|
||||
cfg = root / "config"
|
||||
if (cfg / "healer-rules.yaml").exists():
|
||||
healer = HealerConfig.from_file(cfg / "healer-rules.yaml")
|
||||
for rule in healer.rules:
|
||||
for step in [*rule.ladder, *rule.on_resolved]:
|
||||
if step.action not in known:
|
||||
err(f"Healer-Regel {rule.id}: unbekannte Aktion {step.action}")
|
||||
for a in healer.enabled_actions:
|
||||
if a not in {"restart_container", "restart_vm", "reboot_host"}:
|
||||
err(f"Healer: enabled_actions enthält {a} (nur Infrastruktur-Aktionen erlaubt)")
|
||||
if (cfg / "policy.yaml").exists():
|
||||
for kind in Policy.from_file(cfg / "policy.yaml").actions:
|
||||
if kind not in known:
|
||||
err(f"Policy: unbekannte Aktion {kind}")
|
||||
|
||||
if (root / "catalog").exists():
|
||||
roles, _ = load_catalog(root / "catalog")
|
||||
for role in roles:
|
||||
for slot in role.slots:
|
||||
if slot not in slots:
|
||||
err(f"Rolle {role.name}: Slot {slot} fehlt in der Registry")
|
||||
return issues
|
||||
Reference in New Issue
Block a user