From 8cb6322c3eac519e4216d70cf262248ec6e6bb8b Mon Sep 17 00:00:00 2001 From: Shubham Saha Date: Mon, 26 Oct 2026 13:43:09 -0700 Subject: [PATCH 1/2] fix(streaming): keep reasoning_details in stream_chunk_builder stream_chunk_builder did not copy delta.reasoning_details into the rebuilt message, so a streamed and rebuilt response had no reasoning_details. The non-streaming response keeps them in message.provider_specific_fields["reasoning_details"]. Collect the entries from every chunk in arrival order and put them in message.provider_specific_fields["reasoning_details"]. Do not merge entries by index: Vercel AI Gateway sends whole entries that all have index 0. Also stop the text-only fast path when a delta has reasoning_details, so that path does not skip them. Co-Authored-By: Claude Opus 5.5 --- .../streaming_chunk_builder_utils.py | 23 ++++++++++++--- .../test_streaming_chunk_builder_utils.py | 28 +++++++++++++++++++ 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/litellm/litellm_core_utils/streaming_chunk_builder_utils.py b/litellm/litellm_core_utils/streaming_chunk_builder_utils.py index 68cdc204af4..c9a27a8b518 100644 --- a/litellm/litellm_core_utils/streaming_chunk_builder_utils.py +++ b/litellm/litellm_core_utils/streaming_chunk_builder_utils.py @@ -1438,16 +1438,17 @@ def _field_of(value: object, name: str) -> object: return getattr(value, name, None) -def _delta_reasoning_items(delta: object) -> Sequence[object]: - items: Final = _field_of(delta, "reasoning_items") +def _delta_reasoning_items(delta: object, field: str = "reasoning_items") -> Sequence[object]: + items: Final = _field_of(delta, field) return cast(Sequence[object], items) if isinstance(items, list) else () # cast-ok: items forwarded verbatim -def _chunk_reasoning_items(chunk: object) -> Sequence[object]: +def _chunk_reasoning_items(chunk: object, field: str = "reasoning_items") -> Sequence[object]: choices: Final = _field_of(chunk, "choices") if not isinstance(choices, list) or not choices: return () - return _delta_reasoning_items(_field_of(cast(Sequence[object], choices)[0], "delta")) # cast-ok: untyped chunk + delta: Final = _field_of(cast(Sequence[object], choices)[0], "delta") # cast-ok: untyped chunk + return _delta_reasoning_items(delta, field) _NON_TEXT_DELTA_FIELDS: Final = ( @@ -1455,6 +1456,7 @@ _NON_TEXT_DELTA_FIELDS: Final = ( "function_call", "reasoning_content", "reasoning_items", + "reasoning_details", "thinking_blocks", "annotations", "audio", @@ -1725,6 +1727,19 @@ def stream_chunk_builder( _choice = response.choices[0] _choice.message.provider_specific_fields = combined_provider_fields + # Keep every streamed reasoning_details entry in arrival order, as the + # non-streaming response does. Do not merge by index: some providers + # send whole entries that all have index 0. + reasoning_details: Final = tuple( + chain.from_iterable(_chunk_reasoning_items(chunk, "reasoning_details") for chunk in typed_chunks) + ) + if reasoning_details: + _choice = response.choices[0] + _choice.message.provider_specific_fields = { + **(_choice.message.provider_specific_fields or {}), + "reasoning_details": list(reasoning_details), + } + completion_output = get_content_from_model_response(response) reasoning_tokens: Final = processor.count_reasoning_tokens(response) diff --git a/tests/unit/litellm_core_utils/test_streaming_chunk_builder_utils.py b/tests/unit/litellm_core_utils/test_streaming_chunk_builder_utils.py index 5c56245cf33..f076d9a7c8e 100644 --- a/tests/unit/litellm_core_utils/test_streaming_chunk_builder_utils.py +++ b/tests/unit/litellm_core_utils/test_streaming_chunk_builder_utils.py @@ -1154,6 +1154,34 @@ def test_stream_chunk_builder_tolerates_trailing_chunk_without_choices(): assert response.choices[0].message.content == "Hello world" +def test_stream_chunk_builder_keeps_reasoning_details_from_every_chunk(): + """A streamed `reasoning_details` list must rebuild into the same + `provider_specific_fields["reasoning_details"]` that a non-streaming + response carries: every entry, in arrival order, not merged by `index` + (Vercel AI Gateway sends whole entries that all have `index: 0`).""" + text_entry: Final = {"type": "reasoning.text", "text": "step one", "index": 0} + encrypted_entry: Final = {"type": "reasoning.encrypted", "data": "ENC-TWO", "index": 0} + chunks: Final = [ + ModelResponseStream( + model="m", + choices=[ + StreamingChoices(index=0, delta=Delta(role="assistant", content="Hi", reasoning_details=[text_entry])) + ], + ), + ModelResponseStream( + model="m", choices=[StreamingChoices(index=0, delta=Delta(reasoning_details=[encrypted_entry]))] + ), + ModelResponseStream(model="m", choices=[StreamingChoices(index=0, delta=Delta(content=" there"))]), + ] + + response = stream_chunk_builder(chunks=chunks) + + assert response is not None + message = response.choices[0].message + assert message.content == "Hi there" + assert message.provider_specific_fields == {"reasoning_details": [text_entry, encrypted_entry]} + + def test_anthropic_speed_and_geo_survive_stream_assembly(): """Anthropic prices fast mode and non-global regions with a multiplier read off ``usage.speed`` / ``usage.inference_geo``. Dropping them while reassembling a stream From d5268a349e6a117b31d2179339d4303a9d1709b8 Mon Sep 17 00:00:00 2001 From: Shubham Saha Date: Mon, 26 Oct 2026 13:49:30 -0700 Subject: [PATCH 2/2] fix(streaming): use a Final local for the rebuilt message Satisfies the LIT010 type-discipline gate. Co-Authored-By: Claude Opus 5.5 --- litellm/litellm_core_utils/streaming_chunk_builder_utils.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/litellm/litellm_core_utils/streaming_chunk_builder_utils.py b/litellm/litellm_core_utils/streaming_chunk_builder_utils.py index c9a27a8b518..f9b5189c3b8 100644 --- a/litellm/litellm_core_utils/streaming_chunk_builder_utils.py +++ b/litellm/litellm_core_utils/streaming_chunk_builder_utils.py @@ -1734,9 +1734,9 @@ def stream_chunk_builder( chain.from_iterable(_chunk_reasoning_items(chunk, "reasoning_details") for chunk in typed_chunks) ) if reasoning_details: - _choice = response.choices[0] - _choice.message.provider_specific_fields = { - **(_choice.message.provider_specific_fields or {}), + message: Final = response.choices[0].message + message.provider_specific_fields = { + **(message.provider_specific_fields or {}), "reasoning_details": list(reasoning_details), }