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
This commit is contained in:
willcai1984 2026-08-29 11:11:39 +08:00
parent 03f7a4b1f1
commit e10ff10247
2 changed files with 115 additions and 0 deletions

View file

@ -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,

View file

@ -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",
[