fix(anthropic): keep the caller's tool names in tool search results

A tool name Anthropic cannot take gets rewritten on the request, so Claude's
tool_search_tool_result named the rewritten tool. The response now maps those
tool references back to the caller's names, streamed and not, and a replayed
search result maps them forward again on the next turn.
This commit is contained in:
mateo-berri 2026-10-29 14:32:23 -07:00
parent e610512b05
commit 5d56e180ca
7 changed files with 258 additions and 69 deletions

View file

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

View file

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

View file

@ -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, ...]:

View file

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

View file

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

View file

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

View file

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