feat: add admin panel

This commit is contained in:
hh committed 2026-05-20 13:00:08 +02:00
1 parent 7970d4be9b
commit 0128191ac3
26 files changed
+2985 -115

No files matched your search

+52 -16
View File
@@ -40,6 +40,7 @@ from anthropic.types import (
from fastapi import FastAPI, HTTPException, Request, status
from fastapi.responses import JSONResponse, StreamingResponse
from beaver_gateway.core import audit
from beaver_gateway.frontends.base import Frontend
if TYPE_CHECKING:
@@ -58,9 +59,24 @@ __all__ = ["AnthropicMessagesFrontend"]
class AnthropicMessagesFrontend(Frontend):
"""FastAPI app behind ``POST /v1/messages`` + ``GET /v1/models``."""
def __init__(self, *, host: str = "0.0.0.0", port: int = 8000) -> None: # noqa: S104
def __init__(
self,
*,
host: str = "0.0.0.0", # noqa: S104
port: int = 8000,
public_base_url: str | None = None,
) -> None:
self.host = host
self.port = port
# External URL prefix the reverse proxy (Caddy/nginx/Cloudflare/…)
# uses to reach this frontend, e.g. ``https://api.example.com/ai``.
# The frontend's internal paths (``/v1/messages``, ``/v1/models``)
# are appended to it when the admin dashboard renders copy-pastable
# URLs. Trailing slash is stripped so the concatenation is
# idempotent. ``None`` means "advertise raw ``host:port``" (dev /
# no proxy) — the dashboard then derives the base from the
# browser's own request hostname.
self.public_base_url = public_base_url.rstrip("/") if public_base_url else None
self._runtime: GatewayRuntime | None = None
self._app: FastAPI | None = None
@@ -93,7 +109,7 @@ class AnthropicMessagesFrontend(Frontend):
@app.get("/v1/models")
async def list_models(request: Request) -> dict[str, Any]:
_require_token(request, runtime)
await _require_token(request, runtime, scope="messages")
data = [
{
"type": "model",
@@ -107,7 +123,7 @@ class AnthropicMessagesFrontend(Frontend):
@app.post("/v1/messages")
async def create_message(request: Request) -> Any:
token_name = _require_token(request, runtime)
token_name = await _require_token(request, runtime, scope="messages")
try:
body = await request.json()
except json.JSONDecodeError as exc:
@@ -148,6 +164,18 @@ class AnthropicMessagesFrontend(Frontend):
stream_flag,
len(messages),
)
# Record at request acceptance, not at stream completion:
# a long streaming response can be aborted mid-flight by
# the client, and we still want the row in the audit trail.
# Detail stays small — no message bodies, no system prompt.
await audit.log(
runtime,
actor=f"token:{token_name}",
kind="messages",
agent_name=agent.name,
stream=stream_flag,
msgs=len(messages),
)
# Forward per-request knobs the Anthropic body may carry —
# backend adapters layer these over per-agent defaults. Only
@@ -166,37 +194,45 @@ class AnthropicMessagesFrontend(Frontend):
)
if stream_flag:
return StreamingResponse(
_sse(events), media_type="text/event-stream"
)
return StreamingResponse(_sse(events), media_type="text/event-stream")
message = await _accumulate(events, model=model)
return JSONResponse(content=message.model_dump(mode="json"))
return app
def _require_token(request: Request, runtime: GatewayRuntime) -> str:
"""Verify the request's bearer and return the token's audit name.
async def _require_token(
request: Request, runtime: GatewayRuntime, *, scope: str
) -> str:
"""Verify the request's bearer + scope, return the token's audit name.
Accepts both ``X-Api-Key: <token>`` (what the official Anthropic
SDK sends — LibreChat, the CLI, third-party clients) and
``Authorization: Bearer <token>`` (curl, Cursor). Raises 401 on
miss. ``TokenStore`` doesn't know about HTTP, so response shape
is owned here.
``Authorization: Bearer <token>`` (curl, Cursor). 401 on missing /
unknown token; 403 on a known token whose scope doesn't cover
``scope`` (Phase 4.3 — bootstrap tokens get ``"*"`` and pass
everything).
"""
api_key = request.headers.get("x-api-key")
name = (
runtime.token_store.verify(api_key)
identity = (
await runtime.token_store.verify(api_key)
if api_key
else runtime.token_store.verify_bearer(request.headers.get("authorization"))
else await runtime.token_store.verify_bearer(
request.headers.get("authorization")
)
)
if name is None:
if identity is None:
raise HTTPException(
status.HTTP_401_UNAUTHORIZED,
"invalid or missing bearer token",
headers={"WWW-Authenticate": "Bearer"},
)
return name
if not identity.allows(scope):
raise HTTPException(
status.HTTP_403_FORBIDDEN,
f"token scope {identity.scope!r} does not cover {scope!r}",
)
return identity.name
async def _sse(events: AsyncIterator[MessageStreamEvent]) -> AsyncIterator[bytes]: