fix(websearch): sync tool_choice when converting web_search tools (#31375)

failing test is not related to the pr

* fix(websearch): sync tool_choice when converting web_search tools

Claude Code forces native web search via tool_choice pointing at web_search
while websearch_interception renames the tool to litellm_web_search, causing
Anthropic 400s. Forward tool_choice into pre-request hooks and rewrite forced
tool_choice to match the converted tool name.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(websearch): re-wrap agentic loop responses as SSE for streaming clients

When websearch interception converts stream=true to false for the agentic
loop, dict responses from the loop were returned as application/json even
though the client requested SSE. Wrap those responses in
FakeAnthropicMessagesStreamIterator so /v1/messages streaming callers
(e.g. Claude Code) receive text/event-stream after search completes.

Fixes #27721

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(websearch): cover tool_choice sync and post-loop SSE wrap; fix UP006

Add regression tests for both websearch interception fixes: _sync_forced_tool_choice
repointing a forced web_search tool_choice to litellm_web_search (the 400 fix) and
_maybe_websearch_fake_stream_wrap re-wrapping agentic loop dict responses as SSE for
streaming clients (#27721). Switch the new helper annotations to builtin dict/list so
the ruff UP006 strict-rule ceiling stays within budget.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(websearch): resolve merge conflict and unify fake stream wrapping

Remove the duplicate _maybe_websearch_fake_stream_wrap helper left by a bad merge that caused a SyntaxError in CI, and route all call sites through _maybe_wrap_in_fake_stream instead.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Shivam Rawat <shivamrawat@Shivams-MacBook-Pro.local>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Shivam Rawat <shivamrawat@Shivams-MBP.localdomain>
This commit is contained in:
Shivam Rawat 2026-06-26 19:44:57 -07:00 committed by GitHub
parent 99b1a323c1
commit de82f78e5b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 143 additions and 2 deletions

View file

@ -345,6 +345,34 @@ class WebSearchInterceptionLogger(CustomLogger):
search_tool_name=search_tool_name,
)
@staticmethod
def _tool_name(tool: dict[str, Any]) -> Optional[str]:
"""Effective tool name, handling OpenAI ``function`` wrapper shape."""
fn = tool.get("function")
if tool.get("type") == "function" and isinstance(fn, dict):
return fn.get("name")
return tool.get("name")
@classmethod
def _sync_forced_tool_choice(
cls, tool_choice: Any, converted_tools: list[dict[str, Any]]
) -> Any:
"""Repoint a forced ``tool_choice`` at ``litellm_web_search`` when it
names a web-search tool that was just converted away.
Native clients (e.g. Claude Code) force the search tool via
``tool_choice={"type": "tool", "name": "web_search"}``. Since the tool
definition gets renamed to ``litellm_web_search``, an unrewritten
``tool_choice`` points at a tool that no longer exists, which Anthropic
rejects with "Tool 'web_search' not found in provided tools".
"""
if not isinstance(tool_choice, dict) or tool_choice.get("type") != "tool":
return tool_choice
converted_names = {cls._tool_name(t) for t in converted_tools}
if tool_choice.get("name") in converted_names:
return tool_choice
return {**tool_choice, "name": LITELLM_WEB_SEARCH_TOOL_NAME}
async def async_pre_request_hook(
self, model: str, messages: List[Dict], kwargs: Dict
) -> Optional[Dict]:
@ -422,6 +450,11 @@ class WebSearchInterceptionLogger(CustomLogger):
f"WebSearchInterception: Tools after conversion: {[t.get('name') for t in converted_tools]}"
)
if "tool_choice" in kwargs:
kwargs["tool_choice"] = self._sync_forced_tool_choice(
kwargs.get("tool_choice"), converted_tools
)
# Also convert here for direct callers that bypass the deployment hook.
if kwargs.get("stream"):
verbose_logger.debug(

View file

@ -223,13 +223,17 @@ async def anthropic_messages(
"_websearch_interception_converted_stream", False
)
# Execute pre-request hooks to allow CustomLoggers to modify request
# Execute pre-request hooks to allow CustomLoggers to modify request.
# tool_choice is forwarded explicitly (it is a named param, not in kwargs)
# so hooks that rename tools — e.g. websearch_interception converting
# web_search -> litellm_web_search — can keep a forced tool_choice in sync.
request_kwargs = await _execute_pre_request_hooks(
model=model,
messages=messages,
tools=tools,
stream=stream,
custom_llm_provider=custom_llm_provider,
tool_choice=tool_choice,
**kwargs,
)

View file

@ -2233,7 +2233,11 @@ class BaseLLMHTTPHandler:
kwargs=kwargs,
)
return final_response if final_response is not None else initial_response
return self._maybe_wrap_in_fake_stream(
final_response if final_response is not None else initial_response,
logging_obj,
"anthropic_messages",
)
def anthropic_messages_handler(
self,

View file

@ -8,6 +8,7 @@ from unittest.mock import AsyncMock, MagicMock, Mock
import pytest
from litellm.constants import LITELLM_WEB_SEARCH_TOOL_NAME
from litellm.integrations.websearch_interception.handler import (
WebSearchInterceptionLogger,
)
@ -409,3 +410,102 @@ async def test_deployment_hook_converts_stream_and_logging_obj_syncs():
logging_obj.stream = _hook_stream
assert logging_obj.stream is False
def test_sync_forced_tool_choice_repoints_converted_web_search():
"""Regression (tool_choice 400): a forced tool_choice naming the original
web_search tool must be repointed to litellm_web_search after conversion.
Native clients (e.g. Claude Code) send
tool_choice={"type": "tool", "name": "web_search"}. The tool definition is
renamed to litellm_web_search, so an unrewritten tool_choice points at a
tool that no longer exists and Anthropic rejects with
"Tool 'web_search' not found in provided tools".
"""
converted_tools = [
{
"type": "function",
"function": {"name": LITELLM_WEB_SEARCH_TOOL_NAME, "parameters": {}},
}
]
result = WebSearchInterceptionLogger._sync_forced_tool_choice(
{"type": "tool", "name": "web_search"}, converted_tools
)
assert result == {"type": "tool", "name": LITELLM_WEB_SEARCH_TOOL_NAME}
def test_sync_forced_tool_choice_leaves_existing_tool_untouched():
"""A native Anthropic tool_choice already naming a tool on the converted
list (top-level name, no function wrapper) must not be rewritten."""
converted_tools = [{"name": LITELLM_WEB_SEARCH_TOOL_NAME}]
result = WebSearchInterceptionLogger._sync_forced_tool_choice(
{"type": "tool", "name": LITELLM_WEB_SEARCH_TOOL_NAME}, converted_tools
)
assert result == {"type": "tool", "name": LITELLM_WEB_SEARCH_TOOL_NAME}
def test_sync_forced_tool_choice_preserves_extra_tool_choice_fields():
"""Repointing must keep other tool_choice keys intact."""
converted_tools = [
{
"type": "function",
"function": {"name": LITELLM_WEB_SEARCH_TOOL_NAME, "parameters": {}},
}
]
result = WebSearchInterceptionLogger._sync_forced_tool_choice(
{"type": "tool", "name": "web_search", "disable_parallel_tool_use": True},
converted_tools,
)
assert result == {
"type": "tool",
"name": LITELLM_WEB_SEARCH_TOOL_NAME,
"disable_parallel_tool_use": True,
}
@pytest.mark.parametrize(
"tool_choice",
["auto", {"type": "auto"}, {"type": "any"}, None],
)
def test_sync_forced_tool_choice_leaves_non_forced_untouched(tool_choice):
"""Only a forced {"type": "tool", ...} choice is rewritten; auto/any/string
and None pass through unchanged."""
converted_tools = [{"name": LITELLM_WEB_SEARCH_TOOL_NAME}]
result = WebSearchInterceptionLogger._sync_forced_tool_choice(
tool_choice, converted_tools
)
assert result == tool_choice
@pytest.mark.asyncio
async def test_pre_request_hook_syncs_forced_tool_choice():
"""End-to-end: async_pre_request_hook converts web_search and repoints the
forced tool_choice in the same pass, so the outgoing request is consistent.
"""
logger = WebSearchInterceptionLogger(enabled_providers=["anthropic"])
kwargs = {
"litellm_params": {"custom_llm_provider": "anthropic"},
"tools": [{"type": "web_search_20250305", "name": "web_search"}],
"tool_choice": {"type": "tool", "name": "web_search"},
}
result = await logger.async_pre_request_hook(
model="claude-sonnet-4-5",
messages=[{"role": "user", "content": "search the web"}],
kwargs=kwargs,
)
assert result is not None
assert result["tool_choice"] == {
"type": "tool",
"name": LITELLM_WEB_SEARCH_TOOL_NAME,
}