diff --git a/src/openai/lib/_parsing/_responses.py b/src/openai/lib/_parsing/_responses.py index c607587ec1..4f0b539d2f 100644 --- a/src/openai/lib/_parsing/_responses.py +++ b/src/openai/lib/_parsing/_responses.py @@ -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 []: if output.type == "message": content_list: List[ParsedContent[TextFormatT]] = [] for item in output.content: @@ -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 + ), }, ) ) diff --git a/src/openai/lib/streaming/responses/_responses.py b/src/openai/lib/streaming/responses/_responses.py index 6975a9260d..6f5181cf6f 100644 --- a/src/openai/lib/streaming/responses/_responses.py +++ b/src/openai/lib/streaming/responses/_responses.py @@ -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, @@ -352,14 +353,59 @@ def accumulate_event(self, event: RawResponseStreamEvent) -> ParsedResponseSnaps content = output.content[event.content_index] assert content.type == "output_text" content.text += event.delta + elif event.type == "response.content_part.done": + # the done event carries the authoritative part payload, including + # anything the deltas do not convey such as annotations or logprobs + output = snapshot.output[event.output_index] + if output.type == "message": + output.content[event.content_index] = construct_type_unchecked( + type_=cast(Any, ParsedContent), value=event.part.to_dict() + ) + elif event.type == "response.output_item.done": + # likewise for the item itself: `added` delivers it as in_progress, + # and only this event carries the final status and content + if event.item.type == "function_call": + snapshot.output[event.output_index] = construct_type_unchecked( + type_=cast(Any, ParsedResponseFunctionToolCall), value=event.item.to_dict() + ) + elif event.item.type == "message": + snapshot.output[event.output_index] = construct_type_unchecked( + type_=cast(Any, ParsedResponseOutputMessage), value=event.item.to_dict() + ) + else: + snapshot.output[event.output_index] = event.item elif event.type == "response.function_call_arguments.delta": output = snapshot.output[event.output_index] 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 by earlier 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. The done events handled + # above mean that snapshot holds the authoritative payloads rather + # than the in-progress ones. + 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, ) diff --git a/src/openai/types/responses/response.py b/src/openai/types/responses/response.py index 6503d679b0..f5550db378 100644 --- a/src/openai/types/responses/response.py +++ b/src/openai/types/responses/response.py @@ -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 []: 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) diff --git a/tests/lib/responses/test_null_output.py b/tests/lib/responses/test_null_output.py new file mode 100644 index 0000000000..0be0ffb8bf --- /dev/null +++ b/tests/lib/responses/test_null_output.py @@ -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 diff --git a/tests/lib/responses/test_null_output_streaming.py b/tests/lib/responses/test_null_output_streaming.py new file mode 100644 index 0000000000..9ae033e116 --- /dev/null +++ b/tests/lib/responses/test_null_output_streaming.py @@ -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"