diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py index ce328833629..156c25a0a1a 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py @@ -1470,8 +1470,8 @@ class LiteLLMAnthropicMessagesAdapter: 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 hasattr(choice.delta, "thinking_blocks"): - thinking_blocks = choice.delta.thinking_blocks or [] + elif isinstance(choice, StreamingChoices): + thinking_blocks = getattr(choice.delta, "thinking_blocks", None) or [] if len(thinking_blocks) > 0: thinking_block = thinking_blocks[0] if thinking_block["type"] == "thinking": @@ -1484,16 +1484,33 @@ class LiteLLMAnthropicMessagesAdapter: 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="") + if getattr(choice.delta, "reasoning_content", None): + return "thinking", ChatCompletionThinkingBlock(type="thinking", thinking="", signature="") return "text", TextBlock(type="text", text="") + @staticmethod + def _streaming_reasoning_fields(choice: StreamingChoices) -> tuple[str, str]: + reasoning_content = "" + reasoning_signature = "" + thinking_blocks = getattr(choice.delta, "thinking_blocks", None) or [] + for thinking_block in thinking_blocks: + if thinking_block["type"] == "thinking": + thinking = thinking_block.get("thinking") or "" + signature = thinking_block.get("signature") or "" + + assert isinstance(thinking, str) + assert isinstance(signature, str) + + reasoning_content += thinking + reasoning_signature += signature + + if reasoning_content or reasoning_signature: + return reasoning_content, reasoning_signature + + fallback = getattr(choice.delta, "reasoning_content", None) + return fallback or "", "" + def _translate_streaming_openai_chunk_to_anthropic( self, choices: list[OpenAIStreamingChoice | StreamingChoices] ) -> tuple[ @@ -1512,24 +1529,10 @@ class LiteLLMAnthropicMessagesAdapter: for tool in choice.delta.tool_calls: if tool.function is not None and tool.function.arguments is not None: partial_json = (partial_json or "") + tool.function.arguments - elif isinstance(choice, StreamingChoices) and hasattr(choice.delta, "thinking_blocks"): - thinking_blocks = choice.delta.thinking_blocks or [] - if len(thinking_blocks) > 0: - for thinking_block in thinking_blocks: - if thinking_block["type"] == "thinking": - thinking = thinking_block.get("thinking") or "" - signature = thinking_block.get("signature") or "" - - assert isinstance(thinking, str) - assert isinstance(signature, str) - - reasoning_content += thinking - reasoning_signature += signature - # Handle reasoning_content when thinking_blocks is not present - # This handles providers like OpenRouter that return reasoning_content - elif isinstance(choice, StreamingChoices) and hasattr(choice.delta, "reasoning_content"): - if choice.delta.reasoning_content is not None: - reasoning_content += choice.delta.reasoning_content + elif isinstance(choice, StreamingChoices): + choice_reasoning_content, choice_reasoning_signature = self._streaming_reasoning_fields(choice) + reasoning_content += choice_reasoning_content + reasoning_signature += choice_reasoning_signature if partial_json is not None: return "input_json_delta", ContentJsonBlockDelta(type="input_json_delta", partial_json=partial_json) diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_streaming_iterator_first_delta.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_streaming_iterator_first_delta.py index f64ffb6d233..46ab7200c2f 100644 --- a/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_streaming_iterator_first_delta.py +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_streaming_iterator_first_delta.py @@ -573,6 +573,43 @@ def test_reasoning_content_first_stream_opens_thinking_block_at_index_zero_sync( _assert_thinking_first_block_opens_at_index_zero(_drain_sync(wrapper)) +@pytest.mark.parametrize( + "thinking_blocks", + [ + pytest.param([], id="empty"), + pytest.param( + [{"type": "redacted_thinking", "data": "redacted"}], + id="redacted-only", + ), + ], +) +def test_reasoning_content_falls_back_without_usable_thinking_blocks_sync( + thinking_blocks, +): + chunks = [ + _make_chunk( + Delta( + content=None, + reasoning_content="fallback thought", + thinking_blocks=thinking_blocks, + ) + ), + _make_chunk(Delta(content=None), finish_reason="stop"), + ] + wrapper = AnthropicStreamWrapper(completion_stream=iter(chunks), model="claude-x") + events = _drain_sync(wrapper) + + starts = [ + (event["index"], event["content_block"]["type"]) + for event in events + if event.get("type") == "content_block_start" + ] + assert starts == [(0, "thinking")] + assert _thinking_deltas(events) == ["fallback thought"] + assert _text_deltas(events) == [] + _assert_deltas_match_their_block_type(events) + + def _blank_lead_chunks() -> List[MagicMock]: return [ _make_chunk(Delta(content=None)),