diff --git a/litellm/completion_extras/litellm_responses_transformation/handler.py b/litellm/completion_extras/litellm_responses_transformation/handler.py index ebbaec8c1bb..1f5b95e8c26 100644 --- a/litellm/completion_extras/litellm_responses_transformation/handler.py +++ b/litellm/completion_extras/litellm_responses_transformation/handler.py @@ -67,19 +67,20 @@ class ResponsesToCompletionBridgeHandler: else: raise ValueError("Unexpected responses stream payload") - from .transformation import LiteLLMResponsesTransformationHandler + response = base_response + if not base_response.output: + from .transformation import LiteLLMResponsesTransformationHandler - parsed_chunks: Final = tuple( - payload - for event in stream_events - if (payload := ResponsesToCompletionBridgeHandler._stream_event_payload(event)) is not None - ) - recovered_output: Final = LiteLLMResponsesTransformationHandler.recover_output_items_from_chunks(parsed_chunks) - response: Final = ( - base_response - if base_response.output or not recovered_output - else base_response.model_copy(update={"output": recovered_output}) - ) + parsed_chunks: Final = ( + payload + for event in stream_events + if (payload := ResponsesToCompletionBridgeHandler._stream_event_payload(event)) is not None + ) + recovered_output: Final = LiteLLMResponsesTransformationHandler.recover_output_items_from_chunks( + parsed_chunks + ) + if recovered_output: + response = base_response.model_copy(update={"output": recovered_output}) if hidden_params: existing: Final = getattr(response, "_hidden_params", None) diff --git a/litellm/completion_extras/litellm_responses_transformation/transformation.py b/litellm/completion_extras/litellm_responses_transformation/transformation.py index 844da405a98..e260e5188bb 100644 --- a/litellm/completion_extras/litellm_responses_transformation/transformation.py +++ b/litellm/completion_extras/litellm_responses_transformation/transformation.py @@ -334,22 +334,29 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge): # Handle message items with output_text content if item_type == "message": content_list: Final = item.get("content", []) + response_text_parts: Final[list[str]] = [] + message_annotations: Final[list[ChatCompletionAnnotation]] = [] + has_output_text = False for content_item in content_list: - if isinstance(content_item, dict): - content_type = content_item.get("type") - if content_type == "output_text": - response_text = content_item.get("text", "") - # Extract annotations from content if present - annotations = LiteLLMResponsesTransformationHandler._convert_annotations_to_chat_format( - content_item.get("annotations", None) - ) - msg = Message( - role=item.get("role", "assistant"), - content=response_text if response_text else "", - annotations=annotations, - ) - choice = Choices(message=msg, finish_reason="stop", index=index) - return choice, index + 1 + if not isinstance(content_item, dict) or content_item.get("type") != "output_text": + continue + has_output_text = True + response_text = content_item.get("text", "") + response_text_parts.append(response_text if isinstance(response_text, str) else "") + annotations = LiteLLMResponsesTransformationHandler._convert_annotations_to_chat_format( + content_item.get("annotations", None) + ) + if annotations: + message_annotations.extend(annotations) + + if has_output_text: + msg = Message( + role=item.get("role", "assistant"), + content="".join(response_text_parts), + annotations=message_annotations or None, + ) + choice = Choices(message=msg, finish_reason="stop", index=index) + return choice, index + 1 # function_call / custom_tool_call dicts are intercepted and accumulated by # _convert_response_output_to_choices before this callback is reached @@ -879,7 +886,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge): if not raw_sse or not isinstance(raw_sse, str): return [] - parsed_chunks: Final = tuple( + parsed_chunks: Final = ( parsed_chunk for chunk in raw_sse.splitlines() if (parsed_chunk := parse_sse_json_chunk(chunk)) is not None ) return cls.recover_output_items_from_chunks(parsed_chunks) diff --git a/tests/unit/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py b/tests/unit/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py index d0f9bad795d..b09ac17d3ea 100644 --- a/tests/unit/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py +++ b/tests/unit/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py @@ -610,6 +610,42 @@ def test_transform_response_recovers_empty_output_from_raw_sse(): assert result.choices[0].message.content == "Recovered from SSE" +def test_transform_response_recovers_all_text_parts_from_raw_sse(): + from litellm.completion_extras.litellm_responses_transformation.transformation import ( + LiteLLMResponsesTransformationHandler, + ) + + handler = LiteLLMResponsesTransformationHandler() + raw_sse = "\n".join( + [ + 'data: {"type":"response.output_text.done","output_index":0,"content_index":0,"item_id":"msg_from_stream","text":"Hello, "}', + 'data: {"type":"response.output_text.done","output_index":0,"content_index":1,"item_id":"msg_from_stream","text":"world!"}', + 'data: {"type":"response.completed","response":{"id":"resp_from_stream","object":"response","created_at":1760144904,"status":"completed","model":"gpt-5.4","output":[]}}', + "data: [DONE]", + "", + ] + ) + raw_response = _make_empty_responses_api_response() + model_response = _make_empty_model_response() + logging_obj = Mock() + logging_obj.model_call_details = {"original_response": raw_sse} + + result = handler.transform_response( + model="gpt-5.4", + raw_response=raw_response, + model_response=model_response, + logging_obj=logging_obj, + request_data={"model": "gpt-5.4"}, + messages=[{"role": "user", "content": "Reply with exactly: Hello, world!"}], + optional_params={}, + litellm_params={}, + encoding=Mock(), + ) + + assert len(result.choices) == 1 + assert result.choices[0].message.content == "Hello, world!" + + def test_transform_response_recovers_output_item_done_from_raw_sse(): from litellm.completion_extras.litellm_responses_transformation.transformation import ( LiteLLMResponsesTransformationHandler,