fix(codex): use allowlist to strip non-Responses-API fields in non-passthrough path (#2608) (#2615)

Integrated into release/v3.8.3 — fix(codex): allowlist-based sanitization for gpt-5.5 Responses API
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-05-23 03:45:06 -03:00
committed by GitHub
parent ebe0b6607c
commit f52df7b824
2 changed files with 98 additions and 14 deletions

View File

@@ -1286,21 +1286,35 @@ export class CodexExecutor extends BaseExecutor {
return body;
}
// Remove unsupported parameters for Codex API
delete body.temperature;
delete body.top_p;
delete body.frequency_penalty;
delete body.presence_penalty;
delete body.logprobs;
delete body.top_logprobs;
delete body.n;
delete body.seed;
// max_tokens and max_output_tokens already deleted above (before passthrough return)
delete body.user; // Cursor sends this but Codex doesn't support it
// Issue #2608: Use an allowlist of known Responses API fields instead of a
// denylist of Chat Completions fields. The denylist approach missed fields
// like `stop`, `response_format`, `logit_bias`, `function_call`, `functions`,
// `max_completion_tokens`, and `parallel_tool_calls` — causing gpt-5.5 to
// reject with "routing_unsupported" (400). An allowlist is future-proof:
// any unknown field from Chat Completions (or other formats) is stripped.
const RESPONSES_API_ALLOWLIST = new Set([
"model",
"input",
"instructions",
"tools",
"tool_choice",
"stream",
"store",
"reasoning",
"service_tier",
"include",
"previous_response_id",
"prompt_cache_key",
"client_metadata",
// Internal markers used by OmniRoute pipeline
"_omnirouteResponsesStore",
]);
delete body.metadata; // Cursor sends this but Codex doesn't support it
delete body.stream_options; // Cursor sends this but Codex doesn't support it
delete body.safety_identifier; // Droid CLI sends this but Codex doesn't support it
for (const key of Object.keys(body)) {
if (!RESPONSES_API_ALLOWLIST.has(key)) {
delete body[key];
}
}
return body;
}

View File

@@ -216,6 +216,76 @@ test("CodexExecutor.transformRequest injects default instructions, clamps reason
assert.equal(result.stream_options, undefined);
});
// Issue #2608: gpt-5.5 models reject residual Chat Completions fields via Codex OAuth.
// The non-passthrough path must strip ALL non-Responses-API fields using an allowlist.
test("CodexExecutor.transformRequest non-passthrough allowlist strips all residual Chat Completions fields (#2608)", () => {
const executor = new CodexExecutor();
const body = {
model: "gpt-5.5",
messages: [{ role: "user", content: "hello" }],
instructions: "",
// All of these are Chat Completions fields that must be stripped:
temperature: 0.7,
top_p: 0.9,
frequency_penalty: 0.5,
presence_penalty: 0.3,
logprobs: true,
top_logprobs: 3,
n: 2,
seed: 42,
stop: ["\n"],
response_format: { type: "json_object" },
logit_bias: { "123": 1 },
function_call: "auto",
functions: [{ name: "test", parameters: {} }],
max_completion_tokens: 1000,
parallel_tool_calls: true,
user: "cursor-user",
metadata: { key: "value" },
stream_options: { include_usage: true },
safety_identifier: "safe-1",
suffix: "end",
// Custom/arbitrary fields that could be injected by middleware
custom_field: "should be stripped",
_internal_marker: true,
};
const result = executor.transformRequest("gpt-5.5", body, false, {
requestEndpointPath: "/responses",
});
// Allowed Responses API fields should survive
assert.equal(result.model, "gpt-5.5");
assert.ok(Array.isArray(result.input));
assert.equal(typeof result.instructions, "string");
assert.equal(result.store, false);
assert.equal(result.stream, true);
// All Chat Completions fields must be stripped
assert.equal(result.temperature, undefined, "temperature should be stripped");
assert.equal(result.top_p, undefined, "top_p should be stripped");
assert.equal(result.frequency_penalty, undefined, "frequency_penalty should be stripped");
assert.equal(result.presence_penalty, undefined, "presence_penalty should be stripped");
assert.equal(result.logprobs, undefined, "logprobs should be stripped");
assert.equal(result.top_logprobs, undefined, "top_logprobs should be stripped");
assert.equal(result.n, undefined, "n should be stripped");
assert.equal(result.seed, undefined, "seed should be stripped");
assert.equal(result.stop, undefined, "stop should be stripped");
assert.equal(result.response_format, undefined, "response_format should be stripped");
assert.equal(result.logit_bias, undefined, "logit_bias should be stripped");
assert.equal(result.function_call, undefined, "function_call should be stripped");
assert.equal(result.functions, undefined, "functions should be stripped");
assert.equal(result.max_completion_tokens, undefined, "max_completion_tokens should be stripped");
assert.equal(result.parallel_tool_calls, undefined, "parallel_tool_calls should be stripped");
assert.equal(result.user, undefined, "user should be stripped");
assert.equal(result.metadata, undefined, "metadata should be stripped");
assert.equal(result.stream_options, undefined, "stream_options should be stripped");
assert.equal(result.safety_identifier, undefined, "safety_identifier should be stripped");
assert.equal(result.suffix, undefined, "suffix should be stripped");
assert.equal(result.custom_field, undefined, "arbitrary custom fields should be stripped");
assert.equal(result._internal_marker, undefined, "internal markers should be stripped");
});
test("CodexExecutor.transformRequest normalizes max reasoning_effort to xhigh", () => {
const executor = new CodexExecutor();
const result = executor.transformRequest(