From 0650b5e80d21281c7be5513c49e1be772bd7b76e Mon Sep 17 00:00:00 2001 From: Kevin Marx <1192602+kevinmarx@users.noreply.github.com> Date: Mon, 8 Dec 2025 01:24:58 -0600 Subject: [PATCH] fix(anthropic): prevent duplicate tool_result blocks with same (#17632) tool_use_id --- .../adapters/transformation.py | 105 +++++++++--- ...al_pass_through_adapters_transformation.py | 153 +++++++++++++++++- 2 files changed, 235 insertions(+), 23 deletions(-) diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py index 98e57f279cf..a5eff2aa17d 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py @@ -130,16 +130,17 @@ class LiteLLMAnthropicMessagesAdapter: ### FOR [BETA] `/v1/messages` endpoint support - def _extract_signature_from_tool_call( - self, tool_call: Any - ) -> Optional[str]: + def _extract_signature_from_tool_call(self, tool_call: Any) -> Optional[str]: """ Extract signature from a tool call's provider_specific_fields. Only checks provider_specific_fields, not thinking blocks. """ signature = None - - if hasattr(tool_call, "provider_specific_fields") and tool_call.provider_specific_fields: + + if ( + hasattr(tool_call, "provider_specific_fields") + and tool_call.provider_specific_fields + ): if "thought_signature" in tool_call.provider_specific_fields: signature = tool_call.provider_specific_fields["thought_signature"] elif ( @@ -147,8 +148,10 @@ class LiteLLMAnthropicMessagesAdapter: and tool_call.function.provider_specific_fields ): if "thought_signature" in tool_call.function.provider_specific_fields: - signature = tool_call.function.provider_specific_fields["thought_signature"] - + signature = tool_call.function.provider_specific_fields[ + "thought_signature" + ] + return signature def _extract_signature_from_tool_use_content( @@ -162,7 +165,6 @@ class LiteLLMAnthropicMessagesAdapter: return provider_specific_fields.get("signature") return None - def translatable_anthropic_params(self) -> List: """ Which anthropic params, we need to translate to the openai format. @@ -231,7 +233,14 @@ class LiteLLMAnthropicMessagesAdapter: ) tool_message_list.append(tool_result) elif isinstance(content.get("content"), list): - for c in content.get("content", []): + # Combine all content items into a single tool message + # to avoid creating multiple tool_result blocks with the same ID + # (each tool_use must have exactly one tool_result) + content_items = content.get("content", []) + + # For single-item content, maintain backward compatibility with string/url format + if len(content_items) == 1: + c = content_items[0] if isinstance(c, str): tool_result = ChatCompletionToolMessage( role="tool", @@ -250,7 +259,6 @@ class LiteLLMAnthropicMessagesAdapter: ) tool_message_list.append(tool_result) elif c.get("type") == "image": - # Convert Anthropic image format to OpenAI format for tool results source = c.get("source", {}) openai_image_url = ( self._translate_anthropic_image_to_openai( @@ -258,7 +266,6 @@ class LiteLLMAnthropicMessagesAdapter: ) or "" ) - tool_result = ChatCompletionToolMessage( role="tool", tool_call_id=content.get( @@ -267,6 +274,55 @@ class LiteLLMAnthropicMessagesAdapter: content=openai_image_url, ) tool_message_list.append(tool_result) + else: + # For multiple content items, combine into a single tool message + # with list content to preserve all items while having one tool_use_id + combined_content_parts: List[ + Union[ + ChatCompletionTextObject, + ChatCompletionImageObject, + ] + ] = [] + for c in content_items: + if isinstance(c, str): + combined_content_parts.append( + ChatCompletionTextObject( + type="text", text=c + ) + ) + elif isinstance(c, dict): + if c.get("type") == "text": + combined_content_parts.append( + ChatCompletionTextObject( + type="text", + text=c.get("text", ""), + ) + ) + elif c.get("type") == "image": + source = c.get("source", {}) + openai_image_url = ( + self._translate_anthropic_image_to_openai( + source + ) + or "" + ) + if openai_image_url: + combined_content_parts.append( + ChatCompletionImageObject( + type="image_url", + image_url=ChatCompletionImageUrlObject( + url=openai_image_url + ), + ) + ) + # Create a single tool message with combined content + if combined_content_parts: + tool_result = ChatCompletionToolMessage( + role="tool", + tool_call_id=content.get("tool_use_id", ""), + content=combined_content_parts, # type: ignore + ) + tool_message_list.append(tool_result) if len(tool_message_list) > 0: new_messages.extend(tool_message_list) @@ -301,14 +357,23 @@ class LiteLLMAnthropicMessagesAdapter: "name": content.get("name", ""), "arguments": json.dumps(content.get("input", {})), } - signature = self._extract_signature_from_tool_use_content(content) - + signature = ( + self._extract_signature_from_tool_use_content( + content + ) + ) + if signature: provider_specific_fields: Dict[str, Any] = ( - function_chunk.get("provider_specific_fields") or {} + function_chunk.get("provider_specific_fields") + or {} + ) + provider_specific_fields["thought_signature"] = ( + signature + ) + function_chunk["provider_specific_fields"] = ( + provider_specific_fields ) - provider_specific_fields["thought_signature"] = signature - function_chunk["provider_specific_fields"] = provider_specific_fields tool_calls.append( ChatCompletionAssistantToolCall( @@ -556,11 +621,11 @@ class LiteLLMAnthropicMessagesAdapter: for tool_call in choice.message.tool_calls: # Extract signature from provider_specific_fields only signature = self._extract_signature_from_tool_call(tool_call) - + provider_specific_fields = {} if signature: provider_specific_fields["signature"] = signature - + tool_use_block = AnthropicResponseContentBlockToolUse( type="tool_use", id=tool_call.id, @@ -573,7 +638,9 @@ class LiteLLMAnthropicMessagesAdapter: ) # Add provider_specific_fields if signature is present if provider_specific_fields: - tool_use_block.provider_specific_fields = provider_specific_fields + tool_use_block.provider_specific_fields = ( + provider_specific_fields + ) new_content.append(tool_use_block) # Handle text content elif choice.message.content is not None: diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py index 04e901d7be9..c4b94481dfd 100644 --- a/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py @@ -794,9 +794,9 @@ def test_translate_anthropic_messages_to_openai_mixed_content_with_image(): def test_translate_anthropic_messages_to_openai_tool_use_with_signature(): """Test that thought signatures from tool_use blocks are correctly extracted and placed in provider_specific_fields.""" - + test_signature = "EpYECpMEAdHtim9iBECdK1l5uVIIXoZZmq+PUBH9nz3Q6EMeIdEqWwVb5GlxSNtxuSkFoseFco5U4zxN/lacJxD2WUjFvEyL2GOkbPgXFeCcgNBMEYVRg7UAr45KGeWJJmJMoheLHezKawI1L94vi2PsB9TDpWv4vyAx1vKG2PByiVmWWtd0rondsdbENNp2Rrz3ol1zha+XhOtyhTCdSWce8GVD/zElklL3C0h9HrsTQrnNyouaZa9KlXZJ72XDCIkIlV0m6EtxbzdMwbH4sLFOpifRlRn+AmzXjxvLovRtn2bXh/X3bUgPxqypaST57Dlpddlk1Mt0oJmGFtwB/FH1JmK21cIC06uXtlUc8lm/9cTQLd5hcEUX+XRrmTdzqxDgRttN8CRfVUAGE7Er+prN4yCIdNtEQdZm8zymEpHTkYplJ/hK7SMf9Iu1k+eCDFYCzvQuzLcJtNpRaGS1BbVA3va5JKrEu96G7a3Wl3DyzmrH8N3+RA+UIHvP6P5v93tI/eTyfMY54rKpLGkfFeeSMAr5aSoUZVYkvFI8xGEcIrqLWPDF91MclLZa7USSVql0wYu1G9KD10IkopeKkTIAl81WfoY5+Kw1o4CHo7bEQ6tfTuTB4IEywf1XKMBYHmsfAe5B9ferkLYtnAzzt1hoiK1m/2CjX8yQAknRLsnAuyeXfJZRZidVKYOKaSDftddbXJpIlJApC" - + anthropic_messages = [ AnthropicMessagesUserMessageParam( role="user", @@ -825,10 +825,155 @@ def test_translate_anthropic_messages_to_openai_tool_use_with_signature(): assert result[1]["role"] == "assistant" assert "tool_calls" in result[1] assert len(result[1]["tool_calls"]) == 1 - + # Verify thought signature is extracted and placed in provider_specific_fields tool_call = result[1]["tool_calls"][0] assert tool_call["id"] == "call_386f67af31f9415781bc35071405" assert "function" in tool_call assert "provider_specific_fields" in tool_call["function"] - assert tool_call["function"]["provider_specific_fields"]["thought_signature"] == test_signature + assert ( + tool_call["function"]["provider_specific_fields"]["thought_signature"] + == test_signature + ) + + +def test_translate_anthropic_messages_to_openai_tool_result_with_multiple_content_items(): + """ + Test that tool_result with multiple content items creates a single tool message + (not multiple messages with the same tool_call_id). + + This is a regression test for the bug: + "each tool_use must have a single result. Found multiple `tool_result` blocks with id" + + When a tool_result has a list of content items (e.g., text + image), we should create + ONE tool message with combined content, not multiple tool messages with the same ID. + """ + + anthropic_messages = [ + AnthropicMessagesUserMessageParam( + role="user", + content=[{"type": "text", "text": "Take a screenshot and describe it"}], + ), + AnthopicMessagesAssistantMessageParam( + role="assistant", + content=[ + { + "type": "tool_use", + "id": "toolu_016hYHBkTf4JDF3p22UoYk5C", + "name": "screenshot_tool", + "input": {}, + } + ], + ), + AnthropicMessagesUserMessageParam( + role="user", + content=[ + { + "type": "tool_result", + "tool_use_id": "toolu_016hYHBkTf4JDF3p22UoYk5C", + "content": [ + {"type": "text", "text": "Here is the screenshot:"}, + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/png", + "data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", + }, + }, + {"type": "text", "text": "Screenshot captured successfully."}, + ], + } + ], + ), + ] + + adapter = LiteLLMAnthropicMessagesAdapter() + result = adapter.translate_anthropic_messages_to_openai(messages=anthropic_messages) + + # Count how many tool messages have the same tool_call_id + tool_messages = [ + msg for msg in result if isinstance(msg, dict) and msg.get("role") == "tool" + ] + tool_call_ids = [msg.get("tool_call_id") for msg in tool_messages] + + # The critical assertion: each tool_call_id should appear only ONCE + assert len(tool_call_ids) == len(set(tool_call_ids)), ( + f"Bug: Found duplicate tool_call_ids! " + f"Each tool_use must have exactly one tool_result. " + f"tool_call_ids: {tool_call_ids}" + ) + + # There should be exactly one tool message + assert len(tool_messages) == 1, f"Expected 1 tool message, got {len(tool_messages)}" + + # The content should be a list with all items combined + tool_message = tool_messages[0] + assert tool_message["tool_call_id"] == "toolu_016hYHBkTf4JDF3p22UoYk5C" + assert isinstance( + tool_message["content"], list + ), "Multiple content items should be combined into a list" + assert ( + len(tool_message["content"]) == 3 + ), f"Expected 3 content items, got {len(tool_message['content'])}" + + # Verify content types + assert tool_message["content"][0]["type"] == "text" + assert tool_message["content"][0]["text"] == "Here is the screenshot:" + assert tool_message["content"][1]["type"] == "image_url" + assert tool_message["content"][2]["type"] == "text" + assert tool_message["content"][2]["text"] == "Screenshot captured successfully." + + +def test_translate_anthropic_messages_to_openai_tool_result_single_item_backward_compat(): + """ + Test that tool_result with a single content item maintains backward compatibility + by returning a string content (not a list). + """ + + anthropic_messages = [ + AnthropicMessagesUserMessageParam( + role="user", + content=[{"type": "text", "text": "Get the weather"}], + ), + AnthopicMessagesAssistantMessageParam( + role="assistant", + content=[ + { + "type": "tool_use", + "id": "toolu_single_item", + "name": "get_weather", + "input": {"location": "Boston"}, + } + ], + ), + AnthropicMessagesUserMessageParam( + role="user", + content=[ + { + "type": "tool_result", + "tool_use_id": "toolu_single_item", + "content": [ + {"type": "text", "text": "72°F and sunny"}, + ], + } + ], + ), + ] + + adapter = LiteLLMAnthropicMessagesAdapter() + result = adapter.translate_anthropic_messages_to_openai(messages=anthropic_messages) + + tool_messages = [ + msg for msg in result if isinstance(msg, dict) and msg.get("role") == "tool" + ] + + assert len(tool_messages) == 1 + tool_message = tool_messages[0] + + # Single item should be a string for backward compatibility + assert isinstance(tool_message["content"], str), ( + f"Single content item should be a string for backward compatibility, " + f"got {type(tool_message['content'])}" + ) + assert tool_message["content"] == "72°F and sunny"