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"