diff --git a/litellm/litellm_core_utils/prompt_templates/common_utils.py b/litellm/litellm_core_utils/prompt_templates/common_utils.py index 14d47a15c6d..41563d501a9 100644 --- a/litellm/litellm_core_utils/prompt_templates/common_utils.py +++ b/litellm/litellm_core_utils/prompt_templates/common_utils.py @@ -1992,11 +1992,14 @@ def is_encrypted_reasoning_block(block: object) -> bool: def is_unsignable_thinking_block(block: object) -> bool: """A thinking block Anthropic cannot accept on input. - Anthropic verifies the thinking signature cryptographically, so a block whose - 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, and - so is a block whose signature or data carries another provider's encrypted - reasoning. A `redacted_thinking` block Anthropic minted is always kept. + Anthropic verifies the signature cryptographically, so a block with a null, + empty, or missing signature (e.g. from an open-source reasoning model) is + rejected with a 400, and so is a block whose signature or data carries + another provider's encrypted reasoning. It also rejects a `thinking` block + whose text is empty or whitespace-only ("each thinking block must contain + thinking"), regardless of signature, e.g. when a `thinking_blocks` history + item from a non-Anthropic reasoning provider is replayed through this path. + `redacted_thinking` blocks carry no signature and are always kept. """ if is_encrypted_reasoning_block(block): return True @@ -2006,7 +2009,10 @@ def is_unsignable_thinking_block(block: object) -> bool: if mapping.get("type") != "thinking": return False signature: Final = mapping.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 = mapping.get("thinking") + return not (isinstance(thinking_text, str) and len(thinking_text.strip()) > 0) def strip_encrypted_reasoning_from_messages(messages: object) -> None: diff --git a/tests/unit/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py b/tests/unit/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py index 26124ac24de..0c08c5dfd85 100644 --- a/tests/unit/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py +++ b/tests/unit/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py @@ -3895,3 +3895,179 @@ def test_anthropic_messages_pt_drops_a_system_message_with_no_text(): result = anthropic_messages_pt(messages=messages, model="claude-opus-4-8", llm_provider="anthropic") assert [m["role"] for m in result] == ["user", "assistant"] + + +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. + + The check lives in the shared `is_unsignable_thinking_block` helper, which + `_drop_unsignable_thinking_blocks` calls standalone, so the whitespace-aware + test has to hold there rather than only at the factory call site. + """ + from litellm.litellm_core_utils.prompt_templates.common_utils 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