Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions litellm/integrations/websearch_interception/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Comment thread
shivamrawat1 marked this conversation as resolved.

async def async_pre_request_hook(
self, model: str, messages: List[Dict], kwargs: Dict
) -> Optional[Dict]:
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Comment thread
greptile-apps[bot] marked this conversation as resolved.

Expand Down
6 changes: 5 additions & 1 deletion litellm/llms/custom_httpx/llm_http_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import pytest

from litellm.constants import LITELLM_WEB_SEARCH_TOOL_NAME
from litellm.integrations.websearch_interception.handler import (
WebSearchInterceptionLogger,
)
Expand Down Expand Up @@ -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