diff --git a/src/openai/lib/_parsing/_responses.py b/src/openai/lib/_parsing/_responses.py index c607587ec1..abf3eb2217 100644 --- a/src/openai/lib/_parsing/_responses.py +++ b/src/openai/lib/_parsing/_responses.py @@ -58,7 +58,13 @@ def parse_response( ) -> ParsedResponse[TextFormatT]: output_list: List[ParsedResponseOutputItem[TextFormatT]] = [] - for output in response.output: + # Guard against `response.output` being `None` (observed in the chatgpt.com + # Codex backend's consolidated `response.completed` event — see issue #3325). + # When the streaming accumulator has already collected output items, it + # injects them into the response before calling this function, so reaching + # here with `None` means the stream genuinely had no output items and an + # empty list is the correct result. + 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..764d54e6a9 100644 --- a/src/openai/lib/streaming/responses/_responses.py +++ b/src/openai/lib/streaming/responses/_responses.py @@ -356,12 +356,76 @@ 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.output_text.done": + output = snapshot.output[event.output_index] + if output.type == "message": + content = output.content[event.content_index] + assert content.type == "output_text" + content.text = event.text + elif event.type == "response.output_item.done": + # Replace the item in the snapshot with the finalized item from the + # done event. The server sends the authoritative item payload here, + # which may include final fields like `results`/`outputs` on tool + # items, or a final `status` of `failed`/`incomplete`. Simply + # setting `status = "completed"` would discard those fields and + # produce a stale final response in the null-output fallback path. + if event.output_index < len(snapshot.output): + snapshot.output[event.output_index] = construct_type_unchecked( + type_=type(snapshot.output[event.output_index]), + value=event.item.to_dict(), + ) + elif event.type == "response.content_part.done": + # Replace the content part in the snapshot with the finalized part + # from the done event. The server sends the authoritative part + # payload here, which may include metadata like annotations, + # logprobs, or finalized text/refusal content that the delta + # accumulation may not fully capture. + output = snapshot.output[event.output_index] + if output.type == "message" and event.content_index < len(output.content): + output.content[event.content_index] = construct_type_unchecked( + type_=type(output.content[event.content_index]), + value=event.part.to_dict(), + ) + elif event.type == "response.function_call_arguments.done": + # Apply the finalized arguments string from the done event. + # The server sends the authoritative arguments payload here, which + # may differ from the accumulated deltas. Using the finalized + # arguments ensures `parse_response()` can correctly parse + # `parsed_arguments` in the null-output fallback path. + output = snapshot.output[event.output_index] + if output.type == "function_call": + output.arguments = event.arguments + if hasattr(output, "status"): + output.status = "completed" elif event.type == "response.completed": - self._completed_response = parse_response( - text_format=self._text_format, - response=event.response, - input_tools=self._input_tools, - ) + # The chatgpt.com Codex backend sometimes sends `response.output: null` + # in the consolidated `response.completed` event even when valid + # `output_item.done` events were streamed earlier (see issue #3325). + # `parse_response()` guards against `None` with `response.output or []`, + # but that would discard the already-accumulated `snapshot.output` and + # emit an empty final response. When the completed event has no + # output but the snapshot has accumulated items, inject the streamed + # items into a shallow copy of the response so `parse_response()` can + # still run its text_format / parsed_arguments logic on them. + if event.response.output is None and snapshot.output: + response_with_output = construct_type_unchecked( + type_=type(event.response), + value={ + **event.response.to_dict(), + "output": [item.to_dict() for item in snapshot.output], + }, + ) + self._completed_response = parse_response( + text_format=self._text_format, + response=response_with_output, + input_tools=self._input_tools, + ) + else: + self._completed_response = parse_response( + text_format=self._text_format, + response=event.response, + input_tools=self._input_tools, + ) return snapshot