From 1f53041a2708f5b52e4ca6bbfbccaa4b4eb5bfaf Mon Sep 17 00:00:00 2001 From: LeSingh1 Date: Sun, 31 May 2026 22:51:52 -0700 Subject: [PATCH 1/3] Handle null output in parse_response parse_response iterates response.output directly. Response.output is typed as a required list, but the SDK constructs models leniently, so when a backend emits a terminal response.completed event with output null (seen with the Codex backend for incomplete responses) the field becomes None and the loop raises TypeError: 'NoneType' object is not iterable. This is the streaming responses.stream() / responses.parse() crash path. Guard the loop with `response.output or []` so a response with no output items parses to an empty output list. --- src/openai/lib/_parsing/_responses.py | 2 +- tests/lib/responses/test_responses.py | 30 +++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/openai/lib/_parsing/_responses.py b/src/openai/lib/_parsing/_responses.py index 8853a0749f..36346177b8 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 8e5f16df95..f9cd8657af 100644 --- a/tests/lib/responses/test_responses.py +++ b/tests/lib/responses/test_responses.py @@ -61,3 +61,33 @@ def test_parse_method_definition_in_sync(sync: bool, client: OpenAI, async_clien checking_client.responses.parse, exclude_params={"tools"}, ) + + +def test_parse_response_with_null_output() -> None: + # some backends (e.g. Codex) emit a terminal `response.completed` event where + # `output` is null; the SDK constructs the model leniently so `.output` ends + # up as None and parsing must not crash, see #3312/#3313/#3314/#3321/#3325 + from openai._types import omit + from openai._models import construct_type + from openai.types.responses.response import Response + from openai.lib._parsing._responses import parse_response + + response = construct_type( + type_=Response, + value={ + "id": "resp_123", + "created_at": 0, + "model": "gpt-5", + "object": "response", + "output": None, + "parallel_tool_calls": True, + "tool_choice": "auto", + "tools": [], + "status": "incomplete", + }, + ) + assert isinstance(response, Response) + assert response.output is None + + parsed = parse_response(text_format=omit, input_tools=omit, response=response) + assert parsed.output == [] From 5225266b5ac3c2ce3f5ae22304511555f4a9797a Mon Sep 17 00:00:00 2001 From: LeSingh1 Date: Mon, 1 Jun 2026 08:40:18 -0700 Subject: [PATCH 2/3] Preserve streamed output when completed event omits output When a backend sends the terminal response.completed event with a null or empty output after streaming output items, the streaming path passed that response straight to parse_response, so the final response dropped the text and tool calls already accumulated in the snapshot. Fall back to the accumulated snapshot output in that case. Addresses the streaming review note on this PR. --- .../lib/streaming/responses/_responses.py | 11 ++- tests/lib/responses/test_responses.py | 83 ++++++++++++++++++- 2 files changed, 92 insertions(+), 2 deletions(-) diff --git a/src/openai/lib/streaming/responses/_responses.py b/src/openai/lib/streaming/responses/_responses.py index 6975a9260d..46a6f76b0c 100644 --- a/src/openai/lib/streaming/responses/_responses.py +++ b/src/openai/lib/streaming/responses/_responses.py @@ -15,6 +15,7 @@ ) from ...._types import Omit, omit from ...._utils import is_given, consume_sync_iterator, consume_async_iterator +from ...._compat import model_copy from ...._models import build, construct_type_unchecked from ...._streaming import Stream, AsyncStream from ....types.responses import ParsedResponse, ResponseStreamEvent as RawResponseStreamEvent @@ -357,9 +358,17 @@ def accumulate_event(self, event: RawResponseStreamEvent) -> ParsedResponseSnaps if output.type == "function_call": output.arguments += event.delta elif event.type == "response.completed": + response = event.response + if not response.output and snapshot.output: + # Some backends send the terminal completed event with a null or + # empty output even though items were streamed earlier. Fall back + # to the accumulated snapshot so the final response keeps that + # content instead of dropping it. + response = model_copy(response) + response.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 f9cd8657af..6f8ab36b82 100644 --- a/tests/lib/responses/test_responses.py +++ b/tests/lib/responses/test_responses.py @@ -69,8 +69,8 @@ def test_parse_response_with_null_output() -> None: # up as None and parsing must not crash, see #3312/#3313/#3314/#3321/#3325 from openai._types import omit from openai._models import construct_type - from openai.types.responses.response import Response from openai.lib._parsing._responses import parse_response + from openai.types.responses.response import Response response = construct_type( type_=Response, @@ -91,3 +91,84 @@ def test_parse_response_with_null_output() -> None: parsed = parse_response(text_format=omit, input_tools=omit, response=response) assert parsed.output == [] + + +def test_streaming_completed_with_null_output_keeps_snapshot() -> None: + # if the terminal `response.completed` event omits output but items were + # streamed earlier, the final response should keep the accumulated content + # rather than collapsing to an empty list + from openai._types import omit + from openai._models import construct_type_unchecked + from openai.types.responses import ResponseStreamEvent + from openai.lib.streaming.responses._responses import ResponseStreamState + + def ev(value: dict) -> ResponseStreamEvent: + return construct_type_unchecked(type_=ResponseStreamEvent, value=value) + + base_resp = { + "id": "resp_1", + "created_at": 0, + "model": "gpt-5", + "object": "response", + "parallel_tool_calls": True, + "tool_choice": "auto", + "tools": [], + "status": "in_progress", + "output": [], + } + + state: ResponseStreamState[object] = ResponseStreamState(input_tools=omit, text_format=omit) + state.handle_event(ev({"type": "response.created", "sequence_number": 0, "response": base_resp})) + state.handle_event( + ev( + { + "type": "response.output_item.added", + "sequence_number": 1, + "output_index": 0, + "item": { + "id": "msg_1", + "type": "message", + "role": "assistant", + "status": "in_progress", + "content": [], + }, + } + ) + ) + state.handle_event( + ev( + { + "type": "response.content_part.added", + "sequence_number": 2, + "output_index": 0, + "content_index": 0, + "item_id": "msg_1", + "part": {"type": "output_text", "text": "", "annotations": []}, + } + ) + ) + state.handle_event( + ev( + { + "type": "response.output_text.delta", + "sequence_number": 3, + "output_index": 0, + "content_index": 0, + "item_id": "msg_1", + "delta": "Hello world", + "logprobs": [], + } + ) + ) + + completed_resp = dict(base_resp) + completed_resp["status"] = "completed" + completed_resp["output"] = None + state.handle_event(ev({"type": "response.completed", "sequence_number": 4, "response": completed_resp})) + + final = state._completed_response + assert final is not None + assert len(final.output) == 1 + message = final.output[0] + assert message.type == "message" + assert message.content[0].text == "Hello world" From 859e182f004e30d352d3277760440985b812ede4 Mon Sep 17 00:00:00 2001 From: Marcus Wood Date: Wed, 9 Sep 2026 22:54:58 +0000 Subject: [PATCH 3/3] fix: recover finalized response items on null completion --- .../lib/streaming/responses/_responses.py | 13 +- tests/lib/responses/test_null_output.py | 125 ++++++++++++++++++ tests/lib/responses/test_responses.py | 107 +-------------- 3 files changed, 134 insertions(+), 111 deletions(-) create mode 100644 tests/lib/responses/test_null_output.py diff --git a/src/openai/lib/streaming/responses/_responses.py b/src/openai/lib/streaming/responses/_responses.py index 46a6f76b0c..735238493e 100644 --- a/src/openai/lib/streaming/responses/_responses.py +++ b/src/openai/lib/streaming/responses/_responses.py @@ -26,6 +26,7 @@ ParsedResponseOutputMessage, ParsedResponseFunctionToolCall, ) +from ....types.responses.response_output_item import ResponseOutputItem class ResponseStream(Generic[TextFormatT]): @@ -241,6 +242,7 @@ def __init__( ) -> None: self.__current_snapshot: ParsedResponseSnapshot | None = None self._completed_response: ParsedResponse[TextFormatT] | None = None + self._completed_output: dict[int, ResponseOutputItem] = {} self._input_tools = [tool for tool in input_tools] if is_given(input_tools) else [] self._text_format = text_format self._rich_text_format: type | Omit = text_format if inspect.isclass(text_format) else omit @@ -357,15 +359,14 @@ def accumulate_event(self, event: RawResponseStreamEvent) -> ParsedResponseSnaps output = snapshot.output[event.output_index] if output.type == "function_call": output.arguments += event.delta + elif event.type == "response.output_item.done": + self._completed_output[event.output_index] = event.item elif event.type == "response.completed": response = event.response - if not response.output and snapshot.output: - # Some backends send the terminal completed event with a null or - # empty output even though items were streamed earlier. Fall back - # to the accumulated snapshot so the final response keeps that - # content instead of dropping it. + if getattr(response, "output", None) is None: + # Recover finalized items; deltas can omit final status, annotations, or refusals. response = model_copy(response) - response.output = snapshot.output + response.output = [self._completed_output[index] for index in sorted(self._completed_output)] self._completed_response = parse_response( text_format=self._text_format, response=response, diff --git a/tests/lib/responses/test_null_output.py b/tests/lib/responses/test_null_output.py new file mode 100644 index 0000000000..4c782de03a --- /dev/null +++ b/tests/lib/responses/test_null_output.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +import json + +import httpx2 +import pytest +from pydantic import BaseModel + +from openai import OpenAI, AsyncOpenAI +from openai.types.responses import ToolParam + + +class Answer(BaseModel): + answer: int + + +@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) +@pytest.mark.parametrize("terminal_output", ["null", "missing", "empty", "present"]) +@pytest.mark.parametrize("has_items", [True, False], ids=["with-items", "without-items"]) +async def test_stream_recovers_finalized_output(sync: bool, terminal_output: str, has_items: bool) -> None: + items: list[dict[str, object]] = ( + [ + { + "id": "msg_test", + "type": "message", + "role": "assistant", + "status": "completed", + "content": [ + { + "type": "output_text", + "text": '{"answer": 4}', + "annotations": [ + { + "type": "url_citation", + "url": "https://example.com", + "title": "Example", + "start_index": 0, + "end_index": 1, + } + ], + }, + {"type": "refusal", "refusal": "Final refusal"}, + ], + }, + { + "id": "fc_test", + "type": "function_call", + "call_id": "call_test", + "name": "lookup", + "arguments": '{"answer": 4}', + "status": "completed", + }, + ] + if has_items + else [] + ) + response: dict[str, object] = {"id": "resp_test", "status": "in_progress", "output": []} + events: list[dict[str, object]] = [{"type": "response.created", "response": response}] + for index, item in enumerate(items): + added = {**item, "status": "in_progress"} + if item["type"] == "message": + added["content"] = [] + else: + added["arguments"] = "" + events.append({"type": "response.output_item.added", "output_index": index, "item": added}) + # Final items carry data absent from the live snapshot. Preserve output_index order. + for index in reversed(range(len(items))): + events.append({"type": "response.output_item.done", "output_index": index, "item": items[index]}) + completed: dict[str, object] = { + "id": "resp_test", + "status": "completed", + "usage": {"input_tokens": 1, "output_tokens": 2, "total_tokens": 3}, + } + if terminal_output != "missing": + # A supplied list must win, even when it differs from the streamed items. + completed["output"] = {"null": None, "empty": [], "present": items[:1]}[terminal_output] + events.append({"type": "response.completed", "response": completed}) + body = "".join( + f"data: {json.dumps({**event, 'sequence_number': index})}\n\n" for index, event in enumerate(events) + ).encode() + transport = httpx2.MockTransport( + lambda _request: httpx2.Response(200, content=body, headers={"content-type": "text/event-stream"}) + ) + tools: list[ToolParam] = [{"type": "function", "name": "lookup", "parameters": {}, "strict": True}] + if sync: + with OpenAI(api_key="fake-test-key", http_client=httpx2.Client(transport=transport)) as client: + with client.responses.stream(model="test-model", input="test", text_format=Answer, tools=tools) as stream: + emitted = list(stream) + final = stream.get_final_response() + else: + async with AsyncOpenAI( + api_key="fake-test-key", http_client=httpx2.AsyncClient(transport=transport) + ) as async_client: + async with async_client.responses.stream( + model="test-model", input="test", text_format=Answer, tools=tools + ) as async_stream: + emitted = [event async for event in async_stream] + final = await async_stream.get_final_response() + + last_event = emitted[-1] + assert last_event.type == "response.completed" + assert last_event.response == final + assert final.id == "resp_test" + assert final.status == "completed" + assert final.usage is not None and final.usage.total_tokens == 3 + if not has_items or terminal_output == "empty": + assert final.output == [] + return + + assert len(final.output) == (1 if terminal_output == "present" else 2) + message = final.output[0] + assert message.type == "message" and message.status == "completed" + assert message.id == "msg_test" + assert final.output_parsed == Answer(answer=4) + text = message.content[0] + assert text.type == "output_text" + citation = text.annotations[0] + assert citation.type == "url_citation" and citation.url == "https://example.com" + refusal = message.content[1] + assert refusal.type == "refusal" and refusal.refusal == "Final refusal" + if terminal_output != "present": + tool = final.output[1] + assert tool.type == "function_call" and tool.status == "completed" + assert tool.id == "fc_test" + assert tool.parsed_arguments == {"answer": 4} diff --git a/tests/lib/responses/test_responses.py b/tests/lib/responses/test_responses.py index f06a1e8590..390681e8fb 100644 --- a/tests/lib/responses/test_responses.py +++ b/tests/lib/responses/test_responses.py @@ -106,111 +106,8 @@ def test_parse_method_definition_in_sync(sync: bool, client: OpenAI, async_clien def test_parse_response_with_null_output() -> None: - # some backends (e.g. Codex) emit a terminal `response.completed` event where - # `output` is null; the SDK constructs the model leniently so `.output` ends - # up as None and parsing must not crash, see #3312/#3313/#3314/#3321/#3325 - from openai._types import omit - from openai._models import construct_type - from openai.lib._parsing._responses import parse_response - from openai.types.responses.response import Response - - response = construct_type( - type_=Response, - value={ - "id": "resp_123", - "created_at": 0, - "model": "gpt-5", - "object": "response", - "output": None, - "parallel_tool_calls": True, - "tool_choice": "auto", - "tools": [], - "status": "incomplete", - }, - ) - assert isinstance(response, Response) - assert response.output is None + response = construct_type_unchecked(type_=Response, value={"output": None}) parsed = parse_response(text_format=omit, input_tools=omit, response=response) - assert parsed.output == [] - -def test_streaming_completed_with_null_output_keeps_snapshot() -> None: - # if the terminal `response.completed` event omits output but items were - # streamed earlier, the final response should keep the accumulated content - # rather than collapsing to an empty list - from openai._types import omit - from openai._models import construct_type_unchecked - from openai.types.responses import ResponseStreamEvent - from openai.lib.streaming.responses._responses import ResponseStreamState - - def ev(value: dict) -> ResponseStreamEvent: - return construct_type_unchecked(type_=ResponseStreamEvent, value=value) - - base_resp = { - "id": "resp_1", - "created_at": 0, - "model": "gpt-5", - "object": "response", - "parallel_tool_calls": True, - "tool_choice": "auto", - "tools": [], - "status": "in_progress", - "output": [], - } - - state: ResponseStreamState[object] = ResponseStreamState(input_tools=omit, text_format=omit) - state.handle_event(ev({"type": "response.created", "sequence_number": 0, "response": base_resp})) - state.handle_event( - ev( - { - "type": "response.output_item.added", - "sequence_number": 1, - "output_index": 0, - "item": { - "id": "msg_1", - "type": "message", - "role": "assistant", - "status": "in_progress", - "content": [], - }, - } - ) - ) - state.handle_event( - ev( - { - "type": "response.content_part.added", - "sequence_number": 2, - "output_index": 0, - "content_index": 0, - "item_id": "msg_1", - "part": {"type": "output_text", "text": "", "annotations": []}, - } - ) - ) - state.handle_event( - ev( - { - "type": "response.output_text.delta", - "sequence_number": 3, - "output_index": 0, - "content_index": 0, - "item_id": "msg_1", - "delta": "Hello world", - "logprobs": [], - } - ) - ) - - completed_resp = dict(base_resp) - completed_resp["status"] = "completed" - completed_resp["output"] = None - state.handle_event(ev({"type": "response.completed", "sequence_number": 4, "response": completed_resp})) - - final = state._completed_response - assert final is not None - assert len(final.output) == 1 - message = final.output[0] - assert message.type == "message" - assert message.content[0].text == "Hello world" + assert parsed.output == []