mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-14 04:09:09 +00:00
fix(websearch): mint srvtoolu_ ids for native web_search_tool_result blocks
The agentic-loop path in WebSearch Interception built each web_search_tool_result block by reusing the client's original tool_call id (a toolu_ id) with no matching server_tool_use block. Single-turn requests pass because the assistant turn is not re-validated, but on multi-turn requests the client replays that turn and Anthropic (and Bedrock, which inherits the Anthropic Messages shape) rejects it with a 400 because the tool_use_id does not match ^srvtoolu_ Mint a srvtoolu_ id per search call and emit a paired server_tool_use and web_search_tool_result, matching the short-circuit path that was already correct. The server_tool_use name is set to web_search, the native Anthropic server-tool name, rather than the litellm_web_search name the model sees after pre-request conversion. Fixes #31569
This commit is contained in:
parent
8e30cfbeb1
commit
4c541d16cd
2 changed files with 157 additions and 51 deletions
|
|
@ -683,23 +683,57 @@ class WebSearchInterceptionLogger(CustomLogger):
|
|||
return response
|
||||
return self._inject_native_blocks(response, native_blocks)
|
||||
|
||||
@staticmethod
|
||||
def _build_server_tool_use_pair(
|
||||
tool_call: dict,
|
||||
structured: Optional[SearchResponse],
|
||||
) -> tuple[dict, dict]:
|
||||
"""Mint a ``srvtoolu_`` id and return the paired ``server_tool_use`` +
|
||||
``web_search_tool_result`` blocks for one search call.
|
||||
|
||||
``name`` is always ``web_search`` (the Anthropic native server-tool
|
||||
name); the intercepted tool_call carries ``litellm_web_search`` after
|
||||
pre-request conversion, which is not a valid native tool name.
|
||||
"""
|
||||
tool_use_id = f"srvtoolu_{uuid.uuid4().hex}"
|
||||
query = (tool_call.get("input") or {}).get("query")
|
||||
server_tool_use = {
|
||||
"type": "server_tool_use",
|
||||
"id": tool_use_id,
|
||||
"name": "web_search",
|
||||
"input": {"query": query} if query is not None else {},
|
||||
}
|
||||
return (
|
||||
server_tool_use,
|
||||
WebSearchTransformation.build_web_search_tool_result_block(
|
||||
tool_use_id=tool_use_id,
|
||||
search_response=structured,
|
||||
),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _build_native_result_blocks(
|
||||
tool_calls: List[Dict],
|
||||
structured_results: List[Optional[SearchResponse]],
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Build one ``web_search_tool_result`` block per tool_call."""
|
||||
blocks: List[Dict[str, Any]] = []
|
||||
for i, tool_call in enumerate(tool_calls):
|
||||
tool_use_id = tool_call.get("id") or ""
|
||||
structured = structured_results[i] if i < len(structured_results) else None
|
||||
blocks.append(
|
||||
WebSearchTransformation.build_web_search_tool_result_block(
|
||||
tool_use_id=tool_use_id,
|
||||
search_response=structured,
|
||||
)
|
||||
"""Build paired ``server_tool_use`` + ``web_search_tool_result`` blocks
|
||||
for each tool_call.
|
||||
|
||||
The result block's ``tool_use_id`` must match ``^srvtoolu_`` and pair
|
||||
with a ``server_tool_use`` block in the same assistant turn. Reusing the
|
||||
client's original ``toolu_`` id leaves the turn without a matching
|
||||
``server_tool_use`` block, which Anthropic (and Bedrock, which inherits
|
||||
the Anthropic Messages shape) rejects with a 400 the moment the turn is
|
||||
replayed on a later request.
|
||||
"""
|
||||
return [
|
||||
block
|
||||
for i, tool_call in enumerate(tool_calls)
|
||||
for block in WebSearchInterceptionLogger._build_server_tool_use_pair(
|
||||
tool_call=tool_call,
|
||||
structured=(structured_results[i] if i < len(structured_results) else None),
|
||||
)
|
||||
return blocks
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _inject_native_blocks(response: Any, native_blocks: List[Dict[str, Any]]) -> Any:
|
||||
|
|
|
|||
|
|
@ -52,24 +52,16 @@ class TestIsAnthropicNativeWebSearchTool:
|
|||
"""The detector must match native tools without catching look-alikes."""
|
||||
|
||||
def test_matches_web_search_20250305(self):
|
||||
assert is_anthropic_native_web_search_tool(
|
||||
{"type": "web_search_20250305", "name": "web_search", "max_uses": 5}
|
||||
)
|
||||
assert is_anthropic_native_web_search_tool({"type": "web_search_20250305", "name": "web_search", "max_uses": 5})
|
||||
|
||||
def test_matches_future_dated_variant(self):
|
||||
assert is_anthropic_native_web_search_tool(
|
||||
{"type": "web_search_20260101", "name": "web_search"}
|
||||
)
|
||||
assert is_anthropic_native_web_search_tool({"type": "web_search_20260101", "name": "web_search"})
|
||||
|
||||
def test_rejects_litellm_standard(self):
|
||||
assert not is_anthropic_native_web_search_tool(
|
||||
{"name": "litellm_web_search", "input_schema": {}}
|
||||
)
|
||||
assert not is_anthropic_native_web_search_tool({"name": "litellm_web_search", "input_schema": {}})
|
||||
|
||||
def test_rejects_openai_function_shape(self):
|
||||
assert not is_anthropic_native_web_search_tool(
|
||||
{"type": "function", "function": {"name": "litellm_web_search"}}
|
||||
)
|
||||
assert not is_anthropic_native_web_search_tool({"type": "function", "function": {"name": "litellm_web_search"}})
|
||||
|
||||
def test_rejects_claude_desktop_builtin(self):
|
||||
# Claude Desktop's builtin client-side ``WebSearch`` tool must not be
|
||||
|
|
@ -77,9 +69,7 @@ class TestIsAnthropicNativeWebSearchTool:
|
|||
assert not is_anthropic_native_web_search_tool({"name": "WebSearch"})
|
||||
|
||||
def test_rejects_unrelated_tool(self):
|
||||
assert not is_anthropic_native_web_search_tool(
|
||||
{"type": "function", "function": {"name": "calculator"}}
|
||||
)
|
||||
assert not is_anthropic_native_web_search_tool({"type": "function", "function": {"name": "calculator"}})
|
||||
|
||||
def test_handles_missing_type(self):
|
||||
assert not is_anthropic_native_web_search_tool({"name": "web_search"})
|
||||
|
|
@ -159,14 +149,10 @@ class TestPreRequestHookFlagsNativeTools:
|
|||
async def test_native_tool_sets_flag(self):
|
||||
logger = WebSearchInterceptionLogger(enabled_providers=["bedrock"])
|
||||
kwargs = {
|
||||
"tools": [
|
||||
{"type": "web_search_20250305", "name": "web_search", "max_uses": 5}
|
||||
],
|
||||
"tools": [{"type": "web_search_20250305", "name": "web_search", "max_uses": 5}],
|
||||
"litellm_params": {"custom_llm_provider": "bedrock"},
|
||||
}
|
||||
out = await logger.async_pre_request_hook(
|
||||
model="bedrock/claude", messages=[], kwargs=kwargs
|
||||
)
|
||||
out = await logger.async_pre_request_hook(model="bedrock/claude", messages=[], kwargs=kwargs)
|
||||
assert out is not None
|
||||
assert out.get(WEBSEARCH_EMIT_NATIVE_BLOCKS_KEY) is True
|
||||
|
||||
|
|
@ -177,9 +163,7 @@ class TestPreRequestHookFlagsNativeTools:
|
|||
"tools": [{"name": "litellm_web_search", "input_schema": {}}],
|
||||
"litellm_params": {"custom_llm_provider": "bedrock"},
|
||||
}
|
||||
out = await logger.async_pre_request_hook(
|
||||
model="bedrock/claude", messages=[], kwargs=kwargs
|
||||
)
|
||||
out = await logger.async_pre_request_hook(model="bedrock/claude", messages=[], kwargs=kwargs)
|
||||
assert out is not None
|
||||
assert WEBSEARCH_EMIT_NATIVE_BLOCKS_KEY not in out
|
||||
|
||||
|
|
@ -224,10 +208,15 @@ class TestBuildPlanAttachesBlocks:
|
|||
|
||||
blocks = plan.metadata.get(WEBSEARCH_NATIVE_BLOCKS_METADATA_KEY)
|
||||
assert isinstance(blocks, list)
|
||||
assert len(blocks) == 1
|
||||
assert blocks[0]["type"] == "web_search_tool_result"
|
||||
assert blocks[0]["tool_use_id"] == "toolu_one"
|
||||
assert blocks[0]["content"][0]["url"] == "https://docs.litellm.ai/"
|
||||
assert len(blocks) == 2
|
||||
server_use, tool_result = blocks
|
||||
assert server_use["type"] == "server_tool_use"
|
||||
assert server_use["id"].startswith("srvtoolu_")
|
||||
assert server_use["input"] == {"query": "what is litellm"}
|
||||
assert tool_result["type"] == "web_search_tool_result"
|
||||
assert tool_result["tool_use_id"] == server_use["id"]
|
||||
assert tool_result["tool_use_id"] != "toolu_one"
|
||||
assert tool_result["content"][0]["url"] == "https://docs.litellm.ai/"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_metadata_does_not_carry_blocks_when_flag_absent(self):
|
||||
|
|
@ -288,9 +277,7 @@ class TestPostHookInjectsBlocks:
|
|||
"stop_reason": "end_turn",
|
||||
}
|
||||
|
||||
out = await logger.async_post_agentic_loop_response_hook(
|
||||
response=response, plan=plan, kwargs={}
|
||||
)
|
||||
out = await logger.async_post_agentic_loop_response_hook(response=response, plan=plan, kwargs={})
|
||||
|
||||
# Native block must be first so the client can pair it with the
|
||||
# tool_use before reading the assistant text.
|
||||
|
|
@ -306,9 +293,7 @@ class TestPostHookInjectsBlocks:
|
|||
"id": "msg_1",
|
||||
"content": [{"type": "text", "text": "answer"}],
|
||||
}
|
||||
out = await logger.async_post_agentic_loop_response_hook(
|
||||
response=response, plan=plan, kwargs={}
|
||||
)
|
||||
out = await logger.async_post_agentic_loop_response_hook(response=response, plan=plan, kwargs={})
|
||||
assert out == response
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -328,9 +313,7 @@ class TestPostHookInjectsBlocks:
|
|||
self.content = [{"type": "text", "text": "ok"}]
|
||||
|
||||
resp = _Resp()
|
||||
out = await logger.async_post_agentic_loop_response_hook(
|
||||
response=resp, plan=plan, kwargs={}
|
||||
)
|
||||
out = await logger.async_post_agentic_loop_response_hook(response=resp, plan=plan, kwargs={})
|
||||
assert out.content[0]["type"] == "web_search_tool_result"
|
||||
assert out.content[1]["type"] == "text"
|
||||
|
||||
|
|
@ -479,6 +462,95 @@ class TestLegacyPathMatchesNewPath:
|
|||
kwargs={WEBSEARCH_EMIT_NATIVE_BLOCKS_KEY: True},
|
||||
)
|
||||
|
||||
assert out["content"][0]["type"] == "web_search_tool_result"
|
||||
assert out["content"][0]["tool_use_id"] == "toolu_legacy"
|
||||
assert out["content"][1]["type"] == "text"
|
||||
assert out["content"][0]["type"] == "server_tool_use"
|
||||
assert out["content"][0]["id"].startswith("srvtoolu_")
|
||||
assert out["content"][1]["type"] == "web_search_tool_result"
|
||||
assert out["content"][1]["tool_use_id"] == out["content"][0]["id"]
|
||||
assert out["content"][1]["tool_use_id"] != "toolu_legacy"
|
||||
assert out["content"][2]["type"] == "text"
|
||||
|
||||
|
||||
class TestNativeResultBlockUsesServerToolId:
|
||||
"""Regression for the multi-turn 400 on Bedrock/Anthropic Claude.
|
||||
|
||||
The agentic-loop path must mint ``srvtoolu_`` ids and pair each
|
||||
``web_search_tool_result`` with a ``server_tool_use`` block, rather than
|
||||
reusing the client's original ``toolu_`` tool_call id. The reused id has no
|
||||
matching ``server_tool_use`` block and fails ``^srvtoolu_`` validation when
|
||||
the assistant turn is replayed on the next request."""
|
||||
|
||||
def test_blocks_never_reuse_client_toolu_id(self):
|
||||
tool_calls = [
|
||||
{
|
||||
"id": "toolu_client_original",
|
||||
"type": "tool_use",
|
||||
"name": "web_search",
|
||||
"input": {"query": "weather in tokyo"},
|
||||
}
|
||||
]
|
||||
blocks = WebSearchInterceptionLogger._build_native_result_blocks(
|
||||
tool_calls=tool_calls,
|
||||
structured_results=[_make_search_response()],
|
||||
)
|
||||
|
||||
assert [b["type"] for b in blocks] == [
|
||||
"server_tool_use",
|
||||
"web_search_tool_result",
|
||||
]
|
||||
server_use, tool_result = blocks
|
||||
assert server_use["id"].startswith("srvtoolu_")
|
||||
assert server_use["name"] == "web_search"
|
||||
assert server_use["input"] == {"query": "weather in tokyo"}
|
||||
assert tool_result["tool_use_id"] == server_use["id"]
|
||||
assert tool_result["tool_use_id"].startswith("srvtoolu_")
|
||||
assert "toolu_client_original" not in (
|
||||
server_use["id"],
|
||||
tool_result["tool_use_id"],
|
||||
)
|
||||
assert tool_result["content"][0]["url"] == "https://docs.litellm.ai/"
|
||||
|
||||
def test_multiple_tool_calls_get_distinct_server_ids(self):
|
||||
tool_calls = [
|
||||
{"id": "toolu_a", "name": "web_search", "input": {"query": "a"}},
|
||||
{"id": "toolu_b", "name": "web_search", "input": {"query": "b"}},
|
||||
]
|
||||
blocks = WebSearchInterceptionLogger._build_native_result_blocks(
|
||||
tool_calls=tool_calls,
|
||||
structured_results=[_make_search_response(), None],
|
||||
)
|
||||
|
||||
assert [b["type"] for b in blocks] == [
|
||||
"server_tool_use",
|
||||
"web_search_tool_result",
|
||||
"server_tool_use",
|
||||
"web_search_tool_result",
|
||||
]
|
||||
first_use, first_result, second_use, second_result = blocks
|
||||
assert first_use["id"] != second_use["id"]
|
||||
assert first_result["tool_use_id"] == first_use["id"]
|
||||
assert second_result["tool_use_id"] == second_use["id"]
|
||||
assert all(b["id"].startswith("srvtoolu_") for b in (first_use, second_use))
|
||||
|
||||
def test_missing_query_emits_empty_input(self):
|
||||
tool_calls = [{"id": "toolu_x", "name": "web_search", "input": {}}]
|
||||
blocks = WebSearchInterceptionLogger._build_native_result_blocks(
|
||||
tool_calls=tool_calls,
|
||||
structured_results=[None],
|
||||
)
|
||||
|
||||
server_use, tool_result = blocks
|
||||
assert server_use["input"] == {}
|
||||
assert server_use["id"].startswith("srvtoolu_")
|
||||
assert tool_result["tool_use_id"] == server_use["id"]
|
||||
|
||||
def test_server_tool_use_name_is_native_not_intercepted(self):
|
||||
# After pre-request conversion the model's tool_call carries the
|
||||
# litellm_web_search name; the native block must report web_search.
|
||||
tool_calls = [{"id": "toolu_x", "name": "litellm_web_search", "input": {"query": "q"}}]
|
||||
blocks = WebSearchInterceptionLogger._build_native_result_blocks(
|
||||
tool_calls=tool_calls,
|
||||
structured_results=[None],
|
||||
)
|
||||
|
||||
server_use = blocks[0]
|
||||
assert server_use["name"] == "web_search"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue