mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-26 01:12:21 +00:00
fix(stream_chunk_builder): handle raw 'reasoning' delta key alongside 'reasoning_content'
Some providers (e.g. Scaleway) return {"delta": {"reasoning": "..."}} in
streaming chunks instead of {"delta": {"reasoning_content": "..."}}.
stream_chunk_builder's fast-path check and reasoning-chunk accumulation
only checked the 'reasoning_content' key, so:
• Chunks with raw 'reasoning' were classified as simple-text chunks,
causing the fast path to silently discard all reasoning content.
• The reasoning_chunks filter skipped those chunks entirely, so the
final message's reasoning_content field was never populated.
• get_combined_reasoning_content also only read 'reasoning_content',
so even if a chunk slipped through the filter it produced empty output.
Fix: add 'reasoning' as an accepted alternative to 'reasoning_content' in
all three spots. The streaming handler's Delta.__init__ already maps
'reasoning' → 'reasoning_content' for the normal async-iteration path
(commit e48b7ae8), but stream_chunk_builder is a public function that can
be called with raw dict chunks that have not been through that mapping.
Closes #27670
https://claude.ai/code/session_01NBiFzhjQDkqrSLfxZbQry8
This commit is contained in:
parent
e58a561caa
commit
9694a11645
3 changed files with 170 additions and 3 deletions
|
|
@ -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]]
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
147
tests/test_litellm/test_stream_chunk_builder_reasoning.py
Normal file
147
tests/test_litellm/test_stream_chunk_builder_reasoning.py
Normal file
|
|
@ -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
|
||||
Loading…
Add table
Reference in a new issue