Skip to content

fix(openai): keep every streamed tool call when a chunk carries several - #960

Open
alexcastgonz (pcbeingused333) wants to merge 2 commits into
braintrustdata:mainfrom
pcbeingused333:fix/openai-stream-parallel-tool-calls
Open

alexcastgonz (pcbeingused333) wants to merge 2 commits into
braintrustdata:mainfrom
pcbeingused333:fix/openai-stream-parallel-tool-calls

Conversation

@pcbeingused333

Copy link
Copy Markdown

What

ChatCompletionWrapper._postprocess_streaming_results read only delta.tool_calls[0] and appended argument fragments to the last call it had opened. OpenAI itself streams one call per chunk, so this works there. OpenAI-compatible providers that send parallel tool calls in a single delta (Gemini's OpenAI-compatible endpoint, Together, vLLM) lost every call after the first in the logged span. Argument fragments interleaved across two indexes were also appended to the wrong call.

The loop now walks every entry of delta.tool_calls and continues the call named by its index. When a provider omits index (the GLM-4.6 case covered by test_chat_completion_streaming_none_arguments), a fragment still continues the last call, and a new id still opens a new call, so providers that reuse index 0 keep working.

Through wrap_openai, a stream whose first chunk carries two calls, Paris and Rome (reproduced against a local OpenAI-compatible SSE endpoint):

  • before: span tool_calls = get_weather {"city": "Paris"} only
  • after: get_weather {"city": "Paris"}, get_weather {"city": "Rome"}, matching what the caller received

Tests

New tests in TestZAICompatibleOpenAI, next to the existing GLM-4.6 postprocessor test, built from openai.types.chat.ChatCompletionChunk objects:

  • test_chat_completion_streaming_parallel_tool_calls_in_one_chunk: two complete calls in one delta. Fails on main (second call missing).
  • test_chat_completion_streaming_interleaved_tool_call_arguments: fragments for indexes 0 and 1 interleaved across chunks. Fails on main (arguments land on the wrong call).
  • test_chat_completion_streaming_new_tool_call_id_under_reused_index: every call at index 0 with distinct ids. Passes on main and after the fix; it guards the behavior kept on purpose.

I didn't add a VCR cassette: the official OpenAI API never streams several calls in one delta, so recording one needs one of those third-party providers. Happy to add one if you'd rather have it.

Run from py/ with the test_openai(latest) files and env (BRAINTRUST_TEST_PACKAGE_VERSION=latest, -p no:braintrust, openai 3.29.0): test_openai.py, test_oai_attachments.py, test_openai_openrouter_gateway.py → 84 passed (81 on main plus the 3 new tests). ruff 0.15.21 format/check, codespell 2.2.5 and pylint --errors-only are clean on the changed files. The older openai matrix versions (1.92/1.77/1.71) were not run locally.

Follow-up

The LiteLLM integration has the same loop (integrations/litellm/tracing.py ~L727, _postprocess_completion_streaming_results). Happy to follow up with the same change there.


This change was written with Claude Code; the tests and the reproduction above were run before opening the PR.

The chat completion stream postprocessor read only delta.tool_calls[0] and
appended argument fragments to the last call opened. Providers that stream
parallel tool calls in one delta (OpenAI-compatible endpoints such as
Gemini's, Together, vLLM) lost every call after the first in the logged
span, and fragments interleaved across indexes went to the wrong call.

Walk every entry of delta.tool_calls and continue the call named by its
index, falling back to the last call when a provider omits index. A new id
still opens a new call, so providers that reuse index 0 keep working.

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

chatgpt-codex-connector Bot commented Oct 16, 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-10-16T20:53:20.427717Z 5b9e5da PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

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: 5b9e5da08b

ℹ️ 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 (@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 (@codex) address that feedback".

else:
tool_call = tool_calls[-1] if tool_calls else None
# A new id always opens a new call, even under an index already in use.
if tool_call is not None and tool_id and tool_call["id"] and tool_call["id"] != tool_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.

P1 Badge Treat a nonempty ID as new after an ID-less call

When a compatible provider first emits an ID-less call and then starts a distinct call with a nonempty ID at the same reused index, this guard stays false because tool_call["id"] is falsy. The following merge path drops the new ID/name/type and concatenates its arguments into the prior call; before this change, any nonempty differing ID opened a new entry, so these streams now produce a silently corrupted span. Compare incoming IDs even when the stored one is None (or explicitly handle late ID hydration) rather than unconditionally merging.

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.

Good catch: a call that arrived without an id stored id=None, so the guard never fired and a later call with an id under the same index was merged into it. Fixed in f8972ee: any non-empty id that differs from the stored one now opens a new call, as before this change; id-less continuation fragments still extend the current call (the GLM-4.6 test still passes). Added test_chat_completion_streaming_call_with_id_after_call_without_id_under_reused_index, which fails on the previous commit.

Comment on lines +2283 to +2284
return openai.types.chat.ChatCompletionChunk.model_validate(
{

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 Add cassette-backed coverage for the provider response shape

The new regression tests only pass handcrafted chunks through the postprocessor, so they never establish that Gemini/Together/vLLM produce this multi-call wire shape or that the full wrap_openai streaming path logs it correctly; a mistaken synthetic payload or parser/version difference can therefore leave all three tests green while the production bug remains. Keep these as supplemental unit cases, but add a VCR-backed test against at least one claimed compatible provider, as required for response-shape-dependent integration behavior.

AGENTS.md reference: AGENTS.md:L184-L186

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.

There's no cassette because the official OpenAI API never streams several calls in one delta, and recording one needs keys for a provider that does (Gemini's OpenAI-compatible endpoint, Together, vLLM), which aren't available here. The shape was reproduced end to end through wrap_openai against a local OpenAI-compatible SSE endpoint (before: only the first call in the span; after: both). If a maintainer can record a cassette against one of those providers, happy to wire it in; or this can include that end-to-end test with a local fake server instead, if you prefer.

…ess call

A call that arrived without an id stored id=None, so a later call with an id
under the same (reused) index was merged into it. A non-empty id that differs
from the stored one now always opens a new call, as before this branch.
Fragments without an id still continue the current call (GLM-4.6 behavior).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
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.

1 participant