diff --git a/litellm/litellm_core_utils/streaming_chunk_builder_utils.py b/litellm/litellm_core_utils/streaming_chunk_builder_utils.py index c87357122ce..118a2282a16 100644 --- a/litellm/litellm_core_utils/streaming_chunk_builder_utils.py +++ b/litellm/litellm_core_utils/streaming_chunk_builder_utils.py @@ -1535,8 +1535,8 @@ def _streamed_vllm_choice_fields(choices: Iterable[object]) -> Mapping[str, obje return MappingProxyType({field: streamed_values[field] for field in VLLM_CHOICE_FIELDS if field in present_fields}) -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 @@ -1547,8 +1547,8 @@ def _chunk_first_delta(chunk: object) -> object: return _field_of(cast(Sequence[object], choices)[0], "delta") # cast-ok: untyped chunk -def _chunk_reasoning_items(chunk: object) -> Sequence[object]: - return _delta_reasoning_items(_chunk_first_delta(chunk)) +def _chunk_reasoning_items(chunk: object, field: str = "reasoning_items") -> Sequence[object]: + return _delta_reasoning_items(_chunk_first_delta(chunk), field) def _chunk_refusal(chunk: object) -> str: @@ -1561,6 +1561,7 @@ _NON_TEXT_DELTA_FIELDS: Final = ( "function_call", "reasoning_content", "reasoning_items", + "reasoning_details", "thinking_blocks", "annotations", "audio", @@ -1789,9 +1790,16 @@ def _assembled_message_fields(processor: ChunkProcessor, chunks: Sequence) -> It ) citation_fields: Final = {"citations": _joined_streamed_citations(streamed_citations)} if streamed_citations else {} provider_annotation_fields: Final = {"annotations": list(provider_annotations)} if provider_annotations else {} + # 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) + ) + reasoning_detail_fields: Final = {"reasoning_details": list(reasoning_details)} if reasoning_details else {} combined_provider_fields: Final = { key: value - for fields in (provider_annotation_fields, citation_fields, *provider_field_dicts) + for fields in (provider_annotation_fields, citation_fields, *provider_field_dicts, reasoning_detail_fields) for key, value in fields.items() if key != "citation" } 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 527c83d5760..9dca75caa23 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 @@ -1233,6 +1233,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