Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions src/auth/codex/usage-limit-error.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
formatCodexUsageLimitMessage,
formatResetETA,
parseCodexUsageLimitError,
readCodexNestedErrorMessage,
} from "./usage-limit-error.js";

/** Live body captured from chatgpt.com/backend-api/codex/responses. */
Expand All @@ -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);
Expand Down
13 changes: 13 additions & 0 deletions src/auth/codex/usage-limit-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,19 @@ function readErrorNode(body: unknown): Record<string, unknown> | 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).
Expand Down
119 changes: 119 additions & 0 deletions src/inference-gateway-error.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
41 changes: 39 additions & 2 deletions src/inference-gateway-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
codexUsageLimitRetryAfterMs,
formatCodexUsageLimitMessage,
parseCodexUsageLimitError,
readCodexNestedErrorMessage,
} from "./auth/codex/usage-limit-error.js";
import {
codexProfileFromProviderName,
Expand Down Expand Up @@ -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,
Expand All @@ -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;
Expand Down
Loading