feat(ui,api,admin): sveltekit admin spa, usage by day/agent/conversation/model, rate limits, memory tree, turn snapshot

This commit is contained in:
hh
2026-08-28 22:14:15 +02:00
parent aaaf459e6d
commit 00618ab7ad
158 changed files with 8459 additions and 2531 deletions
+459 -61
View File
@@ -1,27 +1,35 @@
"""``ApiFrontend`` - ``/api/conversations``, SSE events, sessions, usage (§3.9).
"""``ApiFrontend`` - ``/api/*``: conversations, SSE, sessions, usage, limits (§3.9).
Bearer scope ``api``. Every write goes through ``core/conversations``; the
frontend only shapes JSON. ``/api/events`` and
``/api/conversations/{id}/events`` replay the gateway bus as SSE with the
same keepalive the markdown frontend uses, so a proxy never sees a
silent socket.
Bearer scope ``api``; token and audit management need ``admin``. Every
write goes through ``core/conversations``; the frontend only shapes JSON.
``/api/events`` and ``/api/conversations/{id}/events`` replay the gateway
bus as SSE with the same keepalive the markdown frontend uses, so a proxy
never sees a silent socket.
Usage figures come from the ``usage`` table (one row per turn, API-price
``cost_usd`` and per-model ``model_usage`` from the SDK's
``ResultMessage``); subscription quotas come from ``rate_limits``
(``RateLimitEvent``). The quota covers the whole subscription, so
``/api/limits`` puts the gateway's own spend for the window next to it
for calibration by eye.
"""
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 sqlalchemy import func
from sqlalchemy import select as sa_select
from sqlmodel import col
from sqlmodel import col, select
from beaver_gateway.core import audit
from beaver_gateway.core.auth import VALID_SCOPES, hash_token
from beaver_gateway.core.conversations import SEEDS
from beaver_gateway.core.kinds import Kind, as_kind
from beaver_gateway.frontends._auth import require_token
@@ -32,20 +40,37 @@ from beaver_gateway.frontends._sse import (
sse_pack,
)
from beaver_gateway.frontends.base import Frontend
from beaver_gateway.storage.models import Usage
from beaver_gateway.storage import (
create_token,
list_audit_records,
list_tokens,
revoke_token,
)
from beaver_gateway.storage.models import Conversation, RateLimit, Token, Usage
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from collections.abc import AsyncIterator, Iterable, Sequence
from pathlib import Path
from beaver_gateway.core.conversations import Conversations
from beaver_gateway.frontends.base import GatewayRuntime
from beaver_gateway.storage.models import Conversation
_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):
@@ -62,6 +87,7 @@ class ApiFrontend(Frontend):
branch_agent: str | None = None,
deep_agent: str | None = None,
job_agent: str | None = None,
memory_root: Path | None = None,
) -> None:
self.host = host
self.port = port
@@ -70,6 +96,7 @@ class ApiFrontend(Frontend):
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:
@@ -84,7 +111,7 @@ class ApiFrontend(Frontend):
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)
self._app = build_app(runtime, memory_root=self.memory_root)
async def serve(self) -> None:
import uvicorn
@@ -98,7 +125,7 @@ class ApiFrontend(Frontend):
await server.serve()
def _build_app(runtime: GatewayRuntime) -> FastAPI: # noqa: PLR0915
def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> FastAPI: # noqa: PLR0915
app = FastAPI(title="beaver-gateway / API")
app.add_middleware(
CORSMiddleware,
@@ -146,6 +173,10 @@ def _build_app(runtime: GatewayRuntime) -> FastAPI: # noqa: PLR0915
)
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"}
@@ -158,20 +189,26 @@ def _build_app(runtime: GatewayRuntime) -> FastAPI: # noqa: PLR0915
{
"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,
"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)
},
"port": getattr(fe, "port", None),
"public_base_url": getattr(fe, "public_base_url", None),
}
for fe in conversations.frontends
for fe in runtime.frontends
],
"mcps": [{"name": m.name, "kind": m.kind} for m in runtime.mcps],
}
@app.get("/api/conversations")
@@ -179,7 +216,9 @@ def _build_app(runtime: GatewayRuntime) -> FastAPI: # noqa: PLR0915
await require_token(request, runtime, scope=SCOPE)
q = request.query_params
rows = await conversations.find(
status=q.get("status"), kind=q.get("kind"), limit=int(q.get("limit", "200"))
status=q.get("status"),
kind=q.get("kind"),
limit=query_int(request, "limit", 200),
)
return {"conversations": [conversations.public(r) for r in rows]}
@@ -238,7 +277,7 @@ def _build_app(runtime: GatewayRuntime) -> FastAPI: # noqa: PLR0915
"priority": i.priority,
"origin": i.origin,
"status": i.status,
"created_at": i.created_at.isoformat(),
"created_at": _iso(i.created_at),
"text": i.text[:200],
}
for i in await conversations.queue.recent(cast("int", conv.id), limit=20)
@@ -256,6 +295,29 @@ def _build_app(runtime: GatewayRuntime) -> FastAPI: # noqa: PLR0915
"text": await conversations.read(conv, window=window),
}
@app.get("/api/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("/api/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(
"/api/conversations/{public_id}/messages", status_code=status.HTTP_202_ACCEPTED
)
@@ -309,6 +371,18 @@ def _build_app(runtime: GatewayRuntime) -> FastAPI: # noqa: PLR0915
conv = await conv_of(public_id)
return await conversations.say(conv, text_of(await body_of(request)))
@app.post("/api/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(
"/api/conversations/{public_id}/branch", status_code=status.HTTP_201_CREATED
)
@@ -460,11 +534,10 @@ def _build_app(runtime: GatewayRuntime) -> FastAPI: # noqa: PLR0915
{
"id": s.id,
"conversation_row": s.conversation_id,
"execute_at": s.execute_at.isoformat(),
"execute_at": _iso(s.execute_at),
"text": s.text,
"delivered_at": s.delivered_at.isoformat()
if s.delivered_at
else None,
"created_at": _iso(s.created_at),
"delivered_at": _iso(s.delivered_at),
}
for s in await conversations.schedules(conv)
]
@@ -473,35 +546,156 @@ def _build_app(runtime: GatewayRuntime) -> FastAPI: # noqa: PLR0915
@app.get("/api/usage")
async def usage(request: Request) -> dict[str, Any]:
await require_token(request, runtime, scope=SCOPE)
hours = float(request.query_params.get("hours", "24"))
since = datetime.now(UTC) - timedelta(hours=hours)
columns = (
func.count(),
func.coalesce(func.sum(Usage.input_tokens), 0),
func.coalesce(func.sum(Usage.output_tokens), 0),
func.coalesce(func.sum(Usage.cache_read_tokens), 0),
func.coalesce(func.sum(Usage.cache_creation_tokens), 0),
func.coalesce(func.sum(Usage.cost_usd), 0.0),
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"))
)
async with runtime.db.session() as session:
by_agent = (
await session.execute( # ty: ignore[deprecated]
sa_select(col(Usage.agent_name), *columns)
.where(col(Usage.ts) >= since.replace(tzinfo=None))
.group_by(col(Usage.agent_name))
)
).all()
by_conversation = (
await session.execute( # ty: ignore[deprecated]
sa_select(col(Usage.conversation_id), *columns)
.where(col(Usage.ts) >= since.replace(tzinfo=None))
.group_by(col(Usage.conversation_id))
)
).all()
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"),
"by_agent": [_usage_row("agent", r) for r in by_agent],
"by_conversation": [_usage_row("conversation", r) for r in by_conversation],
"until": until.isoformat(timespec="seconds"),
"group_by": group_by,
"total": _sum_usage(rows),
"rows": groups,
}
@app.get("/api/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("/api/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("/api/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("/api/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("/api/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("/api/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("/api/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)
@@ -515,19 +709,6 @@ def _build_app(runtime: GatewayRuntime) -> FastAPI: # noqa: PLR0915
return app
def _usage_row(label: str, row: Any) -> dict[str, Any]:
key, turns, inp, out, cache_read, cache_creation, cost = row
return {
label: key,
"turns": turns,
"input": inp,
"output": out,
"cache_read": cache_read,
"cache_creation": cache_creation,
"cost_usd": round(float(cost or 0.0), 4),
}
def _sse(runtime: GatewayRuntime, *, conversation_id: str | None) -> StreamingResponse:
async def gen() -> AsyncIterator[bytes]:
stream = runtime.bus.stream(conversation_id=conversation_id)
@@ -539,3 +720,220 @@ def _sse(runtime: GatewayRuntime, *, conversation_id: str | None) -> StreamingRe
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 _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