From 16b6bbddda5487207caa57dca8bfbd0bc8e60d2a Mon Sep 17 00:00:00 2001 From: h Date: Sat, 29 Aug 2026 00:49:06 +0200 Subject: [PATCH] feat: one gateway port with path-mounted frontends, markdown chat, collapsible sidebars --- examples/config.py | 45 +++---- examples/docker-compose.yml | 7 +- src/beaver_gateway/cli.py | 18 ++- src/beaver_gateway/core/registry.py | 8 ++ src/beaver_gateway/frontends/_urls.py | 30 +++++ .../frontends/admin/frontend.py | 68 +++------- src/beaver_gateway/frontends/anthropic.py | 26 +--- src/beaver_gateway/frontends/api/frontend.py | 86 ++++++------- src/beaver_gateway/frontends/base.py | 37 ++++-- .../frontends/markdown/frontend.py | 33 +---- src/beaver_gateway/frontends/mcp_server.py | 92 +++----------- src/beaver_gateway/frontends/root.py | 49 ++++++++ tests/test_api.py | 76 +++++++----- tests/test_routing.py | 16 +-- ui/bun.lock | 8 ++ ui/package.json | 4 + ui/src/lib/api/types.ts | 4 +- ui/src/lib/components/app-sidebar.svelte | 116 ++++++++++++++---- .../lib/components/conversation-list.svelte | 12 ++ ui/src/lib/components/endpoints.svelte | 16 +-- ui/src/lib/panel/chat-view.svelte | 10 +- ui/src/lib/panel/conversation-header.svelte | 23 +++- ui/src/lib/panel/conversation-view.svelte | 109 +++++++--------- ui/src/lib/panel/markdown.svelte | 14 +++ ui/src/lib/panel/markdown.ts | 18 +++ ui/src/lib/panel/turn-card.svelte | 20 +-- ui/src/lib/session.svelte.ts | 3 +- ui/src/lib/ui.svelte.ts | 44 +++++++ ui/src/routes/+layout.svelte | 30 ++++- ui/src/routes/conversations/+layout.svelte | 46 ++++++- ui/src/routes/layout.css | 57 +++++++++ ui/vite.config.ts | 7 +- 32 files changed, 691 insertions(+), 441 deletions(-) create mode 100644 src/beaver_gateway/frontends/_urls.py create mode 100644 src/beaver_gateway/frontends/root.py create mode 100644 ui/src/lib/panel/markdown.svelte create mode 100644 ui/src/lib/panel/markdown.ts create mode 100644 ui/src/lib/ui.svelte.ts diff --git a/examples/config.py b/examples/config.py index 3664bb5..02b377e 100644 --- a/examples/config.py +++ b/examples/config.py @@ -144,41 +144,27 @@ gateway = Gateway( # Anthropic-compatible Messages endpoint. Auth comes from # `BOOTSTRAP_TOKENS` in the env (`name1:value1,name2:value2`). # - # Behind a reverse proxy (Caddy / nginx / Cloudflare) pass - # `public_base_url=` so the admin dashboard advertises the - # outside URL instead of `host:port`. Caddy strips its own - # prefix and the frontend's internal paths (`/v1/messages`, - # `/v1/models`) get appended: - # Caddy: handle_path /ai/* { reverse_proxy localhost:8000 } - # Config: AnthropicMessagesFrontend( - # port=8000, - # public_base_url="https://domain.com/ai") - # Result: https://domain.com/ai/v1/messages - AnthropicMessagesFrontend(host="0.0.0.0", port=8000), + # Every HTTP frontend is mounted under its own path on the one + # gateway port (`Gateway.port`, 8000 here): `/anthropic/v1/messages`. + # Behind a reverse proxy set `Gateway(public_url="https://domain.com")` + # so advertised endpoints use the outside origin; the proxy just + # forwards everything to the gateway, no prefix stripping. + AnthropicMessagesFrontend(), # Phase 3 — re-exposes every declared `McpServer` outside the - # gateway with bearer auth + audit log. Each namespace lives - # at `//` on this port (the port itself disambiguates - # MCP traffic — no extra `/mcp` segment in the route); a flat - # bundle is published at `/all/`. Discovery page (HTML, - # auth-gated) at `/` with copy-pastable Cursor / Claude - # Desktop snippets. Auth re-uses `BOOTSTRAP_TOKENS`. - # - # Same `public_base_url=` knob as above. Caddy strips its - # prefix; the frontend's `//` segment gets appended: - # Caddy: handle_path /mcp/* { reverse_proxy localhost:8001 } - # Config: McpServerFrontend( - # port=8001, - # public_base_url="https://domain.com/mcp") - # Result: https://domain.com/mcp// (and /mcp/all/) - McpServerFrontend(host="0.0.0.0", port=8001), + # gateway with bearer auth + audit log. Each namespace lives at + # `/mcp//`; a flat bundle is published at `/mcp/all/`. + # Discovery page (HTML, auth-gated) at `/mcp/` with copy-pastable + # Cursor / Claude Desktop snippets. Auth re-uses `BOOTSTRAP_TOKENS`. + McpServerFrontend(), # Phase 4.3 — browser admin UI. Creds come from # `ADMIN_USER`/`ADMIN_PASS`; the session cookie is signed with # `SESSION_SECRET`. Use it to mint tokens (Argon2-hashed in # the DB), revoke them, and watch the audit log. Scope is # enforced on the bearer frontends: tokens minted with scope # `messages` only work on `/v1/messages`; `mcp` only on - # `/mcp/`; `*` works everywhere. - AdminFrontend(host="0.0.0.0", port=8002), + # `/mcp/`; `*` works everywhere. Served at `/admin/`; `/` + # redirects there. + AdminFrontend(), # Obsidian-vault chat frontend. Each `.md` is one conversation # (User/Assistant turn pairs). The Obsidian companion plugin # POSTs `{filename, content?}` to `/chat` — the frontend reads @@ -191,9 +177,8 @@ gateway = Gateway( # example boots cleanly; in real deployments mount the # Obsidian-sync container's vault volume to a stable path and # pass that instead. + # Mounted at `/md` (`/md/chat`, `/md/chat/stream`). MarkdownFrontend( - host="0.0.0.0", - port=8003, # Point at the dedicated chats subdir of your real Obsidian # vault — the gateway has no idea (and no need) about other # notes outside it. Path resolution / vault-escape checks diff --git a/examples/docker-compose.yml b/examples/docker-compose.yml index 2eebeef..4bd4ae1 100644 --- a/examples/docker-compose.yml +++ b/examples/docker-compose.yml @@ -24,11 +24,8 @@ services: # config.py declares one, so set these (or remove the agent) # before exposing port 8000. ports: - # /v1/messages frontend + # The one gateway port: /anthropic, /mcp, /admin, /md under it + # (change ADMIN_USER/ADMIN_PASS/SESSION_SECRET before exposing). - "8000:8000" - # MCP server frontend - - "8001:8001" - # Admin UI (Phase 4.3) — change ADMIN_USER/ADMIN_PASS/SESSION_SECRET - - "8002:8002" volumes: - ./config.py:/config/config.py:ro diff --git a/src/beaver_gateway/cli.py b/src/beaver_gateway/cli.py index 78d6f1d..60ab858 100644 --- a/src/beaver_gateway/cli.py +++ b/src/beaver_gateway/cli.py @@ -47,9 +47,10 @@ from beaver_gateway.core.auth import TokenStore from beaver_gateway.core.bus import EventBus from beaver_gateway.core.conversations import Conversations from beaver_gateway.core.gateway_tools import build_tool_server -from beaver_gateway.core.registry import AgentRegistry, McpRegistry +from beaver_gateway.core.registry import AgentRegistry, Gateway, McpRegistry from beaver_gateway.core.sessions import SessionPool from beaver_gateway.frontends.base import GatewayRuntime +from beaver_gateway.frontends.root import build_root_app from beaver_gateway.mcp.internal_app import build_internal_app from beaver_gateway.settings import Settings from beaver_gateway.storage import Database, PostgresSessionStore, Usage, append_usage @@ -188,6 +189,7 @@ async def _async_main() -> None: conversations=conversations, bus=bus, pool=pool, + public_url=gateway.public_url.rstrip("/") if gateway.public_url else None, ) for fe in gateway.frontends: @@ -219,10 +221,24 @@ async def _async_main() -> None: tg.create_task(pool.reap_loop()) if internal_app is not None: tg.create_task(_serve_internal_mcp(internal_app, settings=settings)) + tg.create_task(_serve_root(gateway)) for fe in gateway.frontends: tg.create_task(fe.serve()) +async def _serve_root(gateway: Gateway) -> None: + app = build_root_app(gateway.frontends) + config = uvicorn.Config(app, host=gateway.host, port=gateway.port, log_level="info") + _log.info( + "gateway on http://%s:%d - %s", + gateway.host, + gateway.port, + ", ".join(fe.path for fe in gateway.frontends if fe.path) + or "no http frontends", + ) + await uvicorn.Server(config).serve() + + def _build_internal_mcp( mcps: list[McpServerT], *, settings: Settings ) -> tuple[Starlette | None, dict[str, str], dict[str, FastMCP]]: diff --git a/src/beaver_gateway/core/registry.py b/src/beaver_gateway/core/registry.py index a7704a7..dfb985c 100644 --- a/src/beaver_gateway/core/registry.py +++ b/src/beaver_gateway/core/registry.py @@ -84,3 +84,11 @@ class Gateway: frontends: list[Frontend] = field(default_factory=list) texts: ConversationTexts | None = None """Merge prompt and seed bodies for ``core/conversations`` (§8.2-8.3).""" + host: str = "0.0.0.0" # noqa: S104 + port: int = 8000 + """The one listener; every HTTP frontend is mounted under its ``path``.""" + public_url: str | None = None + """Origin the reverse proxy shows the world (``https://b.example.com``). + + Advertised endpoints and MCP discovery are built on it; ``None`` + derives the origin from each request.""" diff --git a/src/beaver_gateway/frontends/_urls.py b/src/beaver_gateway/frontends/_urls.py new file mode 100644 index 0000000..0e203f7 --- /dev/null +++ b/src/beaver_gateway/frontends/_urls.py @@ -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 diff --git a/src/beaver_gateway/frontends/admin/frontend.py b/src/beaver_gateway/frontends/admin/frontend.py index bcc2070..b6f9ccb 100644 --- a/src/beaver_gateway/frontends/admin/frontend.py +++ b/src/beaver_gateway/frontends/admin/frontend.py @@ -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: diff --git a/src/beaver_gateway/frontends/anthropic.py b/src/beaver_gateway/frontends/anthropic.py index ace6739..d61b237 100644 --- a/src/beaver_gateway/frontends/anthropic.py +++ b/src/beaver_gateway/frontends/anthropic.py @@ -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") diff --git a/src/beaver_gateway/frontends/api/frontend.py b/src/beaver_gateway/frontends/api/frontend.py index 496e97c..1f0ec28 100644 --- a/src/beaver_gateway/frontends/api/frontend.py +++ b/src/beaver_gateway/frontends/api/frontend.py @@ -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") diff --git a/src/beaver_gateway/frontends/base.py b/src/beaver_gateway/frontends/base.py index e1ded4b..1dcc879 100644 --- a/src/beaver_gateway/frontends/base.py +++ b/src/beaver_gateway/frontends/base.py @@ -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 diff --git a/src/beaver_gateway/frontends/markdown/frontend.py b/src/beaver_gateway/frontends/markdown/frontend.py index 6c42f17..ba3cd94 100644 --- a/src/beaver_gateway/frontends/markdown/frontend.py +++ b/src/beaver_gateway/frontends/markdown/frontend.py @@ -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 --------------------------------------------------- diff --git a/src/beaver_gateway/frontends/mcp_server.py b/src/beaver_gateway/frontends/mcp_server.py index 111baba..6b7e635 100644 --- a/src/beaver_gateway/frontends/mcp_server.py +++ b/src/beaver_gateway/frontends/mcp_server.py @@ -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 - # (``//`` 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// - # - # The frontend's ``//`` 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, diff --git a/src/beaver_gateway/frontends/root.py b/src/beaver_gateway/frontends/root.py new file mode 100644 index 0000000..9bc1247 --- /dev/null +++ b/src/beaver_gateway/frontends/root.py @@ -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) diff --git a/tests/test_api.py b/tests/test_api.py index 3f45c3a..3df958e 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -21,9 +21,12 @@ from test_conversations import ScriptedClient, World from beaver_gateway.core.auth import TokenStore from beaver_gateway.core.registry import McpRegistry from beaver_gateway.core.transcript import build_entries +from beaver_gateway.frontends.admin import AdminFrontend from beaver_gateway.frontends.admin.frontend import build_app as build_admin +from beaver_gateway.frontends.api import ApiFrontend from beaver_gateway.frontends.api.frontend import build_app as build_api -from beaver_gateway.frontends.base import GatewayRuntime +from beaver_gateway.frontends.base import Frontend, GatewayRuntime +from beaver_gateway.frontends.root import build_root_app from beaver_gateway.storage.models import RateLimit, Usage TOKEN = "tok" @@ -114,6 +117,11 @@ class Api: transport=ASGITransport(app=self.app), base_url="http://api" ) + def root(self, *frontends: Frontend) -> AsyncClient: + return AsyncClient( + transport=ASGITransport(app=build_root_app(frontends)), base_url="http://gw" + ) + async def get(self, path: str, params: dict[str, Any] | None = None) -> Any: res = await self.http.get(path, params=params, headers=HEADERS) assert res.status_code == 200, res.text @@ -175,7 +183,7 @@ async def test_usage_groups_by_agent_day_and_model(world: World) -> None: }, ], ) - by_agent = await api.get("/api/usage", {"group_by": "agent"}) + by_agent = await api.get("/usage", {"group_by": "agent"}) assert [r["agent"] for r in by_agent["rows"]] == ["a"] assert by_agent["total"] == { "turns": 1, @@ -187,16 +195,16 @@ async def test_usage_groups_by_agent_day_and_model(world: World) -> None: "web_searches": 2, } since = (datetime.now(UTC) - timedelta(days=3)).isoformat() - by_day = await api.get("/api/usage", {"group_by": "day", "since": since}) + by_day = await api.get("/usage", {"group_by": "day", "since": since}) assert len(by_day["rows"]) == 2 assert by_day["rows"][0]["day"] < by_day["rows"][1]["day"] - by_model = await api.get("/api/usage", {"group_by": "model", "since": since}) + by_model = await api.get("/usage", {"group_by": "model", "since": since}) models = {r["model"]: r for r in by_model["rows"]} assert models["claude-opus-5"]["web_searches"] == 2 assert models["claude-sonnet-5"]["output"] == 2 - by_conv = await api.get("/api/usage", {"group_by": "conversation", "since": since}) + by_conv = await api.get("/usage", {"group_by": "conversation", "since": since}) assert {r["conversation"] for r in by_conv["rows"]} == {"c1", "c2"} - bad = await api.http.get("/api/usage", params={"group_by": "x"}, headers=HEADERS) + bad = await api.http.get("/usage", params={"group_by": "x"}, headers=HEADERS) assert bad.status_code == 400 @@ -234,7 +242,7 @@ async def test_limits_report_latest_window_with_gateway_spend(world: World) -> N } ], ) - out = await api.get("/api/limits") + out = await api.get("/limits") windows = {w["window"]: w for w in out["windows"]} assert windows["five_hour"]["utilization"] == 0.9 assert windows["five_hour"]["status"] == "allowed_warning" @@ -252,18 +260,18 @@ async def test_memory_tree_and_file(world: World) -> None: (root / "состояние.md").write_text("# state", encoding="utf-8") (root / ".hidden").write_text("x", encoding="utf-8") api = Api(world, memory_root=root) - tree = await api.get("/api/memory") + tree = await api.get("/memory") names = [n["name"] for n in tree["tree"]] assert names == ["дни", "состояние.md"] assert tree["tree"][0]["children"][0]["path"] == "дни/2026-08-27.md" - file = await api.get("/api/memory/file", {"path": "дни/2026-08-27.md"}) + file = await api.get("/memory/file", {"path": "дни/2026-08-27.md"}) assert file["content"] == "# day" escape = await api.http.get( - "/api/memory/file", params={"path": "../w.db"}, headers=HEADERS + "/memory/file", params={"path": "../w.db"}, headers=HEADERS ) assert escape.status_code == 404 unset = Api(world) - res = await unset.http.get("/api/memory", headers=HEADERS) + res = await unset.http.get("/memory", headers=HEADERS) assert res.status_code == 404 @@ -291,7 +299,7 @@ async def test_describe_snapshots_open_tools_and_records_rate_limit( ScriptedClient.hold = asyncio.Event() await world.conversations.post(conv, "go") await asyncio.sleep(0.3) - described = await api.get(f"/api/conversations/{conv.external_id}") + described = await api.get(f"/conversations/{conv.external_id}") turn = described["turn"] assert turn is not None and turn["id"] == described["running_turn"] tools = {t["tool_use_id"]: t for t in turn["tools"]} @@ -299,12 +307,12 @@ async def test_describe_snapshots_open_tools_and_records_rate_limit( assert tools["tu_2"]["parent_tool_use_id"] == "tu_1" ScriptedClient.hold.set() await world.settle(conv, 1) - described = await api.get(f"/api/conversations/{conv.external_id}") + described = await api.get(f"/conversations/{conv.external_id}") assert described["turn"] is None - limits = await api.get("/api/limits") + limits = await api.get("/limits") assert limits["windows"][0]["utilization"] == 0.8 assert limits["windows"][0]["gateway"]["cost_usd"] == 0.5 - usage = await api.get("/api/usage", {"group_by": "model"}) + usage = await api.get("/usage", {"group_by": "model"}) assert {r["model"] for r in usage["rows"]} == {"claude-opus-5", "claude-haiku-4-5"} assert usage["total"]["web_searches"] == 1 conv = await world.conversations.get(conv.external_id) @@ -321,12 +329,12 @@ async def test_describe_snapshots_open_tools_and_records_rate_limit( model="m", ), ) - history = await api.get(f"/api/conversations/{conv.external_id}/history") + history = await api.get(f"/conversations/{conv.external_id}/history") assert [m["role"] for m in history["messages"]] == ["user", "assistant"] - entries = await api.get(f"/api/conversations/{conv.external_id}/entries") + entries = await api.get(f"/conversations/{conv.external_id}/entries") assert entries["total"] == len(entries["entries"]) == 2 page = await api.get( - f"/api/conversations/{conv.external_id}/entries", {"limit": 1, "offset": 0} + f"/conversations/{conv.external_id}/entries", {"limit": 1, "offset": 0} ) assert page["offset"] == 0 and len(page["entries"]) == 1 @@ -335,22 +343,22 @@ async def test_tokens_and_audit_need_admin_scope(world: World) -> None: api = Api(world) api.store.grant("api-only", "weak", scope="api") weak = {"Authorization": "Bearer weak"} - assert (await api.http.get("/api/tokens", headers=weak)).status_code == 403 + assert (await api.http.get("/tokens", headers=weak)).status_code == 403 created = await api.http.post( - "/api/tokens", json={"name": "cursor", "scope": "mcp"}, headers=HEADERS + "/tokens", json={"name": "cursor", "scope": "mcp"}, headers=HEADERS ) assert created.status_code == 201 plaintext = created.json()["plaintext"] await api.store.invalidate() identity = await api.store.verify(plaintext) assert identity is not None and identity.scope == "mcp" - listed = await api.get("/api/tokens") + listed = await api.get("/tokens") assert [t["name"] for t in listed["tokens"]] == ["cursor"] token_id = listed["tokens"][0]["id"] - revoked = await api.http.post(f"/api/tokens/{token_id}/revoke", headers=HEADERS) + revoked = await api.http.post(f"/tokens/{token_id}/revoke", headers=HEADERS) assert revoked.status_code == 200 - assert (await api.get("/api/tokens"))["tokens"] == [] - audit = await api.get("/api/audit") + assert (await api.get("/tokens"))["tokens"] == [] + audit = await api.get("/audit") assert [r["kind"] for r in audit["records"]] == ["token_revoke", "token_create"] @@ -359,9 +367,12 @@ async def test_admin_login_hands_out_the_ui_bearer(world: World) -> None: ui_dir = world.root / "build" ui_dir.mkdir() (ui_dir / "index.html").write_text("ui", encoding="utf-8") - admin = build_admin(api.runtime, token="ui-bearer", ui_dir=ui_dir) + admin = AdminFrontend(ui_dir=ui_dir) + admin._app = build_admin(api.runtime, token="ui-bearer", ui_dir=ui_dir) # noqa: SLF001 api.store.grant("admin-ui", "ui-bearer") - http = AsyncClient(transport=ASGITransport(app=admin), base_url="http://admin") + api_fe = ApiFrontend() + api_fe._app = api.app # noqa: SLF001 + http = api.root(admin, api_fe) assert (await http.get("/admin/auth/session")).status_code == 401 bad = await http.post( "/admin/auth/login", json={"username": "admin", "password": "nope"} @@ -372,16 +383,17 @@ async def test_admin_login_hands_out_the_ui_bearer(world: World) -> None: ) assert ok.status_code == 200 session = (await http.get("/admin/auth/session")).json() - assert session["user"] == "admin" and session["token"] == "ui-bearer" - assert session["api_base"] is None + assert session == {"user": "admin", "token": "ui-bearer"} spa = await http.get("/admin/conversations/abc") assert spa.status_code == 200 and spa.text == "ui" - assert (await http.get("/")).status_code == 307 + root = await http.get("/") + assert root.status_code == 307 and root.headers["location"] == "/admin/" + assert (await http.get("/admin")).status_code == 307 + health = await http.get("/healthz") + assert health.json()["frontends"] == ["/admin", "/api"] escape = await http.get("/admin/%2e%2e/pyproject.toml") assert escape.status_code == 200 and escape.text == "ui" - me = await api.http.get( - "/api/agents", headers={"Authorization": "Bearer ui-bearer"} - ) + me = await api.http.get("/agents", headers={"Authorization": "Bearer ui-bearer"}) assert me.status_code == 200 assert (await http.post("/admin/auth/logout")).status_code == 204 assert (await http.get("/admin/auth/session")).status_code == 401 diff --git a/tests/test_routing.py b/tests/test_routing.py index 2c1fe43..5c89038 100644 --- a/tests/test_routing.py +++ b/tests/test_routing.py @@ -14,6 +14,7 @@ from beaver_gateway.frontends.anthropic import AnthropicMessagesFrontend from beaver_gateway.frontends.api import ApiFrontend from beaver_gateway.frontends.base import GatewayRuntime from beaver_gateway.frontends.markdown import MarkdownFrontend +from beaver_gateway.frontends.root import build_root_app from test_conversations import ScriptedClient, World AUTH = {"Authorization": "Bearer tok"} @@ -42,10 +43,11 @@ class Stack: for fe in frontends: fe.configure(self.runtime) self.mirror = asyncio.create_task(self.markdown.mirror.run()) + self.root = build_root_app(frontends) def client(self, fe) -> httpx.AsyncClient: return httpx.AsyncClient( - transport=httpx.ASGITransport(app=fe._app), base_url="http://t" + transport=httpx.ASGITransport(app=self.root), base_url=f"http://t{fe.path}" ) async def close(self) -> None: @@ -83,14 +85,14 @@ async def stack() -> Stack: async def test_api_rejects_deep_with_dispatcher(stack: Stack) -> None: async with stack.client(stack.api) as c: r = await c.post( - "/api/conversations", json={"kind": "deep", "agent": "a"}, headers=AUTH + "/conversations", json={"kind": "deep", "agent": "a"}, headers=AUTH ) assert r.status_code == 400 assert "does not serve kind 'deep'" in r.json()["error"] - r = await c.post("/api/conversations", json={"kind": "job"}, headers=AUTH) + r = await c.post("/conversations", json={"kind": "job"}, headers=AUTH) assert r.status_code == 400 assert "no default agent" in r.json()["error"] - r = await c.get("/api/agents", headers=AUTH) + r = await c.get("/agents", headers=AUTH) agents = {a["name"]: a["kinds"] for a in r.json()["agents"]} assert agents == {"a": ["master", "branch", "job", "fork"], "d": ["deep"]} homes = {f["name"]: f["default_agents"] for f in r.json()["frontends"]} @@ -103,11 +105,11 @@ async def test_api_rejects_deep_with_dispatcher(stack: Stack) -> None: async def test_api_spawn_deep_lands_in_vault(stack: Stack) -> None: async with stack.client(stack.api) as c: - r = await c.post("/api/conversations", json={"kind": "master"}, headers=AUTH) + r = await c.post("/conversations", json={"kind": "master"}, headers=AUTH) assert r.status_code == 201 and r.json()["agent"] == "a" master = r.json()["id"] r = await c.post( - "/api/conversations", + "/conversations", json={"kind": "deep", "seed": "brief", "text": "dig", "title": "Тема"}, headers=AUTH, ) @@ -118,7 +120,7 @@ async def test_api_spawn_deep_lands_in_vault(stack: Stack) -> None: rel = body["bindings"][0]["external_id"] assert rel.endswith("_Тема.md") and rel.startswith("_logs/d/") r = await c.post( - f"/api/conversations/{master}/bind", + f"/conversations/{master}/bind", json={"frontend": "markdown", "external_id": "x.md"}, headers=AUTH, ) diff --git a/ui/bun.lock b/ui/bun.lock index 5df8a50..8aa08a0 100644 --- a/ui/bun.lock +++ b/ui/bun.lock @@ -4,6 +4,10 @@ "workspaces": { "": { "name": "ui", + "dependencies": { + "dompurify": "^3.4.14", + "marked": "^18.0.11", + }, "devDependencies": { "@biomejs/biome": "2.5.9", "@fontsource-variable/inter": "^5.3.0", @@ -230,6 +234,8 @@ "devalue": ["devalue@5.9.2", "", {}, "sha512-po4PAY5c53tw5XMocSnf8A/5OHhbbUftpr93aEN6BBoAdntUmK7vu7wOATqvt7cXO7m1Cl4gMVn6p7n6n4mj0w=="], + "dompurify": ["dompurify@3.4.14", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg=="], + "empathic": ["empathic@2.0.1", "", {}, "sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q=="], "enhanced-resolve": ["enhanced-resolve@5.24.5", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A=="], @@ -330,6 +336,8 @@ "magicast": ["magicast@0.5.4", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "source-map-js": "^1.2.1" } }, "sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w=="], + "marked": ["marked@18.0.11", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-HnslJfsZkRPBDJRHvVtAaWlZHEpSu7u8LgQuJCELjRKuWR+hpq4A7sLq3p8HaI9ypVoXDXxV34CsQJEe1+J5Aw=="], + "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], diff --git a/ui/package.json b/ui/package.json index d9947df..9f9854d 100644 --- a/ui/package.json +++ b/ui/package.json @@ -13,6 +13,10 @@ "prepare": "svelte-kit sync || echo ''", "preview": "vite preview" }, + "dependencies": { + "dompurify": "^3.4.14", + "marked": "^18.0.11" + }, "devDependencies": { "@biomejs/biome": "2.5.9", "@fontsource-variable/inter": "^5.3.0", diff --git a/ui/src/lib/api/types.ts b/ui/src/lib/api/types.ts index 58c5bff..9d92c11 100644 --- a/ui/src/lib/api/types.ts +++ b/ui/src/lib/api/types.ts @@ -127,9 +127,9 @@ export interface FrontendInfo { default_agents: Record; kinds: Kind[]; name: string; - port: number | null; - public_base_url: string | null; + path: string | null; type: string; + url: string | null; } export interface AgentsResponse { diff --git a/ui/src/lib/components/app-sidebar.svelte b/ui/src/lib/components/app-sidebar.svelte index e909482..fedf6a2 100644 --- a/ui/src/lib/components/app-sidebar.svelte +++ b/ui/src/lib/components/app-sidebar.svelte @@ -1,5 +1,7 @@ diff --git a/ui/src/lib/components/conversation-list.svelte b/ui/src/lib/components/conversation-list.svelte index 34ca623..871c036 100644 --- a/ui/src/lib/components/conversation-list.svelte +++ b/ui/src/lib/components/conversation-list.svelte @@ -1,4 +1,5 @@ + +
+ + {@html html} +
diff --git a/ui/src/lib/panel/markdown.ts b/ui/src/lib/panel/markdown.ts new file mode 100644 index 0000000..22e9c9b --- /dev/null +++ b/ui/src/lib/panel/markdown.ts @@ -0,0 +1,18 @@ +import DOMPurify from "dompurify"; +import { marked } from "marked"; + +marked.use({ breaks: true, gfm: true }); + +DOMPurify.addHook("afterSanitizeAttributes", (node) => { + if (node.tagName === "A") { + node.setAttribute("target", "_blank"); + node.setAttribute("rel", "noopener"); + } +}); + +// The one place chat text becomes HTML. The Obsidian panel swaps this for +// Obsidian's own MarkdownRenderer; the admin uses marked + DOMPurify. +export function renderMarkdown(text: string): string { + const html = marked.parse(text, { async: false }); + return DOMPurify.sanitize(html, { ADD_ATTR: ["target"] }); +} diff --git a/ui/src/lib/panel/turn-card.svelte b/ui/src/lib/panel/turn-card.svelte index 2552b0a..a182fd6 100644 --- a/ui/src/lib/panel/turn-card.svelte +++ b/ui/src/lib/panel/turn-card.svelte @@ -10,6 +10,7 @@ } from "$lib/format"; import { cn } from "$lib/utils"; import type { Turn } from "./activity.svelte"; + import Markdown from "./markdown.svelte"; import ToolNodeView from "./tool-node.svelte"; let { turn, now }: { turn: Turn; now: number } = $props(); @@ -65,11 +66,10 @@ {/if} {#if turn.userText} -

- {turn.userText} -

+ {/if} {#if turn.roots.length > 0}
@@ -81,13 +81,13 @@
{/if} {#each turn.says as text, index (index)} -

- - {text} -

+
+ + +
{/each} {#if turn.text} -

{turn.text}

+ {:else if turn.status === "running" && turn.roots.length === 0 && turn.thinking === 0}

Waiting for the model… diff --git a/ui/src/lib/session.svelte.ts b/ui/src/lib/session.svelte.ts index d02b171..e7ba070 100644 --- a/ui/src/lib/session.svelte.ts +++ b/ui/src/lib/session.svelte.ts @@ -2,7 +2,6 @@ import { base } from "$app/paths"; import { ApiClient } from "./api/client"; interface SessionPayload { - api_base: string | null; token: string; user: string; } @@ -37,7 +36,7 @@ class Session { return; } this.user = payload.user; - this.apiBase = payload.api_base ?? window.location.origin; + this.apiBase = window.location.origin; this.client = new ApiClient(this.apiBase, payload.token, { onUnauthorized: () => this.refreshToken(), }); diff --git a/ui/src/lib/ui.svelte.ts b/ui/src/lib/ui.svelte.ts new file mode 100644 index 0000000..05e4837 --- /dev/null +++ b/ui/src/lib/ui.svelte.ts @@ -0,0 +1,44 @@ +import { browser } from "$app/environment"; + +const NAV_KEY = "beaver.ui.nav"; +const RAIL_KEY = "beaver.ui.rail"; + +function stored(key: string, fallback: boolean): boolean { + if (!browser) { + return fallback; + } + const raw = localStorage.getItem(key); + return raw === null ? fallback : raw === "1"; +} + +function store(key: string, value: boolean): void { + if (browser) { + localStorage.setItem(key, value ? "1" : "0"); + } +} + +// Chrome state: the section sidebar and the conversation rail. Explicit +// toggles persist; the automatic collapse on the conversations pages does +// not, so leaving them restores what the user had. +class Ui { + nav = $state(stored(NAV_KEY, true)); + rail = $state(stored(RAIL_KEY, true)); + + setNav(open: boolean, persist = false): void { + this.nav = open; + if (persist) { + store(NAV_KEY, open); + } + } + + toggleNav(): void { + this.setNav(!this.nav, true); + } + + toggleRail(): void { + this.rail = !this.rail; + store(RAIL_KEY, this.rail); + } +} + +export const ui = new Ui(); diff --git a/ui/src/routes/+layout.svelte b/ui/src/routes/+layout.svelte index ada71d8..1fc1fbf 100644 --- a/ui/src/routes/+layout.svelte +++ b/ui/src/routes/+layout.svelte @@ -1,7 +1,7 @@ + + diff --git a/ui/src/routes/conversations/+layout.svelte b/ui/src/routes/conversations/+layout.svelte index 7269560..c015cd2 100644 --- a/ui/src/routes/conversations/+layout.svelte +++ b/ui/src/routes/conversations/+layout.svelte @@ -1,26 +1,66 @@ Conversations · Beaver -

+
- + {#if ui.rail} + + {:else} + +
+ +
+ {/if}
{@render children()}
diff --git a/ui/src/routes/layout.css b/ui/src/routes/layout.css index 603cbdc..04d6475 100644 --- a/ui/src/routes/layout.css +++ b/ui/src/routes/layout.css @@ -297,3 +297,60 @@ @utility hairline { border-color: color-mix(in oklab, var(--border) 100%, transparent); } + +/* chat markdown: typography plugin on the theme tokens, compact rhythm */ +.prose { + --tw-prose-body: var(--foreground); + --tw-prose-headings: var(--foreground); + --tw-prose-lead: var(--muted-foreground); + --tw-prose-links: var(--link); + --tw-prose-bold: var(--foreground); + --tw-prose-counters: var(--muted-foreground); + --tw-prose-bullets: var(--muted-foreground); + --tw-prose-hr: var(--border); + --tw-prose-quotes: var(--foreground); + --tw-prose-quote-borders: var(--border); + --tw-prose-captions: var(--muted-foreground); + --tw-prose-code: var(--foreground); + --tw-prose-pre-code: var(--foreground); + --tw-prose-pre-bg: var(--muted); + --tw-prose-th-borders: var(--border); + --tw-prose-td-borders: var(--border); + font-size: 0.875rem; + line-height: 1.5; +} +.prose + :where(p, ul, ol, pre, blockquote, table):not( + :where([class~="not-prose"] *) + ) { + margin-block: 0.4em; +} +.prose :where(h1, h2, h3, h4):not(:where([class~="not-prose"] *)) { + margin-block: 0.8em 0.4em; + font-size: 1em; + font-weight: 600; +} +.prose :where(li):not(:where([class~="not-prose"] *)) { + margin-block: 0.15em; +} +.prose :where(code):not(:where([class~="not-prose"] *))::before, +.prose :where(code):not(:where([class~="not-prose"] *))::after { + content: none; +} +.prose :where(code):not(:where(pre *)):not(:where([class~="not-prose"] *)) { + padding: 0.1em 0.3em; + font-weight: 500; + background: color-mix(in oklch, var(--muted) 70%, transparent); + border-radius: 0.25rem; +} +.prose :where(pre):not(:where([class~="not-prose"] *)) { + padding: 0.6em 0.8em; + font-size: 0.8125rem; + border-radius: 0.375rem; +} +.prose > :first-child { + margin-top: 0; +} +.prose > :last-child { + margin-bottom: 0; +} diff --git a/ui/vite.config.ts b/ui/vite.config.ts index 7e22963..cda4774 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -3,8 +3,7 @@ import { sveltekit } from "@sveltejs/kit/vite"; import tailwindcss from "@tailwindcss/vite"; import { defineConfig } from "vite"; -const adminOrigin = process.env.ADMIN_ORIGIN ?? "http://127.0.0.1:62992"; -const apiOrigin = process.env.API_ORIGIN ?? "http://127.0.0.1:62994"; +const gatewayOrigin = process.env.GATEWAY_ORIGIN ?? "http://127.0.0.1:62990"; export default defineConfig({ plugins: [ @@ -22,8 +21,8 @@ export default defineConfig({ ], server: { proxy: { - "/admin/auth": adminOrigin, - "/api": apiOrigin, + "/admin/auth": gatewayOrigin, + "/api": gatewayOrigin, }, }, });