fix(anthropic): keep signed empty thinking blocks on /v1/messages requests (#41362)

The request-side sanitizer on /v1/messages dropped every thinking block whose
text is empty, even with a valid signature. Claude Opus 4.8 / Fable 5 / Fable 5.1
return exactly that shape by default (thinking.display omitted): the signature
is the preserved reasoning. Replaying the assistant turn as returned therefore
lost the model's reasoning on every multi-turn request, silently (the provider
answers 200 without it), and left preserved-thinking enforcement
(thinking.block_binding) nothing to bind.

Anthropic accepts an empty thinking block that carries a signature and binds it
to its prefix; only an empty block with no signature (the LIT-6357 case, a turn
a non-Anthropic model served through the bridge) is rejected with "each
thinking block must contain thinking". Use the signature-aware predicate
is_empty_unsigned_thinking_block, which #38809 already applied on the emit
side, for the request side too. The unsigned case is still dropped.

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Chloe Lu 2026-10-15 14:30:03 +08:00 • committed by GitHub
parent ca04c4909e
commit 897bfea38d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 59 additions and 23 deletions

View file

@ -1701,7 +1701,7 @@ def strip_empty_content_blocks_from_anthropic_messages(
) -> list[Any]:
"""
Return a new message list with empty or whitespace-only ``{"type": "text"}``
and ``{"type": "thinking"}`` content blocks removed.
content blocks and empty *unsigned* ``{"type": "thinking"}`` blocks removed.
Anthropic's API rejects requests containing such blocks with
``"messages: text content blocks must be non-empty"`` and
@ -1717,8 +1717,13 @@ def strip_empty_content_blocks_from_anthropic_messages(
on the unified ``/v1/messages`` path. ``/v1/chat/completions`` already
handles this in ``anthropic_messages_pt``; this helper provides the
equivalent guarantee for the native Anthropic Messages path.
``redacted_thinking`` blocks are never touched: they carry opaque
``data`` instead of thinking text.
A thinking block with empty text but a non-empty ``signature`` is kept:
it is the default response shape of Claude Opus 4.8 / Fable 5 / Fable 5.1
(``thinking.display`` omitted), the signature is the model's preserved
reasoning, and Anthropic accepts it on input. ``redacted_thinking``
blocks are never touched: they carry opaque ``data`` instead of thinking
text.
Messages whose content is a list and becomes empty after stripping are
omitted, matching :func:`strip_thinking_blocks_from_anthropic_messages`.
@ -1731,7 +1736,7 @@ def strip_empty_content_blocks_from_anthropic_messages(
out.append(m)
continue
content = m["content"]
filtered = [b for b in content if not _is_empty_text_block(b) and not is_empty_thinking_block(b)]
filtered = [b for b in content if not _is_empty_text_block(b) and not is_empty_unsigned_thinking_block(b)]
if len(filtered) == len(content):
out.append(m)
elif filtered:
@ -1749,11 +1754,11 @@ def _is_empty_text_block(block: object) -> bool:
def is_empty_thinking_block(block: object) -> bool:
"""
True for a ``{"type": "thinking"}`` content block whose thinking text is
missing, not a string, or empty/whitespace-only after ``.strip()``.
Anthropic rejects such blocks with ``"each thinking block must contain
thinking"`` (whitespace-only included, verified live), regardless of any
signature they carry. ``redacted_thinking`` blocks are a different type
and always return False.
missing, not a string, or empty/whitespace-only after ``.strip()``,
whatever its signature. This is a shape check only; whether such a block
may be sent to Anthropic depends on the signature, see
:func:`is_empty_unsigned_thinking_block`. ``redacted_thinking`` blocks are
a different type and always return False.
"""
if not isinstance(block, dict) or block.get("type") != "thinking":
return False
@ -1765,15 +1770,16 @@ def is_empty_unsigned_thinking_block(block: object) -> bool:
"""
True for an empty ``{"type": "thinking"}`` block carrying no signature.
The emit-side predicate: response paths drop a thinking block only when it
holds nothing the client could need. A signature-only block is a real
provider response (Bedrock Converse under adaptive thinking emits a
reasoning block with empty text and only a signature) and the client needs
the signature to replay reasoning across tool-use turns, so it must be
emitted. Request paths keep using :func:`is_empty_thinking_block`:
Anthropic rejects empty thinking blocks in request history regardless of
signature, and the inbound strip self-heals a replayed signature-only
block.
Used on both the emit and the request side: a thinking block is dropped
only when it holds nothing the provider or the client could use. A
signature-only block is a real provider response (Claude Opus 4.8 /
Fable 5 / Fable 5.1 return it by default, and Bedrock Converse under
adaptive thinking emits a reasoning block with empty text and only a
signature); the client needs the signature to replay reasoning across
tool-use turns, and Anthropic accepts the replayed block and binds it to
its prefix. Only an empty block with no signature (a turn a
non-Anthropic reasoning model served through the bridge) is rejected
with ``"each thinking block must contain thinking"`` and must go.
"""
if not isinstance(block, dict) or not is_empty_thinking_block(block):
return False

View file

@ -1475,11 +1475,11 @@ class TestAnthropicThinkingSignatureSelfHeal:
assert is_empty_unsigned_thinking_block({"type": "redacted_thinking", "data": "opaque"}) is False
assert is_empty_unsigned_thinking_block("not a dict") is False
def test_strip_empty_content_blocks_drops_empty_thinking_blocks(self):
def test_strip_empty_content_blocks_drops_empty_unsigned_thinking_blocks(self):
"""LIT-6357 ingestion half: an assistant tool-loop turn carrying an
empty (even signed) thinking block keeps its tool_use blocks and loses
the poison; whitespace-only counts as empty; a non-empty thinking block
and redacted_thinking are untouched."""
empty, unsigned thinking block keeps its tool_use blocks and loses the
poison; whitespace-only counts as empty; a non-empty thinking block and
redacted_thinking are untouched."""
from litellm.llms.anthropic.common_utils import (
strip_empty_content_blocks_from_anthropic_messages,
)
@ -1489,7 +1489,7 @@ class TestAnthropicThinkingSignatureSelfHeal:
{"role": "user", "content": "weather?"},
{
"role": "assistant",
"content": [{"type": "thinking", "thinking": "", "signature": "sig_abc"}, tu],
"content": [{"type": "thinking", "thinking": "", "signature": ""}, tu],
},
{
"role": "assistant",
@ -1508,6 +1508,36 @@ class TestAnthropicThinkingSignatureSelfHeal:
assert out[2]["content"][0]["thinking"] == "real plan"
assert len(msgs[1]["content"]) == 2
def test_strip_empty_content_blocks_keeps_signed_empty_thinking_blocks(self):
"""A thinking block with empty text and a signature is the default
response shape of Claude Opus 4.8 / Fable 5 / Fable 5.1 (thinking.display
omitted): the signature IS the preserved reasoning, Anthropic accepts it
on input and binds it to its prefix. Dropping it silently discards the
model's reasoning on every replayed turn and leaves preserved-thinking
enforcement (thinking.block_binding) nothing to check. The message and
block order must survive untouched."""
from litellm.llms.anthropic.common_utils import (
strip_empty_content_blocks_from_anthropic_messages,
)
tu = {"type": "tool_use", "id": "toolu_01A", "name": "get_weather", "input": {"city": "Paris"}}
signed = {"type": "thinking", "thinking": "", "signature": "EqQBCkYIBxgCKkD"}
msgs = [
{"role": "user", "content": "weather?"},
{"role": "assistant", "content": [signed, tu]},
{"role": "user", "content": [{"type": "tool_result", "tool_use_id": "toolu_01A", "content": "sunny"}]},
{
"role": "assistant",
"content": [signed, {"type": "text", "text": ""}, {"type": "text", "text": "Sunny."}],
},
]
out = strip_empty_content_blocks_from_anthropic_messages(msgs)
assert len(out) == 4
assert out[1] is msgs[1]
assert [b["type"] for b in out[3]["content"]] == ["thinking", "text"]
assert out[3]["content"][0] is signed
assert out[3]["content"][1]["text"] == "Sunny."
def test_strip_thinking_blocks_from_anthropic_messages(self):
from litellm.llms.anthropic.common_utils import (
strip_thinking_blocks_from_anthropic_messages,