From c3c38ba836ca78c91c152bf74f3a6e4c65778915 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:17:35 -0700 Subject: [PATCH] fix(anthropic): drop answered tool search replays beside client calls, place system before dropped search turns, flag tool search on azure_ai --- .../prompt_templates/factory.py | 50 ++------ .../mid_conversation_system.py | 121 ++++++++++++++++-- litellm/llms/anthropic/common_utils.py | 7 +- ...odel_prices_and_context_window_backup.json | 4 +- .../tool_search.py | 2 +- model_prices_and_context_window.json | 4 +- ...nses_bridge_tool_search_undeferred_wire.py | 7 +- ...llm_core_utils_prompt_templates_factory.py | 40 ++++-- ...rompt_templates_mid_conversation_system.py | 12 ++ .../test_fallback_generalizations.py | 11 +- .../test_litellm_completion_responses.py | 11 +- 11 files changed, 185 insertions(+), 84 deletions(-) diff --git a/litellm/litellm_core_utils/prompt_templates/factory.py b/litellm/litellm_core_utils/prompt_templates/factory.py index 560d756ab9a..ad46121e6d9 100644 --- a/litellm/litellm_core_utils/prompt_templates/factory.py +++ b/litellm/litellm_core_utils/prompt_templates/factory.py @@ -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] diff --git a/litellm/litellm_core_utils/prompt_templates/mid_conversation_system.py b/litellm/litellm_core_utils/prompt_templates/mid_conversation_system.py index cb4bbf00c85..3fa4bd25b1a 100644 --- a/litellm/litellm_core_utils/prompt_templates/mid_conversation_system.py +++ b/litellm/litellm_core_utils/prompt_templates/mid_conversation_system.py @@ -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, ) diff --git a/litellm/llms/anthropic/common_utils.py b/litellm/llms/anthropic/common_utils.py index 47a82aa097c..aca93fd1c13 100644 --- a/litellm/llms/anthropic/common_utils.py +++ b/litellm/llms/anthropic/common_utils.py @@ -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, diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index b3002fafc45..24e60fed232 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 Microsoft Foundry runs tool search for Claude on both Anthropic-hosted and Azure-hosted deployments.", "model_info": { "supports_tool_search": true } diff --git a/litellm/responses/litellm_completion_transformation/tool_search.py b/litellm/responses/litellm_completion_transformation/tool_search.py index e17aec939fc..b1e865f5f09 100644 --- a/litellm/responses/litellm_completion_transformation/tool_search.py +++ b/litellm/responses/litellm_completion_transformation/tool_search.py @@ -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 diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index b3002fafc45..24e60fed232 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 Microsoft Foundry runs tool search for Claude on both Anthropic-hosted and Azure-hosted deployments.", "model_info": { "supports_tool_search": true } diff --git a/tests/integration/providers/test_responses_bridge_tool_search_undeferred_wire.py b/tests/integration/providers/test_responses_bridge_tool_search_undeferred_wire.py index c43194100cc..3da86285e3d 100644 --- a/tests/integration/providers/test_responses_bridge_tool_search_undeferred_wire.py +++ b/tests/integration/providers/test_responses_bridge_tool_search_undeferred_wire.py @@ -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:]] == [ diff --git a/tests/unit/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py b/tests/unit/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py index 14fea46516e..e0abc14c6bd 100644 --- a/tests/unit/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py +++ b/tests/unit/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py @@ -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"}, diff --git a/tests/unit/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_mid_conversation_system.py b/tests/unit/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_mid_conversation_system.py index 9e3704a9df0..912b4687e62 100644 --- a/tests/unit/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_mid_conversation_system.py +++ b/tests/unit/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_mid_conversation_system.py @@ -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", ], ) diff --git a/tests/unit/litellm_core_utils/test_fallback_generalizations.py b/tests/unit/litellm_core_utils/test_fallback_generalizations.py index 31db7d6f30c..7807c438503 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, 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 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..e942675e753 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 @@ -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"},