From 5db97d99d5209613c4b17ac5c44e73f48b26d5db Mon Sep 17 00:00:00 2001 From: "Alexandr A. Alexandrov" Date: Mon, 21 Sep 2026 15:19:39 +0000 Subject: [PATCH] fix(responses): keep message output_item.done sequence_number monotonic The chat-completions to Responses API streaming bridge hardcoded sequence_number=1 on the final response.output_item.done event for the text message. When a response contains a tool call, every other event in the same stream uses a growing counter, so that event ends up with a sequence_number lower than events that were actually emitted earlier (observed live: [...9, 10, 1]). A client that orders streamed events by sequence_number treats the message's out-of-order done event as a new, later item and re-renders the already-streamed message text a second time. This reproduced with claude-sonnet-5 through our ChatGPT/Anthropic Responses bridge whenever the model replied with text before calling a tool. Advance the existing per-stream sequence_number counter for this event instead of hardcoding 1, matching every other event type in this class. Confirmed still present in upstream v1.101.0 and v1.102.0 (stable). --- .../streaming_iterator.py | 3 +- .../test_streaming_iterator_transformation.py | 75 +++++++++++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/litellm/responses/litellm_completion_transformation/streaming_iterator.py b/litellm/responses/litellm_completion_transformation/streaming_iterator.py index c25dd4a405e..91e5f7eddd0 100644 --- a/litellm/responses/litellm_completion_transformation/streaming_iterator.py +++ b/litellm/responses/litellm_completion_transformation/streaming_iterator.py @@ -951,10 +951,11 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator): annotations=annotations ) ) + self._sequence_number += 1 return OutputItemDoneEvent( type=ResponsesAPIStreamEvents.OUTPUT_ITEM_DONE, output_index=self._message_index(), - sequence_number=1, + sequence_number=self._sequence_number, item=BaseLiteLLMOpenAIResponseObject( **{ "id": self._cached_item_id, diff --git a/tests/unit/responses/litellm_completion_transformation/test_streaming_iterator_transformation.py b/tests/unit/responses/litellm_completion_transformation/test_streaming_iterator_transformation.py index ed90acbd2fa..53f08220426 100644 --- a/tests/unit/responses/litellm_completion_transformation/test_streaming_iterator_transformation.py +++ b/tests/unit/responses/litellm_completion_transformation/test_streaming_iterator_transformation.py @@ -742,6 +742,81 @@ async def test_streaming_response_id_falls_back_when_upstream_yields_nothing(): assert response_ids[0].startswith("resp_") +def _named_tool_call_chunk( + call_id: str, name: str, arguments: str, finish_reason: str | None = None +) -> ModelResponseStream: + return ModelResponseStream( + id=CHAT_COMPLETION_ID, + created=1748575031, + model="claude-haiku-4-5", + object="chat.completion.chunk", + choices=[ + StreamingChoices( + index=0, + delta=Delta( + role="assistant", + tool_calls=[ + { + "id": call_id, + "type": "function", + "function": {"name": name, "arguments": arguments}, + } + ], + ), + finish_reason=finish_reason, + ) + ], + ) + + +@pytest.mark.asyncio +async def test_sequence_numbers_are_monotonic_when_text_precedes_a_tool_call(): + """ + A response that opens with a text message and then calls a tool must emit + strictly increasing `sequence_number` values across every event, including + the final `response.output_item.done` for the text message. A client that + orders events by `sequence_number` must never see the message's done event + reordered before events that were actually sent earlier, or it re-renders + the message text a second time. + """ + iterator = _build_iterator( + [ + _chunk("Found it."), + _named_tool_call_chunk("call_1", "list_files", '{"path":"/tmp"}', finish_reason="tool_calls"), + ] + ) + + events = [event async for event in iterator] + + sequence_numbers = [ + event.sequence_number for event in events if getattr(event, "sequence_number", None) is not None + ] + + assert len(sequence_numbers) == len(set(sequence_numbers)), ( + f"sequence_number values must be unique, got {sequence_numbers}" + ) + assert sequence_numbers == sorted(sequence_numbers), ( + f"sequence_number values must be monotonically increasing, got {sequence_numbers}" + ) + + message_done_events = [ + event + for event in events + if getattr(event, "type", None) == ResponsesAPIStreamEvents.OUTPUT_ITEM_DONE + and getattr(event.item, "type", None) == "message" + ] + assert len(message_done_events) == 1 + message_done_index = events.index(message_done_events[0]) + for earlier_event in events[:message_done_index]: + earlier_seq = getattr(earlier_event, "sequence_number", None) + if earlier_seq is None: + continue + assert earlier_seq < message_done_events[0].sequence_number, ( + "an event emitted before response.output_item.done for the message " + "must carry a smaller sequence_number than that done event" + ) + + def test_completed_event_restores_usage_hidden_by_stream_options_none(): final_chunk = _chunk("", finish_reason="stop") final_chunk._hidden_params = {"usage": Usage(prompt_tokens=117, completion_tokens=5, total_tokens=122)}