diff --git a/litellm/llms/anthropic/messages/responses_adapters/transformation.py b/litellm/llms/anthropic/messages/responses_adapters/transformation.py index 110e9fc40ff..40e335dfe17 100644 --- a/litellm/llms/anthropic/messages/responses_adapters/transformation.py +++ b/litellm/llms/anthropic/messages/responses_adapters/transformation.py @@ -25,7 +25,7 @@ from litellm.litellm_core_utils.prompt_templates.reasoning_utils import ( from litellm.litellm_core_utils.reasoning_effort_utils import ( reasoning_effort_from_thinking, ) -from litellm.llms.anthropic.messages.convertible_blocks import tool_result_text_parts +from litellm.llms.anthropic.messages.convertible_blocks import tool_result_text_part from litellm.llms.anthropic.messages.native.utils import ( refusal_stop_details, responses_output_refusal_text, @@ -60,6 +60,22 @@ REASONING_SUMMARY_PART_SEPARATOR: Final = "\n\n" RESPONSES_INCLUDE_ENCRYPTED_REASONING: Final = "reasoning.encrypted_content" +def _responses_tool_result_part(block: object) -> str | None: + match block: + case {"type": "tool_reference", "tool_name": str(tool_name)}: + return f"[Loaded tool: {tool_name}]" + case {"type": "tool_reference"}: + return "[Loaded tool: ]" + case _: + return tool_result_text_part(block) + + +def _responses_tool_result_text_parts(tool_result: Mapping[str, object]) -> tuple[str, ...]: + content: Final = tool_result.get("content") + parts: Final = map(_responses_tool_result_part, content if isinstance(content, list) else ()) + return tuple(part for part in parts if part is not None) + + class LiteLLMAnthropicToResponsesAPIAdapter: """ Converts Anthropic /v1/messages requests to OpenAI Responses API format and @@ -387,7 +403,7 @@ class LiteLLMAnthropicToResponsesAPIAdapter: elif isinstance(inner, str): output_text = inner elif isinstance(inner, list): - output_text = "\n".join(tool_result_text_parts(tool_result_block)) + output_text = "\n".join(_responses_tool_result_text_parts(tool_result_block)) if covered_image_parts or later_image_parts: output_text = ( f"{output_text}\n{TOOL_RESULT_IMAGE_PLACEHOLDER}" diff --git a/tests/unit/llms/anthropic/messages/responses_adapters/test_responses_adapters_transformation.py b/tests/unit/llms/anthropic/messages/responses_adapters/test_responses_adapters_transformation.py index dbb8c925fa0..079f974e439 100644 --- a/tests/unit/llms/anthropic/messages/responses_adapters/test_responses_adapters_transformation.py +++ b/tests/unit/llms/anthropic/messages/responses_adapters/test_responses_adapters_transformation.py @@ -490,6 +490,33 @@ class TestTranslateMessagesToResponsesInput: result = _translate_messages(messages) assert result[0]["output"] == "Line 1\nLine 2" + def test_user_tool_result_list_content_preserves_tool_references(self): + messages = [ + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "call_search", + "content": [ + {"type": "text", "text": "Available:"}, + {"type": "tool_reference", "tool_name": "get_weather"}, + ], + } + ], + } + ] + + result = _translate_messages(messages) + + assert result == [ + { + "type": "function_call_output", + "call_id": "call_search", + "output": "Available:\n[Loaded tool: get_weather]", + } + ] + def test_user_tool_result_null_content(self): """tool_result with null content becomes empty string output.""" messages = [ diff --git a/tests/unit/llms/anthropic/messages/test_convertible_blocks.py b/tests/unit/llms/anthropic/messages/test_convertible_blocks.py index 5fb6cc32dfb..4bad6cfb754 100644 --- a/tests/unit/llms/anthropic/messages/test_convertible_blocks.py +++ b/tests/unit/llms/anthropic/messages/test_convertible_blocks.py @@ -136,7 +136,6 @@ _DROPPED_INSIDE_TOOL_RESULT: Final[tuple[tuple[Mapping[str, object], str, str], "text-notes", "document with a text source inside a tool_result", ), - ({"type": "tool_reference", "tool_name": "find_docs"}, "find_docs", "tool_reference inside a tool_result"), ) @@ -158,6 +157,22 @@ def test_tool_result_content_both_counters_drop_is_named(inner: Mapping[str, obj assert unconvertible_anthropic_blocks(_tool_turn(inner)) == (label,) +def test_tool_reference_count_guard_rejects_when_only_responses_preserves_it() -> None: + inner: Final = {"type": "tool_reference", "tool_name": "find_docs"} + gemini, openai = _sent_by_each_counter(inner) + + assert "find_docs" not in gemini + assert _JSON_OBJECTS.validate_json(openai) == [ + {"type": "function_call", "call_id": "toolu_01", "name": "lookup", "arguments": "{}"}, + {"type": "function_call_output", "call_id": "toolu_01", "output": "[Loaded tool: find_docs]"}, + ] + assert unconvertible_anthropic_blocks(_tool_turn(inner)) == ("tool_reference inside a tool_result",) + assert unconvertible_blocks_error(_tool_turn(inner), "anthropic") == ( + "count_tokens can't convert these content blocks for this model, so the count would leave them out: " + "tool_reference inside a tool_result" + ) + + def _replayed_web_search(encrypted_content: str) -> tuple[Mapping[str, object], ...]: return ( {"role": "user", "content": "What is new?"},