Skip to content

fix(chat-completions): tolerate a missing tool call index when buffering streamed tool calls - #4824

Open
rioyu123 wants to merge 6 commits into
openai:mainfrom
rioyu123:fix/buffered-tool-call-missing-index
Open

fix(chat-completions): tolerate a missing tool call index when buffering streamed tool calls#4824
rioyu123 wants to merge 6 commits into
openai:mainfrom
rioyu123:fix/buffered-tool-call-missing-index

Conversation

@rioyu123

@rioyu123 rioyu123 commented Sep 2, 2026

Copy link
Copy Markdown

Summary

This pull request makes buffered Chat Completions resilient to OpenAI-compatible providers that omit tool_calls[].index on function-call deltas.

Previously, a recoverable stream could fail validation, split one logical function call into multiple incomplete calls, attach a continuation to the wrong owner, or silently lose a function call when a generated fallback index collided with a passthrough tool call. The fix preserves a usable completed response when ownership is unambiguous and fails early when it is not, preventing silent tool-call corruption.

The buffered path now:

  • preserves indexed streams and their established integer-index ordering;
  • retains one compatibility slot for an unindexed function call and assigns it a collision-free integer only at replay;
  • resolves missing-index continuations by a unique repeated function-call ID and reconciles a later provider-supplied integer index with the same call;
  • reconciles an ID-less late integer index with the sole unindexed function only when their names are compatible, while keeping different explicitly named calls distinct;
  • tracks passthrough IDs across indexed and unindexed continuations, promotes the first free integer owner, and restores it on later missing-index chunks;
  • preserves supported provider_specific_fields and extra_content on late-ID function deltas; and
  • raises ModelBehaviorError before mutation for ambiguous ownership, owner changes, and buffered/passthrough index collisions.

This improves compatibility without broadening the official OpenAI streaming contract. Multiple genuinely distinct function calls whose provider supplies no usable index or unique identity remain unsupported; providers can emit integer indexes, or callers can disable buffer_streamed_tool_calls.

Test plan

  • Expanded tests/models/test_openai_chatcompletions_stream.py with missing-index, repeated/late ID, name ownership, mixed replay ordering, passthrough promotion, metadata, fallback-collision, and ambiguity coverage: 148 passed.
  • ruff format, ruff check, optional-truthiness validation, mypy src, and pyright pass.
  • The repository's serial test gate passes.
  • The Windows parallel suite reached 8,356 passed and 81 skipped. Six credential-dependent failures pass when rerun with a test-only dummy API key, leaving the same 19 merge-base platform failures: 17 require Windows symlink privileges and 2 read a UTF-8 example file using the system GBK default. The two encoding tests pass with PYTHONUTF8=1, for an effective 8,362 passing cases under the required test environment.
  • Two independent Station Claude reviews of the final frozen diff returned clean with no findings or remaining uncertainty.

Issue number

Closes #4823

Checks

  • I've added new tests, if relevant
  • I've run .agents/skills/code-change-verification/scripts/run.sh (make is unavailable on this Windows host, so I ran the Makefile-equivalent commands directly)
  • I've confirmed every repository-wide test passes locally (blocked only by the reproducible Windows environment conditions described above)
  • If using Codex, I've run /review before submitting this PR and addressed the findings

…ing streamed tool calls

With buffer_streamed_tool_calls=True, the buffered path keyed tool calls
on tool_call_delta.index. The OpenAI SDK's lenient chunk parsing leaves
that index as None when an OpenAI-compatible provider omits it, so the
replayed ChoiceDeltaToolCall failed pydantic validation and sorting the
buffered calls raised TypeError once None and int keys coexisted. The
unbuffered path already handled the same stream.

Replay indexed calls first and give the index-less call the next free
index when building the buffered chunk.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 2, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-02T15:40:02.397379Z 122fda0 New commits
🔒 Security Review Completed 2026-09-02T15:38:30.841529Z 122fda0 New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@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: d2b3f8d5cc

ℹ️ About Codex in GitHub

Your team has set up Codex to 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 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +394 to +396
fallback_index = (
max((call.index for call in ordered_calls if isinstance(call.index, int)), default=-1)
+ 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 Exclude passthrough indexes when assigning the fallback

When a stream contains a passthrough custom call at index 0 and a function call whose provider omitted index, this calculation also chooses fallback index 0 because it inspects only buffered_calls. buffer_tool_call_stream emits the custom call first, causing handle_stream to add 0 to ignored_tool_call_indexes; the replayed function call is then skipped as though it were the custom call, silently removing a valid function call from the completed response. Include passthrough_tool_call_indexes when selecting the fallback so the generated index cannot collide.

AGENTS.md reference: AGENTS.md:L145-L148

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 9930941. The replay fallback now considers both buffered integer indexes and passthrough tool-call indexes, so a missing-index function call receives a collision-free index. I added a regression test with a passthrough custom call at index 0 followed by an unindexed function call.

Comment on lines 398 to +400
tool_call_deltas = [
cls._buffered_tool_call_delta(buffered_call)
for _, buffered_call in sorted(buffered_calls.items())
cls._buffered_tool_call_delta(buffered_call, fallback_index=fallback_index)
for buffered_call in ordered_calls

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 Merge unindexed continuation deltas with the active call

When a provider supplies index on the opening delta but omits it on a later arguments-only delta, accumulation creates separate entries for the numeric index and None. This replay processes them as separate calls, so the None entry lacks call_id and name and _buffered_tool_call_delta raises instead of returning the otherwise complete function call. This is an unreliable-chunk pattern covered by buffer_streamed_tool_calls; when exactly one active call makes the association unambiguous, merge the unindexed continuation into it and reserve rejection for ambiguous multi-call streams.

AGENTS.md reference: AGENTS.md:L147-L148

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 9930941. An arguments-only unindexed continuation now merges into the existing unindexed slot or the sole active buffered function call. If multiple indexed calls make ownership ambiguous, buffering raises ModelBehaviorError before replay. I added regression tests for both the unambiguous merge and ambiguous rejection paths.

@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: 993094149b

ℹ️ About Codex in GitHub

Your team has set up Codex to 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 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +401 to +404
occupied_indexes = passthrough_tool_call_indexes | {
call.index for call in ordered_calls if isinstance(call.index, int)
}
fallback_index = max(occupied_indexes, default=-1) + 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 Filter missing passthrough indexes before computing fallback

When default non-strict handling receives a custom/passthrough tool-call delta whose provider also omitted index, line 453 stores None in passthrough_tool_call_indexes; if the stream also contains a buffered indexed function call, max(occupied_indexes) then compares None with an integer and raises TypeError, instead of ignoring the unsupported custom call and returning the valid function call. Fresh evidence beyond the earlier indexed-passthrough collision is this opposite missing-index arrangement, where the passthrough index itself is absent; filter the passthrough set to integer indexes before calculating the fallback.

AGENTS.md reference: AGENTS.md:L147-L147

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in dacd81d. Passthrough identity is now recorded only when the provider supplies an integer index, so None cannot enter numeric fallback allocation or capture a later unindexed function call. I added parameterized regression coverage for a missing-index custom call followed by a function call with index 0 and with index omitted; both cases pass, and the complete streaming test module now has 115 passing tests.

@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: dacd81d21e

ℹ️ About Codex in GitHub

Your team has set up Codex to 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 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +320 to +321
tool_call_index = tool_call_delta.index
if not isinstance(tool_call_index, int) and not tool_call_delta.id:

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 Match repeated IDs before allocating an unindexed call

When an indexed opening delta is followed by an index-less continuation that repeats the same id, this condition is false, so the continuation is stored in the None slot instead of the existing call. A normal arguments continuation that omits name then makes replay raise “without a function name”; if it repeats the name, replay emits a duplicate call. Fresh evidence beyond the earlier arguments-only case is that the repeated ID makes ownership unambiguous, so match it against existing call_id values before allocating a new unindexed slot.

AGENTS.md reference: AGENTS.md:L145-L148

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Thanks — addressed in 3a2eef5. Missing-index deltas now resolve a unique repeated function-call ID before allocating the compatibility None slot, so continuations stay attached to the existing indexed call. The same ownership path also rejects duplicate or cross-domain ID ambiguity before mutation. Regression coverage now includes repeated-ID continuations with and without a repeated name, late IDs, passthrough-ID routing, and named continuations into an existing unindexed slot; the complete stream test module passes with 137 tests.

@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: 3a2eef5c45

ℹ️ About Codex in GitHub

Your team has set up Codex to 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 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +334 to +335
tool_call_index = tool_call_delta.index
if not isinstance(tool_call_index, int):

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 Reconcile a late index with the existing call ID

When a function-call opening omits index but a later continuation supplies an integer index and repeats the same id, ID matching is skipped because it only runs for non-integer deltas. The continuation therefore creates a second buffered call instead of completing the None-keyed call; replay then either raises because one half lacks a name or emits duplicate partial calls if the continuation repeats the name. Match the ID before treating a newly supplied index as a distinct call, and re-key the existing unindexed call when the match is unique.

AGENTS.md reference: AGENTS.md:L147-L147

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 10195b50. Integer-index deltas now match a unique None-keyed buffered call by ID and re-key that same call before merging. Occupied buffered or passthrough destinations and ambiguous same-ID ownership fail before state mutation.

I added regression coverage for late indexes with and without a repeated name, the provider-supplied replay index, passthrough-index collisions, and mutation-sensitive failure assertions. The focused streaming module passes all 142 tests; the full Windows suite remains at the unchanged 19-failure platform baseline.

@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: 10195b50c6

ℹ️ About Codex in GitHub

Your team has set up Codex to 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 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +340 to +341
if isinstance(tool_call_index, int):
if None in matching_indexes:

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 Re-key the sole unindexed call when the late index lacks an ID

When the sole opening delta contains the call ID and function name but omits index, and an arguments continuation later supplies an unused integer index while omitting id, matching_indexes is empty, so this branch leaves the opening under None and creates a second buffered call under the integer index. Replay then raises because the continuation lacks an ID/name instead of returning the complete call. Fresh evidence beyond the prior repeated-ID late-index finding is the repository's established continuation shape in test_stream_response_buffers_tool_call_deltas_when_enabled, where subsequent argument deltas omit id; reconcile the sole None entry when ownership is unambiguous.

AGENTS.md reference: AGENTS.md:L147-L147

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 122fda0. A sole ID-less late-index continuation now migrates the unindexed call only when function names are compatible; a different explicit name remains a distinct call, and ambiguous multi-call ownership still fails before mutation. Added success, distinct-name, and atomic ambiguity coverage.

Comment on lines +528 to +529
is_unindexed_passthrough_continuation = (
is_unindexed_untyped_continuation

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 Route indexed continuations by the passthrough ID

When a custom/passthrough opening has an ID but omits index, and a later continuation repeats that ID while supplying an index but omitting type, this predicate cannot recognize the continuation because it requires the new delta to remain unindexed. The new index is also absent from passthrough_tool_call_indexes, so _should_buffer_tool_call_delta classifies the continuation as a function call; replay then raises because that buffered entry has no function name, causing default non-strict handling to fail instead of ignoring the unsupported custom call and preserving any valid function calls. Match known passthrough IDs regardless of whether the continuation introduces an integer index, and promote that index into the passthrough tracking state.

AGENTS.md reference: AGENTS.md:L147-L147

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 122fda0. Untyped continuations now route by a known passthrough ID even when they introduce an integer index; a free index is promoted and restored for later deltas, while owner changes and function-index collisions fail before mutation. Added promotion and collision coverage.

@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: 122fda0c61

ℹ️ About Codex in GitHub

Your team has set up Codex to 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 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +362 to +363
buffered_name = buffered_calls[None].name
if not function_name or not buffered_name or function_name == buffered_name:

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 Reject ambiguous same-named late indexes

When a completed index-less call is followed by a distinct invocation whose opening supplies a new integer index but delays its ID, this condition treats an identical function name as proof that the delta belongs to the old call. The later ID then overwrites the first call's ID while both argument payloads are concatenated, silently dropping one invocation. Fresh evidence beyond the earlier late-index finding is the added different-name test at lines 1698-1742: changing both calls to invoke the same function turns that supported two-call shape into corruption, so this ambiguous case should fail rather than re-key.

AGENTS.md reference: AGENTS.md:L147-L147

Useful? React with 👍 / 👎.

Comment on lines +632 to +635
is_unindexed_untyped_continuation
and saw_passthrough_tool_call
and (
not tool_call_delta.id

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 Isolate passthrough ownership from ignored choices

When a nonzero choice contains a tool call, it sets saw_passthrough_tool_call even though the default handler later ignores that choice and processes choice 0. If the sole index-less function call in choice 0 subsequently emits an ID-less, function-less metadata delta such as extra_content or provider_specific_fields, this branch interprets the unrelated nonzero choice as an ownership conflict and raises instead of merging the delta into the sole buffered call. Fresh evidence beyond the earlier unindexed-continuation comments is that the conflicting state originates from a different choice, so track passthrough ownership only for choice 0 here.

AGENTS.md reference: AGENTS.md:L147-L148

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.

buffer_streamed_tool_calls=True crashes when a Chat Completions provider omits the tool call index

1 participant