Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -1403,11 +1403,6 @@ def _translate_streaming_openai_chunk_to_anthropic_content_block(
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
)
Expand Down Expand Up @@ -1463,17 +1458,14 @@ def _translate_streaming_openai_chunk_to_anthropic(
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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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():
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading