diff --git a/litellm/responses/litellm_completion_transformation/streaming_iterator.py b/litellm/responses/litellm_completion_transformation/streaming_iterator.py index b241107d43d..83e7bfc5e60 100644 --- a/litellm/responses/litellm_completion_transformation/streaming_iterator.py +++ b/litellm/responses/litellm_completion_transformation/streaming_iterator.py @@ -177,6 +177,7 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator): self._cached_reasoning_item_id: str | None = None self._sent_reasoning_summary_text_done_event: bool = False self._sent_reasoning_summary_part_done_event: bool = False + self._sent_reasoning_summary_part_added_event: bool = False self._reasoning_summary_text: str = "" # -- GENERIC RESPONSE-EVENTS PENDING QUEUE as required by fix -- self._pending_response_events: list[BaseLiteLLMOpenAIResponseObject] = [] @@ -1035,12 +1036,7 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator): **{ "id": reasoning_item_id, "type": "reasoning", - "summary": [ - { - "type": "summary_text", - "text": reasoning_content, - } - ], + "summary": [{"type": "summary_text", "text": reasoning_content}] if reasoning_content else [], } ), ) @@ -1157,9 +1153,7 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator): ), ) event.__dict__["sequence_number"] = self._sequence_number - self._pending_response_events.extend( - [event, self.create_reasoning_summary_part_added_event(self._cached_reasoning_item_id)] - ) + self._pending_response_events.append(event) return # Tool-first @@ -1186,25 +1180,27 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator): ) reasoning_item_id: Final = self._cached_reasoning_item_id self._queue_compaction_item_done() - self._sequence_number += 1 - text_done_event: Final = self.create_reasoning_summary_text_done_event( - reasoning_item_id=reasoning_item_id, - reasoning_content=reasoning_content, - sequence_number=self._sequence_number, - ) - self._sequence_number += 1 - part_done_event: Final = self.create_reasoning_summary_part_done_event( - reasoning_item_id=reasoning_item_id, - reasoning_content=reasoning_content, - sequence_number=self._sequence_number, - ) + if reasoning_content: + self._sequence_number += 1 + text_done_event: Final = self.create_reasoning_summary_text_done_event( + reasoning_item_id=reasoning_item_id, + reasoning_content=reasoning_content, + sequence_number=self._sequence_number, + ) + self._sequence_number += 1 + part_done_event: Final = self.create_reasoning_summary_part_done_event( + reasoning_item_id=reasoning_item_id, + reasoning_content=reasoning_content, + sequence_number=self._sequence_number, + ) + self._pending_response_events.extend([text_done_event, part_done_event]) self._sequence_number += 1 reasoning_output_item_done_event: Final = self.create_reasoning_output_item_done_event( reasoning_item_id=reasoning_item_id, reasoning_content=reasoning_content, sequence_number=self._sequence_number, ) - self._pending_response_events.extend([text_done_event, part_done_event, reasoning_output_item_done_event]) + self._pending_response_events.append(reasoning_output_item_done_event) self._reasoning_done_emitted = True self._reasoning_active = False @@ -1377,6 +1373,11 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator): if self._cached_reasoning_item_id is None: self._cached_reasoning_item_id = mint_reasoning_item_id() + if not self._sent_reasoning_summary_part_added_event: + self._sent_reasoning_summary_part_added_event = True + self._pending_response_events.append( + self.create_reasoning_summary_part_added_event(self._cached_reasoning_item_id) + ) return ReasoningSummaryTextDeltaEvent( type=ResponsesAPIStreamEvents.REASONING_SUMMARY_TEXT_DELTA, diff --git a/tests/unit/responses/litellm_completion_transformation/test_litellm_completion_responses.py b/tests/unit/responses/litellm_completion_transformation/test_litellm_completion_responses.py index 8a94d475918..3fd4be6f722 100644 --- a/tests/unit/responses/litellm_completion_transformation/test_litellm_completion_responses.py +++ b/tests/unit/responses/litellm_completion_transformation/test_litellm_completion_responses.py @@ -4834,9 +4834,9 @@ class TestEnsureOutputItemContentPartAdded: assert completed_event.type == ResponsesAPIStreamEvents.RESPONSE_COMPLETED assert completed_event.response.status == "completed" - def test_reasoning_item_opens_a_summary_part_instead_of_a_content_part(self): - """Reasoning items announce their summary part, never a content_part.added event.""" - from litellm.types.llms.openai import OutputItemAddedEvent, ResponsePartAddedEvent + def test_reasoning_item_does_not_emit_content_part_added(self): + """Reasoning items should not get a content_part.added event.""" + from litellm.types.llms.openai import OutputItemAddedEvent iterator = self._make_iterator() chunk = self._make_reasoning_chunk() @@ -4844,11 +4844,8 @@ class TestEnsureOutputItemContentPartAdded: iterator._ensure_output_item_for_chunk(chunk) events = iterator._pending_response_events - assert len(events) == 2 + assert len(events) == 1 assert isinstance(events[0], OutputItemAddedEvent) - assert isinstance(events[1], ResponsePartAddedEvent) - assert events[1].item_id == events[0].item.id - assert events[1].output_index == events[0].output_index assert iterator.sent_content_part_added_event is False def test_only_emits_once(self): diff --git a/tests/unit/responses/litellm_completion_transformation/test_streaming_iterator_transformation.py b/tests/unit/responses/litellm_completion_transformation/test_streaming_iterator_transformation.py index 8c5b620f51c..65bcafef683 100644 --- a/tests/unit/responses/litellm_completion_transformation/test_streaming_iterator_transformation.py +++ b/tests/unit/responses/litellm_completion_transformation/test_streaming_iterator_transformation.py @@ -1245,6 +1245,10 @@ def _split_reasoning_then_text_chunks() -> tuple[ModelResponseStream, ...]: return (_reasoning_chunk("let me "), _reasoning_chunk("think"), _chunk("Hello!", finish_reason="stop")) +def _signature_only_thinking_then_text_chunks() -> tuple[ModelResponseStream, ...]: + return (_signature_only_thinking_chunk("sig_only"), _chunk("4", finish_reason="stop")) + + def _streamed_reasoning_text(chunks: Sequence[ModelResponseStream]) -> str: return "".join(getattr(chunk.choices[0].delta, "reasoning_content", None) or "" for chunk in chunks) @@ -1277,11 +1281,17 @@ def _reasoning_item(items: Sequence[Mapping[str, object]]) -> Mapping[str, objec @pytest.mark.parametrize( "stream_chunks", - [_split_reasoning_then_text_chunks, _reasoning_and_text_in_one_chunk, _signed_thinking_and_text_in_one_chunk], + [ + _split_reasoning_then_text_chunks, + _reasoning_and_text_in_one_chunk, + _signed_thinking_and_text_in_one_chunk, + _signature_only_thinking_then_text_chunks, + ], ids=[ "split_reasoning_then_text_chunks", "reasoning_and_text_in_one_chunk", "signed_thinking_and_text_in_one_chunk", + "signature_only_thinking_then_text_chunks", ], ) @pytest.mark.parametrize("sync_mode", [True, False]) @@ -1298,7 +1308,7 @@ async def test_sdk_stream_readers_can_place_every_reasoning_summary_delta(stream completed_summary: Final = _reasoning_item(completed_payload["response"]["output"])["summary"] assert "".join(part["text"] for part in accumulated) == _streamed_reasoning_text(chunks) assert _reasoning_item(done_items)["summary"] == accumulated - assert "".join(part["text"] for part in completed_summary) == _streamed_reasoning_text(chunks) + assert completed_summary == accumulated TypeAdapter(list[OpenAIReasoningSummaryPart]).validate_python([*accumulated, *completed_summary])