From 617a3d88992b6fa2f8b92a5f024e811bdfc8b212 Mon Sep 17 00:00:00 2001 From: Lichao Chen <3780722+chenlichao@users.noreply.github.com> Date: Fri, 11 Sep 2026 10:04:22 -0700 Subject: [PATCH 1/2] fix(lib): treat null message content as empty in parse_response Responses parsing raised `TypeError: 'NoneType' object is not iterable` when a message output item had `content: null` - the same class of bug as null `output` fixed in #3345. Null reaches the parser because stream events are built without validation, and the same payload also passes the non-streaming path. Treat null content as empty with the same `or []` idiom used for null output, and add a regression test alongside the null-output tests. Fixes #3840 --- src/openai/lib/_parsing/_responses.py | 2 +- tests/lib/responses/test_null_output.py | 31 ++++++++++++++++++++++++- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src/openai/lib/_parsing/_responses.py b/src/openai/lib/_parsing/_responses.py index 81e6b2b983..033e06e6ed 100644 --- a/src/openai/lib/_parsing/_responses.py +++ b/src/openai/lib/_parsing/_responses.py @@ -61,7 +61,7 @@ def parse_response( for output in response.output or []: if output.type == "message": content_list: List[ParsedContent[TextFormatT]] = [] - for item in output.content: + for item in output.content or []: if item.type != "output_text": content_list.append(item) continue diff --git a/tests/lib/responses/test_null_output.py b/tests/lib/responses/test_null_output.py index 4c782de03a..1394cc0980 100644 --- a/tests/lib/responses/test_null_output.py +++ b/tests/lib/responses/test_null_output.py @@ -7,7 +7,10 @@ from pydantic import BaseModel from openai import OpenAI, AsyncOpenAI -from openai.types.responses import ToolParam +from openai._types import omit +from openai._models import construct_type_unchecked +from openai.types.responses import Response, ToolParam +from openai.lib._parsing._responses import parse_response class Answer(BaseModel): @@ -123,3 +126,29 @@ async def test_stream_recovers_finalized_output(sync: bool, terminal_output: str assert tool.type == "function_call" and tool.status == "completed" assert tool.id == "fc_test" assert tool.parsed_arguments == {"answer": 4} + + +def test_parse_response_with_null_message_content() -> None: + response = construct_type_unchecked( + type_=Response, + value={ + "id": "resp_test", + "status": "completed", + "output": [ + { + "id": "msg_test", + "type": "message", + "role": "assistant", + "status": "completed", + "content": None, + } + ], + }, + ) + + parsed = parse_response(text_format=omit, input_tools=omit, response=response) + + assert len(parsed.output) == 1 + message = parsed.output[0] + assert message.type == "message" + assert message.content == [] From 23aef752f86d65955fb187cf9a183ece66fb4ef1 Mon Sep 17 00:00:00 2001 From: Marcus Wood Date: Fri, 18 Sep 2026 21:33:15 +0000 Subject: [PATCH 2/2] test(responses): cover null message content through public helpers Exercise sync and async parsing and streaming with mocked null-content responses. Verify message metadata, completion events, and empty parsed accessors. Adapt coverage from community PR #3841. --- tests/lib/responses/test_null_content.py | 113 +++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 tests/lib/responses/test_null_content.py diff --git a/tests/lib/responses/test_null_content.py b/tests/lib/responses/test_null_content.py new file mode 100644 index 0000000000..7ff1c27f79 --- /dev/null +++ b/tests/lib/responses/test_null_content.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +import json + +import httpx2 +import pytest + +from openai import OpenAI, AsyncOpenAI + + +def _null_content_item() -> dict[str, object]: + return { + "id": "msg_test", + "type": "message", + "role": "assistant", + "status": "completed", + "content": None, + } + + +def _completed_event(output: object) -> dict[str, object]: + return { + "type": "response.completed", + "sequence_number": 1, + "response": { + "id": "resp_test", + "status": "completed", + "model": "test-model", + "output": output, + }, + } + + +def _stream_body(output: object) -> bytes: + events: list[dict[str, object]] = [ + {"type": "response.created", "sequence_number": 0, "response": {"id": "resp_test", "status": "in_progress"}}, + _completed_event(output), + ] + return "".join(f"data: {json.dumps(event)}\n\n" for event in events).encode() + + +@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) +async def test_stream_explicit_null_message_content(sync: bool) -> None: + """An explicit completed output whose message has content:null must parse to empty content, not TypeError.""" + transport = httpx2.MockTransport( + lambda _request: httpx2.Response( + 200, content=_stream_body([_null_content_item()]), headers={"content-type": "text/event-stream"} + ) + ) + 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") 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") as async_stream: + emitted = [event async for event in async_stream] + final = await async_stream.get_final_response() + + completed = emitted[-1] + assert completed.type == "response.completed" + assert completed.response == final + assert final.output_text == "" + assert final.output_parsed is None + assert len(final.output) == 1 + message = final.output[0] + assert message.type == "message" + assert message.id == "msg_test" + assert message.role == "assistant" + assert message.status == "completed" + assert message.content == [] + + +@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) +async def test_parse_null_message_content(sync: bool) -> None: + """Non-streaming responses.parse must not raise TypeError on content:null either.""" + body = json.dumps( + { + "id": "resp_test", + "object": "response", + "created_at": 1, + "model": "test-model", + "status": "completed", + "parallel_tool_calls": False, + "tool_choice": "auto", + "tools": [], + "output": [_null_content_item()], + } + ) + transport = httpx2.MockTransport( + lambda _request: httpx2.Response(200, content=body, headers={"content-type": "application/json"}) + ) + if sync: + with OpenAI(api_key="fake-test-key", http_client=httpx2.Client(transport=transport)) as client: + parsed = client.responses.parse(model="test-model", input="test") + else: + async with AsyncOpenAI( + api_key="fake-test-key", http_client=httpx2.AsyncClient(transport=transport) + ) as async_client: + parsed = await async_client.responses.parse(model="test-model", input="test") + + assert parsed.output_text == "" + assert parsed.output_parsed is None + assert len(parsed.output) == 1 + message = parsed.output[0] + assert message.type == "message" + assert message.id == "msg_test" + assert message.role == "assistant" + assert message.status == "completed" + assert message.content == []