From d7ccd939ba3a385c5518b8df4a339f7a8359978e Mon Sep 17 00:00:00 2001 From: CharlesCai Date: Wed, 4 Nov 2026 11:41:50 +0800 Subject: [PATCH] fix(chatgpt): keep Responses string input wrapped when extra_body re-introduces it --- .../llms/chatgpt/responses/transformation.py | 30 ++++++++++ .../test_chatgpt_responses_transformation.py | 59 +++++++++++++++++++ 2 files changed, 89 insertions(+) diff --git a/litellm/llms/chatgpt/responses/transformation.py b/litellm/llms/chatgpt/responses/transformation.py index 26a5a3f150b..a309ad0e13d 100644 --- a/litellm/llms/chatgpt/responses/transformation.py +++ b/litellm/llms/chatgpt/responses/transformation.py @@ -155,6 +155,36 @@ class ChatGPTResponsesAPIConfig(OpenAIResponsesAPIConfig): filtered["service_tier"] = service_tier return filtered + def sign_request( + self, + headers: dict, # mutable-ok: mirrors the base hook signature + optional_params: dict, # mutable-ok: mirrors the base hook signature + request_data: dict, # mutable-ok: the hook contract allows in-place body fixes + 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: mirrors the base hook signature + # extra_body is merged into the payload after transform_responses_api_request + # (llm_http_handler does data.update(transform_extra_body(...)) after the + # transform), so a string input carried in extra_body re-introduces the + # shorthand the entry wrap removed and the backend 400s again. sign_request + # runs after the body is final, so re-apply the entry wrap's list form here. + request_input: Final[object] = request_data.get("input") + if isinstance(request_input, str): + request_data["input"] = [{"role": "user", "content": request_input}] # rebind-ok: in-place final-body fix + return super().sign_request( + headers=headers, + optional_params=optional_params, + request_data=request_data, + api_base=api_base, + api_key=api_key, + model=model, + stream=stream, + fake_stream=fake_stream, + ) + 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 635363e77ea..e2dde6d82dd 100644 --- a/tests/unit/llms/chatgpt/responses/test_chatgpt_responses_transformation.py +++ b/tests/unit/llms/chatgpt/responses/test_chatgpt_responses_transformation.py @@ -128,6 +128,65 @@ class TestChatGPTResponsesAPITransformation: assert isinstance(config, ChatGPTResponsesAPIConfig) assert config.custom_llm_provider == LlmProviders.CHATGPT + def test_sign_request_rewraps_string_input_reintroduced_after_transform(self): + """extra_body merges after transform_responses_api_request, so a string + input merged there re-introduces the shorthand the entry wrap removed.""" + config: Final = ChatGPTResponsesAPIConfig() + request: Final = config.transform_responses_api_request( + model="chatgpt/gpt-6.1-sol", + input="Reply with exactly the word: pong", + response_api_optional_request_params={}, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + assert request["input"] == [{"role": "user", "content": "Reply with exactly the word: pong"}] + + # llm_http_handler merges extra_body after the transform: + # data.update(config.transform_extra_body(extra_body=..., request=data, ...)) + request.update({"input": "Reply with exactly the word: pong"}) + + config.sign_request( + headers={}, + optional_params={}, + request_data=request, + api_base="https://chatgpt.com/backend-api/codex/responses", + api_key=None, + model="chatgpt/gpt-6.1-sol", + ) + assert request["input"] == [{"role": "user", "content": "Reply with exactly the word: pong"}] + + def test_sign_request_wraps_string_input_without_entry_wrap(self): + """The final signing hook also covers payload paths that never ran the + entry wrap (list input fully replaced by a string via extra_body).""" + config: Final = ChatGPTResponsesAPIConfig() + request: Final = { + "model": "chatgpt/gpt-6.1-sol", + "input": "standalone shorthand", + } + config.sign_request( + headers={}, + optional_params={}, + request_data=request, + api_base="https://chatgpt.com/backend-api/codex/responses", + api_key=None, + model="chatgpt/gpt-6.1-sol", + ) + assert request["input"] == [{"role": "user", "content": "standalone shorthand"}] + + def test_sign_request_leaves_list_input_unchanged(self): + config: Final = ChatGPTResponsesAPIConfig() + list_input: Final = [{"role": "user", "content": [{"type": "input_text", "text": "hi"}]}] + request: Final = {"model": "chatgpt/gpt-6.1-sol", "input": list_input} + config.sign_request( + headers={}, + optional_params={}, + request_data=request, + api_base="https://chatgpt.com/backend-api/codex/responses", + api_key=None, + model="chatgpt/gpt-6.1-sol", + ) + assert request["input"] == list_input + @pytest.mark.parametrize( "model_name", [