Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/openai/lib/_parsing/_responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 []:
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve accumulated output when completed payload is null

When a streaming backend sends response.completed with output: null after earlier response.output_item.* events, this fallback parses the final payload as output=[]. The streaming accumulator sets _completed_response by calling parse_response(event.response, ...) on completion (src/openai/lib/streaming/responses/_responses.py:359-364), so ResponseCompletedEvent.response and get_final_response() lose all message/tool output that was already accumulated in the snapshot instead of letting consumers inspect it. In this null-output case, the parser/stream state should preserve the accumulated snapshot output rather than replacing it with an empty list.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d9b78c4d: ResponseStreamState now preserves the accumulated snapshot output when the final response.completed payload has output: null, and the new streaming regression test covers the created/output_item/content_part/text_delta/completed sequence.

if output.type == "message":
content_list: List[ParsedContent[TextFormatT]] = []
for item in output.content:
Expand Down
10 changes: 9 additions & 1 deletion src/openai/lib/streaming/responses/_responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -357,9 +357,17 @@ def accumulate_event(self, event: RawResponseStreamEvent) -> ParsedResponseSnaps
if output.type == "function_call":
output.arguments += event.delta
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,
)

Expand Down
163 changes: 163 additions & 0 deletions tests/lib/responses/test_responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,17 @@
from inline_snapshot import snapshot

from openai import OpenAI, AsyncOpenAI
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.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_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
Expand All @@ -21,6 +31,39 @@
# `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": {},
}


@pytest.mark.respx(base_url=base_url)
def test_output_text(client: OpenAI, respx_mock: MockRouter) -> None:
response = make_snapshot_request(
Expand All @@ -41,6 +84,126 @@ def test_output_text(client: OpenAI, respx_mock: MockRouter) -> None:
)


def test_parse_response_handles_null_output() -> None:
response = construct_type_unchecked(
type_=Response,
value={
"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": None,
"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": {},
},
)

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 = ResponseStreamState(text_format=omit, input_tools=omit)
state.handle_event(
construct_type_unchecked(
type_=ResponseCreatedEvent,
value={
"type": "response.created",
"sequence_number": 0,
"response": _response_payload(output=[]),
},
)
)
state.handle_event(
construct_type_unchecked(
type_=ResponseOutputItemAddedEvent,
value={
"type": "response.output_item.added",
"sequence_number": 1,
"output_index": 0,
"item": {
"id": "msg_1",
"type": "message",
"status": "in_progress",
"role": "assistant",
"content": [],
},
},
)
)
state.handle_event(
construct_type_unchecked(
type_=ResponseContentPartAddedEvent,
value={
"type": "response.content_part.added",
"sequence_number": 2,
"item_id": "msg_1",
"output_index": 0,
"content_index": 0,
"part": {
"id": "text_1",
"type": "output_text",
"text": "",
"annotations": [],
},
},
)
)
state.handle_event(
construct_type_unchecked(
type_=ResponseTextDeltaEvent,
value={
"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"


@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
Expand Down