"""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