Skip to content
Open
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
11 changes: 8 additions & 3 deletions src/openai/lib/streaming/_deltas.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,13 @@
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:
acc[key] = delta_value
continue
# Seed indexed-dict lists with [] so duplicate-index entries in the
# first chunk are merged by the list path below instead of stored raw.
if is_list(delta_value) and delta_value and is_dict(delta_value[0]) and "index" in delta_value[0]:
acc[key] = []
Comment on lines +11 to +12

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 duplicates in the initial stream chunk

When the stream's initial SSE chunk itself contains multiple tool_calls entries with the same index, this branch is never reached: _accumulate_chunk() returns _convert_initial_chunk_into_snapshot() while the snapshot is None, and that conversion copies choice.delta.to_dict() directly. The duplicate entries therefore remain split, so later deltas merge only into the first physical entry and the final arguments can still be truncated or invalid; the same indexed-list normalization needs to be applied during initial snapshot conversion.

Useful? React with 👍 / 👎.

Comment on lines +11 to +12

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Normalize the None accumulator path

In the normal role-first stream, the parsed message snapshot has tool_calls=None, and _accumulate_chunk() serializes that snapshot with model_dump() whose exclude_unset default is false. Consequently tool_calls is already present in acc, so the first later tool-call chunk takes the acc_value is None branch and stores the duplicate-index list raw; this new key not in acc normalization never runs for the target scenario, leaving the reported truncated-arguments bug unfixed. Apply the indexed-list normalization when replacing a None accumulator as well.

Useful? React with 👍 / 👎.

else:
acc[key] = delta_value
continue

acc_value = acc[key]
if acc_value is None:
Expand All @@ -33,7 +38,7 @@ def accumulate_delta(acc: dict[object, object], delta: dict[object, object]) ->
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):

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 primitive deltas after an empty list

When an accumulated list is empty and the next delta contains primitive entries (for example, accumulate_delta({"items": []}, {"items": ["value"]})), this new guard skips the primitive-list branch and the loop below raises TypeError because the string is not a dictionary. Empty primitive lists previously extended correctly, and the function explicitly supports non-dictionary lists; distinguish the seeded indexed-dict case using delta_value rather than treating every empty accumulator as an indexed-object list.

Useful? React with 👍 / 👎.

acc_value.extend(delta_value)
continue

Expand Down