Files
cluster-knaster/core/src/knaster/api.py
T

209 lines
9.2 KiB
Python

"""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, start_prober: bool | None = None) -> FastAPI:
if start_prober is None:
start_prober = system is None # nur im echten Betrieb, nicht in Tests mit injiziertem System
system = system or build_system(Settings.from_env())
app = FastAPI(title="knaster", version="0.1.0")
app.state.system = system
if start_prober and system.settings.canary_interval > 0:
system.prober.start(system.settings.canary_interval)
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