diff --git a/src/openai/lib/_parsing/_responses.py b/src/openai/lib/_parsing/_responses.py index 232718cef6..bb60837ad5 100644 --- a/src/openai/lib/_parsing/_responses.py +++ b/src/openai/lib/_parsing/_responses.py @@ -71,7 +71,7 @@ def parse_response( type_=ParsedResponseOutputText[TextFormatT], value={ **item.to_dict(), - "parsed": parse_text(item.text, text_format=text_format), + "parsed": None if item.text is None else parse_text(item.text, text_format=text_format), }, ) ) diff --git a/src/openai/types/responses/response.py b/src/openai/types/responses/response.py index 67102d2628..d34bdbec3e 100644 --- a/src/openai/types/responses/response.py +++ b/src/openai/types/responses/response.py @@ -448,6 +448,7 @@ def output_text(self) -> str: if output.type == "message": for content in output.content: if content.type == "output_text": - texts.append(content.text) + if content.text is not None: + texts.append(content.text) return "".join(texts) diff --git a/src/openai/types/responses/response_output_text.py b/src/openai/types/responses/response_output_text.py index 2386fcb3c0..cc739e450c 100644 --- a/src/openai/types/responses/response_output_text.py +++ b/src/openai/types/responses/response_output_text.py @@ -122,7 +122,7 @@ class ResponseOutputText(BaseModel): annotations: List[Annotation] """The annotations of the text output.""" - text: str + text: Optional[str] """The text output from the model.""" type: Literal["output_text"] diff --git a/tests/lib/responses/test_responses.py b/tests/lib/responses/test_responses.py index 8e5f16df95..1c863f80ec 100644 --- a/tests/lib/responses/test_responses.py +++ b/tests/lib/responses/test_responses.py @@ -1,13 +1,17 @@ from __future__ import annotations +from typing import Any from typing_extensions import TypeVar import pytest from respx import MockRouter +from pydantic import BaseModel from inline_snapshot import snapshot from openai import OpenAI, AsyncOpenAI from openai._utils import assert_signatures_in_sync +from openai.lib._parsing._responses import parse_response +from openai.types.responses.response import Response from ...conftest import base_url from ..snapshots import make_snapshot_request @@ -21,6 +25,56 @@ # `OPENAI_LIVE=1 pytest --inline-snapshot=fix -p no:xdist -o addopts=""` +def _response_with_output_text_items(items: list[dict[str, Any]]) -> Response: + return Response.model_validate( + { + "id": "resp_null_output_text", + "object": "response", + "created_at": 0, + "status": "completed", + "background": False, + "error": None, + "incomplete_details": None, + "instructions": None, + "max_output_tokens": None, + "max_tool_calls": None, + "model": "gpt-4o-mini", + "output": [ + { + "id": "msg_null_output_text", + "type": "message", + "status": "completed", + "role": "assistant", + "content": items, + } + ], + "parallel_tool_calls": True, + "previous_response_id": None, + "prompt_cache_key": None, + "reasoning": {"effort": None, "summary": None}, + "safety_identifier": None, + "service_tier": "default", + "store": True, + "temperature": 1.0, + "text": {"format": {"type": "text"}, "verbosity": "medium"}, + "tool_choice": "auto", + "tools": [], + "top_logprobs": 0, + "top_p": 1.0, + "truncation": "disabled", + "usage": { + "input_tokens": 1, + "input_tokens_details": {"cached_tokens": 0}, + "output_tokens": 1, + "output_tokens_details": {"reasoning_tokens": 0}, + "total_tokens": 2, + }, + "user": None, + "metadata": {}, + } + ) + + @pytest.mark.respx(base_url=base_url) def test_output_text(client: OpenAI, respx_mock: MockRouter) -> None: response = make_snapshot_request( @@ -41,6 +95,39 @@ def test_output_text(client: OpenAI, respx_mock: MockRouter) -> None: ) +def test_output_text_skips_null_content_items() -> None: + response = _response_with_output_text_items( + [ + {"type": "output_text", "annotations": [], "logprobs": [], "text": None}, + {"type": "output_text", "annotations": [], "logprobs": [], "text": "hello"}, + ] + ) + + assert response.output_text == "hello" + + +def test_parse_response_skips_null_output_text_items() -> None: + class Message(BaseModel): + message: str + + response = _response_with_output_text_items( + [ + {"type": "output_text", "annotations": [], "logprobs": [], "text": None}, + {"type": "output_text", "annotations": [], "logprobs": [], "text": '{"message":"hello"}'}, + ] + ) + + parsed = parse_response(text_format=Message, input_tools=None, response=response) + message = parsed.output[0] + assert message.type == "message" + content = message.content + + assert content[0].type == "output_text" + assert content[1].type == "output_text" + assert content[0].parsed is None + assert content[1].parsed == Message(message="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