Skip to content

fix(streaming): merge duplicate-index entries in first tool_call chunk - #3633

Open
Xsidz wants to merge 1 commit into
openai:mainfrom
Xsidz:fix/accumulate-delta-duplicate-index
Open

fix(streaming): merge duplicate-index entries in first tool_call chunk#3633
Xsidz wants to merge 1 commit into
openai:mainfrom
Xsidz:fix/accumulate-delta-duplicate-index

Conversation

@Xsidz

@Xsidz Xsidz commented Aug 16, 2026

Copy link
Copy Markdown

Summary

Fixes #3201.

accumulate_delta in src/openai/lib/streaming/_deltas.py had a fast-path for the first-time-seen case:

if key not in acc:
    acc[key] = delta_value
    continue

When a first streaming chunk contained two tool_calls entries with the same index: 0 (the API sends partial updates for the same item in one chunk), this stored the raw list directly — bypassing the index-based merge logic used for all subsequent chunks. Later chunks for index: 0 then merged into physical position 0, ignoring the second entry, and the arguments JSON ended up truncated/invalid.

Fix: detect indexed-dict lists on first sight, seed acc[key] = [], and fall through to the existing merge path. Also guard the primitive-list extend branch with acc_value and ... so the empty seed list doesn't incorrectly short-circuit into extend.

Test plan

  • Smoke test: first chunk with two tool_calls[index:0] entries merges correctly; subsequent chunk appends to the same entry.
  • tests/test_streaming.py + tests/lib/: 276 passed, 0 failures.
  • Ruff lint: all checks passed.

Fixes openai#3201: when the first streamed chunk contained multiple tool_call
delta entries sharing the same index (e.g. two partial updates for
index 0), accumulate_delta stored the raw list on the `key not in acc`
fast-path, bypassing the index-based merge logic. Later deltas for the
same index then only merged into the first physical entry, stranding the
second and producing truncated/invalid arguments JSON.

Fix: seed indexed-dict lists with [] on first sight and fall through to
the existing merge path. Also guard the primitive-extend branch with
`acc_value and ...` so the empty seed list doesn't short-circuit there.
@Xsidz
Xsidz requested a review from a team as a code owner August 16, 2026 19:08
Copilot AI lite review requested due to automatic review settings August 16, 2026 19:08

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ba80359cc0

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

# 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 👍 / 👎.

Comment on lines +11 to +12
if is_list(delta_value) and delta_value and is_dict(delta_value[0]) and "index" in delta_value[0]:
acc[key] = []

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
if is_list(delta_value) and delta_value and is_dict(delta_value[0]) and "index" in delta_value[0]:
acc[key] = []

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 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Streaming tool_call deltas with duplicate indexes in first chunk are accumulated incorrectly

2 participants