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
|
||||
Reference in New Issue
Block a user