Skip to content

fix: guard against null output in parse_response and output_text property - #3404

Open
adhavan18 wants to merge 6 commits into
openai:mainfrom
adhavan18:fix/null-output-crash
Open

fix: guard against null output in parse_response and output_text property#3404
adhavan18 wants to merge 6 commits into
openai:mainfrom
adhavan18:fix/null-output-crash

Conversation

@adhavan18

@adhavan18 adhavan18 commented Jun 15, 2026

Copy link
Copy Markdown

Fixes #3325 and #3063.

Problem

Two crashes in the Responses API when the server returns unexpected null values:

  1. Some backends send output: null in the final response.completed event even when output items were already streamed. parse_response and Response.output_text both iterate response.output directly → TypeError: 'NoneType' object is not iterable.
  2. When an output_text content item has text: null, parse_response passes None to the structured parser and output_text concatenates None into the string.

Fix

  • _parsing/_responses.py — iterate response.output or []; skip structured parsing when item.text is None
  • types/responses/response.py — same or [] guard in the output_text property; skip content items where text is None
  • lib/streaming/responses/_responses.py — when response.completed arrives with output: null, fall back to the accumulated snapshot output instead of parsing an empty list

Test

All existing tests in tests/lib/responses/ pass.

Known limitation: strict response validation

This fixes the default, non-strict path. Clients using _strict_response_validation=True are unaffected, because validate_type() rejects the payload earlier: Response.output is typed List[ResponseOutputItem] and ResponseOutputText.text is typed str, both in Stainless-generated files. Allowing None there needs an OpenAPI spec change rather than an edit in this repo.

The output_text guard does live in the generated src/openai/types/responses/response.py. Per CONTRIBUTING that is supported: manual modifications are persisted between generations, though they can conflict with generator changes. So no restructuring is proposed here.

…erty

When the Responses API returns `output: null` in a `response.completed`
event (observed with the chatgpt.com Codex backend), iterating
`response.output` raised `TypeError: 'NoneType' object is not iterable`.
A second related case is `output_text` content items whose `text` field
is `null`, which caused `parse_response` to pass `None` to structured
text parsing and `output_text` to concatenate `None` into the result.

Fixes:
- `parse_response` in `lib/_parsing/_responses.py`: iterate
  `response.output or []` so a null list is treated as empty.
- Same function: skip structured parsing for `output_text` items where
  `item.text is None`; set `parsed=None` for those items instead.
- `Response.output_text` property in `types/responses/response.py`:
  iterate `self.output or []` and skip content items with `text is None`.

Adds four regression tests covering all three cases.

Closes openai#3325, openai#3063
@adhavan18
adhavan18 requested a review from a team as a code owner June 15, 2026 07:42

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 49ab38b624

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7928863ad7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread tests/lib/responses/test_null_output.py Outdated
"""Regression tests for null-output edge cases in the Responses API."""
from __future__ import annotations

import pytest

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 Remove unused pytest import to keep lint green

With the repository lint target running ruff check . and selecting F401, this new test file fails before the regression tests can run because pytest is imported but never used; I confirmed ruff check tests/lib/responses/test_null_output.py reports F401 here. Removing the unused import and formatting the import blocks keeps ./scripts/lint green.

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.

Fixed in de0ac7b. You were right, and it was worse than just the unused import: ruff check also flagged three I001 import-order errors in the same file.

tests/lib/responses/test_null_output.py:
   2:1  I001  Import block is un-sorted or un-formatted
   4:8  F401  `pytest` imported but unused
  52:5  I001  Import block is un-sorted or un-formatted
  83:5  I001  Import block is un-sorted or un-formatted
Found 4 errors.

ruff check . across the repo is clean now.

"""
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.

Comment thread src/openai/types/responses/response.py Outdated
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:

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 Allow null text before strict validation

For clients using _strict_response_validation=True, a response containing an output_text block with text: null is still rejected while validating ResponseOutputText.text: str, so execution never reaches this new content.text is not None guard. Since the change explicitly tries to tolerate null text in content blocks, strict clients still see the same crash unless the payload is normalized before validation or the generated text field accepts None.

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.

Same root cause as the output comment above: ResponseOutputText.text is typed str in a Stainless-generated file, so a strict client rejects text: null in validate_type() before reaching this guard. Changing that field needs an OpenAPI spec change rather than an edit here.

The guard in this PR still covers the default non-strict path, where text: null currently concatenates None into the aggregated string.

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]},

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 Patch completed streams from final done events

When response.completed has output: null, copying snapshot.output here can return stale items because the accumulator above only records output_item.added, content_part.added, and deltas, while the stream also carries final response.output_item.done / response.content_part.done payloads. In that null-completed streaming case, get_final_response() can expose in_progress item statuses or miss final part data such as annotations/logprobs even though the done events already provided them; update the snapshot from the done events before using it as the completed response output.

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.

Correct, and worth separating from this change: the accumulator has never consumed response.output_item.done or response.content_part.done. That is true on main today, independently of this PR:

$ git show main:src/openai/lib/streaming/responses/_responses.py | grep -c 'output_item\.done\|content_part\.done'
0

accumulate_event handles output_item.added, content_part.added, the two delta events, and completed. So a snapshot built from those can carry in_progress statuses and miss annotations or logprobs regardless of what this PR does.

What changes here is only what happens when completed arrives with output: null. Today that yields output=[] and every streamed item is lost. With the fallback it yields the accumulated items, which may have stale status. Partial data with a stale status seems clearly better than no data, so I would rather land this and treat "consume the done events in the accumulator" as its own change, since it affects every stream and not just the null-completed case.

Happy to open a separate issue for it, or to fold it in here if you would prefer one PR.

ruff was reporting F401 for the unused pytest import plus three I001
import-order errors in this file, which fails scripts/lint before the
regression tests get a chance to run. ruff check . is clean now.
@adhavan18

Copy link
Copy Markdown
Author

Status summary, since this has picked up a few review rounds.

Everything raised is now answered or fixed.

Finding State
P1 streamed output discarded on null completed payload Fixed in bbd1830, the day it was raised
P2 unused pytest import breaks lint Fixed in de0ac7b
P2 strict validation rejects output: null Answered: needs an OpenAPI spec change
P2 strict validation rejects text: null Answered: same root cause
P2 snapshot can hold stale items Answered: pre-existing, proposed as a separate change

The lint break was real and I had missed it. ruff check was reporting F401 plus three I001 errors in the new test file, which fails scripts/lint before the tests run. That is fixed and ruff check . is clean across the repo. pytest tests/lib/responses/test_null_output.py is 4 passed, and the branch is up to date with main.

One decision I would like your call on before this goes further.

The output_text guard in this PR lives in src/openai/types/responses/response.py, which is generated:

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

If that file is regenerated and the change is not carried in the Stainless config, the guard is silently dropped and the bug returns with no failing test to catch it. Two options, and I am happy to do either:

  1. Leave it as is, and carry the guard in the Stainless config on your side.
  2. Move it out of the generated file, so nothing in this PR touches generated code. parse_response and the streaming accumulator changes already live in non-generated modules, so this would only affect the output_text property.

I lean towards option 2 because it survives regeneration without anyone needing to remember, but it is your codebase and your generation setup, so I would rather ask than guess.

Related note for the same reason: the strict-validation gap the reviewer raised cannot be closed from this PR at all, since Response.output and ResponseOutputText.text are both generated as non-optional. This PR fixes the default non-strict path; strict clients would still need a spec change. That is now written up in the PR description so it does not get lost.

@adhavan18

Copy link
Copy Markdown
Author

Correction to my previous comment: disregard the question about moving the output_text guard out of the generated file. I was wrong, and CONTRIBUTING says so plainly:

Most of the SDK is generated code. Modifications to code will be persisted between generations, but may result in merge conflicts between manual patches and changes from the generator. The generator will never modify the contents of the src/openai/lib/ and examples/ directories.

So the guard is not at risk of being silently dropped; manual patches persist. The worst case is a merge conflict when the generator touches the same lines, which is a normal maintenance cost rather than a correctness risk. No decision is needed from you on that point and no restructuring is proposed. I have corrected the PR description too.

The rest of the previous comment stands: the lint break is fixed, ruff check . is clean, the tests pass, and the strict-validation limitation still needs an OpenAPI spec change rather than anything in this PR.

The lint job was failing on pyright with 12 errors across the four files.

Three of them are the defensive guards themselves: output and text are
generated as non-optional, so pyright reads `is not None` as a comparison that
can never be false. That is exactly the backend behaviour this PR exists to
tolerate, so each guard now carries a targeted
`pyright: ignore[reportUnnecessaryComparison]` with a comment saying why,
rather than being removed.

build() returns an untyped value, which made completed_response Unknown and
leaked into the parse_response call. It is now annotated as Response and the
build() result cast back to it.

The rest were in the test: parse_response takes Omit rather than NotGiven since
the merge with main, and the fixture helper had no parameter annotation.

pyright is clean on all four files, ruff check and ruff format pass, and the
null-output tests still pass.
The previous commit fixed pyright but broke mypy, which reported the branch as
unreachable for the same reason: output is generated as non-optional, so
`is None` looks impossible to both checkers.

Rather than silencing them separately, the value is now read through getattr,
which neither narrows. The `snapshot is not None` half of the condition was
genuinely redundant, since accumulate_event returns early when snapshot is
None, so it is gone.

to_dict() returns dict[str, object], so the ** spread could not be checked
field by field against Response; it is now built as an explicit dict[str, Any].
That also made build() resolve to Response, so the cast is no longer needed.

Test functions gained return annotations, which mypy requires.

mypy and pyright are both clean on the four changed files, ruff check and
ruff format pass, and the null-output tests pass.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

parse_response crashes with TypeError when response.output is null in response.completed event (chatgpt.com Codex backend)

1 participant