From 9aaf01d8ea90679117a96bf058bfca5b0e45ee0c Mon Sep 17 00:00:00 2001 From: Alexander Chernov Date: Mon, 24 Aug 2026 16:03:00 +0100 Subject: [PATCH 1/3] fix(chatgpt): keep text and accept string input for the Codex backend Two request-side defects stop chatgpt/* serving structured output or a string input. The allowlist at the end of transform_responses_api_request omits "text". The chat-to-responses bridge translates response_format into text.format, so the allowlist discards strict schemas silently and the backend answers with prose instead of JSON. The backend does honour text.format when it receives it. Separately the backend rejects a bare string input with {"detail": "Input must be a list"}, while the Responses API itself accepts either shape, so the string needs wrapping before it is sent. Both are request-shaping only, so neither depends on the streaming work in #31332 or #34095, though those two block the same call and have to be in place to observe this one end to end. The "text" case was reported in #24356 and closed by the stale bot without a fix. Signed-off-by: Alexander Chernov --- .../llms/chatgpt/responses/transformation.py | 7 ++ .../test_chatgpt_responses_transformation.py | 91 +++++++++++++++++++ 2 files changed, 98 insertions(+) diff --git a/litellm/llms/chatgpt/responses/transformation.py b/litellm/llms/chatgpt/responses/transformation.py index 8e4bbf1d3c9..8c7999c3b11 100644 --- a/litellm/llms/chatgpt/responses/transformation.py +++ b/litellm/llms/chatgpt/responses/transformation.py @@ -66,6 +66,10 @@ class ChatGPTResponsesAPIConfig(OpenAIResponsesAPIConfig): litellm_params: GenericLiteLLMParams, headers: dict, ) -> dict: + # The Responses API accepts a string or a list, but this backend + # rejects a string with {"detail": "Input must be a list"}. + if isinstance(input, str): + input = [{"role": "user", "content": input}] request: Final = super().transform_responses_api_request( model, input, @@ -99,6 +103,9 @@ class ChatGPTResponsesAPIConfig(OpenAIResponsesAPIConfig): "reasoning", "previous_response_id", "truncation", + # The chat-to-responses bridge translates response_format into + # "text"; dropping it here discards strict schemas silently. + "text", } return {k: v for k, v in request.items() if k in allowed_keys} diff --git a/tests/test_litellm/llms/chatgpt/responses/test_chatgpt_responses_transformation.py b/tests/test_litellm/llms/chatgpt/responses/test_chatgpt_responses_transformation.py index 8e0415d50de..ff118e6bba3 100644 --- a/tests/test_litellm/llms/chatgpt/responses/test_chatgpt_responses_transformation.py +++ b/tests/test_litellm/llms/chatgpt/responses/test_chatgpt_responses_transformation.py @@ -155,6 +155,97 @@ class TestChatGPTResponsesAPITransformation: "function": {"name": "hello"}, } + @pytest.mark.parametrize( + "model_name", + [ + "chatgpt/gpt-5.4", + "chatgpt/gpt-5.3-codex", + ], + ) + def test_chatgpt_preserves_text_for_structured_output(self, model_name): + """text carries the schema, so dropping it loses structured output. + + The chat-to-responses bridge turns response_format into text.format, + so an allowlist without "text" silently discards strict schemas and + the backend answers with prose. + """ + config = ChatGPTResponsesAPIConfig() + text_param = { + "format": { + "type": "json_schema", + "name": "ExtractedEntities", + "strict": True, + "schema": { + "type": "object", + "properties": {"name": {"type": "string"}}, + "required": ["name"], + "additionalProperties": False, + }, + } + } + + request = config.transform_responses_api_request( + model=model_name, + input="hi", + response_api_optional_request_params={"text": text_param}, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + + assert request["text"] == text_param + assert request["text"]["format"]["type"] == "json_schema" + assert request["text"]["format"]["strict"] is True + + @pytest.mark.parametrize( + "model_name", + [ + "chatgpt/gpt-5.4", + "chatgpt/gpt-5.3-codex", + ], + ) + def test_chatgpt_coerces_string_input_to_list(self, model_name): + """The backend rejects a string input with "Input must be a list". + + The Responses API itself accepts either, so the string has to be + wrapped before it reaches this backend. + """ + config = ChatGPTResponsesAPIConfig() + + request = config.transform_responses_api_request( + model=model_name, + input="say hi", + response_api_optional_request_params={}, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + + assert isinstance(request["input"], list) + assert request["input"] == [{"role": "user", "content": "say hi"}] + + @pytest.mark.parametrize( + "model_name", + [ + "chatgpt/gpt-5.4", + ], + ) + def test_chatgpt_leaves_list_input_untouched(self, model_name): + """Only a bare string needs wrapping; a list must pass through.""" + config = ChatGPTResponsesAPIConfig() + original = [ + {"role": "system", "content": "be brief"}, + {"role": "user", "content": "say hi"}, + ] + + request = config.transform_responses_api_request( + model=model_name, + input=list(original), + response_api_optional_request_params={}, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + + assert request["input"] == original + @pytest.mark.parametrize( ("model_name", "response_model"), [ From c9117ec6c12fc0d23170be7eed5d63c7c6344259 Mon Sep 17 00:00:00 2001 From: Alexander Chernov Date: Mon, 24 Aug 2026 16:11:14 +0100 Subject: [PATCH 2/3] fix(chatgpt): annotate the input coercion for the type-discipline gate The LIT002 budget counts mutable-collection construction, and wrapping the string input builds a list and a dict, which put the total two over its ceiling. Both constructions are the wire payload this backend requires, so the list cannot be replaced with an immutable equivalent; annotate the line with a reason instead, matching the convention used elsewhere in the tree. LIT005 freezes reasonless suppressions at zero, so the reason is required rather than decorative. Signed-off-by: Alexander Chernov --- litellm/llms/chatgpt/responses/transformation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/litellm/llms/chatgpt/responses/transformation.py b/litellm/llms/chatgpt/responses/transformation.py index 8c7999c3b11..c5f67b78654 100644 --- a/litellm/llms/chatgpt/responses/transformation.py +++ b/litellm/llms/chatgpt/responses/transformation.py @@ -69,7 +69,7 @@ class ChatGPTResponsesAPIConfig(OpenAIResponsesAPIConfig): # The Responses API accepts a string or a list, but this backend # rejects a string with {"detail": "Input must be a list"}. if isinstance(input, str): - input = [{"role": "user", "content": input}] + input = [{"role": "user", "content": input}] # mutable-ok: the wire payload this backend requires request: Final = super().transform_responses_api_request( model, input, From 3ee1ccf3fdf19ceb51def767dbed48e52b13e709 Mon Sep 17 00:00:00 2001 From: Alexander Chernov Date: Mon, 24 Aug 2026 16:16:35 +0100 Subject: [PATCH 3/3] refactor(chatgpt): bind the coerced input instead of rebinding the parameter Greptile flagged the parameter rebinding and the explanatory comments against the repo conventions in CLAUDE.md, which bans rebinding a function parameter (LIT011) and keeps comments to suppressions, TODOs and genuinely complex logic Bind a Final local for the coerced input rather than reassigning `input`, and drop both prose comments; the rationale lives in the commit and the PR body rather than duplicated at the call site. The mutable-ok suppression stays, since it is the allowed kind and the list is still constructed Behaviour is unchanged: 24 tests pass, and the same 4 fail with only the provider file reverted to base Signed-off-by: Alexander Chernov --- litellm/llms/chatgpt/responses/transformation.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/litellm/llms/chatgpt/responses/transformation.py b/litellm/llms/chatgpt/responses/transformation.py index c5f67b78654..2fbbffde1ef 100644 --- a/litellm/llms/chatgpt/responses/transformation.py +++ b/litellm/llms/chatgpt/responses/transformation.py @@ -66,13 +66,14 @@ class ChatGPTResponsesAPIConfig(OpenAIResponsesAPIConfig): litellm_params: GenericLiteLLMParams, headers: dict, ) -> dict: - # The Responses API accepts a string or a list, but this backend - # rejects a string with {"detail": "Input must be a list"}. - if isinstance(input, str): - input = [{"role": "user", "content": input}] # mutable-ok: the wire payload this backend requires + coerced_input: Final = ( + [{"role": "user", "content": input}] # mutable-ok: the wire shape this backend accepts + if isinstance(input, str) + else input + ) request: Final = super().transform_responses_api_request( model, - input, + coerced_input, response_api_optional_request_params, litellm_params, headers, @@ -103,8 +104,6 @@ class ChatGPTResponsesAPIConfig(OpenAIResponsesAPIConfig): "reasoning", "previous_response_id", "truncation", - # The chat-to-responses bridge translates response_format into - # "text"; dropping it here discards strict schemas silently. "text", }