From 81b857e575d38b098b47aa809f506df3be08f0b2 Mon Sep 17 00:00:00 2001 From: Chase Cai Date: Tue, 25 Aug 2026 16:54:18 +0800 Subject: [PATCH 1/5] fix(chatgpt): normalize Responses string input --- .../llms/chatgpt/responses/transformation.py | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/litellm/llms/chatgpt/responses/transformation.py b/litellm/llms/chatgpt/responses/transformation.py index 9774b762396..ac977188574 100644 --- a/litellm/llms/chatgpt/responses/transformation.py +++ b/litellm/llms/chatgpt/responses/transformation.py @@ -45,6 +45,27 @@ class ChatGPTResponsesAPIConfig(OpenAIResponsesAPIConfig): def custom_llm_provider(self) -> LlmProviders: return LlmProviders.CHATGPT + @staticmethod + def _normalized_responses_input( + request_input: str | list[Any], # mutable-ok: outbound Responses payload is a JSON list by spec + ) -> list[Any]: # mutable-ok: same — the ChatGPT backend requires the message-list form + """Return the ChatGPT-compatible form of a Responses API ``input`` value. + + The public Responses API accepts the string shorthand; the ChatGPT OAuth + backend requires the message-list form. Non-string values are returned + unchanged. + """ + if isinstance(request_input, str): + return [ # mutable-ok: ChatGPT backend requires a JSON message list + { # mutable-ok: message payload is inherently a JSON object + "role": "user", + "content": [ # mutable-ok: ditto + {"type": "input_text", "text": request_input}, # mutable-ok: ditto + ], + } + ] + return request_input + def validate_environment( self, headers: dict, @@ -80,6 +101,9 @@ class ChatGPTResponsesAPIConfig(OpenAIResponsesAPIConfig): litellm_params, headers, ) + request["input"] = self._normalized_responses_input( + request_input=request.get("input", []), + ) base_instructions: Final = get_chatgpt_default_instructions() existing_instructions: Final = request.get("instructions") if existing_instructions: From 418752b731e85242b26237c28e5632683e128506 Mon Sep 17 00:00:00 2001 From: Chase Cai Date: Tue, 25 Aug 2026 16:54:32 +0800 Subject: [PATCH 2/5] Add ChatGPT Responses string input test --- .../llms/chatgpt/responses/transformation.py | 2 +- .../test_chatgpt_responses_transformation.py | 29 +++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/litellm/llms/chatgpt/responses/transformation.py b/litellm/llms/chatgpt/responses/transformation.py index ac977188574..377d11ff163 100644 --- a/litellm/llms/chatgpt/responses/transformation.py +++ b/litellm/llms/chatgpt/responses/transformation.py @@ -102,7 +102,7 @@ class ChatGPTResponsesAPIConfig(OpenAIResponsesAPIConfig): headers, ) request["input"] = self._normalized_responses_input( - request_input=request.get("input", []), + request_input=request.get("input", []), # mutable-ok: fallback is an empty Responses input list ) base_instructions: Final = get_chatgpt_default_instructions() existing_instructions: Final = request.get("instructions") 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 0b04dd0ed78..6324929bd35 100644 --- a/tests/unit/llms/chatgpt/responses/test_chatgpt_responses_transformation.py +++ b/tests/unit/llms/chatgpt/responses/test_chatgpt_responses_transformation.py @@ -142,9 +142,38 @@ class TestChatGPTResponsesAPITransformation: ) assert request["stream"] is True + assert request["input"] == [ + { + "role": "user", + "content": [{"type": "input_text", "text": "hi"}], + } + ] assert "reasoning.encrypted_content" in request["include"] assert request["instructions"].startswith("You are Codex, based on GPT-5.") + def test_chatgpt_normalizes_string_input_with_tools(self): + config = ChatGPTResponsesAPIConfig() + tools = [{"type": "function", "name": "echo", "parameters": {}}] + request = config.transform_responses_api_request( + model="chatgpt/gpt-5.3-codex", + input="Call echo with ping.", + response_api_optional_request_params={ + "tools": tools, + "tool_choice": "required", + }, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + + assert request["input"] == [ + { + "role": "user", + "content": [{"type": "input_text", "text": "Call echo with ping."}], + } + ] + assert request["tools"] == tools + assert request["tool_choice"] == "required" + @pytest.mark.parametrize( "model_name", [ From 0e91bdecd479bcd050f83d16070c6e2e98b72a03 Mon Sep 17 00:00:00 2001 From: willcai1984 Date: Sat, 29 Aug 2026 11:11:39 +0800 Subject: [PATCH 3/5] fix(chatgpt): re-normalize Responses string input after extra_body merge The sync/async Responses HTTP handlers merge extra_body into the transformed request AFTER transform_responses_api_request and call sign_request as the last hook before sending, so an extra_body input string can bypass the transform-level normalization and reach the ChatGPT backend in the plain-string shorthand it rejects. Add a sign_request override that re-runs _normalized_responses_input on the final request_data["input"] as the last line of defense, returning (headers, None) since ChatGPT needs no request signing. Regression tests mirror the handler order (transform -> extra_body merge -> sign_request) and cover list passthrough, the no-input no-op, and tools/tool_choice preservation. code-generated-by-manual/ai: true --- .../llms/chatgpt/responses/transformation.py | 28 ++++++ .../test_chatgpt_responses_transformation.py | 87 +++++++++++++++++++ 2 files changed, 115 insertions(+) diff --git a/litellm/llms/chatgpt/responses/transformation.py b/litellm/llms/chatgpt/responses/transformation.py index 377d11ff163..2549bc60aa6 100644 --- a/litellm/llms/chatgpt/responses/transformation.py +++ b/litellm/llms/chatgpt/responses/transformation.py @@ -134,6 +134,34 @@ class ChatGPTResponsesAPIConfig(OpenAIResponsesAPIConfig): return {k: v for k, v in request.items() if k in allowed_keys} + def sign_request( + self, + headers: dict, # mutable-ok: signature is pinned by BaseResponsesAPIConfig.sign_request + optional_params: dict, # mutable-ok: ditto + request_data: dict, # mutable-ok: ditto + api_base: str, + api_key: str | None = None, + model: str | None = None, + stream: bool | None = None, + fake_stream: bool | None = None, + ) -> tuple[dict, bytes | None]: # mutable-ok: signature is pinned by BaseResponsesAPIConfig.sign_request + """Re-normalize ``input`` after the handler's ``extra_body`` merge. + + The HTTP handlers merge ``extra_body`` into the transformed request *after* + ``transform_responses_api_request`` and call this hook last, right before the + body is sent — so ``extra_body`` can reintroduce the plain-string ``input`` + shorthand that the transform already normalized away. Re-run the normalizer + here as the final defense. No signing is needed: the ChatGPT backend takes a + bearer token, so this only repairs the body and returns it unsigned (the + handlers send ``request_data`` itself as JSON when signed body bytes are + ``None``). + """ + current_input: Final = request_data.get("input") + if isinstance(current_input, str): + normalized: Final = self._normalized_responses_input(request_input=current_input) + request_data["input"] = normalized # rebind-ok: handler sends this same dict as the outbound JSON body + return headers, None + def transform_response_api_response( self, model: str, 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 6324929bd35..6c4c683d26f 100644 --- a/tests/unit/llms/chatgpt/responses/test_chatgpt_responses_transformation.py +++ b/tests/unit/llms/chatgpt/responses/test_chatgpt_responses_transformation.py @@ -174,6 +174,93 @@ class TestChatGPTResponsesAPITransformation: assert request["tools"] == tools assert request["tool_choice"] == "required" + def test_sign_request_repairs_string_input_after_extra_body_merge(self): + """sign_request must be the last line of defense against extra_body. + + The HTTP handlers merge ``extra_body`` into the request AFTER + ``transform_responses_api_request`` and call ``sign_request`` last, so a + string ``input`` arriving via ``extra_body`` bypasses the transform-level + normalization. Mirror the handler order here: transform -> extra_body + merge -> sign_request. + """ + config = ChatGPTResponsesAPIConfig() + tools = [{"type": "function", "name": "echo", "parameters": {}}] + extra_body = { + "input": "hello from extra_body", + "tools": tools, + "tool_choice": "required", + } + + data = config.transform_responses_api_request( + model="chatgpt/gpt-5.3-codex", + input="original input", + response_api_optional_request_params={}, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + # Same merge the sync/async Responses HTTP handlers perform (data.update(extra_body)) + data.update(extra_body) + assert data["input"] == "hello from extra_body" # transform normalization was overridden + + headers = {"Authorization": "Bearer token", "session_id": "session-123"} + returned_headers, signed_body = config.sign_request( + headers=headers, + optional_params={}, + request_data=data, + api_base="https://chatgpt.example.com/responses", + ) + + assert data["input"] == [ + { + "role": "user", + "content": [ + {"type": "input_text", "text": "hello from extra_body"}, + ], + } + ] + # tools / tool_choice survive the repair untouched + assert data["tools"] == tools + assert data["tool_choice"] == "required" + # no request signing for ChatGPT: headers pass through, body stays JSON-serialized by the handler + assert returned_headers == headers + assert signed_body is None + + def test_sign_request_keeps_list_input_unchanged(self): + config = ChatGPTResponsesAPIConfig() + list_input = [ + { + "role": "user", + "content": [{"type": "input_text", "text": "already normalized"}], + } + ] + data = {"input": list_input, "model": "gpt-5.3-codex"} + + returned_headers, signed_body = config.sign_request( + headers={"content-type": "application/json"}, + optional_params={}, + request_data=data, + api_base="https://chatgpt.example.com/responses", + ) + + assert data["input"] is list_input # same object, not rebuilt + assert returned_headers == {"content-type": "application/json"} + assert signed_body is None + + def test_sign_request_noop_without_input(self): + config = ChatGPTResponsesAPIConfig() + data = {"model": "gpt-5.3-codex", "stream": True} + + returned_headers, signed_body = config.sign_request( + headers={}, + optional_params={}, + request_data=data, + api_base="https://chatgpt.example.com/responses", + ) + + assert data == {"model": "gpt-5.3-codex", "stream": True} + assert returned_headers == {} + assert signed_body is None + @pytest.mark.parametrize( "model_name", [ From 3aeca94708e20ea516e6babd9c339f1f116347fc Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Mon, 5 Oct 2026 16:02:29 -0700 Subject: [PATCH 4/5] fix(chatgpt): inline the string input normalization and drop the extra_body re-normalization --- .../llms/chatgpt/responses/transformation.py | 54 +--------- .../test_chatgpt_responses_transformation.py | 101 +++--------------- 2 files changed, 17 insertions(+), 138 deletions(-) diff --git a/litellm/llms/chatgpt/responses/transformation.py b/litellm/llms/chatgpt/responses/transformation.py index 41c169574fc..8c0261cd1f9 100644 --- a/litellm/llms/chatgpt/responses/transformation.py +++ b/litellm/llms/chatgpt/responses/transformation.py @@ -47,27 +47,6 @@ class ChatGPTResponsesAPIConfig(OpenAIResponsesAPIConfig): def custom_llm_provider(self) -> LlmProviders: return LlmProviders.CHATGPT - @staticmethod - def _normalized_responses_input( - request_input: str | list[Any], # mutable-ok: outbound Responses payload is a JSON list by spec - ) -> list[Any]: # mutable-ok: same — the ChatGPT backend requires the message-list form - """Return the ChatGPT-compatible form of a Responses API ``input`` value. - - The public Responses API accepts the string shorthand; the ChatGPT OAuth - backend requires the message-list form. Non-string values are returned - unchanged. - """ - if isinstance(request_input, str): - return [ # mutable-ok: ChatGPT backend requires a JSON message list - { # mutable-ok: message payload is inherently a JSON object - "role": "user", - "content": [ # mutable-ok: ditto - {"type": "input_text", "text": request_input}, # mutable-ok: ditto - ], - } - ] - return request_input - def validate_environment( self, headers: dict, @@ -103,9 +82,8 @@ class ChatGPTResponsesAPIConfig(OpenAIResponsesAPIConfig): litellm_params, headers, ) - request["input"] = self._normalized_responses_input( - request_input=request.get("input", []), # mutable-ok: fallback is an empty Responses input list - ) + if isinstance(input, str): + request["input"] = [{"role": "user", "content": [{"type": "input_text", "text": input}]}] base_instructions: Final = get_chatgpt_default_instructions() existing_instructions: Final = request.get("instructions") if existing_instructions: @@ -140,34 +118,6 @@ class ChatGPTResponsesAPIConfig(OpenAIResponsesAPIConfig): filtered["service_tier"] = service_tier return filtered - def sign_request( - self, - headers: dict, # mutable-ok: signature is pinned by BaseResponsesAPIConfig.sign_request - optional_params: dict, # mutable-ok: ditto - request_data: dict, # mutable-ok: ditto - api_base: str, - api_key: str | None = None, - model: str | None = None, - stream: bool | None = None, - fake_stream: bool | None = None, - ) -> tuple[dict, bytes | None]: # mutable-ok: signature is pinned by BaseResponsesAPIConfig.sign_request - """Re-normalize ``input`` after the handler's ``extra_body`` merge. - - The HTTP handlers merge ``extra_body`` into the transformed request *after* - ``transform_responses_api_request`` and call this hook last, right before the - body is sent — so ``extra_body`` can reintroduce the plain-string ``input`` - shorthand that the transform already normalized away. Re-run the normalizer - here as the final defense. No signing is needed: the ChatGPT backend takes a - bearer token, so this only repairs the body and returns it unsigned (the - handlers send ``request_data`` itself as JSON when signed body bytes are - ``None``). - """ - current_input: Final = request_data.get("input") - if isinstance(current_input, str): - normalized: Final = self._normalized_responses_input(request_input=current_input) - request_data["input"] = normalized # rebind-ok: handler sends this same dict as the outbound JSON body - return headers, None - def transform_response_api_response( self, model: str, 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 3c41a17dcfd..f7a8033e2dd 100644 --- a/tests/unit/llms/chatgpt/responses/test_chatgpt_responses_transformation.py +++ b/tests/unit/llms/chatgpt/responses/test_chatgpt_responses_transformation.py @@ -187,11 +187,11 @@ class TestChatGPTResponsesAPITransformation: assert "reasoning.encrypted_content" in request["include"] assert request["instructions"].startswith("You are Codex, based on GPT-5.") - def test_chatgpt_normalizes_string_input_with_tools(self): - config = ChatGPTResponsesAPIConfig() - tools = [{"type": "function", "name": "echo", "parameters": {}}] - request = config.transform_responses_api_request( - model="chatgpt/gpt-5.3-codex", + def test_chatgpt_normalizes_string_input_with_tools(self) -> None: + config: Final = ChatGPTResponsesAPIConfig() + tools: Final = [{"type": "function", "name": "echo", "parameters": {}}] + request: Final = config.transform_responses_api_request( + model="chatgpt/gpt-6.1-sol", input="Call echo with ping.", response_api_optional_request_params={ "tools": tools, @@ -210,92 +210,21 @@ class TestChatGPTResponsesAPITransformation: assert request["tools"] == tools assert request["tool_choice"] == "required" - def test_sign_request_repairs_string_input_after_extra_body_merge(self): - """sign_request must be the last line of defense against extra_body. - - The HTTP handlers merge ``extra_body`` into the request AFTER - ``transform_responses_api_request`` and call ``sign_request`` last, so a - string ``input`` arriving via ``extra_body`` bypasses the transform-level - normalization. Mirror the handler order here: transform -> extra_body - merge -> sign_request. - """ - config = ChatGPTResponsesAPIConfig() - tools = [{"type": "function", "name": "echo", "parameters": {}}] - extra_body = { - "input": "hello from extra_body", - "tools": tools, - "tool_choice": "required", - } - - data = config.transform_responses_api_request( - model="chatgpt/gpt-5.3-codex", - input="original input", + def test_chatgpt_keeps_list_input_unchanged(self) -> None: + config: Final = ChatGPTResponsesAPIConfig() + list_input: Final = [ + {"role": "user", "content": [{"type": "input_text", "text": "first"}]}, + {"type": "function_call_output", "call_id": "call_1", "output": "done"}, + ] + request: Final = config.transform_responses_api_request( + model="chatgpt/gpt-6.1-sol", + input=list_input, response_api_optional_request_params={}, litellm_params=GenericLiteLLMParams(), headers={}, ) - # Same merge the sync/async Responses HTTP handlers perform (data.update(extra_body)) - data.update(extra_body) - assert data["input"] == "hello from extra_body" # transform normalization was overridden - headers = {"Authorization": "Bearer token", "session_id": "session-123"} - returned_headers, signed_body = config.sign_request( - headers=headers, - optional_params={}, - request_data=data, - api_base="https://chatgpt.example.com/responses", - ) - - assert data["input"] == [ - { - "role": "user", - "content": [ - {"type": "input_text", "text": "hello from extra_body"}, - ], - } - ] - # tools / tool_choice survive the repair untouched - assert data["tools"] == tools - assert data["tool_choice"] == "required" - # no request signing for ChatGPT: headers pass through, body stays JSON-serialized by the handler - assert returned_headers == headers - assert signed_body is None - - def test_sign_request_keeps_list_input_unchanged(self): - config = ChatGPTResponsesAPIConfig() - list_input = [ - { - "role": "user", - "content": [{"type": "input_text", "text": "already normalized"}], - } - ] - data = {"input": list_input, "model": "gpt-5.3-codex"} - - returned_headers, signed_body = config.sign_request( - headers={"content-type": "application/json"}, - optional_params={}, - request_data=data, - api_base="https://chatgpt.example.com/responses", - ) - - assert data["input"] is list_input # same object, not rebuilt - assert returned_headers == {"content-type": "application/json"} - assert signed_body is None - - def test_sign_request_noop_without_input(self): - config = ChatGPTResponsesAPIConfig() - data = {"model": "gpt-5.3-codex", "stream": True} - - returned_headers, signed_body = config.sign_request( - headers={}, - optional_params={}, - request_data=data, - api_base="https://chatgpt.example.com/responses", - ) - - assert data == {"model": "gpt-5.3-codex", "stream": True} - assert returned_headers == {} - assert signed_body is None + assert request["input"] == list_input @pytest.mark.parametrize( "model_name", From dfd16b0b50e117106d808080f59143556daf9c2f Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Mon, 5 Oct 2026 16:37:38 -0700 Subject: [PATCH 5/5] fix(chatgpt): leave an empty string input for the backend to reject OpenAI's Responses API answers 400 for input "", so wrapping it into a one-message list would turn that error into a billed completion on the Codex backend. Only a non-empty string becomes the user message. --- litellm/llms/chatgpt/responses/transformation.py | 2 +- .../test_chatgpt_responses_transformation.py | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/litellm/llms/chatgpt/responses/transformation.py b/litellm/llms/chatgpt/responses/transformation.py index 8c0261cd1f9..db5e43f65bc 100644 --- a/litellm/llms/chatgpt/responses/transformation.py +++ b/litellm/llms/chatgpt/responses/transformation.py @@ -82,7 +82,7 @@ class ChatGPTResponsesAPIConfig(OpenAIResponsesAPIConfig): litellm_params, headers, ) - if isinstance(input, str): + if isinstance(input, str) and input != "": request["input"] = [{"role": "user", "content": [{"type": "input_text", "text": input}]}] base_instructions: Final = get_chatgpt_default_instructions() existing_instructions: Final = request.get("instructions") 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 f7a8033e2dd..f505ceb8da4 100644 --- a/tests/unit/llms/chatgpt/responses/test_chatgpt_responses_transformation.py +++ b/tests/unit/llms/chatgpt/responses/test_chatgpt_responses_transformation.py @@ -226,6 +226,18 @@ class TestChatGPTResponsesAPITransformation: assert request["input"] == list_input + def test_chatgpt_leaves_empty_string_input_for_the_backend_to_reject(self) -> None: + config: Final = ChatGPTResponsesAPIConfig() + request: Final = config.transform_responses_api_request( + model="chatgpt/gpt-6.1-sol", + input="", + response_api_optional_request_params={}, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + + assert request["input"] == "" + @pytest.mark.parametrize( "model_name", [