fix(anthropic): prevent duplicate tool_result blocks with same (#17632)

tool_use_id
This commit is contained in:
Kevin Marx 2025-12-08 01:24:58 -06:00 • committed by GitHub
parent d8ac213c6a
commit 0650b5e80d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 235 additions and 23 deletions

View file

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

View file

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