初始化提交
This commit is contained in:
commit
887c1f449e
963 files changed
+166869
No files matched your search
@@ -0,0 +1,543 @@
|
||||
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"
|
||||
Reference in new issue
Block a user