From 667ca64ac6727713ee661308916659263a8e0470 Mon Sep 17 00:00:00 2001 From: Mike Ma Date: Thu, 7 May 2026 01:34:07 -0500 Subject: [PATCH] Preserve incomplete Responses stream metadata The chat-completion Responses stream adapter treated response.incomplete like an unknown event, so terminal metadata from providers could be dropped before downstream stream hooks saw it. Handle response.incomplete as a terminal chunk, map content-filtered responses to the matching chat finish reason, and carry incomplete details plus content filters through provider-specific fields. Tested: uvx --from uv==0.10.9 uv run --no-sync pytest tests/test_litellm/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py -k "response_incomplete_preserves_terminal_metadata or response_completed_emits_is_finished" Tested: uvx --from uv==0.10.9 uv run --no-sync ruff check litellm/completion_extras/litellm_responses_transformation/transformation.py Tested: uvx --from uv==0.10.9 uv run --no-sync black --check litellm/completion_extras/litellm_responses_transformation/transformation.py tests/test_litellm/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py Tested: git diff --check --- .../transformation.py | 49 ++++++++++++ ...responses_transformation_transformation.py | 76 +++++++++++++++++++ 2 files changed, 125 insertions(+) diff --git a/litellm/completion_extras/litellm_responses_transformation/transformation.py b/litellm/completion_extras/litellm_responses_transformation/transformation.py index da3b9184edb..1038b2970aa 100644 --- a/litellm/completion_extras/litellm_responses_transformation/transformation.py +++ b/litellm/completion_extras/litellm_responses_transformation/transformation.py @@ -1301,6 +1301,55 @@ class OpenAiResponsesToChatCompletionStreamIterator(BaseModelResponseIterator): ) ] ) + elif event_type == "response.incomplete": + response_data = parsed_chunk.get("response", {}) or {} + if isinstance(response_data, BaseModel): + response_data = response_data.model_dump() + + incomplete_details = response_data.get("incomplete_details") + if isinstance(incomplete_details, BaseModel): + incomplete_details = incomplete_details.model_dump() + content_filters = response_data.get("content_filters") + if isinstance(content_filters, BaseModel): + content_filters = content_filters.model_dump() + + finish_reason = "length" + if ( + isinstance(incomplete_details, dict) + and incomplete_details.get("reason") == "content_filter" + ): + finish_reason = "content_filter" + + provider_specific_fields = {} + if content_filters is not None: + provider_specific_fields["content_filters"] = content_filters + if incomplete_details is not None: + provider_specific_fields["incomplete_details"] = incomplete_details + + usage = None + if response_data.get("usage"): + from litellm.responses.utils import ResponseAPILoggingUtils + + usage = ( + ResponseAPILoggingUtils._transform_response_api_usage_to_chat_usage( + response_data.get("usage") + ) + ) + + return ModelResponseStream( + choices=[ + StreamingChoices( + index=0, + delta=Delta( + content="", + provider_specific_fields=provider_specific_fields or None, + ), + finish_reason=finish_reason, + ) + ], + provider_specific_fields=provider_specific_fields or None, + usage=usage, + ) elif event_type == "response.completed": # Response is fully complete - now we can signal is_finished=True # This ensures we don't prematurely end the stream before tool_calls arrive diff --git a/tests/test_litellm/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py b/tests/test_litellm/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py index e40543e01a0..30f09cbef7c 100644 --- a/tests/test_litellm/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py +++ b/tests/test_litellm/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py @@ -688,6 +688,82 @@ def test_response_completed_emits_is_finished(): ), "response.completed should emit finish_reason='stop'" +def test_response_incomplete_preserves_terminal_metadata(): + from litellm.completion_extras.litellm_responses_transformation.transformation import ( + OpenAiResponsesToChatCompletionStreamIterator, + ) + from pydantic import BaseModel + + class ContentFilters(BaseModel): + hate: dict + self_harm: dict + + class IncompleteDetails(BaseModel): + reason: str + + iterator = OpenAiResponsesToChatCompletionStreamIterator( + streaming_response=None, sync_stream=True + ) + + expected_content_filters = { + "hate": {"filtered": False, "severity": "safe"}, + "self_harm": {"filtered": True, "severity": "high"}, + } + expected_incomplete_details = {"reason": "content_filter"} + chunk = { + "type": "response.incomplete", + "response": { + "id": "resp_incomplete", + "status": "incomplete", + "incomplete_details": IncompleteDetails(**expected_incomplete_details), + "content_filters": ContentFilters(**expected_content_filters), + }, + } + + result = iterator.chunk_parser(chunk) + + assert len(result.choices) == 1 + choice = result.choices[0] + assert choice.finish_reason == "content_filter" + assert choice.delta.content == "" + assert choice.delta.provider_specific_fields == { + "content_filters": expected_content_filters, + "incomplete_details": expected_incomplete_details, + } + assert result.provider_specific_fields == choice.delta.provider_specific_fields + + +def test_response_incomplete_defaults_to_length_finish_reason(): + from litellm.completion_extras.litellm_responses_transformation.transformation import ( + OpenAiResponsesToChatCompletionStreamIterator, + ) + + iterator = OpenAiResponsesToChatCompletionStreamIterator( + streaming_response=None, sync_stream=True + ) + + incomplete_details = {"reason": "max_output_tokens"} + chunk = { + "type": "response.incomplete", + "response": { + "id": "resp_incomplete_length", + "status": "incomplete", + "incomplete_details": incomplete_details, + }, + } + + result = iterator.chunk_parser(chunk) + + assert len(result.choices) == 1 + choice = result.choices[0] + assert choice.finish_reason == "length" + assert choice.delta.content == "" + assert choice.delta.provider_specific_fields == { + "incomplete_details": incomplete_details, + } + assert result.provider_specific_fields == choice.delta.provider_specific_fields + + def test_response_completed_with_function_calls_emits_tool_calls_finish_reason(): """ Test that response.completed with function_call items in output emits finish_reason='tool_calls'.