From c2b1757cdce417633710ea47d587dc92c05aa8a1 Mon Sep 17 00:00:00 2001 From: markstuart-oai <323302876+markstuart-oai@users.noreply.github.com> Date: Fri, 30 Oct 2026 00:51:21 +0000 Subject: [PATCH] feat(api): support response format updates and local output types Castiron-Internal-PR: https://github.com/openai/openai-python-internal/pull/365 Castiron-Source-SHA: 76f8408a2d73fd095ef2e6e849072c6033eb93ba Castiron-Public-Base-SHA: 10f9dd012363229a6c6f3fddb73403427fcd5eef --- .castiron.stats.yml | 10 +- api_reference/openapi.transformed.yml | 78 +++++-- helpers.md | 35 ++++ scripts/castiron/generated-files.json | 16 +- src/openai/lib/_response_resources.py | 50 ++++- src/openai/lib/responses_tool_runner.py | 7 +- src/openai/resources/responses/api.md | 2 +- src/openai/resources/responses/responses.py | 192 +++++++++++++++++- ...beta_response_configuration_update_item.py | 37 +++- ...esponse_configuration_update_item_param.py | 35 +++- ...e_configuration_update_item_param_param.py | 40 +++- .../response_configuration_update_item.py | 31 ++- ...esponse_configuration_update_item_param.py | 29 ++- ...e_configuration_update_item_param_param.py | 26 ++- tests/lib/responses/test_parse_as.py | 127 ++++++++++++ tests/lib/test_responses_tool_runner.py | 28 +++ 16 files changed, 671 insertions(+), 72 deletions(-) create mode 100644 tests/lib/responses/test_parse_as.py diff --git a/.castiron.stats.yml b/.castiron.stats.yml index 92eb4f5552..d003338ffb 100644 --- a/.castiron.stats.yml +++ b/.castiron.stats.yml @@ -1,7 +1,7 @@ schema_version: 1 -generation_id: edefdf16-46c7-4f93-94a6-10028a087b3e -openapi_spec_hash: 0c25fc15ac5b26e2054196d579cc67a0 -openapi_transformed_spec_hash: 651cea16581547a33af56bd631894a03 +generation_id: 5dfacb1b-1abf-4d69-9083-e6586471a6f6 +openapi_spec_hash: 53e44788ffb3382947f27b9cf1d14cfe +openapi_transformed_spec_hash: 1385724889e077e80e65b0e8da123eca config_hash: 8a684aec06907ff8ced7809753e16225 -codegen_sha: a1dd47dd01ce07be1ecbc39e2f66f2156d89c6f3 -codegen_hash: 2c7827fd0ccd38edcd80a97839ae5c10cf89f8da20ea468d4b20c41468607b81 +codegen_sha: 11cd26ec903a4168e95565cd46fb7f33f657afca +codegen_hash: 5a9715f0fcf2bbdc00a4d9ad8b7c749a51e4d0531ee776d1ae7707881e0adf2a diff --git a/api_reference/openapi.transformed.yml b/api_reference/openapi.transformed.yml index ff6378fcfa..12f30ba247 100644 --- a/api_reference/openapi.transformed.yml +++ b/api_reference/openapi.transformed.yml @@ -64749,8 +64749,8 @@ components: ResponseConfigurationUpdate: type: object description: | - A configuration update that applies to subsequent responses until it is - replaced by another configuration update. + A configuration update for subsequent responses. Each setting stays in effect + until a later update replaces that setting. properties: id: type: string @@ -64772,15 +64772,27 @@ components: description: | The reasoning effort used for subsequent responses until another configuration update replaces it. + text: + type: object + description: The text output configuration for subsequent responses. + properties: + format: + description: Use a JSON schema for structured output or text to return to plain text. + discriminator: + propertyName: type + anyOf: + - $ref: '#/components/schemas/ResponseFormatText' + - $ref: '#/components/schemas/TextResponseFormatJsonSchema' + required: + - format required: - id - type ResponseConfigurationUpdateItemParam: type: object description: | - An update to the conversation's response configuration. The configuration - remains in effect for subsequent responses until it is replaced by another - configuration update. + An update to the conversation's response configuration. Supply reasoning, + text, or both. Each setting stays in effect until a later update replaces it. properties: id: anyOf: @@ -64804,6 +64816,19 @@ components: description: | The reasoning effort to use for subsequent responses until another configuration update replaces it. + text: + type: object + description: The text output configuration for subsequent responses. + properties: + format: + description: Use a JSON schema for structured output or text to return to plain text. + discriminator: + propertyName: type + anyOf: + - $ref: '#/components/schemas/ResponseFormatText' + - $ref: '#/components/schemas/TextResponseFormatJsonSchema' + required: + - format required: - type ResponseContentPartAddedEvent: @@ -70390,13 +70415,13 @@ components: Setting to `{ "type": "json_object" }` enables the older JSON mode, which ensures the message the model generates is valid JSON. Using `json_schema` is preferred for models that support it. + discriminator: + propertyName: type anyOf: - $ref: '#/components/schemas/ResponseFormatText' - $ref: '#/components/schemas/TextResponseFormatJsonSchema' x-openai-ruby-json-schema-input: true - $ref: '#/components/schemas/ResponseFormatJsonObject' - discriminator: - propertyName: type TextResponseFormatJsonSchema: x-openai-structured-output: json_schema_default_strict type: object @@ -98392,12 +98417,12 @@ components: Setting to `{ "type": "json_object" }` enables the older JSON mode, which ensures the message the model generates is valid JSON. Using `json_schema` is preferred for models that support it. + discriminator: + propertyName: type anyOf: - $ref: '#/components/schemas/BetaResponseFormatText' - $ref: '#/components/schemas/BetaTextResponseFormatJsonSchema' - $ref: '#/components/schemas/BetaResponseFormatJsonObject' - discriminator: - propertyName: type BetaResponseFormatJsonObject: type: object title: JSON object @@ -101460,9 +101485,8 @@ components: BetaResponseConfigurationUpdateItemParam: type: object description: | - An update to the conversation's response configuration. The configuration - remains in effect for subsequent responses until it is replaced by another - configuration update. + An update to the conversation's response configuration. Supply reasoning, + text, or both. Each setting stays in effect until a later update replaces it. properties: agent: anyOf: @@ -101491,6 +101515,19 @@ components: description: | The reasoning effort to use for subsequent responses until another configuration update replaces it. + text: + type: object + description: The text output configuration for subsequent responses. + properties: + format: + description: Use a JSON schema for structured output or text to return to plain text. + discriminator: + propertyName: type + anyOf: + - $ref: '#/components/schemas/BetaResponseFormatText' + - $ref: '#/components/schemas/BetaTextResponseFormatJsonSchema' + required: + - format required: - type BetaAdditionalToolsItemParam: @@ -104217,8 +104254,8 @@ components: BetaResponseConfigurationUpdate: type: object description: | - A configuration update that applies to subsequent responses until it is - replaced by another configuration update. + A configuration update for subsequent responses. Each setting stays in effect + until a later update replaces that setting. properties: agent: $ref: '#/components/schemas/BetaAgentTag' @@ -104243,6 +104280,19 @@ components: description: | The reasoning effort used for subsequent responses until another configuration update replaces it. + text: + type: object + description: The text output configuration for subsequent responses. + properties: + format: + description: Use a JSON schema for structured output or text to return to plain text. + discriminator: + propertyName: type + anyOf: + - $ref: '#/components/schemas/BetaResponseFormatText' + - $ref: '#/components/schemas/BetaTextResponseFormatJsonSchema' + required: + - format required: - id - type diff --git a/helpers.md b/helpers.md index c979675254..8be574b2ce 100644 --- a/helpers.md +++ b/helpers.md @@ -63,6 +63,41 @@ output into a Pydantic model. The same parsing rules apply to Use `output_parsed` for the structured result and retain `output` when replaying messages so their phases are preserved. +When the output schema changes during a conversation, pass `parse_as` to +`client.responses.parse()` to select the local output type. For example, if a +previous turn switched to `DetailedAnswer`, continue with this call. + +```py +response = client.responses.parse( + model=model, + previous_response_id=previous.id, + input="Explain the evidence.", + parse_as=DetailedAnswer, +) +``` + +The SDK parses the answer as `DetailedAnswer`. The request inherits its output +format from the previous turn. If you also pass `text_format=ShortAnswer`, +the request sends that class's schema as the top-level format. Pass +`parse_as=None` to read unparsed text when the previous turn reset the format. +You can also pass a format update directly in `input`. For example, given a +JSON schema format named `detailed_format`: + +```py +response = client.responses.parse( + model=model, + input=[ + {"type": "configuration_update", "text": {"format": detailed_format}}, + {"role": "user", "content": "Explain the evidence."}, + ], + parse_as=DetailedAnswer, +) +``` + +To switch back to plain text, send `{"type": "text"}` as the update's format. +A reasoning-only update keeps the active format. Retrieved configuration items +include `item.text.format` when the input supplied it. + ## Auto-parsing function tool calls The `.parse()` method will also automatically parse `function` tool calls if: diff --git a/scripts/castiron/generated-files.json b/scripts/castiron/generated-files.json index 0fa0d0b98e..fcfa0ab308 100644 --- a/scripts/castiron/generated-files.json +++ b/scripts/castiron/generated-files.json @@ -619,7 +619,7 @@ }, "src/openai/resources/responses/api.md": { "mode": "100644", - "sha256": "e7815b1278aa2780bb885ac5a249fe00859dff67d3899b708d25d2968e734785" + "sha256": "b635f37005fb7126cc69a7b69e3f14a9df135d9523271cc4407b1ffcb9bd5c8c" }, "src/openai/resources/responses/input_items.py": { "mode": "100644", @@ -631,7 +631,7 @@ }, "src/openai/resources/responses/responses.py": { "mode": "100644", - "sha256": "b9a2ef1b61d33450318babc49e2b25f604628983804e7ebf4291f8802f372aee" + "sha256": "e74696203a015c8a87a6e1117d5e73449a1dd4b591c57d4c68d1cfda2641934a" }, "src/openai/resources/safety/__init__.py": { "mode": "100644", @@ -2523,15 +2523,15 @@ }, "src/openai/types/beta/beta_response_configuration_update_item.py": { "mode": "100644", - "sha256": "4bd97e32e710edbd688bf8c6eac0dd8f24d1d0a64da60efda522ffefa0790046" + "sha256": "eebab46ab26a7650a94cf353d3b98c86fc12378284a9431cd4379808515ba6b0" }, "src/openai/types/beta/beta_response_configuration_update_item_param.py": { "mode": "100644", - "sha256": "eb06f107b27ef1fa3a9266bfaf8921b68384faf7c0dd0d300e554767bb7aed30" + "sha256": "c10511a06c2bd67530ce2f6ab2e94b63e49602e4e29df8cfb64e4bb73359e3a9" }, "src/openai/types/beta/beta_response_configuration_update_item_param_param.py": { "mode": "100644", - "sha256": "3c9bcd2d7e730b102b0674424e56cbd35e6bed0664a58acc1830466eea28d2be" + "sha256": "269015eac1b4dd3a8bfbd1053218e64c9a2fd3cac4b50db961089f3813c6bbd0" }, "src/openai/types/beta/beta_response_container_reference.py": { "mode": "100644", @@ -6167,15 +6167,15 @@ }, "src/openai/types/responses/response_configuration_update_item.py": { "mode": "100644", - "sha256": "4d07aca0f5dbb6f87f1db31fc16dbba2e4a49d0670edbd46256907066605a703" + "sha256": "89a39d84322be94f3805145e1ca70bae0c2b0500f89102ea93918700ecc65a02" }, "src/openai/types/responses/response_configuration_update_item_param.py": { "mode": "100644", - "sha256": "c8eb1b18f489c4d820bc17c10e505e97a4da8059592e903882aae5ed3ca919ad" + "sha256": "fb7a2cb3b72d2a5dd73fc78ad6194bce51eb4e1b805d9ccc85c2725e3c0bab3f" }, "src/openai/types/responses/response_configuration_update_item_param_param.py": { "mode": "100644", - "sha256": "8e177dbfa309934a8aedbc5bd278b48bddfa3365a42cfb661ead8677aa924a9b" + "sha256": "d7e43648f155a90a35d2f57d31e8f8481c463ada28805dd810d17db00304ce23" }, "src/openai/types/responses/response_container_reference.py": { "mode": "100644", diff --git a/src/openai/lib/_response_resources.py b/src/openai/lib/_response_resources.py index 27795b7a8d..0d0050d740 100644 --- a/src/openai/lib/_response_resources.py +++ b/src/openai/lib/_response_resources.py @@ -8,7 +8,7 @@ import httpx2 from openai._types import NOT_GIVEN, Body, Omit, Query, Headers, NotGiven, omit -from openai._utils import is_given, maybe_transform +from openai._utils import is_dict, is_list, is_given, maybe_transform from openai._streaming import Stream from openai.lib._tools import PydanticFunctionTool, ResponsesPydanticFunctionTool from openai._base_client import make_request_options @@ -36,10 +36,41 @@ from openai.resources.responses.responses import Responses, AsyncResponses +def _output_text_parser( + *, + input: object, + text: ResponseTextConfigParam | Omit, + text_format: type[TextFormatT] | Omit, + parse_as: type[TextFormatT] | None | Omit, +) -> type[TextFormatT] | Omit: + if parse_as is None: + return omit + + if is_list(input): + for item in reversed(input): + if not is_dict(item) or item.get("type") != "configuration_update": + continue + update = item.get("text") + if not is_dict(update) or not is_dict(fmt := update.get("format")): + continue + if fmt.get("type") == "text": + if is_given(parse_as): + raise TypeError("Cannot use parse_as with a plain-text format update") + return omit + if is_given(text_format) and not is_given(parse_as) and (not is_given(text) or fmt != text.get("format")): + raise TypeError( + "Pass parse_as for the updated output schema, or parse_as=None to read plain output_text" + ) + break + + return parse_as if is_given(parse_as) else text_format + + def parse( resource: Responses, *, text_format: type[TextFormatT] | Omit = omit, + parse_as: type[TextFormatT] | None | Omit = omit, access_programs: response_create_params.AccessPrograms | Omit = omit, background: Optional[bool] | Omit = omit, context_management: Optional[Iterable[response_create_params.ContextManagement]] | Omit = omit, @@ -89,12 +120,18 @@ def parse( text = copy(text) text["format"] = _type_to_text_format_param(text_format) + output_format = _output_text_parser( + input=extra_body.get("input", input) if is_dict(extra_body) else input, + text=text, + text_format=text_format, + parse_as=parse_as, + ) tools = _make_tools(tools) def parser(raw_response: Response) -> ParsedResponse[TextFormatT]: return parse_response( input_tools=tools, - text_format=text_format, + text_format=output_format, response=raw_response, ) @@ -156,6 +193,7 @@ async def async_parse( resource: AsyncResponses, *, text_format: type[TextFormatT] | Omit = omit, + parse_as: type[TextFormatT] | None | Omit = omit, access_programs: response_create_params.AccessPrograms | Omit = omit, background: Optional[bool] | Omit = omit, context_management: Optional[Iterable[response_create_params.ContextManagement]] | Omit = omit, @@ -205,12 +243,18 @@ async def async_parse( text = copy(text) text["format"] = _type_to_text_format_param(text_format) + output_format = _output_text_parser( + input=extra_body.get("input", input) if is_dict(extra_body) else input, + text=text, + text_format=text_format, + parse_as=parse_as, + ) tools = _make_tools(tools) def parser(raw_response: Response) -> ParsedResponse[TextFormatT]: return parse_response( input_tools=tools, - text_format=text_format, + text_format=output_format, response=raw_response, ) diff --git a/src/openai/lib/responses_tool_runner.py b/src/openai/lib/responses_tool_runner.py index 09b5b70298..59a3b9e6f4 100644 --- a/src/openai/lib/responses_tool_runner.py +++ b/src/openai/lib/responses_tool_runner.py @@ -49,6 +49,7 @@ from ..types.responses.tool_param import ToolParam, FunctionToolParam from .streaming.responses._events import ResponseStreamEvent from .streaming.responses._responses import ResponseStream, AsyncResponseStream +from ..types.responses.response_input import ResponseInput from ..types.responses.response_input_param import ComputerCallOutput, FunctionCallOutput, ResponseInputParam from ..types.responses.response_output_item import McpCall, McpApprovalRequest, ResponseOutputItem from ..types.responses.response_create_params import ResponseCreateParamsStreaming, ResponseCreateParamsNonStreaming @@ -506,8 +507,9 @@ def _history_calls(history: ResponseInputParam, tools: list[ToolParam], *, budge # Modern computer calls can omit the legacy checklist; validate these with # the same action parser as dispatch, without changing the saved input. try: + # The input models alias wire fields such as "schema" that are reserved by Pydantic v1. validate_type( - type_=ResponseInputParam, + type_=ResponseInput, value=[item for item in history if item.get("type") not in ("computer_call", "computer_call_output")], ) except pydantic.ValidationError: @@ -522,6 +524,9 @@ def _history_calls(history: ResponseInputParam, tools: list[ToolParam], *, budge answered: set[str] = set() for index, item in enumerate(cast(list[dict[str, object]], history)): kind = item.get("type", "message") + if kind == "configuration_update": + # Already schema-validated above. There is no tool call to pair. + continue if budget_only: if kind == "compaction_trigger": if index != len(history) - 1: diff --git a/src/openai/resources/responses/api.md b/src/openai/resources/responses/api.md index 9ef00fb631..c45a2b2ae7 100644 --- a/src/openai/resources/responses/api.md +++ b/src/openai/resources/responses/api.md @@ -180,7 +180,7 @@ Methods: - client.responses.delete(response_id) -> None - client.responses.cancel(response_id) -> Response - client.responses.compact(\*\*params) -> CompactedResponse -- client.responses.parse(\*, text_format=..., access_programs=..., background=..., context_management=..., conversation=..., include=..., input=..., instructions=..., max_output_tokens=..., max_tool_calls=..., metadata=..., model=..., moderation=..., parallel_tool_calls=..., previous_response_id=..., prompt=..., prompt_cache_key=..., prompt_cache_options=..., prompt_cache_retention=..., reasoning=..., safety_identifier=..., service_tier=..., store=..., stream=..., stream_options=..., temperature=..., text=..., tool_choice=..., tools=..., top_logprobs=..., top_p=..., truncation=..., user=..., verbosity=..., extra_headers=..., extra_query=..., extra_body=..., timeout=...) -> ParsedResponse[TextFormatT] +- client.responses.parse(\*, text_format=..., parse_as=..., access_programs=..., background=..., context_management=..., conversation=..., include=..., input=..., instructions=..., max_output_tokens=..., max_tool_calls=..., metadata=..., model=..., moderation=..., parallel_tool_calls=..., previous_response_id=..., prompt=..., prompt_cache_key=..., prompt_cache_options=..., prompt_cache_retention=..., reasoning=..., safety_identifier=..., service_tier=..., store=..., stream=..., stream_options=..., temperature=..., text=..., tool_choice=..., tools=..., top_logprobs=..., top_p=..., truncation=..., user=..., verbosity=..., extra_headers=..., extra_query=..., extra_body=..., timeout=...) -> ParsedResponse[Any] - client.responses.stream(\*, response_id=..., input=..., model=..., access_programs=..., background=..., context_management=..., text_format=..., tools=..., conversation=..., include=..., instructions=..., max_output_tokens=..., max_tool_calls=..., metadata=..., parallel_tool_calls=..., moderation=..., previous_response_id=..., prompt=..., prompt_cache_key=..., prompt_cache_options=..., prompt_cache_retention=..., reasoning=..., safety_identifier=..., service_tier=..., store=..., stream_options=..., temperature=..., text=..., tool_choice=..., top_logprobs=..., top_p=..., truncation=..., user=..., starting_after=..., extra_headers=..., extra_query=..., extra_body=..., timeout=...) -> ResponseStreamManager[TextFormatT] ## InputItems diff --git a/src/openai/resources/responses/responses.py b/src/openai/resources/responses/responses.py index 1eac3b2e96..56b9ad67fd 100644 --- a/src/openai/resources/responses/responses.py +++ b/src/openai/resources/responses/responses.py @@ -114,10 +114,101 @@ def with_streaming_response(self) -> ResponsesWithStreamingResponse: """ return ResponsesWithStreamingResponse(self) + @overload + def parse( + self, + *, + text_format: type | Omit = omit, + parse_as: type[TextFormatT] | None, + access_programs: response_create_params.AccessPrograms | Omit = omit, + background: Optional[bool] | Omit = omit, + context_management: Optional[Iterable[response_create_params.ContextManagement]] | Omit = omit, + conversation: Optional[response_create_params.Conversation] | Omit = omit, + include: Optional[List[ResponseIncludable]] | Omit = omit, + input: Union[str, ResponseInputParam] | Omit = omit, + instructions: Optional[str] | Omit = omit, + max_output_tokens: Optional[int] | Omit = omit, + max_tool_calls: Optional[int] | Omit = omit, + metadata: Optional[Metadata] | Omit = omit, + model: ResponsesModel | Omit = omit, + moderation: Optional[response_create_params.Moderation] | Omit = omit, + parallel_tool_calls: Optional[bool] | Omit = omit, + previous_response_id: Optional[str] | Omit = omit, + prompt: Optional[ResponsePromptParam] | Omit = omit, + prompt_cache_key: Optional[str] | Omit = omit, + prompt_cache_options: response_create_params.PromptCacheOptions | Omit = omit, + prompt_cache_retention: Optional[Literal["in_memory", "24h"]] | Omit = omit, + reasoning: Optional[Reasoning] | Omit = omit, + safety_identifier: Optional[str] | Omit = omit, + service_tier: Optional[ServiceTier] | Omit = omit, + store: Optional[bool] | Omit = omit, + stream: Optional[Literal[False]] | Literal[True] | Omit = omit, + stream_options: Optional[response_create_params.StreamOptions] | Omit = omit, + temperature: Optional[float] | Omit = omit, + text: ResponseTextConfigParam | Omit = omit, + tool_choice: response_create_params.ToolChoice | Omit = omit, + tools: Iterable[ParseableToolParam] | Omit = omit, + top_logprobs: Optional[int] | Omit = omit, + top_p: Optional[float] | Omit = omit, + truncation: Optional[Literal["auto", "disabled"]] | Omit = omit, + user: str | Omit = omit, + verbosity: Optional[Literal["low", "medium", "high"]] | Omit = omit, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx2.Timeout | None | NotGiven = not_given, + ) -> ParsedResponse[TextFormatT]: ... + + @overload def parse( self, *, text_format: type[TextFormatT] | Omit = omit, + parse_as: Omit = omit, + access_programs: response_create_params.AccessPrograms | Omit = omit, + background: Optional[bool] | Omit = omit, + context_management: Optional[Iterable[response_create_params.ContextManagement]] | Omit = omit, + conversation: Optional[response_create_params.Conversation] | Omit = omit, + include: Optional[List[ResponseIncludable]] | Omit = omit, + input: Union[str, ResponseInputParam] | Omit = omit, + instructions: Optional[str] | Omit = omit, + max_output_tokens: Optional[int] | Omit = omit, + max_tool_calls: Optional[int] | Omit = omit, + metadata: Optional[Metadata] | Omit = omit, + model: ResponsesModel | Omit = omit, + moderation: Optional[response_create_params.Moderation] | Omit = omit, + parallel_tool_calls: Optional[bool] | Omit = omit, + previous_response_id: Optional[str] | Omit = omit, + prompt: Optional[ResponsePromptParam] | Omit = omit, + prompt_cache_key: Optional[str] | Omit = omit, + prompt_cache_options: response_create_params.PromptCacheOptions | Omit = omit, + prompt_cache_retention: Optional[Literal["in_memory", "24h"]] | Omit = omit, + reasoning: Optional[Reasoning] | Omit = omit, + safety_identifier: Optional[str] | Omit = omit, + service_tier: Optional[ServiceTier] | Omit = omit, + store: Optional[bool] | Omit = omit, + stream: Optional[Literal[False]] | Literal[True] | Omit = omit, + stream_options: Optional[response_create_params.StreamOptions] | Omit = omit, + temperature: Optional[float] | Omit = omit, + text: ResponseTextConfigParam | Omit = omit, + tool_choice: response_create_params.ToolChoice | Omit = omit, + tools: Iterable[ParseableToolParam] | Omit = omit, + top_logprobs: Optional[int] | Omit = omit, + top_p: Optional[float] | Omit = omit, + truncation: Optional[Literal["auto", "disabled"]] | Omit = omit, + user: str | Omit = omit, + verbosity: Optional[Literal["low", "medium", "high"]] | Omit = omit, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx2.Timeout | None | NotGiven = not_given, + ) -> ParsedResponse[TextFormatT]: ... + + def parse( + self, + *, + text_format: type | Omit = omit, + parse_as: type | None | Omit = omit, access_programs: response_create_params.AccessPrograms | Omit = omit, background: Optional[bool] | Omit = omit, context_management: Optional[Iterable[response_create_params.ContextManagement]] | Omit = omit, @@ -155,11 +246,12 @@ def parse( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx2.Timeout | None | NotGiven = not_given, - ) -> ParsedResponse[TextFormatT]: - """parse""" + ) -> ParsedResponse[Any]: + """Parse output with `parse_as` when supplied. Only `text_format` sets the request schema.""" return parse( self, text_format=text_format, + parse_as=parse_as, access_programs=access_programs, background=background, context_management=context_management, @@ -1940,10 +2032,101 @@ def with_streaming_response(self) -> AsyncResponsesWithStreamingResponse: """ return AsyncResponsesWithStreamingResponse(self) + @overload + async def parse( + self, + *, + text_format: type | Omit = omit, + parse_as: type[TextFormatT] | None, + access_programs: response_create_params.AccessPrograms | Omit = omit, + background: Optional[bool] | Omit = omit, + context_management: Optional[Iterable[response_create_params.ContextManagement]] | Omit = omit, + conversation: Optional[response_create_params.Conversation] | Omit = omit, + include: Optional[List[ResponseIncludable]] | Omit = omit, + input: Union[str, ResponseInputParam] | Omit = omit, + instructions: Optional[str] | Omit = omit, + max_output_tokens: Optional[int] | Omit = omit, + max_tool_calls: Optional[int] | Omit = omit, + metadata: Optional[Metadata] | Omit = omit, + model: ResponsesModel | Omit = omit, + moderation: Optional[response_create_params.Moderation] | Omit = omit, + parallel_tool_calls: Optional[bool] | Omit = omit, + previous_response_id: Optional[str] | Omit = omit, + prompt: Optional[ResponsePromptParam] | Omit = omit, + prompt_cache_key: Optional[str] | Omit = omit, + prompt_cache_options: response_create_params.PromptCacheOptions | Omit = omit, + prompt_cache_retention: Optional[Literal["in_memory", "24h"]] | Omit = omit, + reasoning: Optional[Reasoning] | Omit = omit, + safety_identifier: Optional[str] | Omit = omit, + service_tier: Optional[ServiceTier] | Omit = omit, + store: Optional[bool] | Omit = omit, + stream: Optional[Literal[False]] | Literal[True] | Omit = omit, + stream_options: Optional[response_create_params.StreamOptions] | Omit = omit, + temperature: Optional[float] | Omit = omit, + text: ResponseTextConfigParam | Omit = omit, + tool_choice: response_create_params.ToolChoice | Omit = omit, + tools: Iterable[ParseableToolParam] | Omit = omit, + top_logprobs: Optional[int] | Omit = omit, + top_p: Optional[float] | Omit = omit, + truncation: Optional[Literal["auto", "disabled"]] | Omit = omit, + user: str | Omit = omit, + verbosity: Optional[Literal["low", "medium", "high"]] | Omit = omit, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx2.Timeout | None | NotGiven = not_given, + ) -> ParsedResponse[TextFormatT]: ... + + @overload async def parse( self, *, text_format: type[TextFormatT] | Omit = omit, + parse_as: Omit = omit, + access_programs: response_create_params.AccessPrograms | Omit = omit, + background: Optional[bool] | Omit = omit, + context_management: Optional[Iterable[response_create_params.ContextManagement]] | Omit = omit, + conversation: Optional[response_create_params.Conversation] | Omit = omit, + include: Optional[List[ResponseIncludable]] | Omit = omit, + input: Union[str, ResponseInputParam] | Omit = omit, + instructions: Optional[str] | Omit = omit, + max_output_tokens: Optional[int] | Omit = omit, + max_tool_calls: Optional[int] | Omit = omit, + metadata: Optional[Metadata] | Omit = omit, + model: ResponsesModel | Omit = omit, + moderation: Optional[response_create_params.Moderation] | Omit = omit, + parallel_tool_calls: Optional[bool] | Omit = omit, + previous_response_id: Optional[str] | Omit = omit, + prompt: Optional[ResponsePromptParam] | Omit = omit, + prompt_cache_key: Optional[str] | Omit = omit, + prompt_cache_options: response_create_params.PromptCacheOptions | Omit = omit, + prompt_cache_retention: Optional[Literal["in_memory", "24h"]] | Omit = omit, + reasoning: Optional[Reasoning] | Omit = omit, + safety_identifier: Optional[str] | Omit = omit, + service_tier: Optional[ServiceTier] | Omit = omit, + store: Optional[bool] | Omit = omit, + stream: Optional[Literal[False]] | Literal[True] | Omit = omit, + stream_options: Optional[response_create_params.StreamOptions] | Omit = omit, + temperature: Optional[float] | Omit = omit, + text: ResponseTextConfigParam | Omit = omit, + tool_choice: response_create_params.ToolChoice | Omit = omit, + tools: Iterable[ParseableToolParam] | Omit = omit, + top_logprobs: Optional[int] | Omit = omit, + top_p: Optional[float] | Omit = omit, + truncation: Optional[Literal["auto", "disabled"]] | Omit = omit, + user: str | Omit = omit, + verbosity: Optional[Literal["low", "medium", "high"]] | Omit = omit, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx2.Timeout | None | NotGiven = not_given, + ) -> ParsedResponse[TextFormatT]: ... + + async def parse( + self, + *, + text_format: type | Omit = omit, + parse_as: type | None | Omit = omit, access_programs: response_create_params.AccessPrograms | Omit = omit, background: Optional[bool] | Omit = omit, context_management: Optional[Iterable[response_create_params.ContextManagement]] | Omit = omit, @@ -1981,11 +2164,12 @@ async def parse( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx2.Timeout | None | NotGiven = not_given, - ) -> ParsedResponse[TextFormatT]: - """parse""" + ) -> ParsedResponse[Any]: + """Parse output with `parse_as` when supplied. Only `text_format` sets the request schema.""" return await async_parse( self, text_format=text_format, + parse_as=parse_as, access_programs=access_programs, background=background, context_management=context_management, diff --git a/src/openai/types/beta/beta_response_configuration_update_item.py b/src/openai/types/beta/beta_response_configuration_update_item.py index a6d1780521..e0779ef6d3 100644 --- a/src/openai/types/beta/beta_response_configuration_update_item.py +++ b/src/openai/types/beta/beta_response_configuration_update_item.py @@ -1,11 +1,13 @@ # File generated from our OpenAPI spec by Castiron. See CONTRIBUTING.md for details. -from typing import Optional -from typing_extensions import Literal +from typing import Union, Optional +from typing_extensions import Literal, Annotated, TypeAlias +from ..._utils import PropertyInfo from ..._models import BaseModel +from .beta_response_format_text_json_schema_config import BetaResponseFormatTextJSONSchemaConfig -__all__ = ["BetaResponseConfigurationUpdateItem", "Agent", "Reasoning"] +__all__ = ["BetaResponseConfigurationUpdateItem", "Agent", "Reasoning", "Text", "TextFormat", "TextFormatText"] class Agent(BaseModel): @@ -25,10 +27,30 @@ class Reasoning(BaseModel): """ +class TextFormatText(BaseModel): + """Default response format. Used to generate text responses.""" + + type: Literal["text"] + """The type of response format being defined. Always `text`.""" + + +TextFormat: TypeAlias = Annotated[ + Union[TextFormatText, BetaResponseFormatTextJSONSchemaConfig], PropertyInfo(discriminator="type") +] + + +class Text(BaseModel): + """The text output configuration for subsequent responses.""" + + format: TextFormat + """Use a JSON schema for structured output or text to return to plain text.""" + + class BetaResponseConfigurationUpdateItem(BaseModel): - """ - A configuration update that applies to subsequent responses until it is - replaced by another configuration update. + """A configuration update for subsequent responses. + + Each setting stays in effect + until a later update replaces that setting. """ id: str @@ -42,3 +64,6 @@ class BetaResponseConfigurationUpdateItem(BaseModel): reasoning: Optional[Reasoning] = None """The reasoning configuration applied by this update.""" + + text: Optional[Text] = None + """The text output configuration for subsequent responses.""" diff --git a/src/openai/types/beta/beta_response_configuration_update_item_param.py b/src/openai/types/beta/beta_response_configuration_update_item_param.py index 36090be07d..2f6e759918 100644 --- a/src/openai/types/beta/beta_response_configuration_update_item_param.py +++ b/src/openai/types/beta/beta_response_configuration_update_item_param.py @@ -1,11 +1,13 @@ # File generated from our OpenAPI spec by Castiron. See CONTRIBUTING.md for details. -from typing import Optional -from typing_extensions import Literal +from typing import Union, Optional +from typing_extensions import Literal, Annotated, TypeAlias +from ..._utils import PropertyInfo from ..._models import BaseModel +from .beta_response_format_text_json_schema_config import BetaResponseFormatTextJSONSchemaConfig -__all__ = ["BetaResponseConfigurationUpdateItemParam", "Agent", "Reasoning"] +__all__ = ["BetaResponseConfigurationUpdateItemParam", "Agent", "Reasoning", "Text", "TextFormat", "TextFormatText"] class Agent(BaseModel): @@ -25,12 +27,30 @@ class Reasoning(BaseModel): """ +class TextFormatText(BaseModel): + """Default response format. Used to generate text responses.""" + + type: Literal["text"] + """The type of response format being defined. Always `text`.""" + + +TextFormat: TypeAlias = Annotated[ + Union[TextFormatText, BetaResponseFormatTextJSONSchemaConfig], PropertyInfo(discriminator="type") +] + + +class Text(BaseModel): + """The text output configuration for subsequent responses.""" + + format: TextFormat + """Use a JSON schema for structured output or text to return to plain text.""" + + class BetaResponseConfigurationUpdateItemParam(BaseModel): """An update to the conversation's response configuration. - The configuration - remains in effect for subsequent responses until it is replaced by another - configuration update. + Supply reasoning, + text, or both. Each setting stays in effect until a later update replaces it. """ type: Literal["configuration_update"] @@ -44,3 +64,6 @@ class BetaResponseConfigurationUpdateItemParam(BaseModel): reasoning: Optional[Reasoning] = None """Updates to reasoning configuration. Only effort is supported.""" + + text: Optional[Text] = None + """The text output configuration for subsequent responses.""" diff --git a/src/openai/types/beta/beta_response_configuration_update_item_param_param.py b/src/openai/types/beta/beta_response_configuration_update_item_param_param.py index 1c9b060f83..4b233c90c6 100644 --- a/src/openai/types/beta/beta_response_configuration_update_item_param_param.py +++ b/src/openai/types/beta/beta_response_configuration_update_item_param_param.py @@ -2,10 +2,19 @@ from __future__ import annotations -from typing import Optional -from typing_extensions import Literal, Required, TypedDict +from typing import Union, Optional +from typing_extensions import Literal, Required, TypeAlias, TypedDict -__all__ = ["BetaResponseConfigurationUpdateItemParamParam", "Agent", "Reasoning"] +from .beta_response_format_text_json_schema_config_param import BetaResponseFormatTextJSONSchemaConfigParam + +__all__ = [ + "BetaResponseConfigurationUpdateItemParamParam", + "Agent", + "Reasoning", + "Text", + "TextFormat", + "TextFormatText", +] class Agent(TypedDict, total=False): @@ -25,12 +34,28 @@ class Reasoning(TypedDict, total=False): """ +class TextFormatText(TypedDict, total=False): + """Default response format. Used to generate text responses.""" + + type: Required[Literal["text"]] + """The type of response format being defined. Always `text`.""" + + +TextFormat: TypeAlias = Union[TextFormatText, BetaResponseFormatTextJSONSchemaConfigParam] + + +class Text(TypedDict, total=False): + """The text output configuration for subsequent responses.""" + + format: Required[TextFormat] + """Use a JSON schema for structured output or text to return to plain text.""" + + class BetaResponseConfigurationUpdateItemParamParam(TypedDict, total=False): """An update to the conversation's response configuration. - The configuration - remains in effect for subsequent responses until it is replaced by another - configuration update. + Supply reasoning, + text, or both. Each setting stays in effect until a later update replaces it. """ type: Required[Literal["configuration_update"]] @@ -44,3 +69,6 @@ class BetaResponseConfigurationUpdateItemParamParam(TypedDict, total=False): reasoning: Reasoning """Updates to reasoning configuration. Only effort is supported.""" + + text: Text + """The text output configuration for subsequent responses.""" diff --git a/src/openai/types/responses/response_configuration_update_item.py b/src/openai/types/responses/response_configuration_update_item.py index 5d14e6091d..9224cb3000 100644 --- a/src/openai/types/responses/response_configuration_update_item.py +++ b/src/openai/types/responses/response_configuration_update_item.py @@ -1,12 +1,15 @@ # File generated from our OpenAPI spec by Castiron. See CONTRIBUTING.md for details. -from typing import Optional -from typing_extensions import Literal +from typing import Union, Optional +from typing_extensions import Literal, Annotated, TypeAlias +from ..._utils import PropertyInfo from ..._models import BaseModel from ..shared.reasoning_effort import ReasoningEffort +from ..shared.response_format_text import ResponseFormatText +from .response_format_text_json_schema_config import ResponseFormatTextJSONSchemaConfig -__all__ = ["ResponseConfigurationUpdateItem", "Reasoning"] +__all__ = ["ResponseConfigurationUpdateItem", "Reasoning", "Text", "TextFormat"] class Reasoning(BaseModel): @@ -19,10 +22,23 @@ class Reasoning(BaseModel): """ +TextFormat: TypeAlias = Annotated[ + Union[ResponseFormatText, ResponseFormatTextJSONSchemaConfig], PropertyInfo(discriminator="type") +] + + +class Text(BaseModel): + """The text output configuration for subsequent responses.""" + + format: TextFormat + """Use a JSON schema for structured output or text to return to plain text.""" + + class ResponseConfigurationUpdateItem(BaseModel): - """ - A configuration update that applies to subsequent responses until it is - replaced by another configuration update. + """A configuration update for subsequent responses. + + Each setting stays in effect + until a later update replaces that setting. """ id: str @@ -33,3 +49,6 @@ class ResponseConfigurationUpdateItem(BaseModel): reasoning: Optional[Reasoning] = None """The reasoning configuration applied by this update.""" + + text: Optional[Text] = None + """The text output configuration for subsequent responses.""" diff --git a/src/openai/types/responses/response_configuration_update_item_param.py b/src/openai/types/responses/response_configuration_update_item_param.py index 7a18c2a51d..157dcc31be 100644 --- a/src/openai/types/responses/response_configuration_update_item_param.py +++ b/src/openai/types/responses/response_configuration_update_item_param.py @@ -1,12 +1,15 @@ # File generated from our OpenAPI spec by Castiron. See CONTRIBUTING.md for details. -from typing import Optional -from typing_extensions import Literal +from typing import Union, Optional +from typing_extensions import Literal, Annotated, TypeAlias +from ..._utils import PropertyInfo from ..._models import BaseModel from ..shared.reasoning_effort import ReasoningEffort +from ..shared.response_format_text import ResponseFormatText +from .response_format_text_json_schema_config import ResponseFormatTextJSONSchemaConfig -__all__ = ["ResponseConfigurationUpdateItemParam", "Reasoning"] +__all__ = ["ResponseConfigurationUpdateItemParam", "Reasoning", "Text", "TextFormat"] class Reasoning(BaseModel): @@ -19,12 +22,23 @@ class Reasoning(BaseModel): """ +TextFormat: TypeAlias = Annotated[ + Union[ResponseFormatText, ResponseFormatTextJSONSchemaConfig], PropertyInfo(discriminator="type") +] + + +class Text(BaseModel): + """The text output configuration for subsequent responses.""" + + format: TextFormat + """Use a JSON schema for structured output or text to return to plain text.""" + + class ResponseConfigurationUpdateItemParam(BaseModel): """An update to the conversation's response configuration. - The configuration - remains in effect for subsequent responses until it is replaced by another - configuration update. + Supply reasoning, + text, or both. Each setting stays in effect until a later update replaces it. """ type: Literal["configuration_update"] @@ -35,3 +49,6 @@ class ResponseConfigurationUpdateItemParam(BaseModel): reasoning: Optional[Reasoning] = None """Updates to reasoning configuration. Only effort is supported.""" + + text: Optional[Text] = None + """The text output configuration for subsequent responses.""" diff --git a/src/openai/types/responses/response_configuration_update_item_param_param.py b/src/openai/types/responses/response_configuration_update_item_param_param.py index ed7e5949ec..f14f79b344 100644 --- a/src/openai/types/responses/response_configuration_update_item_param_param.py +++ b/src/openai/types/responses/response_configuration_update_item_param_param.py @@ -2,12 +2,14 @@ from __future__ import annotations -from typing import Optional -from typing_extensions import Literal, Required, TypedDict +from typing import Union, Optional +from typing_extensions import Literal, Required, TypeAlias, TypedDict from ..shared.reasoning_effort import ReasoningEffort +from ..shared_params.response_format_text import ResponseFormatText +from .response_format_text_json_schema_config_param import ResponseFormatTextJSONSchemaConfigParam -__all__ = ["ResponseConfigurationUpdateItemParamParam", "Reasoning"] +__all__ = ["ResponseConfigurationUpdateItemParamParam", "Reasoning", "Text", "TextFormat"] class Reasoning(TypedDict, total=False): @@ -20,12 +22,21 @@ class Reasoning(TypedDict, total=False): """ +TextFormat: TypeAlias = Union[ResponseFormatText, ResponseFormatTextJSONSchemaConfigParam] + + +class Text(TypedDict, total=False): + """The text output configuration for subsequent responses.""" + + format: Required[TextFormat] + """Use a JSON schema for structured output or text to return to plain text.""" + + class ResponseConfigurationUpdateItemParamParam(TypedDict, total=False): """An update to the conversation's response configuration. - The configuration - remains in effect for subsequent responses until it is replaced by another - configuration update. + Supply reasoning, + text, or both. Each setting stays in effect until a later update replaces it. """ type: Required[Literal["configuration_update"]] @@ -36,3 +47,6 @@ class ResponseConfigurationUpdateItemParamParam(TypedDict, total=False): reasoning: Reasoning """Updates to reasoning configuration. Only effort is supported.""" + + text: Text + """The text output configuration for subsequent responses.""" diff --git a/tests/lib/responses/test_parse_as.py b/tests/lib/responses/test_parse_as.py new file mode 100644 index 0000000000..dd3bba194e --- /dev/null +++ b/tests/lib/responses/test_parse_as.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +import json +from typing_extensions import assert_type + +import pytest +from pydantic import BaseModel + +from openai import OpenAI, AsyncOpenAI +from tests.respx2 import MockRouter +from openai.types.responses import ResponseConfigurationUpdateItem, ResponseConfigurationUpdateItemParamParam +from openai.lib._parsing._responses import type_to_text_format_param + +from ...conftest import base_url + + +class ShortAnswer(BaseModel): + answer: str + + +class DetailedAnswer(ShortAnswer): + evidence: str + + +def _detailed_update() -> ResponseConfigurationUpdateItemParamParam: + fmt = type_to_text_format_param(DetailedAnswer) + assert fmt["type"] == "json_schema" + return {"type": "configuration_update", "text": {"format": fmt}} + + +@pytest.mark.respx2(base_url=base_url) +@pytest.mark.parametrize("client,async_client", [(False, False)], indirect=True) +@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) +async def test_parse_as_on_continuation( + client: OpenAI, async_client: AsyncOpenAI, respx2_mock: MockRouter, sync: bool +) -> None: + text = '{"answer":"yes","evidence":"the receipt"}' + route = respx2_mock.post("/responses").respond( + json={"output": [{"type": "message", "content": [{"type": "output_text", "text": text}]}]} + ) + if sync: + response = client.responses.parse( + previous_response_id="resp_previous", input="Explain.", text_format=ShortAnswer, parse_as=DetailedAnswer + ) + else: + response = await async_client.responses.parse( + previous_response_id="resp_previous", input="Explain.", text_format=ShortAnswer, parse_as=DetailedAnswer + ) + + assert_type(response.output_parsed, DetailedAnswer | None) + assert response.output_parsed == DetailedAnswer(answer="yes", evidence="the receipt") + body = json.loads(route.calls.last.request.content) + assert body["text"]["format"] == type_to_text_format_param(ShortAnswer) + assert body["previous_response_id"] == "resp_previous" + assert "parse_as" not in body + + +@pytest.mark.respx2(base_url=base_url) +@pytest.mark.parametrize("client", [False], indirect=True) +def test_parse_as_does_not_send_a_top_level_schema(client: OpenAI, respx2_mock: MockRouter) -> None: + update = _detailed_update() + text = '{"answer":"yes","evidence":"the receipt"}' + route = respx2_mock.post("/responses").respond( + json={"output": [{"type": "message", "content": [{"type": "output_text", "text": text}]}]} + ) + response = client.responses.parse(input=[update], parse_as=DetailedAnswer) + + assert response.output_parsed == DetailedAnswer(answer="yes", evidence="the receipt") + assert json.loads(route.calls.last.request.content) == {"input": [update]} + + +@pytest.mark.respx2(base_url=base_url) +@pytest.mark.parametrize("client", [False], indirect=True) +def test_visible_format_updates(client: OpenAI, respx2_mock: MockRouter) -> None: + detailed = _detailed_update() + reasoning: ResponseConfigurationUpdateItemParamParam = { + "type": "configuration_update", + "reasoning": {"effort": "high"}, + } + reset: ResponseConfigurationUpdateItemParamParam = { + "type": "configuration_update", + "text": {"format": {"type": "text"}}, + } + route = respx2_mock.post("/responses").respond( + json={"output": [{"type": "message", "content": [{"type": "output_text", "text": "plain text"}]}]} + ) + with pytest.raises(TypeError, match="Pass parse_as"): + client.responses.parse(text_format=ShortAnswer, input=[detailed, reasoning]) + assert not route.called + + response = client.responses.parse(text_format=ShortAnswer, input=[detailed, reset, reasoning]) + assert response.output_text == "plain text" + assert response.output_parsed is None + + with pytest.raises(TypeError, match="plain-text format update"): + client.responses.parse(text_format=ShortAnswer, parse_as=DetailedAnswer, input=[reset]) + assert route.call_count == 1 + + response_text = client.responses.parse(previous_response_id="resp_reset", text_format=ShortAnswer, parse_as=None) + assert_type(response_text.output_parsed, None) + assert response_text.output_parsed is None + + +@pytest.mark.respx2(base_url=base_url) +def test_list_input_items_preserves_format_updates(client: OpenAI, respx2_mock: MockRouter) -> None: + items = [ + {"id": "cnfu_schema", **_detailed_update()}, + { + "id": "cnfu_reset", + "type": "configuration_update", + "reasoning": {"effort": "high"}, + "text": {"format": {"type": "text"}}, + }, + {"id": "cnfu_reason", "type": "configuration_update", "reasoning": {"effort": "low"}}, + ] + respx2_mock.get("/responses/resp_test/input_items").respond( + json={"object": "list", "data": items, "first_id": "cnfu_schema", "last_id": "cnfu_reason", "has_more": False} + ) + + detailed, reset, reasoning = client.responses.input_items.list("resp_test").data + assert isinstance(detailed, ResponseConfigurationUpdateItem) and detailed.text is not None + assert detailed.text.format.type == "json_schema" + assert detailed.text.format.name == "DetailedAnswer" + assert isinstance(reset, ResponseConfigurationUpdateItem) and reset.text is not None + assert reset.text.format.type == "text" + assert isinstance(reasoning, ResponseConfigurationUpdateItem) and reasoning.text is None + assert [item.to_dict() for item in (detailed, reset, reasoning)] == items diff --git a/tests/lib/test_responses_tool_runner.py b/tests/lib/test_responses_tool_runner.py index 336d2b66e4..d1593f26ef 100644 --- a/tests/lib/test_responses_tool_runner.py +++ b/tests/lib/test_responses_tool_runner.py @@ -2022,6 +2022,34 @@ async def test_replay_reasoning_requires_native_fields(lane: str, mode: str, fie assert requests == [] +@pytest.mark.parametrize("lane", ["sync", "async"]) +@pytest.mark.parametrize("mode", ["ledger", "budget"]) +async def test_replay_preserves_configuration_updates(lane: str, mode: str) -> None: + history: ResponseInputParam = [ + { + "type": "configuration_update", + "text": {"format": {"type": "json_schema", "name": "answer", "schema": {"type": "object"}}}, + }, + {"role": "user", "content": "Continue"}, + {"type": "configuration_update", "text": {"format": {"type": "text"}}}, + ] + requests: list[dict[str, Any]] = [] + records: list[ResponsesToolCheckpoint] = [] + await run( + lane, + None, + [resp([fn("record", "one", text="once")], "r"), FINAL], + requests, + params={**PARAMS, "input": history}, + save=records.append if mode == "ledger" else None, + context_budget=ContextBudget(max_input_bytes=10000, max_input_images=0) if mode == "budget" else None, + ) + assert requests[0]["input"] == history + assert requests[1]["input"][: len(history)] == history + if mode == "ledger": + assert records[0]["history"] == history + + @pytest.mark.parametrize("lane", ["sync", "async"]) async def test_replay_reasoning_preserves_native_fields(lane: str) -> None: reasoning = {