-
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
dff8a92
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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,192 @@ | ||
| """Streaming regression tests for a `response.completed` event with `output: null`. | ||
|
|
||
| Some backends deliver the output items through `output_item.added` / | ||
| `output_item.done` and then send `output: null` on the final `response.completed` | ||
| event. The stream state falls back to the accumulated snapshot in that case, so | ||
| the snapshot has to hold the authoritative done-event payloads rather than the | ||
| earlier in-progress ones. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from typing import Any, cast | ||
|
|
||
| from openai import omit | ||
| from openai._models import construct_type_unchecked | ||
| from openai.types.responses import ResponseStreamEvent | ||
| from openai.lib.streaming.responses._responses import ResponseStreamState | ||
|
|
||
|
|
||
| def _response(output: Any, status: str = "completed") -> dict[str, Any]: | ||
| return { | ||
| "id": "resp_1", | ||
| "object": "response", | ||
| "created_at": 0, | ||
| "status": status, | ||
| "error": None, | ||
| "incomplete_details": None, | ||
| "instructions": None, | ||
| "max_output_tokens": None, | ||
| "model": "gpt-4o-mini", | ||
| "output": output, | ||
| "parallel_tool_calls": True, | ||
| "previous_response_id": None, | ||
| "temperature": 1.0, | ||
| "tool_choice": "auto", | ||
| "tools": [], | ||
| "top_p": 1.0, | ||
| "usage": None, | ||
| "user": None, | ||
| "metadata": {}, | ||
| } | ||
|
|
||
|
|
||
| def _message(status: str, text: str) -> dict[str, Any]: | ||
| return { | ||
| "id": "msg_1", | ||
| "type": "message", | ||
| "role": "assistant", | ||
| "status": status, | ||
| "content": [{"type": "output_text", "text": text, "annotations": []}], | ||
| } | ||
|
|
||
|
|
||
| def _event(value: dict[str, Any]) -> ResponseStreamEvent: | ||
| return cast( | ||
| ResponseStreamEvent, | ||
| construct_type_unchecked(type_=cast(Any, ResponseStreamEvent), value=value), | ||
| ) | ||
|
|
||
|
|
||
| def _drive(events: list[dict[str, Any]]) -> ResponseStreamState[Any]: | ||
| state: ResponseStreamState[Any] = ResponseStreamState(input_tools=omit, text_format=omit) | ||
| for value in events: | ||
| state.handle_event(_event(value)) | ||
| return state | ||
|
|
||
|
|
||
| def test_null_completed_uses_done_event_payload() -> None: | ||
| """The fallback must serialise the done payload, not the in_progress one.""" | ||
| state = _drive( | ||
| [ | ||
| {"type": "response.created", "response": _response([], status="in_progress"), "sequence_number": 0}, | ||
| { | ||
| "type": "response.output_item.added", | ||
| "output_index": 0, | ||
| "item": _message("in_progress", ""), | ||
| "sequence_number": 1, | ||
| }, | ||
| { | ||
| "type": "response.output_item.done", | ||
| "output_index": 0, | ||
| "item": _message("completed", "hello world"), | ||
| "sequence_number": 2, | ||
| }, | ||
| {"type": "response.completed", "response": _response(None), "sequence_number": 3}, | ||
| ] | ||
| ) | ||
|
|
||
| final = state._completed_response | ||
| assert final is not None | ||
| assert len(final.output) == 1 | ||
|
|
||
| item = final.output[0] | ||
| assert item.type == "message" | ||
| # the whole point: `added` said in_progress, `done` said completed | ||
| assert item.status == "completed" | ||
| assert item.content[0].type == "output_text" | ||
| assert item.content[0].text == "hello world" | ||
| assert final.output_text == "hello world" | ||
|
|
||
|
|
||
| def test_null_completed_uses_content_part_done_payload() -> None: | ||
| """content_part.done carries annotations that the deltas never send.""" | ||
| annotation = { | ||
| "type": "url_citation", | ||
| "url": "https://example.com", | ||
| "title": "Example", | ||
| "start_index": 0, | ||
| "end_index": 5, | ||
| } | ||
| state = _drive( | ||
| [ | ||
| {"type": "response.created", "response": _response([], status="in_progress"), "sequence_number": 0}, | ||
| { | ||
| "type": "response.output_item.added", | ||
| "output_index": 0, | ||
| "item": { | ||
| "id": "msg_1", | ||
| "type": "message", | ||
| "role": "assistant", | ||
| "status": "in_progress", | ||
| "content": [], | ||
| }, | ||
| "sequence_number": 1, | ||
| }, | ||
| { | ||
| "type": "response.content_part.added", | ||
| "output_index": 0, | ||
| "content_index": 0, | ||
| "item_id": "msg_1", | ||
| "part": {"type": "output_text", "text": "", "annotations": []}, | ||
| "sequence_number": 2, | ||
| }, | ||
| { | ||
| "type": "response.output_text.delta", | ||
| "output_index": 0, | ||
| "content_index": 0, | ||
| "item_id": "msg_1", | ||
| "delta": "hello", | ||
| "sequence_number": 3, | ||
| }, | ||
| { | ||
| "type": "response.content_part.done", | ||
| "output_index": 0, | ||
| "content_index": 0, | ||
| "item_id": "msg_1", | ||
| "part": {"type": "output_text", "text": "hello", "annotations": [annotation]}, | ||
| "sequence_number": 4, | ||
| }, | ||
| {"type": "response.completed", "response": _response(None), "sequence_number": 5}, | ||
| ] | ||
| ) | ||
|
|
||
| final = state._completed_response | ||
| assert final is not None | ||
| item = final.output[0] | ||
| assert item.type == "message" | ||
| content = item.content[0] | ||
| assert content.type == "output_text" | ||
| assert content.text == "hello" | ||
| # the annotation only ever arrives on the done event | ||
| assert len(content.annotations) == 1 | ||
|
|
||
|
|
||
| def test_non_null_completed_is_unchanged() -> None: | ||
| """When the completed event carries output, it is used as-is.""" | ||
| state = _drive( | ||
| [ | ||
| {"type": "response.created", "response": _response([], status="in_progress"), "sequence_number": 0}, | ||
| { | ||
| "type": "response.output_item.added", | ||
| "output_index": 0, | ||
| "item": _message("in_progress", ""), | ||
| "sequence_number": 1, | ||
| }, | ||
| { | ||
| "type": "response.output_item.done", | ||
| "output_index": 0, | ||
| "item": _message("completed", "from done"), | ||
| "sequence_number": 2, | ||
| }, | ||
| { | ||
| "type": "response.completed", | ||
| "response": _response([_message("completed", "from completed")]), | ||
| "sequence_number": 3, | ||
| }, | ||
| ] | ||
| ) | ||
|
|
||
| final = state._completed_response | ||
| assert final is not None | ||
| assert final.output_text == "from completed" |
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.