From 8c64b7980cf9de0ad1bc21ef36e528599c40e25f Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 00:49:45 +0000 Subject: [PATCH] fix(responses): keep the reasoning item open while reasoning interleaves with content --- .../streaming_iterator.py | 20 ++++++-- .../test_reasoning_content_transformation.py | 50 +++++++++++++++++++ 2 files changed, 66 insertions(+), 4 deletions(-) diff --git a/litellm/responses/litellm_completion_transformation/streaming_iterator.py b/litellm/responses/litellm_completion_transformation/streaming_iterator.py index c7f999c7018..bd67aedfad2 100644 --- a/litellm/responses/litellm_completion_transformation/streaming_iterator.py +++ b/litellm/responses/litellm_completion_transformation/streaming_iterator.py @@ -174,13 +174,13 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator): chunk.model_copy(update=MappingProxyType({"choices": (content_choice,)})), ) - def _queue_reasoning_lifecycle_events(self, chunk: ModelResponseStream) -> None: + def _queue_reasoning_lifecycle_events(self, chunk: ModelResponseStream, *, ends_reasoning: bool) -> None: if not self._reasoning_active or self._reasoning_done_emitted: return delta: Final = chunk.choices[0].delta if chunk.choices else None if delta and getattr(delta, "reasoning_content", None): self._accumulated_reasoning_content_parts.append(delta.reasoning_content) - if not self._is_reasoning_end(chunk): + if not ends_reasoning: return reasoning_content: Final = "".join(self._accumulated_reasoning_content_parts) @@ -208,10 +208,22 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator): self._reasoning_done_emitted = True self._reasoning_active = False + def _chunk_ends_reasoning(self, chunk: ModelResponseStream, *, is_split: bool) -> bool: + if not chunk.choices: + return False + # A chunk carrying both reasoning and content only ends reasoning if the turn ends + # there too: providers that interleave the two keep emitting reasoning afterwards. + if is_split: + return chunk.choices[0].finish_reason is not None + return bool(self._is_reasoning_end(chunk)) + def _queue_events_for_chunk(self, chunk: ModelResponseStream) -> None: - for part in self._split_reasoning_and_content(chunk): + parts: Final = self._split_reasoning_and_content(chunk) + ends_reasoning: Final = self._chunk_ends_reasoning(chunk, is_split=len(parts) > 1) + for part in parts: self._ensure_output_item_for_chunk(part) - self._queue_reasoning_lifecycle_events(part) + if part is parts[0]: + self._queue_reasoning_lifecycle_events(part, ends_reasoning=ends_reasoning) response_api_chunk = self._transform_chat_completion_chunk_to_response_api_chunk(part) if response_api_chunk: self._pending_response_events.append(response_api_chunk) diff --git a/tests/test_litellm/responses/litellm_completion_transformation/test_reasoning_content_transformation.py b/tests/test_litellm/responses/litellm_completion_transformation/test_reasoning_content_transformation.py index 14b6f9c3d1f..4b4b569704c 100644 --- a/tests/test_litellm/responses/litellm_completion_transformation/test_reasoning_content_transformation.py +++ b/tests/test_litellm/responses/litellm_completion_transformation/test_reasoning_content_transformation.py @@ -375,6 +375,56 @@ def test_combined_reasoning_and_content_chunk_emits_both_deltas_sync(): assert "".join(text_deltas) == "MARKER_ALPHA begin middle end" +def _interleaved_reasoning_and_text_chunks() -> list[ModelResponseStream]: + def make( + content: str | None = None, + reasoning: str | None = None, + finish_reason: str | None = None, + ) -> ModelResponseStream: + return ModelResponseStream( + id="chatcmpl-interleaved", + created=1234567890, + model="test-model", + object="chat.completion.chunk", + choices=[ + StreamingChoices( + finish_reason=finish_reason, + index=0, + delta=Delta(content=content, role="assistant", reasoning_content=reasoning), + ) + ], + ) + + return [ + make(reasoning="think one ", content="answer one "), + make(reasoning="think two ", content="answer two "), + make(reasoning="think three", content="answer three"), + make(finish_reason="stop"), + ] + + +@pytest.mark.asyncio +async def test_interleaved_reasoning_stays_open_until_the_turn_ends(): + """Reasoning that keeps arriving alongside content must not be closed after the first chunk.""" + iterator = _new_iterator(_interleaved_reasoning_and_text_chunks()) + + types = [] + reasoning_deltas = [] + summary_done_text = None + async for event in iterator: + types.append(event.type) + if event.type == "response.reasoning_summary_text.delta": + reasoning_deltas.append(event.delta) + elif event.type == "response.reasoning_summary_text.done": + summary_done_text = event.text + + assert "".join(reasoning_deltas) == "think one think two think three" + assert summary_done_text == "".join(reasoning_deltas) + assert types.count("response.reasoning_summary_text.done") == 1 + last_reasoning_delta = len(types) - 1 - types[::-1].index("response.reasoning_summary_text.delta") + assert last_reasoning_delta < types.index("response.reasoning_summary_text.done") + + def test_streaming_chunk_id_raw(): """Test that streaming chunk IDs are raw (not encoded) to match OpenAI format""" chunk = ModelResponseStream(