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 stream output on null completion

When this parser is used from the streaming path, response.completed is parsed from event.response while the actual output items have already been accumulated on the stream snapshot. If the backend sends output: null on the completion event after normal response.output_item.*/delta events, this fallback now returns a successful parsed response with output == []; get_final_response() and the wrapped completed event then lose all generated content instead of returning the accumulated response. Please have the stream completion path reuse the snapshot output when the completed response output is None.

Useful? React with 👍 / 👎.

if output.type == "message":
content_list: List[ParsedContent[TextFormatT]] = []
for item in output.content:
Expand Down
15 changes: 13 additions & 2 deletions src/openai/lib/streaming/responses/_responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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.
Comment thread
thiagobarbosa marked this conversation as resolved.
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,
)

Expand Down
78 changes: 76 additions & 2 deletions tests/lib/responses/test_responses.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

from typing import Any, cast
from typing_extensions import TypeVar

import pytest
Expand All @@ -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

Expand Down Expand Up @@ -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.
"""
Comment thread
thiagobarbosa marked this conversation as resolved.
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
Expand Down