refactor: add markdown frontend
This commit is contained in:
1 parent
a7827b2fa6
commit
3dc780c74c
15 files changed
+1721
-141
No files matched your search
@@ -21,31 +21,20 @@ import json
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from anthropic.types import (
|
||||
InputJSONDelta,
|
||||
Message,
|
||||
RawContentBlockDeltaEvent,
|
||||
RawContentBlockStartEvent,
|
||||
RawContentBlockStopEvent,
|
||||
RawMessageDeltaEvent,
|
||||
RawMessageStartEvent,
|
||||
SignatureDelta,
|
||||
TextBlock,
|
||||
TextDelta,
|
||||
ThinkingBlock,
|
||||
ThinkingDelta,
|
||||
ToolUseBlock,
|
||||
Usage,
|
||||
)
|
||||
from fastapi import FastAPI, HTTPException, Request, status
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
|
||||
from beaver_gateway.core import audit
|
||||
from beaver_gateway.core.turn_record import TurnRecord
|
||||
from beaver_gateway.frontends._accumulate import StreamAccumulator, accumulate
|
||||
from beaver_gateway.frontends._auth import require_token
|
||||
from beaver_gateway.frontends.base import Frontend
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
from anthropic.types import Message, MessageParam
|
||||
|
||||
from beaver_gateway.core.events import MessageStreamEvent
|
||||
from beaver_gateway.frontends.base import GatewayRuntime
|
||||
|
||||
@@ -109,7 +98,7 @@ class AnthropicMessagesFrontend(Frontend):
|
||||
|
||||
@app.get("/v1/models")
|
||||
async def list_models(request: Request) -> dict[str, Any]:
|
||||
await _require_token(request, runtime, scope="messages")
|
||||
await require_token(request, runtime, scope="messages")
|
||||
data = [
|
||||
{
|
||||
"type": "model",
|
||||
@@ -123,7 +112,7 @@ class AnthropicMessagesFrontend(Frontend):
|
||||
|
||||
@app.post("/v1/messages")
|
||||
async def create_message(request: Request) -> Any:
|
||||
token_name = await _require_token(request, runtime, scope="messages")
|
||||
token_name = await require_token(request, runtime, scope="messages")
|
||||
try:
|
||||
body = await request.json()
|
||||
except json.JSONDecodeError as exc:
|
||||
@@ -193,58 +182,63 @@ class AnthropicMessagesFrontend(Frontend):
|
||||
**options,
|
||||
)
|
||||
|
||||
system_str = system if isinstance(system, str) else None
|
||||
|
||||
if stream_flag:
|
||||
return StreamingResponse(_sse(events), media_type="text/event-stream")
|
||||
message = await _accumulate(events, model=model)
|
||||
# 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,
|
||||
agent_name=agent.name,
|
||||
input_messages=messages,
|
||||
system=system_str,
|
||||
model=model,
|
||||
),
|
||||
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,
|
||||
)
|
||||
return JSONResponse(content=message.model_dump(mode="json"))
|
||||
|
||||
return app
|
||||
|
||||
|
||||
async def _require_token(
|
||||
request: Request, runtime: GatewayRuntime, *, scope: str
|
||||
) -> str:
|
||||
"""Verify the request's bearer + scope, return the token's audit name.
|
||||
|
||||
Accepts both ``X-Api-Key: <token>`` (what the official Anthropic
|
||||
SDK sends — LibreChat, the CLI, third-party clients) and
|
||||
``Authorization: Bearer <token>`` (curl, Cursor). 401 on missing /
|
||||
unknown token; 403 on a known token whose scope doesn't cover
|
||||
``scope``. Bootstrap tokens implicitly carry ``"*"`` and pass
|
||||
every scope check.
|
||||
"""
|
||||
api_key = request.headers.get("x-api-key")
|
||||
identity = (
|
||||
await runtime.token_store.verify(api_key)
|
||||
if api_key
|
||||
else await runtime.token_store.verify_bearer(
|
||||
request.headers.get("authorization")
|
||||
)
|
||||
)
|
||||
if identity is None:
|
||||
raise HTTPException(
|
||||
status.HTTP_401_UNAUTHORIZED,
|
||||
"invalid or missing bearer token",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
if not identity.allows(scope):
|
||||
raise HTTPException(
|
||||
status.HTTP_403_FORBIDDEN,
|
||||
f"token scope {identity.scope!r} does not cover {scope!r}",
|
||||
)
|
||||
return identity.name
|
||||
|
||||
|
||||
async def _sse(events: AsyncIterator[MessageStreamEvent]) -> AsyncIterator[bytes]:
|
||||
r"""Serialize an event stream into Anthropic's ``text/event-stream`` form.
|
||||
async def _sse_and_broadcast(
|
||||
events: AsyncIterator[MessageStreamEvent],
|
||||
*,
|
||||
runtime: GatewayRuntime,
|
||||
agent_name: str,
|
||||
input_messages: list[MessageParam],
|
||||
system: str | None,
|
||||
model: str,
|
||||
) -> AsyncIterator[bytes]:
|
||||
r"""Serialize an event stream to SSE; broadcast a :class:`TurnRecord` after.
|
||||
|
||||
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:
|
||||
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
|
||||
@@ -253,83 +247,41 @@ async def _sse(events: AsyncIterator[MessageStreamEvent]) -> AsyncIterator[bytes
|
||||
{"type": "error", "error": {"type": "api_error", "message": str(exc)}}
|
||||
)
|
||||
yield f"event: error\ndata: {err}\n\n".encode()
|
||||
|
||||
|
||||
async def _accumulate(
|
||||
events: AsyncIterator[MessageStreamEvent], *, model: str
|
||||
) -> Message:
|
||||
"""Collapse a stream-event sequence into one ``Message`` (``stream=false``).
|
||||
|
||||
Mirrors the Anthropic SDK's own accumulator: walk events, build
|
||||
block dicts indexed by their ``content_block`` index, fold text /
|
||||
thinking deltas in, buffer ``input_json_delta`` chunks until the
|
||||
block closes (then JSON-parse them once).
|
||||
"""
|
||||
message_id = ""
|
||||
role = "assistant"
|
||||
usage = Usage(input_tokens=0, output_tokens=0)
|
||||
blocks: dict[int, dict[str, Any]] = {}
|
||||
json_buffers: dict[int, str] = {}
|
||||
stop_reason: str | None = None
|
||||
stop_sequence: str | None = None
|
||||
|
||||
async for ev in events:
|
||||
# isinstance, not ``ev.type == "..."``: ty narrows on the
|
||||
# discriminator only via the class, and the raw event union
|
||||
# carries its own discriminators (``Raw*Event``) the SDK
|
||||
# already promises.
|
||||
if isinstance(ev, RawMessageStartEvent):
|
||||
message_id = ev.message.id
|
||||
role = ev.message.role
|
||||
usage = ev.message.usage
|
||||
elif isinstance(ev, RawContentBlockStartEvent):
|
||||
blocks[ev.index] = ev.content_block.model_dump()
|
||||
if blocks[ev.index].get("type") == "tool_use":
|
||||
json_buffers[ev.index] = ""
|
||||
elif isinstance(ev, RawContentBlockDeltaEvent):
|
||||
blk = blocks.setdefault(ev.index, {})
|
||||
delta = ev.delta
|
||||
if isinstance(delta, TextDelta):
|
||||
blk["text"] = blk.get("text", "") + delta.text
|
||||
elif isinstance(delta, InputJSONDelta):
|
||||
json_buffers[ev.index] = (
|
||||
json_buffers.get(ev.index, "") + delta.partial_json
|
||||
)
|
||||
elif isinstance(delta, ThinkingDelta):
|
||||
blk["thinking"] = blk.get("thinking", "") + delta.thinking
|
||||
elif isinstance(delta, SignatureDelta):
|
||||
blk["signature"] = delta.signature
|
||||
elif isinstance(ev, RawContentBlockStopEvent):
|
||||
blk = blocks.get(ev.index, {})
|
||||
if blk.get("type") == "tool_use":
|
||||
raw = json_buffers.pop(ev.index, "")
|
||||
blk["input"] = json.loads(raw) if raw.strip() else {}
|
||||
elif isinstance(ev, RawMessageDeltaEvent):
|
||||
stop_reason = ev.delta.stop_reason
|
||||
stop_sequence = ev.delta.stop_sequence
|
||||
if ev.usage.output_tokens:
|
||||
usage = Usage.model_validate(
|
||||
{**usage.model_dump(), "output_tokens": ev.usage.output_tokens}
|
||||
)
|
||||
|
||||
content = []
|
||||
for idx in sorted(blocks):
|
||||
bd = blocks[idx]
|
||||
btype = bd.get("type")
|
||||
if btype == "text":
|
||||
content.append(TextBlock.model_validate(bd))
|
||||
elif btype == "tool_use":
|
||||
content.append(ToolUseBlock.model_validate(bd))
|
||||
elif btype == "thinking":
|
||||
content.append(ThinkingBlock.model_validate(bd))
|
||||
|
||||
return Message(
|
||||
id=message_id or "msg_unknown",
|
||||
type="message",
|
||||
role=role,
|
||||
model=model,
|
||||
content=content,
|
||||
stop_reason=stop_reason, # type: ignore[arg-type]
|
||||
stop_sequence=stop_sequence,
|
||||
usage=usage,
|
||||
return
|
||||
message = acc.finalize(model=model)
|
||||
await _broadcast_turn(
|
||||
runtime,
|
||||
agent_name=agent_name,
|
||||
input_messages=input_messages,
|
||||
output_message=message,
|
||||
system=system,
|
||||
)
|
||||
|
||||
|
||||
async def _broadcast_turn(
|
||||
runtime: GatewayRuntime,
|
||||
*,
|
||||
agent_name: str,
|
||||
input_messages: list[MessageParam],
|
||||
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(
|
||||
agent_name=agent_name,
|
||||
input_messages=list(input_messages),
|
||||
output_message=output_message,
|
||||
system=system,
|
||||
source="anthropic",
|
||||
)
|
||||
for handler in runtime.turn_log_handlers:
|
||||
try:
|
||||
await handler(record)
|
||||
except Exception: # noqa: BLE001
|
||||
_log.exception("turn_log_handler raised; continuing")
|
||||
Reference in new issue
Block a user