fix(anthropic): emit signature-only thinking blocks on the /v1/messages bridge (#38809)

This commit is contained in:
tin-berri 2026-08-29 15:04:22 -07:00 committed by GitHub
parent 5c034fda74
commit 36ea28b092
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 104 additions and 30 deletions

View file

@ -1099,6 +1099,25 @@ def is_empty_thinking_block(block: object) -> bool:
return not isinstance(thinking, str) or not thinking.strip()
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.
"""
if not isinstance(block, dict) or not is_empty_thinking_block(block):
return False
return not block.get("signature")
def normalize_anthropic_tool_use_id(raw_id: str) -> str:
"""
Normalize a tool_use / tool_result id for Anthropic's ``^[a-zA-Z0-9_-]+$``

View file

@ -1029,7 +1029,7 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper):
@staticmethod
def _is_blank_delta(chunk: "ModelResponseStream") -> bool:
from litellm.llms.anthropic.common_utils import is_empty_thinking_block
from litellm.llms.anthropic.common_utils import is_empty_unsigned_thinking_block
choice: Final = chunk.choices[0]
if choice.finish_reason is not None:
@ -1041,11 +1041,14 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper):
return False
if getattr(delta, "reasoning_content", None):
return False
# thinking_blocks whose entries are all empty (even if signed) must not
# thinking_blocks whose entries are all empty AND unsigned must not
# open a block: the emitted {"type": "thinking", "thinking": ""} gets
# replayed as history and Anthropic rejects it (LIT-6357).
# replayed as history and Anthropic rejects it (LIT-6357). A signed
# entry opens the block so the client receives the replay signature.
thinking_blocks: Final = getattr(delta, "thinking_blocks", None)
if thinking_blocks and any(isinstance(b, dict) and not is_empty_thinking_block(b) for b in thinking_blocks):
if thinking_blocks and any(
isinstance(b, dict) and not is_empty_unsigned_thinking_block(b) for b in thinking_blocks
):
return False
return True

View file

@ -90,7 +90,7 @@ from litellm.litellm_core_utils.reasoning_effort_utils import (
reasoning_effort_from_thinking_budget,
)
from litellm.llms.anthropic.common_utils import (
is_empty_thinking_block,
is_empty_unsigned_thinking_block,
normalize_anthropic_tool_use_id,
)
from litellm.llms.anthropic.experimental_pass_through.context_management import (
@ -1267,7 +1267,7 @@ class LiteLLMAnthropicMessagesAdapter:
if hasattr(choice.message, "thinking_blocks") and choice.message.thinking_blocks:
for thinking_block in choice.message.thinking_blocks:
if thinking_block.get("type") == "thinking":
if is_empty_thinking_block(thinking_block):
if is_empty_unsigned_thinking_block(thinking_block):
continue
thinking_value = thinking_block.get("thinking", "")
signature_value = thinking_block.get("signature", "")

View file

@ -1013,12 +1013,15 @@ def test_translate_openai_content_to_anthropic_thinking_and_redacted_thinking():
assert result[1]["data"] == "REDACTED"
def test_translate_openai_content_to_anthropic_drops_empty_thinking_blocks():
"""LIT-6357 non-streaming producer half: a bridged reasoning model whose
thinking_blocks entry has empty or whitespace-only text (signed or not)
must not surface as {"type": "thinking", "thinking": ""} clients replay
it as history and Anthropic 400s with "each thinking block must contain
thinking". Non-empty thinking and redacted_thinking pass through."""
def test_translate_openai_content_to_anthropic_drops_empty_unsigned_thinking_blocks():
"""LIT-6357 non-streaming producer half, narrowed to unsigned blocks: a
bridged reasoning model whose thinking_blocks entry has empty or
whitespace-only text and no signature must not surface as
{"type": "thinking", "thinking": ""}. A signature-only block (Bedrock
Converse adaptive thinking) must be emitted so the client keeps the
signature for tool-use replay; the inbound strip self-heals it if the
client loops it back. Non-empty thinking and redacted_thinking pass
through."""
openai_choices = [
Choices(
message=Message(
@ -1037,9 +1040,11 @@ def test_translate_openai_content_to_anthropic_drops_empty_thinking_blocks():
adapter = LiteLLMAnthropicMessagesAdapter()
result = adapter._translate_openai_content_to_anthropic(choices=openai_choices)
assert [b["type"] for b in result] == ["thinking", "redacted_thinking", "text"]
assert result[0]["thinking"] == "real plan"
assert result[1]["data"] == "REDACTED"
assert [b["type"] for b in result] == ["thinking", "thinking", "redacted_thinking", "text"]
assert result[0]["thinking"] == ""
assert result[0]["signature"] == "sig_abc"
assert result[1]["thinking"] == "real plan"
assert result[2]["data"] == "REDACTED"
def test_translate_streaming_openai_chunk_to_anthropic_thinking_delta():

View file

@ -1048,19 +1048,20 @@ def _empty_thinking_then_tool_chunks(thinking: str = "", signature: str = "") ->
@pytest.mark.parametrize("is_async", [False, True])
@pytest.mark.parametrize(
"thinking,signature",
[("", ""), (" \n\t ", ""), ("", "sig_abc")],
ids=["empty", "whitespace-only", "empty-but-signed"],
[("", ""), (" \n\t ", "")],
ids=["empty", "whitespace-only"],
)
@pytest.mark.asyncio
async def test_contentless_thinking_chunk_opens_no_thinking_block(is_async: bool, thinking: str, signature: str):
"""LIT-6357 producer half: a reasoning model that goes straight to tool
calls streams a ``thinking_blocks`` entry with no real thinking text; the
wrapper used to open ``{"type": "thinking", "thinking": ""}`` for it and
close the block with no delta. Clients (Claude Code) replay that block as
history and Anthropic rejects the next tool-loop request with
"each thinking block must contain thinking" empty-but-signed included.
The contentless chunk must open nothing; the tool_use block must be
unaffected."""
calls streams a ``thinking_blocks`` entry with no real thinking text and
no signature; the wrapper used to open ``{"type": "thinking",
"thinking": ""}`` for it and close the block with no delta. Clients
(Claude Code) replay that block as history and Anthropic rejects the next
tool-loop request with "each thinking block must contain thinking".
The contentless unsigned chunk must open nothing; the tool_use block must
be unaffected. A SIGNED contentless chunk is different: see
test_signature_only_thinking_chunk_opens_signed_block."""
chunks = _empty_thinking_then_tool_chunks(thinking, signature)
if is_async:
wrapper = AnthropicStreamWrapper(completion_stream=_AsyncStream(chunks), model="claude-x")
@ -1138,11 +1139,38 @@ async def test_early_signature_on_blank_thinking_chunk_is_carried_to_the_opened_
@pytest.mark.parametrize("is_async", [False, True])
@pytest.mark.asyncio
async def test_early_signature_discarded_when_first_block_is_not_thinking(is_async: bool):
"""An early signature from a skipped blank thinking chunk must not leak
into a text or tool_use first block, and must not resurrect an empty
thinking block on its own (an empty-but-signed block is exactly what
Anthropic rejects)."""
async def test_signature_only_thinking_chunk_opens_signed_block(is_async: bool):
"""Bedrock Converse under adaptive thinking emits a reasoning delta with
empty text and only a signature. The signed chunk must open a thinking
block that carries the signature to the client (needed to replay reasoning
across tool-use turns); the tool_use block must be unaffected. Dropping it
like the unsigned case regressed the claude_code thinking e2e cells."""
chunks = _empty_thinking_then_tool_chunks("", "sig_bedrock")
if is_async:
wrapper = AnthropicStreamWrapper(completion_stream=_AsyncStream(chunks), model="claude-x")
events = await _drain_async(wrapper)
else:
wrapper = AnthropicStreamWrapper(completion_stream=iter(chunks), model="claude-x")
events = _drain_sync(wrapper)
starts = _thinking_block_starts(events)
assert len(starts) == 1
assert starts[0].get("signature") == "sig_bedrock" or _signature_deltas(events) == ["sig_bedrock"]
assert _thinking_deltas(events) == []
tool_starts = [
e["content_block"]
for e in events
if e.get("type") == "content_block_start" and e["content_block"].get("type") == "tool_use"
]
assert [b["name"] for b in tool_starts] == ["get_weather"]
_assert_deltas_match_their_block_type(events)
@pytest.mark.parametrize("is_async", [False, True])
@pytest.mark.asyncio
async def test_signature_only_thinking_chunk_before_text_leaks_no_signature(is_async: bool):
"""The signed thinking block a signature-only chunk opens must stay its
own block: the text block that follows carries no signature."""
chunks = [
_thinking_chunk("", signature="sig_early"),
_make_chunk(Delta(content="Hello")),
@ -1155,7 +1183,9 @@ async def test_early_signature_discarded_when_first_block_is_not_thinking(is_asy
wrapper = AnthropicStreamWrapper(completion_stream=iter(chunks), model="claude-x")
events = _drain_sync(wrapper)
assert _thinking_block_starts(events) == []
starts = _thinking_block_starts(events)
assert len(starts) == 1
assert starts[0].get("signature") == "sig_early" or _signature_deltas(events) == ["sig_early"]
text_starts = [
e["content_block"]
for e in events

View file

@ -1364,6 +1364,23 @@ class TestAnthropicThinkingSignatureSelfHeal:
assert is_empty_thinking_block({"type": "text", "text": ""}) is False
assert is_empty_thinking_block("not a dict") is False
def test_is_empty_unsigned_thinking_block(self):
"""Emit-side predicate: a signature-only block must be kept (Bedrock
Converse adaptive thinking emits empty text with only a signature, and
the client needs it to replay reasoning in tool-use turns); only an
empty block with nothing to preserve is droppable."""
from litellm.llms.anthropic.common_utils import is_empty_unsigned_thinking_block
assert is_empty_unsigned_thinking_block({"type": "thinking", "thinking": ""}) is True
assert is_empty_unsigned_thinking_block({"type": "thinking", "thinking": " \n\t "}) is True
assert is_empty_unsigned_thinking_block({"type": "thinking"}) is True
assert is_empty_unsigned_thinking_block({"type": "thinking", "thinking": "", "signature": ""}) is True
assert is_empty_unsigned_thinking_block({"type": "thinking", "thinking": "", "signature": "sig_abc"}) is False
assert is_empty_unsigned_thinking_block({"type": "thinking", "thinking": " ", "signature": "sig_abc"}) is False
assert is_empty_unsigned_thinking_block({"type": "thinking", "thinking": "plan"}) is False
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):
"""LIT-6357 ingestion half: an assistant tool-loop turn carrying an
empty (even signed) thinking block keeps its tool_use blocks and loses