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
This commit is contained in:
Mike Ma 2026-05-07 01:34:07 -05:00
parent 07824b5eec
commit 667ca64ac6
2 changed files with 125 additions and 0 deletions

View file

@ -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

View file

@ -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'.