From d560e4c009b6da2afa0ee9bbb1414cde51a62cd4 Mon Sep 17 00:00:00 2001 From: Lei Nie Date: Mon, 23 Mar 2026 21:02:15 +0000 Subject: [PATCH 1/2] fix(responses): emit content_part.added event for non-OpenAI models LiteLLMCompletionStreamingIterator defined create_content_part_added_event() but never called it, so non-OpenAI providers (Claude, Gemini, etc.) skipped this spec-required event. Downstream parsers that process content_part.added to initialize the text part structure would fail when output_text.delta arrived before the text part existed. --- .../streaming_iterator.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/litellm/responses/litellm_completion_transformation/streaming_iterator.py b/litellm/responses/litellm_completion_transformation/streaming_iterator.py index 0672b03bcd..9acc8f41c2 100644 --- a/litellm/responses/litellm_completion_transformation/streaming_iterator.py +++ b/litellm/responses/litellm_completion_transformation/streaming_iterator.py @@ -845,6 +845,14 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator): ) event.__dict__["sequence_number"] = self._sequence_number self._pending_response_events.append(event) + + # Emit content_part.added immediately after output_item.added for message + # items. The OpenAI Responses spec requires this event before any + # output_text.delta events so downstream parsers can initialize the + # text part structure. + self.sent_content_part_added_event = True + content_part_event = self.create_content_part_added_event() + self._pending_response_events.append(content_part_event) return async def __anext__( From 1716956520e12588e2918eb0d9e109b68b6549f0 Mon Sep 17 00:00:00 2001 From: Lei Nie Date: Mon, 23 Mar 2026 22:21:29 +0000 Subject: [PATCH 2/2] test(responses): update expected events and add mock test for content_part.added - Update test_anthropic_via_responses_api expected_events to include CONTENT_PART_ADDED between OUTPUT_ITEM_ADDED and OUTPUT_TEXT_DELTA - Add TestEnsureOutputItemContentPartAdded with 3 mock tests: message item emits content_part.added, reasoning item does not, and the event is only emitted once --- .../streaming_iterator.py | 7 +- .../test_anthropic_completion.py | 1 + .../test_litellm_completion_responses.py | 94 +++++++++++++++++++ 3 files changed, 99 insertions(+), 3 deletions(-) diff --git a/litellm/responses/litellm_completion_transformation/streaming_iterator.py b/litellm/responses/litellm_completion_transformation/streaming_iterator.py index 9acc8f41c2..8e75ffdff6 100644 --- a/litellm/responses/litellm_completion_transformation/streaming_iterator.py +++ b/litellm/responses/litellm_completion_transformation/streaming_iterator.py @@ -850,9 +850,10 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator): # items. The OpenAI Responses spec requires this event before any # output_text.delta events so downstream parsers can initialize the # text part structure. - self.sent_content_part_added_event = True - content_part_event = self.create_content_part_added_event() - self._pending_response_events.append(content_part_event) + if not self.sent_content_part_added_event: + self.sent_content_part_added_event = True + content_part_event = self.create_content_part_added_event() + self._pending_response_events.append(content_part_event) return async def __anext__( diff --git a/tests/llm_translation/test_anthropic_completion.py b/tests/llm_translation/test_anthropic_completion.py index 8630ba6561..78e51f9ba4 100644 --- a/tests/llm_translation/test_anthropic_completion.py +++ b/tests/llm_translation/test_anthropic_completion.py @@ -1594,6 +1594,7 @@ def test_anthropic_via_responses_api(): ResponsesAPIStreamEvents.RESPONSE_CREATED, ResponsesAPIStreamEvents.RESPONSE_IN_PROGRESS, ResponsesAPIStreamEvents.OUTPUT_ITEM_ADDED, + ResponsesAPIStreamEvents.CONTENT_PART_ADDED, ResponsesAPIStreamEvents.OUTPUT_TEXT_DELTA, # Can occur multiple times ResponsesAPIStreamEvents.OUTPUT_TEXT_DONE, ResponsesAPIStreamEvents.CONTENT_PART_DONE, diff --git a/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py b/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py index a931a9bc93..aaa131c23d 100644 --- a/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py +++ b/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py @@ -1899,3 +1899,97 @@ class TestStreamingIDConsistency: else getattr(assistant_messages[0], "tool_calls", None) ) assert tool_calls is not None and len(tool_calls) == 1 + + +class TestEnsureOutputItemContentPartAdded: + """Test that _ensure_output_item_for_chunk emits content_part.added after + output_item.added for message items.""" + + def _make_iterator(self): + """Create a minimal LiteLLMCompletionStreamingIterator for testing.""" + from unittest.mock import MagicMock + + from litellm.responses.litellm_completion_transformation.streaming_iterator import ( + LiteLLMCompletionStreamingIterator, + ) + + iterator = LiteLLMCompletionStreamingIterator.__new__( + LiteLLMCompletionStreamingIterator + ) + iterator.sent_output_item_added_event = False + iterator.sent_content_part_added_event = False + iterator._sequence_number = 0 + iterator._cached_item_id = None + iterator._cached_reasoning_item_id = None + iterator._reasoning_active = False + iterator._pending_response_events = [] + return iterator + + def _make_text_chunk(self): + """Create a mock ModelResponseStream with a text delta.""" + from unittest.mock import MagicMock + + chunk = MagicMock() + delta = MagicMock() + delta.reasoning_content = None + delta.tool_calls = None + chunk.choices = [MagicMock(delta=delta)] + return chunk + + def _make_reasoning_chunk(self): + """Create a mock ModelResponseStream with a reasoning delta.""" + from unittest.mock import MagicMock + + chunk = MagicMock() + delta = MagicMock() + delta.reasoning_content = "thinking..." + delta.tool_calls = None + chunk.choices = [MagicMock(delta=delta)] + return chunk + + def test_message_item_emits_content_part_added(self): + """content_part.added must follow output_item.added for message items.""" + from litellm.types.llms.openai import ( + ContentPartAddedEvent, + OutputItemAddedEvent, + ResponsesAPIStreamEvents, + ) + + iterator = self._make_iterator() + chunk = self._make_text_chunk() + + iterator._ensure_output_item_for_chunk(chunk) + + events = iterator._pending_response_events + assert len(events) == 2 + assert isinstance(events[0], OutputItemAddedEvent) + assert events[0].type == ResponsesAPIStreamEvents.OUTPUT_ITEM_ADDED + assert isinstance(events[1], ContentPartAddedEvent) + assert events[1].type == ResponsesAPIStreamEvents.CONTENT_PART_ADDED + assert events[1].part.type == "output_text" + assert iterator.sent_content_part_added_event is True + + 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() + + iterator._ensure_output_item_for_chunk(chunk) + + events = iterator._pending_response_events + assert len(events) == 1 + assert isinstance(events[0], OutputItemAddedEvent) + assert iterator.sent_content_part_added_event is False + + def test_only_emits_once(self): + """Calling _ensure_output_item_for_chunk twice should not duplicate events.""" + iterator = self._make_iterator() + chunk = self._make_text_chunk() + + iterator._ensure_output_item_for_chunk(chunk) + iterator._ensure_output_item_for_chunk(chunk) + + events = iterator._pending_response_events + assert len(events) == 2