diff --git a/src/openai/lib/_parsing/_responses.py b/src/openai/lib/_parsing/_responses.py index c607587ec1..81e6b2b983 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: diff --git a/src/openai/lib/streaming/responses/_responses.py b/src/openai/lib/streaming/responses/_responses.py index 6975a9260d..05f66888c4 100644 --- a/src/openai/lib/streaming/responses/_responses.py +++ b/src/openai/lib/streaming/responses/_responses.py @@ -17,7 +17,7 @@ from ...._utils import is_given, consume_sync_iterator, consume_async_iterator from ...._models import build, construct_type_unchecked from ...._streaming import Stream, AsyncStream -from ....types.responses import ParsedResponse, ResponseStreamEvent as RawResponseStreamEvent +from ....types.responses import Response, ParsedResponse, ResponseStreamEvent as RawResponseStreamEvent from ..._parsing._responses import TextFormatT, parse_text, parse_response from ....types.responses.tool_param import ToolParam from ....types.responses.parsed_response import ( @@ -357,9 +357,20 @@ def accumulate_event(self, event: RawResponseStreamEvent) -> ParsedResponseSnaps if output.type == "function_call": output.arguments += event.delta elif event.type == "response.completed": + response: Response = event.response + if response.output is None: + # Some backends omit `output` on the completion event even though the + # individual output items were streamed via `response.output_item.*` / + # `response.*.delta` events. Fall back to the accumulated snapshot so we + # don't silently drop the generated content. + response = construct_type_unchecked( + type_=cast(Any, Response), + value={**response.to_dict(), "output": snapshot.output}, + ) + self._completed_response = parse_response( text_format=self._text_format, - response=event.response, + response=response, input_tools=self._input_tools, ) diff --git a/tests/lib/responses/test_responses.py b/tests/lib/responses/test_responses.py index 4ed6dff47d..35213872f2 100644 --- a/tests/lib/responses/test_responses.py +++ b/tests/lib/responses/test_responses.py @@ -1,5 +1,6 @@ from __future__ import annotations +from typing import Any, cast from typing_extensions import TypeVar import pytest @@ -10,9 +11,14 @@ from openai._types import omit from openai._utils import assert_signatures_in_sync from openai._models import construct_type_unchecked -from openai.types.responses import Response +from openai.types.responses import ( + Response, + ResponseCreatedEvent, + ResponseCompletedEvent, + ResponseOutputItemAddedEvent, +) from openai.lib._parsing._responses import parse_response - +from openai.lib.streaming.responses import ResponseStreamState from ...conftest import base_url from ..snapshots import make_snapshot_request @@ -72,6 +78,74 @@ def test_parse_response_preserves_program_items(item: dict[str, object]) -> None assert parsed.output[0].to_dict() == item +def test_parse_response_handles_null_output() -> None: + response = construct_type_unchecked(type_=Response, value={"output": None}) + + parsed = parse_response(text_format=omit, input_tools=omit, response=response) + + assert parsed.output == [] + + +def test_stream_state_preserves_accumulated_output_on_null_completion() -> None: + """Regression test: some backends omit `output` on `response.completed` even though + the output items were streamed via `response.output_item.*` events beforehand. The + accumulated snapshot must be used instead of discarding the generated content. + """ + state: ResponseStreamState[None] = ResponseStreamState(input_tools=omit, text_format=omit) + + state.handle_event( + construct_type_unchecked( + type_=cast(Any, ResponseCreatedEvent), + value={ + "type": "response.created", + "sequence_number": 0, + "response": {"output": []}, + }, + ) + ) + + message_item = { + "id": "msg_123", + "type": "message", + "status": "completed", + "role": "assistant", + "content": [{"type": "output_text", "text": "hello world", "annotations": []}], + } + state.handle_event( + construct_type_unchecked( + type_=cast(Any, ResponseOutputItemAddedEvent), + value={ + "type": "response.output_item.added", + "sequence_number": 1, + "output_index": 0, + "item": message_item, + }, + ) + ) + + events = state.handle_event( + construct_type_unchecked( + type_=cast(Any, ResponseCompletedEvent), + value={ + "type": "response.completed", + "sequence_number": 2, + "response": {"status": "completed", "output": None}, + }, + ) + ) + + assert state._completed_response is not None + output = state._completed_response.output + assert len(output) == 1 + assert output[0].type == "message" + assert output[0].content[0].type == "output_text" + assert output[0].content[0].text == "hello world" + + completed_event = events[-1] + assert completed_event.type == "response.completed" + assert completed_event.response.output == output + + @pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) def test_stream_method_definition_in_sync(sync: bool, client: OpenAI, async_client: AsyncOpenAI) -> None: checking_client: OpenAI | AsyncOpenAI = client if sync else async_client