Files
beaver-gateway/src/beaver_gateway/frontends/api/frontend.py
T

1025 lines
38 KiB
Python

"""``ApiFrontend`` - ``/api/*``: conversations, SSE, sessions, usage, limits.
Bearer scope ``api``; token and audit management need ``admin``. Every
write goes through conversations/service.py; the frontend only shapes JSON.
"""
from __future__ import annotations
import json
import logging
import secrets
from collections import Counter
from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING, Any, cast
from fastapi import FastAPI, HTTPException, Request, status
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, StreamingResponse
from sqlmodel import col, select
from beaver_gateway.conversations.injects import URGENCY
from beaver_gateway.conversations.kinds import Kind, as_kind
from beaver_gateway.conversations.service import SEEDS, implied_title
from beaver_gateway.frontends.base import Frontend
from beaver_gateway.frontends.bearer import require_token
from beaver_gateway.frontends.sse import (
KEEPALIVE,
SSE_HEADERS,
events_with_heartbeat,
sse_pack,
)
from beaver_gateway.frontends.urls import frontend_url
from beaver_gateway.security import audit
from beaver_gateway.security.auth import VALID_SCOPES, hash_token
from beaver_gateway.storage import (
create_token,
list_audit_records,
list_tokens,
revoke_token,
)
from beaver_gateway.storage.models import (
Conversation,
InjectQueueItem,
RateLimit,
Token,
Usage,
)
if TYPE_CHECKING:
from collections.abc import AsyncIterator, Iterable, Sequence
from pathlib import Path
from beaver_gateway.conversations.service import Conversations
from beaver_gateway.frontends.base import GatewayRuntime
from beaver_gateway.jobs.scheduler import Job, Scheduler
_log = logging.getLogger("beaver_gateway.frontends.api")
__all__ = ["ApiFrontend"]
SCOPE = "api"
ADMIN_SCOPE = "admin"
GROUPS = ("day", "agent", "conversation", "model")
WINDOWS: dict[str, timedelta] = {
"five_hour": timedelta(hours=5),
"seven_day": timedelta(days=7),
"seven_day_opus": timedelta(days=7),
"seven_day_sonnet": timedelta(days=7),
}
MEMORY_MAX_DEPTH = 12
MEMORY_MAX_FILE = 2_000_000
MEMORY_MAX_ENTRIES = 5000
class ApiFrontend(Frontend):
name = "api"
kinds = ("master", "branch", "deep", "job")
path = "/api"
def __init__(
self,
*,
master_agent: str | None = None,
branch_agent: str | None = None,
deep_agent: str | None = None,
job_agent: str | None = None,
memory_root: Path | None = None,
) -> None:
self.master_agent = master_agent
self.branch_agent = branch_agent
self.deep_agent = deep_agent
self.job_agent = job_agent
self.memory_root = memory_root.resolve() if memory_root is not None else None
self._app: FastAPI | None = None
def agent_for(self, kind: Kind) -> str | None:
return {
"master": self.master_agent,
"branch": self.branch_agent,
"deep": self.deep_agent,
"job": self.job_agent,
}.get(kind)
def configure(self, runtime: GatewayRuntime) -> None:
if runtime.conversations is None or runtime.bus is None:
msg = "ApiFrontend needs runtime.conversations and runtime.bus"
raise RuntimeError(msg)
self._app = build_app(runtime, memory_root=self.memory_root)
def app(self) -> FastAPI | None:
return self._app
def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> FastAPI: # noqa: PLR0915
app = FastAPI(title="beaver-gateway / API")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=False,
allow_methods=["*"],
allow_headers=["*"],
)
conversations = cast("Conversations", runtime.conversations)
async def body_of(request: Request) -> dict[str, Any]:
if not await request.body():
return {}
try:
data = await request.json()
except json.JSONDecodeError as exc:
raise HTTPException(
status.HTTP_400_BAD_REQUEST, f"invalid JSON: {exc}"
) from exc
if not isinstance(data, dict):
raise HTTPException(status.HTTP_400_BAD_REQUEST, "body must be an object")
return data
async def conv_of(public_id: str) -> Conversation:
conv = await conversations.resolve(public_id)
if conv is None:
raise HTTPException(
status.HTTP_404_NOT_FOUND, f"unknown conversation {public_id}"
)
return conv
def text_of(data: dict[str, Any], key: str = "text") -> str:
text = data.get(key)
if not isinstance(text, str) or not text.strip():
raise HTTPException(status.HTTP_400_BAD_REQUEST, f"missing `{key}`")
return text
def urgency_of(data: dict[str, Any], default: str) -> Any:
urgency = str(data.get("urgency") or default)
if urgency not in URGENCY:
raise HTTPException(
status.HTTP_400_BAD_REQUEST, f"urgency must be one of {URGENCY}"
)
return urgency
def int_or_none(data: dict[str, Any], key: str) -> int | None:
value = data.get(key)
if value is None:
return None
if not isinstance(value, int) or value < 1:
raise HTTPException(
status.HTTP_400_BAD_REQUEST, f"`{key}` must be a positive int"
)
return value
def query_int(request: Request, key: str, default: int) -> int:
raw = request.query_params.get(key)
return int(raw) if raw and raw.isdigit() else default
@app.get("/healthz")
async def healthz() -> dict[str, str]:
return {"status": "ok"}
@app.get("/agents")
async def list_agents(request: Request) -> dict[str, Any]:
await require_token(request, runtime, scope=SCOPE)
return {
"agents": [
{
"name": a.name,
"model": a.model,
"type": a.__class__.__name__,
"kinds": list(getattr(a, "kinds", ())),
"effort": getattr(getattr(a, "options", None), "effort", None),
}
for a in runtime.agents
],
"frontends": [
{
"name": fe.name or fe.__class__.__name__,
"type": fe.__class__.__name__,
"kinds": list(fe.kinds),
"default_agents": {
k: fe.agent_for(k) for k in fe.kinds if fe.agent_for(k)
},
"path": fe.path,
"url": frontend_url(request, runtime, fe),
}
for fe in runtime.frontends
],
"mcps": [{"name": m.name, "kind": m.kind} for m in runtime.mcps],
}
@app.get("/conversations")
async def list_conversations(request: Request) -> dict[str, Any]:
await require_token(request, runtime, scope=SCOPE)
q = request.query_params
rows = await conversations.find(
status=q.get("status"),
kind=q.get("kind"),
limit=query_int(request, "limit", 200),
)
latest = await conversations.queue.latest(cast("int", r.id) for r in rows)
firsts = await conversations.first_user_texts(
cast("int", r.id) for r in rows if not r.title
)
return {
"conversations": [
{
**conversations.public(r),
"title": r.title or implied_title(firsts.get(cast("int", r.id))),
"last_item": _queue_item(latest.get(cast("int", r.id))),
}
for r in rows
]
}
@app.post("/conversations", status_code=status.HTTP_201_CREATED)
async def create_conversation(request: Request) -> dict[str, Any]:
token = await require_token(request, runtime, scope=SCOPE)
data = await body_of(request)
agent = data.get("agent")
try:
kind = as_kind(str(data.get("kind") or "deep"))
except ValueError as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
if kind == "fork":
raise HTTPException(status.HTTP_400_BAD_REQUEST, "fork is internal")
if agent is not None and (
not isinstance(agent, str) or agent not in runtime.agents
):
raise HTTPException(status.HTTP_400_BAD_REQUEST, "unknown `agent`")
seed = str(data.get("seed") or "clean")
if seed not in SEEDS:
raise HTTPException(
status.HTTP_400_BAD_REQUEST, f"seed must be one of {SEEDS}"
)
parent = await conv_of(str(data["parent"])) if data.get("parent") else None
try:
conv = await conversations.spawn(
kind=kind,
agent=agent,
seed=seed,
parent=parent,
text=data.get("text"),
title=data.get("title"),
window=int_or_none(data, "window"),
origin="api",
)
except (ValueError, LookupError) as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
await audit.log(
runtime,
actor=f"token:{token}",
kind="api_spawn",
agent_name=conv.agent_name,
conversation=conv.external_id,
seed=seed,
)
return await conversations.describe(conv)
@app.get("/conversations/{public_id}")
async def get_conversation(public_id: str, request: Request) -> dict[str, Any]:
await require_token(request, runtime, scope=SCOPE)
conv = await conv_of(public_id)
out = await conversations.describe(conv)
out["queue"] = [
_queue_item(i)
for i in await conversations.queue.recent(cast("int", conv.id), limit=20)
]
return out
@app.get("/conversations/{public_id}/messages")
async def get_messages(public_id: str, request: Request) -> dict[str, Any]:
await require_token(request, runtime, scope=SCOPE)
conv = await conv_of(public_id)
raw = request.query_params.get("window")
window = int(raw) if raw and raw.isdigit() else None
return {
"id": conv.external_id,
"text": await conversations.read(conv, window=window),
}
@app.get("/conversations/{public_id}/history")
async def get_history(public_id: str, request: Request) -> dict[str, Any]:
await require_token(request, runtime, scope=SCOPE)
conv = await conv_of(public_id)
return {"id": conv.external_id, "messages": await conversations.history(conv)}
@app.get("/conversations/{public_id}/entries")
async def get_entries(public_id: str, request: Request) -> dict[str, Any]:
await require_token(request, runtime, scope=SCOPE)
conv = await conv_of(public_id)
subpath = request.query_params.get("subpath") or ""
entries = await conversations.entries(conv, subpath=subpath)
limit = query_int(request, "limit", 100)
offset = query_int(request, "offset", max(len(entries) - limit, 0))
return {
"id": conv.external_id,
"subpath": subpath,
"subpaths": await conversations.subpaths(conv),
"total": len(entries),
"offset": offset,
"entries": entries[offset : offset + limit],
}
@app.post(
"/conversations/{public_id}/messages", status_code=status.HTTP_202_ACCEPTED
)
async def post_message(public_id: str, request: Request) -> dict[str, Any]:
token = await require_token(request, runtime, scope=SCOPE)
conv = await conv_of(public_id)
data = await body_of(request)
item = await conversations.post(
conv, text_of(data), origin=str(data.get("origin") or "user")
)
await audit.log(
runtime,
actor=f"token:{token}",
kind="api_message",
agent_name=conv.agent_name,
conversation=conv.external_id,
)
return {"id": conv.external_id, "item": item.id, "status": item.status}
@app.post("/conversations/{public_id}/inject", status_code=status.HTTP_202_ACCEPTED)
async def post_inject(public_id: str, request: Request) -> dict[str, Any]:
token = await require_token(request, runtime, scope=SCOPE)
conv = await conv_of(public_id)
data = await body_of(request)
urgency = urgency_of(data, "normal")
item = await conversations.inject(
conv,
text_of(data),
urgency=urgency,
origin=str(data.get("origin") or "api"),
)
await audit.log(
runtime,
actor=f"token:{token}",
kind="api_inject",
agent_name=conv.agent_name,
conversation=conv.external_id,
urgency=urgency,
)
return {"id": conv.external_id, "item": item.id, "priority": item.priority}
@app.post("/conversations/{public_id}/say")
async def post_say(public_id: str, request: Request) -> dict[str, Any]:
await require_token(request, runtime, scope=SCOPE)
conv = await conv_of(public_id)
return await conversations.say(conv, text_of(await body_of(request)))
@app.post("/conversations/{public_id}/answer")
async def post_answer(public_id: str, request: Request) -> dict[str, Any]:
await require_token(request, runtime, scope=SCOPE)
conv = await conv_of(public_id)
data = await body_of(request)
question_id = text_of(data, "question_id")
if not conversations.answer(question_id, text_of(data, "answer")):
raise HTTPException(
status.HTTP_404_NOT_FOUND, f"no open question {question_id}"
)
return {"id": conv.external_id, "question_id": question_id}
@app.post("/conversations/{public_id}/branch", status_code=status.HTTP_201_CREATED)
async def post_branch(public_id: str, request: Request) -> dict[str, Any]:
token = await require_token(request, runtime, scope=SCOPE)
parent = await conv_of(public_id)
data = await body_of(request)
seed = str(data.get("seed_mode") or data.get("seed") or "morning")
if seed not in SEEDS:
raise HTTPException(
status.HTTP_400_BAD_REQUEST, f"seed must be one of {SEEDS}"
)
agent = data.get("agent")
if agent is not None and not isinstance(agent, str):
raise HTTPException(status.HTTP_400_BAD_REQUEST, "`agent` must be a string")
try:
child = await conversations.spawn(
kind="branch",
agent=agent,
seed=seed,
parent=parent,
text=data.get("text"),
title=data.get("title"),
window=int_or_none(data, "window"),
origin="api",
)
except (ValueError, LookupError) as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
await audit.log(
runtime,
actor=f"token:{token}",
kind="api_branch",
agent_name=child.agent_name,
conversation=child.external_id,
parent=parent.external_id,
seed=seed,
)
return await conversations.describe(child)
@app.post("/conversations/{public_id}/merge")
async def post_merge(public_id: str, request: Request) -> dict[str, Any]:
token = await require_token(request, runtime, scope=SCOPE)
conv = await conv_of(public_id)
try:
result = await conversations.merge(conv)
except (ValueError, LookupError) as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
await audit.log(
runtime,
actor=f"token:{token}",
kind="api_merge",
agent_name=conv.agent_name,
conversation=conv.external_id,
)
return {
"id": conv.external_id,
"status": "merged",
"fork": result.conversation.external_id,
"text": result.text,
}
@app.post("/conversations/{public_id}/close")
async def post_close(public_id: str, request: Request) -> dict[str, Any]:
token = await require_token(request, runtime, scope=SCOPE)
conv = await conv_of(public_id)
if conv.kind != "deep":
closed = await conversations.close(conv)
return {"id": closed.external_id, "status": closed.status}
try:
result = await conversations.distill(conv, reason="api")
except (ValueError, LookupError) as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
except RuntimeError as exc:
raise HTTPException(status.HTTP_409_CONFLICT, str(exc)) from exc
await audit.log(
runtime,
actor=f"token:{token}",
kind="api_close",
agent_name=conv.agent_name,
conversation=conv.external_id,
digest=str(result.digest.path) if result.digest else None,
)
return {
"id": conv.external_id,
"status": "closed",
"fork": result.fork.external_id,
"text": result.text,
"digest": str(result.digest.path) if result.digest else None,
"error": result.error,
}
@app.post("/conversations/{public_id}/fork")
async def post_fork(public_id: str, request: Request) -> dict[str, Any]:
await require_token(request, runtime, scope=SCOPE)
conv = await conv_of(public_id)
data = await body_of(request)
try:
result = await conversations.fork(
conv,
text_of(data, "prompt"),
window=int_or_none(data, "window"),
strip_tools=bool(data.get("strip_tools", False)),
)
except (ValueError, RuntimeError) as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
return {
"id": conv.external_id,
"fork": result.conversation.external_id,
"text": result.text,
}
@app.post("/conversations/{public_id}/bind")
async def post_bind(public_id: str, request: Request) -> dict[str, Any]:
await require_token(request, runtime, scope=SCOPE)
conv = await conv_of(public_id)
data = await body_of(request)
frontend = text_of(data, "frontend")
external_id = data.get("external_id")
try:
if isinstance(external_id, str) and external_id.strip():
await conversations.bind(
conv,
frontend=frontend,
external_id=external_id,
visible=bool(data.get("visible", True)),
)
elif await conversations.frontend(frontend).materialize(conv) is None:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
f"frontend {frontend!r} cannot open a window for {conv.kind!r}",
)
except ValueError as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
return await conversations.describe(conv)
@app.patch("/conversations/{public_id}/flags")
async def patch_flags(public_id: str, request: Request) -> dict[str, Any]:
await require_token(request, runtime, scope=SCOPE)
conv = await conv_of(public_id)
data = await body_of(request)
return conversations.public(await conversations.set_flags(conv, data))
@app.patch("/conversations/{public_id}")
async def patch_conversation(public_id: str, request: Request) -> dict[str, Any]:
await require_token(request, runtime, scope=SCOPE)
conv = await conv_of(public_id)
data = await body_of(request)
try:
if isinstance(data.get("status"), str):
conv = await conversations.set_status(conv, data["status"])
if isinstance(data.get("title"), str):
conv = await conversations.set_title(conv, data["title"])
except ValueError as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
return conversations.public(conv)
@app.get("/conversations/{public_id}/events")
async def conversation_events(public_id: str, request: Request) -> Any:
await require_token(request, runtime, scope=SCOPE)
conv = await conv_of(public_id)
return _sse(runtime, conversation_id=conv.external_id)
@app.get("/events")
async def all_events(request: Request) -> Any:
await require_token(request, runtime, scope=SCOPE)
return _sse(runtime, conversation_id=None)
@app.get("/sessions")
async def sessions(request: Request) -> dict[str, Any]:
await require_token(request, runtime, scope=SCOPE)
pool = runtime.pool
return {
"rss": pool.rss() if pool is not None else None,
"rss_limit": pool.rss_limit if pool is not None else None,
"sessions": pool.snapshot() if pool is not None else [],
}
@app.get("/schedules")
async def schedules(request: Request) -> dict[str, Any]:
await require_token(request, runtime, scope=SCOPE)
raw = request.query_params.get("conversation")
conv = await conv_of(raw) if raw else None
return {"schedules": await conversations.schedules(conv)}
def scheduler_of() -> Scheduler:
if runtime.scheduler is None:
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, "no scheduler")
return cast("Scheduler", runtime.scheduler)
@app.get("/jobs")
async def jobs(request: Request) -> dict[str, Any]:
await require_token(request, runtime, scope=SCOPE)
return await scheduler_of().snapshot()
def job_of(scheduler: Scheduler, name: str) -> Job:
job = scheduler.job(name)
if job is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, f"no job {name!r}")
return job
@app.post("/jobs/{name}/run", status_code=status.HTTP_202_ACCEPTED)
async def run_job(name: str, request: Request) -> dict[str, Any]:
await require_token(request, runtime, scope=SCOPE)
scheduler = scheduler_of()
job = job_of(scheduler, name)
return {"job": await scheduler.trigger(job, await body_of(request))}
@app.get("/jobs/{name}/runs")
async def job_runs(name: str, request: Request) -> dict[str, Any]:
await require_token(request, runtime, scope=SCOPE)
scheduler = scheduler_of()
job = job_of(scheduler, name)
limit = min(int(request.query_params.get("limit", 50)), 500)
return {"job": job.name, "runs": await scheduler.runs(job.name, limit)}
@app.delete("/jobs/queue/{job_id}")
async def cancel_job(job_id: int, request: Request) -> dict[str, Any]:
await require_token(request, runtime, scope=SCOPE)
if not await scheduler_of().cancel(job_id):
raise HTTPException(status.HTTP_404_NOT_FOUND, f"no queued job {job_id}")
return {"cancelled": job_id}
@app.post(
"/conversations/{public_id}/schedule", status_code=status.HTTP_201_CREATED
)
async def post_schedule(public_id: str, request: Request) -> dict[str, Any]:
await require_token(request, runtime, scope=SCOPE)
conv = await conv_of(public_id)
data = await body_of(request)
try:
job_id, when = await conversations.schedule(
conv,
text_of(data, "at"),
text_of(data, "text"),
urgency=urgency_of(data, "wake"),
)
except (RuntimeError, ValueError) as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
return {"job": job_id, "execute_at": _iso(when)}
@app.get("/usage")
async def usage(request: Request) -> dict[str, Any]:
await require_token(request, runtime, scope=SCOPE)
q = request.query_params
group_by = q.get("group_by", "agent")
if group_by not in GROUPS:
raise HTTPException(
status.HTTP_400_BAD_REQUEST, f"group_by must be one of {GROUPS}"
)
until = _parse_time(q.get("until")) or datetime.now(UTC)
since = _parse_time(q.get("since")) or until - timedelta(
hours=float(q.get("hours", "24"))
)
rows = await _usage_rows(runtime, since, until)
groups = _group_usage(rows, group_by)
if group_by == "conversation":
titles = await _conversation_titles(runtime, [g["key"] for g in groups])
for g in groups:
g.update(titles.get(g["key"], {}))
return {
"since": since.isoformat(timespec="seconds"),
"until": until.isoformat(timespec="seconds"),
"group_by": group_by,
"total": _sum_usage(rows),
"rows": groups,
}
@app.get("/limits")
async def limits(request: Request) -> dict[str, Any]:
await require_token(request, runtime, scope=SCOPE)
rows = await conversations.rate_limits(limit=200)
latest: dict[str, RateLimit] = {}
for row in rows:
latest.setdefault(row.window, row)
now = datetime.now(UTC)
windows = []
for window, row in latest.items():
length = WINDOWS.get(window)
since = (
_aware(row.resets_at) - length
if length is not None and row.resets_at is not None
else _aware(row.ts)
)
gateway = _sum_usage(await _usage_rows(runtime, since, now))
gateway["since"] = since.isoformat(timespec="seconds")
windows.append({**_limit_public(row), "gateway": gateway})
windows.sort(
key=lambda w: (WINDOWS.get(w["window"], timedelta.max), w["window"])
)
return {"windows": windows, "history": [_limit_public(r) for r in rows[:50]]}
@app.get("/memory")
async def memory_tree(request: Request) -> dict[str, Any]:
await require_token(request, runtime, scope=SCOPE)
root = _memory_root(memory_root)
return {"root": str(root), "tree": _tree(root, root, depth=0)}
@app.get("/memory/file")
async def memory_file(request: Request) -> dict[str, Any]:
await require_token(request, runtime, scope=SCOPE)
root = _memory_root(memory_root)
target = _memory_path(root, request.query_params.get("path") or "")
if not target.is_file():
raise HTTPException(status.HTTP_404_NOT_FOUND, "no such file")
stat = target.stat()
if stat.st_size > MEMORY_MAX_FILE:
raise HTTPException(status.HTTP_413_CONTENT_TOO_LARGE, "file too large")
try:
content = target.read_text(encoding="utf-8")
except UnicodeDecodeError as exc:
raise HTTPException(
status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, "not a text file"
) from exc
return {
"path": str(target.relative_to(root)),
"size": stat.st_size,
"mtime": _mtime(stat.st_mtime),
"content": content,
}
@app.get("/tokens")
async def tokens(request: Request) -> dict[str, Any]:
await require_token(request, runtime, scope=ADMIN_SCOPE)
include_revoked = request.query_params.get("include_revoked") == "1"
async with runtime.db.session() as session:
rows = await list_tokens(session, include_revoked=include_revoked)
return {"tokens": [_token_public(t) for t in rows]}
@app.post("/tokens", status_code=status.HTTP_201_CREATED)
async def token_create(request: Request) -> dict[str, Any]:
actor = await require_token(request, runtime, scope=ADMIN_SCOPE)
data = await body_of(request)
name = text_of(data, "name").strip()
scope = str(data.get("scope") or "*")
if scope not in VALID_SCOPES:
raise HTTPException(status.HTTP_400_BAD_REQUEST, f"invalid scope {scope!r}")
plaintext = secrets.token_urlsafe(32)
async with runtime.db.session() as session:
try:
row = await create_token(
session, name=name, scope=scope, hashed_value=hash_token(plaintext)
)
except Exception as exc:
raise HTTPException(
status.HTTP_409_CONFLICT, f"could not create token {name!r}: {exc}"
) from exc
await runtime.token_store.invalidate()
await audit.log(
runtime,
actor=f"token:{actor}",
kind="token_create",
name=name,
scope=scope,
token_id=row.id,
)
return {"token": _token_public(row), "plaintext": plaintext}
@app.post("/tokens/{token_id}/revoke")
async def token_revoke(token_id: int, request: Request) -> dict[str, Any]:
actor = await require_token(request, runtime, scope=ADMIN_SCOPE)
async with runtime.db.session() as session:
ok = await revoke_token(session, token_id=token_id)
if not ok:
raise HTTPException(
status.HTTP_404_NOT_FOUND, f"no active token with id {token_id}"
)
await runtime.token_store.invalidate()
await audit.log(
runtime, actor=f"token:{actor}", kind="token_revoke", token_id=token_id
)
return {"id": token_id, "revoked": True}
@app.get("/audit")
async def audit_list(request: Request) -> dict[str, Any]:
await require_token(request, runtime, scope=ADMIN_SCOPE)
before_raw = request.query_params.get("before")
before = int(before_raw) if before_raw and before_raw.isdigit() else None
limit = min(query_int(request, "limit", 50), 500)
async with runtime.db.session() as session:
rows = await list_audit_records(session, limit=limit, before_id=before)
return {
"records": [
{
"id": r.id,
"ts": _iso(r.ts),
"actor": r.actor,
"kind": r.kind,
"agent": r.agent_name,
"detail": _detail(r.detail_json),
}
for r in rows
],
"next_before": rows[-1].id if len(rows) == limit else None,
}
@app.exception_handler(HTTPException)
async def http_error(_request: Request, exc: HTTPException) -> JSONResponse:
return JSONResponse(
status_code=exc.status_code,
content={"error": exc.detail},
headers=exc.headers,
)
return app
def _sse(runtime: GatewayRuntime, *, conversation_id: str | None) -> StreamingResponse:
async def gen() -> AsyncIterator[bytes]:
stream = runtime.bus.stream(conversation_id=conversation_id)
yield sse_pack("hello", {"type": "hello", "conversation_id": conversation_id})
async for event in events_with_heartbeat(stream):
if event is None:
yield KEEPALIVE
continue
yield sse_pack(str(event["type"]), event)
return StreamingResponse(gen(), media_type="text/event-stream", headers=SSE_HEADERS)
def _parse_time(raw: str | None) -> datetime | None:
if not raw:
return None
try:
value = datetime.fromisoformat(raw)
except ValueError as exc:
raise HTTPException(
status.HTTP_400_BAD_REQUEST, f"bad timestamp {raw!r}"
) from exc
return _aware(value)
def _aware(value: datetime) -> datetime:
return value if value.tzinfo is not None else value.replace(tzinfo=UTC)
def _iso(value: datetime | None) -> str | None:
return _aware(value).isoformat(timespec="seconds") if value is not None else None
def _mtime(value: float) -> str:
return datetime.fromtimestamp(value, tz=UTC).isoformat(timespec="seconds")
async def _usage_rows(
runtime: GatewayRuntime, since: datetime, until: datetime
) -> list[Usage]:
stmt = (
select(Usage)
.where(
col(Usage.ts) >= since.astimezone(UTC).replace(tzinfo=None),
col(Usage.ts) < until.astimezone(UTC).replace(tzinfo=None),
)
.order_by(col(Usage.ts))
)
async with runtime.db.session() as session:
return list((await session.exec(stmt)).all())
def _empty() -> dict[str, Any]:
return {
"turns": 0,
"input": 0,
"output": 0,
"cache_read": 0,
"cache_creation": 0,
"cost_usd": 0.0,
"web_searches": 0,
}
def _add(acc: dict[str, Any], row: Usage) -> None:
acc["turns"] += 1
acc["input"] += row.input_tokens
acc["output"] += row.output_tokens
acc["cache_read"] += row.cache_read_tokens
acc["cache_creation"] += row.cache_creation_tokens
acc["cost_usd"] += row.cost_usd or 0.0
for per_model in (row.model_usage or {}).values():
acc["web_searches"] += int(per_model.get("webSearchRequests") or 0)
def _add_model(acc: dict[str, Any], per_model: dict[str, Any]) -> None:
acc["turns"] += 1
acc["input"] += int(per_model.get("inputTokens") or 0)
acc["output"] += int(per_model.get("outputTokens") or 0)
acc["cache_read"] += int(per_model.get("cacheReadInputTokens") or 0)
acc["cache_creation"] += int(per_model.get("cacheCreationInputTokens") or 0)
acc["cost_usd"] += float(per_model.get("costUSD") or 0.0)
acc["web_searches"] += int(per_model.get("webSearchRequests") or 0)
def _finish(acc: dict[str, Any]) -> dict[str, Any]:
acc["cost_usd"] = round(acc["cost_usd"], 4)
return acc
def _sum_usage(rows: Iterable[Usage]) -> dict[str, Any]:
acc = _empty()
for row in rows:
_add(acc, row)
return _finish(acc)
def _group_usage(rows: Sequence[Usage], group_by: str) -> list[dict[str, Any]]:
groups: dict[str, dict[str, Any]] = {}
agents: dict[str, Counter[str]] = {}
for row in rows:
if group_by == "model":
per_model = row.model_usage or {}
if not per_model:
_add(groups.setdefault(row.model, _empty()), row)
for model, mu in per_model.items():
_add_model(groups.setdefault(model, _empty()), mu)
continue
key = _group_key(row, group_by)
_add(groups.setdefault(key, _empty()), row)
agents.setdefault(key, Counter())[row.agent_name] += 1
out = []
for key, acc in groups.items():
entry = {"key": key, group_by: key, **_finish(acc)}
if key in agents:
entry["agent"] = agents[key].most_common(1)[0][0]
out.append(entry)
if group_by == "day":
out.sort(key=lambda g: g["key"])
else:
out.sort(key=lambda g: (-g["cost_usd"], -g["output"], g["key"]))
return out
def _group_key(row: Usage, group_by: str) -> str:
if group_by == "day":
return _aware(row.ts).date().isoformat()
if group_by == "agent":
return row.agent_name
return row.conversation_id or "-"
async def _conversation_titles(
runtime: GatewayRuntime, ids: Sequence[str]
) -> dict[str, dict[str, Any]]:
wanted = [i for i in ids if i != "-"]
if not wanted:
return {}
async with runtime.db.session() as session:
rows = (
await session.exec(
select(Conversation).where(col(Conversation.external_id).in_(wanted))
)
).all()
return {
r.external_id: {"title": r.title, "kind": r.kind, "status": r.status}
for r in rows
}
def _queue_item(item: InjectQueueItem | None) -> dict[str, Any] | None:
if item is None:
return None
return {
"id": item.id,
"priority": item.priority,
"origin": item.origin,
"status": item.status,
"created_at": _iso(item.created_at),
"text": item.text[:200],
}
def _limit_public(row: RateLimit) -> dict[str, Any]:
return {
"id": row.id,
"ts": _iso(row.ts),
"window": row.window,
"status": row.status,
"utilization": row.utilization,
"resets_at": _iso(row.resets_at),
"overage_status": row.overage_status,
"overage_resets_at": _iso(row.overage_resets_at),
"agent": row.agent_name,
"session_id": row.session_id,
}
def _token_public(row: Token) -> dict[str, Any]:
return {
"id": row.id,
"name": row.name,
"scope": row.scope,
"created_at": _iso(row.created_at),
"last_used_at": _iso(row.last_used_at),
"revoked_at": _iso(row.revoked_at),
}
def _detail(raw: str) -> Any:
try:
return json.loads(raw) if raw else {}
except json.JSONDecodeError:
return raw
def _memory_root(root: Path | None) -> Path:
if root is None or not root.is_dir():
raise HTTPException(
status.HTTP_404_NOT_FOUND,
"memory root is not configured (ApiFrontend(memory_root=...))",
)
return root.resolve()
def _memory_path(root: Path, raw: str) -> Path:
target = (root / raw).resolve()
if not target.is_relative_to(root):
raise HTTPException(status.HTTP_404_NOT_FOUND, "no such file")
return target
def _tree(root: Path, directory: Path, *, depth: int) -> list[dict[str, Any]]:
if depth > MEMORY_MAX_DEPTH:
return []
out: list[dict[str, Any]] = []
try:
children = sorted(
directory.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower())
)
except OSError:
return out
for child in children:
if child.name.startswith(".") or len(out) >= MEMORY_MAX_ENTRIES:
continue
try:
stat = child.stat()
except OSError:
continue
node: dict[str, Any] = {
"name": child.name,
"path": str(child.relative_to(root)),
"type": "dir" if child.is_dir() else "file",
"mtime": _mtime(stat.st_mtime),
}
if child.is_dir():
node["children"] = _tree(root, child, depth=depth + 1)
else:
node["size"] = stat.st_size
out.append(node)
return out