diff --git a/litellm/llms/anthropic/pass_through/adapters/transformation.py b/litellm/llms/anthropic/pass_through/adapters/transformation.py index 9b1297dbd75..a110af5a4b9 100644 --- a/litellm/llms/anthropic/pass_through/adapters/transformation.py +++ b/litellm/llms/anthropic/pass_through/adapters/transformation.py @@ -1692,6 +1692,9 @@ class LiteLLMAnthropicMessagesAdapter: assert isinstance(thinking, str) assert isinstance(signature, str) + # Without a signature the same text arrives again as a + # thinking_delta, so an SSE accumulator seeding from this + # start body would show it twice. return "thinking", ChatCompletionThinkingBlock( type="thinking", thinking=thinking if signature else "", signature=signature ) diff --git a/tests/unit/llms/anthropic/pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py b/tests/unit/llms/anthropic/pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py index 658aa009683..654dcac7c53 100644 --- a/tests/unit/llms/anthropic/pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py +++ b/tests/unit/llms/anthropic/pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py @@ -288,6 +288,7 @@ def test_translate_streaming_openai_chunk_to_anthropic_thinking_content_block(): ) = LiteLLMAnthropicMessagesAdapter().translate_streaming_openai_chunk_to_anthropic_content_block(choices=choices) assert block_type == "thinking" + # Unsigned: the text is delivered by the thinking_delta, so the start body stays empty. assert content_block_start == { "type": "thinking", "thinking": "", diff --git a/tests/unit/llms/anthropic/pass_through/adapters/test_streaming_iterator_first_delta.py b/tests/unit/llms/anthropic/pass_through/adapters/test_streaming_iterator_first_delta.py index 92234282bb2..1e876450520 100644 --- a/tests/unit/llms/anthropic/pass_through/adapters/test_streaming_iterator_first_delta.py +++ b/tests/unit/llms/anthropic/pass_through/adapters/test_streaming_iterator_first_delta.py @@ -948,6 +948,27 @@ def test_mixed_thinking_start_body_is_empty_and_thinking_not_doubled_sync(): assert _text_deltas(events) == ["Answer."] +def test_pure_thinking_start_body_is_empty_and_thinking_not_doubled_sync(): + chunks = [ + _thinking_chunk("Hello"), + _thinking_chunk(" world"), + _make_chunk(Delta(content="answer")), + _make_chunk(Delta(content=None), finish_reason="stop"), + ] + wrapper = AnthropicStreamWrapper(completion_stream=iter(chunks), model="claude-x") + events = _drain_sync(wrapper) + + accumulated = "" + for event in events: + if event.get("type") == "content_block_start" and event["content_block"].get("type") == "thinking": + assert not event["content_block"].get("thinking"), event["content_block"] + accumulated += event["content_block"].get("thinking") or "" + if event.get("type") == "content_block_delta" and event["delta"].get("type") == "thinking_delta": + accumulated += event["delta"]["thinking"] + assert accumulated == "Hello world" + assert _text_deltas(events) == ["answer"] + + def test_mixed_chunk_with_tool_argument_continuation_is_not_split_sync(): """Streaming providers send a tool call's name only on its first chunk; later chunks carry argument fragments with ``name=None``. Splitting a