From 2d08c77172ba3c274c45a08e573f30ac7dfd8eba Mon Sep 17 00:00:00 2001 From: Junhyuk Lee Date: Thu, 30 Apr 2026 07:45:42 +0000 Subject: [PATCH 1/2] Advance OSS contribution for Typing: when stream is completed, delta in ChatCompletionChunk from azure openai is None; should be ChoiceDelta Nightly Codex produced a focused contribution for https://github.com/openai/openai-python/issues/1677. Constraint: Automated nightly run; keep changes small and reviewable. Confidence: medium Scope-risk: narrow Tested: See uploaded nightly artifacts and workflow logs. Not-tested: Maintainer CI beyond this workflow. --- NIGHTLY_CODEX_FINAL_ATTEMPT_1.md | 11 +++++ examples/demo.py | 3 +- examples/module_client.py | 3 +- src/openai/cli/_api/chat/completions.py | 2 +- src/openai/lib/streaming/chat/_completions.py | 39 ++++++++++----- .../types/chat/chat_completion_chunk.py | 2 +- tests/lib/chat/test_completions_streaming.py | 49 +++++++++++++++++++ 7 files changed, 94 insertions(+), 15 deletions(-) create mode 100644 NIGHTLY_CODEX_FINAL_ATTEMPT_1.md diff --git a/NIGHTLY_CODEX_FINAL_ATTEMPT_1.md b/NIGHTLY_CODEX_FINAL_ATTEMPT_1.md new file mode 100644 index 0000000000..065964abf1 --- /dev/null +++ b/NIGHTLY_CODEX_FINAL_ATTEMPT_1.md @@ -0,0 +1,11 @@ +Implemented a focused fix for [openai/openai-python#1677](https://github.com/openai/openai-python/issues/1677). + +Changed `ChatCompletionChunk.Choice.delta` to be nullable, then updated streaming accumulation, event emission, CLI output, and checked examples to treat `delta=None` as an empty incremental update while still honoring finish events. Added a regression test for a content chunk followed by a final null-delta chunk. + +Validation run: +- `pytest tests/lib/chat/test_completions_streaming.py -q` via `uv` passed: `16 passed` +- targeted `ruff check` passed +- targeted `pyright` passed +- `python -m py_compile` passed + +Also wrote `NIGHTLY_REPORT.md`. Existing untracked `.codex-nightly-prompt.md` and `uv.lock` were already present in the workspace and left untouched. \ No newline at end of file diff --git a/examples/demo.py b/examples/demo.py index ac1710f3e0..9c69eb63ca 100755 --- a/examples/demo.py +++ b/examples/demo.py @@ -34,7 +34,8 @@ if not chunk.choices: continue - print(chunk.choices[0].delta.content, end="") + delta = chunk.choices[0].delta + print(delta.content if delta and delta.content is not None else "", end="") print() # Response headers: diff --git a/examples/module_client.py b/examples/module_client.py index 5f2fb79dcf..8efeaba33d 100755 --- a/examples/module_client.py +++ b/examples/module_client.py @@ -20,6 +20,7 @@ ) for chunk in stream: - print(chunk.choices[0].delta.content or "", end="", flush=True) + delta = chunk.choices[0].delta + print(delta.content if delta and delta.content is not None else "", end="", flush=True) print() diff --git a/src/openai/cli/_api/chat/completions.py b/src/openai/cli/_api/chat/completions.py index 344eeff37c..868832f07b 100644 --- a/src/openai/cli/_api/chat/completions.py +++ b/src/openai/cli/_api/chat/completions.py @@ -149,7 +149,7 @@ def _stream_create(params: CompletionCreateParamsStreaming) -> None: if should_print_header: sys.stdout.write("===== Chat Completion {} =====\n".format(choice.index)) - content = choice.delta.content or "" + content = choice.delta.content if choice.delta and choice.delta.content is not None else "" sys.stdout.write(content) if should_print_header: diff --git a/src/openai/lib/streaming/chat/_completions.py b/src/openai/lib/streaming/chat/_completions.py index 5f072cafbd..4fc4891983 100644 --- a/src/openai/lib/streaming/chat/_completions.py +++ b/src/openai/lib/streaming/chat/_completions.py @@ -364,6 +364,9 @@ def _accumulate_chunk(self, chunk: ChatCompletionChunk) -> ParsedChatCompletionS return _convert_initial_chunk_into_snapshot(chunk) for choice in chunk.choices: + choice_delta = choice.delta + choice_delta_dict = choice_delta.to_dict() if choice_delta is not None else {} + try: choice_snapshot = completion_snapshot.choices[choice.index] previous_tool_calls = choice_snapshot.message.tool_calls or [] @@ -393,7 +396,7 @@ def _accumulate_chunk(self, chunk: ChatCompletionChunk) -> ParsedChatCompletionS ), ), ), - cast("dict[object, object]", choice.delta.to_dict()), + cast("dict[object, object]", choice_delta_dict), ), ), ) @@ -415,7 +418,7 @@ def _accumulate_chunk(self, chunk: ChatCompletionChunk) -> ParsedChatCompletionS type_=ParsedChoiceSnapshot, value={ **choice.model_dump(exclude_unset=True, exclude={"delta"}), - "message": choice.delta.to_dict(), + "message": choice_delta_dict, }, ), ) @@ -445,7 +448,7 @@ def _accumulate_chunk(self, chunk: ChatCompletionChunk) -> ParsedChatCompletionS partial_mode=True, ) - for tool_call_chunk in choice.delta.tool_calls or []: + for tool_call_chunk in choice_delta.tool_calls if choice_delta and choice_delta.tool_calls else []: tool_call_snapshot = (choice_snapshot.message.tool_calls or [])[tool_call_chunk.index] if tool_call_snapshot.type == "function": @@ -505,33 +508,42 @@ def _build_events( for choice in chunk.choices: choice_state = self._get_choice_state(choice) choice_snapshot = completion_snapshot.choices[choice.index] + choice_delta = choice.delta - if choice.delta.content is not None and choice_snapshot.message.content is not None: + if ( + choice_delta is not None + and choice_delta.content is not None + and choice_snapshot.message.content is not None + ): events_to_fire.append( build( ContentDeltaEvent, type="content.delta", - delta=choice.delta.content, + delta=choice_delta.content, snapshot=choice_snapshot.message.content, parsed=choice_snapshot.message.parsed, ) ) - if choice.delta.refusal is not None and choice_snapshot.message.refusal is not None: + if ( + choice_delta is not None + and choice_delta.refusal is not None + and choice_snapshot.message.refusal is not None + ): events_to_fire.append( build( RefusalDeltaEvent, type="refusal.delta", - delta=choice.delta.refusal, + delta=choice_delta.refusal, snapshot=choice_snapshot.message.refusal, ) ) - if choice.delta.tool_calls: + if choice_delta is not None and choice_delta.tool_calls: tool_calls = choice_snapshot.message.tool_calls assert tool_calls is not None - for tool_call_delta in choice.delta.tool_calls: + for tool_call_delta in choice_delta.tool_calls: tool_call = tool_calls[tool_call_delta.index] if tool_call.type == "function": @@ -617,7 +629,9 @@ def get_done_events( tool_index=self.__current_tool_call_index, ) - for tool_call in choice_chunk.delta.tool_calls or []: + choice_delta = choice_chunk.delta + + for tool_call in choice_delta.tool_calls if choice_delta and choice_delta.tool_calls else []: if self.__current_tool_call_index != tool_call.index: events_to_fire.extend( self._content_done_events(choice_snapshot=choice_snapshot, response_format=response_format) @@ -742,9 +756,12 @@ def _convert_initial_chunk_into_snapshot(chunk: ChatCompletionChunk) -> ParsedCh choices = cast("list[object]", data["choices"]) for choice in chunk.choices: + choice_delta = choice.delta + choice_delta_dict = choice_delta.to_dict() if choice_delta is not None else {} + choices[choice.index] = { **choice.model_dump(exclude_unset=True, exclude={"delta"}), - "message": choice.delta.to_dict(), + "message": choice_delta_dict, } return cast( diff --git a/src/openai/types/chat/chat_completion_chunk.py b/src/openai/types/chat/chat_completion_chunk.py index ecbfd0a5aa..58f4cbe393 100644 --- a/src/openai/types/chat/chat_completion_chunk.py +++ b/src/openai/types/chat/chat_completion_chunk.py @@ -94,7 +94,7 @@ class ChoiceLogprobs(BaseModel): class Choice(BaseModel): - delta: ChoiceDelta + delta: Optional[ChoiceDelta] = None """A chat completion delta generated by streamed model responses.""" finish_reason: Optional[Literal["stop", "length", "tool_calls", "content_filter", "function_call"]] = None diff --git a/tests/lib/chat/test_completions_streaming.py b/tests/lib/chat/test_completions_streaming.py index eb3a0973ac..d818eb8204 100644 --- a/tests/lib/chat/test_completions_streaming.py +++ b/tests/lib/chat/test_completions_streaming.py @@ -20,6 +20,7 @@ from openai import OpenAI, AsyncOpenAI from openai._utils import consume_sync_iterator, assert_signatures_in_sync from openai._compat import model_copy +from openai._models import construct_type from openai.types.chat import ChatCompletionChunk from openai.lib.streaming.chat import ( ContentDoneEvent, @@ -1068,6 +1069,54 @@ def streamer(client: OpenAI) -> Iterator[ChatCompletionChunk]: ) +def test_chat_completion_state_accepts_null_delta() -> None: + state = ChatCompletionStreamState() + + first_chunk = cast( + ChatCompletionChunk, + construct_type( + type_=ChatCompletionChunk, + value={ + "id": "chatcmpl-test", + "choices": [ + { + "delta": {"content": "Hello", "role": "assistant"}, + "finish_reason": None, + "index": 0, + } + ], + "created": 1720000000, + "model": "gpt-4o-mini", + "object": "chat.completion.chunk", + }, + ), + ) + final_chunk = cast( + ChatCompletionChunk, + construct_type( + type_=ChatCompletionChunk, + value={ + "id": "chatcmpl-test", + "choices": [{"delta": None, "finish_reason": "stop", "index": 0}], + "created": 1720000000, + "model": "gpt-4o-mini", + "object": "chat.completion.chunk", + }, + ), + ) + + assert first_chunk.choices[0].delta is not None + assert final_chunk.choices[0].delta is None + + state.handle_chunk(first_chunk) + events = list(state.handle_chunk(final_chunk)) + + assert [event.type for event in events] == ["chunk", "content.done"] + completion = state.get_final_completion() + assert completion.choices[0].message.content == "Hello" + assert completion.choices[0].finish_reason == "stop" + + @pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) def test_stream_method_in_sync(sync: bool, client: OpenAI, async_client: AsyncOpenAI) -> None: checking_client: OpenAI | AsyncOpenAI = client if sync else async_client From 0d601c3284f6ebfbf7d3c12890c8c7ffafc19c08 Mon Sep 17 00:00:00 2001 From: Junhyuk Lee <58055473+xodn348@users.noreply.github.com> Date: Thu, 30 Apr 2026 11:46:03 -0500 Subject: [PATCH 2/2] Remove nightly runner artifact from PR branch --- NIGHTLY_CODEX_FINAL_ATTEMPT_1.md | 11 ----------- 1 file changed, 11 deletions(-) delete mode 100644 NIGHTLY_CODEX_FINAL_ATTEMPT_1.md diff --git a/NIGHTLY_CODEX_FINAL_ATTEMPT_1.md b/NIGHTLY_CODEX_FINAL_ATTEMPT_1.md deleted file mode 100644 index 065964abf1..0000000000 --- a/NIGHTLY_CODEX_FINAL_ATTEMPT_1.md +++ /dev/null @@ -1,11 +0,0 @@ -Implemented a focused fix for [openai/openai-python#1677](https://github.com/openai/openai-python/issues/1677). - -Changed `ChatCompletionChunk.Choice.delta` to be nullable, then updated streaming accumulation, event emission, CLI output, and checked examples to treat `delta=None` as an empty incremental update while still honoring finish events. Added a regression test for a content chunk followed by a final null-delta chunk. - -Validation run: -- `pytest tests/lib/chat/test_completions_streaming.py -q` via `uv` passed: `16 passed` -- targeted `ruff check` passed -- targeted `pyright` passed -- `python -m py_compile` passed - -Also wrote `NIGHTLY_REPORT.md`. Existing untracked `.codex-nightly-prompt.md` and `uv.lock` were already present in the workspace and left untouched. \ No newline at end of file