diff --git a/litellm/llms/anthropic/chat/transformation.py b/litellm/llms/anthropic/chat/transformation.py index e6eda300a3b..6212939ba00 100644 --- a/litellm/llms/anthropic/chat/transformation.py +++ b/litellm/llms/anthropic/chat/transformation.py @@ -138,6 +138,31 @@ else: _ANTHROPIC_TOOL_NAME_INVALID_CHARS: Final = re.compile(r"[^a-zA-Z0-9_-]") _ANTHROPIC_TOOL_NAME_MAX_LEN: Final = 128 _DEFER_LOADING_ADAPTER: Final = TypeAdapter(bool) +_TOOL_FIELDS_ADAPTER: Final = TypeAdapter(Mapping[str, object]) +_TOOLS_ADAPTER: Final = TypeAdapter(list[object]) +_DEFERRAL_CARRYING_TOOL_KEYS: Final = frozenset({"function", "tools"}) + + +def _is_openai_tool_search_tool(tool: object) -> bool: + match tool: + case {"type": "tool_search"}: + return True + case _: + return False + + +def _without_deferral(tool: object) -> object: + match tool: + case Mapping(): + return { + key: _without_deferral(value) if key in _DEFERRAL_CARRYING_TOOL_KEYS else value + for key, value in _TOOL_FIELDS_ADAPTER.validate_python(tool).items() + if key != "defer_loading" + } + case list(): + return [_without_deferral(member) for member in _TOOLS_ADAPTER.validate_python(tool)] + case _: + return tool def _validated_defer_loading(value: object) -> bool: @@ -296,6 +321,11 @@ DROP_UNSUPPORTED_SPEED_WARNING: Final = ( "Dropping unsupported `speed` for model=%s (drop_params=True). Fast mode is only supported on select Opus models." ) +DROP_UNSUPPORTED_TOOL_SEARCH_WARNING: Final = ( + "Dropping the OpenAI `tool_search` tool and the `defer_loading` of the other tools for model=%s on %s " + "(drop_params=True): this route cannot run tool search." +) + class AnthropicConfig(AnthropicModelInfo, BaseConfig): """ @@ -1000,6 +1030,32 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): initial_tool["authorization_token"] = authorization_token return initial_tool + def _tools_with_openai_tool_search_mapped( + self, tools: Sequence[object], model: str, drop_params: bool + ) -> Sequence[object]: + from litellm.responses.litellm_completion_transformation.tool_search import ( + anthropic_tool_search_chat_tool, + is_server_tool_search_tool, + ) + + search_tools: Final = tuple(tool for tool in tools if _is_openai_tool_search_tool(tool)) + if not search_tools: + return tools + if all(is_server_tool_search_tool(tool) for tool in search_tools) and litellm.supports_tool_search( + model, self._resolved_provider + ): + return [anthropic_tool_search_chat_tool() if _is_openai_tool_search_tool(tool) else tool for tool in tools] + if not (litellm.drop_params or drop_params): + raise litellm.utils.UnsupportedParamsError( + message=( + f"{model} on {self._resolved_provider} cannot run the OpenAI `tool_search` tool. To drop it " + "and the `defer_loading` of the other tools, set `litellm.drop_params = True`." + ), + status_code=400, + ) + litellm.verbose_logger.warning(DROP_UNSUPPORTED_TOOL_SEARCH_WARNING, model, self._resolved_provider) + return [_without_deferral(tool) for tool in tools if not _is_openai_tool_search_tool(tool)] + def _map_tools( self, tools: list, @@ -1568,7 +1624,9 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): if param == "max_tokens" or param == "max_completion_tokens": optional_params["max_tokens"] = value if isinstance(value, int) else max(1, int(round(value))) elif param == "tools": - anthropic_tools, mcp_servers = self._map_tools(value) + anthropic_tools, mcp_servers = self._map_tools( + list(self._tools_with_openai_tool_search_mapped(tools=value, model=model, drop_params=drop_params)) + ) optional_params = self._add_tools_to_optional_params( optional_params=optional_params, tools=anthropic_tools ) diff --git a/tests/unit/llms/anthropic/chat/test_anthropic_chat_transformation.py b/tests/unit/llms/anthropic/chat/test_anthropic_chat_transformation.py index 9dbcac5f678..a7501ccbc7a 100644 --- a/tests/unit/llms/anthropic/chat/test_anthropic_chat_transformation.py +++ b/tests/unit/llms/anthropic/chat/test_anthropic_chat_transformation.py @@ -8345,3 +8345,98 @@ def test_completion_surfaces_response_citations_in_provider_specific_fields(resp assert response.choices[0].message.provider_specific_fields["citations"] == [ [{**citation, "supported_text": "the grass is green"}] ] + + +def _openai_tool_search_with_deferred_functions(execution: str = "server") -> list[dict[str, object]]: + return [ + {"type": "tool_search", "execution": execution}, + { + "type": "function", + "function": {"name": "get_weather", "parameters": {"type": "object", "properties": {}}}, + "defer_loading": True, + }, + { + "type": "function", + "function": { + "name": "get_stock_price", + "parameters": {"type": "object", "properties": {}}, + "defer_loading": True, + }, + }, + { + "type": "namespace", + "name": "crm", + "tools": [ + { + "type": "function", + "name": "lookup_customer", + "parameters": {"type": "object", "properties": {}}, + "defer_loading": True, + } + ], + }, + ] + + +_DEFERRED_FUNCTION_NAMES: Final = ["get_weather", "get_stock_price", "lookup_customer"] + + +@pytest.mark.parametrize( + ("config", "model", "provider"), + [ + pytest.param(AnthropicConfig(), "claude-sonnet-5-5", "anthropic", id="anthropic"), + pytest.param(AmazonAnthropicClaudeConfig(), "us.anthropic.claude-sonnet-5-5", "bedrock", id="bedrock-invoke"), + pytest.param(VertexAIAnthropicConfig(), "claude-sonnet-5-5", "vertex_ai", id="vertex_ai"), + ], +) +def test_map_openai_params_translates_the_openai_tool_search_tool_on_a_route_that_runs_it(config, model, provider): + from litellm.responses.litellm_completion_transformation.tool_search import anthropic_tool_search_chat_tool + + assert litellm.supports_tool_search(model, provider) + mapped: Final = config.map_openai_params( + non_default_params={"tools": _openai_tool_search_with_deferred_functions()}, + optional_params={}, + model=model, + drop_params=False, + ) + tools: Final = mapped["tools"] + assert {key: tools[0][key] for key in ("type", "name")} == anthropic_tool_search_chat_tool() + assert [tool["name"] for tool in tools[1:]] == _DEFERRED_FUNCTION_NAMES + assert all(tool["defer_loading"] is True for tool in tools[1:]) + + +@pytest.mark.parametrize( + ("config", "model", "provider", "execution"), + [ + pytest.param(AzureAnthropicConfig(), "claude-sonnet-5-5", "azure_ai", "server", id="route-without-tool-search"), + pytest.param(AnthropicConfig(), "claude-3-haiku-20240307", "anthropic", "server", id="model-without-tool-search"), + pytest.param(AnthropicConfig(), "claude-sonnet-5-5", None, "client", id="client-executed-search"), + ], +) +def test_map_openai_params_answers_400_for_an_openai_tool_search_tool_it_cannot_run( + monkeypatch, config, model, provider, execution +): + monkeypatch.setattr(litellm, "drop_params", False) + assert provider is None or not litellm.supports_tool_search(model, provider) + with pytest.raises(litellm.utils.UnsupportedParamsError) as raised: + config.map_openai_params( + non_default_params={"tools": _openai_tool_search_with_deferred_functions(execution=execution)}, + optional_params={}, + model=model, + drop_params=False, + ) + assert raised.value.status_code == 400 + assert "tool_search" in str(raised.value) + + +def test_map_openai_params_drops_the_openai_tool_search_tool_and_every_deferral_under_drop_params(monkeypatch): + monkeypatch.setattr(litellm, "drop_params", False) + assert not litellm.supports_tool_search("claude-sonnet-5-5", "azure_ai") + mapped: Final = AzureAnthropicConfig().map_openai_params( + non_default_params={"tools": _openai_tool_search_with_deferred_functions()}, + optional_params={}, + model="claude-sonnet-5-5", + drop_params=True, + ) + assert [tool["name"] for tool in mapped["tools"]] == _DEFERRED_FUNCTION_NAMES + assert all("defer_loading" not in tool for tool in mapped["tools"])