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;