mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-08 03:08:45 +00:00
fix(anthropic): prevent duplicate tool_result blocks with same (#17632)
tool_use_id
This commit is contained in:
parent
d8ac213c6a
commit
0650b5e80d
2 changed files with 235 additions and 23 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue