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.
This commit is contained in:
mateo-berri 2026-10-13 23:43:12 -07:00
parent 07516c334b
commit bf3de2f5cb
8 changed files with 105 additions and 31 deletions

View file

@ -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:

View file

@ -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"

View file

@ -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-<family>-: 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-<family>-: 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
}

View file

@ -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-<family>-: 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-<family>-: 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
}

View file

@ -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

View file

@ -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"),
]

View file

@ -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": {}}}}
]

View file

@ -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"]