From 9fa8d062b3beb8fdcb55b9fa94569d7ae74c499c Mon Sep 17 00:00:00 2001 From: Jeremy Schoemaker Date: Sun, 23 Aug 2026 20:15:13 -0500 Subject: [PATCH] =?UTF-8?q?fix(anthropic):=20drop=20thinking=20blocks=20wi?= =?UTF-8?q?th=20empty=20thinking=20text,=20not=20just=20missing=20signatur?= =?UTF-8?q?e=20=F0=9F=A7=A0=F0=9F=9A=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _is_unsignable_thinking_block() only checked block["signature"], so a thinking block with a valid-looking signature but empty (or whitespace-only) thinking text sailed through _drop_unsignable_thinking_blocks and into anthropic_messages_pt(). Anthropic rejects that with: 400 messages.N.content.M.thinking: each thinking block must contain thinking This is reachable whenever a thinking_blocks history item gets replayed through this Anthropic-shaped request path (e.g. a non-Anthropic reasoning turn with no summary text), the same class of bug PR #36033 fixed on the Responses adapter's own separate code path. Now the signature check runs first (unsigned blocks are still dropped, same as before), then an additional check drops the block if `thinking` is missing, not a string, or strips to empty. redacted_thinking blocks are untouched since they don't have type == "thinking". --- .../prompt_templates/factory.py | 14 +- ...llm_core_utils_prompt_templates_factory.py | 183 ++++++++++++++++++ 2 files changed, 196 insertions(+), 1 deletion(-) diff --git a/litellm/litellm_core_utils/prompt_templates/factory.py b/litellm/litellm_core_utils/prompt_templates/factory.py index b676077ab0e..22e0ea2cc33 100644 --- a/litellm/litellm_core_utils/prompt_templates/factory.py +++ b/litellm/litellm_core_utils/prompt_templates/factory.py @@ -2334,11 +2334,23 @@ def _is_unsignable_thinking_block(block: object) -> bool: signature is null, empty, or missing (e.g. from an open-source reasoning model) is rejected with a 400 and must be dropped rather than blanked or repaired. `redacted_thinking` blocks carry no signature and are always kept. + + Anthropic also rejects a `thinking` block whose `thinking` text is empty or + whitespace-only ("each thinking block must contain thinking"), regardless of + signature. This shape reaches us when a caller replays a `thinking_blocks` + history item that originated from a non-Anthropic reasoning provider (e.g. an + OpenAI Responses-API turn with no summary text) through this Anthropic-shaped + request path (`/v1/chat/completions` -> anthropic/vertex_ai's claude models), + which is the same failure the Anthropic Responses-bridge adapter guards + against (see PR #36033) for its own separate content-block path. """ if not isinstance(block, dict) or block.get("type") != "thinking": return False signature: Final = block.get("signature") - return not (isinstance(signature, str) and len(signature) > 0) + if not (isinstance(signature, str) and len(signature) > 0): + return True + thinking_text: Final = block.get("thinking") + return not (isinstance(thinking_text, str) and len(thinking_text.strip()) > 0) def _drop_unsignable_thinking_blocks( diff --git a/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py b/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py index 3a7e06d085a..532193f2a7f 100644 --- a/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py +++ b/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py @@ -3516,3 +3516,186 @@ async def test_bedrock_converse_pdf_only_user_message_gets_text_block_async(): assert len(result) == 1 assert any("document" in block for block in result[0]["content"]) assert _text_blocks(result[0]) == [BEDROCK_DOCUMENT_PLACEHOLDER_TEXT] + + +def test_anthropic_messages_pt_drops_empty_but_signed_thinking_block(): + """ + Anthropic rejects a `thinking` block whose `thinking` text is empty, even + when it carries a valid-looking signature, with: + 400 messages.N.content.M.thinking: each thinking block must contain thinking + This shape is reachable via cross-provider replay of a `thinking_blocks` + history item (see PR #36033), so `_is_unsignable_thinking_block()` must + also check the thinking text, not just the signature. + """ + from litellm.litellm_core_utils.prompt_templates.factory import ( + anthropic_messages_pt, + ) + + messages = [ + {"role": "user", "content": "What's 2+2?"}, + { + "role": "assistant", + "content": "4", + "thinking_blocks": [ + { + "type": "thinking", + "thinking": "", + "signature": "sig_abc123_looks_valid", + } + ], + }, + ] + + result = anthropic_messages_pt( + messages=messages, + model="claude-sonnet-4-5-20250929", + llm_provider="anthropic", + ) + + assistant_msg = result[1] + assert isinstance(assistant_msg["content"], list) + content_types = [block.get("type") for block in assistant_msg["content"]] + assert "thinking" not in content_types, "empty-text thinking block must be dropped even though it has a signature" + + +def test_anthropic_messages_pt_keeps_non_empty_signed_thinking_block(): + """ + Regression: a real, non-empty, signed thinking block must still pass + through unchanged. + """ + from litellm.litellm_core_utils.prompt_templates.factory import ( + anthropic_messages_pt, + ) + + messages = [ + {"role": "user", "content": "What's 2+2?"}, + { + "role": "assistant", + "content": "4", + "thinking_blocks": [ + { + "type": "thinking", + "thinking": "Let me add these numbers together.", + "signature": "sig_abc123_looks_valid", + } + ], + }, + ] + + result = anthropic_messages_pt( + messages=messages, + model="claude-sonnet-4-5-20250929", + llm_provider="anthropic", + ) + + assistant_msg = result[1] + assert isinstance(assistant_msg["content"], list) + thinking_block = next((b for b in assistant_msg["content"] if b.get("type") == "thinking"), None) + assert thinking_block is not None, "non-empty signed thinking block must be kept" + assert thinking_block["thinking"] == "Let me add these numbers together." + assert thinking_block["signature"] == "sig_abc123_looks_valid" + + +def test_anthropic_messages_pt_keeps_redacted_thinking_block(): + """ + Regression: `redacted_thinking` blocks carry no signature and no plaintext + `thinking` field by design, and must always be kept regardless of the new + emptiness check (which only applies to `type == "thinking"` blocks). + """ + from litellm.litellm_core_utils.prompt_templates.factory import ( + anthropic_messages_pt, + ) + + messages = [ + {"role": "user", "content": "What's 2+2?"}, + { + "role": "assistant", + "content": "4", + "thinking_blocks": [ + { + "type": "redacted_thinking", + "data": "encrypted_opaque_blob", + } + ], + }, + ] + + result = anthropic_messages_pt( + messages=messages, + model="claude-sonnet-4-5-20250929", + llm_provider="anthropic", + ) + + assistant_msg = result[1] + assert isinstance(assistant_msg["content"], list) + content_types = [block.get("type") for block in assistant_msg["content"]] + assert "redacted_thinking" in content_types, "redacted_thinking blocks must always be kept" + + +def test_anthropic_messages_pt_drops_unsigned_thinking_block(): + """ + Regression (pre-existing behaviour): a thinking block with no signature + (or an empty/null one) must still be dropped, independent of whether the + thinking text is populated. + """ + from litellm.litellm_core_utils.prompt_templates.factory import ( + anthropic_messages_pt, + ) + + messages = [ + {"role": "user", "content": "What's 2+2?"}, + { + "role": "assistant", + "content": "4", + "thinking_blocks": [ + { + "type": "thinking", + "thinking": "Let me add these numbers together.", + "signature": "", + } + ], + }, + ] + + result = anthropic_messages_pt( + messages=messages, + model="claude-sonnet-4-5-20250929", + llm_provider="anthropic", + ) + + assistant_msg = result[1] + assert isinstance(assistant_msg["content"], list) + content_types = [block.get("type") for block in assistant_msg["content"]] + assert "thinking" not in content_types, "unsigned thinking block must still be dropped" + + +def test_is_unsignable_thinking_block_treats_whitespace_only_as_empty(): + """ + Edge case: a `thinking` field that is present but whitespace-only (e.g. + a single trailing newline forwarded from another provider's empty + reasoning summary) is functionally empty and Anthropic's API will still + reject it with "each thinking block must contain thinking". We treat it + the same as a fully empty string and drop the block. + + Note the sibling check ~30 lines below this function's definition + (the "don't pass empty text blocks" comment) uses a bare + `len(thinking_block) > 0`, which by itself would treat whitespace-only + text as non-empty. That sibling check is always combined with + `not _is_unsignable_thinking_block(m)` in an `and`, so this function's + stricter whitespace-aware check is still the one that decides whether a + whitespace-only block survives — the two checks don't disagree in + practice, but this function is intentionally the stricter of the two + since it is also called standalone (via `_drop_unsignable_thinking_blocks`) + without that extra `len(...) > 0` guard. + """ + from litellm.litellm_core_utils.prompt_templates.factory import ( + _is_unsignable_thinking_block, + ) + + whitespace_only_block = { + "type": "thinking", + "thinking": " \n\t ", + "signature": "sig_abc123_looks_valid", + } + + assert _is_unsignable_thinking_block(whitespace_only_block) is True