fix(anthropic): drop answered tool search replays beside client calls, place system before dropped search turns, flag tool search on azure_ai

This commit is contained in:
mateo-berri 2026-10-14 00:17:35 -07:00
parent b22215bcf9
commit c3c38ba836
11 changed files with 185 additions and 84 deletions

View file

@ -6,7 +6,7 @@ import json
import mimetypes
import re
import xml.etree.ElementTree as ET
from collections.abc import Container, Iterable, Iterator, Mapping, Sequence
from collections.abc import Container, Iterator, Mapping, Sequence
from enum import Enum
from types import MappingProxyType
from typing import Any, Final, TypeAlias, TypedDict, cast, overload
@ -20,7 +20,13 @@ import litellm.types.llms
from litellm import verbose_logger
from litellm._uuid import uuid
from litellm.constants import REDACTED_BY_LITELLM
from litellm.litellm_core_utils.prompt_templates.mid_conversation_system import anthropic_system_messages
from litellm.litellm_core_utils.prompt_templates.mid_conversation_system import (
answered_tool_call_ids,
anthropic_system_messages,
find_anthropic_server_tool_result,
is_anthropic_tool_search_use,
tool_search_ids_dropped_with_their_answers,
)
from litellm.litellm_core_utils.url_utils import async_safe_get, safe_get
from litellm.llms.custom_httpx.http_handler import HTTPHandler, get_async_httpx_client
from litellm.types.files import get_file_extension_from_mime_type
@ -1815,39 +1821,6 @@ def convert_function_to_anthropic_tool_invoke(
raise e
def is_anthropic_tool_search_use(tool_id: str, tool_name: str) -> bool:
return tool_id.startswith(ANTHROPIC_SERVER_TOOL_USE_ID_PREFIX) and tool_name in ANTHROPIC_TOOL_SEARCH_TOOL_NAMES
def _tool_result_ids(answers: Iterable[AllMessageValues]) -> Iterator[str]:
for message in answers:
if message["role"] == "tool":
yield message["tool_call_id"]
elif message["role"] == "function" and (tool_call_id := message.get("tool_call_id")):
yield tool_call_id
def answered_tool_call_ids(messages: Sequence[AllMessageValues], assistant_index: int) -> frozenset[str]:
from_assistant: Final = (messages[index] for index in range(assistant_index, len(messages)))
following: Final = itertools.dropwhile(lambda message: message["role"] == "assistant", from_assistant)
answers: Final = itertools.takewhile(lambda message: message["role"] in ("tool", "function"), following)
return frozenset(_tool_result_ids(answers))
def find_anthropic_server_tool_result(
tool_id: str,
web_search_results: Sequence[object] | None,
tool_results: Sequence[object] | None,
) -> dict[str, object] | None:
if not tool_id.startswith(ANTHROPIC_SERVER_TOOL_USE_ID_PREFIX):
return None
candidates: Final = (*(web_search_results or ()), *(tool_results or ()))
return next(
(result for result in candidates if isinstance(result, dict) and result.get("tool_use_id") == tool_id),
None,
)
_AnthropicToolInvokeItem: TypeAlias = AnthropicMessagesToolUseParam | dict[str, Any]
@ -2502,6 +2475,7 @@ def anthropic_messages_pt(
is_bedrock_invoke = model.lower().startswith("invoke/")
is_vertex_ai = llm_provider.startswith("vertex_ai") if llm_provider else False
force_base64 = is_bedrock_invoke or is_vertex_ai
dropped_tool_search_ids: Final = tool_search_ids_dropped_with_their_answers(messages)
msg_i = 0
while msg_i < len(messages):
@ -2602,7 +2576,9 @@ def anthropic_messages_pt(
user_content.append(_anthropic_content_text_element)
elif user_message_types_block["role"] == "tool" or user_message_types_block["role"] == "function":
elif (
user_message_types_block["role"] == "tool" or user_message_types_block["role"] == "function"
) and user_message_types_block.get("tool_call_id") not in dropped_tool_search_ids:
# OpenAI's tool message content will always be a string
user_content.append(
convert_to_anthropic_tool_result(user_message_types_block, force_base64=force_base64)
@ -2617,7 +2593,7 @@ def anthropic_messages_pt(
unique_tool_ids: set[str] = set()
assistant_content: list[AnthropicMessagesAssistantMessageValues] = []
answered_tool_ids = answered_tool_call_ids(messages, msg_i)
answered_tool_ids = answered_tool_call_ids(messages, msg_i) - dropped_tool_search_ids
## MERGE CONSECUTIVE ASSISTANT CONTENT ##
while msg_i < len(messages) and messages[msg_i]["role"] == "assistant":
assistant_content_block: ChatCompletionAssistantMessage = messages[msg_i]

View file

@ -29,11 +29,16 @@ prompt cache readable across turns. Messages are handled in OpenAI format; the
Anthropic wire shape is built later by ``anthropic_messages_pt``.
"""
from collections.abc import Iterator, Mapping, Sequence
from itertools import chain, groupby
from collections.abc import Iterable, Iterator, Mapping, Sequence
from itertools import chain, dropwhile, groupby, islice, takewhile
from typing import Final, Literal, TypeAlias, TypeVar
from litellm.types.llms.anthropic import AnthropicMessagesSystemMessageParam, AnthropicSystemMessageContent
from litellm.types.llms.anthropic import (
ANTHROPIC_SERVER_TOOL_USE_ID_PREFIX,
ANTHROPIC_TOOL_SEARCH_TOOL_NAMES,
AnthropicMessagesSystemMessageParam,
AnthropicSystemMessageContent,
)
from litellm.types.llms.openai import (
AllMessageValues,
ChatCompletionCachedContent,
@ -283,14 +288,107 @@ def _separate_thinking_blocks_render(message: object, parts: Sequence[object]) -
return any(_thinking_block_renders(block) for block in parts_of(message_field(message, "thinking_blocks")))
def _assistant_renders(message: object) -> bool:
def is_anthropic_tool_search_use(tool_id: str, tool_name: str) -> bool:
return tool_id.startswith(ANTHROPIC_SERVER_TOOL_USE_ID_PREFIX) and tool_name in ANTHROPIC_TOOL_SEARCH_TOOL_NAMES
def find_anthropic_server_tool_result(
tool_id: str,
web_search_results: Sequence[object] | None,
tool_results: Sequence[object] | None,
) -> dict[str, object] | None:
if not tool_id.startswith(ANTHROPIC_SERVER_TOOL_USE_ID_PREFIX):
return None
candidates: Final = (*(web_search_results or ()), *(tool_results or ()))
return next(
(result for result in candidates if isinstance(result, dict) and result.get("tool_use_id") == tool_id),
None,
)
def _tool_result_ids(answers: Iterable[object]) -> Iterator[str]:
for message in answers:
if isinstance(tool_call_id := message_field(message, "tool_call_id"), str) and tool_call_id:
yield tool_call_id
def answered_tool_call_ids(messages: Sequence[object], assistant_index: int) -> frozenset[str]:
from_assistant: Final = islice(messages, assistant_index, None)
following: Final = dropwhile(lambda message: message_field(message, "role") == "assistant", from_assistant)
answers: Final = takewhile(lambda message: message_field(message, "role") in _TOOL_ROLES, following)
return frozenset(_tool_result_ids(answers))
def _unresolved_tool_search_id(tool_call: object, message: object) -> str | None:
tool_id: Final = message_field(tool_call, "id")
tool_name: Final = message_field(message_field(tool_call, "function"), "name")
if not isinstance(tool_id, str) or not isinstance(tool_name, str):
return None
if not is_anthropic_tool_search_use(tool_id, tool_name):
return None
provider_fields: Final = message_field(message, "provider_specific_fields")
server_result: Final = find_anthropic_server_tool_result(
tool_id,
parts_of(message_field(provider_fields, "web_search_results")),
parts_of(message_field(provider_fields, "tool_results")),
)
return tool_id if server_result is None else None
def _unresolved_tool_search_ids(message: object) -> tuple[str, ...]:
return tuple(
tool_id
for tool_call in parts_of(message_field(message, "tool_calls"))
if (tool_id := _unresolved_tool_search_id(tool_call, message)) is not None
)
def _is_client_tool_call(tool_call: object) -> bool:
tool_id: Final = message_field(tool_call, "id")
return isinstance(tool_id, str) and not tool_id.startswith(ANTHROPIC_SERVER_TOOL_USE_ID_PREFIX)
def _tool_search_ids_beside_client_calls(message: object) -> tuple[str, ...]:
tool_calls: Final = parts_of(message_field(message, "tool_calls"))
return _unresolved_tool_search_ids(message) if any(map(_is_client_tool_call, tool_calls)) else ()
def tool_search_ids_dropped_with_their_answers(messages: Iterable[object]) -> frozenset[str]:
"""Tool search uses ``anthropic_messages_pt`` leaves out together with the caller's answer to them.
The search already ran on Anthropic's side, so a caller's ``tool`` message for it
is never the real result. Dropping the pair is safe only beside a client tool call,
whose result keeps a user turn after the assistant turn: Claude rejects a
conversation that ends on an assistant turn.
"""
return frozenset(
chain.from_iterable(
_tool_search_ids_beside_client_calls(message)
for message in messages
if message_field(message, "role") == "assistant"
)
)
def _tool_calls_render(messages: Sequence[object], index: int) -> bool:
tool_calls: Final = parts_of(message_field(messages[index], "tool_calls"))
search_ids: Final = _unresolved_tool_search_ids(messages[index])
if len(search_ids) < len(tool_calls):
return True
return bool(search_ids) and not answered_tool_call_ids(messages, index).isdisjoint(search_ids)
def _assistant_renders(messages: Sequence[object], index: int) -> bool:
"""Whether ``anthropic_messages_pt`` puts a block on the wire for this assistant message.
String content (the converter pads an empty one with a placeholder), a text part,
a signed thinking part, a server tool part, tool calls, a function call, a kept
thinking block and compaction blocks each render. An assistant message with none
of them, such as ``content: None`` or an empty list, vanishes from the wire.
thinking block and compaction blocks each render. A tool search use with neither a
server result nor a caller's answer is dropped, so it renders nothing. An assistant
message with none of them, such as ``content: None`` or an empty list, vanishes
from the wire.
"""
message: Final = messages[index]
content: Final = message_field(message, "content")
if isinstance(content, str):
return True
@ -298,13 +396,13 @@ def _assistant_renders(message: object) -> bool:
return (
any(_assistant_part_renders(part) for part in parts)
or _separate_thinking_blocks_render(message, parts)
or bool(message_field(message, "tool_calls"))
or _tool_calls_render(messages, index)
or bool(message_field(message, "function_call"))
or bool(message_field(message_field(message, "provider_specific_fields"), "compaction_blocks"))
)
def _renders(message: object) -> bool:
def _renders(messages: Sequence[object], index: int) -> bool:
"""Whether ``anthropic_messages_pt`` puts a block on the wire for this message.
A tool message always becomes a ``tool_result`` and a user message with string
@ -312,11 +410,12 @@ def _renders(message: object) -> bool:
renders only through parts of a type the converter emits; ``None``, an empty list,
and a list of other parts vanish. Assistant messages follow ``_assistant_renders``.
"""
message: Final = messages[index]
role: Final = message_field(message, "role")
if role in _TOOL_ROLES:
return True
if role == "assistant":
return _assistant_renders(message)
return _assistant_renders(messages, index)
content: Final = message_field(message, "content")
return isinstance(content, str) or any(
message_field(part, "type") in _RENDERED_PART_TYPES for part in parts_of(content)
@ -330,7 +429,7 @@ def _rendered_block(
) -> int | None:
block_index: Final = _block_containing(message_index, blocks)
_, indices = blocks[block_index]
return block_index if any(_renders(messages[index]) for index in indices) else None
return block_index if any(_renders(messages, index) for index in indices) else None
def _system_may_follow(
@ -347,7 +446,7 @@ def _system_may_follow(
(
not is_user
for is_user, indices in blocks[block_index + 1 :]
if any(_renders(messages[index]) for index in indices)
if any(_renders(messages, index) for index in indices)
),
True,
)

View file

@ -26,11 +26,12 @@ from litellm.litellm_core_utils.prompt_templates.common_utils import (
get_file_ids_from_messages,
is_encrypted_reasoning_block,
)
from litellm.litellm_core_utils.prompt_templates.factory import (
THOUGHT_SIGNATURE_SEPARATOR,
from litellm.litellm_core_utils.prompt_templates.factory import THOUGHT_SIGNATURE_SEPARATOR
from litellm.litellm_core_utils.prompt_templates.mid_conversation_system import (
find_anthropic_server_tool_result,
message_field,
parts_of,
)
from litellm.litellm_core_utils.prompt_templates.mid_conversation_system import message_field, parts_of
from litellm.llms.anthropic.wif import (
aget_anthropic_wif_token,
anthropic_base_without_chat_suffix,

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 Microsoft Foundry runs tool search for Claude on both Anthropic-hosted and Azure-hosted deployments.",
"model_info": {
"supports_tool_search": true
}

View file

@ -3,7 +3,7 @@ from typing import Final, Literal, TypeAlias
from pydantic import ConfigDict, TypeAdapter, ValidationError
from litellm.litellm_core_utils.prompt_templates.factory import is_anthropic_tool_search_use
from litellm.litellm_core_utils.prompt_templates.mid_conversation_system import is_anthropic_tool_search_use
from litellm.types.llms.anthropic import ANTHROPIC_TOOL_SEARCH_TOOL_TYPES, AnthropicToolSearchToolRegex
from litellm.types.llms.base import LiteLLMBaseModel
from litellm.types.responses.main import OutputToolSearchCall

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 Microsoft Foundry runs tool search for Claude on both Anthropic-hosted and Azure-hosted deployments.",
"model_info": {
"supports_tool_search": true
}

View file

@ -432,17 +432,18 @@ def test_a_false_defer_loading_never_reaches_an_openai_compatible_wire_as_a_key(
@pytest.mark.parametrize("stream", [False, True], ids=["sync", "stream"])
@pytest.mark.parametrize("route", ["anthropic", "azure_ai"])
def test_the_supported_anthropic_route_keeps_each_tools_own_defer_loading_with_the_search_tool(
gateway: Gateway, stream: bool
gateway: Gateway, route: Route, stream: bool
) -> None:
marker: Final = uuid.uuid4().hex
with wire_server(_claude) as wire, gateway.scenario() as scenario:
model: Final = _deployment(scenario, "anthropic", wire.url)
model: Final = _deployment(scenario, route, wire.url)
tools: Final = (_tool_search(), _function_tool(), _namespace_tool(), _loaded_tool())
response: Final = _raw(gateway, _responses_body(model, marker, tools, stream))
_assert_served(response, marker, stream)
request, body = _only_request(wire)
_assert_target("anthropic", stream, request, body)
_assert_target(route, stream, request, body)
sent: Final = _ITEMS.validate_python(body["tools"])
assert sent[0] == {"type": _REGEX_TOOL_TYPE, "name": _REGEX_TOOL_NAME}, sent
assert [(tool["name"], tool.get("defer_loading")) for tool in sent[1:]] == [

View file

@ -7144,18 +7144,22 @@ def test_anthropic_messages_pt_drops_replayed_tool_search_call_without_its_resul
]
def test_anthropic_messages_pt_keeps_a_replayed_tool_search_call_the_caller_answered():
def _tool_search_call() -> dict:
return {
"id": "srvtoolu_01Search",
"type": "function",
"function": {"name": "tool_search_tool_regex", "arguments": '{"pattern": "weather"}'},
}
def test_anthropic_messages_pt_drops_an_answered_tool_search_call_and_its_answer_beside_a_client_call():
messages: Final = [
{"role": "user", "content": "What is the weather in Paris?"},
{
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "srvtoolu_01Search",
"type": "function",
"function": {"name": "tool_search_tool_regex", "arguments": '{"pattern": "weather"}'},
},
_tool_search_call(),
{
"id": "toolu_01Weather",
"type": "function",
@ -7163,22 +7167,32 @@ def test_anthropic_messages_pt_keeps_a_replayed_tool_search_call_the_caller_answ
},
],
},
{"role": "tool", "tool_call_id": "srvtoolu_01Search", "content": '[{"name": "get_weather"}]'},
{"role": "tool", "tool_call_id": "srvtoolu_01Search", "content": "Error: unknown tool tool_search_tool_regex"},
{"role": "tool", "tool_call_id": "toolu_01Weather", "content": "18C and cloudy"},
]
result: Final = anthropic_messages_pt(messages=messages, model="claude-sonnet-5-5", llm_provider="anthropic")
assert [(block["type"], block["id"]) for block in result[1]["content"]] == [
("tool_use", "srvtoolu_01Search"),
("tool_use", "toolu_01Weather"),
]
assert [(block["type"], block["id"]) for block in result[1]["content"]] == [("tool_use", "toolu_01Weather")]
assert [block["tool_use_id"] for block in result[2]["content"] if block["type"] == "tool_result"] == [
"srvtoolu_01Search",
"toolu_01Weather",
"toolu_01Weather"
]
def test_anthropic_messages_pt_keeps_an_answered_tool_search_call_that_is_the_turns_only_call():
messages: Final = [
{"role": "user", "content": "What is the weather in Paris?"},
{"role": "assistant", "content": "Let me look for a weather tool.", "tool_calls": [_tool_search_call()]},
{"role": "tool", "tool_call_id": "srvtoolu_01Search", "content": "Error: unknown tool tool_search_tool_regex"},
]
result: Final = anthropic_messages_pt(messages=messages, model="claude-sonnet-5-5", llm_provider="anthropic")
assert [message["role"] for message in result] == ["user", "assistant", "user"]
assert [block["type"] for block in result[1]["content"]] == ["text", "tool_use"]
assert [block["tool_use_id"] for block in result[2]["content"]] == ["srvtoolu_01Search"]
def _merged_assistant_run_cost(run_length: int) -> float:
messages: Final = [
{"role": "user", "content": "Plan the trip"},

View file

@ -281,6 +281,17 @@ EMPTY_ASSISTANT = pytest.mark.parametrize(
"thinking_blocks": [{"type": "redacted_thinking", "data": "x"}],
},
litellm.Message(role="assistant", content=None),
{
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "srvtoolu_01Search",
"type": "function",
"function": {"name": "tool_search_tool_regex", "arguments": '{"pattern": "weather"}'},
}
],
},
],
ids=[
"none",
@ -291,6 +302,7 @@ EMPTY_ASSISTANT = pytest.mark.parametrize(
"unsigned-inline-part-hides-signed-block",
"inline-redacted-part-hides-redacted-block",
"pydantic-none",
"unanswered-tool-search-use",
],
)

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, Azure Foundry, Vertex and Bedrock, 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"),
("azure_ai/claude-opus-5", "claude-opus-5", "azure_ai"),
("vertex_ai/claude-opus-5", "claude-opus-5", "vertex_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

@ -1783,13 +1783,14 @@ class TestToolTransformation:
assert len(result_tools) == function_and_custom_count
assert web_search_options is not None
def test_server_tool_search_becomes_anthropic_tool_search_for_claude(self) -> None:
@pytest.mark.parametrize("provider", ["anthropic", "azure_ai"])
def test_server_tool_search_becomes_anthropic_tool_search_for_claude(self, provider: str) -> None:
transform: Final = LiteLLMCompletionResponsesConfig.transform_responses_api_request_to_chat_completion_request
result: Final = transform(
model="anthropic/claude-sonnet-5-5",
model=f"{provider}/claude-sonnet-5-5",
input="What is the weather in Paris right now?",
responses_api_request={"tools": _deferred_tools_with_server_tool_search()},
custom_llm_provider="anthropic",
custom_llm_provider=provider,
)
chat_tools: Final = result["tools"]
@ -1811,7 +1812,7 @@ 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"
model: Final = "azure_ai/claude-opus-4-1"
assert litellm.supports_tool_search(model, "azure_ai") is False
result: Final = LiteLLMCompletionResponsesConfig.transform_responses_api_request_to_chat_completion_request(
@ -1876,7 +1877,7 @@ 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"
model: Final = "azure_ai/claude-opus-4-1"
assert litellm.supports_tool_search(model, "azure_ai") is False
tools: Final = [
{"type": "tool_search_tool_regex_20251119", "name": "tool_search_tool_regex"},