Repository navigation
Fix streaming delta merge for duplicate tool call indexes #3425
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
marcuswood-oai
merged 4 commits into
openai:main
from
fengjikui:codex/fix-duplicate-tool-call-deltas
Sep 10, 2026
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
8a006bb
fix: merge indexed streaming deltas on first chunk
fengjikui f152508
fix initial duplicate tool call deltas
fengjikui 4677fff
Merge remote-tracking branch 'origin/main' into review/pr3425-contrib…
marcuswood-oai ac1c3c4
fix(streaming): share delta accumulation and cover initialization paths
marcuswood-oai File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,139 @@ | ||
| from __future__ import annotations | ||
|
|
||
| import json | ||
| from typing import Any | ||
|
|
||
| import httpx2 | ||
| import pytest | ||
| from pydantic import BaseModel | ||
|
|
||
| import openai | ||
| from openai import OpenAI, AsyncOpenAI | ||
| from tests.respx2 import MockRouter | ||
| from openai.types.chat import ParsedChatCompletion | ||
| from openai.lib.streaming.chat import ChatCompletionStreamEvent | ||
|
|
||
| from ...conftest import base_url | ||
|
|
||
|
|
||
| class ListFilesArgs(BaseModel): | ||
| path: str | ||
|
|
||
|
|
||
| @pytest.fixture(params=["initial", "after_role", "empty", "new_choice", "existing_tool", "parallel"]) | ||
| def tool_call_chunks(request: pytest.FixtureRequest) -> list[dict[str, Any]]: | ||
| scenario = request.param | ||
| chunks: list[dict[str, Any]] = [] | ||
| choice_index = 1 if scenario == "new_choice" else 0 | ||
| tool_index = 1 if scenario in ("existing_tool", "parallel") else 0 | ||
|
|
||
| def chunk(delta: dict[str, Any], index: int = choice_index, finish: str | None = None) -> dict[str, Any]: | ||
| return { | ||
| "id": "chatcmpl-test", | ||
| "object": "chat.completion.chunk", | ||
| "created": 0, | ||
| "model": "gpt-4o-mini", | ||
| "choices": [{"index": index, "delta": delta, "finish_reason": finish}], | ||
| } | ||
|
|
||
| if scenario in ("after_role", "empty", "new_choice"): | ||
| chunks.append(chunk({"role": "assistant", **({"tool_calls": []} if scenario == "empty" else {})}, index=0)) | ||
|
|
||
| other_tool = {"index": 0, "id": "call_other", "type": "function", "function": {"name": "other", "arguments": "{}"}} | ||
| if scenario == "existing_tool": | ||
| chunks.append(chunk({"role": "assistant", "tool_calls": [other_tool]})) | ||
|
|
||
| # Match the report: the name and first argument fragment share an index | ||
| # within one chunk. Also split a later argument delta into two entries. | ||
| first_tools = [ | ||
| {"index": tool_index, "id": "call_files", "type": "function", "function": {"name": "list_files"}}, | ||
| {"index": tool_index, "function": {"arguments": '{"path"'}}, | ||
| ] | ||
| if scenario == "parallel": | ||
| first_tools.insert(0, other_tool) | ||
| chunks.extend( | ||
| [ | ||
| chunk({"role": "assistant", "tool_calls": first_tools}), | ||
| chunk( | ||
| { | ||
| "tool_calls": [ | ||
| {"index": tool_index, "function": {"arguments": ": "}}, | ||
| {"index": tool_index, "function": {"arguments": '"."}'}}, | ||
| ] | ||
| } | ||
| ), | ||
| chunk({}, finish="tool_calls"), | ||
| ] | ||
| ) | ||
| return chunks | ||
|
|
||
|
|
||
| def mock_tool_stream(router: MockRouter, chunks: list[dict[str, Any]]) -> None: | ||
| body = "".join(f"data: {json.dumps(chunk)}\n\n" for chunk in chunks) + "data: [DONE]\n\n" | ||
| router.post("/chat/completions").mock( | ||
| return_value=httpx2.Response(200, text=body, headers={"content-type": "text/event-stream"}) | ||
| ) | ||
|
|
||
|
|
||
| def assert_tool_stream( | ||
| completion: ParsedChatCompletion[None], | ||
| events: list[ChatCompletionStreamEvent[None]], | ||
| chunks: list[dict[str, Any]], | ||
| ) -> None: | ||
| calls = completion.choices[-1].message.tool_calls | ||
| assert calls is not None | ||
| expected_count = ( | ||
| 2 | ||
| if any( | ||
| tool.get("id") == "call_other" | ||
| for chunk in chunks | ||
| for choice in chunk["choices"] | ||
| for tool in choice["delta"].get("tool_calls", []) | ||
| ) | ||
| else 1 | ||
| ) | ||
| assert len(calls) == expected_count | ||
| assert calls[-1].id == "call_files" | ||
| assert calls[-1].function.name == "list_files" | ||
| assert calls[-1].function.arguments == '{"path": "."}' | ||
| assert calls[-1].function.parsed_arguments == ListFilesArgs(path=".") | ||
| if expected_count == 2: | ||
| assert calls[0].id == "call_other" | ||
| assert calls[0].function.arguments == "{}" | ||
|
|
||
| deltas = [ | ||
| event for event in events if event.type == "tool_calls.function.arguments.delta" and event.name == "list_files" | ||
| ] | ||
| assert [event.arguments_delta for event in deltas] == ["", '{"path"', ": ", '"."}'] | ||
| done = [ | ||
| event for event in events if event.type == "tool_calls.function.arguments.done" and event.name == "list_files" | ||
| ] | ||
| assert len(done) == 1 | ||
| assert done[0].arguments == '{"path": "."}' | ||
| assert done[0].parsed_arguments == ListFilesArgs(path=".") | ||
| # Snapshot normalization must not change the raw chunks exposed to callers. | ||
| assert [event.chunk.to_dict() for event in events if event.type == "chunk"] == chunks | ||
|
|
||
|
|
||
| @pytest.mark.respx2(base_url=base_url) | ||
| def test_duplicate_tool_call_deltas( | ||
| client: OpenAI, respx2_mock: MockRouter, tool_call_chunks: list[dict[str, Any]] | ||
| ) -> None: | ||
| mock_tool_stream(respx2_mock, tool_call_chunks) | ||
| with client.chat.completions.stream( | ||
| model="gpt-4o-mini", messages=[], tools=[openai.pydantic_function_tool(ListFilesArgs, name="list_files")] | ||
| ) as stream: | ||
| events = list(stream) | ||
| assert_tool_stream(stream.get_final_completion(), events, tool_call_chunks) | ||
|
|
||
|
|
||
| @pytest.mark.respx2(base_url=base_url) | ||
| async def test_async_duplicate_tool_call_deltas( | ||
| async_client: AsyncOpenAI, respx2_mock: MockRouter, tool_call_chunks: list[dict[str, Any]] | ||
| ) -> None: | ||
| mock_tool_stream(respx2_mock, tool_call_chunks) | ||
| async with async_client.chat.completions.stream( | ||
| model="gpt-4o-mini", messages=[], tools=[openai.pydantic_function_tool(ListFilesArgs, name="list_files")] | ||
| ) as stream: | ||
| events = [event async for event in stream] | ||
| assert_tool_stream(await stream.get_final_completion(), events, tool_call_chunks) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,94 @@ | ||
| from __future__ import annotations | ||
|
|
||
| import json | ||
|
|
||
| import httpx2 | ||
| import pytest | ||
|
|
||
| from openai import OpenAI, AsyncOpenAI | ||
| from tests.respx2 import MockRouter | ||
| from openai.types.beta.threads.runs import RunStep | ||
|
|
||
| from ..conftest import base_url | ||
|
|
||
|
|
||
| @pytest.fixture(params=[False, True], ids=["empty", "snapshot-without-index"]) | ||
| def assistant_tool_stream(request: pytest.FixtureRequest, respx2_mock: MockRouter) -> None: | ||
| tool = {"id": "call_files", "type": "function", "function": {"name": "list_files", "arguments": ""}} | ||
| step = { | ||
| "id": "step_test", | ||
| "object": "thread.run.step", | ||
| "assistant_id": "asst_test", | ||
| "thread_id": "thread_test", | ||
| "run_id": "run_test", | ||
| "created_at": 0, | ||
| "status": "in_progress", | ||
| "type": "tool_calls", | ||
| "step_details": {"type": "tool_calls", "tool_calls": [tool] if request.param else []}, | ||
| } | ||
| fragments = [ | ||
| {"index": 0, "type": "function", "function": {"arguments": '{"'}}, | ||
| {"index": 0, "type": "function", "function": {"arguments": 'path"'}}, | ||
| ] | ||
| if not request.param: | ||
| fragments.insert(0, {"index": 0, **tool}) | ||
| events = [ | ||
| ("thread.run.step.created", step), | ||
| ( | ||
| "thread.run.step.delta", | ||
| { | ||
| "id": "step_test", | ||
| "object": "thread.run.step.delta", | ||
| "delta": { | ||
| "step_details": {"type": "tool_calls", "tool_calls": fragments}, | ||
| }, | ||
| }, | ||
| ), | ||
| ( | ||
| "thread.run.step.delta", | ||
| { | ||
| "id": "step_test", | ||
| "object": "thread.run.step.delta", | ||
| "delta": { | ||
| "step_details": { | ||
| "type": "tool_calls", | ||
| "tool_calls": [ | ||
| {"index": 0, "type": "function", "function": {"arguments": ': "."}'}}, | ||
| ], | ||
| }, | ||
| }, | ||
| }, | ||
| ), | ||
| ] | ||
| body = "".join(f"event: {name}\ndata: {json.dumps(data)}\n\n" for name, data in events) + "data: [DONE]\n\n" | ||
| respx2_mock.post("/threads/thread_test/runs").mock( | ||
| return_value=httpx2.Response(200, text=body, headers={"content-type": "text/event-stream"}) | ||
| ) | ||
|
|
||
|
|
||
| def assert_tool_arguments(steps: list[RunStep]) -> None: | ||
| assert len(steps) == 1 | ||
| details = steps[0].step_details | ||
| assert details.type == "tool_calls" | ||
| assert len(details.tool_calls) == 1 | ||
| tool = details.tool_calls[0] | ||
| assert tool.type == "function" | ||
| assert tool.id == "call_files" | ||
| assert tool.function.name == "list_files" | ||
| assert json.loads(tool.function.arguments) == {"path": "."} | ||
|
|
||
|
|
||
| @pytest.mark.respx2(base_url=base_url) | ||
| @pytest.mark.usefixtures("assistant_tool_stream") | ||
| @pytest.mark.filterwarnings("ignore:The Assistants API is deprecated:DeprecationWarning") | ||
| def test_assistant_tool_call_deltas(client: OpenAI) -> None: | ||
| with client.beta.threads.runs.stream(thread_id="thread_test", assistant_id="asst_test") as stream: # pyright: ignore[reportDeprecated] | ||
| assert_tool_arguments(stream.get_final_run_steps()) | ||
|
|
||
|
|
||
| @pytest.mark.respx2(base_url=base_url) | ||
| @pytest.mark.usefixtures("assistant_tool_stream") | ||
| @pytest.mark.filterwarnings("ignore:The Assistants API is deprecated:DeprecationWarning") | ||
| async def test_async_assistant_tool_call_deltas(async_client: AsyncOpenAI) -> None: | ||
| async with async_client.beta.threads.runs.stream(thread_id="thread_test", assistant_id="asst_test") as stream: # pyright: ignore[reportDeprecated] | ||
| assert_tool_arguments(await stream.get_final_run_steps()) |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.