feat(core,frontends,agents): agent kinds, frontend routing, anthropic on conversations, vault mirror
This commit is contained in:
1 parent
33ccc78fec
commit
1d8d65b69a
17 files changed
+1009
-292
No files matched your search
@@ -4,39 +4,44 @@ Exposes the gateway as an Anthropic-compatible Messages endpoint, so any
|
||||
client that already speaks Anthropic (Cursor, Cline, the official SDK,
|
||||
``curl``) can hit a configured agent by passing its name as ``model``.
|
||||
|
||||
The frontend is intentionally thin: it authenticates the bearer token,
|
||||
resolves ``body.model`` to an agent + its backend, and then either
|
||||
streams the backend's events straight to SSE or accumulates them into a
|
||||
single ``Message`` for ``stream=false`` callers. All provider quirks
|
||||
already live in the backend adapters; we don't translate here.
|
||||
|
||||
Phase 1.4 wires only ``RaycastAgent`` through ``RaycastBackend``;
|
||||
``ClaudeAgent`` lands in Phase 2 and will plug into the same dispatch
|
||||
table without changes to this module.
|
||||
A Claude agent behind this endpoint is a ``deep`` conversation: the client
|
||||
knows nothing about our ids, so the text fingerprint of the history it
|
||||
sends is the ``(anthropic, fingerprint)`` binding of the conversation,
|
||||
rebound after every turn to the fingerprint the next request will carry.
|
||||
A history nobody has seen becomes a new conversation, materialized by the
|
||||
home frontend of ``deep`` (the vault file), and every reply is published
|
||||
on the bus so that file follows the chat. Other agents (Raycast) stay
|
||||
stateless and are only archived through ``turn_log_handlers``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request, status
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
|
||||
from beaver_gateway.agents.claude import ClaudeAgent
|
||||
from beaver_gateway.core import audit
|
||||
from beaver_gateway.core.transcript import fingerprint, text_of
|
||||
from beaver_gateway.core.turn_capture import TurnCapture
|
||||
from beaver_gateway.core.turn_record import TurnRecord
|
||||
from beaver_gateway.frontends._accumulate import StreamAccumulator, accumulate
|
||||
from beaver_gateway.frontends._accumulate import StreamAccumulator
|
||||
from beaver_gateway.frontends._auth import require_token
|
||||
from beaver_gateway.frontends.base import Frontend
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator
|
||||
from collections.abc import AsyncIterator, Awaitable, Callable
|
||||
|
||||
from anthropic.types import Message, MessageParam
|
||||
|
||||
from beaver_gateway.core.conversations import Conversations
|
||||
from beaver_gateway.core.events import MessageStreamEvent
|
||||
from beaver_gateway.frontends.base import GatewayRuntime
|
||||
from beaver_gateway.storage.models import Conversation
|
||||
|
||||
|
||||
_log = logging.getLogger("beaver_gateway.frontends.anthropic")
|
||||
@@ -44,10 +49,16 @@ _log = logging.getLogger("beaver_gateway.frontends.anthropic")
|
||||
|
||||
__all__ = ["AnthropicMessagesFrontend"]
|
||||
|
||||
FRONTEND = "anthropic"
|
||||
_TITLE_LEN = 80
|
||||
|
||||
|
||||
class AnthropicMessagesFrontend(Frontend):
|
||||
"""FastAPI app behind ``POST /v1/messages`` + ``GET /v1/models``."""
|
||||
|
||||
name = FRONTEND
|
||||
kinds = ("deep",)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
@@ -57,14 +68,6 @@ class AnthropicMessagesFrontend(Frontend):
|
||||
) -> None:
|
||||
self.host = host
|
||||
self.port = port
|
||||
# External URL prefix the reverse proxy (Caddy/nginx/Cloudflare/…)
|
||||
# uses to reach this frontend, e.g. ``https://api.example.com/ai``.
|
||||
# The frontend's internal paths (``/v1/messages``, ``/v1/models``)
|
||||
# are appended to it when the admin dashboard renders copy-pastable
|
||||
# URLs. Trailing slash is stripped so the concatenation is
|
||||
# idempotent. ``None`` means "advertise raw ``host:port``" (dev /
|
||||
# no proxy) — the dashboard then derives the base from the
|
||||
# browser's own request hostname.
|
||||
self.public_base_url = public_base_url.rstrip("/") if public_base_url else None
|
||||
self._runtime: GatewayRuntime | None = None
|
||||
self._app: FastAPI | None = None
|
||||
@@ -74,9 +77,6 @@ class AnthropicMessagesFrontend(Frontend):
|
||||
self._app = self._build_app(runtime)
|
||||
|
||||
async def serve(self) -> None:
|
||||
# Local import: uvicorn pulls in a lot, no reason to load it when
|
||||
# something else (a test, a script) imports this module just for
|
||||
# the FastAPI factory.
|
||||
import uvicorn
|
||||
|
||||
if self._app is None:
|
||||
@@ -134,9 +134,6 @@ class AnthropicMessagesFrontend(Frontend):
|
||||
|
||||
backend = runtime.backends.get(agent.name)
|
||||
if backend is None:
|
||||
# Agent exists in config but its backend isn't wired in
|
||||
# this phase (e.g. ClaudeAgent before Phase 2, or a
|
||||
# RaycastAgent without RAYCAST_BEARER set at startup).
|
||||
raise HTTPException(
|
||||
status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
f"no backend configured for agent {agent.name!r}",
|
||||
@@ -144,7 +141,12 @@ class AnthropicMessagesFrontend(Frontend):
|
||||
|
||||
messages = body.get("messages") or []
|
||||
system = body.get("system")
|
||||
system_str = system if isinstance(system, str) else None
|
||||
stream_flag = bool(body.get("stream", False))
|
||||
if not messages or messages[-1].get("role") != "user":
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST, "the last message must be a user turn"
|
||||
)
|
||||
|
||||
_log.info(
|
||||
"messages: actor=%s agent=%s stream=%s msgs=%d",
|
||||
@@ -153,10 +155,6 @@ class AnthropicMessagesFrontend(Frontend):
|
||||
stream_flag,
|
||||
len(messages),
|
||||
)
|
||||
# Record at request acceptance, not at stream completion:
|
||||
# a long streaming response can be aborted mid-flight by
|
||||
# the client, and we still want the row in the audit trail.
|
||||
# Detail stays small — no message bodies, no system prompt.
|
||||
await audit.log(
|
||||
runtime,
|
||||
actor=f"token:{token_name}",
|
||||
@@ -166,74 +164,139 @@ class AnthropicMessagesFrontend(Frontend):
|
||||
msgs=len(messages),
|
||||
)
|
||||
|
||||
# Forward per-request knobs the Anthropic body may carry —
|
||||
# backend adapters layer these over per-agent defaults. Only
|
||||
# values explicitly present (not Anthropic-defaulted ones we
|
||||
# never received) are forwarded, so the agent's default still
|
||||
# wins when the caller omits the field.
|
||||
options: dict[str, Any] = {}
|
||||
if isinstance(body.get("temperature"), int | float):
|
||||
options["temperature"] = body["temperature"]
|
||||
if isinstance(agent, ClaudeAgent) and runtime.conversations is not None:
|
||||
conv, history = await _resolve(runtime.conversations, agent, messages)
|
||||
turn_id = f"turn_{uuid.uuid4().hex[:12]}"
|
||||
capture = TurnCapture()
|
||||
events = runtime.conversations.turn(
|
||||
conv,
|
||||
messages=messages,
|
||||
origin="user",
|
||||
capture=capture,
|
||||
turn_id=turn_id,
|
||||
)
|
||||
|
||||
events = backend.complete(
|
||||
agent=agent,
|
||||
messages=messages,
|
||||
system=system if isinstance(system, str) else None,
|
||||
**options,
|
||||
)
|
||||
async def after(message: Message) -> None:
|
||||
await _finish(
|
||||
runtime,
|
||||
conv,
|
||||
messages=messages,
|
||||
message=message,
|
||||
capture=capture,
|
||||
turn_id=turn_id,
|
||||
history=history,
|
||||
)
|
||||
else:
|
||||
options: dict[str, Any] = {}
|
||||
if isinstance(body.get("temperature"), int | float):
|
||||
options["temperature"] = body["temperature"]
|
||||
events = backend.complete(
|
||||
agent=agent, messages=messages, system=system_str, **options
|
||||
)
|
||||
|
||||
system_str = system if isinstance(system, str) else None
|
||||
|
||||
if stream_flag:
|
||||
# Side-accumulate while streaming so we can still emit a
|
||||
# ``TurnRecord`` after the response closes. The buffered
|
||||
# ``Message`` lives only in this coroutine's frame; SSE
|
||||
# bytes still flow to the client unchanged.
|
||||
return StreamingResponse(
|
||||
_sse_and_broadcast(
|
||||
events,
|
||||
runtime=runtime,
|
||||
async def after(message: Message) -> None:
|
||||
await _broadcast_turn(
|
||||
runtime,
|
||||
agent_name=agent.name,
|
||||
input_messages=messages,
|
||||
output_message=message,
|
||||
system=system_str,
|
||||
model=model,
|
||||
),
|
||||
)
|
||||
|
||||
if stream_flag:
|
||||
return StreamingResponse(
|
||||
_sse(events, model=model, after=after),
|
||||
media_type="text/event-stream",
|
||||
)
|
||||
message = await accumulate(events, model=model)
|
||||
await _broadcast_turn(
|
||||
runtime,
|
||||
agent_name=agent.name,
|
||||
input_messages=messages,
|
||||
output_message=message,
|
||||
system=system_str,
|
||||
)
|
||||
acc = StreamAccumulator()
|
||||
async for ev in events:
|
||||
acc.feed(ev)
|
||||
message = acc.finalize(model=model)
|
||||
await after(message)
|
||||
return JSONResponse(content=message.model_dump(mode="json"))
|
||||
|
||||
return app
|
||||
|
||||
|
||||
async def _sse_and_broadcast(
|
||||
async def _resolve(
|
||||
conversations: Conversations, agent: ClaudeAgent, messages: list[MessageParam]
|
||||
) -> tuple[Conversation, list[dict[str, str]] | None]:
|
||||
prior = [dict(m) for m in messages[:-1]]
|
||||
if prior:
|
||||
conv = await conversations.find_bound(
|
||||
frontend=FRONTEND, external_id=fingerprint(prior)
|
||||
)
|
||||
if conv is not None:
|
||||
if conv.agent_name != agent.name:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
f"this chat runs on {conv.agent_name!r}, not {agent.name!r}",
|
||||
)
|
||||
return conv, None
|
||||
first = next(
|
||||
(text_of(m.get("content")) for m in messages if m.get("role") == "user"), ""
|
||||
)
|
||||
try:
|
||||
conv = await conversations.create(
|
||||
kind="deep",
|
||||
agent=agent.name,
|
||||
origin=FRONTEND,
|
||||
title=" ".join(first.split())[:_TITLE_LEN] or None,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
|
||||
await conversations.materialize(conv)
|
||||
history = [
|
||||
{"role": str(m.get("role", "")), "text": text_of(m.get("content"))}
|
||||
for m in prior
|
||||
]
|
||||
return conv, history
|
||||
|
||||
|
||||
async def _finish(
|
||||
runtime: GatewayRuntime,
|
||||
conv: Conversation,
|
||||
*,
|
||||
messages: list[MessageParam],
|
||||
message: Message,
|
||||
capture: TurnCapture,
|
||||
turn_id: str,
|
||||
history: list[dict[str, str]] | None,
|
||||
) -> None:
|
||||
reply = "\n\n".join(
|
||||
getattr(b, "text", "")
|
||||
for b in message.content
|
||||
if getattr(b, "type", "") == "text"
|
||||
).strip()
|
||||
tail = capture.synthesized_messages or [
|
||||
{"role": "assistant", "content": [{"type": "text", "text": reply}]}
|
||||
]
|
||||
await runtime.conversations.bind(
|
||||
conv, frontend=FRONTEND, external_id=fingerprint([*messages, *tail])
|
||||
)
|
||||
runtime.bus.publish(
|
||||
"reply",
|
||||
conversation_id=conv.external_id,
|
||||
turn_id=turn_id,
|
||||
source=FRONTEND,
|
||||
prompt=text_of(messages[-1].get("content")),
|
||||
text=reply,
|
||||
history=history,
|
||||
)
|
||||
|
||||
|
||||
async def _sse(
|
||||
events: AsyncIterator[MessageStreamEvent],
|
||||
*,
|
||||
runtime: GatewayRuntime,
|
||||
agent_name: str,
|
||||
input_messages: list[MessageParam],
|
||||
system: str | None,
|
||||
model: str,
|
||||
after: Callable[[Message], Awaitable[None]],
|
||||
) -> AsyncIterator[bytes]:
|
||||
r"""Serialize an event stream to SSE; broadcast a :class:`TurnRecord` after.
|
||||
r"""Serialize an event stream to SSE, then hand the assembled ``Message`` on.
|
||||
|
||||
Each event becomes ``event: <type>\ndata: <json>\n\n`` — the shape
|
||||
Each event becomes ``event: <type>\ndata: <json>\n\n`` - the shape
|
||||
the Anthropic SDK's SSE decoder expects. Errors mid-stream are
|
||||
swallowed into a synthetic ``error`` event so the client sees the
|
||||
failure rather than a hung connection.
|
||||
|
||||
The same events feed a :class:`StreamAccumulator` on the side so that
|
||||
once the SSE response closes we can hand a fully-assembled
|
||||
``Message`` to every ``runtime.turn_log_handlers`` entry (the
|
||||
markdown frontend's archive logger lives in there). Broadcast
|
||||
failures are caught — they must never bubble up to the client.
|
||||
"""
|
||||
acc = StreamAccumulator()
|
||||
try:
|
||||
@@ -248,14 +311,10 @@ async def _sse_and_broadcast(
|
||||
)
|
||||
yield f"event: error\ndata: {err}\n\n".encode()
|
||||
return
|
||||
message = acc.finalize(model=model)
|
||||
await _broadcast_turn(
|
||||
runtime,
|
||||
agent_name=agent_name,
|
||||
input_messages=input_messages,
|
||||
output_message=message,
|
||||
system=system,
|
||||
)
|
||||
try:
|
||||
await after(acc.finalize(model=model))
|
||||
except Exception: # noqa: BLE001
|
||||
_log.exception("post-turn hook failed")
|
||||
|
||||
|
||||
async def _broadcast_turn(
|
||||
@@ -266,11 +325,6 @@ async def _broadcast_turn(
|
||||
output_message: Message,
|
||||
system: str | None,
|
||||
) -> None:
|
||||
"""Fire each ``turn_log_handlers`` entry with a fresh :class:`TurnRecord`.
|
||||
|
||||
Handler exceptions are caught and logged — they're observability
|
||||
plumbing, not part of the user-visible request path.
|
||||
"""
|
||||
if not runtime.turn_log_handlers:
|
||||
return
|
||||
record = TurnRecord(
|
||||
@@ -278,7 +332,7 @@ async def _broadcast_turn(
|
||||
input_messages=list(input_messages),
|
||||
output_message=output_message,
|
||||
system=system,
|
||||
source="anthropic",
|
||||
source=FRONTEND,
|
||||
)
|
||||
for handler in runtime.turn_log_handlers:
|
||||
try:
|
||||
|
||||
Reference in new issue
Block a user