mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-15 23:31:29 +00:00
fix(anthropic): always sanitize empty text content blocks
Some checks failed
Unit Tests: Proxy DB Operations / proxy-db (auth-checks, tests/proxy_unit_tests/test_auth_checks.py tests/proxy_unit_tests/test_user_api_key_auth.py, 20, 8) (push) Has been cancelled
Unit Tests: Proxy DB Operations / proxy-db (key-generation, tests/proxy_unit_tests/test_key_generate_prisma.py, 30, 0) (push) Has been cancelled
Unit Tests: Proxy DB Operations / proxy-db (proxy-utils, tests/proxy_unit_tests/test_proxy_utils.py, 20, 8) (push) Has been cancelled
Unit Tests: Proxy DB Operations / proxy-db (remaining, tests/proxy_unit_tests --ignore=tests/proxy_unit_tests/test_key_generate_prisma.py --ignore=tests/proxy_unit_tests/test_auth_checks.py --ignore=tests/proxy_unit_tests/test_user_api_key_auth.py --ignore=tests/proxy_unit_tests/test_p… (push) Has been cancelled
Unit Tests: Security / security (push) Has been cancelled
Some checks failed
Unit Tests: Proxy DB Operations / proxy-db (auth-checks, tests/proxy_unit_tests/test_auth_checks.py tests/proxy_unit_tests/test_user_api_key_auth.py, 20, 8) (push) Has been cancelled
Unit Tests: Proxy DB Operations / proxy-db (key-generation, tests/proxy_unit_tests/test_key_generate_prisma.py, 30, 0) (push) Has been cancelled
Unit Tests: Proxy DB Operations / proxy-db (proxy-utils, tests/proxy_unit_tests/test_proxy_utils.py, 20, 8) (push) Has been cancelled
Unit Tests: Proxy DB Operations / proxy-db (remaining, tests/proxy_unit_tests --ignore=tests/proxy_unit_tests/test_key_generate_prisma.py --ignore=tests/proxy_unit_tests/test_auth_checks.py --ignore=tests/proxy_unit_tests/test_user_api_key_auth.py --ignore=tests/proxy_unit_tests/test_p… (push) Has been cancelled
Unit Tests: Security / security (push) Has been cancelled
Anthropic 400s on `{"role": "user", "content": ""}` with:
"messages: text content blocks must be non-empty"
LiteLLM already had `_sanitize_empty_text_content` to rewrite empty text
to a placeholder, but it was gated behind `litellm.modify_params=True`.
With that flag off (default), empty content from upstream agent
frameworks (e.g. pydantic-ai) flowed straight through and tripped the
Anthropic validator.
Fix:
- Always run `_sanitize_empty_text_content` at the top of
`anthropic_messages_pt`, independent of `modify_params`. There is no
way to "pass through" an empty text block, so this is non-optional.
The richer tool-call sanitizations (Cases A/B/D, which actually
mutate conversation structure) remain gated on `modify_params`.
- Extend `_sanitize_empty_text_content` to also handle list-of-blocks
content (`[{"type": "text", "text": ""}]`), not just string content.
Adds 3 regression tests covering string content, list-of-blocks
content, and the no-op case (non-empty messages with modify_params off).
Made-with: Cursor
This commit is contained in:
parent
13d9d674f9
commit
18e5aa418e
2 changed files with 147 additions and 11 deletions
|
|
@ -2065,27 +2065,62 @@ def anthropic_process_openai_file_message(
|
|||
)
|
||||
|
||||
|
||||
_EMPTY_TEXT_PLACEHOLDER = (
|
||||
"[System: Empty message content sanitised to satisfy protocol]"
|
||||
)
|
||||
|
||||
|
||||
def _sanitize_empty_text_content(
|
||||
message: AllMessageValues,
|
||||
) -> AllMessageValues:
|
||||
"""
|
||||
Case C: Sanitize empty text content
|
||||
- Replace empty or whitespace-only text content with a placeholder message.
|
||||
- Handles both string content and list-of-blocks content (rewriting only
|
||||
the empty text blocks in place; non-text blocks like images are left
|
||||
untouched).
|
||||
|
||||
Returns:
|
||||
The message with sanitized content if needed, otherwise the original message
|
||||
"""
|
||||
if message.get("role") in ["user", "assistant"]:
|
||||
content = message.get("content")
|
||||
if isinstance(content, str):
|
||||
if not content or not content.strip():
|
||||
message = cast(AllMessageValues, dict(message)) # Make a copy
|
||||
message["content"] = (
|
||||
"[System: Empty message content sanitised to satisfy protocol]"
|
||||
)
|
||||
verbose_logger.debug(
|
||||
f"_sanitize_empty_text_content: Replaced empty text content in {message.get('role')} message"
|
||||
)
|
||||
if message.get("role") not in ["user", "assistant"]:
|
||||
return message
|
||||
|
||||
content = message.get("content")
|
||||
|
||||
if isinstance(content, str):
|
||||
if not content or not content.strip():
|
||||
message = cast(AllMessageValues, dict(message)) # Make a copy
|
||||
message["content"] = _EMPTY_TEXT_PLACEHOLDER
|
||||
verbose_logger.debug(
|
||||
f"_sanitize_empty_text_content: Replaced empty text content in {message.get('role')} message"
|
||||
)
|
||||
return message
|
||||
|
||||
if isinstance(content, list):
|
||||
# Walk the blocks and rewrite any empty text blocks. We rewrite (rather
|
||||
# than drop) so callers don't end up with an entirely empty content
|
||||
# list, which Anthropic also rejects.
|
||||
new_blocks: List[Any] = []
|
||||
rewrote_any = False
|
||||
for block in content:
|
||||
if isinstance(block, dict) and block.get("type") == "text":
|
||||
text = block.get("text")
|
||||
if not isinstance(text, str) or not text or not text.strip():
|
||||
new_block = dict(block)
|
||||
new_block["text"] = _EMPTY_TEXT_PLACEHOLDER
|
||||
new_blocks.append(new_block)
|
||||
rewrote_any = True
|
||||
continue
|
||||
new_blocks.append(block)
|
||||
|
||||
if rewrote_any:
|
||||
message = cast(AllMessageValues, dict(message)) # Make a copy
|
||||
message["content"] = new_blocks # type: ignore
|
||||
verbose_logger.debug(
|
||||
f"_sanitize_empty_text_content: Replaced empty text block(s) in {message.get('role')} message"
|
||||
)
|
||||
|
||||
return message
|
||||
|
||||
|
||||
|
|
@ -2368,6 +2403,18 @@ def anthropic_messages_pt( # noqa: PLR0915
|
|||
# Sanitize messages for tool calling issues when modify_params=True
|
||||
messages = sanitize_messages_for_tool_calling(messages)
|
||||
|
||||
# Anthropic rejects empty text content blocks with:
|
||||
# "messages: text content blocks must be non-empty"
|
||||
# OpenAI/other providers silently tolerate `{"role": "user", "content": ""}`,
|
||||
# so callers (and upstream agent frameworks like pydantic-ai) routinely
|
||||
# send empty user/assistant turns. We always rewrite these to a placeholder
|
||||
# for Anthropic-shaped requests, independent of `litellm.modify_params`,
|
||||
# because there is no way to "pass through" an empty text block — the
|
||||
# request will always 400 otherwise. The richer tool-call sanitization
|
||||
# (Cases A/B/D in `sanitize_messages_for_tool_calling`) remains gated on
|
||||
# `modify_params` because it actually mutates conversation structure.
|
||||
messages = [_sanitize_empty_text_content(m) for m in messages]
|
||||
|
||||
# add role=tool support to allow function call result/error submission
|
||||
user_message_types = {"user", "tool", "function"}
|
||||
# reformat messages to ensure user/assistant are alternating, if there's either 2 consecutive 'user' messages or 2 consecutive 'assistant' message, merge them.
|
||||
|
|
|
|||
|
|
@ -339,6 +339,95 @@ class TestMessageSanitization:
|
|||
assert result[0]["role"] == "user"
|
||||
assert result[1]["role"] == "assistant"
|
||||
|
||||
def test_empty_string_content_sanitized_without_modify_params(self):
|
||||
"""
|
||||
Regression: An empty user message ({"role": "user", "content": ""}) must
|
||||
be rewritten to a non-empty placeholder *before* it reaches Anthropic,
|
||||
even when litellm.modify_params is False. Otherwise Anthropic returns:
|
||||
"messages: text content blocks must be non-empty"
|
||||
Reproduces a real failure from the pr-review agent (pydantic-ai).
|
||||
"""
|
||||
litellm.modify_params = False
|
||||
|
||||
messages = [
|
||||
{"role": "user", "content": "First message"},
|
||||
{"role": "user", "content": "please review this"},
|
||||
{"role": "user", "content": ""},
|
||||
]
|
||||
|
||||
result = anthropic_messages_pt(
|
||||
messages=messages, model="claude-sonnet-4-5", llm_provider="anthropic"
|
||||
)
|
||||
|
||||
# All three user messages get merged into one user turn for Anthropic.
|
||||
assert len(result) == 1
|
||||
assert result[0]["role"] == "user"
|
||||
text_blocks = [
|
||||
b for b in result[0]["content"] if isinstance(b, dict) and b.get("type") == "text"
|
||||
]
|
||||
assert len(text_blocks) == 3
|
||||
# No text block may be empty — that's the contract Anthropic enforces.
|
||||
for block in text_blocks:
|
||||
assert block["text"].strip() != ""
|
||||
assert text_blocks[2]["text"] == (
|
||||
"[System: Empty message content sanitised to satisfy protocol]"
|
||||
)
|
||||
|
||||
def test_empty_text_block_in_list_content_sanitized(self):
|
||||
"""
|
||||
Same regression for the list-of-blocks form:
|
||||
{"role": "user", "content": [{"type": "text", "text": ""}]}
|
||||
Empty text *blocks* must be rewritten too, regardless of modify_params.
|
||||
"""
|
||||
litellm.modify_params = False
|
||||
|
||||
messages = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "real content"},
|
||||
{"type": "text", "text": ""},
|
||||
{"type": "text", "text": " \n "},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
result = anthropic_messages_pt(
|
||||
messages=messages, model="claude-sonnet-4-5", llm_provider="anthropic"
|
||||
)
|
||||
|
||||
assert len(result) == 1
|
||||
text_blocks = [
|
||||
b for b in result[0]["content"] if isinstance(b, dict) and b.get("type") == "text"
|
||||
]
|
||||
assert len(text_blocks) == 3
|
||||
assert text_blocks[0]["text"] == "real content"
|
||||
for block in text_blocks[1:]:
|
||||
assert block["text"].strip() != ""
|
||||
|
||||
def test_non_empty_content_unchanged_without_modify_params(self):
|
||||
"""
|
||||
Sanity check: when nothing is empty, the messages flow through unchanged
|
||||
even with modify_params disabled.
|
||||
"""
|
||||
litellm.modify_params = False
|
||||
|
||||
messages = [
|
||||
{"role": "user", "content": "Hello"},
|
||||
{"role": "assistant", "content": "Hi there"},
|
||||
{"role": "user", "content": "How are you?"},
|
||||
]
|
||||
|
||||
result = anthropic_messages_pt(
|
||||
messages=messages, model="claude-sonnet-4-5", llm_provider="anthropic"
|
||||
)
|
||||
|
||||
# Two user turns + one assistant turn (alternation preserved).
|
||||
assert len(result) == 3
|
||||
assert result[0]["content"][0]["text"] == "Hello"
|
||||
assert result[1]["content"][0]["text"] == "Hi there"
|
||||
assert result[2]["content"][0]["text"] == "How are you?"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue