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..d780c18967 100644 --- a/src/openai/lib/streaming/responses/_responses.py +++ b/src/openai/lib/streaming/responses/_responses.py @@ -356,10 +356,50 @@ def accumulate_event(self, event: RawResponseStreamEvent) -> ParsedResponseSnaps output = snapshot.output[event.output_index] if output.type == "function_call": output.arguments += event.delta + elif event.type == "response.refusal.delta": + output = snapshot.output[event.output_index] + if output.type == "message": + content = output.content[event.content_index] + assert content.type == "refusal" + content.refusal += event.delta + elif event.type == "response.refusal.done": + output = snapshot.output[event.output_index] + if output.type == "message": + content = output.content[event.content_index] + assert content.type == "refusal" + content.refusal = event.refusal + elif event.type == "response.content_part.done": + 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": + 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.completed": + response = event.response + response_dict: dict[str, object] = response.to_dict() + if response_dict.get("output") is None: + response_dict["output"] = [output.to_dict() for output in snapshot.output] + response = construct_type_unchecked( + type_=ParsedResponseSnapshot, + value=response_dict, + ) 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 43879942b8..612278f8af 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 from typing_extensions import TypeVar import pytest @@ -12,6 +13,16 @@ from openai._models import construct_type_unchecked from openai.types.responses import Response from openai.lib._parsing._responses import parse_response +from openai.lib.streaming.responses._responses import ResponseStreamState +from openai.types.responses.response_created_event import ResponseCreatedEvent +from openai.types.responses.response_completed_event import ResponseCompletedEvent +from openai.types.responses.response_text_delta_event import ResponseTextDeltaEvent +from openai.types.responses.response_refusal_done_event import ResponseRefusalDoneEvent +from openai.types.responses.response_refusal_delta_event import ResponseRefusalDeltaEvent +from openai.types.responses.response_output_item_done_event import ResponseOutputItemDoneEvent +from openai.types.responses.response_content_part_done_event import ResponseContentPartDoneEvent +from openai.types.responses.response_output_item_added_event import ResponseOutputItemAddedEvent +from openai.types.responses.response_content_part_added_event import ResponseContentPartAddedEvent from ...conftest import base_url from ..snapshots import make_snapshot_request @@ -25,6 +36,79 @@ # `OPENAI_LIVE=1 pytest --inline-snapshot=fix -p no:xdist -o addopts=""` +def _response_payload(*, output: object) -> dict[str, object]: + return { + "id": "resp_null_output", + "object": "response", + "created_at": 0, + "status": "completed", + "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": {"effort": None, "summary": None}, + "safety_identifier": None, + "service_tier": "default", + "store": False, + "temperature": 1.0, + "text": {"format": {"type": "text"}, "verbosity": "medium"}, + "tool_choice": "auto", + "tools": [], + "top_logprobs": 0, + "top_p": 1.0, + "truncation": "disabled", + "usage": None, + "user": None, + "metadata": {}, + } + + +def _handle_stream_event(state: ResponseStreamState[Any], event_type: Any, **value: object) -> None: + state.handle_event(construct_type_unchecked(type_=event_type, value=value)) + + +def _message_stream_state(content_part: dict[str, object]) -> ResponseStreamState[Any]: + state = ResponseStreamState(text_format=omit, input_tools=omit) + _handle_stream_event( + state, + ResponseCreatedEvent, + type="response.created", + sequence_number=0, + response=_response_payload(output=[]), + ) + _handle_stream_event( + state, + ResponseOutputItemAddedEvent, + type="response.output_item.added", + sequence_number=1, + output_index=0, + item={ + "id": "msg_1", + "type": "message", + "status": "in_progress", + "role": "assistant", + "content": [], + }, + ) + _handle_stream_event( + state, + ResponseContentPartAddedEvent, + type="response.content_part.added", + sequence_number=2, + item_id="msg_1", + output_index=0, + content_index=0, + part=content_part, + ) + return state + + @pytest.mark.respx2(base_url=base_url) def test_output_text(client: OpenAI, respx2_mock: MockRouter) -> None: response = make_snapshot_request( @@ -72,6 +156,168 @@ 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=_response_payload(output=None), + ) + + parsed = parse_response(text_format=omit, input_tools=omit, response=response) + + assert parsed.output == [] + + +def test_streaming_completed_null_output_preserves_accumulated_snapshot() -> None: + state = _message_stream_state({"type": "output_text", "text": "", "annotations": []}) + _handle_stream_event( + state, + ResponseTextDeltaEvent, + type="response.output_text.delta", + sequence_number=3, + item_id="msg_1", + output_index=0, + content_index=0, + delta="hello", + logprobs=[], + ) + + events = state.handle_event( + construct_type_unchecked( + type_=ResponseCompletedEvent, + value={ + "type": "response.completed", + "sequence_number": 4, + "response": _response_payload(output=None), + }, + ) + ) + + completed = events[0] + assert completed.type == "response.completed" + assert completed.response.output_text == "hello" + assert state._completed_response is not None + assert state._completed_response.output_text == "hello" + + +def test_streaming_completed_null_output_applies_finalization_events() -> None: + state = _message_stream_state({"type": "output_text", "text": "", "annotations": []}) + _handle_stream_event( + state, + ResponseTextDeltaEvent, + type="response.output_text.delta", + sequence_number=3, + item_id="msg_1", + output_index=0, + content_index=0, + delta="draft", + logprobs=[], + ) + final_part = { + "type": "output_text", + "text": "final", + "annotations": [ + { + "type": "url_citation", + "url": "https://example.com", + "title": "Example", + "start_index": 0, + "end_index": 5, + } + ], + "logprobs": [], + } + _handle_stream_event( + state, + ResponseContentPartDoneEvent, + type="response.content_part.done", + sequence_number=4, + item_id="msg_1", + output_index=0, + content_index=0, + part=final_part, + ) + _handle_stream_event( + state, + ResponseOutputItemDoneEvent, + type="response.output_item.done", + sequence_number=5, + output_index=0, + item={ + "id": "msg_1", + "type": "message", + "status": "completed", + "role": "assistant", + "content": [final_part], + }, + ) + + events = state.handle_event( + construct_type_unchecked( + type_=ResponseCompletedEvent, + value={ + "type": "response.completed", + "sequence_number": 6, + "response": _response_payload(output=None), + }, + ) + ) + + completed = events[0] + assert completed.type == "response.completed" + assert completed.response.output[0].to_dict() == { + "id": "msg_1", + "type": "message", + "status": "completed", + "role": "assistant", + "content": [{**final_part, "parsed": None}], + } + + +def test_streaming_completed_null_output_preserves_refusal_done() -> None: + state = _message_stream_state({"type": "refusal", "refusal": ""}) + _handle_stream_event( + state, + ResponseRefusalDeltaEvent, + type="response.refusal.delta", + sequence_number=3, + item_id="msg_1", + output_index=0, + content_index=0, + delta="draft", + ) + _handle_stream_event( + state, + ResponseRefusalDoneEvent, + type="response.refusal.done", + sequence_number=4, + item_id="msg_1", + output_index=0, + content_index=0, + refusal="final refusal", + ) + + events = state.handle_event( + construct_type_unchecked( + type_=ResponseCompletedEvent, + value={ + "type": "response.completed", + "sequence_number": 5, + "response": _response_payload(output=None), + }, + ) + ) + + completed = events[0] + assert completed.type == "response.completed" + assert completed.response.output[0].to_dict() == { + "id": "msg_1", + "type": "message", + "status": "in_progress", + "role": "assistant", + "content": [{"type": "refusal", "refusal": "final refusal"}], + } + + @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