refactor: add markdown frontend

This commit is contained in:
hh
2026-05-20 21:30:10 +02:00
parent a7827b2fa6
commit 3dc780c74c
15 changed files with 1721 additions and 141 deletions
+71 -5
View File
@@ -45,6 +45,8 @@ from jinja2 import Environment, PackageLoader, select_autoescape
from beaver_gateway.core import audit
from beaver_gateway.core.auth import VALID_SCOPES, hash_token
from beaver_gateway.core.turn_record import TurnRecord
from beaver_gateway.frontends._accumulate import StreamAccumulator
from beaver_gateway.frontends.base import Frontend
from beaver_gateway.storage import (
create_token,
@@ -57,6 +59,8 @@ if TYPE_CHECKING:
from collections.abc import AsyncIterator
from datetime import datetime
from anthropic.types import MessageParam
from beaver_gateway.core.events import MessageStreamEvent
from beaver_gateway.frontends.base import GatewayRuntime
@@ -449,7 +453,15 @@ class AdminFrontend(Frontend):
system=system if isinstance(system, str) else None,
)
return StreamingResponse(
_sse_events(events), media_type="text/event-stream"
_sse_events_and_broadcast(
events,
runtime=runtime,
agent_name=agent.name,
input_messages=messages,
system=system if isinstance(system, str) else None,
model=agent.model or agent.name,
),
media_type="text/event-stream",
)
return app
@@ -510,23 +522,31 @@ def _build_endpoint_catalog(
and may have non-trivial transitive deps (aiohttp etc.).
"""
from beaver_gateway.frontends.anthropic import AnthropicMessagesFrontend
from beaver_gateway.frontends.markdown import MarkdownFrontend
from beaver_gateway.frontends.mcp_server import McpServerFrontend
scheme = request.headers.get("x-forwarded-proto") or request.url.scheme
hostname = request.url.hostname or "localhost"
def _base_for(fe: AnthropicMessagesFrontend | McpServerFrontend) -> str:
def _base_for(
fe: AnthropicMessagesFrontend | McpServerFrontend | MarkdownFrontend,
) -> str:
if fe.public_base_url:
return fe.public_base_url
return f"{scheme}://{hostname}:{fe.port}"
anthropic_base: str | None = None
mcp_base: str | None = None
markdown_fe: MarkdownFrontend | None = None
markdown_base: str | None = None
for fe in runtime.frontends:
if isinstance(fe, AnthropicMessagesFrontend) and anthropic_base is None:
anthropic_base = _base_for(fe)
elif isinstance(fe, McpServerFrontend) and mcp_base is None:
mcp_base = _base_for(fe)
elif isinstance(fe, MarkdownFrontend) and markdown_fe is None:
markdown_fe = fe
markdown_base = _base_for(fe)
agent_rows: list[dict[str, Any]] = []
if anthropic_base is not None:
@@ -554,27 +574,57 @@ def _build_endpoint_catalog(
{"namespace": "all", "kind": "bundle", "url": f"{mcp_base}/all/"}
)
markdown_row: dict[str, Any] | None = None
if markdown_fe is not None and markdown_base is not None:
# Pick a sane example filename from the vault, prefer something
# the user can recognise. Falls back to a generic ``chat.md``.
sample_agent = markdown_fe.default_agent or next(
(a.name for a in runtime.agents), "<agent>"
)
markdown_row = {
"url": f"{markdown_base}/chat",
"vault_path": str(markdown_fe.vault_path),
"default_agent": markdown_fe.default_agent,
"log_all_chats": markdown_fe.log_all_chats,
"logged_subdir": markdown_fe.logged_subdir,
"sample_agent": sample_agent,
}
return {
"anthropic_base": anthropic_base,
"mcp_base": mcp_base,
"markdown_base": markdown_base,
"agents": agent_rows,
"mcps": mcp_rows,
"markdown": markdown_row,
}
async def _sse_events(
async def _sse_events_and_broadcast(
events: AsyncIterator[MessageStreamEvent],
*,
runtime: GatewayRuntime,
agent_name: str,
input_messages: list[MessageParam],
system: str | None,
model: str,
) -> AsyncIterator[bytes]:
r"""Serialize a backend stream into Anthropic's ``text/event-stream`` form.
r"""Serialize a backend stream as SSE and broadcast a ``TurnRecord`` after.
Same wire shape as :mod:`beaver_gateway.frontends.anthropic` —
duplicated rather than imported so the admin frontend stays
independent of that module's private helpers, and so a mid-stream
failure surfaces as an in-band ``error`` event the chat UI can
render rather than a dangling connection.
render rather than a dangling connection. The events also feed a
side ``StreamAccumulator`` so once the SSE response closes we hand
the assembled ``Message`` to ``runtime.turn_log_handlers`` (the
markdown frontend's archive logger lives there). ``source="admin"``
so the cross-frontend logger knows where the turn came from.
"""
acc = StreamAccumulator()
try:
async for ev in events:
acc.feed(ev)
payload = ev.model_dump_json()
yield f"event: {ev.type}\ndata: {payload}\n\n".encode()
except Exception as exc: # noqa: BLE001
@@ -583,6 +633,22 @@ async def _sse_events(
{"type": "error", "error": {"type": "api_error", "message": str(exc)}}
)
yield f"event: error\ndata: {err}\n\n".encode()
return
if not runtime.turn_log_handlers:
return
message = acc.finalize(model=model)
record = TurnRecord(
agent_name=agent_name,
input_messages=list(input_messages),
output_message=message,
system=system,
source="admin",
)
for handler in runtime.turn_log_handlers:
try:
await handler(record)
except Exception: # noqa: BLE001
_log.exception("turn_log_handler raised; continuing")
def _set_session_cookie(response: Response, value: str) -> None: