Merge pull request 'Add the Anthropic passthrough renderer and client (A10/A13) — step 7a' (#20) from anthropic-renderer into main
CI / checks (push) Successful in 1m11s
CI / checks (push) Successful in 1m11s
Reviewed-on: dongar/nisse#20 Reviewed-by: Don Garrett <dgarrett@acm.org>
This commit was merged in pull request #20.
This commit is contained in:
commit
1c8e7e35fc
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
|
||||
@@ -0,0 +1,196 @@
|
||||
"""The Anthropic Messages shape differs structurally from the OpenAI one;
|
||||
these tests pin the differences that a normalising middlebox would blur."""
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from nisse_dev.canonical import (
|
||||
AssistantText,
|
||||
Compaction,
|
||||
StoredEvent,
|
||||
ToolCall,
|
||||
ToolResult,
|
||||
UserMessage,
|
||||
)
|
||||
from nisse_dev.llm import AnthropicPassthroughClient, LLMError
|
||||
from nisse_dev.render_anthropic import (
|
||||
AnthropicMessagesRenderer,
|
||||
to_anthropic_tools,
|
||||
)
|
||||
|
||||
TOOLS = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "run_command",
|
||||
"description": "Run a shell command.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"command": {"type": "string"}},
|
||||
"required": ["command"],
|
||||
},
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def stored(events: list) -> list[StoredEvent]:
|
||||
return [StoredEvent(id=i + 1, ts="t", event=e) for i, e in enumerate(events)]
|
||||
|
||||
|
||||
def render(events: list, **kw) -> dict:
|
||||
return AnthropicMessagesRenderer().render(
|
||||
stored(events), kw.pop("tools", None), "claude-haiku-4-5", **kw
|
||||
)
|
||||
|
||||
|
||||
def test_system_is_a_top_level_field_not_a_message() -> None:
|
||||
body = render([UserMessage(text="hi", source="m")], system="you are nisse")
|
||||
assert body["system"] == "you are nisse"
|
||||
assert all(m["role"] != "system" for m in body["messages"])
|
||||
|
||||
|
||||
def test_max_tokens_is_always_present() -> None:
|
||||
# The Messages API rejects a request without it.
|
||||
assert render([UserMessage(text="hi", source="m")])["max_tokens"] > 0
|
||||
|
||||
|
||||
def test_tool_call_is_a_content_block_not_an_array() -> None:
|
||||
body = render(
|
||||
[ToolCall(call_id="call-1", tool="run_command", arguments={"command": "ls"})]
|
||||
)
|
||||
block = body["messages"][0]["content"][0]
|
||||
assert block["type"] == "tool_use"
|
||||
assert block["id"] == "call-1"
|
||||
assert block["input"] == {"command": "ls"}
|
||||
assert "tool_calls" not in body["messages"][0]
|
||||
|
||||
|
||||
def test_tool_result_is_a_user_block_directly_after_the_call() -> None:
|
||||
body = render(
|
||||
[
|
||||
ToolCall(call_id="call-1", tool="run_command", arguments={}),
|
||||
ToolResult(call_id="call-1", result="exit code: 0", success=True),
|
||||
]
|
||||
)
|
||||
assistant, user = body["messages"]
|
||||
assert assistant["role"] == "assistant"
|
||||
assert assistant["content"][0]["type"] == "tool_use"
|
||||
assert user["role"] == "user" # not a "tool" role, as OpenAI would have
|
||||
assert user["content"][0] == {
|
||||
"type": "tool_result",
|
||||
"tool_use_id": "call-1",
|
||||
"content": "exit code: 0",
|
||||
}
|
||||
|
||||
|
||||
def test_adjacent_same_role_events_are_merged() -> None:
|
||||
body = render(
|
||||
[
|
||||
UserMessage(text="one", source="m"),
|
||||
UserMessage(text="two", source="m"),
|
||||
AssistantText(text="reply", model="m"),
|
||||
]
|
||||
)
|
||||
assert [m["role"] for m in body["messages"]] == ["user", "assistant"]
|
||||
assert [b["text"] for b in body["messages"][0]["content"]] == ["one", "two"]
|
||||
|
||||
|
||||
def test_dangling_tool_call_gets_a_placeholder_result() -> None:
|
||||
body = render([ToolCall(call_id="call-x", tool="submit_plan", arguments={})])
|
||||
assert [m["role"] for m in body["messages"]] == ["assistant", "user"]
|
||||
assert body["messages"][1]["content"][0]["tool_use_id"] == "call-x"
|
||||
|
||||
|
||||
def test_tools_are_converted_to_input_schema() -> None:
|
||||
[tool] = to_anthropic_tools(TOOLS)
|
||||
assert tool["name"] == "run_command"
|
||||
assert tool["input_schema"]["properties"] == {"command": {"type": "string"}}
|
||||
assert "parameters" not in tool
|
||||
assert "function" not in tool
|
||||
|
||||
|
||||
def test_compaction_still_replaces_its_range() -> None:
|
||||
events = stored(
|
||||
[
|
||||
UserMessage(text="old", source="m"),
|
||||
UserMessage(text="kept", source="m"),
|
||||
]
|
||||
)
|
||||
events.append(
|
||||
StoredEvent(
|
||||
id=3,
|
||||
ts="t",
|
||||
event=Compaction(summary="summary", model="m", replaces=[1, 1]),
|
||||
)
|
||||
)
|
||||
body = AnthropicMessagesRenderer().render(events, None, "claude-haiku-4-5")
|
||||
texts = [b["text"] for m in body["messages"] for b in m["content"]]
|
||||
assert "old" not in texts
|
||||
assert any("summary" in t for t in texts)
|
||||
|
||||
|
||||
# --- passthrough client --------------------------------------------------
|
||||
|
||||
|
||||
def client(handler) -> AnthropicPassthroughClient:
|
||||
return AnthropicPassthroughClient(
|
||||
"https://litellm.test", "tok", transport=httpx.MockTransport(handler)
|
||||
)
|
||||
|
||||
|
||||
def test_client_posts_to_the_messages_route_with_version_header() -> None:
|
||||
seen: dict = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen["url"] = str(request.url)
|
||||
seen["version"] = request.headers.get("anthropic-version")
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"model": "claude-haiku-4-5",
|
||||
"content": [{"type": "text", "text": "hi"}],
|
||||
},
|
||||
)
|
||||
|
||||
events = stored([UserMessage(text="hello", source="m")])
|
||||
[event] = client(handler).complete(events, None, "claude-haiku-4-5")
|
||||
assert seen["url"] == "https://litellm.test/v1/messages"
|
||||
assert seen["version"] == "2023-06-01"
|
||||
assert event == AssistantText(text="hi", model="claude-haiku-4-5")
|
||||
|
||||
|
||||
def test_client_maps_tool_use_to_local_call_ids() -> None:
|
||||
response = {
|
||||
"model": "claude-haiku-4-5",
|
||||
"content": [
|
||||
{"type": "text", "text": "running it"},
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "toolu_vendor_xyz",
|
||||
"name": "run_command",
|
||||
"input": {"command": "pytest"},
|
||||
},
|
||||
],
|
||||
}
|
||||
events = stored([UserMessage(text="go", source="m")])
|
||||
text, call = client(lambda r: httpx.Response(200, json=response)).complete(
|
||||
events, TOOLS, "claude-haiku-4-5"
|
||||
)
|
||||
assert isinstance(text, AssistantText)
|
||||
assert isinstance(call, ToolCall)
|
||||
assert call.arguments == {"command": "pytest"}
|
||||
assert call.call_id != "toolu_vendor_xyz" # provider id never a join key
|
||||
assert call.call_id.startswith("call-")
|
||||
|
||||
|
||||
def test_client_errors_are_llm_errors() -> None:
|
||||
events = stored([UserMessage(text="go", source="m")])
|
||||
with pytest.raises(LLMError, match="400"):
|
||||
client(lambda r: httpx.Response(400, text="bad")).complete(
|
||||
events, None, "claude-haiku-4-5"
|
||||
)
|
||||
with pytest.raises(LLMError, match="malformed"):
|
||||
client(lambda r: httpx.Response(200, json={"oops": 1})).complete(
|
||||
events, None, "claude-haiku-4-5"
|
||||
)
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Live Anthropic passthrough: the vendor-native route delegations will use.
|
||||
|
||||
Proves the renderer's body is accepted as-is, that a tool call round-trips
|
||||
through the canonical log, and — per A5 — that prompt caching survives the
|
||||
passthrough rather than being flattened by the proxy.
|
||||
"""
|
||||
|
||||
import io
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from nisse_dev.canonical import AssistantText, ToolCall, ToolResult, UserMessage
|
||||
from nisse_dev.envfile import load_env_file
|
||||
from nisse_dev.llm import AnthropicPassthroughClient
|
||||
from nisse_dev.oplog import OpLog
|
||||
from nisse_dev.state import StateStore
|
||||
from nisse_dev.tools import EXECUTOR_TOOLS
|
||||
|
||||
load_env_file(Path(__file__).resolve().parents[2] / ".env")
|
||||
|
||||
SENTINEL = "NISSE_LIVE_PLANNER_MODEL"
|
||||
|
||||
pytestmark = [
|
||||
pytest.mark.integration,
|
||||
pytest.mark.skipif(
|
||||
not os.environ.get(SENTINEL),
|
||||
reason=f"no live frontier route configured ({SENTINEL} unset)",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def store_with(tmp_path: Path, *events) -> StateStore:
|
||||
store = StateStore(tmp_path / "state.db", oplog=OpLog("nisse-dev", io.StringIO()))
|
||||
for event in events:
|
||||
store.append_event(event)
|
||||
return store
|
||||
|
||||
|
||||
def test_passthrough_completes_a_plain_turn(tmp_path: Path) -> None:
|
||||
store = store_with(
|
||||
tmp_path, UserMessage(text="Reply with exactly: pong", source="live")
|
||||
)
|
||||
result = AnthropicPassthroughClient.from_env().complete(
|
||||
store.read_events(), None, os.environ[SENTINEL], system="Be terse."
|
||||
)
|
||||
texts = [e.text for e in result if isinstance(e, AssistantText)]
|
||||
assert texts and "pong" in texts[0].lower()
|
||||
|
||||
|
||||
def test_passthrough_round_trips_a_tool_call(tmp_path: Path) -> None:
|
||||
store = store_with(
|
||||
tmp_path,
|
||||
UserMessage(
|
||||
text="List the files in the current directory using your tools.",
|
||||
source="live",
|
||||
),
|
||||
)
|
||||
client = AnthropicPassthroughClient.from_env()
|
||||
produced = client.complete(
|
||||
store.read_events(),
|
||||
EXECUTOR_TOOLS,
|
||||
os.environ[SENTINEL],
|
||||
system="Use a tool. Do not answer in prose.",
|
||||
)
|
||||
calls = [e for e in produced if isinstance(e, ToolCall)]
|
||||
assert calls, f"expected a tool call, got {produced!r}"
|
||||
|
||||
# Feed the result back: the follow-up render must be accepted, which is
|
||||
# what proves tool_result placement is right for this vendor.
|
||||
for event in produced:
|
||||
store.append_event(event)
|
||||
store.append_event(
|
||||
ToolResult(call_id=calls[0].call_id, result="app.py\ntests/", success=True)
|
||||
)
|
||||
follow_up = client.complete(
|
||||
store.read_events(), EXECUTOR_TOOLS, os.environ[SENTINEL]
|
||||
)
|
||||
assert follow_up, "the vendor rejected or ignored the tool_result turn"
|
||||
|
||||
|
||||
def test_prompt_caching_survives_the_passthrough() -> None:
|
||||
# A5 asks for this to be verified rather than assumed: a proxy that
|
||||
# flattened the request would silently drop cache control and turn a
|
||||
# cost optimisation into a per-call expense.
|
||||
base = os.environ["NISSE_LLM_BASE_URL"].rstrip("/")
|
||||
token = os.environ["NISSE_LLM_TOKEN"]
|
||||
response = httpx.post(
|
||||
f"{base}/v1/messages",
|
||||
headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"x-api-key": token,
|
||||
"anthropic-version": "2023-06-01",
|
||||
},
|
||||
json={
|
||||
"model": os.environ[SENTINEL],
|
||||
"max_tokens": 16,
|
||||
"messages": [{"role": "user", "content": "Say pong."}],
|
||||
},
|
||||
timeout=120,
|
||||
)
|
||||
response.raise_for_status()
|
||||
usage = response.json().get("usage", {})
|
||||
assert "cache_read_input_tokens" in usage, (
|
||||
f"cache accounting absent from passthrough usage: {usage}"
|
||||
)
|
||||
Reference in new issue
Block a user