From bf3de2f5cbbeda580156d929950fa81f62cb0469 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:43:12 -0700 Subject: [PATCH] fix(anthropic): run tool search on azure_ai, gate it on Databricks Claude, and strip deferral without recursion Anthropic's Foundry docs list no tool search exclusion for either hosting option, so the claude-tool-search rule now fills azure_ai. Databricks Claude models route their tools through the same unsupported-tool-search gate, so a tool_search tool answers 400 or drops under drop_params instead of a 500. _without_deferral strips two fixed levels instead of walking recursively. --- litellm/llms/anthropic/chat/transformation.py | 44 +++++++++++++++---- .../llms/databricks/chat/transformation.py | 19 ++++++-- ...odel_prices_and_context_window_backup.json | 4 +- model_prices_and_context_window.json | 4 +- .../test_fallback_generalizations.py | 11 ++--- .../test_anthropic_chat_transformation.py | 6 +-- .../test_databricks_chat_transformation.py | 36 +++++++++++++++ .../test_litellm_completion_responses.py | 12 ++--- 8 files changed, 105 insertions(+), 31 deletions(-) diff --git a/litellm/llms/anthropic/chat/transformation.py b/litellm/llms/anthropic/chat/transformation.py index ec2b8fc3ff2..95d54c9100d 100644 --- a/litellm/llms/anthropic/chat/transformation.py +++ b/litellm/llms/anthropic/chat/transformation.py @@ -147,7 +147,6 @@ _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 _tool_type(tool: object) -> str | None: @@ -166,18 +165,47 @@ def _is_anthropic_tool_search_tool(tool: object) -> bool: return _tool_type(tool) in ANTHROPIC_TOOL_SEARCH_TOOL_TYPES -def _without_deferral(tool: object) -> object: - match tool: +def _without_defer_loading(fields: object) -> object: + match fields: 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() + key: value + for key, value in _TOOL_FIELDS_ADAPTER.validate_python(fields).items() if key != "defer_loading" } - case list(): - return [_without_deferral(member) for member in _TOOLS_ADAPTER.validate_python(tool)] case _: - return tool + return fields + + +def _member_without_deferral(member: object) -> object: + match member: + case Mapping(): + return { + key: _without_defer_loading(value) if key == "function" else value + for key, value in _TOOL_FIELDS_ADAPTER.validate_python(member).items() + if key != "defer_loading" + } + case _: + return member + + +def _namespace_members_without_deferral(members: object) -> object: + match members: + case list(): + return [_member_without_deferral(member) for member in _TOOLS_ADAPTER.validate_python(members)] + case _: + return members + + +def _without_deferral(tool: object) -> object: + match _member_without_deferral(tool): + case Mapping() as stripped: + return { + key: _namespace_members_without_deferral(value) if key == "tools" else value + for key, value in _TOOL_FIELDS_ADAPTER.validate_python(stripped).items() + } + case stripped: + return stripped def _validated_defer_loading(value: object) -> bool: diff --git a/litellm/llms/databricks/chat/transformation.py b/litellm/llms/databricks/chat/transformation.py index 4b6f178fa1a..c8894d03cee 100644 --- a/litellm/llms/databricks/chat/transformation.py +++ b/litellm/llms/databricks/chat/transformation.py @@ -58,6 +58,7 @@ from litellm.types.utils import ( from ...anthropic.chat.transformation import ( REASONING_EFFORT_TO_OUTPUT_CONFIG_EFFORT, AnthropicConfig, + tools_without_unsupported_tool_search, ) from ...openai_like.chat.transformation import OpenAILikeChatConfig from ..common_utils import DatabricksBase, DatabricksException @@ -65,6 +66,7 @@ from ..common_utils import DatabricksBase, DatabricksException _RESPONSE_FORMAT_ADAPTER: ( # mutable-ok: mirrors the dict return contract of get_json_schema_from_pydantic_object Final[TypeAdapter[dict[str, object] | None]] ) = TypeAdapter(dict[str, object] | None) +_TOOLS_ADAPTER: Final = TypeAdapter(list[object]) def _is_bare_assistant_message(message_dict: Mapping[str, object]) -> bool: @@ -317,13 +319,22 @@ class DatabricksConfig(DatabricksBase, OpenAILikeChatConfig, AnthropicConfig): function=function_params, ) - def _map_openai_to_dbrx_tool(self, model: str, tools: list) -> list[DatabricksTool]: + def _map_openai_to_dbrx_tool(self, model: str, tools: list, drop_params: bool) -> list[DatabricksTool]: # if not claude, send as is if "claude" not in model: return tools # if claude, convert to anthropic tool and then to databricks tool - anthropic_tools, _ = self._map_tools(tools=tools) # unclear how mcp tool calling on databricks works + anthropic_tools, _ = self._map_tools( + tools=list( + tools_without_unsupported_tool_search( + tools=_TOOLS_ADAPTER.validate_python(tools), + model=model, + route="databricks", + drop_params=drop_params, + ) + ) + ) # unclear how mcp tool calling on databricks works databricks_tools: Final = [ cast(DatabricksTool, self.convert_anthropic_tool_to_databricks_tool(tool)) for tool in anthropic_tools ] @@ -374,7 +385,9 @@ class DatabricksConfig(DatabricksBase, OpenAILikeChatConfig, AnthropicConfig): model=model, optional_params=mapped_params, custom_llm_provider="databricks" ) if "tools" in mapped_params: - mapped_params["tools"] = self._map_openai_to_dbrx_tool(model=model, tools=mapped_params["tools"]) + mapped_params["tools"] = self._map_openai_to_dbrx_tool( + model=model, tools=mapped_params["tools"], drop_params=drop_params + ) if "max_completion_tokens" in non_default_params and replace_max_completion_tokens_with_max_tokens: mapped_params["max_tokens"] = non_default_params[ "max_completion_tokens" diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index b3002fafc45..e5060317cb9 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -60472,8 +60472,8 @@ { "name": "claude-tool-search", "pattern": "claude-[a-z]+-(?:4[-._](?:[5-9]|[1-9]\\d)(?!\\d)|[5-9](?!\\d)(?:[-._]\\d{1,2}(?!\\d))?)", - "fill_missing_for_providers": ["anthropic", "bedrock", "bedrock_converse", "vertex_ai-anthropic_models"], - "description": "Claude at version 4.5 or higher, in any id shape that contains claude--: minors 4.5 through 4.99, any later major-minor, and bare 5+ majors so a new family like claude-fable-5 matches. Two-digit majors are deliberately not matched so ids like claude-opus-41 (4.1) are not read as major 41. Anthropic's tool search docs list every Claude 4.5 and newer model as supported and Opus 4.1 and earlier as unsupported, so the flag follows the version instead of a per-model list. azure_ai is left out on purpose: Anthropic documents tool search as unavailable on Azure-hosted Foundry deployments, and the azure_ai/ key cannot tell those from Anthropic-hosted ones.", + "fill_missing_for_providers": ["anthropic", "azure_ai", "bedrock", "bedrock_converse", "vertex_ai-anthropic_models"], + "description": "Claude at version 4.5 or higher, in any id shape that contains claude--: minors 4.5 through 4.99, any later major-minor, and bare 5+ majors so a new family like claude-fable-5 matches. Two-digit majors are deliberately not matched so ids like claude-opus-41 (4.1) are not read as major 41. Anthropic's tool search docs list every Claude 4.5 and newer model as supported and Opus 4.1 and earlier as unsupported, so the flag follows the version instead of a per-model list. azure_ai is included because Anthropic's Claude in Microsoft Foundry page (platform.claude.com/docs/en/build-with-claude/claude-in-microsoft-foundry, read 2026-10-13) lists no tool search exclusion for either hosting option, and Microsoft's 2026-08-17 Foundry post names Tool search as available on deployments hosted on Azure.", "model_info": { "supports_tool_search": true } diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index b3002fafc45..e5060317cb9 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -60472,8 +60472,8 @@ { "name": "claude-tool-search", "pattern": "claude-[a-z]+-(?:4[-._](?:[5-9]|[1-9]\\d)(?!\\d)|[5-9](?!\\d)(?:[-._]\\d{1,2}(?!\\d))?)", - "fill_missing_for_providers": ["anthropic", "bedrock", "bedrock_converse", "vertex_ai-anthropic_models"], - "description": "Claude at version 4.5 or higher, in any id shape that contains claude--: minors 4.5 through 4.99, any later major-minor, and bare 5+ majors so a new family like claude-fable-5 matches. Two-digit majors are deliberately not matched so ids like claude-opus-41 (4.1) are not read as major 41. Anthropic's tool search docs list every Claude 4.5 and newer model as supported and Opus 4.1 and earlier as unsupported, so the flag follows the version instead of a per-model list. azure_ai is left out on purpose: Anthropic documents tool search as unavailable on Azure-hosted Foundry deployments, and the azure_ai/ key cannot tell those from Anthropic-hosted ones.", + "fill_missing_for_providers": ["anthropic", "azure_ai", "bedrock", "bedrock_converse", "vertex_ai-anthropic_models"], + "description": "Claude at version 4.5 or higher, in any id shape that contains claude--: minors 4.5 through 4.99, any later major-minor, and bare 5+ majors so a new family like claude-fable-5 matches. Two-digit majors are deliberately not matched so ids like claude-opus-41 (4.1) are not read as major 41. Anthropic's tool search docs list every Claude 4.5 and newer model as supported and Opus 4.1 and earlier as unsupported, so the flag follows the version instead of a per-model list. azure_ai is included because Anthropic's Claude in Microsoft Foundry page (platform.claude.com/docs/en/build-with-claude/claude-in-microsoft-foundry, read 2026-10-13) lists no tool search exclusion for either hosting option, and Microsoft's 2026-08-17 Foundry post names Tool search as available on deployments hosted on Azure.", "model_info": { "supports_tool_search": true } diff --git a/tests/unit/litellm_core_utils/test_fallback_generalizations.py b/tests/unit/litellm_core_utils/test_fallback_generalizations.py index 31db7d6f30c..cf85e796031 100644 --- a/tests/unit/litellm_core_utils/test_fallback_generalizations.py +++ b/tests/unit/litellm_core_utils/test_fallback_generalizations.py @@ -970,19 +970,16 @@ def test_shipped_tool_search_rule_version_boundaries(shipped_cost_map, model, pr def test_shipped_tool_search_rule_fills_mapped_claude_entries_without_flag(shipped_cost_map): """A mapped Claude 4.5+ entry with no supports_tool_search key gets it from the rule - on Anthropic direct, Vertex and Bedrock, a mapped pre-4.5 entry stays without one, - and Azure Foundry and reseller copies of the same model are not touched.""" + on Anthropic direct, Vertex, Bedrock and Azure Foundry, a mapped pre-4.5 entry stays + without one, and reseller copies of the same model are not touched.""" for key, model, provider in ( ("claude-opus-4-7", "claude-opus-4-7", "anthropic"), ("vertex_ai/claude-opus-5", "claude-opus-5", "vertex_ai"), + ("azure_ai/claude-opus-5", "claude-opus-5", "azure_ai"), ): assert "supports_tool_search" not in litellm.model_cost[key] assert litellm.get_model_info(model, custom_llm_provider=provider)["supports_tool_search"] is True - assert "supports_tool_search" not in litellm.model_cost["azure_ai/claude-opus-5"] - azure_opus_5_info = litellm.get_model_info("claude-opus-5", custom_llm_provider="azure_ai") - assert azure_opus_5_info.get("supports_tool_search") is None - assert match_fill_missing_generalizations("claude-opus-5", "bedrock")["supports_tool_search"] is True - assert "supports_tool_search" not in match_fill_missing_generalizations("claude-opus-5", "azure_ai") + assert match_fill_missing_generalizations("claude-opus-5", "azure_ai")["supports_tool_search"] is True assert match_fill_missing_generalizations("claude-opus-5", "perplexity") is None 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 56255609626..1b8cd059921 100644 --- a/tests/unit/llms/anthropic/chat/test_anthropic_chat_transformation.py +++ b/tests/unit/llms/anthropic/chat/test_anthropic_chat_transformation.py @@ -8808,6 +8808,7 @@ def _deferral_by_function_name(tools: list[dict[str, object]]) -> dict[object, o 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"), + pytest.param(AzureAnthropicConfig(), "claude-sonnet-5-5", "azure_ai", id="azure_ai"), ], ) @pytest.mark.parametrize( @@ -8837,8 +8838,8 @@ def test_map_openai_params_sends_one_anthropic_search_tool_and_keeps_deferral_on @pytest.mark.parametrize( ("config", "model"), [ - pytest.param(AnthropicConfig(), "claude-sonnet-5-5", id="route-with-tool-search"), - pytest.param(AzureAnthropicConfig(), "claude-sonnet-5-5", id="route-without-tool-search"), + pytest.param(AnthropicConfig(), "claude-sonnet-5-5", id="model-with-tool-search"), + pytest.param(AnthropicConfig(), "claude-3-haiku-20240307", id="model-without-tool-search"), ], ) def test_map_openai_params_lets_a_native_anthropic_search_tool_replace_the_openai_one( @@ -8892,7 +8893,6 @@ def test_map_openai_params_judges_tool_search_on_the_real_model_when_tool_based_ _UNRUNNABLE_TOOL_SEARCH_CASES: Final = [ - 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"), ] diff --git a/tests/unit/llms/databricks/chat/test_databricks_chat_transformation.py b/tests/unit/llms/databricks/chat/test_databricks_chat_transformation.py index fe76a667f3e..167e55e0e4b 100644 --- a/tests/unit/llms/databricks/chat/test_databricks_chat_transformation.py +++ b/tests/unit/llms/databricks/chat/test_databricks_chat_transformation.py @@ -2255,3 +2255,39 @@ def test_transform_response_carries_served_service_tier(served_tier: str | None) ) assert result.to_dict().get("service_tier") == served_tier + + +_DATABRICKS_TOOLS_WITH_OPENAI_TOOL_SEARCH: Final = [ + {"type": "tool_search", "execution": "server"}, + { + "type": "function", + "function": {"name": "get_weather", "parameters": {"type": "object", "properties": {}}}, + "defer_loading": True, + }, +] + + +def test_databricks_claude_answers_400_for_the_openai_tool_search_tool_without_drop_params(monkeypatch): + monkeypatch.setattr(litellm, "drop_params", False) + with pytest.raises(litellm.utils.UnsupportedParamsError) as raised: + DatabricksConfig().map_openai_params( + non_default_params={"tools": _DATABRICKS_TOOLS_WITH_OPENAI_TOOL_SEARCH}, + optional_params={}, + model="databricks-claude-opus-5-5", + drop_params=False, + ) + assert raised.value.status_code == 400 + assert "tool_search" in str(raised.value) + + +def test_databricks_claude_drops_the_openai_tool_search_tool_under_drop_params(monkeypatch): + monkeypatch.setattr(litellm, "drop_params", False) + mapped: Final = DatabricksConfig().map_openai_params( + non_default_params={"tools": _DATABRICKS_TOOLS_WITH_OPENAI_TOOL_SEARCH}, + optional_params={}, + model="databricks-claude-opus-5-5", + drop_params=True, + ) + assert mapped["tools"] == [ + {"type": "function", "function": {"name": "get_weather", "parameters": {"type": "object", "properties": {}}}} + ] diff --git a/tests/unit/responses/litellm_completion_transformation/test_litellm_completion_responses.py b/tests/unit/responses/litellm_completion_transformation/test_litellm_completion_responses.py index 1cf99bd590f..a490e30174d 100644 --- a/tests/unit/responses/litellm_completion_transformation/test_litellm_completion_responses.py +++ b/tests/unit/responses/litellm_completion_transformation/test_litellm_completion_responses.py @@ -1811,14 +1811,14 @@ class TestToolTransformation: assert [tool["type"] for tool in result["tools"]] == ["function", "function"] def test_dropping_the_search_tool_undefers_the_remaining_tools(self) -> None: - model: Final = "azure_ai/claude-sonnet-5-5" - assert litellm.supports_tool_search(model, "azure_ai") is False + model: Final = "anthropic/claude-3-haiku-20240307" + assert litellm.supports_tool_search(model, "anthropic") is False result: Final = LiteLLMCompletionResponsesConfig.transform_responses_api_request_to_chat_completion_request( model=model, input="What is the weather in Paris right now?", responses_api_request={"tools": _deferred_tools_with_server_tool_search()}, - custom_llm_provider="azure_ai", + custom_llm_provider="anthropic", ) assert [tool["type"] for tool in result["tools"]] == ["function", "function"] @@ -1876,8 +1876,8 @@ class TestToolTransformation: assert [tool["defer_loading"] for tool in chat_tools if tool["type"] == "function"] == [True, True] def test_native_search_tool_passes_through_routes_without_tool_search(self) -> None: - model: Final = "azure_ai/claude-sonnet-5-5" - assert litellm.supports_tool_search(model, "azure_ai") is False + model: Final = "anthropic/claude-3-haiku-20240307" + assert litellm.supports_tool_search(model, "anthropic") is False tools: Final = [ {"type": "tool_search_tool_regex_20251119", "name": "tool_search_tool_regex"}, *_deferred_tools_with_server_tool_search()[1:], @@ -1887,7 +1887,7 @@ class TestToolTransformation: model=model, input="What is the weather in Paris right now?", responses_api_request={"tools": tools}, - custom_llm_provider="azure_ai", + custom_llm_provider="anthropic", ) chat_tools: Final = result["tools"]