From 72ed678c75cea131231e9128de1931f270062d51 Mon Sep 17 00:00:00 2001 From: shouryamaanjain Date: Sun, 1 Mar 2026 08:18:59 +0530 Subject: [PATCH] feat(responses): add retrieve_parsed() for background response polling When using `responses.parse()` with `background=True`, the subsequent `responses.retrieve()` call returns a plain `Response` object without parsed output. This adds `retrieve_parsed()` to both sync and async clients, allowing users to retrieve and parse background responses with `text_format` and `tools` support. Fixes #2830 --- src/openai/resources/responses/responses.py | 154 ++++++++++++ tests/lib/responses/test_responses.py | 252 +++++++++++++++++++- 2 files changed, 405 insertions(+), 1 deletion(-) diff --git a/src/openai/resources/responses/responses.py b/src/openai/resources/responses/responses.py index c85a94495d..6bf8843d0a 100644 --- a/src/openai/resources/responses/responses.py +++ b/src/openai/resources/responses/responses.py @@ -1492,6 +1492,80 @@ def retrieve( stream_cls=Stream[ResponseStreamEvent], ) + def retrieve_parsed( + self, + response_id: str, + *, + text_format: type[TextFormatT] | Omit = omit, + tools: Iterable[ParseableToolParam] | Omit = omit, + include: List[ResponseIncludable] | Omit = omit, + include_obfuscation: bool | Omit = omit, + starting_after: int | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> ParsedResponse[TextFormatT]: + """Retrieves a model response and parses the output. + + This is useful for polling background responses created with `parse()`. + + Args: + text_format: A type to parse the text output into, e.g. a Pydantic model class. + + tools: The tools used in the original request, needed for parsing function tool + call arguments. + + include: Additional fields to include in the response. + + include_obfuscation: When true, stream obfuscation will be enabled. + + starting_after: The sequence number of the event after which to start streaming. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not response_id: + raise ValueError(f"Expected a non-empty value for `response_id` but received {response_id!r}") + + tools = _make_tools(tools) + + def parser(raw_response: Response) -> ParsedResponse[TextFormatT]: + return parse_response( + input_tools=tools, + text_format=text_format, + response=raw_response, + ) + + return self._get( + f"/responses/{response_id}", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "include": include, + "include_obfuscation": include_obfuscation, + "starting_after": starting_after, + }, + response_retrieve_params.ResponseRetrieveParams, + ), + post_parser=parser, + ), + # we turn the `Response` instance into a `ParsedResponse` + # in the `parser` function above + cast_to=cast(Type[ParsedResponse[TextFormatT]], Response), + ) + def delete( self, response_id: str, @@ -3157,6 +3231,80 @@ async def retrieve( stream_cls=AsyncStream[ResponseStreamEvent], ) + async def retrieve_parsed( + self, + response_id: str, + *, + text_format: type[TextFormatT] | Omit = omit, + tools: Iterable[ParseableToolParam] | Omit = omit, + include: List[ResponseIncludable] | Omit = omit, + include_obfuscation: bool | Omit = omit, + starting_after: int | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> ParsedResponse[TextFormatT]: + """Retrieves a model response and parses the output. + + This is useful for polling background responses created with `parse()`. + + Args: + text_format: A type to parse the text output into, e.g. a Pydantic model class. + + tools: The tools used in the original request, needed for parsing function tool + call arguments. + + include: Additional fields to include in the response. + + include_obfuscation: When true, stream obfuscation will be enabled. + + starting_after: The sequence number of the event after which to start streaming. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not response_id: + raise ValueError(f"Expected a non-empty value for `response_id` but received {response_id!r}") + + tools = _make_tools(tools) + + def parser(raw_response: Response) -> ParsedResponse[TextFormatT]: + return parse_response( + input_tools=tools, + text_format=text_format, + response=raw_response, + ) + + return await self._get( + f"/responses/{response_id}", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform( + { + "include": include, + "include_obfuscation": include_obfuscation, + "starting_after": starting_after, + }, + response_retrieve_params.ResponseRetrieveParams, + ), + post_parser=parser, + ), + # we turn the `Response` instance into a `ParsedResponse` + # in the `parser` function above + cast_to=cast(Type[ParsedResponse[TextFormatT]], Response), + ) + async def delete( self, response_id: str, @@ -3429,6 +3577,9 @@ def __init__(self, responses: Responses) -> None: self.parse = _legacy_response.to_raw_response_wrapper( responses.parse, ) + self.retrieve_parsed = _legacy_response.to_raw_response_wrapper( + responses.retrieve_parsed, + ) @cached_property def input_items(self) -> InputItemsWithRawResponse: @@ -3461,6 +3612,9 @@ def __init__(self, responses: AsyncResponses) -> None: self.parse = _legacy_response.async_to_raw_response_wrapper( responses.parse, ) + self.retrieve_parsed = _legacy_response.async_to_raw_response_wrapper( + responses.retrieve_parsed, + ) @cached_property def input_items(self) -> AsyncInputItemsWithRawResponse: diff --git a/tests/lib/responses/test_responses.py b/tests/lib/responses/test_responses.py index 8e5f16df95..161249f68d 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_extensions import TypeVar +import json +from typing_extensions import Literal, TypeVar +import httpx 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.types.responses import ParsedResponse from ...conftest import base_url from ..snapshots import make_snapshot_request @@ -41,6 +45,252 @@ def test_output_text(client: OpenAI, respx_mock: MockRouter) -> None: ) +@pytest.mark.respx(base_url=base_url) +def test_retrieve_parsed_text_format(client: OpenAI, respx_mock: MockRouter) -> None: + class Location(BaseModel): + city: str + temperature: float + units: Literal["c", "f"] + + content = json.dumps( + { + "id": "resp_bg_123", + "object": "response", + "created_at": 1754925861, + "status": "completed", + "background": True, + "error": None, + "incomplete_details": None, + "instructions": None, + "max_output_tokens": None, + "max_tool_calls": None, + "model": "gpt-4o-2024-08-06", + "output": [ + { + "id": "msg_123", + "type": "message", + "status": "completed", + "content": [ + { + "type": "output_text", + "annotations": [], + "text": '{"city": "San Francisco", "temperature": 65, "units": "f"}', + } + ], + "role": "assistant", + } + ], + "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": "json_schema", + "name": "Location", + "schema": Location.model_json_schema(), + }, + }, + "tool_choice": "auto", + "tools": [], + "top_logprobs": 0, + "top_p": 1.0, + "truncation": "disabled", + "usage": { + "input_tokens": 14, + "input_tokens_details": {"cached_tokens": 0}, + "output_tokens": 20, + "output_tokens_details": {"reasoning_tokens": 0}, + "total_tokens": 34, + }, + "user": None, + "metadata": {}, + } + ) + + respx_mock.get("/responses/resp_bg_123").mock( + return_value=httpx.Response(200, content=content, headers={"content-type": "application/json"}) + ) + + response = client.responses.retrieve_parsed("resp_bg_123", text_format=Location) + + assert isinstance(response, ParsedResponse) + assert response.output_parsed is not None + assert isinstance(response.output_parsed, Location) + assert response.output_parsed.city == "San Francisco" + assert response.output_parsed.temperature == 65 + assert response.output_parsed.units == "f" + + +@pytest.mark.respx(base_url=base_url) +async def test_async_retrieve_parsed_text_format(async_client: AsyncOpenAI, respx_mock: MockRouter) -> None: + class Location(BaseModel): + city: str + temperature: float + units: Literal["c", "f"] + + content = json.dumps( + { + "id": "resp_bg_456", + "object": "response", + "created_at": 1754925861, + "status": "completed", + "background": True, + "error": None, + "incomplete_details": None, + "instructions": None, + "max_output_tokens": None, + "max_tool_calls": None, + "model": "gpt-4o-2024-08-06", + "output": [ + { + "id": "msg_456", + "type": "message", + "status": "completed", + "content": [ + { + "type": "output_text", + "annotations": [], + "text": '{"city": "New York", "temperature": 72, "units": "f"}', + } + ], + "role": "assistant", + } + ], + "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": "json_schema", + "name": "Location", + "schema": Location.model_json_schema(), + }, + }, + "tool_choice": "auto", + "tools": [], + "top_logprobs": 0, + "top_p": 1.0, + "truncation": "disabled", + "usage": { + "input_tokens": 14, + "input_tokens_details": {"cached_tokens": 0}, + "output_tokens": 20, + "output_tokens_details": {"reasoning_tokens": 0}, + "total_tokens": 34, + }, + "user": None, + "metadata": {}, + } + ) + + respx_mock.get("/responses/resp_bg_456").mock( + return_value=httpx.Response(200, content=content, headers={"content-type": "application/json"}) + ) + + response = await async_client.responses.retrieve_parsed("resp_bg_456", text_format=Location) + + assert isinstance(response, ParsedResponse) + assert response.output_parsed is not None + assert isinstance(response.output_parsed, Location) + assert response.output_parsed.city == "New York" + assert response.output_parsed.temperature == 72 + assert response.output_parsed.units == "f" + + +@pytest.mark.respx(base_url=base_url) +def test_retrieve_parsed_with_function_tool(client: OpenAI, respx_mock: MockRouter) -> None: + class GetWeather(BaseModel): + city: str + + content = json.dumps( + { + "id": "resp_bg_789", + "object": "response", + "created_at": 1754925861, + "status": "completed", + "background": True, + "error": None, + "incomplete_details": None, + "instructions": None, + "max_output_tokens": None, + "max_tool_calls": None, + "model": "gpt-4o-2024-08-06", + "output": [ + { + "id": "fc_789", + "type": "function_call", + "status": "completed", + "call_id": "call_123", + "name": "get_weather", + "arguments": '{"city": "San Francisco"}', + } + ], + "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"}}, + "tool_choice": "auto", + "tools": [ + { + "type": "function", + "name": "get_weather", + "parameters": GetWeather.model_json_schema(), + "strict": True, + } + ], + "top_logprobs": 0, + "top_p": 1.0, + "truncation": "disabled", + "usage": { + "input_tokens": 14, + "input_tokens_details": {"cached_tokens": 0}, + "output_tokens": 20, + "output_tokens_details": {"reasoning_tokens": 0}, + "total_tokens": 34, + }, + "user": None, + "metadata": {}, + } + ) + + respx_mock.get("/responses/resp_bg_789").mock( + return_value=httpx.Response(200, content=content, headers={"content-type": "application/json"}) + ) + + response = client.responses.retrieve_parsed( + "resp_bg_789", + tools=[ + { + "type": "function", + "name": "get_weather", + "parameters": GetWeather.model_json_schema(), + "strict": True, + } + ], + ) + + assert isinstance(response, ParsedResponse) + assert len(response.output) == 1 + output = response.output[0] + assert output.type == "function_call" + assert output.parsed_arguments == {"city": "San Francisco"} + + @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