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.

P2 Badge Preserve accumulated stream output on null completion

When using responses.stream() against a backend that sends response.completed.response.output = null after prior response.output_item.added / text-delta events, this fallback makes parse_response() treat the completed response as having no output. ResponseStreamState has already accumulated those items in its snapshot, so get_final_response().output and output_text become empty even though the stream delivered content; the null-completion path should parse the accumulated snapshot rather than unconditionally replacing it with [].

Useful? React with 👍 / 👎.

if output.type == "message":
content_list: List[ParsedContent[TextFormatT]] = []
for item in output.content:
Expand Down
8 changes: 7 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,15 @@ 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 response.output and snapshot.output:
# Some backends send `response.completed` with a null/empty `output`
# even though prior `response.output_item.added` / delta events already
# populated it on the snapshot; prefer the accumulated snapshot in that case.
response = response.model_copy(update={"output": snapshot.output})

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 Avoid the Pydantic-v2-only copy method

When this null-output path is reached under supported Pydantic 1.x (pyproject.toml allows pydantic>=1.9.0, <3), response models provide .copy() but not Pydantic 2's .model_copy(). A stream with accumulated items therefore raises AttributeError here, so the original failure still prevents both the completed event and get_final_response() from succeeding; use a compatibility copy path that supports the update argument on both major versions.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Apply done-event payloads before reusing the snapshot

When the stream includes the normal response.output_item.done event before a null completion, accumulate_event() never applies that event's final item to the snapshot; it likewise ignores final content-part and annotation payloads. Copying this snapshot therefore returns items that can still have status="in_progress" and can omit final annotations or logprobs even though the corresponding done events supplied them. Update the accumulated entries from the done-event payloads before using them as the completed response output.

Useful? React with 👍 / 👎.

self._completed_response = parse_response(
text_format=self._text_format,
response=event.response,
response=response,
input_tools=self._input_tools,
)

Expand Down
54 changes: 54 additions & 0 deletions tests/lib/responses/test_responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,60 @@ def test_parse_response_preserves_program_items(item: dict[str, object]) -> None
assert parsed.output[0].to_dict() == item


def test_parse_response_with_null_output() -> None:
# Regression test for https://github.com/openai/openai-python/issues/3325
# Some backends (e.g. the chatgpt.com Codex backend) can send `output: null`
# in the `response.completed` event, even though the schema declares `output`
# as a non-nullable list. `parse_response` should not crash in this case.
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_response_stream_state_preserves_accumulated_output_on_null_completion() -> None:
# Regression test for https://github.com/openai/openai-python/issues/3325
# A `response.completed` event with a null/empty `output` should not discard
# output already accumulated from prior `response.output_item.added` events.
from openai.types.responses import ResponseStreamEvent as RawResponseStreamEvent
from openai.lib.streaming.responses._responses import ResponseStreamState

state: ResponseStreamState[object] = ResponseStreamState(input_tools=omit, text_format=omit)

def make_event(value: dict[str, object]) -> RawResponseStreamEvent:
return construct_type_unchecked(type_=RawResponseStreamEvent, value=value)

created_response = {"id": "resp_123", "output": [], "status": "in_progress"}
state.handle_event(make_event({"type": "response.created", "response": created_response, "sequence_number": 0}))

message_item = {
"id": "msg_123",
"type": "message",
"role": "assistant",
"status": "in_progress",
"content": [],
}
state.handle_event(
make_event(
{
"type": "response.output_item.added",
"output_index": 0,
"item": message_item,
"sequence_number": 1,
}
)
)

completed_response = {"id": "resp_123", "output": None, "status": "completed"}
state.handle_event(make_event({"type": "response.completed", "response": completed_response, "sequence_number": 2}))

final = state._completed_response
assert final is not None
assert len(final.output) == 1
assert final.output[0].id == "msg_123"


@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