Skip to content
Closed
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 stream output when final output is null

When streaming, the API can send response.completed with event.response.output as null after earlier response.output_item.* and delta events have populated the snapshot. ResponseStreamState.accumulate_event() stores _completed_response from parse_response(event.response), and get_final_response() returns that object, so this fallback turns those completed streams into a parsed response with empty output/output_text, discarding the already accumulated content instead of parsing the snapshot.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks for catching this. I updated the stream accumulator so that when the terminal response.completed payload has output: null, it preserves the accumulated snapshot output while keeping the final response metadata. I also added a regression test covering streamed output deltas followed by a completed event with null output.

Tests run:

  • uv run pytest tests/lib/responses/test_responses.py -q
  • uv run ruff check src/openai/lib/streaming/responses/_responses.py tests/lib/responses/test_responses.py
  • uv run ruff format --check src/openai/lib/streaming/responses/_responses.py tests/lib/responses/test_responses.py

if output.type == "message":
content_list: List[ParsedContent[TextFormatT]] = []
for item in output.content:
Expand Down
12 changes: 11 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,19 @@ 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 response.output is None:
response = construct_type_unchecked(
type_=ParsedResponseSnapshot,
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,
)

Expand Down
128 changes: 128 additions & 0 deletions tests/lib/responses/test_responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,131 @@ def test_parse_method_definition_in_sync(sync: bool, client: OpenAI, async_clien
checking_client.responses.parse,
exclude_params={"tools"},
)


def test_parse_response_handles_null_output() -> None:
from openai._types import omit
from openai._models import construct_type_unchecked
from openai.types.responses import Response
from openai.lib._parsing._responses import parse_response

response = construct_type_unchecked(
type_=Response,
value={
"id": "resp_test",
"object": "response",
"created_at": 0,
"model": "gpt-4o-mini",
"output": None,
"parallel_tool_calls": True,
"temperature": 1,
"tool_choice": "auto",
"tools": [],
"top_p": 1,
"metadata": {},
"reasoning": {},
"status": "completed",
"text": {"format": {"type": "text"}},
},
)

parsed = parse_response(text_format=omit, input_tools=None, response=response)

assert parsed.output == []


def test_stream_completed_event_with_null_output_preserves_accumulated_output() -> None:
from openai._types import omit
from openai._models import construct_type_unchecked
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

def event(type_: object, value: object):
return construct_type_unchecked(type_=type_, value=value)

response = {
"id": "resp_test",
"object": "response",
"created_at": 0,
"model": "gpt-4o-mini",
"output": [],
"parallel_tool_calls": True,
"temperature": 1,
"tool_choice": "auto",
"tools": [],
"top_p": 1,
"metadata": {},
"reasoning": {},
"status": "completed",
"text": {"format": {"type": "text"}},
}
state = ResponseStreamState(text_format=omit, input_tools=omit)

for type_, value in [
(ResponseCreatedEvent, {"type": "response.created", "sequence_number": 0, "response": response}),
(
ResponseOutputItemAddedEvent,
{
"type": "response.output_item.added",
"sequence_number": 1,
"output_index": 0,
"item": {
"id": "msg_test",
"type": "message",
"status": "in_progress",
"role": "assistant",
"content": [],
},
},
),
(
ResponseContentPartAddedEvent,
{
"type": "response.content_part.added",
"sequence_number": 2,
"item_id": "msg_test",
"output_index": 0,
"content_index": 0,
"part": {"type": "output_text", "text": "", "annotations": []},
},
),
(
ResponseTextDeltaEvent,
{
"type": "response.output_text.delta",
"sequence_number": 3,
"item_id": "msg_test",
"output_index": 0,
"content_index": 0,
"delta": "Hello",
"logprobs": [],
},
),
]:
state.handle_event(event(type_, value))

events = state.handle_event(
event(
ResponseCompletedEvent,
{
"type": "response.completed",
"sequence_number": 4,
"response": {
**response,
"output": None,
"usage": {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2},
},
},
)
)

assert state._completed_response is not None
assert state._completed_response.output_text == "Hello"
assert state._completed_response.usage is not None
assert state._completed_response.usage.total_tokens == 2
assert events[0].type == "response.completed"
assert events[0].response.output_text == "Hello"