feat: implement raycast backend
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
"""Backend adapters.
|
||||
|
||||
Each backend wraps a provider-specific SDK (``raycast-api``, ``claude-code-api``)
|
||||
and yields the unified :class:`~beaver_gateway.core.events.MessageStreamEvent`
|
||||
family. The Anthropic-style frontend serialises events straight to SSE.
|
||||
"""
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Backend protocol.
|
||||
|
||||
A backend turns an Anthropic-style turn (``messages`` + agent definition)
|
||||
into a stream of :class:`~beaver_gateway.core.events.MessageStreamEvent`
|
||||
records. The frontend serializes whatever comes out straight to SSE, so
|
||||
backends are the only place where provider quirks are translated.
|
||||
|
||||
Implementations are plain :class:`typing.Protocol` conformers — no ABC
|
||||
subclassing — to keep them swappable in tests with bare async generators.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any, Protocol
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator, Iterable
|
||||
|
||||
from anthropic.types import MessageParam
|
||||
|
||||
from beaver_gateway.agents.base import BaseAgent
|
||||
from beaver_gateway.core.events import MessageStreamEvent
|
||||
|
||||
|
||||
class Backend(Protocol):
|
||||
"""Single-method protocol; ``complete`` returns an async iterator of events."""
|
||||
|
||||
def complete(
|
||||
self,
|
||||
*,
|
||||
agent: BaseAgent,
|
||||
messages: Iterable[MessageParam],
|
||||
system: str | None = None,
|
||||
**options: Any,
|
||||
) -> AsyncIterator[MessageStreamEvent]:
|
||||
"""Yield Anthropic stream events for one turn against ``agent``."""
|
||||
...
|
||||
@@ -0,0 +1,468 @@
|
||||
"""Raycast backend adapter.
|
||||
|
||||
Translates between Anthropic's ``/v1/messages`` wire vocabulary (incoming
|
||||
``MessageParam`` history, outgoing ``MessageStreamEvent`` SSE) and the
|
||||
``raycast-api`` SDK (``Message`` history, ``ChatStreamChunk`` SSE).
|
||||
|
||||
Two halves live here:
|
||||
|
||||
* :func:`_to_raycast_messages` — pure conversion of an Anthropic message
|
||||
list into ``list[raycast_api.Message]``. ``tool_result`` blocks carry no
|
||||
tool name in Anthropic; we recover it by remembering each ``tool_use``
|
||||
id we saw upstream.
|
||||
* :meth:`RaycastBackend.complete` — opens a ``client.chat.stream`` and
|
||||
walks chunks through a tiny block-state machine. The state machine
|
||||
exists only because Raycast streams ``tool_calls`` in three phases
|
||||
(open with id+name, deltas with empty id, final summary with the full
|
||||
``arguments``) — Anthropic wants one ``content_block_start`` → deltas
|
||||
→ ``content_block_stop`` per block, so we de-duplicate the final
|
||||
summary against the per-delta increments already emitted.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from raycast_api import Message as RaycastMessage
|
||||
from raycast_api import RemoteTool, Tool, ToolCall
|
||||
|
||||
from beaver_gateway.agents.raycast import RaycastAgent
|
||||
from beaver_gateway.core.events import (
|
||||
StopReason,
|
||||
build_content_block_stop,
|
||||
build_input_json_delta,
|
||||
build_message_delta,
|
||||
build_message_start,
|
||||
build_message_stop,
|
||||
build_text_block_start,
|
||||
build_text_delta,
|
||||
build_thinking_block_start,
|
||||
build_thinking_delta,
|
||||
build_tool_use_block_start,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator, Iterable, Mapping, Sequence
|
||||
|
||||
from anthropic.types import MessageParam
|
||||
from raycast_api import ChatStreamChunk, Client
|
||||
|
||||
from beaver_gateway.agents.base import BaseAgent
|
||||
from beaver_gateway.core.events import MessageStreamEvent
|
||||
else:
|
||||
from collections.abc import Mapping
|
||||
|
||||
|
||||
__all__ = ["RaycastBackend"]
|
||||
|
||||
|
||||
_RAYCAST_TO_ANTHROPIC_STOP: dict[str, StopReason] = {
|
||||
"stop": "end_turn",
|
||||
"STOP": "end_turn",
|
||||
"end_turn": "end_turn",
|
||||
"tool_calls": "tool_use",
|
||||
"tool_use": "tool_use",
|
||||
"length": "max_tokens",
|
||||
"max_tokens": "max_tokens",
|
||||
"stop_sequence": "stop_sequence",
|
||||
}
|
||||
|
||||
|
||||
def _first_set[T](*values: T | None) -> T | None:
|
||||
"""Return the first value that isn't ``None``, else ``None``.
|
||||
|
||||
Used to layer per-request options over per-agent defaults: a real
|
||||
``0.0`` temperature on the request must override the agent's
|
||||
``None``, but the agent's value must take effect when the request
|
||||
omits it. ``or``-chaining is wrong here because ``0.0`` / ``""`` are
|
||||
legitimate values and falsy.
|
||||
"""
|
||||
for v in values:
|
||||
if v is not None:
|
||||
return v
|
||||
return None
|
||||
|
||||
|
||||
def _map_stop_reason(raw: str | None) -> StopReason:
|
||||
"""Map Raycast ``finish_reason`` strings into Anthropic stop reasons.
|
||||
|
||||
Unknown values collapse to ``end_turn`` — Anthropic clients treat that
|
||||
as a clean finish, which is the right user-visible behaviour when the
|
||||
upstream simply went off-vocabulary.
|
||||
"""
|
||||
if raw is None:
|
||||
return "end_turn"
|
||||
return _RAYCAST_TO_ANTHROPIC_STOP.get(raw, "end_turn")
|
||||
|
||||
|
||||
def _as_mapping(block: object) -> Mapping[str, Any] | None:
|
||||
"""Narrow a block param (TypedDict | dict | anything) to a read-only mapping.
|
||||
|
||||
ty refuses to assign a TypedDict to ``dict[str, Any]`` (TypedDicts are
|
||||
not freely-mutable dicts in its model), and our access pattern is
|
||||
strictly read-only — so we go through ``Mapping``.
|
||||
"""
|
||||
if isinstance(block, Mapping):
|
||||
return cast("Mapping[str, Any]", block)
|
||||
return None
|
||||
|
||||
|
||||
def _extract_tool_result_text(content: object) -> str:
|
||||
"""Flatten an Anthropic ``tool_result`` block's content into plain text.
|
||||
|
||||
Anthropic accepts either a string or a list of typed blocks (text /
|
||||
image / etc.). Raycast only carries text in tool results, so we keep
|
||||
text blocks and JSON-encode anything richer rather than dropping it.
|
||||
"""
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
parts: list[str] = []
|
||||
for block in content:
|
||||
block_map = _as_mapping(block)
|
||||
if block_map is not None and block_map.get("type") == "text":
|
||||
parts.append(str(block_map.get("text", "")))
|
||||
else:
|
||||
parts.append(
|
||||
json.dumps(block, separators=(",", ":"), ensure_ascii=False)
|
||||
)
|
||||
return "\n".join(parts)
|
||||
return json.dumps(content, separators=(",", ":"), ensure_ascii=False)
|
||||
|
||||
|
||||
def _to_raycast_messages(
|
||||
messages: Iterable[MessageParam],
|
||||
) -> list[RaycastMessage]:
|
||||
"""Convert an Anthropic message history into a Raycast one.
|
||||
|
||||
``tool_use_id → name`` is tracked across the iteration so that
|
||||
Raycast ``tool`` messages — which require a tool name the Anthropic
|
||||
side does not carry on ``tool_result`` blocks — can be reconstructed.
|
||||
"""
|
||||
out: list[RaycastMessage] = []
|
||||
tool_use_names: dict[str, str] = {}
|
||||
|
||||
for msg in messages:
|
||||
role = msg["role"]
|
||||
content = msg.get("content", "")
|
||||
|
||||
if isinstance(content, str):
|
||||
if role == "user":
|
||||
out.append(RaycastMessage.user(content))
|
||||
else:
|
||||
out.append(RaycastMessage.assistant(text=content))
|
||||
continue
|
||||
|
||||
if role == "user":
|
||||
text_parts: list[str] = []
|
||||
tool_results: list[Mapping[str, Any]] = []
|
||||
for block in content:
|
||||
block_map = _as_mapping(block)
|
||||
if block_map is None:
|
||||
continue
|
||||
btype = block_map.get("type")
|
||||
if btype == "text":
|
||||
text_parts.append(str(block_map.get("text", "")))
|
||||
elif btype == "tool_result":
|
||||
tool_results.append(block_map)
|
||||
if text_parts:
|
||||
out.append(RaycastMessage.user("\n".join(text_parts)))
|
||||
for tr in tool_results:
|
||||
tool_use_id = str(tr.get("tool_use_id", ""))
|
||||
out.append(
|
||||
RaycastMessage.tool(
|
||||
tool_call_id=tool_use_id,
|
||||
name=tool_use_names.get(tool_use_id, ""),
|
||||
result=_extract_tool_result_text(tr.get("content", "")),
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
text_parts = []
|
||||
tool_calls: list[ToolCall] = []
|
||||
for block in content:
|
||||
block_map = _as_mapping(block)
|
||||
if block_map is None:
|
||||
continue
|
||||
btype = block_map.get("type")
|
||||
if btype == "text":
|
||||
text_parts.append(str(block_map.get("text", "")))
|
||||
elif btype == "tool_use":
|
||||
tu_id = str(block_map.get("id", ""))
|
||||
tu_name = str(block_map.get("name", ""))
|
||||
tool_use_names[tu_id] = tu_name
|
||||
tool_calls.append(
|
||||
ToolCall(
|
||||
id=tu_id,
|
||||
name=tu_name,
|
||||
arguments=json.dumps(
|
||||
block_map.get("input", {}),
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
out.append(
|
||||
RaycastMessage.assistant(
|
||||
text="\n".join(text_parts),
|
||||
tool_calls=tool_calls or None,
|
||||
)
|
||||
)
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def _build_tool_list(
|
||||
agent: RaycastAgent,
|
||||
) -> list[Tool | RemoteTool | str] | None:
|
||||
"""Wrap each native-tool name as a Raycast remote tool.
|
||||
|
||||
Phase 1.2 scope: only the three model-agnostic remote tools
|
||||
(`web_search`, `search_images`, `read_page`). Client-defined tools
|
||||
coming through the Anthropic body's ``tools`` field stay out — that's
|
||||
Phase 1.3+ alongside ``accept_client_tools``.
|
||||
"""
|
||||
if not agent.available_native_tools:
|
||||
return None
|
||||
return [Tool.remote(name) for name in agent.available_native_tools]
|
||||
|
||||
|
||||
class _BlockState:
|
||||
"""Tracks the currently-open Anthropic content block, if any.
|
||||
|
||||
Anthropic events are sequential per block (``content_block_start`` →
|
||||
deltas → ``content_block_stop``). Raycast streams text and tool_calls
|
||||
interleaved across chunks; we keep one slot open at a time and close
|
||||
it whenever the kind changes.
|
||||
|
||||
Tool-call routing needs two side tables because Raycast streams ids
|
||||
in phase 1 only and ``index`` in every chunk: ``tool_id_to_block``
|
||||
keys by Raycast tool-call id, ``tool_idx_to_id`` resolves chunk
|
||||
indices back to that id for the no-id delta chunks.
|
||||
"""
|
||||
|
||||
__slots__ = ("index", "kind", "tool_id_to_block", "tool_idx_to_id")
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.index: int = -1
|
||||
self.kind: str | None = None # "text" | "thinking" | "tool_use" | None
|
||||
self.tool_id_to_block: dict[str, int] = {}
|
||||
self.tool_idx_to_id: dict[int, str] = {}
|
||||
|
||||
|
||||
class RaycastBackend:
|
||||
"""Adapter from ``raycast-api`` chat streams to Anthropic stream events.
|
||||
|
||||
Construction takes a long-lived :class:`raycast_api.Client` (one per
|
||||
gateway — bearer + device_id are process-wide). Each
|
||||
:meth:`complete` call opens one ``chat.stream`` and yields a fully
|
||||
Anthropic-shaped event sequence.
|
||||
"""
|
||||
|
||||
def __init__(self, client: Client) -> None:
|
||||
self._client = client
|
||||
|
||||
async def complete(
|
||||
self,
|
||||
*,
|
||||
agent: BaseAgent,
|
||||
messages: Iterable[MessageParam],
|
||||
system: str | None = None,
|
||||
**options: Any,
|
||||
) -> AsyncIterator[MessageStreamEvent]:
|
||||
if not isinstance(agent, RaycastAgent):
|
||||
msg = f"RaycastBackend requires RaycastAgent, got {type(agent).__name__}"
|
||||
raise TypeError(msg)
|
||||
|
||||
raycast_messages = _to_raycast_messages(messages)
|
||||
tools = _build_tool_list(agent)
|
||||
|
||||
# On the wire Raycast uses ``system_instructions`` as a format
|
||||
# marker (``"markdown"`` for AI_CHAT, ``"plain"`` otherwise —
|
||||
# filled in by the SDK from the source default when we pass
|
||||
# ``None``) and ``additional_system_instructions`` as the actual
|
||||
# prompt content. So our ``system_prompt`` (or the per-request
|
||||
# Anthropic ``system``, if present) flows into the *additional*
|
||||
# slot. The SDK still prepends ``<user-preferences>`` to whatever
|
||||
# we hand it via ``_build_preamble``.
|
||||
prompt_content = system if system is not None else agent.system_prompt
|
||||
|
||||
# Per-request options win over agent defaults; agent defaults
|
||||
# win over Raycast SDK defaults. ``None`` means "fall back".
|
||||
async for event in self._stream(
|
||||
agent=agent,
|
||||
raycast_messages=raycast_messages,
|
||||
tools=tools,
|
||||
prompt_content=prompt_content,
|
||||
temperature=_first_set(options.get("temperature"), agent.temperature),
|
||||
reasoning_effort=_first_set(
|
||||
options.get("reasoning_effort"), agent.reasoning_effort
|
||||
),
|
||||
tool_choice=_first_set(options.get("tool_choice"), agent.tool_choice),
|
||||
):
|
||||
yield event
|
||||
|
||||
async def _stream(
|
||||
self,
|
||||
*,
|
||||
agent: RaycastAgent,
|
||||
raycast_messages: Sequence[RaycastMessage],
|
||||
tools: list[Tool | RemoteTool | str] | None,
|
||||
prompt_content: str | None,
|
||||
temperature: float | None,
|
||||
reasoning_effort: str | None,
|
||||
tool_choice: str | None,
|
||||
) -> AsyncIterator[MessageStreamEvent]:
|
||||
message_id = f"msg_{uuid.uuid4().hex}"
|
||||
yield build_message_start(message_id=message_id, model=agent.model)
|
||||
|
||||
state = _BlockState()
|
||||
final_finish: str | None = None
|
||||
final_usage: dict[str, int] | None = None
|
||||
|
||||
stream = self._client.chat.stream(
|
||||
model=agent.model,
|
||||
messages=list(raycast_messages),
|
||||
source=agent.source,
|
||||
# ``system_instructions=None`` → SDK substitutes the source
|
||||
# default (``"markdown"`` / ``"plain"``). Real prompt goes
|
||||
# into ``additional_system_instructions``.
|
||||
additional_system_instructions=prompt_content,
|
||||
user_preferences=agent.user_preferences,
|
||||
tools=tools,
|
||||
tool_choice=tool_choice,
|
||||
temperature=temperature,
|
||||
reasoning_effort=reasoning_effort,
|
||||
)
|
||||
|
||||
async for chunk in stream:
|
||||
for event in self._handle_chunk(chunk, state):
|
||||
yield event
|
||||
if chunk.finish_reason:
|
||||
final_finish = chunk.finish_reason
|
||||
if chunk.usage:
|
||||
final_usage = chunk.usage
|
||||
|
||||
# Close whatever block is still open before the message delta.
|
||||
if state.kind is not None:
|
||||
yield build_content_block_stop(state.index)
|
||||
state.kind = None
|
||||
|
||||
yield build_message_delta(
|
||||
stop_reason=_map_stop_reason(final_finish),
|
||||
usage=final_usage,
|
||||
)
|
||||
yield build_message_stop()
|
||||
|
||||
def _handle_chunk(
|
||||
self, chunk: ChatStreamChunk, state: _BlockState
|
||||
) -> Iterable[MessageStreamEvent]:
|
||||
"""Translate one Raycast chunk into zero or more Anthropic events.
|
||||
|
||||
Branch order matters: we close the previous block kind before
|
||||
opening a new one (text → tool_use, tool_use → text, etc.) and we
|
||||
intentionally fall through ``tool_calls`` only if there's a real
|
||||
delta — the final-summary chunk re-sends the full arguments
|
||||
string we've already streamed delta-by-delta.
|
||||
"""
|
||||
events: list[MessageStreamEvent] = []
|
||||
is_final_summary = chunk.finish_reason is not None
|
||||
|
||||
if chunk.text:
|
||||
events.extend(self._ensure_kind(state, "text"))
|
||||
events.append(build_text_delta(state.index, chunk.text))
|
||||
|
||||
if chunk.reasoning:
|
||||
events.extend(self._ensure_kind(state, "thinking"))
|
||||
events.append(build_thinking_delta(state.index, chunk.reasoning))
|
||||
|
||||
if chunk.tool_calls:
|
||||
events.extend(
|
||||
self._handle_tool_calls(chunk, state, is_final_summary=is_final_summary)
|
||||
)
|
||||
|
||||
return events
|
||||
|
||||
def _ensure_kind(
|
||||
self, state: _BlockState, kind: str
|
||||
) -> Iterable[MessageStreamEvent]:
|
||||
"""Open a block of ``kind``, closing any current block first."""
|
||||
if state.kind == kind:
|
||||
return []
|
||||
events: list[MessageStreamEvent] = []
|
||||
if state.kind is not None:
|
||||
events.append(build_content_block_stop(state.index))
|
||||
state.index += 1
|
||||
state.kind = kind
|
||||
if kind == "text":
|
||||
events.append(build_text_block_start(state.index))
|
||||
elif kind == "thinking":
|
||||
events.append(build_thinking_block_start(state.index))
|
||||
else:
|
||||
msg = f"unexpected block kind: {kind!r}"
|
||||
raise ValueError(msg)
|
||||
return events
|
||||
|
||||
def _handle_tool_calls(
|
||||
self,
|
||||
chunk: ChatStreamChunk,
|
||||
state: _BlockState,
|
||||
*,
|
||||
is_final_summary: bool,
|
||||
) -> Iterable[MessageStreamEvent]:
|
||||
"""Translate one chunk's ``tool_calls`` payload into Anthropic events.
|
||||
|
||||
Mirrors the keying logic of ``raycast_api.ChatResult._merge_tool_calls``
|
||||
so the same dedupe behaviour lands here: tool calls are tracked
|
||||
by id when present, otherwise by their wire ``index`` field, and
|
||||
the final-summary chunk's arguments string is dropped because the
|
||||
deltas already streamed it.
|
||||
"""
|
||||
events: list[MessageStreamEvent] = []
|
||||
raw_tcs = chunk.raw.get("tool_calls") or []
|
||||
|
||||
for i, tc in enumerate(chunk.tool_calls or []):
|
||||
raw_tc = raw_tcs[i] if i < len(raw_tcs) else {}
|
||||
idx_field = raw_tc.get("index") if isinstance(raw_tc, dict) else None
|
||||
|
||||
# Resolve this entry to a tool-id key, mirroring
|
||||
# `raycast_api.ChatResult._merge_tool_calls`. Phase 1 carries
|
||||
# id+index, phase 2 only index, phase 3 only id.
|
||||
tool_id: str | None = None
|
||||
if tc.id:
|
||||
tool_id = tc.id
|
||||
if isinstance(idx_field, int):
|
||||
state.tool_idx_to_id[idx_field] = tc.id
|
||||
elif isinstance(idx_field, int):
|
||||
tool_id = state.tool_idx_to_id.get(idx_field)
|
||||
if tool_id is None:
|
||||
continue
|
||||
|
||||
block_idx = state.tool_id_to_block.get(tool_id)
|
||||
if block_idx is None:
|
||||
# New tool_use block. Close any open text/thinking block first.
|
||||
if state.kind is not None:
|
||||
events.append(build_content_block_stop(state.index))
|
||||
state.index += 1
|
||||
state.kind = "tool_use"
|
||||
block_idx = state.index
|
||||
state.tool_id_to_block[tool_id] = block_idx
|
||||
events.append(
|
||||
build_tool_use_block_start(
|
||||
block_idx, tool_use_id=tool_id, name=tc.name or ""
|
||||
)
|
||||
)
|
||||
if tc.arguments and not is_final_summary:
|
||||
events.append(build_input_json_delta(block_idx, tc.arguments))
|
||||
continue
|
||||
|
||||
# Existing block. Skip args on the final summary — they're a
|
||||
# full restatement of what's already been delta'd.
|
||||
if is_final_summary:
|
||||
continue
|
||||
if tc.arguments:
|
||||
events.append(build_input_json_delta(block_idx, tc.arguments))
|
||||
|
||||
return events
|
||||
Reference in New Issue
Block a user