From 22b98e9963224998ec6ee4bdb3243b340c3e4355 Mon Sep 17 00:00:00 2001 From: Thiago Barbosa Date: Thu, 23 Jul 2026 13:01:04 +0200 Subject: [PATCH 1/4] fix: parse_response crashes on null output from Codex backend stream --- src/openai/lib/_parsing/_responses.py | 2 +- tests/lib/responses/test_responses.py | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/openai/lib/_parsing/_responses.py b/src/openai/lib/_parsing/_responses.py index c607587ec1..81e6b2b983 100644 --- a/src/openai/lib/_parsing/_responses.py +++ b/src/openai/lib/_parsing/_responses.py @@ -58,7 +58,7 @@ def parse_response( ) -> ParsedResponse[TextFormatT]: output_list: List[ParsedResponseOutputItem[TextFormatT]] = [] - for output in response.output: + for output in response.output or []: if output.type == "message": content_list: List[ParsedContent[TextFormatT]] = [] for item in output.content: diff --git a/tests/lib/responses/test_responses.py b/tests/lib/responses/test_responses.py index 4ed6dff47d..8cf96ee1d1 100644 --- a/tests/lib/responses/test_responses.py +++ b/tests/lib/responses/test_responses.py @@ -72,6 +72,14 @@ def test_parse_response_preserves_program_items(item: dict[str, object]) -> None assert parsed.output[0].to_dict() == item +def test_parse_response_handles_null_output() -> None: + response = construct_type_unchecked(type_=Response, value={"output": None}) + + parsed = parse_response(text_format=omit, input_tools=omit, response=response) + + assert parsed.output == [] + + @pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) def test_stream_method_definition_in_sync(sync: bool, client: OpenAI, async_client: AsyncOpenAI) -> None: checking_client: OpenAI | AsyncOpenAI = client if sync else async_client From 35ca37d804c280de1bf2cc81dfdc05d481fc04e8 Mon Sep 17 00:00:00 2001 From: Thiago Barbosa Date: Thu, 23 Jul 2026 14:20:00 +0200 Subject: [PATCH 2/4] fix: handle null output in response.completed events to preserve accumulated output --- .../lib/streaming/responses/_responses.py | 15 +++- tests/lib/responses/test_responses.py | 69 ++++++++++++++++++- 2 files changed, 81 insertions(+), 3 deletions(-) diff --git a/src/openai/lib/streaming/responses/_responses.py b/src/openai/lib/streaming/responses/_responses.py index 6975a9260d..05f66888c4 100644 --- a/src/openai/lib/streaming/responses/_responses.py +++ b/src/openai/lib/streaming/responses/_responses.py @@ -17,7 +17,7 @@ from ...._utils import is_given, consume_sync_iterator, consume_async_iterator from ...._models import build, construct_type_unchecked from ...._streaming import Stream, AsyncStream -from ....types.responses import ParsedResponse, ResponseStreamEvent as RawResponseStreamEvent +from ....types.responses import Response, ParsedResponse, ResponseStreamEvent as RawResponseStreamEvent from ..._parsing._responses import TextFormatT, parse_text, parse_response from ....types.responses.tool_param import ToolParam from ....types.responses.parsed_response import ( @@ -357,9 +357,20 @@ def accumulate_event(self, event: RawResponseStreamEvent) -> ParsedResponseSnaps if output.type == "function_call": output.arguments += event.delta elif event.type == "response.completed": + response: Response = event.response + if response.output is None: + # Some backends omit `output` on the completion event even though the + # individual output items were streamed via `response.output_item.*` / + # `response.*.delta` events. Fall back to the accumulated snapshot so we + # don't silently drop the generated content. + response = construct_type_unchecked( + type_=cast(Any, Response), + value={**response.to_dict(), "output": snapshot.output}, + ) + self._completed_response = parse_response( text_format=self._text_format, - response=event.response, + response=response, input_tools=self._input_tools, ) diff --git a/tests/lib/responses/test_responses.py b/tests/lib/responses/test_responses.py index 8cf96ee1d1..0cbbf74d46 100644 --- a/tests/lib/responses/test_responses.py +++ b/tests/lib/responses/test_responses.py @@ -1,5 +1,6 @@ from __future__ import annotations +from typing import Any, cast from typing_extensions import TypeVar import pytest @@ -10,8 +11,14 @@ from openai._types import omit from openai._utils import assert_signatures_in_sync from openai._models import construct_type_unchecked -from openai.types.responses import Response +from openai.types.responses import ( + Response, + ResponseCreatedEvent, + ResponseCompletedEvent, + ResponseOutputItemAddedEvent, +) from openai.lib._parsing._responses import parse_response +from openai.lib.streaming.responses._responses import ResponseStreamState from ...conftest import base_url from ..snapshots import make_snapshot_request @@ -80,6 +87,66 @@ def test_parse_response_handles_null_output() -> None: assert parsed.output == [] +def test_stream_state_preserves_accumulated_output_on_null_completion() -> None: + """Regression test: some backends omit `output` on `response.completed` even though + the output items were streamed via `response.output_item.*` events beforehand. The + accumulated snapshot must be used instead of discarding the generated content. + """ + state: ResponseStreamState[None] = ResponseStreamState(input_tools=omit, text_format=omit) + + state.handle_event( + construct_type_unchecked( + type_=cast(Any, ResponseCreatedEvent), + value={ + "type": "response.created", + "sequence_number": 0, + "response": {"output": []}, + }, + ) + ) + + message_item = { + "id": "msg_123", + "type": "message", + "status": "completed", + "role": "assistant", + "content": [{"type": "output_text", "text": "hello world", "annotations": []}], + } + state.handle_event( + construct_type_unchecked( + type_=cast(Any, ResponseOutputItemAddedEvent), + value={ + "type": "response.output_item.added", + "sequence_number": 1, + "output_index": 0, + "item": message_item, + }, + ) + ) + + events = state.handle_event( + construct_type_unchecked( + type_=cast(Any, ResponseCompletedEvent), + value={ + "type": "response.completed", + "sequence_number": 2, + "response": {"status": "completed", "output": None}, + }, + ) + ) + + assert state._completed_response is not None + output = state._completed_response.output + assert len(output) == 1 + assert output[0].type == "message" + assert output[0].content[0].type == "output_text" + assert output[0].content[0].text == "hello world" + + completed_event = events[-1] + assert completed_event.type == "response.completed" + assert completed_event.response.output == output + + @pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) def test_stream_method_definition_in_sync(sync: bool, client: OpenAI, async_client: AsyncOpenAI) -> None: checking_client: OpenAI | AsyncOpenAI = client if sync else async_client From 42ea50d8d497008278e3b621da25a1bc5ee60846 Mon Sep 17 00:00:00 2001 From: Thiago Barbosa Date: Thu, 23 Jul 2026 14:27:20 +0200 Subject: [PATCH 3/4] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- tests/lib/responses/test_responses.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/lib/responses/test_responses.py b/tests/lib/responses/test_responses.py index 0cbbf74d46..b82f5765bc 100644 --- a/tests/lib/responses/test_responses.py +++ b/tests/lib/responses/test_responses.py @@ -17,9 +17,7 @@ ResponseCompletedEvent, ResponseOutputItemAddedEvent, ) -from openai.lib._parsing._responses import parse_response -from openai.lib.streaming.responses._responses import ResponseStreamState - +from openai.lib.streaming.responses import ResponseStreamState from ...conftest import base_url from ..snapshots import make_snapshot_request From be08cd3b899da7d657c4b210dc9dc759aa744a0c Mon Sep 17 00:00:00 2001 From: Thiago Barbosa Date: Thu, 23 Jul 2026 14:53:00 +0200 Subject: [PATCH 4/4] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- tests/lib/responses/test_responses.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/lib/responses/test_responses.py b/tests/lib/responses/test_responses.py index b82f5765bc..35213872f2 100644 --- a/tests/lib/responses/test_responses.py +++ b/tests/lib/responses/test_responses.py @@ -17,6 +17,7 @@ ResponseCompletedEvent, ResponseOutputItemAddedEvent, ) +from openai.lib._parsing._responses import parse_response from openai.lib.streaming.responses import ResponseStreamState from ...conftest import base_url from ..snapshots import make_snapshot_request