From 2e258547eed49a9373378abe1dfa5cfdc433b938 Mon Sep 17 00:00:00 2001 From: Devon Krisman Date: Sat, 29 Aug 2026 20:03:06 -0400 Subject: [PATCH] fix(chatgpt): keep structured output text.format in Responses API requests The allowed_keys filter in transform_responses_api_request dropped the text param, so json_schema and json_object response formats never reached the upstream API and callers got free-form prose instead of schema-shaped JSON. Whitelist text only when its format type is json_schema or json_object, keeping other text configs filtered as before. --- .../llms/chatgpt/responses/transformation.py | 53 +++++++++++++++---- .../test_chatgpt_responses_transformation.py | 49 +++++++++++++++++ 2 files changed, 91 insertions(+), 11 deletions(-) diff --git a/litellm/llms/chatgpt/responses/transformation.py b/litellm/llms/chatgpt/responses/transformation.py index 72b5cde21d1..609eacba4fe 100644 --- a/litellm/llms/chatgpt/responses/transformation.py +++ b/litellm/llms/chatgpt/responses/transformation.py @@ -3,6 +3,7 @@ from typing import TYPE_CHECKING, Any, Final, cast import httpx from openai.types.responses.response_input_param import ResponseInputItemParam +from pydantic import TypeAdapter, ValidationError from litellm.exceptions import AuthenticationError from litellm.litellm_core_utils.core_helpers import process_response_headers @@ -43,6 +44,29 @@ if TYPE_CHECKING: _CHATGPT_SERVICE_TIERS: Final = {"default": "default", "priority": "priority", "fast": "priority"} +_STRUCTURED_OUTPUT_FORMAT_TYPES: Final = frozenset({"json_schema", "json_object"}) + + +_REQUEST_FIELDS: Final = TypeAdapter(dict[str, object]) +_MAPPING_FIELDS: Final = TypeAdapter(Mapping[str, object]) + + +def _as_mapping(value: object) -> "Mapping[str, object] | None": + try: + return _MAPPING_FIELDS.validate_python(value) + except ValidationError: + return None + + +def _has_structured_output_format(text: object) -> bool: + text_mapping: Final = _as_mapping(text) + if text_mapping is None: + return False + format_config: Final = _as_mapping(text_mapping.get("format")) + if format_config is None: + return False + return format_config.get("type") in _STRUCTURED_OUTPUT_FORMAT_TYPES + def _system_item_as_developer(item: ResponseInputItemParam) -> ResponseInputItemParam: # The Codex backend 400s on {"type": "message", "role": "system"} items ("System messages are not @@ -111,25 +135,30 @@ class ChatGPTResponsesAPIConfig(OpenAIResponsesAPIConfig): litellm_params: GenericLiteLLMParams, headers: dict, ) -> dict: - request: Final = super().transform_responses_api_request( - model, - [{"role": "user", "content": input}] - if isinstance(input, str) - else [_system_item_as_developer(item) for item in input], - response_api_optional_request_params, - litellm_params, - headers, + request: Final = _REQUEST_FIELDS.validate_python( + super().transform_responses_api_request( + model, + [{"role": "user", "content": input}] + if isinstance(input, str) + else [_system_item_as_developer(item) for item in input], + response_api_optional_request_params, + litellm_params, + headers, + ) ) base_instructions: Final = get_chatgpt_default_instructions() existing_instructions: Final = request.get("instructions") - if existing_instructions: + if isinstance(existing_instructions, str) and existing_instructions: if base_instructions not in existing_instructions: request["instructions"] = f"{base_instructions}\n\n{existing_instructions}" else: request["instructions"] = base_instructions request["store"] = False request["stream"] = True - include: Final = list(request.get("include") or []) + include_value: Final = request.get("include") + include: Final[list[object]] = ( # mutable-ok: request["include"] is a JSON array on the wire + list(include_value) if isinstance(include_value, list) else [] + ) if "reasoning.encrypted_content" not in include: include.append("reasoning.encrypted_content") request["include"] = include @@ -146,10 +175,12 @@ class ChatGPTResponsesAPIConfig(OpenAIResponsesAPIConfig): "reasoning", "previous_response_id", "truncation", + *(("text",) if _has_structured_output_format(request.get("text")) else ()), } filtered: Final = {k: v for k, v in request.items() if k in allowed_keys} - service_tier: Final = _CHATGPT_SERVICE_TIERS.get(request.get("service_tier")) + requested_tier: Final = request.get("service_tier") + service_tier: Final = _CHATGPT_SERVICE_TIERS.get(requested_tier) if isinstance(requested_tier, str) else None if service_tier is not None: filtered["service_tier"] = service_tier return filtered diff --git a/tests/unit/llms/chatgpt/responses/test_chatgpt_responses_transformation.py b/tests/unit/llms/chatgpt/responses/test_chatgpt_responses_transformation.py index 635363e77ea..01356f48fe6 100644 --- a/tests/unit/llms/chatgpt/responses/test_chatgpt_responses_transformation.py +++ b/tests/unit/llms/chatgpt/responses/test_chatgpt_responses_transformation.py @@ -266,6 +266,55 @@ class TestChatGPTResponsesAPITransformation: "function": {"name": "hello"}, } + @pytest.mark.parametrize( + "text_param", + [ + { + "format": { + "type": "json_schema", + "name": "verdict", + "schema": { + "type": "object", + "properties": {"block": {"type": "boolean"}}, + "required": ["block"], + }, + } + }, + {"format": {"type": "json_object"}}, + ], + ) + def test_chatgpt_preserves_structured_output_text(self, text_param): + config = ChatGPTResponsesAPIConfig() + request = config.transform_responses_api_request( + model="chatgpt/gpt-5.3-codex", + input="hi", + response_api_optional_request_params={"text": text_param}, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + + assert request["text"] == text_param + + @pytest.mark.parametrize( + "text_param", + [ + {"format": {"type": "text"}}, + {"verbosity": "low"}, + "not-a-dict", + ], + ) + def test_chatgpt_drops_non_structured_text_param(self, text_param): + config = ChatGPTResponsesAPIConfig() + request = config.transform_responses_api_request( + model="chatgpt/gpt-5.3-codex", + input="hi", + response_api_optional_request_params={"text": text_param}, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + + assert "text" not in request + @pytest.mark.parametrize( ("model_name", "response_model"), [