Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/openai/lib/_parsing/_responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 []:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve streamed output on null completed payloads

In the streamed Responses path, ResponseStreamState.accumulate_event parses event.response on response.completed (src/openai/lib/streaming/responses/_responses.py:359-364) rather than the accumulated snapshot, so when that final completed event has output=None after earlier output item/text delta events, this new fallback turns the final parsed response into output=[]. In that backend case, stream.get_final_response().output_text and the emitted completed event become empty even though the stream already received valid text; the null completed payload should fall back to the accumulated snapshot instead of discarding it.

Useful? React with 👍 / 👎.

if output.type == "message":
content_list: List[ParsedContent[TextFormatT]] = []
for item in output.content:
Expand All @@ -71,7 +71,7 @@ def parse_response(
type_=ParsedResponseOutputText[TextFormatT],
value={
**item.to_dict(),
"parsed": parse_text(item.text, text_format=text_format),
"parsed": parse_text(item.text, text_format=text_format) if item.text is not None else None,
},
)
)
Expand Down
16 changes: 15 additions & 1 deletion src/openai/lib/streaming/responses/_responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -357,9 +357,23 @@ def accumulate_event(self, event: RawResponseStreamEvent) -> ParsedResponseSnaps
if output.type == "function_call":
output.arguments += event.delta
elif event.type == "response.completed":
# Some backends (e.g. the chatgpt.com Codex backend) send
# `output: null` in the final `response.completed` event even when
# valid output items were already delivered via `output_item.done`
# events and accumulated into `snapshot.output`. In that case we
# must not let parse_response iterate over null and produce an empty
# output list; instead we patch the completed event's response with
# the accumulated snapshot output before parsing so that the final
# ParsedResponse contains the real content.
completed_response = event.response
if completed_response.output is None and snapshot is not None:
completed_response = build(
type(completed_response),
**{**completed_response.to_dict(), "output": [item.to_dict() for item in snapshot.output]},
)
self._completed_response = parse_response(
text_format=self._text_format,
response=event.response,
response=completed_response,
input_tools=self._input_tools,
)

Expand Down
4 changes: 2 additions & 2 deletions src/openai/types/responses/response.py
Original file line number Diff line number Diff line change
Expand Up @@ -444,10 +444,10 @@ def output_text(self) -> str:
If no `output_text` content blocks exist, then an empty string is returned.
"""
texts: List[str] = []
for output in self.output:
for output in self.output or []:
if output.type == "message":
for content in output.content:
if content.type == "output_text":
if content.type == "output_text" and content.text is not None:
texts.append(content.text)

return "".join(texts)
101 changes: 101 additions & 0 deletions tests/lib/responses/test_null_output.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
"""Regression tests for null-output edge cases in the Responses API."""
from __future__ import annotations

import pytest

from openai.types.responses.response import Response
from openai.lib._parsing._responses import parse_response


def _make_response(output):
"""Build a minimal Response fixture with the given output value."""
return Response.model_construct(
id="resp_test",
object="response",
created_at=0,
status="completed",
background=False,
error=None,
incomplete_details=None,
instructions=None,
max_output_tokens=None,
max_tool_calls=None,
model="gpt-4o-mini",
output=output,
parallel_tool_calls=True,
previous_response_id=None,
prompt_cache_key=None,
reasoning=None,
safety_identifier=None,
service_tier="default",
store=True,
temperature=1.0,
text=None,
tool_choice="auto",
tools=[],
top_p=1.0,
truncation="disabled",
usage=None,
user=None,
metadata={},
)


def test_output_text_property_null_output():
"""Response.output_text must return '' when output is None (issue #3325 / #3063)."""
resp = _make_response(output=None)
assert resp.output_text == ""


def test_output_text_property_null_text_in_content():
"""Response.output_text must skip output_text items with text=None (issue #3063)."""
from openai.types.responses.response_output_message import ResponseOutputMessage
from openai.types.responses.response_output_text import ResponseOutputText

content = [
ResponseOutputText.model_construct(type="output_text", text=None, annotations=[], logprobs=[]),
ResponseOutputText.model_construct(type="output_text", text='{"ok": true}', annotations=[], logprobs=[]),
]
msg = ResponseOutputMessage.model_construct(
id="msg_test",
type="message",
status="completed",
role="assistant",
content=content,
)
resp = _make_response(output=[msg])
# only the non-null text should be concatenated
assert resp.output_text == '{"ok": true}'


def test_parse_response_null_output_does_not_crash():
"""parse_response must not raise TypeError when response.output is None (issue #3325)."""
from openai import NOT_GIVEN

resp = _make_response(output=None)
# Should not raise
parsed = parse_response(text_format=NOT_GIVEN, input_tools=NOT_GIVEN, response=resp)
assert parsed.output == []


def test_parse_response_null_text_skips_structured_parse():
"""parse_response must not crash when an output_text item has text=None (issue #3063)."""
from openai import NOT_GIVEN
from openai.types.responses.response_output_message import ResponseOutputMessage
from openai.types.responses.response_output_text import ResponseOutputText

content = [
ResponseOutputText.model_construct(type="output_text", text=None, annotations=[], logprobs=[]),
ResponseOutputText.model_construct(type="output_text", text="hello", annotations=[], logprobs=[]),
]
msg = ResponseOutputMessage.model_construct(
id="msg_test",
type="message",
status="completed",
role="assistant",
content=content,
)
resp = _make_response(output=[msg])
# Should not raise; null-text item gets parsed=None, non-null item gets parsed normally.
parsed = parse_response(text_format=NOT_GIVEN, input_tools=NOT_GIVEN, response=resp)
assert len(parsed.output) == 1