refactor: split flat core into capability packages, layer the conversations service, English defaults for every model-facing text
This commit is contained in:
1 parent
253bde1b11
commit
9aaddbed75
77 files changed
+2987
-2944
No files matched your search
@@ -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)
|
||||
Reference in new issue
Block a user