mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-10 03:28:53 +00:00
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:
parent
07824b5eec
commit
667ca64ac6
2 changed files with 125 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue