"""Anthropic Provider 测试""" import json from unittest.mock import AsyncMock, MagicMock, patch import httpx import pytest from pytest_httpx import HTTPXMock from agentkit.core.exceptions import LLMProviderError from agentkit.llm.protocol import LLMRequest, LLMResponse, StreamChunk, TokenUsage from agentkit.llm.providers.anthropic import AnthropicProvider class TestAnthropicMessageConversion: """消息格式转换测试""" def setup_method(self): self.provider = AnthropicProvider(api_key="test-key") def test_system_message_extracted_as_top_level(self): """system 消息应被提取为顶层 system 参数""" messages = [ {"role": "system", "content": "You are a helpful assistant."}, {"role": "user", "content": "Hello"}, ] system, anthropic_msgs = self.provider._convert_messages(messages) assert system == "You are a helpful assistant." assert len(anthropic_msgs) == 1 assert anthropic_msgs[0]["role"] == "user" assert anthropic_msgs[0]["content"] == [{"type": "text", "text": "Hello"}] def test_text_messages_converted_to_content_blocks(self): """普通文本消息应转换为 content blocks""" messages = [ {"role": "user", "content": "Hi"}, {"role": "assistant", "content": "Hello!"}, {"role": "user", "content": "How are you?"}, ] system, anthropic_msgs = self.provider._convert_messages(messages) assert system is None assert len(anthropic_msgs) == 3 assert anthropic_msgs[0] == {"role": "user", "content": [{"type": "text", "text": "Hi"}]} assert anthropic_msgs[1] == {"role": "assistant", "content": [{"type": "text", "text": "Hello!"}]} assert anthropic_msgs[2] == {"role": "user", "content": [{"type": "text", "text": "How are you?"}]} def test_assistant_tool_calls_converted(self): """assistant 的 tool_calls 应转换为 tool_use content blocks""" messages = [ {"role": "user", "content": "What's the weather?"}, { "role": "assistant", "content": None, "tool_calls": [ { "id": "call_123", "type": "function", "function": { "name": "get_weather", "arguments": '{"city": "Beijing"}', }, } ], }, ] system, anthropic_msgs = self.provider._convert_messages(messages) assert len(anthropic_msgs) == 2 assistant_msg = anthropic_msgs[1] assert assistant_msg["role"] == "assistant" assert len(assistant_msg["content"]) == 1 assert assistant_msg["content"][0]["type"] == "tool_use" assert assistant_msg["content"][0]["id"] == "call_123" assert assistant_msg["content"][0]["name"] == "get_weather" assert assistant_msg["content"][0]["input"] == {"city": "Beijing"} def test_assistant_tool_calls_with_text(self): """assistant 同时有文本和 tool_calls""" messages = [ { "role": "assistant", "content": "Let me check that.", "tool_calls": [ { "id": "call_456", "type": "function", "function": { "name": "search", "arguments": '{"q": "test"}', }, } ], }, ] _, anthropic_msgs = self.provider._convert_messages(messages) content = anthropic_msgs[0]["content"] assert len(content) == 2 assert content[0]["type"] == "text" assert content[0]["text"] == "Let me check that." assert content[1]["type"] == "tool_use" def test_tool_result_converted(self): """tool 角色消息应转换为 tool_result content blocks""" messages = [ { "role": "tool", "tool_call_id": "call_123", "content": "Sunny, 25°C", }, ] _, anthropic_msgs = self.provider._convert_messages(messages) assert len(anthropic_msgs) == 1 msg = anthropic_msgs[0] assert msg["role"] == "user" assert len(msg["content"]) == 1 assert msg["content"][0]["type"] == "tool_result" assert msg["content"][0]["tool_use_id"] == "call_123" assert msg["content"][0]["content"] == [{"type": "text", "text": "Sunny, 25°C"}] def test_user_with_tool_call_id_converted(self): """user 消息带 tool_call_id 也应转换为 tool_result""" messages = [ { "role": "user", "tool_call_id": "call_789", "content": "Result data", }, ] _, anthropic_msgs = self.provider._convert_messages(messages) msg = anthropic_msgs[0] assert msg["role"] == "user" assert msg["content"][0]["type"] == "tool_result" assert msg["content"][0]["tool_use_id"] == "call_789" def test_no_system_message(self): """没有 system 消息时返回 None""" messages = [ {"role": "user", "content": "Hello"}, ] system, _ = self.provider._convert_messages(messages) assert system is None class TestAnthropicToolConversion: """工具格式转换测试""" def setup_method(self): self.provider = AnthropicProvider(api_key="test-key") def test_convert_openai_tools_to_anthropic(self): """OpenAI function 格式应转换为 Anthropic tool 格式""" tools = [ { "type": "function", "function": { "name": "get_weather", "description": "Get weather for a city", "parameters": { "type": "object", "properties": {"city": {"type": "string"}}, }, }, } ] result = self.provider._convert_tools(tools) assert len(result) == 1 assert result[0]["name"] == "get_weather" assert result[0]["description"] == "Get weather for a city" assert result[0]["input_schema"] == { "type": "object", "properties": {"city": {"type": "string"}}, } def test_convert_tool_choice_auto(self): """tool_choice=auto 应转换为 Anthropic 格式""" result = self.provider._convert_tool_choice("auto") assert result == {"type": "auto"} def test_convert_tool_choice_required(self): """tool_choice=required 应转换为 Anthropic any 格式""" result = self.provider._convert_tool_choice("required") assert result == {"type": "any"} def test_convert_tool_choice_specific_tool(self): """指定工具名的 tool_choice 应转换为 Anthropic tool 格式""" result = self.provider._convert_tool_choice("get_weather") assert result == {"type": "tool", "name": "get_weather"} def test_convert_tool_choice_none(self): """tool_choice=none 应返回 None""" result = self.provider._convert_tool_choice("none") assert result is None class TestAnthropicResponseParsing: """响应解析测试""" def setup_method(self): self.provider = AnthropicProvider(api_key="test-key") def test_parse_text_response(self): """解析纯文本响应""" data = { "id": "msg_123", "type": "message", "role": "assistant", "model": "claude-sonnet-4-20250514", "content": [ {"type": "text", "text": "Hello! How can I help?"} ], "usage": {"input_tokens": 10, "output_tokens": 6}, } response = self.provider._parse_response(data, "claude-sonnet-4-20250514") assert isinstance(response, LLMResponse) assert response.content == "Hello! How can I help?" assert response.model == "claude-sonnet-4-20250514" assert response.usage.prompt_tokens == 10 assert response.usage.completion_tokens == 6 assert not response.has_tool_calls def test_parse_tool_use_response(self): """解析包含 tool_use 的响应""" data = { "id": "msg_456", "type": "message", "role": "assistant", "model": "claude-sonnet-4-20250514", "content": [ {"type": "text", "text": "Let me check the weather."}, { "type": "tool_use", "id": "toolu_123", "name": "get_weather", "input": {"city": "Beijing"}, }, ], "usage": {"input_tokens": 20, "output_tokens": 15}, } response = self.provider._parse_response(data, "claude-sonnet-4-20250514") assert response.content == "Let me check the weather." assert response.has_tool_calls assert len(response.tool_calls) == 1 assert response.tool_calls[0].id == "toolu_123" assert response.tool_calls[0].name == "get_weather" assert response.tool_calls[0].arguments == {"city": "Beijing"} def test_parse_multiple_tool_uses(self): """解析包含多个 tool_use 的响应""" data = { "id": "msg_789", "type": "message", "role": "assistant", "model": "claude-sonnet-4-20250514", "content": [ { "type": "tool_use", "id": "toolu_1", "name": "get_weather", "input": {"city": "Beijing"}, }, { "type": "tool_use", "id": "toolu_2", "name": "get_weather", "input": {"city": "Shanghai"}, }, ], "usage": {"input_tokens": 25, "output_tokens": 20}, } response = self.provider._parse_response(data, "claude-sonnet-4-20250514") assert len(response.tool_calls) == 2 assert response.tool_calls[0].name == "get_weather" assert response.tool_calls[0].arguments == {"city": "Beijing"} assert response.tool_calls[1].arguments == {"city": "Shanghai"} class TestAnthropicChat: """chat() 方法集成测试""" async def test_chat_returns_llm_response(self, httpx_mock: HTTPXMock): """chat 应返回 LLMResponse""" httpx_mock.add_response( url="https://api.anthropic.com/v1/messages", json={ "id": "msg_001", "type": "message", "role": "assistant", "model": "claude-sonnet-4-20250514", "content": [{"type": "text", "text": "Hello from Claude!"}], "usage": {"input_tokens": 10, "output_tokens": 5}, "stop_reason": "end_turn", }, ) provider = AnthropicProvider(api_key="test-key") request = LLMRequest( messages=[{"role": "user", "content": "Hi"}], model="claude-sonnet-4-20250514", ) response = await provider.chat(request) assert isinstance(response, LLMResponse) assert response.content == "Hello from Claude!" assert response.model == "claude-sonnet-4-20250514" assert response.usage.prompt_tokens == 10 assert response.usage.completion_tokens == 5 assert response.latency_ms > 0 async def test_chat_with_system_message(self, httpx_mock: HTTPXMock): """system 消息应作为顶层参数发送""" httpx_mock.add_response( url="https://api.anthropic.com/v1/messages", json={ "id": "msg_002", "type": "message", "role": "assistant", "model": "claude-sonnet-4-20250514", "content": [{"type": "text", "text": "I am a helpful assistant."}], "usage": {"input_tokens": 15, "output_tokens": 8}, "stop_reason": "end_turn", }, ) provider = AnthropicProvider(api_key="test-key") request = LLMRequest( messages=[ {"role": "system", "content": "You are a helpful assistant."}, {"role": "user", "content": "Who are you?"}, ], model="claude-sonnet-4-20250514", ) response = await provider.chat(request) assert response.content == "I am a helpful assistant." # Verify the request payload request_body = json.loads(httpx_mock.get_requests()[-1].content) assert "system" in request_body assert request_body["system"] == "You are a helpful assistant." # System should NOT be in messages for msg in request_body["messages"]: assert msg["role"] != "system" async def test_chat_with_tools(self, httpx_mock: HTTPXMock): """带工具的请求应正确转换格式""" httpx_mock.add_response( url="https://api.anthropic.com/v1/messages", json={ "id": "msg_003", "type": "message", "role": "assistant", "model": "claude-sonnet-4-20250514", "content": [ { "type": "tool_use", "id": "toolu_001", "name": "get_weather", "input": {"city": "Tokyo"}, } ], "usage": {"input_tokens": 30, "output_tokens": 20}, "stop_reason": "tool_use", }, ) provider = AnthropicProvider(api_key="test-key") request = LLMRequest( messages=[{"role": "user", "content": "Weather in Tokyo?"}], model="claude-sonnet-4-20250514", tools=[ { "type": "function", "function": { "name": "get_weather", "description": "Get weather", "parameters": { "type": "object", "properties": {"city": {"type": "string"}}, }, }, } ], ) response = await provider.chat(request) assert response.has_tool_calls assert response.tool_calls[0].name == "get_weather" assert response.tool_calls[0].arguments == {"city": "Tokyo"} # Verify request format request_body = json.loads(httpx_mock.get_requests()[-1].content) assert "tools" in request_body assert request_body["tools"][0]["name"] == "get_weather" assert "input_schema" in request_body["tools"][0] assert "tool_choice" in request_body assert request_body["tool_choice"] == {"type": "auto"} async def test_chat_sends_correct_headers(self, httpx_mock: HTTPXMock): """验证请求头包含正确的 Anthropic 认证信息""" httpx_mock.add_response( url="https://api.anthropic.com/v1/messages", json={ "id": "msg_004", "type": "message", "role": "assistant", "model": "claude-sonnet-4-20250514", "content": [{"type": "text", "text": "OK"}], "usage": {"input_tokens": 5, "output_tokens": 2}, "stop_reason": "end_turn", }, ) provider = AnthropicProvider(api_key="sk-ant-test-key") request = LLMRequest( messages=[{"role": "user", "content": "Hi"}], model="claude-sonnet-4-20250514", ) await provider.chat(request) sent_request = httpx_mock.get_requests()[-1] assert sent_request.headers.get("x-api-key") == "sk-ant-test-key" assert sent_request.headers.get("anthropic-version") == "2023-06-01" assert sent_request.headers.get("content-type") == "application/json" async def test_chat_with_custom_base_url(self, httpx_mock: HTTPXMock): """自定义 base_url 应正确使用""" httpx_mock.add_response( url="https://custom-proxy.example.com/v1/messages", json={ "id": "msg_005", "type": "message", "role": "assistant", "model": "claude-sonnet-4-20250514", "content": [{"type": "text", "text": "Proxy response"}], "usage": {"input_tokens": 5, "output_tokens": 3}, "stop_reason": "end_turn", }, ) provider = AnthropicProvider( api_key="test-key", base_url="https://custom-proxy.example.com", ) request = LLMRequest( messages=[{"role": "user", "content": "Hi"}], model="claude-sonnet-4-20250514", ) response = await provider.chat(request) assert response.content == "Proxy response" class TestAnthropicStreaming: """chat_stream() 方法测试""" def _make_stream_response(self, sse_lines: list[str]): """Create a mock httpx streaming response context manager.""" response = MagicMock() response.status_code = 200 async def aiter_lines(): for line in sse_lines: yield line response.aiter_lines = aiter_lines response.aread = AsyncMock(return_value=b"") # Create async context manager context = MagicMock() context.__aenter__ = AsyncMock(return_value=response) context.__aexit__ = AsyncMock(return_value=False) return context async def test_stream_text_response(self): """流式文本响应应正确解析""" sse_lines = [ 'event: message_start', 'data: {"type":"message_start","message":{"id":"msg_s1","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[]}}', '', 'event: content_block_start', 'data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}', '', 'event: content_block_delta', 'data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}', '', 'event: content_block_delta', 'data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" world"}}', '', 'event: content_block_stop', 'data: {"type":"content_block_stop","index":0}', '', 'event: message_delta', 'data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"input_tokens":10,"output_tokens":5}}', '', 'event: message_stop', 'data: {"type":"message_stop"}', '', ] mock_client = MagicMock() mock_client.stream = MagicMock(return_value=self._make_stream_response(sse_lines)) provider = AnthropicProvider(api_key="test-key") provider._client = mock_client request = LLMRequest( messages=[{"role": "user", "content": "Hi"}], model="claude-sonnet-4-20250514", ) chunks = [] async for chunk in provider.chat_stream(request): chunks.append(chunk) # Should have text chunks + final chunk text_chunks = [c for c in chunks if c.content] assert len(text_chunks) == 2 assert text_chunks[0].content == "Hello" assert text_chunks[1].content == " world" # Final chunk with usage final_chunks = [c for c in chunks if c.is_final] assert len(final_chunks) == 1 assert final_chunks[0].usage is not None assert final_chunks[0].usage.prompt_tokens == 10 assert final_chunks[0].usage.completion_tokens == 5 async def test_stream_tool_use_response(self): """流式 tool_use 响应应正确解析""" sse_lines = [ 'event: message_start', 'data: {"type":"message_start","message":{"id":"msg_s2","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[]}}', '', 'event: content_block_start', 'data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_s1","name":"get_weather"}}', '', 'event: content_block_delta', 'data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\\"cit"}}', '', 'event: content_block_delta', 'data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"y\\":\\"Paris\\"}"}}', '', 'event: content_block_stop', 'data: {"type":"content_block_stop","index":0}', '', 'event: message_delta', 'data: {"type":"message_delta","delta":{"stop_reason":"tool_use"},"usage":{"input_tokens":20,"output_tokens":15}}', '', 'event: message_stop', 'data: {"type":"message_stop"}', '', ] mock_client = MagicMock() mock_client.stream = MagicMock(return_value=self._make_stream_response(sse_lines)) provider = AnthropicProvider(api_key="test-key") provider._client = mock_client request = LLMRequest( messages=[{"role": "user", "content": "Weather in Paris?"}], model="claude-sonnet-4-20250514", tools=[ { "type": "function", "function": { "name": "get_weather", "description": "Get weather", "parameters": {"type": "object", "properties": {"city": {"type": "string"}}}, }, } ], ) chunks = [] async for chunk in provider.chat_stream(request): chunks.append(chunk) # Final chunk should have tool calls final_chunks = [c for c in chunks if c.is_final] assert len(final_chunks) == 1 assert len(final_chunks[0].tool_calls) == 1 assert final_chunks[0].tool_calls[0].id == "toolu_s1" assert final_chunks[0].tool_calls[0].name == "get_weather" assert final_chunks[0].tool_calls[0].arguments == {"city": "Paris"} async def test_stream_error_event(self): """流式 error 事件应抛出 LLMProviderError""" sse_lines = [ 'event: error', 'data: {"type":"error","error":{"type":"overloaded_error","message":"Server is overloaded"}}', '', ] mock_client = MagicMock() mock_client.stream = MagicMock(return_value=self._make_stream_response(sse_lines)) provider = AnthropicProvider(api_key="test-key") provider._client = mock_client request = LLMRequest( messages=[{"role": "user", "content": "Hi"}], model="claude-sonnet-4-20250514", ) with pytest.raises(LLMProviderError) as exc_info: async for _ in provider.chat_stream(request): pass assert "overloaded" in str(exc_info.value).lower() async def test_stream_non_200_status(self): """流式请求非 200 状态应抛出 LLMProviderError""" response = MagicMock() response.status_code = 429 response.aread = AsyncMock(return_value=b'{"type":"error","error":{"type":"rate_limit_error","message":"Rate limit"}}') context = MagicMock() context.__aenter__ = AsyncMock(return_value=response) context.__aexit__ = AsyncMock(return_value=False) mock_client = MagicMock() mock_client.stream = MagicMock(return_value=context) provider = AnthropicProvider(api_key="test-key") provider._client = mock_client request = LLMRequest( messages=[{"role": "user", "content": "Hi"}], model="claude-sonnet-4-20250514", ) with pytest.raises(LLMProviderError) as exc_info: async for _ in provider.chat_stream(request): pass assert "429" in str(exc_info.value) class TestAnthropicErrors: """错误处理测试""" async def test_401_invalid_api_key(self, httpx_mock: HTTPXMock): """401 错误应抛出 LLMProviderError""" httpx_mock.add_response( url="https://api.anthropic.com/v1/messages", status_code=401, json={ "type": "error", "error": {"type": "authentication_error", "message": "invalid x-api-key"}, }, ) provider = AnthropicProvider(api_key="bad-key") request = LLMRequest( messages=[{"role": "user", "content": "Hi"}], model="claude-sonnet-4-20250514", ) with pytest.raises(LLMProviderError) as exc_info: await provider.chat(request) assert "anthropic" in str(exc_info.value) assert "401" in str(exc_info.value) async def test_429_rate_limit(self, httpx_mock: HTTPXMock): """429 错误应抛出 LLMProviderError""" httpx_mock.add_response( url="https://api.anthropic.com/v1/messages", status_code=429, json={ "type": "error", "error": {"type": "rate_limit_error", "message": "Rate limit exceeded"}, }, ) provider = AnthropicProvider(api_key="test-key") request = LLMRequest( messages=[{"role": "user", "content": "Hi"}], model="claude-sonnet-4-20250514", ) with pytest.raises(LLMProviderError) as exc_info: await provider.chat(request) assert "429" in str(exc_info.value) async def test_529_overloaded(self, httpx_mock: HTTPXMock): """529 错误应抛出 LLMProviderError""" httpx_mock.add_response( url="https://api.anthropic.com/v1/messages", status_code=529, json={ "type": "error", "error": {"type": "overloaded_error", "message": "Overloaded"}, }, ) provider = AnthropicProvider(api_key="test-key") request = LLMRequest( messages=[{"role": "user", "content": "Hi"}], model="claude-sonnet-4-20250514", ) with pytest.raises(LLMProviderError) as exc_info: await provider.chat(request) assert "529" in str(exc_info.value) async def test_500_server_error(self, httpx_mock: HTTPXMock): """500 错误应抛出 LLMProviderError""" httpx_mock.add_response( url="https://api.anthropic.com/v1/messages", status_code=500, json={ "type": "error", "error": {"type": "api_error", "message": "Internal server error"}, }, ) provider = AnthropicProvider(api_key="test-key") request = LLMRequest( messages=[{"role": "user", "content": "Hi"}], model="claude-sonnet-4-20250514", ) with pytest.raises(LLMProviderError): await provider.chat(request) async def test_network_error(self, httpx_mock: HTTPXMock): """网络错误应抛出 LLMProviderError""" httpx_mock.add_exception(httpx.ConnectError("Connection refused")) provider = AnthropicProvider(api_key="test-key") request = LLMRequest( messages=[{"role": "user", "content": "Hi"}], model="claude-sonnet-4-20250514", ) with pytest.raises(LLMProviderError): await provider.chat(request) async def test_error_does_not_expose_api_key(self, httpx_mock: HTTPXMock): """错误消息不应暴露 API Key""" httpx_mock.add_response( url="https://api.anthropic.com/v1/messages", status_code=401, json={ "type": "error", "error": {"type": "authentication_error", "message": "invalid x-api-key"}, }, ) provider = AnthropicProvider(api_key="sk-ant-secret-key-12345") request = LLMRequest( messages=[{"role": "user", "content": "Hi"}], model="claude-sonnet-4-20250514", ) with pytest.raises(LLMProviderError) as exc_info: await provider.chat(request) assert "sk-ant-secret-key-12345" not in str(exc_info.value) class TestAnthropicGetModelInfo: """get_model_info() 测试""" def test_returns_provider_and_model_info(self): provider = AnthropicProvider( api_key="test-key", model="claude-sonnet-4-20250514", max_tokens=8192, ) info = provider.get_model_info() assert info["provider"] == "anthropic" assert info["model"] == "claude-sonnet-4-20250514" assert info["max_tokens"] == 8192 assert info["thinking_enabled"] is False def test_thinking_enabled_flag(self): provider = AnthropicProvider( api_key="test-key", thinking_enabled=True, ) info = provider.get_model_info() assert info["thinking_enabled"] is True class TestAnthropicLazyClient: """Lazy client 初始化测试""" def test_client_not_created_on_init(self): """初始化时不应创建 HTTP 客户端""" provider = AnthropicProvider(api_key="test-key") assert provider._client is None def test_client_created_on_first_use(self): """首次使用时应创建 HTTP 客户端""" provider = AnthropicProvider(api_key="test-key") client = provider._get_client() assert client is not None assert provider._client is not None def test_client_reused(self): """多次调用应复用同一客户端""" provider = AnthropicProvider(api_key="test-key") client1 = provider._get_client() client2 = provider._get_client() assert client1 is client2 async def test_close_resets_client(self): """close 后客户端应被重置""" provider = AnthropicProvider(api_key="test-key") _ = provider._get_client() assert provider._client is not None await provider.close() assert provider._client is None