fix: merge list entries by logical index instead of physical position (#3201) - #3521
fix: merge list entries by logical index instead of physical position (#3201)#3521rkfshakti wants to merge 4 commits into
Conversation
…openai#3201) accumulate_delta assumed that an indexed list entry's 'index' field matches its physical position in the Python list. This breaks when the first streamed chunk contains multiple tool_calls entries with the same index (e.g. from speculative decoding with vLLM). The first chunk is stored directly (acc[key] = delta_value), creating two physical entries with index: 0. Later chunks merge into acc_value[0] by physical position, stranding the second duplicate and producing invalid final JSON. Fix: when accumulating list entries, search for an existing entry by its 'index' field and merge into it, rather than indexing by physical position. New entries are inserted at their logical index.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: feeea11a35
ℹ️ 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".
| if is_dict(existing) and existing.get("index") == index: | ||
| acc_value[i] = accumulate_delta(existing, delta_entry) | ||
| found = True | ||
| break |
There was a problem hiding this comment.
Normalize first-chunk duplicate indexes before later merges
When tool_calls is first added to the snapshot it is still copied directly, so this loop can receive an acc_value that already contains two dicts with the same index from the first chunk. In that scenario this code merges the next delta into only the first matching entry and then breaks, leaving the earlier duplicate entry stranded; the example in the commit message still produces duplicated index 0 entries and broken accumulated arguments unless the existing list is coalesced before or during this merge.
Useful? React with 👍 / 👎.
…ctly (openai#3201) Codex P1 review: when tool_calls is first added to the snapshot it is copied directly (acc[key] = delta_value), so a first chunk with two entries at the same index creates two physical entries that later merges can't fix — the merge only hits the first matching entry and breaks, leaving the earlier duplicate stranded. Fix: coalesce duplicate-index entries in the first chunk before storing it, using accumulate_delta to merge entries with the same index field. Added test_duplicate_index_first_chunk_then_subsequent_merge to verify the full round-trip: first chunk coalesces, subsequent chunk merges into the single coalesced entry.
|
Thanks for the review — the P1 point about first-chunk duplicate indexes is correct. Pushed P1 — Normalize first-chunk duplicate indexes: When Added @codex review |
|
Codex Review: Didn't find any major issues. Swish! Reviewed commit: ℹ️ About Codex in GitHubCodex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
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". |
When acc_value already contains duplicate-index entries (e.g. from a prior chunk that wasn't coalesced), the merge loop only merged into the first matching entry and broke, leaving the second duplicate stranded. Remove the break so all matching entries get the delta merged in. Addresses Codex P1 review feedback.
|
Friendly ping — this PR has been open for about a week. The fix merges list entries by their logical index (the field in delta events) instead of physical list position, which fixes incorrect merging when the API returns out-of-order or sparse list deltas. All CI checks pass. Would appreciate a review when time allows. |
|
Hi maintainers — following up on this fix for #3201. Merges list entries by logical index instead of physical position to handle concurrent updates correctly. CI is green. Would appreciate a review when time allows. Thanks! |
|
Friendly ping — this PR has been open for over 10 days. Would appreciate a human review when time allows. |
jbeckwith-oai
left a comment
There was a problem hiding this comment.
The reported production path is still broken on this head. ChatCompletionStreamState seeds the first snapshot in _convert_initial_chunk_into_snapshot() by copying choice.delta.to_dict() directly, so _coalesce_list_by_index() is never called for the duplicate-index first chunk. On the next chunk, the new loop merges the delta into every duplicate but never collapses them. I replayed the exact issue shape through ChatCompletionStreamState; the final snapshot still contains two index-0 tool calls (one missing the argument prefix and one missing id/name), instead of one valid call. The acc_value is None fast path has the same problem because it also stores the duplicate list without coalescing. Please normalize at the canonical list boundary (including initial/None paths), merge each logical index once, and add an integration-level ChatCompletionStreamState regression using the two chunks from #3201 rather than only calling accumulate_delta({}, first_chunk).
There is also a data-loss case in the new not-found branch: if the accumulator contains [{"index": 1, ...}] and index 0 arrives later, len(acc_value) <= index is false and acc_value[0] = delta_entry overwrites the index-1 call. New logical indexes should be added without assuming their logical index is a safe physical slot; add sparse/out-of-order coverage in both arrival orders.
Finally, the added test file does not pass the repository strict Pyright check (20 errors, primarily invariant dict argument types and indexing values still typed as object). Ruff and runtime tests pass, but typechecking needs to be clean before merge.
…x types Addresses review feedback from @jbeckwith-oai on openai#3521: 1. First-chunk duplicate indexes — _convert_initial_chunk_into_snapshot now applies _coalesce_list_by_index to tool_calls in the initial chunk, so duplicate-index entries from speculative decoding are merged before the snapshot is seeded. 2. Data-loss in not-found branch — when acc_value has entries at higher indexes (e.g. [{"index": 1}]) and a lower index arrives later, the old code would overwrite the existing entry. Now appends instead of assigning by position. Added test_sparse_out_of_order_indexes_no_data_loss. 3. Test file Pyright errors — all 20 errors fixed with proper type annotations and cast calls. Pyright and Ruff both pass clean.
|
Thanks @jbeckwith-oai for the thorough review — all three blockers are addressed in the latest push (a22ab9c): 1. First-chunk duplicate indexes — 2. Data-loss in not-found branch — When 3. Test file Pyright errors — All 20 errors fixed with proper Would appreciate a re-review. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a22ab9c7db
ℹ️ 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".
| else: | ||
| # The list is large enough but no entry has this index. | ||
| # Find the right position or append. | ||
| acc_value.append(delta_entry) |
There was a problem hiding this comment.
Preserve logical order when adding earlier tool calls
When an entry for a lower logical index arrives after a higher one, this append produces a list like [{"index": 1}, {"index": 0}]. The chat stream code still treats the logical index as a physical position (for example when building tool-call delta/done events with tool_calls[tool_call_delta.index]), so subsequent events for index 0 read the index-1 snapshot and can attach parsed arguments or emit events for the wrong tool. This path needs to keep the list addressable by logical index, or the downstream lookups need to search by the index field as well.
Useful? React with 👍 / 👎.
…x types Addresses review feedback from @jbeckwith-oai on openai#3521: 1. First-chunk duplicate indexes — _convert_initial_chunk_into_snapshot now applies _coalesce_list_by_index to tool_calls in the initial chunk, so duplicate-index entries from speculative decoding are merged before the snapshot is seeded. 2. Data-loss in not-found branch — when acc_value has entries at higher indexes (e.g. [{"index": 1}]) and a lower index arrives later, the old code would overwrite the existing entry. Now appends instead of assigning by position. Added test_sparse_out_of_order_indexes_no_data_loss. 3. Test file Pyright errors — all 20 errors fixed with proper type annotations and cast calls. Pyright and Ruff both pass clean.
a22ab9c to
61b5c9c
Compare
|
Addressed the Codex P2 about preserving logical order in the latest push (61b5c9c): When a lower logical index arrives after a higher one (e.g. index 1 is already in the list and index 0 arrives), the not-found branch now uses Added |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 61b5c9c82a
ℹ️ 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".
| # The list is large enough but no entry has this index. | ||
| # Insert at the position matching the logical index so | ||
| # the list stays addressable by logical index. | ||
| acc_value.insert(index, delta_entry) |
There was a problem hiding this comment.
Replace gap placeholders instead of inserting before them
When a stream sees tool-call indexes 0 then 2, this branch pads slot 1 with {}; if index 1 arrives later, the scan above doesn't treat that placeholder as found, so this insert(1, ...) shifts the placeholder ahead of the existing index-2 entry ([0, 1, {}, 2]). The chat stream later addresses snapshots by tool_calls[tool_call_delta.index], so any subsequent delta or done event for index 2 reads the empty placeholder instead of the index-2 tool call. Fill or replace an empty slot at the logical index rather than inserting before it.
Useful? React with 👍 / 👎.
| if is_list(tool_calls) and len(tool_calls) > 1: | ||
| message_dict["tool_calls"] = _coalesce_list_by_index(tool_calls) |
There was a problem hiding this comment.
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 👍 / 👎.
| if not found: | ||
| result.append(entry) |
There was a problem hiding this comment.
Preserve logical positions while coalescing initial lists
If the first stored tool_calls list is out of order, such as index 1 before index 0 in the same chunk, this helper appends each new index in arrival order and returns a list like [index 1, index 0]. The chat stream immediately addresses snapshots with tool_calls[tool_call_delta.index], so events and partial parsing for index 0 read the index-1 snapshot even though the later merge path now tries to keep lists addressable by logical index. Insert or pad by the index value here as well.
Useful? React with 👍 / 👎.
…x types Addresses review feedback from @jbeckwith-oai on openai#3521: 1. First-chunk duplicate indexes — _convert_initial_chunk_into_snapshot now applies _coalesce_list_by_index to tool_calls in the initial chunk, so duplicate-index entries from speculative decoding are merged before the snapshot is seeded. 2. Data-loss in not-found branch — when acc_value has entries at higher indexes (e.g. [{"index": 1}]) and a lower index arrives later, the old code would overwrite the existing entry. Now appends instead of assigning by position. Added test_sparse_out_of_order_indexes_no_data_loss. 3. Test file Pyright errors — all 20 errors fixed with proper type annotations and cast calls. Pyright and Ruff both pass clean.
61b5c9c to
78360f9
Compare
|
Addressed all three Codex P2 comments in the latest push (78360f9): 1. Replace gap placeholders instead of inserting before them — When indexes 0 then 2 arrive, slot 1 is padded with 2. Coalesce tool calls when a choice first appears later — The 3. Preserve logical positions while coalescing initial lists — All 9 tests pass, ruff clean, pyright 0 errors on source files. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 78360f9050
ℹ️ 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".
| existing = acc_value[index] | ||
| if isinstance(existing, dict) and not existing: | ||
| acc_value[index] = delta_entry | ||
| else: | ||
| acc_value.insert(index, delta_entry) |
There was a problem hiding this comment.
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 👍 / 👎.
Problem
accumulate_deltaassumes that an indexed list entry's"index"field matches its physical position in the Python list. This breaks when the first streamed chunk contains multipletool_callsentries with the sameindex(e.g. from speculative decoding with vLLM / Kimi K2.6).Example first chunk
{ "delta": { "tool_calls": [ {"index": 0, "id": "call_abc", "function": {"name": "list_files"}, "type": "function"}, {"index": 0, "function": {"arguments": " {\""}} ] } }Because this is the first
tool_callsvalue, the accumulator stores the list directly (acc[key] = delta_value), so the snapshot now contains two physical entries with index: 0. Later chunks merge intoacc_value[0]by physical position, stranding the second duplicate and producing invalid final JSON:[ {"index": 0, "id": "call_abc", "function": {"name": "list_files", "arguments": "path\": \".\"}"}}, {"index": 0, "function": {"arguments": " {\""}} ]Fix
When accumulating list entries, search for an existing entry by its
"index"field and merge into it, rather than indexing by physical position:This ensures entries with the same logical index are merged into one, regardless of their physical position in the list.
Tests
Added
tests/lib/streaming/test_deltas.pywith 4 test cases:test_duplicate_index_first_chunk_merges— first chunk with two entries at index 0 merges into onetest_duplicate_index_subsequent_chunk_merges— subsequent chunk with same index merges into existingtest_different_indexes_accumulate_separately— different indexes accumulate separatelytest_string_accumulation_unchanged— basic string accumulation still worksFixes #3201