diff --git a/litellm/litellm_core_utils/streaming_chunk_builder_utils.py b/litellm/litellm_core_utils/streaming_chunk_builder_utils.py index fe7c62c3842..833ae4201ff 100644 --- a/litellm/litellm_core_utils/streaming_chunk_builder_utils.py +++ b/litellm/litellm_core_utils/streaming_chunk_builder_utils.py @@ -430,7 +430,18 @@ class ChunkProcessor: def get_combined_reasoning_content( self, chunks: List[Dict[str, Any]] ) -> ChatCompletionAssistantContentValue: - return self.get_combined_content(chunks, delta_key="reasoning_content") + content_list: List[str] = [] + for chunk in chunks: + choices = chunk["choices"] + for choice in choices: + delta = choice.get("delta", {}) + # Prefer reasoning_content; fall back to reasoning for providers + # (e.g. Scaleway) that use the raw 'reasoning' field name. + content = delta.get("reasoning_content") or delta.get("reasoning") or "" + if content is None: + continue + content_list.append(content) + return "".join(content_list) def get_combined_audio_content( self, chunks: List[Dict[str, Any]] diff --git a/litellm/main.py b/litellm/main.py index c3d1c2e05b0..374d810522e 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -7546,6 +7546,7 @@ def stream_chunk_builder( # noqa: PLR0915 delta.get("tool_calls") is not None or delta.get("function_call") is not None or delta.get("reasoning_content") is not None + or delta.get("reasoning") is not None or delta.get("thinking_blocks") is not None or delta.get("annotations") is not None or delta.get("audio") is not None @@ -7653,8 +7654,16 @@ def stream_chunk_builder( # noqa: PLR0915 chunk for chunk in chunks if len(chunk["choices"]) > 0 - and "reasoning_content" in chunk["choices"][0]["delta"] - and chunk["choices"][0]["delta"]["reasoning_content"] is not None + and ( + ( + "reasoning_content" in chunk["choices"][0]["delta"] + and chunk["choices"][0]["delta"]["reasoning_content"] is not None + ) + or ( + "reasoning" in chunk["choices"][0]["delta"] + and chunk["choices"][0]["delta"]["reasoning"] is not None + ) + ) ] if len(reasoning_chunks) > 0: diff --git a/tests/test_litellm/test_stream_chunk_builder_reasoning.py b/tests/test_litellm/test_stream_chunk_builder_reasoning.py new file mode 100644 index 00000000000..9e8315ca7bf --- /dev/null +++ b/tests/test_litellm/test_stream_chunk_builder_reasoning.py @@ -0,0 +1,147 @@ +""" +Tests for stream_chunk_builder handling of the raw 'reasoning' delta field. + +Some providers (e.g. Scaleway) return {"delta": {"reasoning": "..."}} in +streaming chunks instead of {"delta": {"reasoning_content": "..."}}. + +stream_chunk_builder must handle both field names so that: + - Chunks with 'reasoning' are not silently treated as empty text chunks + by the fast-path (is_simple_text_stream would wrongly stay True). + - Reasoning content is accumulated into the final response's + reasoning_content field regardless of which key the provider used. + +Regression guard for: https://github.com/BerriAI/litellm/issues/27670 +""" + +import pytest + +from litellm import stream_chunk_builder +from litellm.types.utils import Delta, ModelResponseStream, StreamingChoices + + +# --------------------------------------------------------------------------- +# helpers +# --------------------------------------------------------------------------- + + +def _make_delta_chunk( + model: str = "test-model", + reasoning_content: str | None = None, + content: str | None = None, + finish_reason: str | None = None, +) -> ModelResponseStream: + """Build a chunk backed by a proper Delta object (reasoning_content key).""" + delta = Delta(reasoning_content=reasoning_content, content=content) + return ModelResponseStream( + id="chatcmpl-test", + created=1700000000, + model=model, + object="chat.completion.chunk", + choices=[StreamingChoices(delta=delta, finish_reason=finish_reason, index=0)], + ) + + +def _make_raw_reasoning_chunk( + model: str = "test-model", + reasoning: str | None = None, + content: str | None = None, + finish_reason: str | None = None, +) -> ModelResponseStream: + """Build a chunk where the delta is a *dict* with the raw 'reasoning' key. + + This simulates chunks created from providers like Scaleway that use + {"delta": {"reasoning": "..."}} instead of {"reasoning_content": "..."}. + """ + delta_dict: dict = {} + if reasoning is not None: + delta_dict["reasoning"] = reasoning + if content is not None: + delta_dict["content"] = content + delta = Delta(**delta_dict) + return ModelResponseStream( + id="chatcmpl-test", + created=1700000000, + model=model, + object="chat.completion.chunk", + choices=[StreamingChoices(delta=delta, finish_reason=finish_reason, index=0)], + ) + + +# --------------------------------------------------------------------------- +# tests — Delta objects (reasoning_content key) — existing behaviour +# --------------------------------------------------------------------------- + + +def test_stream_chunk_builder_reasoning_content_accumulated(): + """Chunks with reasoning_content in Delta are accumulated correctly.""" + chunks = [ + _make_delta_chunk(reasoning_content="I need to think"), + _make_delta_chunk(reasoning_content=" about this"), + _make_delta_chunk(content="The answer is 42"), + _make_delta_chunk(finish_reason="stop"), + ] + result = stream_chunk_builder(chunks=chunks) + + assert result is not None + assert result.choices[0].message.content == "The answer is 42" + assert result.choices[0].message.reasoning_content == "I need to think about this" + + +# --------------------------------------------------------------------------- +# tests — raw dict with 'reasoning' key (provider like Scaleway) +# --------------------------------------------------------------------------- + + +def test_stream_chunk_builder_raw_reasoning_not_treated_as_simple_stream(): + """ + A stream where the delta carries 'reasoning' (not 'reasoning_content') + must NOT be classified as a simple text stream — otherwise reasoning + chunks are silently discarded. + """ + chunks = [ + _make_raw_reasoning_chunk(reasoning="I need to think"), + _make_raw_reasoning_chunk(reasoning=" about this"), + _make_raw_reasoning_chunk(content="The answer is 42"), + _make_raw_reasoning_chunk(finish_reason="stop"), + ] + result = stream_chunk_builder(chunks=chunks) + + assert result is not None, "stream_chunk_builder must not return None" + assert result.choices[0].message.content == "The answer is 42" + assert ( + result.choices[0].message.reasoning_content == "I need to think about this" + ), ( + "reasoning content from 'reasoning' field must be accumulated into " + "reasoning_content on the final message" + ) + + +def test_stream_chunk_builder_raw_reasoning_only_stream(): + """Reasoning-only stream (no content) with raw 'reasoning' key works.""" + chunks = [ + _make_raw_reasoning_chunk(reasoning="step one"), + _make_raw_reasoning_chunk(reasoning=" step two"), + _make_raw_reasoning_chunk(finish_reason="stop"), + ] + result = stream_chunk_builder(chunks=chunks) + + assert result is not None + assert result.choices[0].message.reasoning_content == "step one step two" + + +def test_stream_chunk_builder_mixed_reasoning_keys(): + """Chunks with both 'reasoning_content' and raw 'reasoning' are merged.""" + chunks = [ + _make_delta_chunk(reasoning_content="part A"), + _make_raw_reasoning_chunk(reasoning=" part B"), + _make_delta_chunk(content="answer"), + _make_delta_chunk(finish_reason="stop"), + ] + result = stream_chunk_builder(chunks=chunks) + + assert result is not None + assert result.choices[0].message.content == "answer" + rc = result.choices[0].message.reasoning_content + assert rc is not None + assert "part A" in rc + assert "part B" in rc