fix(anthropic): drop and self-heal empty thinking blocks on /v1/messages - #38625
Conversation
|
@greptileai can you reply here with a score and why that score? |
|
bugbot run |
|
Confidence score: 4/5 This is a focused and well-tested fix for the reported
I’m not giving 5/5 because the PR itself notes an uncovered pathological mid-stream transition where an empty thinking block can still be opened, and the required CI status is currently pending. The sanitizer and retry path mitigate that case, so this is still a strong 4/5 for the stated customer flow. |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 2152456. Configure here.
Greptile SummaryThis PR filters empty thinking blocks from Anthropic Messages history and bridged responses, and broadens recovery for related provider errors
Confidence Score: 4/5The signed-first-chunk regression should be fixed before merging because it can poison later reasoning-history replay The sanitizer and non-streaming producer changes are sound, but the streaming blank check skips real provider signature content before translation Files Needing Attention: litellm/llms/anthropic/experimental_pass_through/adapters/streaming_iterator.py, litellm/llms/anthropic/common_utils.py
|
| Filename | Overview |
|---|---|
| litellm/llms/anthropic/common_utils.py | Adds shared empty-thinking detection, request sanitization, and broader retry matching; the implementation is functionally focused but over-documented |
| litellm/llms/anthropic/experimental_pass_through/adapters/streaming_iterator.py | Suppresses empty initial thinking chunks but also drops meaningful signatures when a signed block arrives before thinking text |
| litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py | Prevents non-streaming bridge responses from emitting empty thinking blocks while preserving non-empty and redacted blocks |
| litellm/llms/anthropic/experimental_pass_through/messages/handler.py | Applies the expanded sanitizer consistently across synchronous and asynchronous Anthropic Messages dispatch |
| litellm/llms/base_llm/anthropic_messages/transformation.py | Extends the bounded strip-and-retry path to Anthropic empty-thinking validation errors |
Comments Outside Diff (1)
-
litellm/llms/anthropic/common_utils.py, line 1040-1067 (link)Filtering logic is over-documented
This docstring duplicates error details, scenarios, and behavior already captured by names and tests, increasing maintenance cost when the logic changes
Context Used: CLAUDE.md (source)
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
Reviews (1): Last reviewed commit: "fix(anthropic): drop and self-heal empty..." | Re-trigger Greptile
| if thinking_blocks and any(isinstance(b, dict) and not is_empty_thinking_block(b) for b in thinking_blocks): | ||
| return False |
There was a problem hiding this comment.
When a stream starts with empty thinking and a signature, this check skips the chunk, dropping the signature needed for later replay
Knowledge Base Used:
There was a problem hiding this comment.
The classifier captures the skipped chunk's signature into the pending block start, so it survives; new tests pin both carry and discard paths
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
|
Addressed the P1 with pinning tests showing the signature already survives the skip. @greptileai can you reply here with a score and why that score? |
|
bugbot run |
|
Confidence score: 5/5 The previous P1 is addressed. The new sync and async pinning tests verify that a signature on an initially blank thinking chunk is preserved when real thinking follows, while also verifying it is discarded when the next block is text. This confirms the blank-chunk suppression does not lose valid signature data or attach it to the wrong content block. The rest of the fix remains well covered: empty thinking blocks are suppressed in streaming and non-streaming bridge output, replayed empty blocks are sanitized, existing invalid-signature recovery is preserved, and the empty-thinking 400 matcher is exercised. The latest e2e status is also passing, so I’m raising the score from 4/5 to 5/5. |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 133c069. Configure here.
Merging this PR will not alter performance
Comparing |
TLDR
Problem this solves:
{"type": "thinking", "thinking": ""}How it solves it:
User Flow
Before: a developer running Claude Code against an auto-router mixing Anthropic and OSS reasoning tiers sees sessions die mid-task
After: the same session runs to completion
Relevant issues
Linear ticket
Resolves LIT-6357
Pre-Submission checklist
Please complete all items before asking a LiteLLM maintainer to review your PR
uv run pytest tests/test_litellm/<your_test_file>.py -v. Leave the suites (make test-unit-*,make test-unit) to CI: it finishes in ~15 minutes where a laptop takes an hour or more@greptileaito re-request a review after pushing changes)Screenshots / Proof of Fix
Shared setup: a local proxy on :4357 with two deployments:
claude-tierisanthropic/claude-haiku-4-5-20251001served through a live gateway upstream (real Anthropic account, real spend),oss-tierishosted_vllm/stub-reasonerserved by a deterministic local upstream that streams a vLLM-style reasoning response going straight to parallel tool calls with no reasoning text (the shape the customer's OSS tier produces)Poisoned-history payload (what Claude Code replays after a bridged turn):
{ "model": "claude-tier", "max_tokens": 2048, "thinking": {"type": "enabled", "budget_tokens": 1024}, "tools": [{"name": "get_weather", "description": "Get weather for a city", "input_schema": {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]}}], "messages": [ {"role": "user", "content": "Weather in Paris and London?"}, {"role": "assistant", "content": [ {"type": "thinking", "thinking": "", "signature": ""}, {"type": "tool_use", "id": "toolu_01A", "name": "get_weather", "input": {"city": "Paris"}}, {"type": "tool_use", "id": "toolu_01B", "name": "get_weather", "input": {"city": "London"}} ]}, {"role": "user", "content": [ {"type": "tool_result", "tool_use_id": "toolu_01A", "content": "18C sunny"}, {"type": "tool_result", "tool_use_id": "toolu_01B", "content": "14C rain"} ]} ] }Before (3002994)
Replayed empty thinking block, non-streaming
curl -s -X POST http://127.0.0.1:4357/v1/messages -H "Authorization: Bearer $KEY" -H "content-type: application/json" -d @poisoned.jsonlitellm.BadRequestError: AnthropicException - ... "messages.1.content.0.thinking: each thinking block must contain thinking"Replayed empty thinking block, streaming
"stream": trueBridged reasoning turn produces the poison
curl -s -X POST http://127.0.0.1:4357/v1/messages ... -d '{"model": "oss-tier", "stream": true, ...}'where the upstream streams an empty thinking entry then two tool callscontent_block_start {"type": "thinking", "thinking": ""}immediately followed bycontent_block_stopwith no delta: the block Claude Code will store and replayAfter (133c069)
Replayed empty thinking block, non-streaming
Replayed empty thinking block, streaming
"stream": trueBridged reasoning turn produces the poison
tool_useblocks directly with no thinking block at all; a stub scenario with real reasoning text still streams its thinking block and deltas unchangedType
🐛 Bug Fix
✅ Test
Caveats (if any)
Medium
Low
Final Attestation
Note
Medium Risk
Touches message sanitization, streaming block assembly, and HTTP retry logic on the Anthropic Messages bridge; behavior changes for empty thinking blocks could affect multi-turn tool clients, though coverage is heavily tested.
Overview
Fixes LIT-6357: mixed-provider
/v1/messagestool loops were failing when conversation history contained{"type": "thinking", "thinking": ""}(often from a bridged non-Anthropic reasoning turn with no reasoning text).Ingestion:
strip_empty_text_blocks_from_anthropic_messagesis renamed tostrip_empty_content_blocks_from_anthropic_messagesand now removes empty/whitespace-only thinking blocks (viais_empty_thinking_block) in addition to empty text blocks, on the native Messages handler path.Producer: The pass-through adapter no longer emits empty thinking blocks in non-streaming responses, and
AnthropicStreamWrappertreats deltas whosethinking_blocksare all empty as blank so it does not open a poison thinking block before tool calls.Retry:
is_anthropic_invalid_thinking_signature_erroris renamed tois_anthropic_invalid_thinking_block_errorand also matches Anthropic’s “each thinking block must contain thinking” 400 so strip-and-retry can still run.Reviewed by Cursor Bugbot for commit 133c069. Bugbot is set up for automated code reviews on this repo. Configure here.