Skip to content

fix: strip empty text content blocks in /v1/messages endpoint - #24030

Closed
saurabhghere wants to merge 2 commits into
BerriAI:mainfrom
saurabhghere:sg/fix-anthropic-empty-blocks
Closed

fix: strip empty text content blocks in /v1/messages endpoint#24030
saurabhghere wants to merge 2 commits into
BerriAI:mainfrom
saurabhghere:sg/fix-anthropic-empty-blocks

Conversation

@saurabhghere

Copy link
Copy Markdown

Relevant issues

Fixes #22930

Pre-Submission checklist

Please complete all items before asking a LiteLLM maintainer to review your PR

  • I have Added testing in the tests/test_litellm/ directory, Adding at least 1 test is a hard requirement - see details
  • My PR passes all unit tests on make test-unit
  • My PR's scope is as isolated as possible, it only solves 1 specific problem
  • I have requested a Greptile review by commenting @greptileai and received a Confidence Score of at least 4/5 before requesting a maintainer review

Delays in PR merge?

If you're seeing a delay in your PR being merged, ping the LiteLLM Team on Slack (#pr-review).

CI (LiteLLM team)

CI status guideline:

  • 50-55 passing tests: main is stable with minor issues.
  • 45-49 passing tests: acceptable but needs attention
  • <= 40 passing tests: unstable; be careful with your merges and assess the risk.
  • Branch creation CI run
    Link:

  • CI run for the last commit
    Link:

  • Merge / cherry-pick CI run
    Links:

Type

🐛 Bug Fix

Changes

Moves empty text content block sanitization from the generic HTTP handler (llm_http_handler.py) into the Anthropic-specific provider config
(AnthropicMessagesConfig.transform_anthropic_messages_request()).

Problem: Claude's API returns assistant messages with empty text blocks ({"type": "text", "text": ""}) alongside tool_use blocks in multi-turn conversations. While
Anthropic returns these, it rejects them when sent back, causing 400: text content blocks must be non-empty. The previous fix (PR #23097) was reverted (PR #23232)
because it crashed on bare string messages. The current v1 fix works but places Anthropic-specific logic in the generic HTTP handler.

What changed:

  • Removed _sanitize_anthropic_messages_empty_text_blocks() standalone function and its call from llm_http_handler.py
  • Added _sanitize_empty_text_content_blocks() as a method on AnthropicMessagesConfig in transformation.py, called at the top of transform_anthropic_messages_request()
  • Updated tests to import from the new location and removed the non-dict message test (no longer needed since messages are List[Dict] at this layer)

Why this is better:

  1. transform_anthropic_messages_request() is the designated place for Anthropic-specific request transformations
  2. Clean type contract — no isinstance(message, dict) guards needed
  3. Bedrock, Vertex AI, and Azure AI all call super().transform_anthropic_messages_request(), so they inherit the fix automatically
  4. Keeps llm_http_handler.py generic with no provider-specific logic

@vercel

vercel Bot commented Mar 18, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
litellm Ready Ready Preview, Comment Mar 19, 2026 4:42am

Request Review

@codspeed-hq

codspeed-hq Bot commented Mar 18, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 16 untouched benchmarks


Comparing saurabhghere:sg/fix-anthropic-empty-blocks (71e5dda) with main (0ecced9)

Open in CodSpeed

@greptile-apps

greptile-apps Bot commented Mar 18, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR re-implements the empty-text-block sanitization (fix for #22930) in the correct architectural layer. Claude's API returns assistant messages containing {"type": "text", "text": ""} alongside tool_use blocks, but rejects those same empty blocks when they are sent back, producing a 400 error. The fix adds _sanitize_empty_text_content_blocks() as a method on AnthropicMessagesConfig and calls it at the very top of transform_anthropic_messages_request(), which is inherited by Bedrock, Vertex AI, and Azure AI — so all four providers now benefit from the fix without any provider-specific code in the generic HTTP handler.

Key points:

  • Correct placement: Anthropic-specific sanitization now lives in AnthropicMessagesConfig rather than the generic llm_http_handler.py, in line with the custom rule to keep provider-specific logic inside llms/.
  • Inheritance chain is sound: Vertex AI and Azure AI call super().transform_anthropic_messages_request(), and Bedrock calls AnthropicMessagesConfig.transform_anthropic_messages_request(self=self, ...) — all three correctly trigger the sanitization method.
  • Test coverage is thorough: 12 unit tests cover all key scenarios, are mock-only, and import from the correct new location.
  • Noted design choices (raised in previous review threads): (1) the sanitization currently runs on user messages as well as assistant messages (broader than the strict bug scope), and (2) when every content block is empty the method silently injects a "..." placeholder with no logging.

Confidence Score: 4/5

  • This PR is safe to merge; the fix is correctly scoped, well-tested, and follows the repository's provider-isolation conventions.
  • The core logic is correct and the inheritance chain for Bedrock, Vertex AI, and Azure AI has been verified. Test coverage is comprehensive (12 mock-only unit tests). The two design concerns raised in prior review threads (sanitization scope including user messages, and the silent "..." placeholder) are style/design trade-offs rather than bugs. No regressions are introduced in the generic HTTP handler.
  • transformation.py — the _sanitize_empty_text_content_blocks method; the two design concerns from prior review threads (user-message scope and silent placeholder injection) remain open for discussion.

Important Files Changed

Filename Overview
litellm/llms/anthropic/experimental_pass_through/messages/transformation.py Adds _sanitize_empty_text_content_blocks() method to AnthropicMessagesConfig and calls it at the top of transform_anthropic_messages_request(), correctly fixing the empty-text-block rejection issue for Anthropic, Bedrock (via direct call), Vertex AI, and Azure AI (via super() calls). Minor concerns already surfaced in prior review threads (user-message scope and silent placeholder).
tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_v1_messages_empty_text_sanitization.py New test file with 12 unit tests covering the sanitization method: the primary bug scenario, whitespace-only text, all-empty replacement, string/no-content pass-through, user messages, tool_result safety, None values, immutability, multi-turn end-to-end, and empty list. Tests are mock-only and correctly importfrom the new location.
poetry.lock Only the content-hash line changed — no new dependencies introduced.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[transform_anthropic_messages_request called\nAnthropic / Bedrock / Vertex / Azure] --> B[_sanitize_empty_text_content_blocks]
    B --> C{For each message}
    C --> D{content is a list?}
    D -- No --> E[Append message unchanged]
    E --> C
    D -- Yes --> F[Filter: remove blocks where\ntype==text AND text is empty/whitespace]
    F --> G{Any blocks filtered?}
    G -- No --> H[Append original message]
    H --> C
    G -- Yes --> I{filtered list empty?}
    I -- No --> J[Append message with filtered content list]
    I -- Yes --> K[Replace with placeholder\ntype=text, text='...']
    K --> J
    J --> C
    C -- Done --> L[Continue with sanitized messages\ne.g. max_tokens check, AnthropicMessagesRequest construction]
Loading

Last reviewed commit: "Update poetry lock"

Comment on lines +195 to +200
if filtered_content != content:
if not filtered_content:
filtered_content = [{"type": "text", "text": "..."}]
result.append({**message, "content": filtered_content})
else:
result.append(message)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Silent placeholder injection on all-empty content

When every block in a content list is an empty text block, the code replaces them with the hardcoded string "...":

if not filtered_content:
    filtered_content = [{"type": "text", "text": "..."}]

This silently inserts a "..." into the conversation without any logging or warning, and it applies equally to user messages and assistant messages. A caller debugging a multi-turn conversation would see unexpected content appear in the request. Consider at least emitting a verbose_logger.warning(...) here to make the substitution observable. Additionally, the placeholder "..." is somewhat arbitrary — a comment explaining why this specific string was chosen would help future maintainers.

Comment on lines +185 to +193
filtered_content = [
block
for block in content
if not (
isinstance(block, dict)
and block.get("type") == "text"
and (not block.get("text") or not str(block["text"]).strip())
)
]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Sanitization applies to user messages too

The method iterates over every message regardless of role, so user-role messages with list content are also sanitized. The bug being fixed (issue #22930) is specifically about assistant messages that come back from Claude with empty text blocks alongside tool_use blocks. Silently stripping empty text blocks from user messages is a broader change than the fix requires, and user messages normally would never contain Anthropic-generated empty blocks. Scoping the filter to role == "assistant" would make the intent more explicit and avoid unintended side-effects on user-constructed messages:

Suggested change
filtered_content = [
block
for block in content
if not (
isinstance(block, dict)
and block.get("type") == "text"
and (not block.get("text") or not str(block["text"]).strip())
)
]
if not isinstance(content, list) or message.get("role") != "assistant":

(The test test_user_message_content_also_sanitized covers this path and would need updating if the scope is narrowed, which might be desirable.)

@Sameerlite

Copy link
Copy Markdown
Contributor

FIx for #22930 was merged. Closing this

@Sameerlite Sameerlite closed this Jun 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: /v1/messages endpoint does not sanitize empty text content blocks

2 participants