fix(anthropic): stop 500 on combined thinking+signature streaming chunk (#33505)

This commit is contained in:
devin-ai-integration[bot] 2026-07-16 00:24:02 -07:00 committed by GitHub
parent bf3a058781
commit bbd52984b1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 100 additions and 20 deletions

View file

@ -1403,11 +1403,6 @@ class LiteLLMAnthropicMessagesAdapter:
assert isinstance(thinking, str)
assert isinstance(signature, str)
if thinking and signature:
raise ValueError(
"Both `thinking` and `signature` in a single streaming chunk isn't supported."
)
return "thinking", ChatCompletionThinkingBlock(
type="thinking", thinking=thinking, signature=signature
)
@ -1463,17 +1458,14 @@ class LiteLLMAnthropicMessagesAdapter:
if choice.delta.reasoning_content is not None:
reasoning_content += choice.delta.reasoning_content
if reasoning_content and reasoning_signature:
raise ValueError("Both `reasoning` and `signature` in a single streaming chunk isn't supported.")
if partial_json is not None:
return "input_json_delta", ContentJsonBlockDelta(type="input_json_delta", partial_json=partial_json)
elif reasoning_content:
return "thinking_delta", ContentThinkingBlockDelta(type="thinking_delta", thinking=reasoning_content)
elif reasoning_signature:
return "signature_delta", ContentThinkingSignatureBlockDelta(
type="signature_delta", signature=reasoning_signature
)
elif reasoning_content:
return "thinking_delta", ContentThinkingBlockDelta(type="thinking_delta", thinking=reasoning_content)
else:
return "text_delta", ContentTextBlockDelta(type="text_delta", text=text)

View file

@ -256,7 +256,14 @@ def test_translate_streaming_openai_chunk_to_anthropic_thinking_signature_block(
}
def test_translate_streaming_openai_chunk_to_anthropic_raises_when_thinking_and_signature_content_block():
def test_translate_streaming_openai_chunk_to_anthropic_content_block_thinking_and_signature():
"""The content-block classifier must treat a chunk carrying both ``thinking``
and ``signature`` as a ``thinking`` block instead of raising.
Such a chunk is the terminal signature event of an already-open thinking block,
so classifying it as ``thinking`` keeps the stream on the same block rather than
500'ing. Before the fix this raised ``ValueError``.
"""
choices = [
StreamingChoices(
finish_reason=None,
@ -289,10 +296,14 @@ def test_translate_streaming_openai_chunk_to_anthropic_raises_when_thinking_and_
)
]
with pytest.raises(ValueError):
LiteLLMAnthropicMessagesAdapter()._translate_streaming_openai_chunk_to_anthropic_content_block(
choices=choices
)
(
block_type,
content_block_start,
) = LiteLLMAnthropicMessagesAdapter()._translate_streaming_openai_chunk_to_anthropic_content_block(
choices=choices
)
assert block_type == "thinking"
def test_translate_anthropic_messages_to_openai_thinking_blocks():
@ -738,7 +749,17 @@ def test_translate_streaming_openai_chunk_to_anthropic_with_thinking():
assert content_block_delta["signature"] == "sigsig"
def test_translate_streaming_openai_chunk_to_anthropic_raises_when_thinking_and_signature():
def test_translate_streaming_openai_chunk_to_anthropic_emits_signature_when_thinking_and_signature():
"""A single streaming chunk carrying both ``thinking`` and ``signature`` must
translate to a ``signature_delta``, not crash.
litellm's Anthropic streaming handler emits the ``signature_delta`` event as an
OpenAI chunk whose ``thinking_blocks`` entry re-states the full accumulated
thinking text alongside the signature (see anthropic/chat/handler.py). That text
was already streamed as ``thinking_delta`` chunks, so the signature must win and
the duplicate thinking must not be re-emitted. Before the fix this raised
``ValueError`` and 500'd the whole stream, breaking Claude Code through the proxy.
"""
choices = [
StreamingChoices(
finish_reason=None,
@ -771,10 +792,25 @@ def test_translate_streaming_openai_chunk_to_anthropic_raises_when_thinking_and_
)
]
with pytest.raises(ValueError):
LiteLLMAnthropicMessagesAdapter()._translate_streaming_openai_chunk_to_anthropic(
choices=choices
)
adapter = LiteLLMAnthropicMessagesAdapter()
(
type_of_content,
content_block_delta,
) = adapter._translate_streaming_openai_chunk_to_anthropic(choices=choices)
assert type_of_content == "signature_delta"
assert content_block_delta["type"] == "signature_delta"
assert content_block_delta["signature"] == "sigsig"
(
block_type,
content_block_start,
) = adapter._translate_streaming_openai_chunk_to_anthropic_content_block(
choices=choices
)
assert block_type == "thinking"
def test_translate_anthropic_messages_to_openai_user_message_with_base64_image():

View file

@ -438,6 +438,58 @@ async def test_empty_reasoning_delta_mid_thinking_block_is_suppressed_async():
_assert_empty_reasoning_delta_suppressed(await _drain_async(wrapper))
def _full_snapshot_signature_chunks() -> List[MagicMock]:
"""Mirror litellm's real Anthropic streaming: incremental ``thinking_delta``
chunks (empty signature), then a terminal chunk whose ``thinking_blocks`` entry
re-states the *full accumulated thinking text* together with the signature
(anthropic/chat/handler.py builds the signature_delta event this way), then the
answer text.
"""
return [
_thinking_chunk("Let me "),
_thinking_chunk("think about it."),
_thinking_chunk("Let me think about it.", signature="sig-abc"),
_make_chunk(Delta(content="42")),
_make_chunk(Delta(content=None), finish_reason="stop"),
]
def _assert_full_snapshot_signature_handled(events: List[dict]) -> None:
_assert_deltas_match_their_block_type(events)
# The full-text snapshot on the signature chunk must NOT be re-emitted as an
# extra thinking_delta (it was already streamed incrementally) - otherwise the
# client renders the reasoning twice.
assert _thinking_deltas(events) == ["Let me ", "think about it."]
assert "".join(_thinking_deltas(events)) == "Let me think about it."
assert _signature_deltas(events) == ["sig-abc"]
assert _text_deltas(events) == ["42"]
def test_full_thinking_snapshot_with_signature_emits_signature_only_sync():
"""Regression: a terminal thinking chunk carrying both the full thinking text
and the signature used to raise ``ValueError`` (500) mid-stream, breaking every
Claude Code request routed through the proxy with an extended-thinking model. It
must instead emit a single ``signature_delta`` without duplicating the thinking.
"""
wrapper = AnthropicStreamWrapper(
completion_stream=iter(_full_snapshot_signature_chunks()),
model="claude-x",
)
_assert_full_snapshot_signature_handled(_drain_sync(wrapper))
@pytest.mark.asyncio
async def test_full_thinking_snapshot_with_signature_emits_signature_only_async():
"""Async twin - the proxy serves the async iterator, so the crash must be gone
on that path too.
"""
wrapper = AnthropicStreamWrapper(
completion_stream=_AsyncStream(_full_snapshot_signature_chunks()),
model="claude-x",
)
_assert_full_snapshot_signature_handled(await _drain_async(wrapper))
def test_empty_content_chunk_mid_text_block_is_suppressed_sync():
"""An empty-content chunk arriving mid-text-block (no transition) used to
emit a pointless ``text_delta {"text": ""}``; it must be dropped without