mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
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:
parent
99b1a323c1
commit
de82f78e5b
4 changed files with 143 additions and 2 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue