diff --git a/litellm/litellm_core_utils/prompt_templates/factory.py b/litellm/litellm_core_utils/prompt_templates/factory.py index 04c8c235557..5da471ca8c8 100644 --- a/litellm/litellm_core_utils/prompt_templates/factory.py +++ b/litellm/litellm_core_utils/prompt_templates/factory.py @@ -1623,7 +1623,8 @@ def convert_function_to_anthropic_tool_invoke( def convert_to_anthropic_tool_invoke( tool_calls: List[ChatCompletionAssistantToolCall], -) -> List[AnthropicMessagesToolUseParam]: + web_search_results: Optional[List[Any]] = None, +) -> List[Union[AnthropicMessagesToolUseParam, Dict[str, Any]]]: """ OpenAI tool invokes: { @@ -1659,38 +1660,68 @@ def convert_to_anthropic_tool_invoke( } ] } + + For server-side tools (web_search), we need to reconstruct: + - server_tool_use blocks (id starts with "srvtoolu_") + - web_search_tool_result blocks (from provider_specific_fields) + + Fixes: https://github.com/BerriAI/litellm/issues/17737 """ - anthropic_tool_invoke = [] + anthropic_tool_invoke: List[Union[AnthropicMessagesToolUseParam, Dict[str, Any]]] = [] for tool in tool_calls: if not get_attribute_or_key(tool, "type") == "function": continue - _anthropic_tool_use_param = AnthropicMessagesToolUseParam( - type="tool_use", - id=cast(str, get_attribute_or_key(tool, "id")), - name=cast( - str, - get_attribute_or_key(get_attribute_or_key(tool, "function"), "name"), - ), - input=json.loads( - get_attribute_or_key( - get_attribute_or_key(tool, "function"), "arguments" - ) - ), + tool_id = cast(str, get_attribute_or_key(tool, "id")) + tool_name = cast( + str, + get_attribute_or_key(get_attribute_or_key(tool, "function"), "name"), + ) + tool_input = json.loads( + get_attribute_or_key( + get_attribute_or_key(tool, "function"), "arguments" + ) ) - _content_element = add_cache_control_to_content( - anthropic_content_element=_anthropic_tool_use_param, - original_content_element=dict(tool), - ) + # Check if this is a server-side tool (web_search, tool_search, etc.) + # Server tool IDs start with "srvtoolu_" + if tool_id.startswith("srvtoolu_"): + # Create server_tool_use block instead of tool_use + _anthropic_server_tool_use: Dict[str, Any] = { + "type": "server_tool_use", + "id": tool_id, + "name": tool_name, + "input": tool_input, + } + anthropic_tool_invoke.append(_anthropic_server_tool_use) - if "cache_control" in _content_element: - _anthropic_tool_use_param["cache_control"] = _content_element[ - "cache_control" - ] + # Add corresponding web_search_tool_result if available + if web_search_results: + for result in web_search_results: + if result.get("tool_use_id") == tool_id: + anthropic_tool_invoke.append(result) + break + else: + # Regular tool_use + _anthropic_tool_use_param = AnthropicMessagesToolUseParam( + type="tool_use", + id=tool_id, + name=tool_name, + input=tool_input, + ) - anthropic_tool_invoke.append(_anthropic_tool_use_param) + _content_element = add_cache_control_to_content( + anthropic_content_element=_anthropic_tool_use_param, + original_content_element=dict(tool), + ) + + if "cache_control" in _content_element: + _anthropic_tool_use_param["cache_control"] = _content_element[ + "cache_control" + ] + + anthropic_tool_invoke.append(_anthropic_tool_use_param) return anthropic_tool_invoke @@ -2052,8 +2083,15 @@ def anthropic_messages_pt( # noqa: PLR0915 if ( assistant_tool_calls is not None ): # support assistant tool invoke conversion + # Get web_search_results from provider_specific_fields for server_tool_use reconstruction + # Fixes: https://github.com/BerriAI/litellm/issues/17737 + _provider_specific_fields = assistant_content_block.get("provider_specific_fields") or {} + _web_search_results = _provider_specific_fields.get("web_search_results") assistant_content.extend( - convert_to_anthropic_tool_invoke(assistant_tool_calls) + convert_to_anthropic_tool_invoke( + assistant_tool_calls, + web_search_results=_web_search_results, + ) ) assistant_function_call = assistant_content_block.get("function_call") diff --git a/litellm/llms/anthropic/chat/transformation.py b/litellm/llms/anthropic/chat/transformation.py index 7b3e8065447..261bfeb5e40 100644 --- a/litellm/llms/anthropic/chat/transformation.py +++ b/litellm/llms/anthropic/chat/transformation.py @@ -1082,6 +1082,7 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): ], Optional[str], List[ChatCompletionToolCallChunk], + Optional[List[Any]], ]: text_content = "" citations: Optional[List[Any]] = None @@ -1092,6 +1093,7 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): ] = None reasoning_content: Optional[str] = None tool_calls: List[ChatCompletionToolCallChunk] = [] + web_search_results: Optional[List[Any]] = None for idx, content in enumerate(completion_response["content"]): if content["type"] == "text": text_content += content["text"] @@ -1117,6 +1119,11 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): # This block contains tool_references that were discovered # We don't need to include this in the response as it's internal metadata pass + ## WEB SEARCH TOOL RESULT - preserve web search results for multi-turn conversations + elif content["type"] == "web_search_tool_result": + if web_search_results is None: + web_search_results = [] + web_search_results.append(content) elif content.get("thinking", None) is not None: if thinking_blocks is None: thinking_blocks = [] @@ -1148,7 +1155,7 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): if thinking_content is not None: reasoning_content += thinking_content - return text_content, citations, thinking_blocks, reasoning_content, tool_calls + return text_content, citations, thinking_blocks, reasoning_content, tool_calls, web_search_results def calculate_usage( self, @@ -1288,6 +1295,7 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): thinking_blocks, reasoning_content, tool_calls, + web_search_results, ) = self.extract_response_content(completion_response=completion_response) if ( @@ -1307,6 +1315,8 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): } if context_management is not None: provider_specific_fields["context_management"] = context_management + if web_search_results is not None: + provider_specific_fields["web_search_results"] = web_search_results _message = litellm.Message( tool_calls=tool_calls, diff --git a/tests/llm_translation/test_prompt_factory.py b/tests/llm_translation/test_prompt_factory.py index abd8ae52157..d0c0c12ba7c 100644 --- a/tests/llm_translation/test_prompt_factory.py +++ b/tests/llm_translation/test_prompt_factory.py @@ -18,6 +18,7 @@ from litellm.litellm_core_utils.prompt_templates.factory import ( anthropic_pt, claude_2_1_pt, convert_to_anthropic_image_obj, + convert_to_anthropic_tool_invoke, convert_url_to_base64, create_anthropic_image_param, llama_2_chat_pt, @@ -947,3 +948,217 @@ def test_ollama_pt(): ] prompt = ollama_pt(model="ollama/llama3.1", messages=messages) print(prompt) + + +# ============ Server Tool Use Reconstruction Tests ============ +# Fixes: https://github.com/BerriAI/litellm/issues/17737 + + +def test_convert_to_anthropic_tool_invoke_regular_tool(): + """Test that regular tool_use is converted correctly.""" + tool_calls = [ + { + "id": "toolu_01ABC123", + "type": "function", + "function": { + "name": "get_weather", + "arguments": '{"location": "San Francisco"}' + } + } + ] + + result = convert_to_anthropic_tool_invoke(tool_calls) + + assert len(result) == 1 + assert result[0]["type"] == "tool_use" + assert result[0]["id"] == "toolu_01ABC123" + assert result[0]["name"] == "get_weather" + assert result[0]["input"] == {"location": "San Francisco"} + + +def test_convert_to_anthropic_tool_invoke_server_tool(): + """ + Test that server_tool_use (srvtoolu_) is reconstructed as server_tool_use. + + Fixes: https://github.com/BerriAI/litellm/issues/17737 + """ + tool_calls = [ + { + "id": "srvtoolu_01ABC123", + "type": "function", + "function": { + "name": "web_search", + "arguments": '{"query": "elephant weight"}' + } + } + ] + + result = convert_to_anthropic_tool_invoke(tool_calls) + + assert len(result) == 1 + assert result[0]["type"] == "server_tool_use" # NOT tool_use + assert result[0]["id"] == "srvtoolu_01ABC123" + assert result[0]["name"] == "web_search" + assert result[0]["input"] == {"query": "elephant weight"} + + +def test_convert_to_anthropic_tool_invoke_with_web_search_results(): + """ + Test that web_search_tool_result is included after server_tool_use. + + Fixes: https://github.com/BerriAI/litellm/issues/17737 + """ + tool_calls = [ + { + "id": "srvtoolu_01ABC123", + "type": "function", + "function": { + "name": "web_search", + "arguments": '{"query": "elephant weight"}' + } + } + ] + + web_search_results = [ + { + "type": "web_search_tool_result", + "tool_use_id": "srvtoolu_01ABC123", + "content": [ + { + "type": "web_search_result", + "url": "https://example.com", + "title": "Elephant Facts", + "snippet": "Elephants weigh 5000 kg" + } + ] + } + ] + + result = convert_to_anthropic_tool_invoke(tool_calls, web_search_results=web_search_results) + + assert len(result) == 2 + # First: server_tool_use + assert result[0]["type"] == "server_tool_use" + assert result[0]["id"] == "srvtoolu_01ABC123" + # Second: web_search_tool_result + assert result[1]["type"] == "web_search_tool_result" + assert result[1]["tool_use_id"] == "srvtoolu_01ABC123" + + +def test_convert_to_anthropic_tool_invoke_mixed_tools(): + """ + Test that mixed server and regular tools are reconstructed correctly. + + Fixes: https://github.com/BerriAI/litellm/issues/17737 + """ + tool_calls = [ + { + "id": "srvtoolu_01ABC123", + "type": "function", + "function": { + "name": "web_search", + "arguments": '{"query": "elephant weight"}' + } + }, + { + "id": "toolu_01XYZ789", + "type": "function", + "function": { + "name": "add_numbers", + "arguments": '{"a": 5000, "b": 100}' + } + } + ] + + web_search_results = [ + { + "type": "web_search_tool_result", + "tool_use_id": "srvtoolu_01ABC123", + "content": [{"url": "https://example.com", "title": "Test"}] + } + ] + + result = convert_to_anthropic_tool_invoke(tool_calls, web_search_results=web_search_results) + + assert len(result) == 3 + # First: server_tool_use + assert result[0]["type"] == "server_tool_use" + assert result[0]["id"] == "srvtoolu_01ABC123" + # Second: web_search_tool_result + assert result[1]["type"] == "web_search_tool_result" + # Third: regular tool_use + assert result[2]["type"] == "tool_use" + assert result[2]["id"] == "toolu_01XYZ789" + + +def test_anthropic_messages_pt_with_server_tool_use(): + """ + Test that anthropic_messages_pt correctly reconstructs server_tool_use from provider_specific_fields. + + Fixes: https://github.com/BerriAI/litellm/issues/17737 + """ + messages = [ + {"role": "user", "content": "Search for elephant weight and add 100"}, + { + "role": "assistant", + "content": "Let me search for that.", + "tool_calls": [ + { + "id": "srvtoolu_01ABC123", + "type": "function", + "function": { + "name": "web_search", + "arguments": '{"query": "elephant weight"}' + } + }, + { + "id": "toolu_01XYZ789", + "type": "function", + "function": { + "name": "add_numbers", + "arguments": '{"a": 5000, "b": 100}' + } + } + ], + "provider_specific_fields": { + "web_search_results": [ + { + "type": "web_search_tool_result", + "tool_use_id": "srvtoolu_01ABC123", + "content": [{"url": "https://example.com", "title": "Test", "snippet": "5000 kg"}] + } + ] + } + }, + { + "role": "tool", + "tool_call_id": "toolu_01XYZ789", + "content": "5100" + } + ] + + result = anthropic_messages_pt(messages, model="claude-sonnet-4-5", llm_provider="anthropic") + + # Find the assistant message + assistant_msg = next(m for m in result if m["role"] == "assistant") + content = assistant_msg["content"] + + # Should have: text, server_tool_use, web_search_tool_result, tool_use + types = [c.get("type") for c in content] + assert "text" in types + assert "server_tool_use" in types + assert "web_search_tool_result" in types + assert "tool_use" in types + + # Verify server_tool_use + server_tool = next(c for c in content if c.get("type") == "server_tool_use") + assert server_tool["id"] == "srvtoolu_01ABC123" + + # Verify web_search_tool_result comes after server_tool_use + server_idx = types.index("server_tool_use") + web_result_idx = types.index("web_search_tool_result") + assert web_result_idx == server_idx + 1 + + # Verify regular tool_use + tool_use = next(c for c in content if c.get("type") == "tool_use") + assert tool_use["id"] == "toolu_01XYZ789" diff --git a/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py b/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py index 6e05ca564fc..436afd2e891 100644 --- a/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py +++ b/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py @@ -185,7 +185,7 @@ def test_extract_response_content_with_citations(): }, } - _, citations, _, _, _ = config.extract_response_content(completion_response) + _, citations, _, _, _, _ = config.extract_response_content(completion_response) assert citations == [ [ { @@ -286,6 +286,204 @@ def test_web_search_tool_transformation_with_search_context_size( assert anthropic_web_search_tool["max_uses"] == expected_max_uses +def test_web_search_tool_result_extraction(): + """ + Test that web_search_tool_result blocks are correctly extracted and preserved. + + Fixes: https://github.com/BerriAI/litellm/issues/17737 + - web_search_tool_result was being dropped entirely from the response + - This caused multi-turn conversations to fail because the web search results + were not available for reconstruction + """ + config = AnthropicConfig() + + # Simulating actual Anthropic API response with web search + completion_response = { + "id": "msg_web_search_test", + "type": "message", + "role": "assistant", + "content": [ + { + "type": "server_tool_use", + "id": "srvtoolu_01ABC123", + "name": "web_search", + "input": {"query": "average weight african elephant kg"} + }, + { + "type": "web_search_tool_result", + "tool_use_id": "srvtoolu_01ABC123", + "content": [ + { + "type": "web_search_result", + "url": "https://example.com/elephants", + "title": "African Elephant Facts", + "encrypted_content": "encrypted_data_here", + "page_age": "2024-01-15", + "snippet": "Adult African elephants weigh between 4,000-6,000 kg..." + } + ] + }, + { + "type": "text", + "text": "Based on my search, African elephants weigh around 5,000 kg." + }, + { + "type": "tool_use", + "id": "toolu_01XYZ789", + "name": "add_numbers", + "input": {"a": 5000, "b": 100} + } + ], + "stop_reason": "tool_use", + "usage": { + "input_tokens": 100, + "output_tokens": 50, + "server_tool_use": {"web_search_requests": 1} + } + } + + text, citations, thinking_blocks, reasoning_content, tool_calls, web_search_results = config.extract_response_content( + completion_response + ) + + # Verify text extraction + assert "Based on my search" in text + assert "5,000 kg" in text + + # Verify tool calls (should have both server_tool_use and tool_use) + assert len(tool_calls) == 2 + assert tool_calls[0]["id"] == "srvtoolu_01ABC123" + assert tool_calls[0]["function"]["name"] == "web_search" + assert tool_calls[1]["id"] == "toolu_01XYZ789" + assert tool_calls[1]["function"]["name"] == "add_numbers" + + # Verify web_search_results is extracted (THIS WAS THE BUG - it was None before the fix) + assert web_search_results is not None + assert len(web_search_results) == 1 + assert web_search_results[0]["type"] == "web_search_tool_result" + assert web_search_results[0]["tool_use_id"] == "srvtoolu_01ABC123" + assert len(web_search_results[0]["content"]) == 1 + assert web_search_results[0]["content"][0]["url"] == "https://example.com/elephants" + assert web_search_results[0]["content"][0]["title"] == "African Elephant Facts" + + +def test_web_search_tool_result_in_provider_specific_fields(): + """ + Test that web_search_results is included in provider_specific_fields. + + This ensures users can access the web search results via: + response.choices[0].message.provider_specific_fields["web_search_results"] + """ + import httpx + from litellm.types.utils import ModelResponse + + config = AnthropicConfig() + + completion_response = { + "id": "msg_web_search_provider_fields", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5-20250929", + "content": [ + { + "type": "server_tool_use", + "id": "srvtoolu_provider_test", + "name": "web_search", + "input": {"query": "test query"} + }, + { + "type": "web_search_tool_result", + "tool_use_id": "srvtoolu_provider_test", + "content": [ + { + "type": "web_search_result", + "url": "https://example.com/test", + "title": "Test Result", + "snippet": "Test snippet content" + } + ] + }, + { + "type": "text", + "text": "Here is the result." + } + ], + "stop_reason": "end_turn", + "usage": { + "input_tokens": 50, + "output_tokens": 25, + "server_tool_use": {"web_search_requests": 1} + } + } + + raw_response = httpx.Response(status_code=200, headers={}) + model_response = ModelResponse() + + result = config.transform_parsed_response( + completion_response=completion_response, + raw_response=raw_response, + model_response=model_response, + json_mode=False, + prefix_prompt=None, + ) + + # Verify web_search_results is in provider_specific_fields + provider_fields = result.choices[0].message.provider_specific_fields + assert provider_fields is not None + assert "web_search_results" in provider_fields + assert len(provider_fields["web_search_results"]) == 1 + assert provider_fields["web_search_results"][0]["type"] == "web_search_tool_result" + assert provider_fields["web_search_results"][0]["tool_use_id"] == "srvtoolu_provider_test" + + +def test_multiple_web_search_tool_results(): + """ + Test that multiple web_search_tool_result blocks are all extracted. + """ + config = AnthropicConfig() + + completion_response = { + "content": [ + { + "type": "server_tool_use", + "id": "srvtoolu_search1", + "name": "web_search", + "input": {"query": "african elephant weight"} + }, + { + "type": "web_search_tool_result", + "tool_use_id": "srvtoolu_search1", + "content": [{"type": "web_search_result", "url": "https://example1.com", "title": "Result 1", "snippet": "First result"}] + }, + { + "type": "server_tool_use", + "id": "srvtoolu_search2", + "name": "web_search", + "input": {"query": "asian elephant weight"} + }, + { + "type": "web_search_tool_result", + "tool_use_id": "srvtoolu_search2", + "content": [{"type": "web_search_result", "url": "https://example2.com", "title": "Result 2", "snippet": "Second result"}] + }, + { + "type": "text", + "text": "Found information about both elephants." + } + ] + } + + text, citations, thinking_blocks, reasoning_content, tool_calls, web_search_results = config.extract_response_content( + completion_response + ) + + # Verify both web_search_tool_results are extracted + assert web_search_results is not None + assert len(web_search_results) == 2 + assert web_search_results[0]["tool_use_id"] == "srvtoolu_search1" + assert web_search_results[1]["tool_use_id"] == "srvtoolu_search2" + + def test_add_code_execution_tool(): config = AnthropicConfig() @@ -693,13 +891,14 @@ def test_server_tool_use_in_response(): ] } - text, citations, thinking_blocks, reasoning_content, tool_calls = config.extract_response_content( + text, citations, thinking_blocks, reasoning_content, tool_calls, web_search_results = config.extract_response_content( completion_response ) - + assert len(tool_calls) == 1 assert tool_calls[0]["id"] == "srvtoolu_01ABC123" assert tool_calls[0]["function"]["name"] == "tool_search_tool_regex" + assert web_search_results is None def test_tool_search_usage_tracking(): @@ -820,18 +1019,21 @@ def test_tool_search_complete_response_parsing(): } # Extract content - text, citations, thinking_blocks, reasoning_content, tool_calls = config.extract_response_content( + text, citations, thinking_blocks, reasoning_content, tool_calls, web_search_results = config.extract_response_content( completion_response ) - + # Verify text extraction (should concatenate both text blocks) assert "I'll search for weather-related tools" in text assert "Great! I found a weather tool" in text - + # Verify tool calls (should have both server_tool_use and tool_use) assert len(tool_calls) == 2 assert tool_calls[0]["function"]["name"] == "tool_search_tool_regex" assert tool_calls[1]["function"]["name"] == "get_weather" + + # Verify web_search_results is None (this response has tool_search, not web_search) + assert web_search_results is None # Verify usage calculation counts tool_search_requests from content usage = config.calculate_usage( @@ -937,14 +1139,15 @@ def test_caller_field_in_response(): "usage": {"input_tokens": 100, "output_tokens": 50} } - text, citations, thinking, reasoning, tool_calls = config.extract_response_content(completion_response) - + text, citations, thinking, reasoning, tool_calls, web_search_results = config.extract_response_content(completion_response) + assert len(tool_calls) == 1 assert tool_calls[0]["id"] == "toolu_123" assert tool_calls[0]["function"]["name"] == "query_database" assert "caller" in tool_calls[0] assert tool_calls[0]["caller"]["type"] == "code_execution_20250825" assert tool_calls[0]["caller"]["tool_id"] == "srvtoolu_abc" + assert web_search_results is None def test_code_execution_20250825_tool_type():