From 962b52d08fc184f131066c2029b62eb856753bf2 Mon Sep 17 00:00:00 2001 From: Yan233_ Date: Sun, 28 Jun 2026 04:57:36 +0800 Subject: [PATCH 1/4] fix(anthropic): reorder elif chain for reasoning_content and thinking_blocks in block classifier Cherry-pick onto updated upstream (force-pushed litellm_internal_staging). --- .../adapters/transformation.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py index 02625605f372..41000fe7f0da 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py @@ -1387,8 +1387,12 @@ def _translate_streaming_openai_chunk_to_anthropic_content_block( "signature": thought_sig, } return "tool_use", cast("ContentBlockContentBlockDict", tool_block) - elif choice.delta.content is not None and len(choice.delta.content) > 0: - return "text", TextBlock(type="text", text="") + elif ( + isinstance(choice, StreamingChoices) + and getattr(choice.delta, "reasoning_content", None) + and not hasattr(choice.delta, "thinking_blocks") + ): + return "thinking", ChatCompletionThinkingBlock(type="thinking", thinking="", signature="") elif isinstance(choice, StreamingChoices) and hasattr(choice.delta, "thinking_blocks"): thinking_blocks = choice.delta.thinking_blocks or [] if len(thinking_blocks) > 0: @@ -1408,13 +1412,9 @@ def _translate_streaming_openai_chunk_to_anthropic_content_block( return "thinking", ChatCompletionThinkingBlock( type="thinking", thinking=thinking, signature=signature ) - # OpenAI-compatible reasoning backends (e.g. vLLM/SGLang reasoning - # parsers) populate ``reasoning_content`` without ``thinking_blocks``. - # ``Delta`` deletes the ``thinking_blocks`` attribute when unset, so the - # branch above is skipped entirely; open a ``thinking`` block here so the - # matching ``thinking_delta`` stream is not emitted into a text block. - elif isinstance(choice, StreamingChoices) and getattr(choice.delta, "reasoning_content", None): - return "thinking", ChatCompletionThinkingBlock(type="thinking", thinking="", signature="") + + elif choice.delta.content is not None and len(choice.delta.content) > 0: + return "text", TextBlock(type="text", text="") return "text", TextBlock(type="text", text="") From 25cf4112e9b5a88431d9ae65c10c29f7aa9154b1 Mon Sep 17 00:00:00 2001 From: Yan233_ Date: Sat, 27 Jun 2026 15:29:56 +0800 Subject: [PATCH 2/4] test(anthropic): add direct unit test for content block classifier with reasoning_content Cover the new elif branch added in the parent fix to satisfy codecov/patch coverage requirements. --- ...al_pass_through_adapters_transformation.py | 143 ++++++++++++++++++ 1 file changed, 143 insertions(+) diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py index 45820b9833f5..3234615f220c 100644 --- a/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py @@ -3073,3 +3073,146 @@ def test_translate_anthropic_tools_to_openai_preserves_parameters_type(): params = new_tools[0]["function"]["parameters"] assert params["type"] == "object" assert new_tools[0]["type"] == "function" + + +def test_text_not_dropped_when_reasoning_content_shares_chunk(): + """ + Integration test: when content and reasoning_content share a streaming chunk, + the content block type must be correctly detected as "thinking", and the + text must reach the SSE output in its own text block. + + Without fix: _translate_streaming_openai_chunk_to_anthropic_content_block + returned "text" for chunks with both content and reasoning_content because + the ``elif content > 0`` check preceded the ``elif reasoning_content`` check. + This caused the thinking_delta to be emitted into the text block, corrupting + the output visible to Claude Code. + """ + from litellm.llms.anthropic.experimental_pass_through.adapters.streaming_iterator import ( + AnthropicStreamWrapper, + ) + + chunk1 = ModelResponseStream( + choices=[StreamingChoices( + index=0, finish_reason=None, + delta=Delta(content=". ", reasoning_content="I need to think", role="assistant"), + )], + ) + chunk2 = ModelResponseStream( + choices=[StreamingChoices( + index=0, finish_reason=None, + delta=Delta(content="The answer is 42.", role="assistant"), + )], + ) + chunk3 = ModelResponseStream( + choices=[StreamingChoices( + index=0, finish_reason="stop", delta=Delta(), + )], + ) + + wrapper = AnthropicStreamWrapper( + completion_stream=iter([chunk1, chunk2, chunk3]), + model="glm-5.2", + ) + + events = [] + while True: + try: + events.append(next(wrapper)) + except StopIteration: + break + + # Verify correct block structure + block_starts = [ + e for e in events + if isinstance(e, dict) and e.get("type") == "content_block_start" + ] + block_types = [b.get("content_block", {}).get("type") for b in block_starts] + + # There must be a thinking block (for reasoning_content) + assert "thinking" in block_types, ( + f"Expected a thinking block in SSE output, got block types: {block_types}" + ) + # There must be a text block (for the actual text) + assert "text" in block_types, ( + f"Expected a text block in SSE output, got block types: {block_types}" + ) + + # Verify text appears in text_delta, not in thinking_delta + text_deltas = [ + e for e in events + if isinstance(e, dict) and e.get("type") == "content_block_delta" + and isinstance(e.get("delta"), dict) + and e["delta"].get("type") == "text_delta" + ] + thinking_deltas = [ + e for e in events + if isinstance(e, dict) and e.get("type") == "content_block_delta" + and isinstance(e.get("delta"), dict) + and e["delta"].get("type") == "thinking_delta" + ] + + full_text = "".join(d["delta"].get("text", "") for d in text_deltas) + assert "The answer is 42." in full_text, ( + f"Response text should appear in text_deltas, got: {full_text!r}" + ) + + # Verify thinking content does not leak into text_delta + for td in text_deltas: + assert "I need to think" not in td["delta"].get("text", ""), ( + "Thinking content leaked into text_delta" + ) + + +def test_content_block_type_for_mixed_reasoning_and_content(): + """ + Direct unit test for _translate_streaming_openai_chunk_to_anthropic_content_block. + Covers the new elif branch added by the fix. + """ + adapter = LiteLLMAnthropicMessagesAdapter() + + # Both content and reasoning_content -> should open a thinking block + choices = [ + StreamingChoices( + finish_reason=None, + index=0, + delta=Delta( + reasoning_content="I need to think", + content=". ", + role="assistant", + function_call=None, + tool_calls=None, + audio=None, + ), + logprobs=None, + ) + ] + + block_type, cb = adapter._translate_streaming_openai_chunk_to_anthropic_content_block( + choices=choices + ) + + assert block_type == "thinking", ( + f"Expected 'thinking' for mixed chunk, got {block_type!r}" + ) + assert cb["type"] == "thinking" + + # Only content (no reasoning) -> should open a text block + text_choices = [ + StreamingChoices( + finish_reason=None, + index=0, + delta=Delta( + content="Hello", + role="assistant", + function_call=None, + tool_calls=None, + audio=None, + ), + logprobs=None, + ) + ] + + block_type2, _ = adapter._translate_streaming_openai_chunk_to_anthropic_content_block( + choices=text_choices + ) + assert block_type2 == "text" From 0af0bb3ed83ed2e70aa4c5c6e8cbc99380c48bdf Mon Sep 17 00:00:00 2001 From: Yan233_ Date: Sat, 27 Jun 2026 21:56:20 +0800 Subject: [PATCH 3/4] test(anthropic): assert thinking_deltas are emitted in integration test --- ...rimental_pass_through_adapters_transformation.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py index 3234615f220c..4b7b0396d216 100644 --- a/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py @@ -3162,6 +3162,19 @@ def test_text_not_dropped_when_reasoning_content_shares_chunk(): "Thinking content leaked into text_delta" ) + # Assert that the thinking content was actually emitted as thinking_deltas + assert len(thinking_deltas) > 0, ( + "Expected at least one thinking_delta for the reasoning_content chunk" + ) + + # The ". " prefix from chunk1's content field is lost by the translate + # function (pre-existing behavior, not addressed by this PR). Assert + # it does NOT appear in text_deltas so the scope of the fix is clear. + assert ". " not in full_text, ( + f"Shared-chunk text prefix should not survive into text_deltas; " + f"found in {full_text!r}" + ) + def test_content_block_type_for_mixed_reasoning_and_content(): """ From 204f6a5c38930e595f3bba33496547bf8dc008e1 Mon Sep 17 00:00:00 2001 From: Yan233_ Date: Sun, 28 Jun 2026 15:29:30 +0800 Subject: [PATCH 4/4] refactor(anthropic): order thinking_blocks before reasoning_content in block classifier Replace the negative 'not hasattr(thinking_blocks)' guard with a natural specific-before-general ordering: thinking_blocks (carries content) is checked first, then reasoning_content (empty placeholder), then text. Functionally equivalent but easier to read, and mirrors the independent-if ordering already used by the non-streaming _translate_openai_content_to_anthropic. --- .../adapters/transformation.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py index 41000fe7f0da..95e7ffe491f0 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py @@ -1387,12 +1387,10 @@ def _translate_streaming_openai_chunk_to_anthropic_content_block( "signature": thought_sig, } return "tool_use", cast("ContentBlockContentBlockDict", tool_block) - elif ( - isinstance(choice, StreamingChoices) - and getattr(choice.delta, "reasoning_content", None) - and not hasattr(choice.delta, "thinking_blocks") - ): - return "thinking", ChatCompletionThinkingBlock(type="thinking", thinking="", signature="") + # Order matters: thinking blocks (reasoning_content / thinking_blocks) + # must be classified before text, otherwise a chunk that carries both + # reasoning and a non-empty ``content`` would open a text block and the + # thinking_delta would leak into it. elif isinstance(choice, StreamingChoices) and hasattr(choice.delta, "thinking_blocks"): thinking_blocks = choice.delta.thinking_blocks or [] if len(thinking_blocks) > 0: @@ -1412,7 +1410,10 @@ def _translate_streaming_openai_chunk_to_anthropic_content_block( return "thinking", ChatCompletionThinkingBlock( type="thinking", thinking=thinking, signature=signature ) - + # OpenAI-compatible reasoning backends (e.g. vLLM/SGLang) populate + # ``reasoning_content`` without ``thinking_blocks``. + elif isinstance(choice, StreamingChoices) and getattr(choice.delta, "reasoning_content", None): + return "thinking", ChatCompletionThinkingBlock(type="thinking", thinking="", signature="") elif choice.delta.content is not None and len(choice.delta.content) > 0: return "text", TextBlock(type="text", text="")