diff --git a/litellm/llms/anthropic/pass_through/adapters/transformation.py b/litellm/llms/anthropic/pass_through/adapters/transformation.py index d1560c5b2ed..32bc01bdd8c 100644 --- a/litellm/llms/anthropic/pass_through/adapters/transformation.py +++ b/litellm/llms/anthropic/pass_through/adapters/transformation.py @@ -1671,8 +1671,11 @@ 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, signature=signature + type="thinking", thinking=thinking if signature else "", signature=signature ) # OpenAI-compatible reasoning backends (e.g. vLLM/SGLang reasoning # parsers) populate ``reasoning_content`` without ``thinking_blocks``. 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 2f0a7ad070b..be98c90ea2c 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 @@ -285,9 +285,10 @@ 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": "I need to summar", + "thinking": "", "signature": "", } 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