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
@@ -26,6 +26,7 @@ from nisse_dev.canonical import (
|
||||
mint_call_id,
|
||||
)
|
||||
from nisse_dev.render import OpenAIChatRenderer
|
||||
from nisse_dev.render_anthropic import AnthropicMessagesRenderer
|
||||
|
||||
|
||||
class LLMError(RuntimeError):
|
||||
@@ -157,3 +158,96 @@ class LiteLLMClient:
|
||||
|
||||
def close(self) -> None:
|
||||
self._http.close()
|
||||
|
||||
|
||||
class AnthropicPassthroughClient:
|
||||
"""LiteLLM's Anthropic passthrough (A5). Same interface as LiteLLMClient,
|
||||
so a caller chooses a route rather than a different kind of client.
|
||||
|
||||
Delegations take this path because fidelity matters there (A13);
|
||||
ask_expert and fallbacks use the normalized route, where it does not.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
token: str,
|
||||
*,
|
||||
timeout_s: float = 300.0,
|
||||
max_tokens: int = 4096,
|
||||
transport: httpx.BaseTransport | None = None,
|
||||
) -> None:
|
||||
self._http = httpx.Client(
|
||||
base_url=base_url.rstrip("/"),
|
||||
headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"x-api-key": token,
|
||||
"anthropic-version": "2023-06-01",
|
||||
},
|
||||
timeout=timeout_s,
|
||||
transport=transport,
|
||||
)
|
||||
self._renderer = AnthropicMessagesRenderer()
|
||||
self._max_tokens = max_tokens
|
||||
|
||||
@classmethod
|
||||
def from_env(
|
||||
cls, env: Mapping[str, str] = os.environ
|
||||
) -> "AnthropicPassthroughClient":
|
||||
base_url = env.get("NISSE_LLM_BASE_URL")
|
||||
token = env.get("NISSE_LLM_TOKEN")
|
||||
if not base_url or not token:
|
||||
raise LLMError("set NISSE_LLM_BASE_URL and NISSE_LLM_TOKEN")
|
||||
return cls(base_url, token)
|
||||
|
||||
def complete(
|
||||
self,
|
||||
events: list[StoredEvent],
|
||||
tools: list[dict[str, Any]] | None,
|
||||
model: str,
|
||||
system: str | None = None,
|
||||
) -> list[CanonicalEvent]:
|
||||
body = self._renderer.render(
|
||||
events, tools, model, system=system, max_tokens=self._max_tokens
|
||||
)
|
||||
try:
|
||||
response = self._http.post("/v1/messages", json=body)
|
||||
except httpx.HTTPError as exc:
|
||||
raise LLMError(f"passthrough request failed: {exc}") from exc
|
||||
if response.status_code != 200:
|
||||
raise LLMError(
|
||||
f"passthrough returned {response.status_code}: {response.text[:500]}"
|
||||
)
|
||||
return self._parse(response.json(), requested_model=model)
|
||||
|
||||
def _parse(
|
||||
self, data: dict[str, Any], requested_model: str
|
||||
) -> list[CanonicalEvent]:
|
||||
content = data.get("content")
|
||||
if not isinstance(content, list):
|
||||
raise LLMError(f"malformed passthrough response: {str(data)[:300]}")
|
||||
produced_by = data.get("model") or requested_model
|
||||
out: list[CanonicalEvent] = []
|
||||
for block in content:
|
||||
kind = block.get("type")
|
||||
if kind == "text" and block.get("text"):
|
||||
out.append(AssistantText(text=block["text"], model=produced_by))
|
||||
elif kind == "tool_use":
|
||||
# Provider ids are mapped at the boundary and never used as a
|
||||
# join key (A10); the canonical log owns identity.
|
||||
out.append(
|
||||
ToolCall(
|
||||
call_id=mint_call_id(),
|
||||
tool=block["name"],
|
||||
arguments=block.get("input") or {},
|
||||
)
|
||||
)
|
||||
# Vendor-opaque reasoning blocks (thinking, redacted_thinking) are
|
||||
# deliberately dropped for now: A10 requires storing them tagged
|
||||
# with the producing model and never replaying them outside the
|
||||
# session that made them, and the canonical schema has no event
|
||||
# for that yet. Nothing requests extended thinking, so none arrive.
|
||||
return out
|
||||
|
||||
def close(self) -> None:
|
||||
self._http.close()
|
||||
@@ -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