fix(anthropic): drop thinking blocks with empty thinking text, not just missing signature 🧠🚫 (#38049)

_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".
This commit is contained in:
Jeremy Schoemaker 2026-09-26 22:17:44 -05:00 • committed by GitHub
parent 9ba552d527
commit ba2d1c2785
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 188 additions and 6 deletions

View file

@ -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:

View file

@ -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