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/types/responses/response.py
Original file line number Diff line number Diff line change
Expand Up @@ -551,10 +551,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)
53 changes: 53 additions & 0 deletions tests/lib/responses/test_null_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

from openai import OpenAI, AsyncOpenAI
from openai.types.responses import ToolParam
from openai.types.responses.response import Response


class Answer(BaseModel):
Expand Down Expand Up @@ -123,3 +124,55 @@ 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 _make_response(**overrides: object) -> Response:
base: dict[str, object] = dict(
id="resp_test",
created_at=0,
model="gpt-5.2",
object="response",
parallel_tool_calls=False,
tool_choice="auto",
tools=[],
)
base.update(overrides)
return Response.model_construct(**base)


def test_output_text_with_null_output() -> None:
"""output_text must not crash when output is None, matching parse_response's own guard."""
response = _make_response(output=None)
assert response.output_text == ""


def test_output_text_with_missing_output_text() -> None:
"""A message content block whose type is output_text but whose text is None (a
partial/aborted item) must be skipped rather than appended as None."""
response = _make_response(
output=[
{
"id": "msg_test",
"type": "message",
"role": "assistant",
"status": "in_progress",
"content": [{"type": "output_text", "text": None, "annotations": []}],
}
]
)
assert response.output_text == ""


def test_output_text_with_present_output() -> None:
response = _make_response(
output=[
{
"id": "msg_test",
"type": "message",
"role": "assistant",
"status": "completed",
"content": [{"type": "output_text", "text": "hello", "annotations": []}],
}
]
)
assert response.output_text == "hello"