Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,22 @@
)


def _output_items_with_id(items: tuple[Any, ...], item_type: str, item_id: str | None) -> tuple[Any, ...]:
if item_id is None:
return items

target_index: Final = next(
(index for index, item in enumerate(items) if getattr(item, "type", None) == item_type),
None,
)
if target_index is None:
return items

return tuple(
item.model_copy(update={"id": item_id}) if index == target_index else item for index, item in enumerate(items)
)


class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
"""
Async iterator for processing streaming responses from the Responses API.
Expand Down Expand Up @@ -851,9 +867,10 @@ async def __anext__(
reasoning_content = "".join(self._accumulated_reasoning_content_parts)

# Ensure we have a valid reasoning_item_id
reasoning_item_id = (
self._cached_reasoning_item_id = (
self._reasoning_item_id or self._cached_reasoning_item_id or f"rs_{uuid.uuid4()}"
)
reasoning_item_id = self._cached_reasoning_item_id

# Create text.done event first with its own sequence number
self._sequence_number += 1
Expand Down Expand Up @@ -966,9 +983,9 @@ def _transform_chat_completion_chunk_to_response_api_chunk(
and the ReasoningSummaryTextDeltaEvent, which is used by the responses API to emit reasoning content.
It also handles emitting annotation.added events when annotations are detected in the chunk.
"""
if self._cached_item_id is None and chunk.id:
self._cached_item_id = chunk.id
item_id: Final = self._cached_item_id or chunk.id
if self._cached_item_id is None:
self._cached_item_id = f"msg_{uuid.uuid4()}"
item_id: Final = self._cached_item_id

# Check if this chunk has annotations first (before processing text/reasoning)
# This ensures we detect and queue annotation events from the annotation chunk
Expand Down Expand Up @@ -1003,9 +1020,12 @@ def _transform_chat_completion_chunk_to_response_api_chunk(
):
reasoning_content: Final = chunk.choices[0].delta.reasoning_content

if self._cached_reasoning_item_id is None:
self._cached_reasoning_item_id = f"rs_{uuid.uuid4()}"

return ReasoningSummaryTextDeltaEvent(
type=ResponsesAPIStreamEvents.REASONING_SUMMARY_TEXT_DELTA,
item_id=f"rs_{hash(str(reasoning_content))}",
item_id=self._cached_reasoning_item_id,
output_index=0,
delta=reasoning_content,
)
Expand Down Expand Up @@ -1056,6 +1076,19 @@ def _get_delta_string_from_streaming_choices(self, choices: list[StreamingChoice
chat_completion_delta: Final[ChatCompletionDelta] = choice.delta
return chat_completion_delta.content or ""

def _output_with_streamed_item_ids(self, responses_api_response: ResponsesAPIResponse) -> tuple[Any, ...]:
"""
Reuse the item IDs already emitted by the incremental streaming events in the
``response.completed`` snapshot, so a streaming client that replays the snapshot
sends back the same IDs it observed mid-stream.
"""
message_aligned: Final = _output_items_with_id(
tuple(responses_api_response.output or ()),
"message",
self._cached_item_id,
)
return _output_items_with_id(message_aligned, "reasoning", self._cached_reasoning_item_id)

def _emit_response_completed_event(self, litellm_model_response: ModelResponse) -> ResponseCompletedEvent | None:
if litellm_model_response:
# Add cost to usage object if include_cost_in_streaming_usage is True
Expand All @@ -1081,6 +1114,8 @@ def _emit_response_completed_event(self, litellm_model_response: ModelResponse)
if self._cached_response_id:
responses_api_response.id = self._cached_response_id

responses_api_response.output = list(self._output_with_streamed_item_ids(responses_api_response))

# Encode the response ID to match non-streaming behavior
encoded_response: Final = ResponsesAPIRequestUtils._update_responses_api_response_id_with_model_id(
responses_api_response=responses_api_response,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import json
import re
import uuid
from collections.abc import Iterator, Mapping, Sequence
from types import MappingProxyType
from typing import (
Expand Down Expand Up @@ -2017,7 +2018,7 @@ def _extract_reasoning_output_items(
return [
GenericResponseOutputItem(
type="reasoning",
id=f"rs_{hash(reasoning_content or encrypted_content)}",
id=f"rs_{uuid.uuid4()}",
status=LiteLLMCompletionResponsesConfig._map_chat_completion_finish_reason_to_responses_status(
choice.finish_reason
),
Expand All @@ -2038,7 +2039,6 @@ def _extract_reasoning_output_items(

@staticmethod
def _extract_image_generation_output_items(
chat_completion_response: ModelResponse,
choice: Choices,
) -> list[OutputImageGenerationCall]:
"""
Expand All @@ -2054,7 +2054,7 @@ def _extract_image_generation_output_items(
To Responses API format:
{
'type': 'image_generation_call',
'id': 'img_...',
'id': 'ig_...',
'status': 'completed',
'result': 'iVBORw0...' # Pure base64 without data: prefix
}
Expand All @@ -2065,7 +2065,7 @@ def _extract_image_generation_output_items(
if not images:
return image_generation_items

for idx, image_item in enumerate(_DICT_ITEMS_LIST_ADAPTER.validate_python(images)):
for image_item in _DICT_ITEMS_LIST_ADAPTER.validate_python(images):
# Extract base64 from data URL
image_url = _TEXT_ADAPTER.validate_python(
_ANY_KEY_DICT_ADAPTER.validate_python(image_item.get("image_url", {})).get("url", "")
Expand All @@ -2076,7 +2076,7 @@ def _extract_image_generation_output_items(
image_generation_items.append(
OutputImageGenerationCall(
type="image_generation_call",
id=f"{chat_completion_response.id}_img_{idx}",
id=f"ig_{uuid.uuid4()}",
status=LiteLLMCompletionResponsesConfig._map_finish_reason_to_image_generation_status(
choice.finish_reason
),
Expand Down Expand Up @@ -2141,7 +2141,6 @@ def _extract_message_output_items(
if hasattr(choice.message, "images") and choice.message.images:
# Extract image generation output
image_generation_items = LiteLLMCompletionResponsesConfig._extract_image_generation_output_items(
chat_completion_response=chat_completion_response,
choice=choice,
)
message_output_items.extend(image_generation_items)
Expand All @@ -2150,7 +2149,7 @@ def _extract_message_output_items(
message_output_items.append(
GenericResponseOutputItem(
type="message",
id=chat_completion_response.id,
id=f"msg_{uuid.uuid4()}",
status=LiteLLMCompletionResponsesConfig._map_chat_completion_finish_reason_to_responses_status(
choice.finish_reason
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,6 @@ class TestExtractImageGenerationOutputItems:

def test_extracts_images_correctly(self):
"""Should extract OutputImageGenerationCall objects from images"""
mock_response = Mock(spec=ModelResponse)
mock_response.id = "test_123"

mock_message = Mock(spec=Message)
mock_message.images = [
{
Expand All @@ -80,7 +77,6 @@ def test_extracts_images_correctly(self):

result = (
LiteLLMCompletionResponsesConfig._extract_image_generation_output_items(
chat_completion_response=mock_response,
choice=mock_choice,
)
)
Expand All @@ -89,13 +85,13 @@ def test_extracts_images_correctly(self):
assert result[0].type == "image_generation_call"
assert result[0].result == "IMG1"
assert result[1].result == "IMG2"
assert result[0].id == "test_123_img_0"
assert result[1].id == "test_123_img_1"
assert result[0].id.startswith("ig_")
assert result[1].id.startswith("ig_")
assert result[0].id != result[1].id
assert result[0].status == "completed"

def test_returns_empty_for_no_images(self):
"""Should return empty list if no images"""
mock_response = Mock(spec=ModelResponse)
mock_message = Mock(spec=Message)
mock_message.images = []

Expand All @@ -105,7 +101,6 @@ def test_returns_empty_for_no_images(self):

result = (
LiteLLMCompletionResponsesConfig._extract_image_generation_output_items(
chat_completion_response=mock_response,
choice=mock_choice,
)
)
Expand All @@ -114,9 +109,6 @@ def test_returns_empty_for_no_images(self):

def test_maps_finish_reason_to_status(self):
"""Should correctly map finish_reason to status"""
mock_response = Mock(spec=ModelResponse)
mock_response.id = "test_finish"

mock_message = Mock(spec=Message)
mock_message.images = [
{
Expand All @@ -132,7 +124,6 @@ def test_maps_finish_reason_to_status(self):

result = (
LiteLLMCompletionResponsesConfig._extract_image_generation_output_items(
chat_completion_response=mock_response,
choice=mock_choice,
)
)
Expand Down Expand Up @@ -198,3 +189,40 @@ def test_creates_regular_message_when_no_images(self):
assert len(result) == 1
assert isinstance(result[0], GenericResponseOutputItem)
assert result[0].type == "message"


class TestImageGenerationOutputItemIds:
"""Image generation call IDs must use the ig_ prefix (issue #27333).

Native OpenAI Responses validates the prefix before it looks the item up, so a
replayed chatcmpl-*_img_N ID is rejected outright.
"""

def _choice_with_images(self, count):
mock_message = Mock(spec=Message)
mock_message.images = [
{"image_url": {"url": f"data:image/png;base64,IMG{idx}"}}
for idx in range(count)
]
mock_choice = Mock(spec=Choices)
mock_choice.message = mock_message
mock_choice.finish_reason = "stop"
return mock_choice

def test_image_generation_item_id_uses_ig_prefix(self):
result = LiteLLMCompletionResponsesConfig._extract_image_generation_output_items(
choice=self._choice_with_images(2),
)

assert len(result) == 2
for item in result:
assert item.id.startswith("ig_")
assert "chatcmpl-" not in item.id
assert "_img_" not in item.id

def test_image_generation_item_ids_are_unique(self):
result = LiteLLMCompletionResponsesConfig._extract_image_generation_output_items(
choice=self._choice_with_images(3),
)

assert len({item.id for item in result}) == 3
Loading
Loading