Skip to content

Commit ff62cdb

Browse files
authored
Codex harness: detect unsupported-model tool-schema failures with a dedicated message (#57256)
1 parent 72ca10c commit ff62cdb

2 files changed

Lines changed: 162 additions & 0 deletions

File tree

actions/setup/js/codex_harness.cjs

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,61 @@ const SERVER_ERROR_PATTERN = /InternalServerError|ServiceUnavailableError|500 In
9393
// an identical rejection: retrying only re-bills the turns that succeeded before the failure point.
9494
const INVALID_REQUEST_ERROR_PATTERN = /invalid_request_error/i;
9595

96+
// Codex's `turn.failed` event nests the actual provider error as a JSON string inside
97+
// `error.message` (sometimes doubly-nested, e.g. `error.message` -> `{"error": {...}}`).
98+
// This is a specific, common form of "unsupported model" failure: the configured model does
99+
// not support the `custom` tool type Codex uses for its `apply_patch`/freeform tool schema.
100+
// The provider rejects the whole request before any work happens, surfacing as:
101+
// {"error": {"message": "Invalid value: 'custom'", "type": "invalid_request_error",
102+
// "param": "tools", "code": "unknown_parameter"}}
103+
// This is a model-capability mismatch, not a malformed request, so it warrants a dedicated,
104+
// more actionable message than the generic invalid_request_error handling below.
105+
106+
/**
107+
* Unwraps up to a few levels of Codex's nested provider error payload to find the
108+
* innermost object that carries string `param`/`code` fields.
109+
* @param {unknown} error
110+
* @returns {{ param?: string, code?: string } | null}
111+
*/
112+
function extractNestedProviderErrorDetails(error) {
113+
const candidates = [error];
114+
for (let visited = 0; visited < 8 && candidates.length > 0; visited++) {
115+
const current = candidates.shift();
116+
if (!current || typeof current !== "object") continue;
117+
/** @type {{ param?: unknown, code?: unknown, error?: unknown, message?: unknown, metadata?: unknown }} */
118+
const candidate = current;
119+
if (typeof candidate.param === "string" && typeof candidate.code === "string") {
120+
return { param: candidate.param, code: candidate.code };
121+
}
122+
if (candidate.error && typeof candidate.error === "object") candidates.push(candidate.error);
123+
if (typeof candidate.message === "string") {
124+
const parsed = parseJsonOrUndefined(candidate.message);
125+
if (parsed !== undefined) candidates.push(parsed);
126+
}
127+
if (candidate.metadata && typeof candidate.metadata === "object") {
128+
/** @type {{ raw?: unknown }} */
129+
const metadata = candidate.metadata;
130+
if (typeof metadata.raw === "string") {
131+
const parsed = parseJsonOrUndefined(metadata.raw);
132+
if (parsed !== undefined) candidates.push(parsed);
133+
}
134+
}
135+
}
136+
return null;
137+
}
138+
139+
/**
140+
* @param {string} value
141+
* @returns {unknown}
142+
*/
143+
function parseJsonOrUndefined(value) {
144+
try {
145+
return JSON.parse(value);
146+
} catch {
147+
return undefined;
148+
}
149+
}
150+
96151
// Post-result watchdog: once the agent writes a terminal safe-output the harness
97152
// arms a watchdog timer and kills the Codex process if it is still running after
98153
// POST_RESULT_WATCHDOG_IDLE_TIMEOUT_MS of inactivity. This prevents the step from
@@ -203,6 +258,28 @@ function isInvalidRequestError(output) {
203258
});
204259
}
205260

261+
/**
262+
* Determines if Codex emitted a `turn.failed` provider event indicating the configured model
263+
* does not support Codex's required `custom` tool-calling schema (the provider rejects the
264+
* `tools` request parameter with code `unknown_parameter`). This is a model-capability mismatch
265+
* — the model itself is valid but incompatible with Codex — so it is surfaced as a dedicated,
266+
* non-retryable condition with actionable guidance rather than the generic invalid-request message.
267+
* @param {string} output - Collected stdout+stderr from the process
268+
* @returns {boolean}
269+
*/
270+
function isUnsupportedModelToolsError(output) {
271+
return output.split(/\r?\n/).some(line => {
272+
try {
273+
const event = JSON.parse(line);
274+
if (event?.type !== "turn.failed" || !event.error) return false;
275+
const details = extractNestedProviderErrorDetails(event.error);
276+
return !!details && details.param === "tools" && details.code === "unknown_parameter";
277+
} catch {
278+
return false;
279+
}
280+
});
281+
}
282+
206283
/**
207284
* Determines if the collected output shows that Codex's internal stream-reconnect
208285
* retries are exhausted (i.e., the output contains "Reconnecting... N/N" where both
@@ -778,6 +855,7 @@ async function main() {
778855
const isMissingApiKey = isMissingApiKeyError(result.output);
779856
const isServer = isServerError(result.output);
780857
const isInvalidModel = isInvalidModelError(result.output);
858+
const isUnsupportedModelTools = isUnsupportedModelToolsError(result.output);
781859
const isInvalidRequest = isInvalidRequestError(result.output);
782860
const permissionDeniedCount = countPermissionDeniedIssues(result.output);
783861
const hasNumerousPermissionDenied = hasNumerousPermissionDeniedIssues(result.output);
@@ -792,6 +870,7 @@ async function main() {
792870
` isMissingApiKeyError=${isMissingApiKey}` +
793871
` isServerError=${isServer}` +
794872
` isInvalidModelError=${isInvalidModel}` +
873+
` isUnsupportedModelToolsError=${isUnsupportedModelTools}` +
795874
` isInvalidRequestError=${isInvalidRequest}` +
796875
` permissionDeniedCount=${permissionDeniedCount}` +
797876
` hasNumerousPermissionDenied=${hasNumerousPermissionDenied}` +
@@ -849,6 +928,15 @@ async function main() {
849928
return { action: "stop" };
850929
}
851930

931+
if (isUnsupportedModelTools) {
932+
log(
933+
`attempt ${attempt + 1}: configured model does not support Codex's required tool-calling schema` +
934+
` ("tools" param rejected with code "unknown_parameter") — not retrying` +
935+
` (pick a model documented as compatible with Codex CLI, or remove the \`model:\` override in workflow frontmatter to use the engine default)`
936+
);
937+
return { action: "stop" };
938+
}
939+
852940
if (isInvalidRequest) {
853941
log(`attempt ${attempt + 1}: invalid_request_error (HTTP 400) — not retrying (the provider rejected the request payload; an identical fresh run would fail the same way)`);
854942
return { action: "stop" };
@@ -913,6 +1001,7 @@ if (typeof module !== "undefined" && module.exports) {
9131001
isMissingApiKeyError,
9141002
isServerError,
9151003
isInvalidModelError,
1004+
isUnsupportedModelToolsError,
9161005
isInvalidRequestError,
9171006
isReconnectExhaustedError,
9181007
countPermissionDeniedIssues,

actions/setup/js/codex_harness.test.cjs

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ const {
1515
isMissingApiKeyError,
1616
isServerError,
1717
isInvalidModelError,
18+
isUnsupportedModelToolsError,
1819
isInvalidRequestError,
1920
isReconnectExhaustedError,
2021
countPermissionDeniedIssues,
@@ -609,6 +610,39 @@ env_key = "OPENAI_API_KEY"
609610
});
610611
});
611612

613+
describe("isUnsupportedModelToolsError", () => {
614+
it("returns true for the observed 'tools' unknown_parameter turn.failed event", () => {
615+
const output =
616+
'{"type":"thread.started","thread_id":"01a0545e-6060-7472-9d50-d4a643611434"}\n' +
617+
'{"type":"turn.started"}\n' +
618+
String.raw`{"type":"error","message":"{\n \"error\": {\n \"message\": \"Invalid value: 'custom'\",\n \"type\": \"invalid_request_error\",\n \"param\": \"tools\",\n \"code\": \"unknown_parameter\"\n }\n}"}` +
619+
"\n" +
620+
String.raw`{"type":"turn.failed","error":{"message":"{\n \"error\": {\n \"message\": \"Invalid value: 'custom'\",\n \"type\": \"invalid_request_error\",\n \"param\": \"tools\",\n \"code\": \"unknown_parameter\"\n }\n}"}}`;
621+
expect(isUnsupportedModelToolsError(output)).toBe(true);
622+
});
623+
624+
it("returns true regardless of the order of param/code fields", () => {
625+
const output = '{"type":"turn.failed","error":{"code":"unknown_parameter","param":"tools"}}';
626+
expect(isUnsupportedModelToolsError(output)).toBe(true);
627+
});
628+
629+
it("returns true for a provider metadata.raw envelope", () => {
630+
const output = String.raw`{"type":"turn.failed","error":{"message":"{\"error\":{\"message\":\"Provider returned error\",\"code\":400,\"metadata\":{\"raw\":\"{\\\"type\\\": \\\"invalid_request_error\\\",\\n \\\"param\\\": \\\"tools\\\",\\n \\\"code\\\": \\\"unknown_parameter\\\"}\"}}}"}}`;
631+
expect(isUnsupportedModelToolsError(output)).toBe(true);
632+
});
633+
634+
it("returns false for an unrelated invalid_request_error (e.g. empty message array)", () => {
635+
const output = String.raw`{"type":"turn.failed","error":{"message":"{\"error\":{\"message\":\"Provider returned error\",\"code\":400,\"metadata\":{\"raw\":\"{\\\"type\\\": \\\"invalid_request_error\\\",\\n \\\"param\\\": \\\"messages[4].content\\\",\\n \\\"code\\\": \\\"empty_array\\\"}\"}}}"}}`;
636+
expect(isUnsupportedModelToolsError(output)).toBe(false);
637+
});
638+
639+
it("returns false for unrelated errors and empty output", () => {
640+
expect(isUnsupportedModelToolsError("rate_limit_exceeded")).toBe(false);
641+
expect(isUnsupportedModelToolsError('{"type":"item.completed","item":{"type":"tool_call_output","output":"unknown_parameter tools"}}')).toBe(false);
642+
expect(isUnsupportedModelToolsError("")).toBe(false);
643+
});
644+
});
645+
612646
describe("permission-denied classification helpers", () => {
613647
it("counts repeated permission-denied signals", () => {
614648
const output = "permission denied\npermissions denied\nEACCES: permission denied";
@@ -747,6 +781,45 @@ process.exit(1);`,
747781
expect(result.stderr).toContain("invalid_request_error (HTTP 400) — not retrying");
748782
});
749783

784+
it("does not retry the observed unsupported-model 'tools' unknown_parameter failure", () => {
785+
const tempDir = makeHarnessTempDir("codex-unsupported-model-tools-");
786+
const stubPath = path.join(tempDir, "stub.cjs");
787+
const promptPath = path.join(tempDir, "prompt.txt");
788+
const callsPath = path.join(tempDir, "calls.jsonl");
789+
fs.writeFileSync(
790+
stubPath,
791+
`const fs = require("fs");
792+
fs.appendFileSync(process.env.CODEX_HARNESS_STUB_CALLS, "called\\n");
793+
process.stderr.write(JSON.stringify({ type: "thread.started", thread_id: "01a0545e-6060-7472-9d50-d4a643611434" }) + "\\n");
794+
process.stderr.write(JSON.stringify({ type: "turn.started" }) + "\\n");
795+
const errorMessage = JSON.stringify({ error: { message: "Invalid value: 'custom'", type: "invalid_request_error", param: "tools", code: "unknown_parameter" } });
796+
process.stderr.write(JSON.stringify({ type: "error", message: errorMessage }) + "\\n");
797+
process.stderr.write(JSON.stringify({ type: "turn.failed", error: { message: errorMessage } }) + "\\n");
798+
process.exit(1);`,
799+
"utf8"
800+
);
801+
fs.writeFileSync(promptPath, "fix the bug", "utf8");
802+
803+
const result = spawnSync(process.execPath, ["codex_harness.cjs", process.execPath, stubPath, "exec", "--prompt-file", promptPath], {
804+
cwd: path.dirname(require.resolve("./codex_harness.cjs")),
805+
env: {
806+
...process.env,
807+
CODEX_HARNESS_STUB_CALLS: callsPath,
808+
CODEX_API_KEY: "fake-key-for-test",
809+
GH_AW_HARNESS_MAX_RETRIES: "1",
810+
GH_AW_HARNESS_INITIAL_DELAY_MS: "1",
811+
},
812+
encoding: "utf8",
813+
timeout: 10000,
814+
});
815+
816+
expect(fs.readFileSync(callsPath, "utf8").trim().split("\n")).toHaveLength(1);
817+
expect(result.status).toBe(1);
818+
expect(result.stderr).toContain("isUnsupportedModelToolsError=true");
819+
expect(result.stderr).toContain("configured model does not support Codex's required tool-calling schema");
820+
expect(result.stderr).toContain("not retrying");
821+
});
822+
750823
it("exits 0 when the AWF API proxy returns HTTP 403 max-AI-credits as an authentication failure", () => {
751824
// Same proxy signature as the claude_harness.test.cjs regression, replayed against codex.
752825
// CODEX_API_KEY is set so the `!isMissingApiKey` guard cannot suppress the budget path.

0 commit comments

Comments
 (0)