feat(ui,api,admin): sveltekit admin spa, usage by day/agent/conversation/model, rate limits, memory tree, turn snapshot
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user