diff --git a/litellm/responses/litellm_completion_transformation/streaming_iterator.py b/litellm/responses/litellm_completion_transformation/streaming_iterator.py index 521a698b276..72d91ac4b53 100644 --- a/litellm/responses/litellm_completion_transformation/streaming_iterator.py +++ b/litellm/responses/litellm_completion_transformation/streaming_iterator.py @@ -58,6 +58,7 @@ from litellm.types.llms.openai import ( ResponseIncompleteEvent, ResponseInProgressEvent, ResponseInputParam, + ResponsePartAddedEvent, ResponsesAPIOptionalRequestParams, ResponsesAPIResponse, ResponsesAPIStreamEvents, @@ -1188,20 +1189,26 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator): self._cached_reasoning_item_id = mint_reasoning_item_id() self._reasoning_item_id = self._cached_reasoning_item_id - self._pending_response_events.append( - OutputItemAddedEvent( - type=ResponsesAPIStreamEvents.OUTPUT_ITEM_ADDED, - output_index=self._reasoning_index(), - item=BaseLiteLLMOpenAIResponseObject( - **{ - "id": self._cached_reasoning_item_id, - "type": "reasoning", - "status": "in_progress", - "summary": None, - } - ), - ) + event = OutputItemAddedEvent( + type=ResponsesAPIStreamEvents.OUTPUT_ITEM_ADDED, + output_index=self._reasoning_index(), + item=BaseLiteLLMOpenAIResponseObject( + **{ + "id": self._cached_reasoning_item_id, + "type": "reasoning", + "status": "in_progress", + "summary": [], + } + ), ) + part_added_event: Final = ResponsePartAddedEvent( + type=ResponsesAPIStreamEvents.RESPONSE_PART_ADDED, + item_id=self._cached_reasoning_item_id, + output_index=self._reasoning_index(), + sequence_number=SEQUENCE_NUMBER_STAMPED_ON_EMISSION, + 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 288e4a7119d..78415d01571 100644 --- a/litellm/responses/streaming_iterator.py +++ b/litellm/responses/streaming_iterator.py @@ -2048,6 +2048,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 c9739eee91d..6f6803f6935 100644 --- a/litellm/types/llms/openai.py +++ b/litellm/types/llms/openai.py @@ -1832,8 +1832,9 @@ class ResponsePartAddedEvent(BaseResponsesStreamEvent): 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(BaseResponsesStreamEvent): 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 2427e7eacb1..566d745bbe7 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 @@ -4840,8 +4840,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() @@ -4849,8 +4849,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 87858741908..9a6069ce79f 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 @@ -1517,6 +1517,35 @@ 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": ""} + assert _serialized_sequence_numbers(events) == list(range(len(events))) + + @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):