diff --git a/litellm/responses/litellm_completion_transformation/streaming_iterator.py b/litellm/responses/litellm_completion_transformation/streaming_iterator.py index 5c9e5cac5ab..d6ecdf6c898 100644 --- a/litellm/responses/litellm_completion_transformation/streaming_iterator.py +++ b/litellm/responses/litellm_completion_transformation/streaming_iterator.py @@ -45,6 +45,7 @@ from litellm.types.llms.openai import ( ResponseIncompleteEvent, ResponseInProgressEvent, ResponseInputParam, + ResponsePartAddedEvent, ResponsesAPIOptionalRequestParams, ResponsesAPIResponse, ResponsesAPIStreamEvents, @@ -1059,12 +1060,20 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator): "id": self._cached_reasoning_item_id, "type": "reasoning", "status": "in_progress", - "summary": None, + "summary": [], } ), ) event.__dict__["sequence_number"] = self._sequence_number - self._pending_response_events.append(event) + self._sequence_number += 1 + part_added_event: Final = ResponsePartAddedEvent( + type=ResponsesAPIStreamEvents.RESPONSE_PART_ADDED, + item_id=self._cached_reasoning_item_id, + output_index=self._reasoning_index(), + sequence_number=self._sequence_number, + part={"type": "summary_text", "text": ""}, + ) + self._pending_response_events.extend([event, part_added_event]) return # Tool-first diff --git a/litellm/responses/streaming_iterator.py b/litellm/responses/streaming_iterator.py index ffde8c66c20..5540a7f9a05 100644 --- a/litellm/responses/streaming_iterator.py +++ b/litellm/responses/streaming_iterator.py @@ -1819,6 +1819,7 @@ def build_synthetic_response_events( item_id=item_id, output_index=output_index, summary_index=summary_index, + sequence_number=sequence_number, part={**summary_payload, "text": ""}, ) ) diff --git a/litellm/types/llms/openai.py b/litellm/types/llms/openai.py index 8dbc0d97d54..905ec321f6c 100644 --- a/litellm/types/llms/openai.py +++ b/litellm/types/llms/openai.py @@ -1720,8 +1720,9 @@ class ResponsePartAddedEvent(BaseLiteLLMOpenAIResponseObject): type: Literal[ResponsesAPIStreamEvents.RESPONSE_PART_ADDED] item_id: str output_index: int - summary_index: int = 0 + sequence_number: int part: dict + summary_index: int = 0 class ReasoningSummaryTextDeltaEvent(BaseLiteLLMOpenAIResponseObject): 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 cc5d5c62739..9e032c5d295 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 @@ -4831,8 +4831,8 @@ class TestEnsureOutputItemContentPartAdded: assert completed_event.response.status == "completed" 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 + """Reasoning items get a reasoning_summary_part.added event, not a content_part.added one.""" + from litellm.types.llms.openai import OutputItemAddedEvent, ResponsePartAddedEvent iterator = self._make_iterator() chunk = self._make_reasoning_chunk() @@ -4840,8 +4840,9 @@ class TestEnsureOutputItemContentPartAdded: iterator._ensure_output_item_for_chunk(chunk) events = iterator._pending_response_events - assert len(events) == 1 + assert len(events) == 2 assert isinstance(events[0], OutputItemAddedEvent) + assert isinstance(events[1], ResponsePartAddedEvent) 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 1a3430224d1..71fd6b339b9 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 @@ -1385,6 +1385,34 @@ async def test_plain_text_stream_announces_exactly_one_message_item(sync_mode: b assert event.item_id == message_item_adds[0].item.id +@pytest.mark.parametrize("sync_mode", [True, False]) +@pytest.mark.asyncio +async def test_reasoning_item_opens_with_empty_summary_and_a_summary_part(sync_mode: bool): + iterator: Final = _build_iterator( + [_reasoning_chunk("let me "), _reasoning_chunk("think"), _chunk("hi", finish_reason="stop")] + ) + events: Final = await _collect_events(iterator, sync_mode) + + types: Final = [event.type for event in events] + item_added: Final = next( + e for e in events if e.type == ResponsesAPIStreamEvents.OUTPUT_ITEM_ADDED and e.item.type == "reasoning" + ) + part_added: Final = next(e for e in events if e.type == ResponsesAPIStreamEvents.RESPONSE_PART_ADDED) + first_delta: Final = types.index(ResponsesAPIStreamEvents.REASONING_SUMMARY_TEXT_DELTA) + + assert item_added.item.summary == [] + assert ( + types.index(ResponsesAPIStreamEvents.OUTPUT_ITEM_ADDED) + < types.index(ResponsesAPIStreamEvents.RESPONSE_PART_ADDED) + < first_delta + ) + assert types.count(ResponsesAPIStreamEvents.RESPONSE_PART_ADDED) == 1 + assert part_added.item_id == item_added.item.id + assert part_added.output_index == item_added.output_index + assert part_added.summary_index == 0 + assert part_added.part == {"type": "summary_text", "text": ""} + + @pytest.mark.parametrize("sync_mode", [True, False]) @pytest.mark.asyncio async def test_anthropic_tool_search_call_streams_as_tool_search_call_item(sync_mode: bool):