fix(responses): preserve multipart recovered text

This commit is contained in:
Daniel Phang 2026-09-26 21:19:44 -07:00
parent 9091c66795
commit d0b0247e3c
3 changed files with 72 additions and 28 deletions

View file

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

View file

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

View file

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