fix(responses-bridge): latch a single chunk id across bridged streaming chunks

This commit is contained in:
Devin AI 2026-07-09 12:08:47 +00:00
parent 60729f733e
commit dd8b4b1d54
2 changed files with 79 additions and 1 deletions

View file

@ -1074,6 +1074,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
class OpenAiResponsesToChatCompletionStreamIterator(BaseModelResponseIterator):
def __init__(self, streaming_response, sync_stream: bool, json_mode: Optional[bool] = False):
super().__init__(streaming_response, sync_stream, json_mode)
self._chat_completion_id: Optional[str] = None
def _handle_string_chunk(
self, str_line: Union[str, "BaseModel"]
@ -1381,4 +1382,9 @@ class OpenAiResponsesToChatCompletionStreamIterator(BaseModelResponseIterator):
ModelResponseStream: OpenAI-formatted streaming chunk
"""
verbose_logger.debug(f"Chat provider: transform_streaming_response called with chunk: {chunk}")
return OpenAiResponsesToChatCompletionStreamIterator.translate_responses_chunk_to_openai_stream(chunk)
parsed_chunk = OpenAiResponsesToChatCompletionStreamIterator.translate_responses_chunk_to_openai_stream(chunk)
if self._chat_completion_id is None and parsed_chunk.id:
self._chat_completion_id = parsed_chunk.id
elif self._chat_completion_id is not None:
parsed_chunk.id = self._chat_completion_id
return parsed_chunk

View file

@ -2401,6 +2401,78 @@ def test_parallel_tool_calls_comprehensive_streaming_integration():
)
def test_bridge_streaming_chunks_share_single_id():
"""Regression test for #32607.
When a chat completions request is bridged to the Responses API and the
streaming events are translated back, every emitted chunk must share one
stable id. Previously each ModelResponseStream got a fresh
"chatcmpl-<uuid>", which broke clients that group tool-call deltas by
chunk.id (splitting a single tool call across multiple messages).
"""
from litellm.completion_extras.litellm_responses_transformation.transformation import (
OpenAiResponsesToChatCompletionStreamIterator,
)
chunks = [
{"type": "response.created", "response": {"id": "resp_001"}},
{
"type": "response.output_item.added",
"output_index": 0,
"item": {
"type": "function_call",
"id": "fc_001",
"call_id": "call_1",
"name": "get_weather",
},
},
{
"type": "response.function_call_arguments.delta",
"output_index": 0,
"delta": '{"city":"Amsterdam"}',
},
{
"type": "response.output_item.done",
"output_index": 0,
"item": {
"type": "function_call",
"id": "fc_001",
"call_id": "call_1",
"name": "get_weather",
"arguments": '{"city":"Amsterdam"}',
},
},
{
"type": "response.completed",
"response": {
"id": "resp_001",
"status": "completed",
"output": [
{
"type": "function_call",
"call_id": "call_1",
"name": "get_weather",
"arguments": '{"city":"Amsterdam"}',
}
],
},
},
]
iterator = OpenAiResponsesToChatCompletionStreamIterator(
streaming_response=None, sync_stream=True
)
ids = [iterator.chunk_parser(chunk).id for chunk in chunks]
assert len(set(ids)) == 1, (
f"All bridged streaming chunks must share one id, got {len(set(ids))} "
f"distinct ids: {set(ids)}"
)
assert ids[0] and ids[0].startswith("chatcmpl-"), (
f"Bridged chunk id must keep the chat completion prefix, got {ids[0]!r}"
)
def test_map_optional_params_preserves_reasoning_summary():
"""Test that reasoning_effort dict with summary field is preserved.