feat: one gateway port with path-mounted frontends, markdown chat, collapsible sidebars
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
"""Where a frontend is reachable from outside, as seen by one request."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from starlette.requests import Request
|
||||
|
||||
from beaver_gateway.frontends.base import Frontend, GatewayRuntime
|
||||
|
||||
|
||||
def request_origin(request: Request, runtime: GatewayRuntime) -> str:
|
||||
"""``Gateway.public_url`` if configured, else the request's own origin."""
|
||||
if runtime.public_url:
|
||||
return runtime.public_url
|
||||
host = request.headers.get("host")
|
||||
if not host:
|
||||
return f"http://{request.url.hostname}:{request.url.port}"
|
||||
scheme = request.headers.get("x-forwarded-proto") or request.url.scheme
|
||||
return f"{scheme}://{host}"
|
||||
|
||||
|
||||
def external_base(request: Request, runtime: GatewayRuntime) -> str:
|
||||
"""Origin plus the mount path of the app handling ``request``."""
|
||||
return request_origin(request, runtime) + str(request.scope.get("root_path", ""))
|
||||
|
||||
|
||||
def frontend_url(request: Request, runtime: GatewayRuntime, fe: Frontend) -> str | None:
|
||||
return request_origin(request, runtime) + fe.path if fe.path else None
|
||||
@@ -1,11 +1,11 @@
|
||||
"""Admin console: serves the ``ui/`` SPA and signs the operator in.
|
||||
|
||||
Everything the console shows comes from ``/api/*`` (``ApiFrontend``)
|
||||
with a bearer. The admin port only owns three JSON routes under
|
||||
``/admin/auth`` - login (``ADMIN_USER`` / ``ADMIN_PASS`` from env,
|
||||
with a bearer. This app, mounted at ``/admin``, owns three JSON routes
|
||||
under ``/admin/auth`` - login (``ADMIN_USER`` / ``ADMIN_PASS`` from env,
|
||||
session cookie signed with ``SESSION_SECRET``, 8 h), logout, and
|
||||
``session``, which hands a signed-in browser the process-lifetime admin
|
||||
bearer plus the API origin - and the static build under ``/admin/``.
|
||||
bearer - and the static build under ``/admin/``.
|
||||
|
||||
The bearer is minted at startup, registered in the token store with
|
||||
scope ``*`` and never persisted; a gateway restart rotates it, and the
|
||||
@@ -25,7 +25,7 @@ from typing import TYPE_CHECKING, Any
|
||||
|
||||
import itsdangerous
|
||||
from fastapi import FastAPI, HTTPException, Request, status
|
||||
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse, Response
|
||||
from fastapi.responses import FileResponse, JSONResponse, Response
|
||||
|
||||
from beaver_gateway.core import audit
|
||||
from beaver_gateway.frontends.base import Frontend
|
||||
@@ -49,17 +49,10 @@ __all__ = ["AdminFrontend"]
|
||||
|
||||
|
||||
class AdminFrontend(Frontend):
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
host: str = "0.0.0.0", # noqa: S104
|
||||
port: int = 8002,
|
||||
public_base_url: str | None = None,
|
||||
ui_dir: Path | None = None,
|
||||
) -> None:
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.public_base_url = public_base_url.rstrip("/") if public_base_url else None
|
||||
path = BASE
|
||||
landing = True
|
||||
|
||||
def __init__(self, *, ui_dir: Path | None = None) -> None:
|
||||
self.ui_dir = (ui_dir or DEFAULT_UI_DIR).resolve()
|
||||
self._app: FastAPI | None = None
|
||||
|
||||
@@ -78,16 +71,8 @@ class AdminFrontend(Frontend):
|
||||
"admin UI build missing at %s (run `make ui-build`)", self.ui_dir
|
||||
)
|
||||
|
||||
async def serve(self) -> None:
|
||||
import uvicorn
|
||||
|
||||
if self._app is None:
|
||||
msg = "configure() must be called before serve()"
|
||||
raise RuntimeError(msg)
|
||||
config = uvicorn.Config(
|
||||
self._app, host=self.host, port=self.port, log_level="info"
|
||||
)
|
||||
await uvicorn.Server(config).serve()
|
||||
def app(self) -> FastAPI | None:
|
||||
return self._app
|
||||
|
||||
|
||||
def build_app(runtime: GatewayRuntime, *, token: str, ui_dir: Path) -> FastAPI:
|
||||
@@ -100,13 +85,7 @@ def build_app(runtime: GatewayRuntime, *, token: str, ui_dir: Path) -> FastAPI:
|
||||
)
|
||||
index = ui_dir / "index.html"
|
||||
|
||||
@app.get("/")
|
||||
async def root() -> Response:
|
||||
return RedirectResponse(
|
||||
f"{BASE}/", status_code=status.HTTP_307_TEMPORARY_REDIRECT
|
||||
)
|
||||
|
||||
@app.post(f"{BASE}/auth/login")
|
||||
@app.post("/auth/login")
|
||||
async def login(request: Request) -> Response:
|
||||
try:
|
||||
data = await request.json()
|
||||
@@ -157,7 +136,7 @@ def build_app(runtime: GatewayRuntime, *, token: str, ui_dir: Path) -> FastAPI:
|
||||
await audit.log(runtime, actor=f"admin:{username}", kind="login_ok", ip=ip)
|
||||
return response
|
||||
|
||||
@app.post(f"{BASE}/auth/logout", status_code=status.HTTP_204_NO_CONTENT)
|
||||
@app.post("/auth/logout", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def logout(request: Request) -> Response:
|
||||
user = _current_user(request, signer)
|
||||
response = Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
@@ -166,15 +145,15 @@ def build_app(runtime: GatewayRuntime, *, token: str, ui_dir: Path) -> FastAPI:
|
||||
await audit.log(runtime, actor=f"admin:{user}", kind="logout")
|
||||
return response
|
||||
|
||||
@app.get(f"{BASE}/auth/session")
|
||||
@app.get("/auth/session")
|
||||
async def session(request: Request) -> dict[str, Any]:
|
||||
user = _current_user(request, signer)
|
||||
if user is None:
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "not signed in")
|
||||
return {"user": user, "token": token, "api_base": _api_base(request, runtime)}
|
||||
return {"user": user, "token": token}
|
||||
|
||||
@app.get(BASE)
|
||||
@app.get(f"{BASE}/{{path:path}}")
|
||||
@app.get("/")
|
||||
@app.get("/{path:path}")
|
||||
async def spa(path: str = "") -> Response:
|
||||
if path:
|
||||
target = (ui_dir / path).resolve()
|
||||
@@ -204,21 +183,6 @@ def build_app(runtime: GatewayRuntime, *, token: str, ui_dir: Path) -> FastAPI:
|
||||
return app
|
||||
|
||||
|
||||
def _api_base(request: Request, runtime: GatewayRuntime) -> str | None:
|
||||
for fe in runtime.frontends:
|
||||
if fe.name != "api":
|
||||
continue
|
||||
public = getattr(fe, "public_base_url", None)
|
||||
if public:
|
||||
return public.removesuffix("/api")
|
||||
port = getattr(fe, "port", None)
|
||||
if port is None:
|
||||
return None
|
||||
scheme = request.headers.get("x-forwarded-proto") or request.url.scheme
|
||||
return f"{scheme}://{request.url.hostname}:{port}"
|
||||
return None
|
||||
|
||||
|
||||
def _current_user(
|
||||
request: Request, signer: itsdangerous.URLSafeTimedSerializer
|
||||
) -> str | None:
|
||||
|
||||
@@ -58,17 +58,9 @@ class AnthropicMessagesFrontend(Frontend):
|
||||
|
||||
name = FRONTEND
|
||||
kinds = ("deep",)
|
||||
path = "/anthropic"
|
||||
|
||||
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
|
||||
self.public_base_url = public_base_url.rstrip("/") if public_base_url else None
|
||||
def __init__(self) -> None:
|
||||
self._runtime: GatewayRuntime | None = None
|
||||
self._app: FastAPI | None = None
|
||||
|
||||
@@ -76,18 +68,8 @@ class AnthropicMessagesFrontend(Frontend):
|
||||
self._runtime = runtime
|
||||
self._app = self._build_app(runtime)
|
||||
|
||||
async def serve(self) -> None:
|
||||
import uvicorn
|
||||
|
||||
if self._app is None:
|
||||
msg = "configure() must be called before serve()"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
config = uvicorn.Config(
|
||||
self._app, host=self.host, port=self.port, log_level="info"
|
||||
)
|
||||
server = uvicorn.Server(config)
|
||||
await server.serve()
|
||||
def app(self) -> FastAPI | None:
|
||||
return self._app
|
||||
|
||||
def _build_app(self, runtime: GatewayRuntime) -> FastAPI:
|
||||
app = FastAPI(title="beaver-gateway / Anthropic Messages")
|
||||
|
||||
@@ -39,6 +39,7 @@ from beaver_gateway.frontends._sse import (
|
||||
events_with_heartbeat,
|
||||
sse_pack,
|
||||
)
|
||||
from beaver_gateway.frontends._urls import frontend_url
|
||||
from beaver_gateway.frontends.base import Frontend
|
||||
from beaver_gateway.storage import (
|
||||
create_token,
|
||||
@@ -82,22 +83,17 @@ MEMORY_MAX_ENTRIES = 5000
|
||||
class ApiFrontend(Frontend):
|
||||
name = "api"
|
||||
kinds = ("master", "branch", "deep", "job")
|
||||
path = "/api"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
host: str = "0.0.0.0", # noqa: S104
|
||||
port: int = 8004,
|
||||
public_base_url: str | None = None,
|
||||
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.host = host
|
||||
self.port = port
|
||||
self.public_base_url = public_base_url.rstrip("/") if public_base_url else None
|
||||
self.master_agent = master_agent
|
||||
self.branch_agent = branch_agent
|
||||
self.deep_agent = deep_agent
|
||||
@@ -119,16 +115,8 @@ class ApiFrontend(Frontend):
|
||||
raise RuntimeError(msg)
|
||||
self._app = build_app(runtime, memory_root=self.memory_root)
|
||||
|
||||
async def serve(self) -> None:
|
||||
import uvicorn
|
||||
|
||||
if self._app is None:
|
||||
msg = "configure() must be called before serve()"
|
||||
raise RuntimeError(msg)
|
||||
server = uvicorn.Server(
|
||||
uvicorn.Config(self._app, host=self.host, port=self.port, log_level="info")
|
||||
)
|
||||
await server.serve()
|
||||
def app(self) -> FastAPI | None:
|
||||
return self._app
|
||||
|
||||
|
||||
def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> FastAPI: # noqa: PLR0915
|
||||
@@ -187,7 +175,7 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa
|
||||
async def healthz() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
@app.get("/api/agents")
|
||||
@app.get("/agents")
|
||||
async def list_agents(request: Request) -> dict[str, Any]:
|
||||
await require_token(request, runtime, scope=SCOPE)
|
||||
return {
|
||||
@@ -209,15 +197,15 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa
|
||||
"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),
|
||||
"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("/api/conversations")
|
||||
@app.get("/conversations")
|
||||
async def list_conversations(request: Request) -> dict[str, Any]:
|
||||
await require_token(request, runtime, scope=SCOPE)
|
||||
q = request.query_params
|
||||
@@ -237,7 +225,7 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa
|
||||
]
|
||||
}
|
||||
|
||||
@app.post("/api/conversations", status_code=status.HTTP_201_CREATED)
|
||||
@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)
|
||||
@@ -281,7 +269,7 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa
|
||||
)
|
||||
return await conversations.describe(conv)
|
||||
|
||||
@app.get("/api/conversations/{public_id}")
|
||||
@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)
|
||||
@@ -292,7 +280,7 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa
|
||||
]
|
||||
return out
|
||||
|
||||
@app.get("/api/conversations/{public_id}/messages")
|
||||
@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)
|
||||
@@ -303,13 +291,13 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa
|
||||
"text": await conversations.read(conv, window=window),
|
||||
}
|
||||
|
||||
@app.get("/api/conversations/{public_id}/history")
|
||||
@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("/api/conversations/{public_id}/entries")
|
||||
@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)
|
||||
@@ -327,7 +315,7 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa
|
||||
}
|
||||
|
||||
@app.post(
|
||||
"/api/conversations/{public_id}/messages", status_code=status.HTTP_202_ACCEPTED
|
||||
"/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)
|
||||
@@ -345,9 +333,7 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa
|
||||
)
|
||||
return {"id": conv.external_id, "item": item.id, "status": item.status}
|
||||
|
||||
@app.post(
|
||||
"/api/conversations/{public_id}/inject", status_code=status.HTTP_202_ACCEPTED
|
||||
)
|
||||
@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)
|
||||
@@ -373,13 +359,13 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa
|
||||
)
|
||||
return {"id": conv.external_id, "item": item.id, "priority": item.priority}
|
||||
|
||||
@app.post("/api/conversations/{public_id}/say")
|
||||
@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("/api/conversations/{public_id}/answer")
|
||||
@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)
|
||||
@@ -391,9 +377,7 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa
|
||||
)
|
||||
return {"id": conv.external_id, "question_id": question_id}
|
||||
|
||||
@app.post(
|
||||
"/api/conversations/{public_id}/branch", status_code=status.HTTP_201_CREATED
|
||||
)
|
||||
@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)
|
||||
@@ -430,7 +414,7 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa
|
||||
)
|
||||
return await conversations.describe(child)
|
||||
|
||||
@app.post("/api/conversations/{public_id}/merge")
|
||||
@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)
|
||||
@@ -452,7 +436,7 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa
|
||||
"text": result.text,
|
||||
}
|
||||
|
||||
@app.post("/api/conversations/{public_id}/fork")
|
||||
@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)
|
||||
@@ -472,7 +456,7 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa
|
||||
"text": result.text,
|
||||
}
|
||||
|
||||
@app.post("/api/conversations/{public_id}/bind")
|
||||
@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)
|
||||
@@ -490,14 +474,14 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
|
||||
return await conversations.describe(conv)
|
||||
|
||||
@app.patch("/api/conversations/{public_id}/flags")
|
||||
@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("/api/conversations/{public_id}")
|
||||
@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)
|
||||
@@ -511,18 +495,18 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
|
||||
return conversations.public(conv)
|
||||
|
||||
@app.get("/api/conversations/{public_id}/events")
|
||||
@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("/api/events")
|
||||
@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("/api/sessions")
|
||||
@app.get("/sessions")
|
||||
async def sessions(request: Request) -> dict[str, Any]:
|
||||
await require_token(request, runtime, scope=SCOPE)
|
||||
pool = runtime.pool
|
||||
@@ -532,7 +516,7 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa
|
||||
"sessions": pool.snapshot() if pool is not None else [],
|
||||
}
|
||||
|
||||
@app.get("/api/schedules")
|
||||
@app.get("/schedules")
|
||||
async def schedules(request: Request) -> dict[str, Any]:
|
||||
await require_token(request, runtime, scope=SCOPE)
|
||||
raw = request.query_params.get("conversation")
|
||||
@@ -551,7 +535,7 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa
|
||||
]
|
||||
}
|
||||
|
||||
@app.get("/api/usage")
|
||||
@app.get("/usage")
|
||||
async def usage(request: Request) -> dict[str, Any]:
|
||||
await require_token(request, runtime, scope=SCOPE)
|
||||
q = request.query_params
|
||||
@@ -578,7 +562,7 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa
|
||||
"rows": groups,
|
||||
}
|
||||
|
||||
@app.get("/api/limits")
|
||||
@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)
|
||||
@@ -602,13 +586,13 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa
|
||||
)
|
||||
return {"windows": windows, "history": [_limit_public(r) for r in rows[:50]]}
|
||||
|
||||
@app.get("/api/memory")
|
||||
@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("/api/memory/file")
|
||||
@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)
|
||||
@@ -631,7 +615,7 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa
|
||||
"content": content,
|
||||
}
|
||||
|
||||
@app.get("/api/tokens")
|
||||
@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"
|
||||
@@ -639,7 +623,7 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa
|
||||
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)
|
||||
@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)
|
||||
@@ -668,7 +652,7 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa
|
||||
)
|
||||
return {"token": _token_public(row), "plaintext": plaintext}
|
||||
|
||||
@app.post("/api/tokens/{token_id}/revoke")
|
||||
@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:
|
||||
@@ -683,7 +667,7 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa
|
||||
)
|
||||
return {"id": token_id, "revoked": True}
|
||||
|
||||
@app.get("/api/audit")
|
||||
@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")
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"""Frontend ABC + the runtime context handed to ``configure``.
|
||||
|
||||
A frontend is anything that listens on a port and routes inbound traffic
|
||||
into the gateway. ``GatewayRuntime`` carries everything a frontend may
|
||||
need that isn't user-config: built registries, per-agent backends, and
|
||||
the in-memory token store. The user's ``/config/config.py`` defines a
|
||||
``Gateway`` (lists); ``cli.main`` turns that into a ``GatewayRuntime``
|
||||
and hands it to each frontend's ``configure``.
|
||||
A frontend is anything that routes inbound traffic into the gateway: an
|
||||
HTTP surface mounted under its ``path`` on the single gateway port
|
||||
(``frontends/root.py``), a poller (Telegram), or both. ``GatewayRuntime``
|
||||
carries everything a frontend may need that isn't user-config: built
|
||||
registries, per-agent backends, and the in-memory token store. The
|
||||
user's ``/config/config.py`` defines a ``Gateway`` (lists); ``cli.main``
|
||||
turns that into a ``GatewayRuntime`` and hands it to each frontend's
|
||||
``configure``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -17,6 +19,8 @@ from typing import TYPE_CHECKING, Any
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Awaitable, Callable, Mapping, Sequence
|
||||
|
||||
from starlette.types import ASGIApp
|
||||
|
||||
from beaver_gateway.backends.base import Backend
|
||||
from beaver_gateway.core.auth import TokenStore
|
||||
from beaver_gateway.core.kinds import Kind
|
||||
@@ -85,10 +89,20 @@ class GatewayRuntime:
|
||||
conversations: Any = None
|
||||
bus: Any = None
|
||||
pool: Any = None
|
||||
# External origin the reverse proxy puts in front of the gateway
|
||||
# (``Gateway.public_url``); ``None`` means "derive from the request".
|
||||
public_url: str | None = None
|
||||
|
||||
|
||||
class Frontend(ABC):
|
||||
"""Listens on a port, dispatches into the gateway.
|
||||
"""Routes inbound traffic into the gateway.
|
||||
|
||||
HTTP frontends set ``path`` and return their ASGI app from ``app()``;
|
||||
``cli`` mounts every such app under that path on the one gateway
|
||||
port, so ``/anthropic/v1/messages`` reaches the Anthropic frontend's
|
||||
``/v1/messages``. ``serve()`` is for work outside HTTP - polling,
|
||||
vault mirrors - and defaults to nothing. ``landing`` marks the app
|
||||
that ``/`` redirects to (the admin console).
|
||||
|
||||
A frontend that shows conversations declares ``name`` (the binding
|
||||
key) and ``kinds`` (which conversation kinds it shows);
|
||||
@@ -103,12 +117,17 @@ class Frontend(ABC):
|
||||
|
||||
name: str = ""
|
||||
kinds: tuple[Kind, ...] = ()
|
||||
path: str | None = None
|
||||
landing: bool = False
|
||||
|
||||
@abstractmethod
|
||||
def configure(self, runtime: GatewayRuntime) -> None: ...
|
||||
|
||||
@abstractmethod
|
||||
async def serve(self) -> None: ...
|
||||
def app(self) -> ASGIApp | None:
|
||||
return None
|
||||
|
||||
async def serve(self) -> None:
|
||||
return
|
||||
|
||||
def agent_for(self, kind: Kind) -> str | None: # noqa: ARG002
|
||||
return None
|
||||
|
||||
@@ -33,8 +33,6 @@ shape.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
@@ -107,31 +105,22 @@ class MarkdownFrontend(Frontend):
|
||||
|
||||
name = FRONTEND
|
||||
kinds = ("deep",)
|
||||
path = "/md"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
vault_path: Path | str,
|
||||
host: str = "0.0.0.0", # noqa: S104
|
||||
port: int = 8003,
|
||||
default_agent: str | None = None,
|
||||
log_all_chats: bool = False,
|
||||
logged_subdir: str = "_logs",
|
||||
chat_path: Callable[[str, str, Path], Path] | None = None,
|
||||
public_base_url: str | None = None,
|
||||
) -> None:
|
||||
self.vault_path = Path(vault_path).expanduser().resolve()
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.default_agent = default_agent
|
||||
self.log_all_chats = log_all_chats
|
||||
self.logged_subdir = logged_subdir
|
||||
self.chat_path = chat_path
|
||||
# External URL prefix when behind a reverse proxy — same role as
|
||||
# on the other bearer frontends. Trailing slash trimmed for
|
||||
# idempotent concatenation; ``None`` means "no proxy / advertise
|
||||
# raw host:port".
|
||||
self.public_base_url = public_base_url.rstrip("/") if public_base_url else None
|
||||
self._runtime: GatewayRuntime | None = None
|
||||
self._app: FastAPI | None = None
|
||||
# Files currently being processed by an in-flight ``POST /chat``.
|
||||
@@ -180,23 +169,11 @@ class MarkdownFrontend(Frontend):
|
||||
async def materialize(self, conv: Conversation) -> ConversationBinding | None:
|
||||
return await self.mirror.materialize(conv)
|
||||
|
||||
async def serve(self) -> None:
|
||||
import uvicorn
|
||||
def app(self) -> FastAPI | None:
|
||||
return self._app
|
||||
|
||||
if self._app is None:
|
||||
msg = "configure() must be called before serve()"
|
||||
raise RuntimeError(msg)
|
||||
config = uvicorn.Config(
|
||||
self._app, host=self.host, port=self.port, log_level="info"
|
||||
)
|
||||
server = uvicorn.Server(config)
|
||||
mirror = asyncio.create_task(self.mirror.run())
|
||||
try:
|
||||
await server.serve()
|
||||
finally:
|
||||
mirror.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await mirror
|
||||
async def serve(self) -> None:
|
||||
await self.mirror.run()
|
||||
|
||||
# ---- app builder ---------------------------------------------------
|
||||
|
||||
|
||||
@@ -37,7 +37,6 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from urllib.parse import urlencode
|
||||
|
||||
@@ -47,6 +46,7 @@ from starlette.responses import HTMLResponse, JSONResponse, StreamingResponse
|
||||
from starlette.routing import Route
|
||||
|
||||
from beaver_gateway.core import audit
|
||||
from beaver_gateway.frontends._urls import external_base
|
||||
from beaver_gateway.frontends.base import Frontend
|
||||
from beaver_gateway.mcp.internal_app import ALL_NAMESPACE
|
||||
|
||||
@@ -94,76 +94,31 @@ __all__ = ["McpServerFrontend"]
|
||||
class McpServerFrontend(Frontend):
|
||||
"""Auth + audit + reverse-proxy in front of the internal MCP aggregator."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
host: str = "0.0.0.0", # noqa: S104
|
||||
port: int = 8001,
|
||||
public_base_url: str | None = None,
|
||||
) -> None:
|
||||
self.host = host
|
||||
self.port = port
|
||||
# External URL prefix the reverse proxy uses to reach this
|
||||
# frontend. Internal routes mount namespaces at the port root
|
||||
# (``/<ns>/`` and ``/all/``) — Caddy / nginx / Cloudflare in
|
||||
# front decides what external prefix they sit under. Typical
|
||||
# symmetric setup:
|
||||
#
|
||||
# Caddy: handle_path /mcp/* { reverse_proxy localhost:8001 }
|
||||
# config: public_base_url -> https://api.example.com/mcp
|
||||
# dashboard advertises: https://api.example.com/mcp/<ns>/
|
||||
#
|
||||
# The frontend's ``/<ns>/`` segment gets appended verbatim. Set
|
||||
# it to whatever matches your proxy. ``None`` means "advertise
|
||||
# raw ``host:port`` derived from the inbound request" (dev /
|
||||
# no proxy).
|
||||
self.public_base_url = public_base_url.rstrip("/") if public_base_url else None
|
||||
path = "/mcp"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._runtime: GatewayRuntime | None = None
|
||||
self._app: Starlette | None = None
|
||||
# Single shared aiohttp session, opened once when uvicorn starts
|
||||
# the Starlette lifespan. Reused across every proxied request —
|
||||
# MCP clients (esp. claude.ai) reconnect frequently, and a fresh
|
||||
# ClientSession per call would cost a TCP handshake every time.
|
||||
self._http: aiohttp.ClientSession | None = None
|
||||
|
||||
def configure(self, runtime: GatewayRuntime) -> None:
|
||||
self._runtime = runtime
|
||||
self._app = self._build_app(runtime)
|
||||
|
||||
def app(self) -> Starlette | None:
|
||||
return self._app
|
||||
|
||||
async def serve(self) -> None:
|
||||
import uvicorn
|
||||
|
||||
if self._app is None:
|
||||
msg = "configure() must be called before serve()"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
config = uvicorn.Config(
|
||||
self._app, host=self.host, port=self.port, log_level="info"
|
||||
self._http = aiohttp.ClientSession(
|
||||
timeout=aiohttp.ClientTimeout(total=None, sock_read=600)
|
||||
)
|
||||
server = uvicorn.Server(config)
|
||||
await server.serve()
|
||||
try:
|
||||
await asyncio.Event().wait()
|
||||
finally:
|
||||
await self._http.close()
|
||||
self._http = None
|
||||
|
||||
def _build_app(self, runtime: GatewayRuntime) -> Starlette: # noqa: ARG002
|
||||
# The Starlette lifespan owns the shared aiohttp session: opened
|
||||
# at startup, closed at shutdown so we don't leak sockets when
|
||||
# uvicorn is restarted under the same process. Starlette wants a
|
||||
# plain context manager, not an async-generator function — we
|
||||
# decorate to match.
|
||||
@asynccontextmanager
|
||||
async def lifespan(_app: Starlette) -> AsyncIterator[None]:
|
||||
self._http = aiohttp.ClientSession(
|
||||
# Long sock_read because MCP tool calls can take a while
|
||||
# (a claude tool over HTTP can easily stretch beyond 30s
|
||||
# on a real tool).
|
||||
timeout=aiohttp.ClientTimeout(total=None, sock_read=600)
|
||||
)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
if self._http is not None:
|
||||
await self._http.close()
|
||||
self._http = None
|
||||
|
||||
routes = [
|
||||
Route("/", self._discovery, methods=["GET"]),
|
||||
Route("/healthz", self._healthz, methods=["GET"]),
|
||||
@@ -186,7 +141,7 @@ class McpServerFrontend(Frontend):
|
||||
methods=["GET", "POST", "DELETE", "OPTIONS"],
|
||||
),
|
||||
]
|
||||
return Starlette(routes=routes, lifespan=lifespan)
|
||||
return Starlette(routes=routes)
|
||||
|
||||
async def _healthz(self, _request: Request) -> JSONResponse:
|
||||
return JSONResponse({"status": "ok"})
|
||||
@@ -197,13 +152,7 @@ class McpServerFrontend(Frontend):
|
||||
if err is not None:
|
||||
return err
|
||||
assert token_name is not None # noqa: S101 — narrow for ty
|
||||
# ``public_base_url`` wins if configured — it's the operator's
|
||||
# explicit statement of "this is the URL my reverse proxy puts
|
||||
# in front of me". Otherwise: use the request's own scheme+host
|
||||
# so snippets work behind generic reverse proxies / tunnels;
|
||||
# and fall back to the configured host:port if the client
|
||||
# didn't send Host (curl --raw).
|
||||
base = self.public_base_url or _external_base_url(request, self.host, self.port)
|
||||
base = external_base(request, runtime)
|
||||
html = _render_discovery_page(
|
||||
base_url=base, namespaces=list(runtime.mcps), actor=token_name
|
||||
)
|
||||
@@ -240,7 +189,6 @@ class McpServerFrontend(Frontend):
|
||||
)
|
||||
|
||||
if self._http is None:
|
||||
# Lifespan hasn't run yet (shouldn't happen with uvicorn).
|
||||
return JSONResponse({"error": "frontend not ready"}, status_code=503)
|
||||
|
||||
return await _reverse_proxy(
|
||||
@@ -337,14 +285,6 @@ def _join_subpath(base_url: str, subpath: str) -> str:
|
||||
return base_url
|
||||
|
||||
|
||||
def _external_base_url(request: Request, fallback_host: str, fallback_port: int) -> str:
|
||||
host = request.headers.get("host")
|
||||
if host:
|
||||
scheme = request.headers.get("x-forwarded-proto", request.url.scheme)
|
||||
return f"{scheme}://{host}"
|
||||
return f"http://{fallback_host}:{fallback_port}"
|
||||
|
||||
|
||||
async def _reverse_proxy(
|
||||
*,
|
||||
client: aiohttp.ClientSession,
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
"""The one ASGI app the gateway listens with.
|
||||
|
||||
Every HTTP frontend is mounted under its ``path`` (``/anthropic``,
|
||||
``/mcp``, ``/md``, ``/api``, ``/admin``); ``/healthz`` answers for the
|
||||
whole process and ``/`` redirects to the landing frontend (the admin
|
||||
console) when one is configured.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from starlette.applications import Starlette
|
||||
from starlette.responses import JSONResponse, RedirectResponse
|
||||
from starlette.routing import Mount, Route
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterable
|
||||
|
||||
from starlette.requests import Request
|
||||
|
||||
from beaver_gateway.frontends.base import Frontend
|
||||
|
||||
__all__ = ["build_root_app"]
|
||||
|
||||
|
||||
def build_root_app(frontends: Iterable[Frontend]) -> Starlette:
|
||||
mounted = [fe for fe in frontends if fe.path and fe.app() is not None]
|
||||
landing = next((fe for fe in mounted if fe.landing), None)
|
||||
paths = [fe.path for fe in mounted]
|
||||
|
||||
async def healthz(_request: Request) -> JSONResponse:
|
||||
return JSONResponse({"status": "ok", "frontends": paths})
|
||||
|
||||
async def index(_request: Request) -> JSONResponse | RedirectResponse:
|
||||
if landing is not None:
|
||||
return RedirectResponse(f"{landing.path}/", status_code=307)
|
||||
return JSONResponse({"frontends": paths})
|
||||
|
||||
routes: list[Route | Mount] = [
|
||||
Route("/healthz", healthz, methods=["GET"]),
|
||||
Route("/", index, methods=["GET"]),
|
||||
]
|
||||
for fe in mounted:
|
||||
app = fe.app()
|
||||
assert app is not None # noqa: S101 - filtered above; narrows for ty
|
||||
assert fe.path is not None # noqa: S101
|
||||
routes.append(Mount(fe.path, app=app, name=fe.name or fe.path.strip("/")))
|
||||
return Starlette(routes=routes)
|
||||
Reference in New Issue
Block a user