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:
2026-10-12 00:47:13 +02:00
co-authored by Claude Opus 5.5
parent f2da893f71
commit 4cbe313c62
20 changed files with 1085 additions and 19 deletions
+204
View File
@@ -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