diff --git a/litellm/integrations/websearch_interception/handler.py b/litellm/integrations/websearch_interception/handler.py index f29b378fcde..ab4c787c96f 100644 --- a/litellm/integrations/websearch_interception/handler.py +++ b/litellm/integrations/websearch_interception/handler.py @@ -345,6 +345,34 @@ def from_config_yaml( 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 @@ async def async_pre_request_hook( 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( diff --git a/litellm/llms/anthropic/experimental_pass_through/messages/handler.py b/litellm/llms/anthropic/experimental_pass_through/messages/handler.py index cb61c196fd8..c06241f0e4f 100644 --- a/litellm/llms/anthropic/experimental_pass_through/messages/handler.py +++ b/litellm/llms/anthropic/experimental_pass_through/messages/handler.py @@ -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, ) diff --git a/litellm/llms/custom_httpx/llm_http_handler.py b/litellm/llms/custom_httpx/llm_http_handler.py index 8ab12bf3f7a..056d3a4b4d5 100644 --- a/litellm/llms/custom_httpx/llm_http_handler.py +++ b/litellm/llms/custom_httpx/llm_http_handler.py @@ -2233,7 +2233,11 @@ async def async_anthropic_messages_handler( 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, diff --git a/tests/test_litellm/integrations/websearch_interception/test_websearch_interception_handler.py b/tests/test_litellm/integrations/websearch_interception/test_websearch_interception_handler.py index c2a502b34eb..ea117380edf 100644 --- a/tests/test_litellm/integrations/websearch_interception/test_websearch_interception_handler.py +++ b/tests/test_litellm/integrations/websearch_interception/test_websearch_interception_handler.py @@ -8,6 +8,7 @@ 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, + }