Skip to content

Commit 995ec36

Browse files
fix(inference): surface Codex nested 400 diagnostics (#1228)
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.
1 parent 56044df commit 995ec36

4 files changed

Lines changed: 213 additions & 2 deletions

File tree

‎src/auth/codex/usage-limit-error.test.ts‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
formatCodexUsageLimitMessage,
66
formatResetETA,
77
parseCodexUsageLimitError,
8+
readCodexNestedErrorMessage,
89
} from "./usage-limit-error.js";
910

1011
/** Live body captured from chatgpt.com/backend-api/codex/responses. */
@@ -19,6 +20,47 @@ const LIVE_USAGE_LIMIT_BODY = {
1920
},
2021
};
2122

23+
describe("readCodexNestedErrorMessage", () => {
24+
const nestedInvalidRequest = {
25+
detail: {
26+
error: {
27+
code: "invalid_request_error",
28+
message: "invalid reasoning.effort",
29+
},
30+
},
31+
};
32+
33+
test("returns the nested message even when the code is not usage_limit", () => {
34+
expect(readCodexNestedErrorMessage(nestedInvalidRequest)).toBe(
35+
"invalid reasoning.effort",
36+
);
37+
});
38+
39+
test("parses a JSON string of the same nested shape", () => {
40+
expect(
41+
readCodexNestedErrorMessage(JSON.stringify(nestedInvalidRequest)),
42+
).toBe("invalid reasoning.effort");
43+
});
44+
45+
test("returns undefined when the nested message is empty or missing", () => {
46+
expect(
47+
readCodexNestedErrorMessage({
48+
detail: { error: { code: "invalid_request_error", message: "" } },
49+
}),
50+
).toBeUndefined();
51+
expect(
52+
readCodexNestedErrorMessage({
53+
detail: { error: { code: "invalid_request_error" } },
54+
}),
55+
).toBeUndefined();
56+
});
57+
58+
test("returns undefined for unrelated bodies", () => {
59+
expect(readCodexNestedErrorMessage({ ok: true })).toBeUndefined();
60+
expect(readCodexNestedErrorMessage(undefined)).toBeUndefined();
61+
});
62+
});
63+
2264
describe("parseCodexUsageLimitError", () => {
2365
test("parses the live nested detail.error body", () => {
2466
const parsed = parseCodexUsageLimitError(LIVE_USAGE_LIMIT_BODY);

‎src/auth/codex/usage-limit-error.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,19 @@ function readErrorNode(body: unknown): Record<string, unknown> | undefined {
6868
return undefined;
6969
}
7070

71+
/**
72+
* Nested Codex `detail.error.message` when the harness left it on `raw` while
73+
* classifying the HTTP statusText. Unlike `parseCodexUsageLimitError`, this
74+
* does not require a usage-limit code — any non-empty nested message qualifies.
75+
*/
76+
export function readCodexNestedErrorMessage(raw: unknown): string | undefined {
77+
const node = readErrorNode(coerceBody(raw));
78+
if (node === undefined) return undefined;
79+
const message = node["message"];
80+
if (typeof message !== "string" || message.length === 0) return undefined;
81+
return message;
82+
}
83+
7184
/**
7285
* Returns a structured usage-limit error when `raw` matches the Codex body.
7386
* Undefined for unrelated payloads (including other providers' quota 429s).

‎src/inference-gateway-error.test.ts‎

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,125 @@ describe("normalizeInferenceErrorForRetry", () => {
440440
expect(normalizeInferenceErrorForRetry(error)).toBe(error);
441441
});
442442

443+
// Live Codex 400: harness classifies HTTP 400 as fatal with statusText
444+
// "Bad Request" while the nested diagnostic rides on raw. The lift must
445+
// surface that diagnostic without reclassifying the category.
446+
const CODEX_FATAL_400_NESTED_RAW = {
447+
detail: {
448+
error: {
449+
code: "invalid_request_error",
450+
message: "invalid reasoning.effort",
451+
},
452+
},
453+
};
454+
455+
test("lifts a Codex fatal 400 nested diagnostic over Bad Request", () => {
456+
const raw = CODEX_FATAL_400_NESTED_RAW;
457+
const normalized = normalizeInferenceErrorForRetry({
458+
category: "fatal",
459+
message: "Bad Request",
460+
statusCode: 400,
461+
providerId: "codex/work",
462+
raw,
463+
});
464+
expect(normalized.category).toBe("fatal");
465+
expect(normalized.message).toBe("invalid reasoning.effort");
466+
expect(normalized.statusCode).toBe(400);
467+
expect(normalized.raw).toBe(raw);
468+
});
469+
470+
test("lifts a Codex fatal 400 when raw is a JSON string of the nested shape", () => {
471+
const normalized = normalizeInferenceErrorForRetry({
472+
category: "fatal",
473+
message: "Bad Request",
474+
statusCode: 400,
475+
providerId: "codex/work",
476+
raw: JSON.stringify(CODEX_FATAL_400_NESTED_RAW),
477+
});
478+
expect(normalized.category).toBe("fatal");
479+
expect(normalized.message).toBe("invalid reasoning.effort");
480+
});
481+
482+
test("keeps an already-real Codex 400 message even when nested copy exists", () => {
483+
const error = {
484+
category: "fatal" as const,
485+
message: "invalid reasoning.effort",
486+
statusCode: 400,
487+
providerId: "codex/work",
488+
raw: CODEX_FATAL_400_NESTED_RAW,
489+
};
490+
expect(normalizeInferenceErrorForRetry(error)).toBe(error);
491+
});
492+
493+
test("does not lift a non-Codex fatal 400 nested diagnostic", () => {
494+
const error = {
495+
category: "fatal" as const,
496+
message: "Bad Request",
497+
statusCode: 400,
498+
providerId: "openai",
499+
raw: CODEX_FATAL_400_NESTED_RAW,
500+
};
501+
expect(normalizeInferenceErrorForRetry(error)).toBe(error);
502+
});
503+
504+
test("does not lift a Codex-shaped fatal 400 without a providerId", () => {
505+
const error = {
506+
category: "fatal" as const,
507+
message: "Bad Request",
508+
statusCode: 400,
509+
raw: CODEX_FATAL_400_NESTED_RAW,
510+
};
511+
expect(normalizeInferenceErrorForRetry(error)).toBe(error);
512+
});
513+
514+
test("does not lift a Codex fatal 400 when the nested message is empty", () => {
515+
const error = {
516+
category: "fatal" as const,
517+
message: "Bad Request",
518+
statusCode: 400,
519+
providerId: "codex/work",
520+
raw: {
521+
detail: { error: { code: "invalid_request_error", message: "" } },
522+
},
523+
};
524+
expect(normalizeInferenceErrorForRetry(error)).toBe(error);
525+
});
526+
527+
test("does not lift a Codex fatal 404 even with a nested diagnostic", () => {
528+
const error = {
529+
category: "fatal" as const,
530+
message: "Not Found",
531+
statusCode: 404,
532+
providerId: "codex/work",
533+
raw: CODEX_FATAL_400_NESTED_RAW,
534+
};
535+
expect(normalizeInferenceErrorForRetry(error)).toBe(error);
536+
});
537+
538+
test("usage-limit 429 still formats as quota_exhausted after the 400 lift", () => {
539+
const normalized = normalizeInferenceErrorForRetry({
540+
category: "quota_exhausted",
541+
message: "Too Many Requests",
542+
statusCode: 429,
543+
providerId: "codex/acme-labs",
544+
raw: CODEX_USAGE_LIMIT_BODY,
545+
});
546+
expect(normalized.category).toBe("quota_exhausted");
547+
expect(normalized.message).toContain('Codex profile "acme-labs"');
548+
});
549+
550+
test("credential 404 still reclassifies after the 400 lift", () => {
551+
const normalized = normalizeInferenceErrorForRetry({
552+
category: "fatal",
553+
message: "Not Found",
554+
statusCode: 404,
555+
providerId: "codex/work",
556+
raw: REVOKED_CREDENTIAL_404_RAW,
557+
});
558+
expect(normalized.category).toBe("credential_failure");
559+
expect(carriesCodexReLoginHint(normalized.message)).toBe(true);
560+
});
561+
443562
test.each([
444563
{
445564
name: "message-only capacity protocol error",

‎src/inference-gateway-error.ts‎

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
codexUsageLimitRetryAfterMs,
99
formatCodexUsageLimitMessage,
1010
parseCodexUsageLimitError,
11+
readCodexNestedErrorMessage,
1112
} from "./auth/codex/usage-limit-error.js";
1213
import {
1314
codexProfileFromProviderName,
@@ -633,14 +634,47 @@ function normalizeCodexCredential404Error(
633634
};
634635
}
635636

637+
/**
638+
* Codex 400s arrive as fatal with HTTP statusText ("Bad Request") while the
639+
* real diagnostic sits on nested `detail.error`. Lift the nested copy so the
640+
* transcript is actionable; keep category fatal — this is not overflow,
641+
* rate-limit, or quota.
642+
*/
643+
function normalizeCodexFatal400Message(
644+
error: InferenceErrorWithGoContext,
645+
): InferenceError {
646+
if (!isKnownCodexProviderId(error.providerId)) return error;
647+
if (error.category !== "fatal") return error;
648+
if (error.statusCode !== 400) return error;
649+
650+
const current = error.message ?? "";
651+
if (current.length > 0 && current.toLowerCase() !== "bad request") {
652+
return error;
653+
}
654+
655+
const nested = readCodexNestedErrorMessage(error.raw);
656+
if (nested === undefined || nested === current) return error;
657+
658+
return {
659+
category: "fatal",
660+
message: nested,
661+
statusCode: 400,
662+
...(error.raw !== undefined ? { raw: error.raw } : {}),
663+
...(error.retryAfterMs !== undefined
664+
? { retryAfterMs: error.retryAfterMs }
665+
: {}),
666+
};
667+
}
668+
636669
/**
637670
* Reclassify gateway overload errors so the default retry policy treats them as
638671
* transient instead of aborting on protocol_mismatch. Also normalizes OpenCode
639672
* Go quota/rate-limit shapes (including HTTP 400 mis-status), known-xAI short
640673
* 429s, attributable xAI capacity protocol_mismatch, Codex usage limits
641674
* (nested detail.error with resets_in_seconds), known-Codex short 429s that
642-
* are not usage_limit_reached, and known-Codex 404s carrying an
643-
* auth-rejection signal (expired/revoked credential).
675+
* are not usage_limit_reached, known-Codex 404s carrying an
676+
* auth-rejection signal (expired/revoked credential), and known-Codex fatal
677+
* 400s whose message is empty or "Bad Request" (nested diagnostic on raw).
644678
*/
645679
export function normalizeInferenceErrorForRetry(
646680
error: InferenceErrorWithGoContext,
@@ -663,6 +697,9 @@ export function normalizeInferenceErrorForRetry(
663697
const codexCredential = normalizeCodexCredential404Error(error);
664698
if (codexCredential !== error) return codexCredential;
665699

700+
const codexFatal400 = normalizeCodexFatal400Message(error);
701+
if (codexFatal400 !== error) return codexFatal400;
702+
666703
if (!isGatewayOverloadInferenceError(error)) return error;
667704
if (error.category === "retryable" || error.category === "timeout")
668705
return error;

0 commit comments

Comments
 (0)