fix: never drop tool_result messages with unknown or empty content in anthropic adapter

This commit is contained in:
Yucheng Zhu 2026-08-25 10:59:38 -07:00
parent 31a67561ab
commit 98db5a1f27
2 changed files with 363 additions and 30 deletions

View file

@ -1,7 +1,7 @@
import copy
import hashlib
import json
from collections.abc import AsyncIterator, Iterator, Mapping
from collections.abc import AsyncIterator, Iterator, Mapping, Sequence
from typing import TYPE_CHECKING, Any, Final, Literal, TypeVar, cast
import litellm
@ -73,6 +73,7 @@ from litellm.litellm_core_utils.prompt_templates.factory import (
from litellm.litellm_core_utils.reasoning_effort_utils import (
reasoning_effort_from_thinking_budget,
)
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
from litellm.llms.anthropic.common_utils import normalize_anthropic_tool_use_id
from litellm.llms.anthropic.experimental_pass_through.context_management import (
PolyfillResult,
@ -437,34 +438,30 @@ class LiteLLMAnthropicMessagesAdapter:
# image becomes a structured image_url part
if len(content_items) == 1:
c = content_items[0]
single_content: str | Sequence[ChatCompletionImageObject]
if isinstance(c, str):
tool_result = ChatCompletionToolMessage(
role="tool",
tool_call_id=content.get("tool_use_id", ""),
content=c,
)
self._add_cache_control_if_applicable(content, tool_result, model)
tool_message_list.append(tool_result)
single_content = c
elif isinstance(c, dict):
if c.get("type") == "text":
tool_result = ChatCompletionToolMessage(
role="tool",
tool_call_id=content.get("tool_use_id", ""),
content=c.get("text", ""),
)
self._add_cache_control_if_applicable(content, tool_result, model)
tool_message_list.append(tool_result)
single_content = c.get("text", "")
elif c.get("type") == "image":
image_part = self._tool_result_image_part(c.get("source"))
tool_result = ChatCompletionToolMessage(
role="tool",
tool_call_id=content.get("tool_use_id", ""),
content=[image_part] # mutable-ok: content must be a json list
single_content = (
[image_part] # mutable-ok: content must be a json list
if image_part
else "",
else ""
)
self._add_cache_control_if_applicable(content, tool_result, model)
tool_message_list.append(tool_result)
else:
single_content = safe_dumps(c)
else:
single_content = safe_dumps(c)
tool_result = ChatCompletionToolMessage(
role="tool",
tool_call_id=content.get("tool_use_id", ""),
content=single_content,
)
self._add_cache_control_if_applicable(content, tool_result, model)
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
@ -486,15 +483,33 @@ class LiteLLMAnthropicMessagesAdapter:
image_part = self._tool_result_image_part(c.get("source"))
if image_part:
combined_content_parts.append(image_part)
else:
combined_content_parts.append(
ChatCompletionTextObject(type="text", text=safe_dumps(c))
)
else:
combined_content_parts.append(
ChatCompletionTextObject(type="text", text=safe_dumps(c))
)
# 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,
)
self._add_cache_control_if_applicable(content, tool_result, model)
tool_message_list.append(tool_result)
tool_result = ChatCompletionToolMessage(
role="tool",
tool_call_id=content.get("tool_use_id", ""),
content=combined_content_parts if combined_content_parts else "",
)
self._add_cache_control_if_applicable(content, tool_result, model)
tool_message_list.append(tool_result)
else:
raw_tool_result_content = content.get("content")
tool_result = ChatCompletionToolMessage(
role="tool",
tool_call_id=content.get("tool_use_id", ""),
content=""
if raw_tool_result_content is None
else safe_dumps(raw_tool_result_content),
)
self._add_cache_control_if_applicable(content, tool_result, model)
tool_message_list.append(tool_result)
if len(tool_message_list) > 0:
new_messages.extend(tool_message_list)

View file

@ -6,6 +6,7 @@ import litellm
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
from litellm.litellm_core_utils.prompt_templates.common_utils import (
TOOL_RESULT_IMAGE_PLACEHOLDER,
)
@ -3926,3 +3927,320 @@ def test_translate_anthropic_messages_to_openai_carries_midturn_system_prompt_ca
assert result == [
{"role": "system", "content": [{"type": "text", "text": "fix", "prompt_cache_breakpoint": explicit}]}
]
def test_translate_anthropic_messages_to_openai_tool_result_with_tool_reference():
"""Regression test for LIT-6103: a tool_result whose content is a single unknown
block type (e.g. tool_reference from Claude Code's ENABLE_TOOL_SEARCH) must still
emit a role:"tool" message instead of being silently dropped."""
tool_reference_block = {"type": "tool_reference", "tool_name": "WebFetch"}
anthropic_messages = [
AnthropicMessagesUserMessageParam(role="user", content=[{"type": "text", "text": "Load the WebFetch tool"}]),
AnthopicMessagesAssistantMessageParam(
role="assistant",
content=[
{
"type": "tool_use",
"id": "toolu_01PV7TQswAGFPMWK6Cbfkxtn",
"name": "tool_search",
"input": {"query": "select:WebFetch"},
}
],
),
AnthropicMessagesUserMessageParam(
role="user",
content=[
{
"type": "tool_result",
"tool_use_id": "toolu_01PV7TQswAGFPMWK6Cbfkxtn",
"content": [tool_reference_block],
}
],
),
]
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 was dropped for tool_reference content"
assert tool_messages[0]["tool_call_id"] == "toolu_01PV7TQswAGFPMWK6Cbfkxtn"
assert tool_messages[0]["content"] == safe_dumps({"type": "tool_reference", "tool_name": "WebFetch"})
def test_translate_anthropic_messages_to_openai_tool_result_tool_reference_with_sibling_text():
"""Regression test for LIT-6103: with a sibling text part next to the tool_result,
dropping the tool_result leaves an assistant tool_use answered only by a user text
message, which Anthropic rejects with a 400. The tool message must be emitted and
placed before the user message."""
anthropic_messages = [
AnthropicMessagesUserMessageParam(role="user", content=[{"type": "text", "text": "Load the WebFetch tool"}]),
AnthopicMessagesAssistantMessageParam(
role="assistant",
content=[
{
"type": "tool_use",
"id": "toolu_01PV7TQswAGFPMWK6Cbfkxtn",
"name": "tool_search",
"input": {"query": "select:WebFetch"},
}
],
),
AnthropicMessagesUserMessageParam(
role="user",
content=[
{
"type": "tool_result",
"tool_use_id": "toolu_01PV7TQswAGFPMWK6Cbfkxtn",
"content": [{"type": "tool_reference", "tool_name": "WebFetch"}],
},
{"type": "text", "text": "Now fetch the page"},
],
),
]
adapter = LiteLLMAnthropicMessagesAdapter()
result = adapter.translate_anthropic_messages_to_openai(messages=anthropic_messages)
tool_message_idx = None
user_message_idx = None
for i, msg in enumerate(result):
if isinstance(msg, dict) and msg.get("role") == "tool":
tool_message_idx = i
elif (
isinstance(msg, dict) and msg.get("role") == "user" and "Now fetch the page" in str(msg.get("content", ""))
):
user_message_idx = i
assert tool_message_idx is not None, "Tool message was dropped, orphaning the tool_use"
assert user_message_idx is not None, "Sibling text user message not found"
assert tool_message_idx < user_message_idx, "Tool message must precede the user message"
def test_translate_anthropic_messages_to_openai_tool_result_mixed_unknown_and_text():
"""Regression test for LIT-6103: unknown block types mixed with text in a multi-item
tool_result content list must be JSON-serialized into text parts, not dropped."""
tool_reference_block = {"type": "tool_reference", "tool_name": "WebFetch"}
search_result_block = {"type": "search_result", "title": "docs", "source": "https://example.com"}
anthropic_messages = [
AnthropicMessagesUserMessageParam(role="user", content=[{"type": "text", "text": "Search and load tools"}]),
AnthopicMessagesAssistantMessageParam(
role="assistant",
content=[
{
"type": "tool_use",
"id": "toolu_mixed01",
"name": "tool_search",
"input": {"query": "web"},
}
],
),
AnthropicMessagesUserMessageParam(
role="user",
content=[
{
"type": "tool_result",
"tool_use_id": "toolu_mixed01",
"content": [
{"type": "text", "text": "Found 2 tools"},
tool_reference_block,
search_result_block,
],
}
],
),
]
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, "Exactly one tool message expected for one tool_use_id"
content = tool_messages[0]["content"]
assert isinstance(content, list)
assert len(content) == 3, "Unknown block types must not be dropped from the content list"
assert content[0] == {"type": "text", "text": "Found 2 tools"}
assert content[1] == {"type": "text", "text": safe_dumps({"type": "tool_reference", "tool_name": "WebFetch"})}
assert content[2] == {
"type": "text",
"text": safe_dumps({"type": "search_result", "title": "docs", "source": "https://example.com"}),
}
def test_translate_anthropic_messages_to_openai_tool_result_empty_content_list():
"""Regression test for LIT-6103: a tool_result with an empty content list must still
emit a role:"tool" message so the tool_use stays answered."""
anthropic_messages = [
AnthropicMessagesUserMessageParam(role="user", content=[{"type": "text", "text": "Run the tool"}]),
AnthopicMessagesAssistantMessageParam(
role="assistant",
content=[
{
"type": "tool_use",
"id": "toolu_empty01",
"name": "noop_tool",
"input": {},
}
],
),
AnthropicMessagesUserMessageParam(
role="user",
content=[
{
"type": "tool_result",
"tool_use_id": "toolu_empty01",
"content": [],
}
],
),
]
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 was dropped for empty content list"
assert tool_messages[0]["tool_call_id"] == "toolu_empty01"
assert tool_messages[0]["content"] == ""
@pytest.mark.parametrize(
("tool_result_content", "expected_tool_content"),
[
([42], safe_dumps(42)),
(None, ""),
({"type": "text", "text": "bare dict"}, safe_dumps({"type": "text", "text": "bare dict"})),
],
)
def test_translate_anthropic_messages_to_openai_tool_result_odd_content_shapes(
tool_result_content, expected_tool_content
):
"""Regression test for LIT-6103: tool_result content that is a non-str non-dict item,
an explicit null, or a bare dict must still emit a role:"tool" message."""
anthropic_messages = [
AnthropicMessagesUserMessageParam(role="user", content=[{"type": "text", "text": "Run the tool"}]),
AnthopicMessagesAssistantMessageParam(
role="assistant",
content=[{"type": "tool_use", "id": "toolu_odd01", "name": "odd_tool", "input": {}}],
),
AnthropicMessagesUserMessageParam(
role="user",
content=[{"type": "tool_result", "tool_use_id": "toolu_odd01", "content": tool_result_content}],
),
]
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, f"Tool message was dropped for content {tool_result_content!r}"
assert tool_messages[0]["tool_call_id"] == "toolu_odd01"
assert tool_messages[0]["content"] == expected_tool_content
def test_translate_anthropic_messages_to_openai_tool_result_multi_item_non_dict_items():
"""Regression test for LIT-6103: non-str non-dict items in a multi-item tool_result
content list must be JSON-serialized into text parts, not silently discarded."""
anthropic_messages = [
AnthropicMessagesUserMessageParam(role="user", content=[{"type": "text", "text": "Run the tool"}]),
AnthopicMessagesAssistantMessageParam(
role="assistant",
content=[{"type": "tool_use", "id": "toolu_odd02", "name": "odd_tool", "input": {}}],
),
AnthropicMessagesUserMessageParam(
role="user",
content=[
{
"type": "tool_result",
"tool_use_id": "toolu_odd02",
"content": [{"type": "text", "text": "a"}, None, 42],
}
],
),
]
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
content = tool_messages[0]["content"]
assert isinstance(content, list)
assert len(content) == 3, "Non-dict items must not be discarded from the content list"
assert content[0] == {"type": "text", "text": "a"}
assert content[1] == {"type": "text", "text": safe_dumps(None)}
assert content[2] == {"type": "text", "text": safe_dumps(42)}
def test_translate_anthropic_messages_to_openai_tool_result_null_content_preserves_cache_control():
"""Regression test for LIT-6103: the fallthrough emit sites must carry cache_control
through for Claude models the same way the pre-existing branches do."""
anthropic_messages = [
AnthropicMessagesUserMessageParam(role="user", content=[{"type": "text", "text": "Run the tool"}]),
AnthopicMessagesAssistantMessageParam(
role="assistant",
content=[{"type": "tool_use", "id": "toolu_cc01", "name": "odd_tool", "input": {}}],
),
AnthropicMessagesUserMessageParam(
role="user",
content=[
{
"type": "tool_result",
"tool_use_id": "toolu_cc01",
"content": None,
"cache_control": {"type": "ephemeral"},
}
],
),
]
adapter = LiteLLMAnthropicMessagesAdapter()
result = adapter.translate_anthropic_messages_to_openai(
messages=anthropic_messages, model="claude-sonnet-5"
)
tool_messages = [msg for msg in result if isinstance(msg, dict) and msg.get("role") == "tool"]
assert len(tool_messages) == 1
assert tool_messages[0]["content"] == ""
assert tool_messages[0].get("cache_control") == {"type": "ephemeral"}
def test_translate_anthropic_messages_to_openai_tool_result_all_image_parts_unconvertible():
"""Regression test for LIT-6103: a multi-item tool_result whose parts all fail to
convert must still emit the tool message with empty content, never drop it."""
anthropic_messages = [
AnthropicMessagesUserMessageParam(role="user", content=[{"type": "text", "text": "Screenshot twice"}]),
AnthopicMessagesAssistantMessageParam(
role="assistant",
content=[{"type": "tool_use", "id": "toolu_img01", "name": "shot_tool", "input": {}}],
),
AnthropicMessagesUserMessageParam(
role="user",
content=[
{
"type": "tool_result",
"tool_use_id": "toolu_img01",
"content": [
{"type": "image", "source": {"type": "weird"}},
{"type": "image", "source": None},
],
}
],
),
]
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 must be emitted even when no parts convert"
assert tool_messages[0]["tool_call_id"] == "toolu_img01"
assert tool_messages[0]["content"] == ""