mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-09 03:18:44 +00:00
fix: sanitize empty text content blocks in /v1/messages endpoint (#22933)
The `/v1/messages` endpoint does not sanitize empty text content blocks
from messages, causing 400 errors ("text content blocks must be
non-empty") in multi-turn tool-use conversations.
Claude's API returns assistant messages with empty text blocks alongside
`tool_use` blocks (e.g., `{"type": "text", "text": ""}`). While the API
returns these, it rejects them when sent back in subsequent requests.
The `/v1/chat/completions` path already handles this via
`process_empty_text_blocks()` in `factory.py`, but the `/v1/messages`
path was missing equivalent sanitization.
Fix: Add `_sanitize_anthropic_messages()` at the entry point of the
`/v1/messages` handler, before any routing. This strips empty/whitespace
text blocks from message content arrays while preserving non-empty text
and other block types (`tool_use`, `tool_result`, etc.). The function
does not leave content arrays empty to avoid a different validation error.
This affects all providers routed through `/v1/messages` (Anthropic
direct, Bedrock Converse, Azure, etc.) and is particularly important for
Claude Code / Agent SDK conversations which frequently produce these
patterns.
Fixes #22930
This commit is contained in:
parent
cf9712d921
commit
a4fe06134f
2 changed files with 206 additions and 0 deletions
|
|
@ -24,6 +24,10 @@ from litellm.types.llms.anthropic_messages.anthropic_response import (
|
|||
from litellm.types.router import GenericLiteLLMParams
|
||||
from litellm.utils import ProviderConfigManager, client
|
||||
|
||||
from litellm.litellm_core_utils.prompt_templates.common_utils import (
|
||||
DEFAULT_ASSISTANT_CONTINUE_MESSAGE,
|
||||
)
|
||||
|
||||
from ..adapters.handler import LiteLLMMessagesToCompletionTransformationHandler
|
||||
from ..responses_adapters.handler import LiteLLMMessagesToResponsesAPIHandler
|
||||
from .utils import AnthropicMessagesRequestUtils, mock_response
|
||||
|
|
@ -49,6 +53,58 @@ base_llm_http_handler = BaseLLMHTTPHandler()
|
|||
#################################################
|
||||
|
||||
|
||||
def _sanitize_anthropic_messages(messages: List[Dict]) -> List[Dict]:
|
||||
"""
|
||||
Sanitize messages for the /v1/messages endpoint.
|
||||
|
||||
The Anthropic API can return assistant messages with empty text blocks
|
||||
alongside tool_use blocks (e.g., {"type": "text", "text": ""}). While
|
||||
the API returns these, it rejects them when sent back in subsequent
|
||||
requests with "text content blocks must be non-empty".
|
||||
|
||||
This is particularly common in multi-turn tool-use conversations (e.g.,
|
||||
Claude Code / Agent SDK) where the model starts a text block but
|
||||
immediately switches to a tool_use block.
|
||||
|
||||
The /v1/chat/completions path already handles this via
|
||||
process_empty_text_blocks() in factory.py, but the /v1/messages path
|
||||
was missing sanitization.
|
||||
"""
|
||||
for i, message in enumerate(messages):
|
||||
content = message.get("content")
|
||||
if not isinstance(content, list):
|
||||
continue
|
||||
|
||||
# Filter out empty text blocks, keeping non-empty text and other types.
|
||||
# Use `(... or "")` to guard against None text values.
|
||||
filtered = [
|
||||
block
|
||||
for block in content
|
||||
if not (
|
||||
isinstance(block, dict)
|
||||
and block.get("type") == "text"
|
||||
and not (block.get("text") or "").strip()
|
||||
)
|
||||
]
|
||||
|
||||
# Only update if we actually removed something.
|
||||
# Avoid mutating the caller's dicts — create a shallow copy.
|
||||
if len(filtered) < len(content):
|
||||
if len(filtered) > 0:
|
||||
messages[i] = {**message, "content": filtered}
|
||||
else:
|
||||
# All blocks were empty text — replace with a continuation
|
||||
# message rather than leaving empty blocks that trigger 400
|
||||
# errors. Matches behavior of process_empty_text_blocks()
|
||||
# in factory.py.
|
||||
messages[i] = {
|
||||
**message,
|
||||
"content": [{"type": "text", "text": DEFAULT_ASSISTANT_CONTINUE_MESSAGE.get("content", "Please continue.")}],
|
||||
}
|
||||
|
||||
return messages
|
||||
|
||||
|
||||
async def _execute_pre_request_hooks(
|
||||
model: str,
|
||||
messages: List[Dict],
|
||||
|
|
@ -137,6 +193,10 @@ async def anthropic_messages(
|
|||
"""
|
||||
Async: Make llm api request in Anthropic /messages API spec
|
||||
"""
|
||||
# Sanitize empty text blocks from messages before processing.
|
||||
# See: https://github.com/BerriAI/litellm/issues/22930
|
||||
messages = _sanitize_anthropic_messages(messages)
|
||||
|
||||
# Execute pre-request hooks to allow CustomLoggers to modify request
|
||||
request_kwargs = await _execute_pre_request_hooks(
|
||||
model=model,
|
||||
|
|
|
|||
|
|
@ -190,6 +190,152 @@ def test_openai_model_with_thinking_converts_to_reasoning():
|
|||
assert "thinking" not in call_kwargs, "thinking should NOT be passed directly to litellm.responses"
|
||||
|
||||
|
||||
class TestSanitizeAnthropicMessages:
|
||||
"""Tests for _sanitize_anthropic_messages which strips empty text blocks."""
|
||||
|
||||
def test_strips_empty_text_block_alongside_tool_use(self):
|
||||
"""The most common case: model returns empty text + tool_use."""
|
||||
from litellm.llms.anthropic.experimental_pass_through.messages.handler import (
|
||||
_sanitize_anthropic_messages,
|
||||
)
|
||||
|
||||
messages = [
|
||||
{"role": "user", "content": "Use the bash tool to list files"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "text", "text": ""},
|
||||
{"type": "tool_use", "id": "toolu_123", "name": "Bash", "input": {"cmd": "ls"}},
|
||||
],
|
||||
},
|
||||
]
|
||||
result = _sanitize_anthropic_messages(messages)
|
||||
assistant = result[1]
|
||||
assert len(assistant["content"]) == 1
|
||||
assert assistant["content"][0]["type"] == "tool_use"
|
||||
|
||||
def test_strips_whitespace_only_text_block(self):
|
||||
from litellm.llms.anthropic.experimental_pass_through.messages.handler import (
|
||||
_sanitize_anthropic_messages,
|
||||
)
|
||||
|
||||
messages = [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "text", "text": " \n "},
|
||||
{"type": "tool_use", "id": "toolu_123", "name": "Bash", "input": {}},
|
||||
],
|
||||
},
|
||||
]
|
||||
result = _sanitize_anthropic_messages(messages)
|
||||
assert len(result[0]["content"]) == 1
|
||||
assert result[0]["content"][0]["type"] == "tool_use"
|
||||
|
||||
def test_preserves_non_empty_text_blocks(self):
|
||||
from litellm.llms.anthropic.experimental_pass_through.messages.handler import (
|
||||
_sanitize_anthropic_messages,
|
||||
)
|
||||
|
||||
messages = [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "text", "text": "I'll run that for you."},
|
||||
{"type": "tool_use", "id": "toolu_123", "name": "Bash", "input": {}},
|
||||
],
|
||||
},
|
||||
]
|
||||
result = _sanitize_anthropic_messages(messages)
|
||||
assert len(result[0]["content"]) == 2
|
||||
|
||||
def test_replaces_all_empty_blocks_with_continuation(self):
|
||||
"""If ALL blocks are empty text, replace with a continuation message."""
|
||||
from litellm.llms.anthropic.experimental_pass_through.messages.handler import (
|
||||
_sanitize_anthropic_messages,
|
||||
)
|
||||
from litellm.litellm_core_utils.prompt_templates.common_utils import (
|
||||
DEFAULT_ASSISTANT_CONTINUE_MESSAGE,
|
||||
)
|
||||
|
||||
messages = [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [{"type": "text", "text": ""}],
|
||||
},
|
||||
]
|
||||
result = _sanitize_anthropic_messages(messages)
|
||||
assert len(result[0]["content"]) == 1
|
||||
assert result[0]["content"][0]["type"] == "text"
|
||||
assert result[0]["content"][0]["text"] == DEFAULT_ASSISTANT_CONTINUE_MESSAGE.get("content", "Please continue.")
|
||||
|
||||
def test_handles_string_content(self):
|
||||
"""String content (not list) should pass through unchanged."""
|
||||
from litellm.llms.anthropic.experimental_pass_through.messages.handler import (
|
||||
_sanitize_anthropic_messages,
|
||||
)
|
||||
|
||||
messages = [{"role": "user", "content": "Hello"}]
|
||||
result = _sanitize_anthropic_messages(messages)
|
||||
assert result[0]["content"] == "Hello"
|
||||
|
||||
def test_handles_user_messages_too(self):
|
||||
"""User messages can also have content lists with empty text blocks."""
|
||||
from litellm.llms.anthropic.experimental_pass_through.messages.handler import (
|
||||
_sanitize_anthropic_messages,
|
||||
)
|
||||
|
||||
messages = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": ""},
|
||||
{"type": "tool_result", "tool_use_id": "toolu_123", "content": "file1.txt"},
|
||||
],
|
||||
},
|
||||
]
|
||||
result = _sanitize_anthropic_messages(messages)
|
||||
assert len(result[0]["content"]) == 1
|
||||
assert result[0]["content"][0]["type"] == "tool_result"
|
||||
|
||||
|
||||
def test_handles_none_text_value(self):
|
||||
"""Text blocks with None text value should be treated as empty, not crash."""
|
||||
from litellm.llms.anthropic.experimental_pass_through.messages.handler import (
|
||||
_sanitize_anthropic_messages,
|
||||
)
|
||||
|
||||
messages = [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "text", "text": None},
|
||||
{"type": "tool_use", "id": "toolu_123", "name": "Bash", "input": {}},
|
||||
],
|
||||
},
|
||||
]
|
||||
result = _sanitize_anthropic_messages(messages)
|
||||
assert len(result[0]["content"]) == 1
|
||||
assert result[0]["content"][0]["type"] == "tool_use"
|
||||
|
||||
def test_does_not_mutate_original_message(self):
|
||||
"""Sanitized messages should be shallow copies, not mutated originals."""
|
||||
from litellm.llms.anthropic.experimental_pass_through.messages.handler import (
|
||||
_sanitize_anthropic_messages,
|
||||
)
|
||||
|
||||
original_content = [
|
||||
{"type": "text", "text": ""},
|
||||
{"type": "tool_use", "id": "toolu_123", "name": "Bash", "input": {}},
|
||||
]
|
||||
messages = [{"role": "assistant", "content": original_content}]
|
||||
result = _sanitize_anthropic_messages(messages)
|
||||
# Original content list should be unchanged
|
||||
assert len(original_content) == 2
|
||||
# Result message should be a different dict
|
||||
assert len(result[0]["content"]) == 1
|
||||
|
||||
|
||||
class TestThinkingParameterTransformation:
|
||||
"""Core tests for thinking parameter transformation logic."""
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue