mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-14 09:03:16 +00:00
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:
parent
e610512b05
commit
5d56e180ca
7 changed files with 258 additions and 69 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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, ...]:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue