mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-06 08:16:43 +00:00
fix(anthropic): emit signature-only thinking blocks on the /v1/messages bridge (#38809)
This commit is contained in:
parent
5c034fda74
commit
36ea28b092
6 changed files with 104 additions and 30 deletions
|
|
@ -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_-]+$``
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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", "")
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue