feat: transparent anthropic api incl. native anthropic api backend
This commit is contained in:
parent
c9e495876b
commit
1d012a92ef
11 changed files with 1431 additions and 20 deletions
440
test/test_messages.py
Normal file
440
test/test_messages.py
Normal file
|
|
@ -0,0 +1,440 @@
|
|||
"""Tests for the Anthropic Messages API support (api/messages.py + requests/anthropic.py).
|
||||
|
||||
Covers the pure translation layer, the translated (Ollama-style) and native
|
||||
(configured-anthropic-endpoint) backend paths, thinking→reasoning mapping,
|
||||
streaming event shape, token counting, and the nomyo-cache reflection.
|
||||
"""
|
||||
from contextlib import ExitStack, contextmanager
|
||||
from types import SimpleNamespace as NS
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import orjson
|
||||
import pytest
|
||||
|
||||
import router
|
||||
from api import messages as api_messages
|
||||
from requests import anthropic as at
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Pure translation unit tests (no app / no I/O)
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestRequestTranslation:
|
||||
def test_system_string(self):
|
||||
chat = at.anthropic_messages_to_chat("be brief", [{"role": "user", "content": "hi"}])
|
||||
assert chat[0] == {"role": "system", "content": "be brief"}
|
||||
assert chat[1] == {"role": "user", "content": "hi"}
|
||||
|
||||
def test_system_blocks_join(self):
|
||||
chat = at.anthropic_messages_to_chat(
|
||||
[{"type": "text", "text": "a"}, {"type": "text", "text": "b"}],
|
||||
[{"role": "user", "content": "hi"}])
|
||||
assert chat[0] == {"role": "system", "content": "a\n\nb"}
|
||||
|
||||
def test_image_base64_to_data_url(self):
|
||||
chat = at.anthropic_messages_to_chat(None, [{
|
||||
"role": "user", "content": [
|
||||
{"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "AAA"}},
|
||||
{"type": "text", "text": "what?"},
|
||||
]}])
|
||||
parts = chat[0]["content"]
|
||||
assert parts[0] == {"type": "image_url", "image_url": {"url": "data:image/png;base64,AAA"}}
|
||||
assert parts[1] == {"type": "text", "text": "what?"}
|
||||
|
||||
def test_tool_use_and_result_roundtrip(self):
|
||||
chat = at.anthropic_messages_to_chat(None, [
|
||||
{"role": "assistant", "content": [
|
||||
{"type": "text", "text": "calling"},
|
||||
{"type": "tool_use", "id": "toolu_1", "name": "get", "input": {"x": 1}}]},
|
||||
{"role": "user", "content": [
|
||||
{"type": "tool_result", "tool_use_id": "toolu_1", "content": "42"}]},
|
||||
])
|
||||
assert chat[0]["role"] == "assistant"
|
||||
assert chat[0]["content"] == "calling"
|
||||
assert chat[0]["tool_calls"][0]["id"] == "toolu_1"
|
||||
assert chat[0]["tool_calls"][0]["function"]["arguments"] == '{"x":1}'
|
||||
assert chat[1] == {"role": "tool", "tool_call_id": "toolu_1", "content": "42"}
|
||||
|
||||
def test_tool_result_blocks_flattened(self):
|
||||
chat = at.anthropic_messages_to_chat(None, [{
|
||||
"role": "user", "content": [
|
||||
{"type": "tool_result", "tool_use_id": "t1",
|
||||
"content": [{"type": "text", "text": "line"}]}]}])
|
||||
assert chat[0] == {"role": "tool", "tool_call_id": "t1", "content": "line"}
|
||||
|
||||
def test_tools_and_choice(self):
|
||||
sp = at.anthropic_to_chat_send_params(
|
||||
{"max_tokens": 10, "tools": [{"name": "get", "description": "d",
|
||||
"input_schema": {"type": "object"}}],
|
||||
"tool_choice": {"type": "tool", "name": "get"}},
|
||||
[], "m")
|
||||
assert sp["tools"] == [{"type": "function", "function": {
|
||||
"name": "get", "description": "d", "parameters": {"type": "object"}}}]
|
||||
assert sp["tool_choice"] == {"type": "function", "function": {"name": "get"}}
|
||||
|
||||
def test_tool_choice_variants(self):
|
||||
assert at.tool_choice_anthropic_to_chat({"type": "auto"}) == "auto"
|
||||
assert at.tool_choice_anthropic_to_chat({"type": "any"}) == "required"
|
||||
assert at.tool_choice_anthropic_to_chat({"type": "none"}) == "none"
|
||||
|
||||
def test_server_tools_dropped(self):
|
||||
# web_search has a type but no input_schema → not runnable locally
|
||||
assert at.tools_anthropic_to_chat([{"type": "web_search_20260209", "name": "web_search"}]) is None
|
||||
|
||||
def test_stop_sequences_and_sampling(self):
|
||||
sp = at.anthropic_to_chat_send_params(
|
||||
{"max_tokens": 5, "stop_sequences": ["X"], "temperature": 0.2, "top_p": 0.9}, [], "m")
|
||||
assert sp["stop"] == ["X"]
|
||||
assert sp["temperature"] == 0.2
|
||||
assert sp["top_p"] == 0.9
|
||||
|
||||
def test_thinking_maps_to_reasoning_effort(self):
|
||||
assert at.anthropic_to_chat_send_params(
|
||||
{"max_tokens": 5, "thinking": {"type": "enabled", "budget_tokens": 1000}}, [], "m"
|
||||
)["reasoning_effort"] == "low"
|
||||
assert at.anthropic_to_chat_send_params(
|
||||
{"max_tokens": 5, "thinking": {"type": "enabled", "budget_tokens": 4096}}, [], "m"
|
||||
)["reasoning_effort"] == "medium"
|
||||
assert at.anthropic_to_chat_send_params(
|
||||
{"max_tokens": 5, "thinking": {"type": "enabled", "budget_tokens": 20000}}, [], "m"
|
||||
)["reasoning_effort"] == "high"
|
||||
assert "reasoning_effort" not in at.anthropic_to_chat_send_params(
|
||||
{"max_tokens": 5, "thinking": {"type": "disabled"}}, [], "m")
|
||||
|
||||
|
||||
class TestResponseTranslation:
|
||||
def test_content_blocks_order(self):
|
||||
blocks = at.chat_message_to_content_blocks({
|
||||
"role": "assistant", "reasoning_content": "hmm", "content": "answer",
|
||||
"tool_calls": [{"id": "c1", "function": {"name": "f", "arguments": '{"a":1}'}}]})
|
||||
assert [b["type"] for b in blocks] == ["thinking", "text", "tool_use"]
|
||||
assert blocks[0]["thinking"] == "hmm"
|
||||
assert blocks[1]["text"] == "answer"
|
||||
assert blocks[2] == {"type": "tool_use", "id": "c1", "name": "f", "input": {"a": 1}}
|
||||
|
||||
def test_stop_reason_mapping(self):
|
||||
assert at.finish_reason_to_stop_reason("stop") == "end_turn"
|
||||
assert at.finish_reason_to_stop_reason("length") == "max_tokens"
|
||||
assert at.finish_reason_to_stop_reason("tool_calls") == "tool_use"
|
||||
assert at.finish_reason_to_stop_reason("stop", has_tool_use=True) == "tool_use"
|
||||
|
||||
def test_usage_mapping(self):
|
||||
u = at.usage_chat_to_anthropic({"prompt_tokens": 7, "completion_tokens": 3},
|
||||
cache_read_tokens=5)
|
||||
assert u == {"input_tokens": 7, "output_tokens": 3,
|
||||
"cache_creation_input_tokens": 0, "cache_read_input_tokens": 5}
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Streaming translator (pure)
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _chunk(content=None, tool_calls=None, reasoning=None, finish_reason=None):
|
||||
delta = NS(content=content, tool_calls=tool_calls)
|
||||
if reasoning is not None:
|
||||
delta.reasoning_content = reasoning
|
||||
return NS(choices=[NS(delta=delta, finish_reason=finish_reason)], usage=None)
|
||||
|
||||
|
||||
def _usage_chunk(p, c):
|
||||
return NS(choices=[], usage=NS(prompt_tokens=p, completion_tokens=c))
|
||||
|
||||
|
||||
async def _collect(translator, gen):
|
||||
frames = []
|
||||
async for sse in translator.events(gen):
|
||||
frames.append(sse.decode())
|
||||
return _parse_sse("".join(frames))
|
||||
|
||||
|
||||
def _parse_sse(text):
|
||||
out = []
|
||||
for frame in text.strip().split("\n\n"):
|
||||
if not frame.strip():
|
||||
continue
|
||||
etype = data = None
|
||||
for line in frame.splitlines():
|
||||
if line.startswith("event: "):
|
||||
etype = line[len("event: "):]
|
||||
elif line.startswith("data: "):
|
||||
data = orjson.loads(line[len("data: "):])
|
||||
out.append((etype, data))
|
||||
return out
|
||||
|
||||
|
||||
class TestStreamTranslator:
|
||||
async def test_text_stream(self):
|
||||
async def gen():
|
||||
yield _chunk(content="Hel")
|
||||
yield _chunk(content="lo", finish_reason="stop")
|
||||
yield _usage_chunk(3, 5)
|
||||
tr = at.ChatToMessagesStream("msg_1", "m")
|
||||
events = await _collect(tr, gen())
|
||||
types = [e[0] for e in events]
|
||||
assert types[0] == "message_start"
|
||||
assert types[-1] == "message_stop"
|
||||
assert "content_block_start" in types and "content_block_stop" in types
|
||||
text = "".join(d["delta"]["text"] for t, d in events
|
||||
if t == "content_block_delta" and d["delta"]["type"] == "text_delta")
|
||||
assert text == "Hello"
|
||||
md = [d for t, d in events if t == "message_delta"][0]
|
||||
assert md["delta"]["stop_reason"] == "end_turn"
|
||||
assert md["usage"]["output_tokens"] == 5
|
||||
assert tr.content_blocks == [{"type": "text", "text": "Hello"}]
|
||||
|
||||
async def test_thinking_then_text(self):
|
||||
async def gen():
|
||||
yield _chunk(reasoning="think ")
|
||||
yield _chunk(reasoning="more")
|
||||
yield _chunk(content="answer", finish_reason="stop")
|
||||
tr = at.ChatToMessagesStream("msg_1", "m")
|
||||
events = await _collect(tr, gen())
|
||||
# thinking block (index 0) then text block (index 1)
|
||||
starts = [(d["index"], d["content_block"]["type"])
|
||||
for t, d in events if t == "content_block_start"]
|
||||
assert starts == [(0, "thinking"), (1, "text")]
|
||||
think = "".join(d["delta"]["thinking"] for t, d in events
|
||||
if t == "content_block_delta" and d["delta"]["type"] == "thinking_delta")
|
||||
assert think == "think more"
|
||||
assert tr.content_blocks[0] == {"type": "thinking", "thinking": "think more"}
|
||||
|
||||
async def test_tool_call_stream(self):
|
||||
tc0 = NS(index=0, id="call_1", function=NS(name="lookup", arguments='{"q":'))
|
||||
tc1 = NS(index=0, id=None, function=NS(name=None, arguments='"hi"}'))
|
||||
|
||||
async def gen():
|
||||
yield _chunk(tool_calls=[tc0])
|
||||
yield _chunk(tool_calls=[tc1], finish_reason="tool_calls")
|
||||
yield _usage_chunk(4, 2)
|
||||
tr = at.ChatToMessagesStream("msg_1", "m")
|
||||
events = await _collect(tr, gen())
|
||||
assert any(t == "content_block_start" and d["content_block"]["type"] == "tool_use"
|
||||
for t, d in events)
|
||||
partial = "".join(d["delta"]["partial_json"] for t, d in events
|
||||
if t == "content_block_delta" and d["delta"]["type"] == "input_json_delta")
|
||||
assert partial == '{"q":"hi"}'
|
||||
md = [d for t, d in events if t == "message_delta"][0]
|
||||
assert md["delta"]["stop_reason"] == "tool_use"
|
||||
assert tr.content_blocks[-1] == {"type": "tool_use", "id": "call_1",
|
||||
"name": "lookup", "input": {"q": "hi"}}
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Cache-hit replay
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestCacheReplay:
|
||||
def test_message_object_to_sse_roundtrip(self):
|
||||
msg = at.build_message_object(
|
||||
message_id="msg_1", model="m",
|
||||
content_blocks=[{"type": "text", "text": "hi"}],
|
||||
stop_reason="end_turn",
|
||||
usage=at.usage_chat_to_anthropic({"prompt_tokens": 2, "completion_tokens": 1}))
|
||||
events = _parse_sse(at.message_object_to_sse(msg).decode())
|
||||
types = [e[0] for e in events]
|
||||
assert types[0] == "message_start"
|
||||
assert types[-1] == "message_stop"
|
||||
text = "".join(d["delta"]["text"] for t, d in events
|
||||
if t == "content_block_delta" and d["delta"]["type"] == "text_delta")
|
||||
assert text == "hi"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Route-level tests
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@contextmanager
|
||||
def _enter(*cms):
|
||||
with ExitStack() as stack:
|
||||
for cm in cms:
|
||||
stack.enter_context(cm)
|
||||
yield
|
||||
|
||||
|
||||
def _fake_completion(content="hello", usage=(3, 5), reasoning=None, tool_calls=None,
|
||||
finish_reason="stop"):
|
||||
md = {"role": "assistant", "content": content}
|
||||
if reasoning is not None:
|
||||
md["reasoning_content"] = reasoning
|
||||
if tool_calls is not None:
|
||||
md["tool_calls"] = tool_calls
|
||||
msg = MagicMock()
|
||||
msg.model_dump.return_value = md
|
||||
usage_obj = MagicMock()
|
||||
usage_obj.model_dump.return_value = {
|
||||
"prompt_tokens": usage[0], "completion_tokens": usage[1], "total_tokens": sum(usage)}
|
||||
return NS(choices=[NS(message=msg, finish_reason=finish_reason)], usage=usage_obj)
|
||||
|
||||
|
||||
def _patch_backend(native=False, endpoint="http://ollama:11434", cache=None):
|
||||
return (
|
||||
patch.object(api_messages, "choose_endpoint",
|
||||
AsyncMock(return_value=(endpoint, "test-model:latest"))),
|
||||
patch.object(api_messages, "decrement_usage", AsyncMock()),
|
||||
patch.object(api_messages, "is_anthropic_endpoint", return_value=native),
|
||||
patch.object(api_messages, "_make_openai_client", return_value=MagicMock()),
|
||||
patch.object(api_messages, "get_llm_cache", return_value=cache),
|
||||
)
|
||||
|
||||
|
||||
class TestTranslatedRoute:
|
||||
async def test_nonstream(self, client):
|
||||
with _enter(*_patch_backend(native=False),
|
||||
patch.object(api_messages, "create_chat_with_retries",
|
||||
AsyncMock(return_value=_fake_completion("hello world")))):
|
||||
resp = await client.post("/v1/messages",
|
||||
json={"model": "test-model", "max_tokens": 100,
|
||||
"messages": [{"role": "user", "content": "hi"}]})
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["type"] == "message"
|
||||
assert body["role"] == "assistant"
|
||||
assert body["content"] == [{"type": "text", "text": "hello world"}]
|
||||
assert body["stop_reason"] == "end_turn"
|
||||
assert body["usage"]["input_tokens"] == 3 and body["usage"]["output_tokens"] == 5
|
||||
assert body["id"].startswith("msg_")
|
||||
|
||||
async def test_missing_max_tokens_400(self, client):
|
||||
with _enter(*_patch_backend(native=False)):
|
||||
resp = await client.post("/v1/messages",
|
||||
json={"model": "m", "messages": [{"role": "user", "content": "hi"}]})
|
||||
assert resp.status_code == 400
|
||||
|
||||
async def test_nonstream_tool_use(self, client):
|
||||
tc = [{"id": "c1", "function": {"name": "get", "arguments": '{"a":1}'}}]
|
||||
with _enter(*_patch_backend(native=False),
|
||||
patch.object(api_messages, "create_chat_with_retries",
|
||||
AsyncMock(return_value=_fake_completion(
|
||||
content=None, tool_calls=tc, finish_reason="tool_calls")))):
|
||||
resp = await client.post("/v1/messages",
|
||||
json={"model": "m", "max_tokens": 50,
|
||||
"messages": [{"role": "user", "content": "call it"}]})
|
||||
body = resp.json()
|
||||
assert body["stop_reason"] == "tool_use"
|
||||
assert body["content"][0] == {"type": "tool_use", "id": "c1", "name": "get", "input": {"a": 1}}
|
||||
|
||||
async def test_stream_event_sequence(self, client):
|
||||
async def _text():
|
||||
yield _chunk(content="Hi", finish_reason="stop")
|
||||
yield _usage_chunk(3, 2)
|
||||
with _enter(*_patch_backend(native=False),
|
||||
patch.object(api_messages, "create_chat_with_retries",
|
||||
AsyncMock(return_value=_text()))):
|
||||
resp = await client.post("/v1/messages",
|
||||
json={"model": "m", "max_tokens": 50, "stream": True,
|
||||
"messages": [{"role": "user", "content": "hi"}]})
|
||||
assert resp.headers["content-type"].startswith("text/event-stream")
|
||||
events = _parse_sse(resp.content.decode())
|
||||
types = [e[0] for e in events]
|
||||
assert types[0] == "message_start" and types[-1] == "message_stop"
|
||||
text = "".join(d["delta"]["text"] for t, d in events
|
||||
if t == "content_block_delta" and d["delta"]["type"] == "text_delta")
|
||||
assert text == "Hi"
|
||||
|
||||
async def test_thinking_passed_as_reasoning_effort(self, client):
|
||||
captured = {}
|
||||
|
||||
async def _spy(oclient, send_params, endpoint, model, tracking_model):
|
||||
captured.update(send_params)
|
||||
return _fake_completion("ok")
|
||||
|
||||
with _enter(*_patch_backend(native=False),
|
||||
patch.object(api_messages, "create_chat_with_retries", _spy)):
|
||||
await client.post("/v1/messages",
|
||||
json={"model": "m", "max_tokens": 50,
|
||||
"thinking": {"type": "enabled", "budget_tokens": 20000},
|
||||
"messages": [{"role": "user", "content": "hi"}]})
|
||||
assert captured["reasoning_effort"] == "high"
|
||||
|
||||
|
||||
class TestCacheRoute:
|
||||
async def test_hit_reports_cache_read(self, client):
|
||||
stored = at.build_message_object(
|
||||
message_id="msg_old", model="m",
|
||||
content_blocks=[{"type": "text", "text": "cached"}], stop_reason="end_turn",
|
||||
usage=at.usage_chat_to_anthropic({"prompt_tokens": 9, "completion_tokens": 4}))
|
||||
fake_cache = MagicMock()
|
||||
fake_cache.get_chat = AsyncMock(return_value=orjson.dumps(stored))
|
||||
with _enter(*_patch_backend(native=False, cache=fake_cache)):
|
||||
resp = await client.post("/v1/messages",
|
||||
json={"model": "m", "max_tokens": 50, "nomyo": {"cache": True},
|
||||
"messages": [{"role": "user", "content": "hi"}]})
|
||||
body = resp.json()
|
||||
assert body["content"] == [{"type": "text", "text": "cached"}]
|
||||
assert body["usage"]["cache_read_input_tokens"] == 9
|
||||
assert body["usage"]["input_tokens"] == 0
|
||||
assert body["id"].startswith("msg_") and body["id"] != "msg_old"
|
||||
|
||||
|
||||
class TestNativeRoute:
|
||||
def _fake_client(self, *, post_return=None, stream_frames=None, status=200):
|
||||
client = MagicMock()
|
||||
if post_return is not None:
|
||||
resp = MagicMock()
|
||||
resp.status_code = status
|
||||
resp.json.return_value = post_return
|
||||
client.post = AsyncMock(return_value=resp)
|
||||
if stream_frames is not None:
|
||||
class _Stream:
|
||||
async def __aenter__(self_):
|
||||
return self_
|
||||
async def __aexit__(self_, *a):
|
||||
return False
|
||||
async def aiter_bytes(self_):
|
||||
for f in stream_frames:
|
||||
yield f
|
||||
client.stream = MagicMock(return_value=_Stream())
|
||||
return client
|
||||
|
||||
async def test_nonstream_passthrough(self, client):
|
||||
upstream = {"id": "msg_upstream", "type": "message", "role": "assistant",
|
||||
"content": [{"type": "text", "text": "native hi"}],
|
||||
"stop_reason": "end_turn",
|
||||
"usage": {"input_tokens": 2, "output_tokens": 3}}
|
||||
fake = self._fake_client(post_return=upstream)
|
||||
with _enter(*_patch_backend(native=True, endpoint="https://api.anthropic.com"),
|
||||
patch.object(api_messages, "_anthropic_http_client", return_value=fake)):
|
||||
resp = await client.post("/v1/messages",
|
||||
json={"model": "claude-x", "max_tokens": 50,
|
||||
"messages": [{"role": "user", "content": "hi"}]})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["content"][0]["text"] == "native hi"
|
||||
# request forwarded verbatim with stream disabled, nomyo stripped
|
||||
sent = fake.post.call_args.kwargs["json"]
|
||||
assert sent["stream"] is False and "nomyo" not in sent
|
||||
headers = fake.post.call_args.kwargs["headers"]
|
||||
assert headers["x-api-key"] and headers["anthropic-version"]
|
||||
|
||||
async def test_stream_passthrough(self, client):
|
||||
frames = [
|
||||
b'event: message_start\ndata: {"type":"message_start","message":{"usage":{"input_tokens":5}}}\n\n',
|
||||
b'event: message_delta\ndata: {"type":"message_delta","usage":{"output_tokens":7}}\n\n',
|
||||
b'event: message_stop\ndata: {"type":"message_stop"}\n\n',
|
||||
]
|
||||
fake = self._fake_client(stream_frames=frames)
|
||||
tracked = []
|
||||
with _enter(*_patch_backend(native=True, endpoint="https://api.anthropic.com"),
|
||||
patch.object(api_messages, "_anthropic_http_client", return_value=fake),
|
||||
patch.object(api_messages, "_track",
|
||||
AsyncMock(side_effect=lambda *a: tracked.append(a)))):
|
||||
resp = await client.post("/v1/messages",
|
||||
json={"model": "claude-x", "max_tokens": 50, "stream": True,
|
||||
"messages": [{"role": "user", "content": "hi"}]})
|
||||
body = resp.content.decode()
|
||||
assert "message_start" in body and "message_stop" in body
|
||||
# usage parsed out of the proxied stream for token tracking
|
||||
assert tracked and tracked[0][2] == 5 and tracked[0][3] == 7
|
||||
|
||||
|
||||
class TestCountTokens:
|
||||
async def test_local_estimate(self, client):
|
||||
with _enter(patch.object(api_messages, "choose_endpoint",
|
||||
AsyncMock(return_value=("http://ollama:11434", "m"))),
|
||||
patch.object(api_messages, "is_anthropic_endpoint", return_value=False)):
|
||||
resp = await client.post("/v1/messages/count_tokens",
|
||||
json={"model": "m",
|
||||
"messages": [{"role": "user", "content": "count me"}]})
|
||||
assert resp.status_code == 200
|
||||
assert isinstance(resp.json()["input_tokens"], int)
|
||||
assert resp.json()["input_tokens"] > 0
|
||||
Loading…
Add table
Add a link
Reference in a new issue