mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-16 04:29:31 +00:00
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:
parent
ca04c4909e
commit
897bfea38d
2 changed files with 59 additions and 23 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue