feat: add admin panel
This commit is contained in:
1 parent
7970d4be9b
commit
0128191ac3
26 files changed
+2985
-115
No files matched your search
@@ -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]:
|
||||
|
||||
Reference in new issue
Block a user