-
Notifications
You must be signed in to change notification settings - Fork 5.1k
fix: guard against null output in parse_response and output_text property #3404
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
49ab38b
bbd1830
7928863
de0ac7b
1e7d216
0794f57
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 []: | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a client opts into Useful? React with 👍 / 👎.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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: List[ResponseOutputItem]and this file opens with: So making the field accept That leaves this PR fixing the non-strict path, which is the default, while Worth flagging to a maintainer: this PR does edit the generated
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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: |
||
| 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) | ||
| 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 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In the streamed Responses path,
ResponseStreamState.accumulate_eventparsesevent.responseonresponse.completed(src/openai/lib/streaming/responses/_responses.py:359-364) rather than the accumulatedsnapshot, so when that final completed event hasoutput=Noneafter earlier output item/text delta events, this new fallback turns the final parsed response intooutput=[]. In that backend case,stream.get_final_response().output_textand 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 👍 / 👎.
There was a problem hiding this comment.
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_eventnow patches the completed event with the accumulated snapshot before parsing, rather than lettingparse_responseiterate a null output:So on a null completed payload the final
ParsedResponsecarries the streamed items rather than[], which is what you asked for.buildis imported at the top of that module, andtests/lib/responses/test_null_output.pycovers 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.