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 <alexander@chernov.it>
This commit is contained in:
Alexander Chernov 2026-08-24 16:03:00 +01:00
parent f005afa146
commit 9aaf01d8ea
No known key found for this signature in database
2 changed files with 98 additions and 0 deletions

View file

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

View file

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