fix(streaming): keep bridged reasoning summaries on choice 0 and infer an unreported choice finish reason

This commit is contained in:
mateo-berri 2026-10-28 23:07:12 -07:00
parent 091d564928
commit 7169634df2
5 changed files with 75 additions and 9 deletions

View file

@ -2130,7 +2130,7 @@ class OpenAiResponsesToChatCompletionStreamIterator(BaseModelResponseIterator):
return ModelResponseStream(
choices=[
StreamingChoices(
index=cast(int, parsed_chunk.get("summary_index")),
index=0,
delta=Delta(reasoning_content=content_part),
)
]

View file

@ -385,8 +385,8 @@ class ChunkProcessor:
return "assistant"
@staticmethod
def _finish_reason_of_chunks(chunks: Sequence["_BaseChunk"]) -> object:
finish_reason = "stop"
def _finish_reason_of_chunks(chunks: Sequence["_BaseChunk"], unreported: str = "stop") -> object:
finish_reason = unreported
for chunk in chunks:
if "choices" in chunk and len(chunk["choices"]) > 0:
chunk_finish_reason = None
@ -402,7 +402,9 @@ class ChunkProcessor:
def build_base_choice(index: int, chunks: Sequence["_BaseChunk"]) -> Choices:
return Choices(
index=index,
finish_reason=ChunkProcessor._finish_reason_of_chunks(chunks),
finish_reason=ChunkProcessor._finish_reason_of_chunks(
chunks, unreported="tool_calls" if _carries_tool_calls(chunks) else "stop"
),
message={"role": ChunkProcessor._get_role_from_chunks(chunks), "content": ""},
)
@ -1532,6 +1534,13 @@ def _stream_choice_index(choice: object) -> int:
return index if isinstance(index, int) else 0
def _carries_tool_calls(chunks: Sequence[_BaseChunk]) -> bool:
return any(
"tool_calls" in choice["delta"] and choice["delta"]["tool_calls"]
for choice in chain.from_iterable(chunk.get("choices") or () for chunk in chunks)
)
def _choice_streams(chunks: Sequence[_BaseChunk]) -> tuple[tuple[int, Sequence[_BaseChunk]], ...]:
indices: Final = sorted(
frozenset(map(_stream_choice_index, chain.from_iterable(chunk.get("choices") or () for chunk in chunks)))

View file

@ -687,11 +687,6 @@ class OpenAIChatCompletionsHandler(BaseTranslation):
responses_so_far: Sequence["ModelResponseStream"],
litellm_logging_obj: "LiteLLMLoggingObj | None",
) -> "ModelResponse":
"""``stream_chunk_builder`` folds every choice of a stream into one index-0
choice, so the stream is rebuilt one choice index at a time (every chunk
kept, its choices narrowed to that index, so usage-only chunks still
count) and the rebuilt choices are stitched into one response, each
carrying the index the stream gave it."""
choice_indices: Final = tuple(
sorted(frozenset(choice.index for response in responses_so_far for choice in response.choices))
)

View file

@ -418,6 +418,35 @@ def test_openai_responses_chunk_parser_reasoning_summary():
assert delta.function_call is None
def test_bridged_reasoning_summary_parts_rebuild_into_the_one_choice_they_streamed_on():
from litellm.completion_extras.litellm_responses_transformation.transformation import (
OpenAiResponsesToChatCompletionStreamIterator,
)
iterator = OpenAiResponsesToChatCompletionStreamIterator(streaming_response=None, sync_stream=True)
events = (
{
"type": "response.reasoning_summary_text.delta",
"delta": "First part.",
"output_index": 0,
"summary_index": 0,
},
{
"type": "response.reasoning_summary_text.delta",
"delta": "Second part.",
"output_index": 0,
"summary_index": 1,
},
{"type": "response.output_text.delta", "delta": "Answer", "output_index": 1, "content_index": 0},
)
rebuilt = litellm.stream_chunk_builder(chunks=[iterator.chunk_parser(event) for event in events])
assert [(choice.index, choice.message.reasoning_content, choice.message.content) for choice in rebuilt.choices] == [
(0, "First part.Second part.", "Answer")
]
def test_chunk_parser_string_output_text_delta_produces_text():
from litellm.completion_extras.litellm_responses_transformation.transformation import (
OpenAiResponsesToChatCompletionStreamIterator,

View file

@ -1339,6 +1339,39 @@ def test_stream_chunk_builder_rebuilds_each_choice_of_a_multi_choice_text_stream
]
def test_stream_chunk_builder_gives_a_choice_without_a_finish_chunk_the_finish_reason_its_content_implies():
def chunk(choice_index: int, delta: Delta, finish_reason: str | None = None) -> ModelResponseStream:
return ModelResponseStream(
id="chatcmpl-123",
object="chat.completion.chunk",
created=1234567890,
model="gpt-4.1-mini",
choices=[StreamingChoices(index=choice_index, delta=delta, finish_reason=finish_reason)],
)
def tool_call(call_id: str) -> list[ChatCompletionDeltaToolCall]:
return [
ChatCompletionDeltaToolCall(
id=call_id, index=0, type="function", function=Function(name="lookup_fruit", arguments="{}")
)
]
response = stream_chunk_builder(
chunks=[
chunk(0, Delta(tool_calls=tool_call("call_a"))),
chunk(1, Delta(tool_calls=tool_call("call_b"))),
chunk(2, Delta(content="Kiwis are fuzzy.")),
chunk(0, Delta(), finish_reason="tool_calls"),
]
)
assert [(choice.index, choice.finish_reason) for choice in response.choices] == [
(0, "tool_calls"),
(1, "tool_calls"),
(2, "stop"),
]
def test_stream_chunk_builder_keeps_each_choices_function_call_annotations_and_images_on_that_choice():
def chunk(choice_index: int, delta: Delta) -> ModelResponseStream:
return ModelResponseStream(