refactor: split flat core into capability packages, layer the conversations service, English defaults for every model-facing text

This commit is contained in:
hh committed 2026-09-02 00:13:20 +02:00
1 parent 253bde1b11
commit 9aaddbed75
77 files changed
+2987 -2944

No files matched your search

+146
View File
@@ -0,0 +1,146 @@
"""Collapse an Anthropic event stream into one ``Message``.
Extracted from ``AnthropicMessagesFrontend`` so the markdown frontend
can run the same accumulation logic when it wants the finalized turn
rather than raw SSE chunks. Mirrors the Anthropic SDK's own accumulator:
walks events, builds block dicts indexed by their ``content_block``
index, folds text / thinking deltas in, buffers ``input_json_delta``
chunks until the block closes (then JSON-parses them once).
"""
from __future__ import annotations
import json
from typing import TYPE_CHECKING, Any, Literal, cast
from anthropic.types import (
InputJSONDelta,
Message,
RawContentBlockDeltaEvent,
RawContentBlockStartEvent,
RawContentBlockStopEvent,
RawMessageDeltaEvent,
RawMessageStartEvent,
SignatureDelta,
TextBlock,
TextDelta,
ThinkingBlock,
ThinkingDelta,
ToolUseBlock,
Usage,
)
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from beaver_gateway.events.stream import MessageStreamEvent, StopReason
__all__ = ["StreamAccumulator", "accumulate"]
class StreamAccumulator:
"""Folds a stream of events into one ``Message``, incrementally.
Use when you need to *both* forward events somewhere (SSE) *and*
keep a finalized ``Message`` for post-stream work (audit, logging
to disk). Call :meth:`feed` for each event, :meth:`finalize` once.
"""
__slots__ = (
"_blocks",
"_json_buffers",
"_message_id",
"_role",
"_stop_reason",
"_stop_sequence",
"_usage",
)
def __init__(self) -> None:
self._message_id: str = ""
self._role: str = "assistant"
self._usage: Usage = Usage(input_tokens=0, output_tokens=0)
self._blocks: dict[int, dict[str, Any]] = {}
self._json_buffers: dict[int, str] = {}
self._stop_reason: str | None = None
self._stop_sequence: str | None = None
def feed(self, ev: MessageStreamEvent) -> None:
# 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):
self._message_id = ev.message.id
self._role = ev.message.role
self._usage = ev.message.usage
elif isinstance(ev, RawContentBlockStartEvent):
self._blocks[ev.index] = ev.content_block.model_dump()
if self._blocks[ev.index].get("type") == "tool_use":
self._json_buffers[ev.index] = ""
elif isinstance(ev, RawContentBlockDeltaEvent):
blk = self._blocks.setdefault(ev.index, {})
delta = ev.delta
if isinstance(delta, TextDelta):
blk["text"] = blk.get("text", "") + delta.text
elif isinstance(delta, InputJSONDelta):
self._json_buffers[ev.index] = (
self._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 = self._blocks.get(ev.index, {})
if blk.get("type") == "tool_use":
raw = self._json_buffers.pop(ev.index, "")
blk["input"] = json.loads(raw) if raw.strip() else {}
elif isinstance(ev, RawMessageDeltaEvent):
self._stop_reason = ev.delta.stop_reason
self._stop_sequence = ev.delta.stop_sequence
if ev.usage.output_tokens:
self._usage = Usage.model_validate(
{
**self._usage.model_dump(),
"output_tokens": ev.usage.output_tokens,
}
)
def finalize(self, *, model: str) -> Message:
content: list[Any] = []
for idx in sorted(self._blocks):
bd = self._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))
# ``role`` is always ``"assistant"`` at the wire level — we
# initialised the field to that and only overwrite from a
# ``RawMessageStartEvent`` which itself carries the same literal.
# The cast keeps both type-checkers happy without a runtime check.
return Message(
id=self._message_id or "msg_unknown",
type="message",
role=cast("Literal['assistant']", self._role),
model=model,
content=content,
stop_reason=cast("StopReason | None", self._stop_reason),
stop_sequence=self._stop_sequence,
usage=self._usage,
)
async def accumulate(
events: AsyncIterator[MessageStreamEvent], *, model: str
) -> Message:
"""Drain ``events`` into one ``Message`` (used by ``stream=false`` paths)."""
acc = StreamAccumulator()
async for ev in events:
acc.feed(ev)
return acc.finalize(model=model)