From e10ff102475afe31f9df51f9ce9c047025148fd8 Mon Sep 17 00:00:00 2001 From: willcai1984 Date: Sat, 29 Aug 2026 11:11:39 +0800 Subject: [PATCH] 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 31ac7691072..42bf59b9eca 100644 --- a/litellm/llms/chatgpt/responses/transformation.py +++ b/litellm/llms/chatgpt/responses/transformation.py @@ -127,6 +127,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/test_litellm/llms/chatgpt/responses/test_chatgpt_responses_transformation.py b/tests/test_litellm/llms/chatgpt/responses/test_chatgpt_responses_transformation.py index f0ca0ad6d74..92b3c7f1117 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 @@ -133,6 +133,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", [