From c4313ce7856646fc396577c897f8fab1a4a264d7 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Tue, 29 Sep 2026 15:05:05 -0700 Subject: [PATCH] fix(inference): surface Codex nested 400 diagnostics The harness classifies Codex HTTP 400s as fatal with statusText "Bad Request" while the real diagnostic sits on nested detail.error. Lift that copy for known Codex providers without reclassifying. --- src/auth/codex/usage-limit-error.test.ts | 42 ++++++++ src/auth/codex/usage-limit-error.ts | 13 +++ src/inference-gateway-error.test.ts | 119 +++++++++++++++++++++++ src/inference-gateway-error.ts | 41 +++++++- 4 files changed, 213 insertions(+), 2 deletions(-) diff --git a/src/auth/codex/usage-limit-error.test.ts b/src/auth/codex/usage-limit-error.test.ts index e7a04d351..b6b8e7d46 100644 --- a/src/auth/codex/usage-limit-error.test.ts +++ b/src/auth/codex/usage-limit-error.test.ts @@ -5,6 +5,7 @@ import { formatCodexUsageLimitMessage, formatResetETA, parseCodexUsageLimitError, + readCodexNestedErrorMessage, } from "./usage-limit-error.js"; /** Live body captured from chatgpt.com/backend-api/codex/responses. */ @@ -19,6 +20,47 @@ const LIVE_USAGE_LIMIT_BODY = { }, }; +describe("readCodexNestedErrorMessage", () => { + const nestedInvalidRequest = { + detail: { + error: { + code: "invalid_request_error", + message: "invalid reasoning.effort", + }, + }, + }; + + test("returns the nested message even when the code is not usage_limit", () => { + expect(readCodexNestedErrorMessage(nestedInvalidRequest)).toBe( + "invalid reasoning.effort", + ); + }); + + test("parses a JSON string of the same nested shape", () => { + expect( + readCodexNestedErrorMessage(JSON.stringify(nestedInvalidRequest)), + ).toBe("invalid reasoning.effort"); + }); + + test("returns undefined when the nested message is empty or missing", () => { + expect( + readCodexNestedErrorMessage({ + detail: { error: { code: "invalid_request_error", message: "" } }, + }), + ).toBeUndefined(); + expect( + readCodexNestedErrorMessage({ + detail: { error: { code: "invalid_request_error" } }, + }), + ).toBeUndefined(); + }); + + test("returns undefined for unrelated bodies", () => { + expect(readCodexNestedErrorMessage({ ok: true })).toBeUndefined(); + expect(readCodexNestedErrorMessage(undefined)).toBeUndefined(); + }); +}); + describe("parseCodexUsageLimitError", () => { test("parses the live nested detail.error body", () => { const parsed = parseCodexUsageLimitError(LIVE_USAGE_LIMIT_BODY); diff --git a/src/auth/codex/usage-limit-error.ts b/src/auth/codex/usage-limit-error.ts index d4593dabe..be23019d7 100644 --- a/src/auth/codex/usage-limit-error.ts +++ b/src/auth/codex/usage-limit-error.ts @@ -68,6 +68,19 @@ function readErrorNode(body: unknown): Record | undefined { return undefined; } +/** + * Nested Codex `detail.error.message` when the harness left it on `raw` while + * classifying the HTTP statusText. Unlike `parseCodexUsageLimitError`, this + * does not require a usage-limit code — any non-empty nested message qualifies. + */ +export function readCodexNestedErrorMessage(raw: unknown): string | undefined { + const node = readErrorNode(coerceBody(raw)); + if (node === undefined) return undefined; + const message = node["message"]; + if (typeof message !== "string" || message.length === 0) return undefined; + return message; +} + /** * Returns a structured usage-limit error when `raw` matches the Codex body. * Undefined for unrelated payloads (including other providers' quota 429s). diff --git a/src/inference-gateway-error.test.ts b/src/inference-gateway-error.test.ts index 6d3acf606..8fe186bd2 100644 --- a/src/inference-gateway-error.test.ts +++ b/src/inference-gateway-error.test.ts @@ -440,6 +440,125 @@ describe("normalizeInferenceErrorForRetry", () => { expect(normalizeInferenceErrorForRetry(error)).toBe(error); }); + // Live Codex 400: harness classifies HTTP 400 as fatal with statusText + // "Bad Request" while the nested diagnostic rides on raw. The lift must + // surface that diagnostic without reclassifying the category. + const CODEX_FATAL_400_NESTED_RAW = { + detail: { + error: { + code: "invalid_request_error", + message: "invalid reasoning.effort", + }, + }, + }; + + test("lifts a Codex fatal 400 nested diagnostic over Bad Request", () => { + const raw = CODEX_FATAL_400_NESTED_RAW; + const normalized = normalizeInferenceErrorForRetry({ + category: "fatal", + message: "Bad Request", + statusCode: 400, + providerId: "codex/work", + raw, + }); + expect(normalized.category).toBe("fatal"); + expect(normalized.message).toBe("invalid reasoning.effort"); + expect(normalized.statusCode).toBe(400); + expect(normalized.raw).toBe(raw); + }); + + test("lifts a Codex fatal 400 when raw is a JSON string of the nested shape", () => { + const normalized = normalizeInferenceErrorForRetry({ + category: "fatal", + message: "Bad Request", + statusCode: 400, + providerId: "codex/work", + raw: JSON.stringify(CODEX_FATAL_400_NESTED_RAW), + }); + expect(normalized.category).toBe("fatal"); + expect(normalized.message).toBe("invalid reasoning.effort"); + }); + + test("keeps an already-real Codex 400 message even when nested copy exists", () => { + const error = { + category: "fatal" as const, + message: "invalid reasoning.effort", + statusCode: 400, + providerId: "codex/work", + raw: CODEX_FATAL_400_NESTED_RAW, + }; + expect(normalizeInferenceErrorForRetry(error)).toBe(error); + }); + + test("does not lift a non-Codex fatal 400 nested diagnostic", () => { + const error = { + category: "fatal" as const, + message: "Bad Request", + statusCode: 400, + providerId: "openai", + raw: CODEX_FATAL_400_NESTED_RAW, + }; + expect(normalizeInferenceErrorForRetry(error)).toBe(error); + }); + + test("does not lift a Codex-shaped fatal 400 without a providerId", () => { + const error = { + category: "fatal" as const, + message: "Bad Request", + statusCode: 400, + raw: CODEX_FATAL_400_NESTED_RAW, + }; + expect(normalizeInferenceErrorForRetry(error)).toBe(error); + }); + + test("does not lift a Codex fatal 400 when the nested message is empty", () => { + const error = { + category: "fatal" as const, + message: "Bad Request", + statusCode: 400, + providerId: "codex/work", + raw: { + detail: { error: { code: "invalid_request_error", message: "" } }, + }, + }; + expect(normalizeInferenceErrorForRetry(error)).toBe(error); + }); + + test("does not lift a Codex fatal 404 even with a nested diagnostic", () => { + const error = { + category: "fatal" as const, + message: "Not Found", + statusCode: 404, + providerId: "codex/work", + raw: CODEX_FATAL_400_NESTED_RAW, + }; + expect(normalizeInferenceErrorForRetry(error)).toBe(error); + }); + + test("usage-limit 429 still formats as quota_exhausted after the 400 lift", () => { + const normalized = normalizeInferenceErrorForRetry({ + category: "quota_exhausted", + message: "Too Many Requests", + statusCode: 429, + providerId: "codex/acme-labs", + raw: CODEX_USAGE_LIMIT_BODY, + }); + expect(normalized.category).toBe("quota_exhausted"); + expect(normalized.message).toContain('Codex profile "acme-labs"'); + }); + + test("credential 404 still reclassifies after the 400 lift", () => { + const normalized = normalizeInferenceErrorForRetry({ + category: "fatal", + message: "Not Found", + statusCode: 404, + providerId: "codex/work", + raw: REVOKED_CREDENTIAL_404_RAW, + }); + expect(normalized.category).toBe("credential_failure"); + expect(carriesCodexReLoginHint(normalized.message)).toBe(true); + }); + test.each([ { name: "message-only capacity protocol error", diff --git a/src/inference-gateway-error.ts b/src/inference-gateway-error.ts index 9024cbd6f..d0bafffb5 100644 --- a/src/inference-gateway-error.ts +++ b/src/inference-gateway-error.ts @@ -8,6 +8,7 @@ import { codexUsageLimitRetryAfterMs, formatCodexUsageLimitMessage, parseCodexUsageLimitError, + readCodexNestedErrorMessage, } from "./auth/codex/usage-limit-error.js"; import { codexProfileFromProviderName, @@ -633,14 +634,47 @@ function normalizeCodexCredential404Error( }; } +/** + * Codex 400s arrive as fatal with HTTP statusText ("Bad Request") while the + * real diagnostic sits on nested `detail.error`. Lift the nested copy so the + * transcript is actionable; keep category fatal — this is not overflow, + * rate-limit, or quota. + */ +function normalizeCodexFatal400Message( + error: InferenceErrorWithGoContext, +): InferenceError { + if (!isKnownCodexProviderId(error.providerId)) return error; + if (error.category !== "fatal") return error; + if (error.statusCode !== 400) return error; + + const current = error.message ?? ""; + if (current.length > 0 && current.toLowerCase() !== "bad request") { + return error; + } + + const nested = readCodexNestedErrorMessage(error.raw); + if (nested === undefined || nested === current) return error; + + return { + category: "fatal", + message: nested, + statusCode: 400, + ...(error.raw !== undefined ? { raw: error.raw } : {}), + ...(error.retryAfterMs !== undefined + ? { retryAfterMs: error.retryAfterMs } + : {}), + }; +} + /** * Reclassify gateway overload errors so the default retry policy treats them as * transient instead of aborting on protocol_mismatch. Also normalizes OpenCode * Go quota/rate-limit shapes (including HTTP 400 mis-status), known-xAI short * 429s, attributable xAI capacity protocol_mismatch, Codex usage limits * (nested detail.error with resets_in_seconds), known-Codex short 429s that - * are not usage_limit_reached, and known-Codex 404s carrying an - * auth-rejection signal (expired/revoked credential). + * are not usage_limit_reached, known-Codex 404s carrying an + * auth-rejection signal (expired/revoked credential), and known-Codex fatal + * 400s whose message is empty or "Bad Request" (nested diagnostic on raw). */ export function normalizeInferenceErrorForRetry( error: InferenceErrorWithGoContext, @@ -663,6 +697,9 @@ export function normalizeInferenceErrorForRetry( const codexCredential = normalizeCodexCredential404Error(error); if (codexCredential !== error) return codexCredential; + const codexFatal400 = normalizeCodexFatal400Message(error); + if (codexFatal400 !== error) return codexFatal400; + if (!isGatewayOverloadInferenceError(error)) return error; if (error.category === "retryable" || error.category === "timeout") return error;