Files
hunjing/backend/tests/ai_provider/llm/test_anthropic_format.py
T
2026-08-21 16:00:13 +08:00

544 lines
21 KiB
Python

from __future__ import annotations
import pytest
from app.ai_provider.config.models import ModelConfig, ParameterMapping, ProviderConfig
from app.ai_provider.llm.anthropic_format import AnthropicFormatAdapter
from app.ai_provider.types.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage
class TestAnthropicMessageConversion:
"""Conversion of internal message types to Anthropic content-block format."""
@pytest.fixture
def adapter(self):
provider = ProviderConfig(
name="anthropic",
format="anthropic",
base_url_env="ANTHROPIC_BASE_URL",
api_key_env="ANTHROPIC_API_KEY",
parameter_mappings={
"temperature": ParameterMapping(provider_param="temperature", min=0, max=1),
"max_tokens": ParameterMapping(provider_param="max_tokens"),
"top_p": ParameterMapping(provider_param="top_p", min=0, max=1),
},
)
return AnthropicFormatAdapter(provider_config=provider)
def test_convert_human_message(self, adapter):
"""HumanMessage becomes user role with a text content block."""
msg = HumanMessage(content="Hello!")
result = adapter._convert_message(msg)
assert result["role"] == "user"
assert result["content"] == [{"type": "text", "text": "Hello!"}]
def test_convert_ai_message(self, adapter):
"""AIMessage becomes assistant role with a text content block."""
msg = AIMessage(content="Sure, let me help.")
result = adapter._convert_message(msg)
assert result["role"] == "assistant"
assert result["content"] == [{"type": "text", "text": "Sure, let me help."}]
def test_convert_ai_message_replays_raw_content_blocks(self, adapter):
blocks = [
{"type": "thinking", "thinking": "summary", "signature": "sig_1"},
{"type": "tool_use", "id": "call_1", "name": "read_file", "input": {}},
]
msg = AIMessage(content="", raw_content_blocks=blocks)
assert adapter._convert_message(msg) == {"role": "assistant", "content": blocks}
def test_convert_ai_message_with_tool_calls(self, adapter):
"""AIMessage with tool_calls produces tool_use content blocks."""
msg = AIMessage(
content="",
tool_calls=[
{"name": "read_file", "args": {"path": "x.txt"}, "id": "call_1"},
],
)
result = adapter._convert_message(msg)
assert result["role"] == "assistant"
assert result["content"] == [
{"type": "tool_use", "id": "call_1", "name": "read_file", "input": {"path": "x.txt"}},
]
def test_convert_ai_message_with_text_and_tool_calls(self, adapter):
"""AIMessage with both text and tool_calls produces multiple content blocks."""
msg = AIMessage(
content="I will check that file.",
tool_calls=[
{"name": "read_file", "args": {"path": "x.txt"}, "id": "call_1"},
{"name": "list_dir", "args": {"path": "/tmp"}, "id": "call_2"},
],
)
result = adapter._convert_message(msg)
assert result["role"] == "assistant"
assert len(result["content"]) == 3
assert result["content"][0] == {"type": "text", "text": "I will check that file."}
assert result["content"][1] == {
"type": "tool_use",
"id": "call_1",
"name": "read_file",
"input": {"path": "x.txt"},
}
assert result["content"][2] == {
"type": "tool_use",
"id": "call_2",
"name": "list_dir",
"input": {"path": "/tmp"},
}
def test_convert_tool_message(self, adapter):
"""ToolMessage becomes user role with a tool_result content block."""
msg = ToolMessage(content="File contents", tool_call_id="call_1")
result = adapter._convert_message(msg)
assert result["role"] == "user"
assert result["content"] == [
{"type": "tool_result", "tool_use_id": "call_1", "content": "File contents"},
]
class TestAnthropicRequestBuilding:
"""Building the full request body for the Anthropic API."""
@pytest.fixture
def adapter(self):
provider = ProviderConfig(
name="anthropic",
format="anthropic",
base_url_env="ANTHROPIC_BASE_URL",
api_key_env="ANTHROPIC_API_KEY",
parameter_mappings={
"temperature": ParameterMapping(provider_param="temperature", min=0, max=1),
"max_tokens": ParameterMapping(provider_param="max_tokens"),
},
)
return AnthropicFormatAdapter(provider_config=provider)
@pytest.fixture
def model(self):
return ModelConfig(
name="claude-sonnet-4",
provider="anthropic",
model="claude-sonnet-4-20250514",
format="anthropic",
max_output_tokens=8192,
)
def test_build_request_body(self, adapter, model):
"""Request body includes model, messages, and pipeline-processed params."""
messages = [HumanMessage(content="Hi")]
body = adapter.build_request(
model=model,
messages=messages,
params={"temperature": 0.7, "max_tokens": 4096},
)
assert body["model"] == "claude-sonnet-4-20250514"
assert body["temperature"] == 0.7
assert body["max_tokens"] == 4096
assert len(body["messages"]) == 1
assert body["messages"][0]["role"] == "user"
def test_build_request_with_system_message(self, adapter, model):
"""System message is placed in top-level 'system' field, not in messages."""
messages = [
SystemMessage(content="You are a helpful assistant."),
HumanMessage(content="Hi"),
]
body = adapter.build_request(model=model, messages=messages)
assert body["system"] == "You are a helpful assistant."
# system message is removed from the messages array
assert len(body["messages"]) == 1
assert body["messages"][0]["role"] == "user"
def test_build_request_with_multiple_system_messages(self, adapter, model):
"""Multiple system messages are concatenated with newlines."""
messages = [
SystemMessage(content="Be concise."),
SystemMessage(content="Use Chinese."),
HumanMessage(content="Hi"),
]
body = adapter.build_request(model=model, messages=messages)
assert body["system"] == "Be concise.\nUse Chinese."
assert len(body["messages"]) == 1
def test_build_request_sets_default_max_tokens(self, adapter, model):
"""max_tokens defaults to model.max_output_tokens when not in params."""
messages = [HumanMessage(content="Hi")]
body = adapter.build_request(model=model, messages=messages, params={})
assert body["max_tokens"] == 8192
def test_build_request_with_tools(self, adapter, model):
"""Tools are converted to Anthropic format in the request."""
messages = [HumanMessage(content="Read file x.txt")]
openai_tools = [
{
"type": "function",
"function": {
"name": "read_file",
"description": "Read a file's contents",
"parameters": {
"type": "object",
"properties": {"path": {"type": "string"}},
"required": ["path"],
},
},
},
]
body = adapter.build_request(
model=model,
messages=messages,
tools=openai_tools,
tool_choice="auto",
)
assert "tools" in body
assert len(body["tools"]) == 1
anthropic_tool = body["tools"][0]
assert anthropic_tool["name"] == "read_file"
assert anthropic_tool["description"] == "Read a file's contents"
assert anthropic_tool["input_schema"] == {
"type": "object",
"properties": {"path": {"type": "string"}},
"required": ["path"],
}
assert body["tool_choice"] == "auto"
def test_build_request_streaming(self, adapter, model):
"""Streaming flag is passed through in the request."""
messages = [HumanMessage(content="Hi")]
body = adapter.build_request(model=model, messages=messages, stream=True)
assert body["stream"] is True
class TestAnthropicToolConversion:
"""Converting tools between OpenAI and Anthropic formats."""
def test_convert_tools_standard(self):
"""OpenAI format tools are converted to Anthropic format."""
tools = [
{
"type": "function",
"function": {
"name": "read_file",
"description": "Read a file",
"parameters": {"type": "object"},
},
},
]
result = AnthropicFormatAdapter._convert_tools(tools)
assert len(result) == 1
assert result[0] == {
"name": "read_file",
"description": "Read a file",
"input_schema": {"type": "object"},
}
def test_convert_tools_already_anthropic(self):
"""Tools already in Anthropic format pass through correctly."""
tools = [
{
"name": "read_file",
"description": "Read a file",
"input_schema": {"type": "object"},
},
]
result = AnthropicFormatAdapter._convert_tools(tools)
assert len(result) == 1
assert result[0] == tools[0]
class TestAnthropicResponseParsing:
"""Parsing Anthropic API responses (non-streaming)."""
@pytest.fixture
def adapter(self):
return AnthropicFormatAdapter(
provider_config=ProviderConfig(
name="anthropic",
format="anthropic",
base_url_env="ANTHROPIC_BASE_URL",
api_key_env="ANTHROPIC_API_KEY",
)
)
def test_parse_nonstreaming_text(self, adapter):
"""Simple text response is parsed correctly."""
raw = {
"id": "msg_123",
"type": "message",
"role": "assistant",
"content": [{"type": "text", "text": "Hello, world!"}],
"model": "claude-sonnet-4-20250514",
"stop_reason": "end_turn",
"usage": {"input_tokens": 10, "output_tokens": 5},
}
result = adapter.parse_nonstreaming_response(raw, "claude-sonnet-4")
assert result.content == "Hello, world!"
assert result.model_name == "claude-sonnet-4"
assert result.usage is not None
assert result.usage.prompt_tokens == 10
assert result.usage.completion_tokens == 5
assert result.usage.total_tokens == 15
def test_parse_nonstreaming_with_tool_calls(self, adapter):
"""Response with tool_use blocks is parsed with tool_calls."""
raw = {
"id": "msg_456",
"type": "message",
"role": "assistant",
"content": [
{"type": "text", "text": "I will read the file."},
{
"type": "tool_use",
"id": "tu_123",
"name": "read_file",
"input": {"path": "x.txt"},
},
],
"model": "claude-sonnet-4-20250514",
"stop_reason": "tool_use",
"usage": {"input_tokens": 20, "output_tokens": 15},
}
result = adapter.parse_nonstreaming_response(raw, "claude-sonnet-4")
assert result.content == "I will read the file."
assert len(result.tool_calls) == 1
assert result.tool_calls[0] == {
"name": "read_file",
"args": {"path": "x.txt"},
"id": "tu_123",
}
def test_parse_nonstreaming_with_reasoning(self, adapter):
"""Response with thinking blocks populates reasoning field."""
raw = {
"id": "msg_789",
"type": "message",
"role": "assistant",
"content": [
{"type": "thinking", "thinking": "Let me think step by step..."},
{"type": "text", "text": "Here is the answer."},
{"type": "signature", "signature": "sig_12345"},
],
"model": "claude-sonnet-4-20250514",
"stop_reason": "end_turn",
"usage": {"input_tokens": 5, "output_tokens": 10},
}
result = adapter.parse_nonstreaming_response(raw, "claude-sonnet-4")
assert result.content == "Here is the answer."
# signature 块是加密签名,不进入 reasoning
assert result.reasoning == "Let me think step by step..."
def test_parse_nonstreaming_no_usage(self, adapter):
"""Response without usage data still parses correctly."""
raw = {
"id": "msg_000",
"type": "message",
"role": "assistant",
"content": [{"type": "text", "text": "No usage info."}],
"model": "claude-sonnet-4-20250514",
"stop_reason": "end_turn",
}
result = adapter.parse_nonstreaming_response(raw, "claude-sonnet-4")
assert result.content == "No usage info."
assert result.usage is None
def test_parse_content_blocks_empty(self):
"""Empty content blocks produce empty text and no tool calls."""
raw = {"content": []}
text, tools, reasoning = AnthropicFormatAdapter._parse_content_blocks(raw)
assert text == ""
assert tools == []
assert reasoning == ""
def test_bind_tools(self):
"""bind_tools adds converted tools to the request body."""
adapter = AnthropicFormatAdapter(
provider_config=ProviderConfig(
name="anthropic",
format="anthropic",
base_url_env="ANTHROPIC_BASE_URL",
api_key_env="ANTHROPIC_API_KEY",
)
)
tools = [
{
"type": "function",
"function": {
"name": "read_file",
"description": "Read a file",
"parameters": {"type": "object"},
},
},
]
body = {}
adapter.bind_tools(body, tools, tool_choice="auto")
assert body["tools"] == [
{
"name": "read_file",
"description": "Read a file",
"input_schema": {"type": "object"},
},
]
assert body["tool_choice"] == "auto"
class TestAnthropicStreamParsing:
"""Parsing Anthropic SSE stream events."""
def test_parse_message_start(self):
"""message_start event sets initial token usage."""
data = {
"type": "message_start",
"message": {
"id": "msg_123",
"usage": {"input_tokens": 15, "output_tokens": 0},
},
}
chunk = AnthropicFormatAdapter._parse_stream_event("message_start", data)
assert chunk.usage is not None
assert chunk.usage.prompt_tokens == 15
assert chunk.usage.completion_tokens == 0
assert chunk.delta_content == ""
def test_parse_content_block_start_text(self):
"""content_block_start for text sets delta_content."""
data = {
"type": "content_block_start",
"index": 0,
"content_block": {"type": "text", "text": "Hello"},
}
chunk = AnthropicFormatAdapter._parse_stream_event("content_block_start", data)
assert chunk.delta_content == "Hello"
def test_parse_content_block_start_thinking(self):
"""content_block_start for thinking sets delta_reasoning."""
data = {
"type": "content_block_start",
"index": 0,
"content_block": {"type": "thinking", "thinking": "Let me think..."},
}
chunk = AnthropicFormatAdapter._parse_stream_event("content_block_start", data)
assert chunk.delta_reasoning == "Let me think..."
def test_parse_content_block_start_tool_use(self):
"""content_block_start for tool_use creates a tool call chunk stub."""
data = {
"type": "content_block_start",
"index": 1,
"content_block": {
"type": "tool_use",
"id": "tu_123",
"name": "read_file",
"input": {},
},
}
chunk = AnthropicFormatAdapter._parse_stream_event("content_block_start", data)
assert len(chunk.tool_call_chunks) == 1
assert chunk.tool_call_chunks[0]["index"] == 1
assert chunk.tool_call_chunks[0]["id"] == "tu_123"
assert chunk.tool_call_chunks[0]["name"] == "read_file"
assert chunk.tool_call_chunks[0]["args"] == ""
def test_parse_content_block_delta_text(self):
"""content_block_delta for text_delta appends content."""
data = {
"type": "content_block_delta",
"index": 0,
"delta": {"type": "text_delta", "text": " world"},
}
chunk = AnthropicFormatAdapter._parse_stream_event("content_block_delta", data)
assert chunk.delta_content == " world"
def test_parse_content_block_delta_thinking(self):
"""content_block_delta for thinking_delta appends reasoning."""
data = {
"type": "content_block_delta",
"index": 0,
"delta": {"type": "thinking_delta", "thinking": " more..."},
}
chunk = AnthropicFormatAdapter._parse_stream_event("content_block_delta", data)
assert chunk.delta_reasoning == " more..."
def test_parse_content_block_delta_signature(self):
"""content_block_delta for signature_delta is ignored (not reasoning)."""
data = {
"type": "content_block_delta",
"index": 0,
"delta": {"type": "signature_delta", "signature": "sig_abc"},
}
chunk = AnthropicFormatAdapter._parse_stream_event("content_block_delta", data)
# signature 不展示为 reasoning,但保留原始 delta 供 Agent 下一轮回放。
assert chunk.delta_reasoning == ""
assert chunk.content_block_index == 0
assert chunk.content_block_delta == data["delta"]
def test_parse_content_block_delta_input_json(self):
"""content_block_delta for input_json_delta creates a tool call arg chunk."""
data = {
"type": "content_block_delta",
"index": 0,
"delta": {"type": "input_json_delta", "partial_json": '{"path": "'},
}
chunk = AnthropicFormatAdapter._parse_stream_event("content_block_delta", data)
assert len(chunk.tool_call_chunks) == 1
assert chunk.tool_call_chunks[0]["args"] == '{"path": "'
def test_parse_message_delta_stop(self):
"""message_delta with end_turn sets finish_reason to 'stop'."""
data = {
"type": "message_delta",
"delta": {"stop_reason": "end_turn", "stop_sequence": None},
"usage": {"output_tokens": 42},
}
chunk = AnthropicFormatAdapter._parse_stream_event("message_delta", data)
assert chunk.finish_reason == "stop"
assert chunk.usage is not None
assert chunk.usage.completion_tokens == 42
def test_parse_message_delta_tool_use(self):
"""message_delta with tool_use sets finish_reason to 'tool_calls'."""
data = {
"type": "message_delta",
"delta": {"stop_reason": "tool_use"},
"usage": {"output_tokens": 10},
}
chunk = AnthropicFormatAdapter._parse_stream_event("message_delta", data)
assert chunk.finish_reason == "tool_calls"
def test_parse_message_delta_max_tokens(self):
"""message_delta with max_tokens stop reason."""
data = {
"type": "message_delta",
"delta": {"stop_reason": "max_tokens"},
"usage": {"output_tokens": 5},
}
chunk = AnthropicFormatAdapter._parse_stream_event("message_delta", data)
assert chunk.finish_reason == "length"
def test_parse_content_block_stop_noop(self):
"""content_block_stop produces an empty chunk."""
data = {"type": "content_block_stop", "index": 0}
chunk = AnthropicFormatAdapter._parse_stream_event("content_block_stop", data)
assert chunk.delta_content == ""
assert chunk.delta_reasoning == ""
assert chunk.usage is None
class TestAnthropicAuthHeaders:
"""Anthropic-specific authentication headers."""
def test_headers_contain_api_key_and_version(self):
"""Headers include x-api-key, anthropic-version, and content type."""
adapter = AnthropicFormatAdapter(
provider_config=ProviderConfig(
name="anthropic",
format="anthropic",
base_url_env="ANTHROPIC_BASE_URL",
api_key_env="ANTHROPIC_API_KEY",
)
)
headers = adapter._headers("sk-ant-12345")
assert headers["x-api-key"] == "sk-ant-12345"
assert headers["anthropic-version"] == "2023-06-01"
assert headers["Content-Type"] == "application/json"