fix(anthropic): preserve server_tool_use and web_search_tool_result in multi-turn conversations (#17746)

- Extract web_search_tool_result blocks in extract_response_content()
- Store web_search_results in provider_specific_fields for round-trip
- Detect srvtoolu_ prefix to reconstruct as server_tool_use (not tool_use)
- Add corresponding web_search_tool_result after server_tool_use blocks

This ensures multi-turn conversations with Anthropic web search + custom
tools work correctly without Anthropic expecting tool_result for server-
side tool executions.
This commit is contained in:
Cesar Garcia 2025-12-09 23:25:23 -03:00 • committed by GitHub
parent 9fa6c51678
commit 01dec55c2f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 499 additions and 33 deletions

View file

@ -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")

View file

@ -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,

View file

@ -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"

View file

@ -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():