mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-09 22:31:41 +00:00
fix(anthropic): stop 500 on combined thinking+signature streaming chunk (#33505)
This commit is contained in:
parent
bf3a058781
commit
bbd52984b1
3 changed files with 100 additions and 20 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue