Add the Anthropic passthrough renderer and client (A10/A13) — 7a
The second renderer adapter, and the concrete case for A13's decision to normalise in the renderer rather than on the wire. The two vendors disagree structurally, not cosmetically: the system prompt is a top-level field rather than a message, a tool call is a tool_use content block rather than an array, a tool result is a block inside a user message rather than a "tool" role, and tool schemas use input_schema rather than parameters. A middlebox flattening those would be lossy exactly where a long-running delegation lives. - render_anthropic.py builds vendor-native /v1/messages bodies from the canonical log, coalescing adjacent same-role blocks so a tool_result lands directly after its tool_use - Tool manifests are declared once in OpenAI shape and converted per vendor, so adding a tool does not mean editing two places - AnthropicPassthroughClient implements the same LLMClient protocol, so callers pick a route rather than a different kind of client, and maps provider tool ids to canonical ones at the boundary - Dangling tool calls get a placeholder result here too; this API rejects them more strictly than the OpenAI shape - Vendor-opaque reasoning blocks are dropped for now with the reason recorded: A10 wants them stored and never replayed across sessions, and the canonical schema has no event for that yet Live tests prove the body is accepted as sent, that a tool_result turn round-trips, and — as A5 asks — that prompt cache accounting survives the passthrough rather than being flattened by the proxy. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1f292793c1
commit
f4a94281ef
4 files changed
+582
No files matched your search
@@ -0,0 +1,184 @@
|
||||
"""Anthropic Messages renderer (spec A10, A13).
|
||||
|
||||
The second adapter, and the reason A13 puts normalisation in the renderer
|
||||
rather than on the wire: the two vendors disagree *structurally*, not
|
||||
cosmetically.
|
||||
|
||||
| | OpenAI-compatible | Anthropic Messages |
|
||||
|---|---|---|
|
||||
| system prompt | a message with role `system` | a top-level `system` field |
|
||||
| tool call | `tool_calls` array on the message | a `tool_use` content block |
|
||||
| tool result | a message with role `tool` | a `tool_result` block in a **user** message |
|
||||
| tool schema | `function.parameters` | `input_schema` |
|
||||
|
||||
A middlebox that normalised between these would be lossy exactly where a
|
||||
long-running delegation lives, which is why delegations take the passthrough
|
||||
route and this renderer builds the vendor-native body.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from nisse_dev.canonical import (
|
||||
AssistantText,
|
||||
Compaction,
|
||||
DelegationCompleted,
|
||||
DelegationStarted,
|
||||
GateOpened,
|
||||
PlanSubmitted,
|
||||
StoredEvent,
|
||||
ToolCall,
|
||||
ToolResult,
|
||||
UserMessage,
|
||||
)
|
||||
from nisse_dev.render import effective_events
|
||||
|
||||
DEFAULT_MAX_TOKENS = 4096
|
||||
NO_RESULT_PLACEHOLDER = "[no result recorded]"
|
||||
|
||||
|
||||
def to_anthropic_tools(tools: list[dict[str, Any]] | None) -> list[dict[str, Any]]:
|
||||
"""Convert the OpenAI-shaped manifests the tool modules already define,
|
||||
so a tool is declared once and rendered per vendor."""
|
||||
converted: list[dict[str, Any]] = []
|
||||
for tool in tools or []:
|
||||
fn = tool.get("function", tool)
|
||||
converted.append(
|
||||
{
|
||||
"name": fn["name"],
|
||||
"description": fn.get("description", ""),
|
||||
"input_schema": fn.get(
|
||||
"parameters", {"type": "object", "properties": {}}
|
||||
),
|
||||
}
|
||||
)
|
||||
return converted
|
||||
|
||||
|
||||
class AnthropicMessagesRenderer:
|
||||
def render(
|
||||
self,
|
||||
events: list[StoredEvent],
|
||||
tools: list[dict[str, Any]] | None,
|
||||
model: str,
|
||||
system: str | None = None,
|
||||
*,
|
||||
max_tokens: int = DEFAULT_MAX_TOKENS,
|
||||
) -> dict[str, Any]:
|
||||
kept = effective_events(events)
|
||||
answered = {e.event.call_id for e in kept if isinstance(e.event, ToolResult)}
|
||||
blocks: list[tuple[str, dict[str, Any]]] = []
|
||||
for stored in kept:
|
||||
blocks.extend(self._blocks_for(stored.event))
|
||||
# A dangling tool_use is rejected outright by this API — more
|
||||
# strictly than the OpenAI shape — so a crash between a call and
|
||||
# its result must not poison the transcript (A3).
|
||||
if isinstance(stored.event, ToolCall) and (
|
||||
stored.event.call_id not in answered
|
||||
):
|
||||
blocks.append(
|
||||
(
|
||||
"user",
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": stored.event.call_id,
|
||||
"content": NO_RESULT_PLACEHOLDER,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
body: dict[str, Any] = {
|
||||
"model": model,
|
||||
"max_tokens": max_tokens,
|
||||
"messages": _coalesce(blocks),
|
||||
}
|
||||
if system is not None:
|
||||
body["system"] = system
|
||||
if tools:
|
||||
body["tools"] = to_anthropic_tools(tools)
|
||||
return body
|
||||
|
||||
def _blocks_for(self, event: object) -> list[tuple[str, dict[str, Any]]]:
|
||||
match event:
|
||||
case UserMessage(text=text):
|
||||
return [("user", {"type": "text", "text": text})]
|
||||
case AssistantText(text=text):
|
||||
return [("assistant", {"type": "text", "text": text})]
|
||||
case ToolCall(call_id=call_id, tool=tool, arguments=arguments):
|
||||
return [
|
||||
(
|
||||
"assistant",
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": call_id,
|
||||
"name": tool,
|
||||
"input": arguments,
|
||||
},
|
||||
)
|
||||
]
|
||||
case ToolResult(call_id=call_id, result=result):
|
||||
return [
|
||||
(
|
||||
"user",
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": call_id,
|
||||
"content": result,
|
||||
},
|
||||
)
|
||||
]
|
||||
case PlanSubmitted(plan=plan):
|
||||
return [("assistant", {"type": "text", "text": plan})]
|
||||
case GateOpened(gate=gate, reason=reason):
|
||||
return [
|
||||
(
|
||||
"user",
|
||||
{"type": "text", "text": f"[gate {gate} opened: {reason}]"},
|
||||
)
|
||||
]
|
||||
case DelegationStarted(brief=brief, model=model):
|
||||
return [
|
||||
(
|
||||
"assistant",
|
||||
{
|
||||
"type": "text",
|
||||
"text": f"[delegation started ({model}): {brief}]",
|
||||
},
|
||||
)
|
||||
]
|
||||
case DelegationCompleted(handback=handback):
|
||||
return [
|
||||
(
|
||||
"assistant",
|
||||
{"type": "text", "text": f"[delegation handback]\n{handback}"},
|
||||
)
|
||||
]
|
||||
case Compaction(summary=summary):
|
||||
return [
|
||||
(
|
||||
"user",
|
||||
{
|
||||
"type": "text",
|
||||
"text": f"[summary of earlier conversation]\n{summary}",
|
||||
},
|
||||
)
|
||||
]
|
||||
case _:
|
||||
raise ValueError(f"unrenderable event: {event!r}")
|
||||
|
||||
|
||||
def _coalesce(
|
||||
blocks: list[tuple[str, dict[str, Any]]],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Merge adjacent blocks of the same role into one message.
|
||||
|
||||
The Messages API expects alternating roles, and a tool_result must sit in
|
||||
the user message directly after the assistant's tool_use — which happens
|
||||
naturally once same-role runs are merged.
|
||||
"""
|
||||
messages: list[dict[str, Any]] = []
|
||||
for role, block in blocks:
|
||||
if messages and messages[-1]["role"] == role:
|
||||
messages[-1]["content"].append(block)
|
||||
else:
|
||||
messages.append({"role": role, "content": [block]})
|
||||
return messages
|
||||
Reference in new issue
Block a user