209 lines
9.2 KiB
Python
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
|