diff --git a/src/openai/lib/_parsing/_responses.py b/src/openai/lib/_parsing/_responses.py index 8853a0749f..36346177b8 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..446b56a16b 100644 --- a/src/openai/lib/streaming/responses/_responses.py +++ b/src/openai/lib/streaming/responses/_responses.py @@ -357,9 +357,22 @@ def accumulate_event(self, event: RawResponseStreamEvent) -> ParsedResponseSnaps if output.type == "function_call": output.arguments += event.delta elif event.type == "response.completed": + response = event.response + if not getattr(response, "output", None) and snapshot.output: + response = construct_type_unchecked( + type_=cast(Any, ParsedResponse[object]), + value={ + **response.to_dict(), + "output": [ + item.to_dict() if hasattr(item, "to_dict") else item + for item in 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/src/openai/types/responses/response.py b/src/openai/types/responses/response.py index dac3e09a89..4c90b029f9 100644 --- a/src/openai/types/responses/response.py +++ b/src/openai/types/responses/response.py @@ -313,7 +313,7 @@ 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": diff --git a/tests/lib/responses/test_responses.py b/tests/lib/responses/test_responses.py index 8e5f16df95..6ec3f43837 100644 --- a/tests/lib/responses/test_responses.py +++ b/tests/lib/responses/test_responses.py @@ -7,6 +7,11 @@ from inline_snapshot import snapshot from openai import OpenAI, AsyncOpenAI +from openai._types import NOT_GIVEN +from openai.types.responses.response import Response +from openai.types.responses.response_output_text import ResponseOutputText +from openai.types.responses.response_output_message import ResponseOutputMessage +from openai.lib.streaming.responses._responses import ResponseStreamState from openai._utils import assert_signatures_in_sync from ...conftest import base_url @@ -61,3 +66,115 @@ def test_parse_method_definition_in_sync(sync: bool, client: OpenAI, async_clien checking_client.responses.parse, exclude_params={"tools"}, ) + + +def test_output_text_tolerates_none_output() -> None: + response = Response.construct( + id="resp_test", + object="response", + created_at=0, + model="gpt-test", + output=None, + parallel_tool_calls=False, + tool_choice="auto", + tools=[], + ) + + assert response.output_text == "" + + +def test_parse_response_tolerates_none_output() -> None: + from openai.lib._parsing._responses import parse_response + response = Response.construct( + id="resp_test", + object="response", + created_at=0, + model="gpt-test", + output=None, + parallel_tool_calls=False, + tool_choice="auto", + tools=[], + ) + + parsed = parse_response(text_format=NOT_GIVEN, input_tools=NOT_GIVEN, response=response) + + assert parsed.output == [] + + +@pytest.mark.parametrize("terminal_output", [None, []]) +def test_response_stream_preserves_snapshot_when_terminal_output_is_missing(terminal_output: object) -> None: + state = ResponseStreamState(input_tools=[], text_format=NOT_GIVEN) + + state.handle_event( + _Event( + type="response.created", + response=Response.construct( + id="resp_test", + object="response", + created_at=0, + model="gpt-test", + output=[], + parallel_tool_calls=False, + tool_choice="auto", + tools=[], + ), + ) + ) + state.handle_event( + _Event( + type="response.output_item.added", + output_index=0, + item=ResponseOutputMessage.construct( + id="msg_test", + type="message", + role="assistant", + status="in_progress", + content=[], + ), + ) + ) + state.handle_event( + _Event( + type="response.content_part.added", + output_index=0, + content_index=0, + part=ResponseOutputText.construct(type="output_text", text="", annotations=[]), + ) + ) + state.handle_event( + _Event( + type="response.output_text.delta", + output_index=0, + content_index=0, + item_id="msg_test", + delta="streamed text", + sequence_number=1, + logprobs=[], + ) + ) + + events = state.handle_event( + _Event( + type="response.completed", + sequence_number=2, + response=Response.construct( + id="resp_test", + object="response", + created_at=0, + model="gpt-test", + output=terminal_output, + parallel_tool_calls=False, + tool_choice="auto", + tools=[], + ), + ) + ) + + completed = events[0].response + assert completed.output_text == "streamed text" + assert completed.output[0].content[0].text == "streamed text" + + +class _Event: + def __init__(self, **kwargs: object) -> None: + self.__dict__.update(kwargs)