Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 154 additions & 10 deletions src/openai/lib/streaming/_deltas.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,52 @@

from ..._utils import is_dict, is_list

#: Maximum gap padded between logical tool-call indexes. A stream with a
#: huge sparse index (e.g. index 1,000,000) must not allocate storage
#: proportional to the numeric index; entries beyond this bound are appended
#: at the end and still found by the index-based merge.
_MAX_INDEX_PADDING = 1024


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:
# Coalesce duplicate-index entries before storing so the snapshot
# starts in a clean state, and normalize single entries to their
# logical slot. (#3201)
if is_list(delta_value) and any(is_dict(x) for x in delta_value):
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 any(is_dict(x) for x in delta_value):
delta_value = _coalesce_list_by_index(delta_value)
acc[key] = delta_value
continue

Expand All @@ -25,21 +62,44 @@ def accumulate_delta(acc: dict[object, object], delta: dict[object, object]) ->
continue

if isinstance(acc_value, str) and isinstance(delta_value, str):
acc_value += delta_value
# Only streamed fields accumulate. Repeated metadata (e.g. a
# duplicate-index entry repeating `id` or `function.name` from a
# speculative decoder) must be replaced, not concatenated —
# otherwise the value becomes `call_abccall_abc`. (#3201)
if key in ("content", "refusal", "arguments"):
acc_value += delta_value
else:
acc_value = delta_value
elif isinstance(acc_value, (int, float)) and isinstance(delta_value, (int, float)):
acc_value += delta_value
elif is_dict(acc_value) and is_dict(delta_value):
acc_value = accumulate_delta(acc_value, delta_value)
elif is_list(acc_value) and is_list(delta_value):
# for lists of non-dictionary items we'll only ever get new entries
# in the array, existing entries will never be changed
if all(isinstance(x, (str, int, float)) for x in acc_value):
if acc_value and all(isinstance(x, (str, int, float)) for x in acc_value):
acc_value.extend(delta_value)
continue

# Coalesce the incoming list so duplicate-index entries are merged
# before placement — covers the empty-acc fast path (an explicit
# `tool_calls: []` from a prior chunk) and any un-coalesced first
# chunk. (#3201)
if any(is_dict(x) for x in delta_value):
delta_value = _coalesce_list_by_index(delta_value)

# Build an index map once so merging is O(n) instead of O(n²).
index_map: dict[int, list[int]] = {}
for i, existing in enumerate(acc_value):
if is_dict(existing) and isinstance(existing.get("index"), int):
index_map.setdefault(existing["index"], []).append(i)

for delta_entry in delta_value:
if not is_dict(delta_entry):
raise TypeError(f"Unexpected list delta entry is not a dictionary: {delta_entry}")
if _is_placeholder(delta_entry):
# Gap-filler from coalescing — nothing to merge.
continue

try:
index = delta_entry["index"]
Expand All @@ -49,16 +109,100 @@ 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.
positions = index_map.get(index)
if positions:
for pos in positions:
acc_value[pos] = accumulate_delta(acc_value[pos], delta_entry)
continue

acc_value[index] = accumulate_delta(acc_entry, delta_entry)
# 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. Bound the padding so a
# huge sparse index cannot allocate storage proportional to
# its value.
if len(acc_value) <= index:
if index - len(acc_value) <= _MAX_INDEX_PADDING:
while len(acc_value) < index:
acc_value.append({})
acc_value.append(delta_entry)
Comment on lines +139 to +141

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:
acc_value.append(delta_entry)
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)
index_map.setdefault(index, []).append(len(acc_value) - 1)

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. A single entry whose index does not
match its position is normalized to its logical slot as well.
"""
merged: dict[int, object] = {}
tail: list[object] = []
for entry in lst:
if not is_dict(entry):
tail.append(entry)
continue
index = entry.get("index")
if not isinstance(index, int):
if _is_placeholder(entry):
# Gap-filler from a previous padding pass — replaced by the
# real entry at the same logical index.
continue
tail.append(entry)
continue
if index in merged:
merged[index] = accumulate_delta(merged[index], entry)
else:
merged[index] = entry

if not merged:
return list(lst)

max_index = max(merged)
if max_index <= _MAX_INDEX_PADDING:
result = [merged.get(i, {}) for i in range(max_index + 1)]
else:
# Huge sparse index: materialize only up to the bound, then append
# the remaining entries in index order so allocation stays bounded.
result = [merged.get(i, {}) for i in range(_MAX_INDEX_PADDING + 1)]
result.extend(merged[i] for i in sorted(merged) if i > _MAX_INDEX_PADDING)
Comment on lines +203 to +207

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 Preserve logical lookup for indexes beyond the padding cap

When a coalesced chunk contains a sparse tool-call index above 1024 (for example, index 1,000,000), this branch appends the entry near physical position 1025, but _accumulate_chunk and _add_tool_done_event still access tool_calls[logical_index]; the first arguments event is silently skipped by the bounds check, and a subsequent delta or finish chunk raises IndexError. Fresh evidence in this revision is that the new padding cap bounds allocation without updating these remaining positional lookups.

AGENTS.md reference: AGENTS.md:L114-L121

Useful? React with 👍 / 👎.

return result + tail
64 changes: 51 additions & 13 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,10 +539,25 @@ 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]
Comment on lines +546 to +549

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 duplicate argument event snapshots progressive

When one chunk contains multiple same-index entries that each contribute a function.arguments fragment, accumulation coalesces all fragments before this raw-entry loop runs, so every emitted delta event reads the final combined tool_call here. The first event consequently exposes arguments from later entries in the same chunk rather than the documented accumulated value at that event; either emit one coalesced event or construct each event from a progressive snapshot.

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

Useful? React with 👍 / 👎.


if tool_call.type == "function":
assert tool_call_delta.function is not None
# A raw delta entry may be metadata-only (e.g. a
# duplicate index-0 entry carrying just `id` and
# `type: "function"` while the arguments arrived in
# an earlier entry). Coalescing merges them into one
# snapshot entry, but the raw delta itself has no
# function payload — skip it rather than aborting
# the stream on the assertion below. (#3201)
if tool_call_delta.function is None:
continue
events_to_fire.append(
build(
FunctionToolCallArgumentsDeltaEvent,
Expand Down Expand Up @@ -618,17 +640,25 @@ def get_done_events(
)

for tool_call in choice_chunk.delta.tool_calls or []:
if self.__current_tool_call_index != tool_call.index:
# Only finalize the previous tool call on a *forward* index
# transition. In an out-of-order stream (e.g. index 1 starts
# before index 0), a backward transition must not mark the
# higher-index call as done — its arguments may still be
# streaming, and finalizing it early would suppress the
# corrected done event when the real end arrives. (#3201)
if (
self.__current_tool_call_index is not None
and tool_call.index > self.__current_tool_call_index
):
Comment on lines +649 to +652

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 Finalize every outstanding out-of-order tool call

When an out-of-order stream emits index 1 and then index 0 without returning to index 1, this condition deliberately leaves index 1 unfinished, while the finish path emits a done event only for the current index 0. Consumers therefore never receive tool_calls.function.arguments.done for the completed index-1 call; the finish path needs to finalize all outstanding indexes rather than only the last observed one.

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

Useful? React with 👍 / 👎.

Comment on lines +649 to +652

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 Emit content completion when the first tool call begins

When a response produces content followed by a single tool call, __current_tool_call_index is None at the transition, so this new condition no longer calls _content_done_events when content generation ends. The content.done event is delayed until the final chunk instead of being emitted at the content-to-tool transition as before, which breaks consumers that use this event to process completed content while arguments continue streaming.

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

Useful? React with 👍 / 👎.

events_to_fire.extend(
self._content_done_events(choice_snapshot=choice_snapshot, response_format=response_format)
)

if self.__current_tool_call_index is not None:
self._add_tool_done_event(
events_to_fire=events_to_fire,
choice_snapshot=choice_snapshot,
tool_index=self.__current_tool_call_index,
)
self._add_tool_done_event(
events_to_fire=events_to_fire,
choice_snapshot=choice_snapshot,
tool_index=self.__current_tool_call_index,
)

self.__current_tool_call_index = tool_call.index

Expand Down Expand Up @@ -743,9 +773,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 +782 to +783

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