Skip to content
126 changes: 118 additions & 8 deletions src/openai/lib/streaming/_deltas.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,46 @@
from ..._utils import is_dict, is_list


def _is_placeholder(entry: object) -> bool:
"""Detect a gap-filler placeholder that should be replaced in-place.

When a sparse tool-call stream emits index 0 then 2, the gap at index 1
is padded with an empty ``{}``. After the snapshot is round-tripped
through ``model_dump`` (which happens on the next chunk), that placeholder
is no longer empty — it becomes a dict of unset tool-call fields such as
``{"id": None, "function": None, "type": None}``. Both forms must be
detected so a later-arriving entry at the same index *replaces* the
placeholder instead of being inserted before it (which would shift
higher-index entries and break ``tool_calls[index]`` lookups).
"""
if not is_dict(entry):
return False
# Empty placeholder from the padding path.
if not entry:
return True
# Dumped placeholder: every value is None (or the dict is empty).
return all(v is None for v in entry.values())


def accumulate_delta(acc: dict[object, object], delta: dict[object, object]) -> dict[object, object]:
for key, delta_value in delta.items():
if key not in acc:
# When the first chunk contains a list with multiple entries at the
# same index (e.g. from speculative decoding), storing it directly
# would leave duplicate entries that later merges can't fix. (#3201)
# Coalesce duplicate-index entries before storing.
if is_list(delta_value) and len(delta_value) > 1:
delta_value = _coalesce_list_by_index(delta_value)
acc[key] = delta_value
continue

acc_value = acc[key]
if acc_value is None:
# Coalesce duplicate-index entries here too — a prior chunk may
# have set acc[key] to None via a delta that only contained the
# key without a value, and now the actual list arrives. (#3201)
if is_list(delta_value) and len(delta_value) > 1:
delta_value = _coalesce_list_by_index(delta_value)
Comment on lines +44 to +45

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Normalize single-entry tool-call lists before storing

When a choice has already been initialized by an earlier role/content chunk and its first tool_calls delta is a single non-zero index (for example index 1 arrives before index 0), model_dump supplies tool_calls: None, so this branch stores [{'index': 1, ...}] without padding because len == 1. The same _accumulate_chunk then immediately indexes choice_snapshot.message.tool_calls[tool_call_chunk.index], so tool_calls[1] is out of range and the stream raises before a later index-0 delta can repair the ordering; normalize indexed lists even when they contain only one entry.

Useful? React with 👍 / 👎.

acc[key] = delta_value
continue

Expand Down Expand Up @@ -49,16 +81,94 @@ def accumulate_delta(acc: dict[object, object], delta: dict[object, object]) ->
if not isinstance(index, int):
raise TypeError(f"Unexpected, list delta entry `index` value is not an integer; {index}")

try:
acc_entry = acc_value[index]
except IndexError:
acc_value.insert(index, delta_entry)
else:
if not is_dict(acc_entry):
raise TypeError("not handled yet")
# Merge by logical index, not physical position. (#3201)
# When the first chunk contains multiple entries with the same
# index (e.g. from speculative decoding), the physical position
# does not match the logical index. Find the existing entry by
# its index field and merge into it.
#
# If acc_value already contains duplicate-index entries
# (e.g. from a prior chunk that wasn't coalesced), merge into
# all of them so none are stranded.
found = False
for i, existing in enumerate(acc_value):
if is_dict(existing) and existing.get("index") == index:
acc_value[i] = accumulate_delta(existing, delta_entry)
found = True

acc_value[index] = accumulate_delta(acc_entry, delta_entry)
if not found:
# Add the new entry. Don't assume the logical index is a
# safe physical slot — if acc_value already has entries at
# higher indexes (e.g. [{"index": 1, ...}] and index 0
# arrives), acc_value[index] would overwrite the existing
# entry. Place the entry at the position matching the
# logical index so downstream code that does
# tool_calls[index] (treating logical index as physical
# position) reads the right entry.
if len(acc_value) <= index:
while len(acc_value) < index:
acc_value.append({})
acc_value.append(delta_entry)
Comment on lines +109 to +111

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid exposing padded tool-call placeholders

If a stream emits a sparse tool-call delta, e.g. index 0 followed by index 2 before index 1, this padding becomes part of choice_snapshot.message.tool_calls and the ChunkEvent.snapshot returned for that chunk. The user-visible snapshot now contains an extra empty tool call at slot 1 (id/function/type dump as None), even when the real index-1 call arrives later, so the exported streaming API exposes a bogus tool call rather than just the API data; keep the sparse padding internal or filter placeholders before constructing public snapshots.

AGENTS.md reference: AGENTS.md:L5-L8

Useful? React with 👍 / 👎.

else:
# The list is large enough but no entry has this
# index. If the slot at `index` is a placeholder
# (empty {} or a dumped placeholder with only None
# values from a model_dump round-trip), replace it
# in-place. Otherwise insert at the correct
# position to keep the list addressable by logical
# index.
existing = acc_value[index]
if _is_placeholder(existing):
acc_value[index] = delta_entry
else:
acc_value.insert(index, delta_entry)
Comment on lines +120 to +124

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Replace dumped sparse placeholders before inserting

When a sparse tool-call stream emits index 0 then 2, this code pads slot 1 with {}; after the snapshot is constructed and the next chunk calls model_dump, that placeholder is no longer empty but a dict of unset tool-call fields such as id/function/type: None. If index 1 then arrives, this branch inserts before that non-empty placeholder instead of replacing it, shifting the existing index-2 tool call to slot 3; the chat stream still reads tool_calls[2] in the parsed-argument preservation/event paths, so it can assert or drop events for tool call 2. Fresh evidence in this revision is that the placeholder replacement only handles raw {} placeholders, not the dumped placeholders produced by the snapshot round trip.

Useful? React with 👍 / 👎.

Comment on lines +123 to +124

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep tool-call done events from following raw index switches

When a higher-index tool call starts before a lower one and then continues later, this insertion keeps the snapshot addressable, but ChoiceEventState still treats the raw index transition, e.g. 1 -> 0, as meaning tool call 1 is complete. In that out-of-order stream, _add_tool_done_event can parse incomplete arguments for index 1 and records it in _done_tool_calls, so later chunks for index 1 won't emit a corrected done event; the accumulator needs matching done-event logic that does not finalize a tool solely because a lower logical index arrived.

Useful? React with 👍 / 👎.


acc[key] = acc_value

return acc


def _coalesce_list_by_index(lst: list[object]) -> list[object]:
"""Merge list entries that share the same ``index`` field into a single entry.

When the first streamed chunk contains multiple entries with the same
``index`` (e.g. from speculative decoding), storing the list directly would
leave duplicate entries. This function coalesces them by merging entries
with the same index using :func:`accumulate_delta`, so the snapshot starts
in a clean state. (#3201)

The result is sorted by the ``index`` field so the list stays addressable
by logical index — downstream code does ``tool_calls[index]`` treating
logical index as physical position.
"""
result: list[object] = []
for entry in lst:
if not is_dict(entry):
result.append(entry)
continue
index = entry.get("index")
if not isinstance(index, int):
result.append(entry)
continue
# Find an existing entry with the same index
found = False
for i, existing in enumerate(result):
if is_dict(existing) and existing.get("index") == index:
result[i] = accumulate_delta(existing, entry)
found = True
break
if not found:
# Place at the position matching the logical index, padding
# with empty dicts if needed, so the list is addressable by
# logical index.
while len(result) <= index:
result.append({})
# Replace the placeholder at `index` (empty {} or a dumped
# placeholder with only None values from a model_dump round-trip)
# or shift if occupied by a real entry.
existing = result[index]
if _is_placeholder(existing):
result[index] = entry
else:
result.insert(index, entry)
return result
32 changes: 27 additions & 5 deletions src/openai/lib/streaming/chat/_completions.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,9 @@
FunctionToolCallArgumentsDoneEvent,
FunctionToolCallArgumentsDeltaEvent,
)
from .._deltas import accumulate_delta
from .._deltas import accumulate_delta, _coalesce_list_by_index
from ...._types import Omit, IncEx, omit
from ...._utils import is_given, consume_sync_iterator, consume_async_iterator
from ...._utils import is_list, is_given, consume_sync_iterator, consume_async_iterator
from ...._compat import model_dump
from ...._models import build, construct_type
from ..._parsing import (
Expand Down Expand Up @@ -409,13 +409,20 @@ def _accumulate_chunk(self, chunk: ChatCompletionChunk) -> ParsedChatCompletionS
elif TYPE_CHECKING: # type: ignore[unreachable]
assert_never(prev_tool)
except IndexError:
# A new choice appeared that wasn't in the initial chunk.
# Coalesce tool_calls by index to handle duplicate-index entries
# from speculative decoding, same as _convert_initial_chunk_into_snapshot.
delta_dict = cast("dict[object, object]", choice.delta.to_dict())
tool_calls = delta_dict.get("tool_calls")
if is_list(tool_calls) and len(tool_calls) > 1:
delta_dict["tool_calls"] = _coalesce_list_by_index(tool_calls)
choice_snapshot = cast(
ParsedChoiceSnapshot,
construct_type(
type_=ParsedChoiceSnapshot,
value={
**choice.model_dump(exclude_unset=True, exclude={"delta"}),
"message": choice.delta.to_dict(),
"message": delta_dict,
},
),
)
Expand Down Expand Up @@ -532,7 +539,14 @@ def _build_events(
assert tool_calls is not None

for tool_call_delta in choice.delta.tool_calls:
tool_call = tool_calls[tool_call_delta.index]
# After coalescing in accumulate_delta / _coalesce_list_by_index,
# the physical position in the list matches the logical index.
# Use the delta's index with bounds checking to handle
# sparse or out-of-order arrival. (#3201)
idx = tool_call_delta.index
if idx < 0 or idx >= len(tool_calls):
continue
tool_call = tool_calls[idx]

if tool_call.type == "function":
assert tool_call_delta.function is not None
Expand Down Expand Up @@ -742,9 +756,17 @@ def _convert_initial_chunk_into_snapshot(chunk: ChatCompletionChunk) -> ParsedCh
choices = cast("list[object]", data["choices"])

for choice in chunk.choices:
message_dict = cast("dict[object, object]", choice.delta.to_dict())
# Coalesce duplicate-index tool_calls in the initial chunk. (#3201)
# When the first chunk contains multiple tool_calls with the same index
# (e.g. from speculative decoding), storing them directly would leave
# duplicate entries that later merges can't fix.
tool_calls = message_dict.get("tool_calls")
if is_list(tool_calls) and len(tool_calls) > 1:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Normalize single out-of-order tool-call entries

When the first chunk for a choice has a single tool_calls entry whose logical index is not 0, this len(tool_calls) > 1 guard stores it at physical slot 0 instead of padding it to its logical slot. If another delta or the finish event for that same index arrives before index 0, the remaining physical-index lookups in parsing/done handling address tool_calls[1] and raise or drop the event; normalize indexed lists even when they contain one entry whose index does not match its position.

Useful? React with 👍 / 👎.

message_dict["tool_calls"] = _coalesce_list_by_index(tool_calls)
Comment on lines +765 to +766

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Coalesce tool calls when a choice first appears later

When streaming multiple choices where the first SSE initializes only an earlier choice, a later choice's first chunk does not pass through this new initial-chunk coalescing; _accumulate_chunk still initializes that choice from choice.delta.to_dict() directly in its IndexError path. If that first delta has duplicate tool_calls index 0, the duplicate entries remain and subsequent argument accumulation/events use the unmerged snapshot. Fresh evidence in this revision is that coalescing was added only here, not in the later-choice initialization path.

Useful? React with 👍 / 👎.

choices[choice.index] = {
**choice.model_dump(exclude_unset=True, exclude={"delta"}),
"message": choice.delta.to_dict(),
"message": message_dict,
}

return cast(
Expand Down
Loading