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
9 changes: 7 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 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

This was accurate against the commit it was written on, and it is the reason the streaming file is in this PR at all. Addressed in bbd1830, pushed about an hour and a half after this comment.

accumulate_event now patches the completed event with the accumulated snapshot before parsing, rather than letting parse_response iterate a null output:

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=completed_response,
    input_tools=self._input_tools,
)

So on a null completed payload the final ParsedResponse carries the streamed items rather than [], which is what you asked for. build is imported at the top of that module, and tests/lib/responses/test_null_output.py covers the case.

The response.output or [] guard you commented on stays as the last line of defence for the non-streaming path, where there is no snapshot to fall back to.

if output.type == "message":
content_list: List[ParsedContent[TextFormatT]] = []
for item in output.content:
Expand All @@ -71,7 +71,12 @@ def parse_response(
type_=ParsedResponseOutputText[TextFormatT],
value={
**item.to_dict(),
"parsed": parse_text(item.text, text_format=text_format),
# item.text is typed str, but some backends send null; guard anyway.
"parsed": (
parse_text(item.text, text_format=text_format)
if item.text is not None # pyright: ignore[reportUnnecessaryComparison]
else None
),
},
)
)
Expand Down
26 changes: 25 additions & 1 deletion src/openai/lib/streaming/responses/_responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from ...._streaming import Stream, AsyncStream
from ....types.responses import ParsedResponse, ResponseStreamEvent as RawResponseStreamEvent
from ..._parsing._responses import TextFormatT, parse_text, parse_response
from ....types.responses.response import Response
from ....types.responses.tool_param import ToolParam
from ....types.responses.parsed_response import (
ParsedContent,
Expand Down Expand Up @@ -357,9 +358,32 @@ 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: Response = event.response
# `output` is generated as non-optional, so both type checkers treat
# `is None` as impossible. Some backends do send null here, which is the
# case this branch exists for, so read it through getattr rather than
# silencing mypy and pyright separately. `snapshot` is already known to
# be non-None by this point.
completed_output = getattr(completed_response, "output", None)
if completed_output is None:
# to_dict() gives dict[str, object], so the ** spread cannot be
# checked field by field against Response.
patched: dict[str, Any] = {
**completed_response.to_dict(),
"output": [item.to_dict() for item in snapshot.output],
}
completed_response = build(type(completed_response), **patched)
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 @@ -479,10 +479,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 []:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Handle null output before strict validation

When a client opts into _strict_response_validation=True, a /responses payload with output: null is rejected in _base_client.py via validate_type() before this output_text guard can ever run, because the model still declares output as a non-optional List[ResponseOutputItem]. That means the null-output backend case this change is trying to tolerate still crashes for strict clients; normalize the payload before validation or make the response field accept None as well.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Confirmed, and I do not think it can be fixed from this PR.

output is declared non-optional here:

output: List[ResponseOutputItem]

and this file opens with:

# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.

So making the field accept None means changing the OpenAPI spec, which is not something a contributor can do in this repository. The same applies to the sibling comment about ResponseOutputText.text.

That leaves this PR fixing the non-strict path, which is the default, while _strict_response_validation=True still raises earlier in validate_type(). That is a genuine remaining gap and I would rather it be stated in the PR than discovered later, so I have noted it in the description.

Worth flagging to a maintainer: this PR does edit the generated response.py to guard the output_text property. If that file is regenerated without the change being carried in the Stainless config, the guard will be dropped. If that is a concern, the property guard could move to a non-generated module instead, and I am happy to restructure it that way.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Correcting one thing I said here: I claimed the guard could be dropped on regeneration. That is wrong. CONTRIBUTING states that manual modifications are persisted between generations, so the only cost is a possible merge conflict with generator changes, not a silent revert.

The substantive point stands unchanged: Response.output is generated as non-optional, so a strict client still fails in validate_type() before reaching this guard, and closing that gap needs an OpenAPI spec change rather than an edit here.

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: # pyright: ignore[reportUnnecessaryComparison]
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

from typing import Any

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


def _make_response(output: Any) -> Response:
"""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() -> None:
"""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() -> None:
"""Response.output_text must skip output_text items with text=None (issue #3063)."""
from openai.types.responses.response_output_text import ResponseOutputText
from openai.types.responses.response_output_message import ResponseOutputMessage

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() -> None:
"""parse_response must not raise TypeError when response.output is None (issue #3325)."""

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


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

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=omit, input_tools=omit, response=resp)
assert len(parsed.output) == 1