diff --git a/litellm/llms/anthropic/chat/handler.py b/litellm/llms/anthropic/chat/handler.py index 8a82ed98991..620bdcf3e58 100644 --- a/litellm/llms/anthropic/chat/handler.py +++ b/litellm/llms/anthropic/chat/handler.py @@ -69,7 +69,12 @@ from litellm.types.utils import ( ) from ...base import BaseLLM -from ..common_utils import AnthropicError, process_anthropic_headers, refusal_provider_specific_fields +from ..common_utils import ( + AnthropicError, + process_anthropic_headers, + refusal_provider_specific_fields, + tool_search_content_renamed, +) from .transformation import ANTHROPIC_TOOL_NAME_REVERSE_MAP_KEY, AnthropicConfig, JsonModeRequest if TYPE_CHECKING: @@ -1080,14 +1085,22 @@ class ModelResponseIterator: # Fixes: https://github.com/BerriAI/litellm/issues/18137 self.web_search_results.append(content_block_start["content_block"]) provider_specific_fields["web_search_results"] = self.web_search_results + elif content_type == "tool_search_tool_result": + tool_search_result: Final = content_block_start["content_block"] + self.tool_results.append( + { + **tool_search_result, + "content": tool_search_content_renamed( + tool_search_result.get("content"), self.tool_name_reverse_map + ), + } + ) + provider_specific_fields["tool_results"] = self.tool_results else: self.tool_results.append(content_block_start["content_block"]) provider_specific_fields["tool_results"] = self.tool_results - if content_type != "tool_search_tool_result": - # Convert to provider-neutral code_interpreter_results - provider_specific_fields["code_interpreter_results"] = ( - self._build_code_interpreter_results() - ) + # Convert to provider-neutral code_interpreter_results + provider_specific_fields["code_interpreter_results"] = self._build_code_interpreter_results() elif type_chunk == "content_block_stop": ContentBlockStop(**chunk) diff --git a/litellm/llms/anthropic/chat/transformation.py b/litellm/llms/anthropic/chat/transformation.py index 156d4cfd49d..05ef6acaeee 100644 --- a/litellm/llms/anthropic/chat/transformation.py +++ b/litellm/llms/anthropic/chat/transformation.py @@ -24,7 +24,7 @@ from litellm.constants import ( DEFAULT_REASONING_EFFORT_XHIGH_THINKING_BUDGET, RESPONSE_FORMAT_TOOL_NAME, ) -from litellm.litellm_core_utils.core_helpers import drops_unsupported_params, map_finish_reason +from litellm.litellm_core_utils.core_helpers import as_str_mapping, drops_unsupported_params, map_finish_reason from litellm.litellm_core_utils.prompt_templates.common_utils import ( DEFS_MAX_INLINED_BYTES, is_object_schema, @@ -45,6 +45,8 @@ from litellm.litellm_core_utils.prompt_templates.image_handling import ( inline_remote_image_urls, ) from litellm.litellm_core_utils.prompt_templates.mid_conversation_system import ( + message_field, + parts_of, place_mid_conversation_system, split_leading_system_run, ) @@ -127,6 +129,8 @@ from ..common_utils import ( refusal_provider_specific_fields, requires_native_compaction_beta, strip_advisor_blocks_from_messages, + tool_search_content_renamed, + tool_search_result_renamed, tools_marked_for_a_tool_config_point, without_stale_tool_search_results, ) @@ -269,6 +273,25 @@ class _AnthropicToolResultBlock(TypedDict, total=False): content: ReadOnly[object] +def _provider_fields_with_tool_names(message: object, forward_map: Mapping[str, str]) -> Mapping[str, object] | None: + provider_fields: Final = as_str_mapping(message_field(message, "provider_specific_fields")) + replayed_results: Final = parts_of(provider_fields.get("tool_results")) if provider_fields is not None else () + if provider_fields is None or not replayed_results: + return None + return { + **provider_fields, + "tool_results": [tool_search_result_renamed(result, forward_map) for result in replayed_results], + } + + +def _tool_result_with_caller_names( + result: _AnthropicToolResultBlock, reverse_map: Mapping[str, str] +) -> _AnthropicToolResultBlock: + if result.get("type") != "tool_search_tool_result": + return result + return {**result, "content": tool_search_content_renamed(result.get("content"), reverse_map)} + + _ENUM_TYPE_CHECKS: Final[Mapping[object, Callable[[object], bool]]] = MappingProxyType( { "null": lambda v: v is None, @@ -1307,8 +1330,9 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): messages: list[AllMessageValues], name_forward_map: dict[str, str], ) -> list[AllMessageValues]: - """Return a copy of `messages` with tool_call/function_call names - rewritten using the per-request forward map. + """Return a copy of `messages` with tool_call/function_call names, and + the tools a replayed tool search found, rewritten using the per-request + forward map. Only mutates messages whose tool_call/function_call name is *in* the forward map. Names absent from the map (already valid, no collision) @@ -1350,6 +1374,9 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): new_fc = dict(function_call) new_fc["name"] = name_forward_map[fc_name] new_msg["function_call"] = new_fc + renamed_provider_fields = _provider_fields_with_tool_names(msg, name_forward_map) + if renamed_provider_fields is not None: + new_msg["provider_specific_fields"] = renamed_provider_fields new_messages.append(cast(AllMessageValues, new_msg)) return new_messages @@ -3021,6 +3048,11 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): _name = fn.get("name") if isinstance(_name, str) and _name in tool_name_reverse_map: fn["name"] = tool_name_reverse_map[_name] + caller_tool_results: Final = ( + [_tool_result_with_caller_names(result, tool_name_reverse_map) for result in tool_results] + if tool_name_reverse_map and tool_results + else tool_results + ) if ( prefix_prompt is not None @@ -3034,7 +3066,7 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): citations, thinking_blocks, web_search_results, - tool_results, + caller_tool_results, compaction_blocks, tool_calls, ) diff --git a/litellm/llms/anthropic/common_utils.py b/litellm/llms/anthropic/common_utils.py index 5be6219bc9c..61b16c3cb47 100644 --- a/litellm/llms/anthropic/common_utils.py +++ b/litellm/llms/anthropic/common_utils.py @@ -24,6 +24,7 @@ from litellm.constants import ( DEFAULT_REASONING_EFFORT_XHIGH_THINKING_BUDGET, ) from litellm.exceptions import UnsupportedParamsError +from litellm.litellm_core_utils.core_helpers import as_str_mapping from litellm.litellm_core_utils.prompt_templates.common_utils import ( get_file_ids_from_messages, is_encrypted_reasoning_block, @@ -1751,6 +1752,29 @@ def _without_stale_tool_search_blocks( return {**message, "content": kept} if kept else None +def _tool_reference_renamed(reference: object, name_map: Mapping[str, str]) -> object: + fields: Final = as_str_mapping(reference) + name: Final = fields.get("tool_name") if fields is not None else None + if fields is None or not isinstance(name, str) or name not in name_map: + return reference + return {**fields, "tool_name": name_map[name]} + + +def tool_search_content_renamed(content: object, name_map: Mapping[str, str]) -> object: + fields: Final = as_str_mapping(content) + if fields is None or "tool_references" not in fields: + return content + references: Final = parts_of(fields["tool_references"]) + return {**fields, "tool_references": [_tool_reference_renamed(reference, name_map) for reference in references]} + + +def tool_search_result_renamed(block: object, name_map: Mapping[str, str]) -> object: + fields: Final = as_str_mapping(block) + if fields is None or fields.get("type") != "tool_search_tool_result": + return block + return {**fields, "content": tool_search_content_renamed(fields.get("content"), name_map)} + + def without_stale_tool_search_results( messages: Sequence[AllAnthropicPassThroughMessageValues], tools: Sequence[object] ) -> tuple[AllAnthropicPassThroughMessageValues, ...]: diff --git a/tests/integration/providers/test_anthropic_tool_search_result_matrix_wire.py b/tests/integration/providers/test_anthropic_tool_search_result_matrix_wire.py index 6879d1f8acd..f1d25ac90f7 100644 --- a/tests/integration/providers/test_anthropic_tool_search_result_matrix_wire.py +++ b/tests/integration/providers/test_anthropic_tool_search_result_matrix_wire.py @@ -421,37 +421,35 @@ def test_odd_string_tool_answer_ids_keep_the_search_rebuilt(gateway: Gateway, to assert SEARCH_ID not in forwarded, received[0].body -_ODD_RESULTS: Final = ( - pytest.param( - { - "type": "tool_search_tool_result", - "tool_use_id": SEARCH_ID, - "content": {"type": "tool_search_tool_result_error", "error_code": "unavailable"}, - }, - id="error-content", - ), - pytest.param( - { - "type": "tool_search_tool_result", - "tool_use_id": SEARCH_ID, - "content": { - "type": "tool_search_tool_search_result", - "tool_references": [{"type": "tool_reference", "tool_name": "w" * 5120}], - }, - }, - id="5kb-reference", - ), -) - - -@pytest.mark.parametrize("odd", _ODD_RESULTS) @_STREAM_MODES -def test_unusual_search_results_from_the_upstream_are_kept_and_replayed_verbatim( - gateway: Gateway, odd: Event, stream: bool +def test_an_errored_search_result_from_the_upstream_is_kept_and_replayed_verbatim( + gateway: Gateway, stream: bool ) -> None: - assistant, _, _, received, _ = _round_trip(gateway, "anthropic", stream, (SEARCH_USE, odd, WEATHER_USE)) - assert kept_results(assistant) == [odd], assistant - assert wire_assistant(received[1]) == [SEARCH_USE, odd, WEATHER_USE], received[1].body + errored: Final[Event] = { + "type": "tool_search_tool_result", + "tool_use_id": SEARCH_ID, + "content": {"type": "tool_search_tool_result_error", "error_code": "unavailable"}, + } + assistant, _, _, received, _ = _round_trip(gateway, "anthropic", stream, (SEARCH_USE, errored, WEATHER_USE)) + assert kept_results(assistant) == [errored], assistant + assert wire_assistant(received[1]) == [SEARCH_USE, errored, WEATHER_USE], received[1].body + + +@_STREAM_MODES +def test_a_search_result_naming_a_tool_the_request_never_sent_is_kept_but_left_out_of_the_replay( + gateway: Gateway, stream: bool +) -> None: + unknown_tool: Final[Event] = { + "type": "tool_search_tool_result", + "tool_use_id": SEARCH_ID, + "content": { + "type": "tool_search_tool_search_result", + "tool_references": [{"type": "tool_reference", "tool_name": "w" * 5120}], + }, + } + assistant, _, _, received, _ = _round_trip(gateway, "anthropic", stream, (SEARCH_USE, unknown_tool, WEATHER_USE)) + assert kept_results(assistant) == [unknown_tool], assistant + assert wire_assistant(received[1]) == [WEATHER_USE], received[1].body @_STREAM_MODES diff --git a/tests/integration/providers/test_responses_bridge_tool_search_wire.py b/tests/integration/providers/test_responses_bridge_tool_search_wire.py index 6c38583c1f4..13ec1e26269 100644 --- a/tests/integration/providers/test_responses_bridge_tool_search_wire.py +++ b/tests/integration/providers/test_responses_bridge_tool_search_wire.py @@ -153,18 +153,18 @@ def _prompt(marker: str) -> str: return f"Weather in Paris and the ACME price marker-{marker}" -def _search_result(marker: str) -> dict[str, JsonValue]: +def _search_result(marker: str, found_tool: str = "get_weather") -> dict[str, JsonValue]: return { "type": "tool_search_tool_result", "tool_use_id": f"srvtoolu_{marker}", "content": { "type": "tool_search_tool_search_result", - "tool_references": [{"type": "tool_reference", "tool_name": "get_weather"}], + "tool_references": [{"type": "tool_reference", "tool_name": found_tool}], }, } -def _search_blocks(marker: str) -> tuple[dict[str, JsonValue], ...]: +def _search_blocks(marker: str, found_tool: str = "get_weather") -> tuple[dict[str, JsonValue], ...]: return ( { "type": "server_tool_use", @@ -172,12 +172,12 @@ def _search_blocks(marker: str) -> tuple[dict[str, JsonValue], ...]: "name": _REGEX_TOOL_NAME, "input": json.loads(_SEARCH_ARGUMENTS), }, - _search_result(marker), + _search_result(marker, found_tool), ) -def _message(marker: str, searched: bool, tool_use_name: str) -> dict[str, JsonValue]: - search_blocks: Final = _search_blocks(marker) if searched else () +def _message(marker: str, searched: bool, tool_use_name: str, found_tool: str = "get_weather") -> dict[str, JsonValue]: + search_blocks: Final = _search_blocks(marker, found_tool) if searched else () return { "id": f"msg_{marker}", "type": "message", @@ -194,7 +194,7 @@ def _message(marker: str, searched: bool, tool_use_name: str) -> dict[str, JsonV } -def _search_events(marker: str, search_arguments: str) -> tuple[Event, ...]: +def _search_events(marker: str, search_arguments: str, found_tool: str) -> tuple[Event, ...]: half: Final = len(search_arguments) // 2 opened: Final[Event] = { "type": "server_tool_use", @@ -215,14 +215,16 @@ def _search_events(marker: str, search_arguments: str) -> tuple[Event, ...]: "delta": {"type": "input_json_delta", "partial_json": search_arguments[half:]}, }, {"type": "content_block_stop", "index": 1}, - {"type": "content_block_start", "index": 2, "content_block": _search_result(marker)}, + {"type": "content_block_start", "index": 2, "content_block": _search_result(marker, found_tool)}, {"type": "content_block_stop", "index": 2}, ) -def _events(marker: str, search_arguments: str, searched: bool, tool_use_name: str) -> tuple[Event, ...]: +def _events( + marker: str, search_arguments: str, searched: bool, tool_use_name: str, found_tool: str = "get_weather" +) -> tuple[Event, ...]: opened: Final = {**_message(marker, searched, tool_use_name), "content": [], "stop_reason": None} - search_events: Final = _search_events(marker, search_arguments) if searched else () + search_events: Final = _search_events(marker, search_arguments, found_tool) if searched else () tool_index: Final = 3 if searched else 1 tool_use: Final[Event] = {"type": "tool_use", "id": f"toolu_{marker}", "name": tool_use_name, "input": {}} return ( @@ -266,6 +268,7 @@ class _Vendor: pause: float = 0 searched: bool | None = None tool_use_name: str = "get_weather" + found_tool: str = "get_weather" error: tuple[int, str] | None = None def respond(self, request: Request) -> Reply: @@ -284,15 +287,17 @@ class _Vendor: return _anthropic_error(400, f"tools: {_ALL_DEFERRED_ERROR} marker-{marker}") searched: Final = bool(regex_tools) if self.searched is None else self.searched if request.target.endswith("/invoke-with-response-stream"): - events: Final = _events(marker, self.search_arguments, searched, self.tool_use_name) + events: Final = _events(marker, self.search_arguments, searched, self.tool_use_name, self.found_tool) return Reply(content_type=EVENT_STREAM, chunks=aws_chunks(events), pause_between_chunks=self.pause) if _streams(request, body): return Reply( content_type="text/event-stream", - chunks=sse_chunks(_events(marker, self.search_arguments, searched, self.tool_use_name)), + chunks=sse_chunks( + _events(marker, self.search_arguments, searched, self.tool_use_name, self.found_tool) + ), pause_between_chunks=self.pause, ) - return Reply(body=json.dumps(_message(marker, searched, self.tool_use_name)).encode()) + return Reply(body=json.dumps(_message(marker, searched, self.tool_use_name, self.found_tool)).encode()) def _base_url(gateway: Gateway) -> str: @@ -693,8 +698,10 @@ def test_search_arguments_that_never_parse_stream_an_incomplete_tool_search_call tools: Final = (_tool_search(), _function_tool()) answer: Final = _raw_answer(gateway, _responses_body(model, marker, tools, True)) _assert_search_call_events(answer.events, marker, "incomplete") - search, function = _tool_items(answer.payload) + search, search_output, function = _tool_items(answer.payload) assert (search["type"], search["status"], search["arguments"]) == ("tool_search_call", "incomplete", {}), search + assert search_output["type"] == "tool_search_output", search_output + assert search_output["call_id"] == search["call_id"], (search, search_output) assert (function["type"], function["name"]) == ("function_call", "get_weather"), function request, body = _only_request(wire) _assert_anthropic_wire(request, body, ("get_weather",)) @@ -996,6 +1003,49 @@ def test_a_replayed_tool_search_whose_tool_is_no_longer_sent_is_left_out(gateway _assert_only_the_function_call_is_replayed(_ITEMS.validate_python(sent["messages"]), marker) +@pytest.mark.parametrize("stream", [False, True], ids=["sync", "stream"]) +def test_a_tool_name_anthropic_rewrites_comes_back_and_is_replayed_under_the_callers_name( + gateway: Gateway, stream: bool +) -> None: + marker: Final = uuid.uuid4().hex + vendor: Final = _Vendor(tool_use_name="weather_get", found_tool="weather_get") + tools: Final = (_tool_search(), {**_function_tool(), "name": "weather.get"}) + with wire_server(vendor.respond) as wire, gateway.scenario() as scenario: + model: Final = _deployment(scenario, "anthropic", wire.url, gateway.upstream_url) + items: Final = _tool_items(_raw_answer(gateway, _responses_body(model, marker, tools, stream)).payload) + _only_request(wire) + assert [(item["type"], item.get("name")) for item in items] == [ + ("tool_search_call", None), + ("tool_search_output", None), + ("function_call", "weather.get"), + ], items + assert [tool["name"] for tool in _ITEMS.validate_python(items[1]["tools"])] == ["weather.get"], items[1] + replay: Final = _raw( + gateway, + "/v1/responses", + { + "model": model, + "store": False, + "input": [ + {"role": "user", "content": _prompt(marker)}, + *items, + {"type": "function_call_output", "call_id": f"toolu_{marker}", "output": "22C"}, + {"role": "user", "content": f"And tomorrow? marker-{marker}"}, + ], + "tools": [dict(tool) for tool in tools], + **_CACHE_BUST, + }, + ) + assert replay.status_code == 200, replay.text + _, sent = _only_request(wire) + (assistant,) = [turn for turn in _ITEMS.validate_python(sent["messages"]) if turn["role"] == "assistant"] + blocks: Final = _ITEMS.validate_python(assistant["content"]) + assert [block for block in blocks if block["type"] != "text"] == [ + *_search_blocks(marker, "weather_get"), + {"type": "tool_use", "id": f"toolu_{marker}", "name": "weather_get", "input": {"city": "Paris"}}, + ], blocks + + def test_chat_completions_drop_a_replayed_search_call_that_has_no_result(gateway: Gateway) -> None: marker: Final = uuid.uuid4().hex with wire_server(_Vendor().respond) as wire, gateway.scenario() as scenario: 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 ebf2995311f..72cf1a77a34 100644 --- a/tests/unit/llms/anthropic/chat/test_anthropic_chat_transformation.py +++ b/tests/unit/llms/anthropic/chat/test_anthropic_chat_transformation.py @@ -3633,7 +3633,9 @@ def _tool_search_turn(tool_references: list[str], last_block: dict[str, object]) ] -def _parsed_assistant_message(content: list[dict[str, object]], stop_reason: str): +def _parsed_assistant_message( + content: list[dict[str, object]], stop_reason: str, tool_name_reverse_map: dict[str, str] | None = None +): anthropic_response: Final = { "id": "msg_01XYZ", "type": "message", @@ -3656,6 +3658,7 @@ def _parsed_assistant_message(content: list[dict[str, object]], stop_reason: str model_response=ModelResponse(), json_mode=False, prefix_prompt=None, + tool_name_reverse_map=tool_name_reverse_map, ) .choices[0] .message @@ -9910,3 +9913,51 @@ def test_transform_request_tool_config_point_without_tools_drops_the_key(): assert "cache_control_injection_points" not in data assert "tools" not in data assert litellm_params["metadata"] == {} + + +def test_a_renamed_tool_leaves_tool_results_without_tool_references_as_claude_sent_them(): + errored_search: Final = { + "type": "tool_search_tool_result", + "tool_use_id": "srvtoolu_01Search", + "content": {"type": "tool_search_tool_result_error", "error_code": "unavailable"}, + } + results: Final = [_CODE_EXECUTION_BLOCKS[1], errored_search] + message: Final = _parsed_assistant_message( + [ + *_CODE_EXECUTION_BLOCKS, + _tool_search_turn([], _WEATHER_CALL)[0], + errored_search, + {**_WEATHER_CALL, "name": "weather_get"}, + ], + "tool_use", + {"weather_get": "weather.get"}, + ) + config: Final = AnthropicConfig() + + request: Final = config.transform_request( + model="claude-opus-5-5", + messages=[ + {"role": "user", "content": "What is the weather in Paris?"}, + message.model_dump(), + {"role": "tool", "tool_call_id": "toolu_01Weather", "content": "18C"}, + ], + optional_params=config.map_openai_params( + non_default_params={ + "tools": [ + {"type": "tool_search_tool_regex_20251119", "name": "tool_search_tool_regex"}, + _function_tool("weather.get"), + ] + }, + optional_params={}, + model="claude-opus-5-5", + drop_params=False, + ), + litellm_params={}, + headers={}, + ) + + assert message.provider_specific_fields["tool_results"] == results + assert message.tool_calls[-1].function.name == "weather.get" + sent: Final = request["messages"][1]["content"] + assert [block for block in sent if block["type"].endswith("_tool_result")] == results + assert [block["name"] for block in sent if block["type"] == "tool_use"] == ["weather_get"] diff --git a/tests/unit/responses/litellm_completion_transformation/test_anthropic_responses_bridge.py b/tests/unit/responses/litellm_completion_transformation/test_anthropic_responses_bridge.py index c77b3e06e56..44132c7a56a 100644 --- a/tests/unit/responses/litellm_completion_transformation/test_anthropic_responses_bridge.py +++ b/tests/unit/responses/litellm_completion_transformation/test_anthropic_responses_bridge.py @@ -1598,20 +1598,39 @@ _WEATHER_USE: Final = {"type": "tool_use", "id": "toolu_01Weather", "name": "get _WEATHER_ANSWER: Final = {"type": "function_call_output", "call_id": "toolu_01Weather", "output": "18C and sunny"} +_TOOL_NAMES_ON_EACH_SIDE: Final = pytest.mark.parametrize( + ("caller_name", "claude_name"), + [("get_weather", "get_weather"), ("weather.get", "weather_get")], + ids=["valid_name", "name_anthropic_rewrites"], +) + + +def _search_result_finding(tool_name: str) -> Mapping[str, object]: + return { + **_SEARCH_RESULT, + "content": { + "type": "tool_search_tool_search_result", + "tool_references": [{"type": "tool_reference", "tool_name": tool_name}], + }, + } + + +@_TOOL_NAMES_ON_EACH_SIDE def test_responses_over_anthropic_return_the_tool_search_output_and_send_the_search_back_without_a_stored_turn( - respx_mock: respx.MockRouter, + respx_mock: respx.MockRouter, caller_name: str, claude_name: str ) -> None: + tools: Final = [{"type": "tool_search"}, {**_WEATHER_TOOL, "name": caller_name}] + search_result: Final = _search_result_finding(claude_name) + weather_use: Final = {**_WEATHER_USE, "name": claude_name} route: Final = respx_mock.post(_ANTHROPIC_MESSAGES_URL).mock( - return_value=_anthropic_message(_SEARCH_USE, _SEARCH_RESULT, _WEATHER_USE) + return_value=_anthropic_message(_SEARCH_USE, search_result, weather_use) ) - first: Final = litellm.responses( - model=_MODEL, input=list(_WEATHER_QUESTION), tools=list(_SEARCHABLE_TOOLS), api_key="mock_api_key" - ) + first: Final = litellm.responses(model=_MODEL, input=list(_WEATHER_QUESTION), tools=tools, api_key="mock_api_key") litellm.responses( model=_MODEL, input=[*_WEATHER_QUESTION, *(item.model_dump(exclude_none=True) for item in first.output), _WEATHER_ANSWER], - tools=list(_SEARCHABLE_TOOLS), + tools=tools, api_key="mock_api_key", ) @@ -1619,8 +1638,9 @@ def test_responses_over_anthropic_return_the_tool_search_output_and_send_the_sea search_output: Final = first.output[1] assert (search_output.id, search_output.call_id) == ("tso_srvtoolu_01Search", "srvtoolu_01Search") assert (search_output.execution, search_output.status) == ("server", "completed") - assert [dict(tool) for tool in search_output.tools] == [_WEATHER_TOOL] - assert _sent_assistant_content(route) == [_SEARCH_USE, _SEARCH_RESULT, _WEATHER_USE] + assert [dict(tool) for tool in search_output.tools] == tools[1:] + assert first.output[2].name == caller_name + assert _sent_assistant_content(route) == [_SEARCH_USE, search_result, weather_use] def _tool_search_stream(*events: Mapping[str, object]) -> httpx.Response: @@ -1658,22 +1678,22 @@ def _streamed_tool_use(index: int, block: Mapping[str, object]) -> tuple[Mapping ) +@_TOOL_NAMES_ON_EACH_SIDE def test_streamed_responses_over_anthropic_emit_the_tool_search_output_in_order_at_its_completed_position( - respx_mock: respx.MockRouter, + respx_mock: respx.MockRouter, caller_name: str, claude_name: str ) -> None: + tools: Final = [{"type": "tool_search"}, {**_WEATHER_TOOL, "name": caller_name}] respx_mock.post(_ANTHROPIC_MESSAGES_URL).mock( return_value=_tool_search_stream( *_streamed_tool_use(0, _SEARCH_USE), - {"type": "content_block_start", "index": 1, "content_block": _SEARCH_RESULT}, + {"type": "content_block_start", "index": 1, "content_block": _search_result_finding(claude_name)}, {"type": "content_block_stop", "index": 1}, - *_streamed_tool_use(2, _WEATHER_USE), + *_streamed_tool_use(2, {**_WEATHER_USE, "name": claude_name}), ) ) events: Final = tuple( - litellm.responses( - model=_MODEL, input=list(_WEATHER_QUESTION), tools=list(_SEARCHABLE_TOOLS), stream=True, api_key="mock" - ) + litellm.responses(model=_MODEL, input=list(_WEATHER_QUESTION), tools=tools, stream=True, api_key="mock") ) completed: Final = events[-1].response.output @@ -1696,7 +1716,8 @@ def test_streamed_responses_over_anthropic_emit_the_tool_search_output_in_order_ search_output: Final = next(event.item for event in done if event.item.type == "tool_search_output") assert search_output.model_dump() == completed[1].model_dump() assert (search_output.call_id, search_output.status) == ("srvtoolu_01Search", "completed") - assert [dict(tool) for tool in search_output.tools] == [_WEATHER_TOOL] + assert [dict(tool) for tool in search_output.tools] == tools[1:] + assert completed[2].name == caller_name _STATELESS_SEARCH_TURN: Final = {