Skip to content

Commit 789809b

Browse files
committed
Surface Codex usage_limit_reached with reset ETA and profile switch
Parse the live nested detail.error body so 429s classify as quota_exhausted with retryAfterMs from resets_in_seconds. Operator message names plan/profile when known, human reset time, and points at /model for another subscription. Long reset windows already abort via the Corbits retry policy once retryAfterMs is populated.
1 parent 2c4effc commit 789809b

12 files changed

Lines changed: 625 additions & 9 deletions

src/agent/renderer.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { ReactorEmittedEvent } from "@intx/inference";
22

33
import { createFaremeter, formatCost } from "../cost/faremeter.js";
44
import type { PricingCache } from "../cost/pricing-fetcher.js";
5+
import { inferenceErrorMessage } from "../inference-error-message.js";
56

67
export type Renderer = {
78
render(event: ReactorEmittedEvent): void;
@@ -192,8 +193,23 @@ export function createRenderer(startedAt: number, modelId?: string, pricingCache
192193
}
193194

194195
case "inference.error": {
195-
const err = e.data?.error as Record<string, unknown>;
196-
writeErrorBlock(String(err?.message ?? e.data?.error ?? "inference error"));
196+
const err = e.data?.error as Record<string, unknown> | undefined;
197+
const rawMessage = String(err?.message ?? e.data?.error ?? "inference error");
198+
const message =
199+
typeof err?.category === "string"
200+
? inferenceErrorMessage({
201+
category: err.category,
202+
message: rawMessage,
203+
...(typeof err.statusCode === "number"
204+
? { statusCode: err.statusCode }
205+
: {}),
206+
...(err.raw !== undefined ? { raw: err.raw } : {}),
207+
...(typeof err.providerId === "string"
208+
? { providerId: err.providerId }
209+
: {}),
210+
})
211+
: rawMessage;
212+
writeErrorBlock(message);
197213
break;
198214
}
199215

src/agent/retry-policy.test.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,4 +31,28 @@ describe("createCorbitsRetryPolicy", () => {
3131
});
3232
expect(decision).toEqual({ kind: "abort" });
3333
});
34-
});
34+
35+
test("aborts Codex usage_limit_reached when resets_in_seconds is a long window", async () => {
36+
const policy = createCorbitsRetryPolicy();
37+
const decision = await policy({
38+
attempt: 1,
39+
elapsedMs: 0,
40+
error: {
41+
category: "quota_exhausted",
42+
message: "Too Many Requests",
43+
statusCode: 429,
44+
raw: {
45+
detail: {
46+
error: {
47+
code: "usage_limit_reached",
48+
message: "You have reached your usage limit.",
49+
plan_type: "workspace_member",
50+
resets_in_seconds: 3435,
51+
},
52+
},
53+
},
54+
},
55+
});
56+
expect(decision).toEqual({ kind: "abort" });
57+
});
58+
});
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import { describe, expect, test } from "bun:test";
2+
import {
3+
codexUsageLimitRetryAfterMs,
4+
formatCodexUsageLimitMessage,
5+
formatResetETA,
6+
parseCodexUsageLimitError,
7+
} from "./usage-limit-error.js";
8+
9+
/** Live body captured from chatgpt.com/backend-api/codex/responses. */
10+
const LIVE_USAGE_LIMIT_BODY = {
11+
detail: {
12+
error: {
13+
code: "usage_limit_reached",
14+
message: "You have reached your usage limit. Try again later.",
15+
plan_type: "workspace_member",
16+
resets_in_seconds: 3435,
17+
},
18+
},
19+
};
20+
21+
describe("parseCodexUsageLimitError", () => {
22+
test("parses the live nested detail.error body", () => {
23+
const parsed = parseCodexUsageLimitError(LIVE_USAGE_LIMIT_BODY);
24+
expect(parsed).toEqual({
25+
code: "usage_limit_reached",
26+
message: "You have reached your usage limit. Try again later.",
27+
planType: "workspace_member",
28+
resetsInSeconds: 3435,
29+
});
30+
});
31+
32+
test("parses a JSON string of the same shape", () => {
33+
const parsed = parseCodexUsageLimitError(JSON.stringify(LIVE_USAGE_LIMIT_BODY));
34+
expect(parsed?.code).toBe("usage_limit_reached");
35+
expect(parsed?.resetsInSeconds).toBe(3435);
36+
});
37+
38+
test("parses a top-level error object", () => {
39+
const parsed = parseCodexUsageLimitError({
40+
error: {
41+
code: "usage_limit_reached",
42+
message: "limit",
43+
plan_type: "plus",
44+
resets_in_seconds: 90,
45+
},
46+
});
47+
expect(parsed).toEqual({
48+
code: "usage_limit_reached",
49+
message: "limit",
50+
planType: "plus",
51+
resetsInSeconds: 90,
52+
});
53+
});
54+
55+
test("returns undefined for unrelated 429 bodies", () => {
56+
expect(
57+
parseCodexUsageLimitError({
58+
error: { message: "Too Many Requests", code: "rate_limit_exceeded" },
59+
}),
60+
).toBeUndefined();
61+
expect(parseCodexUsageLimitError({ ok: true })).toBeUndefined();
62+
expect(parseCodexUsageLimitError(undefined)).toBeUndefined();
63+
});
64+
65+
test("does not claim OpenAI insufficient_quota as Codex", () => {
66+
expect(
67+
parseCodexUsageLimitError({
68+
error: {
69+
message: "You exceeded your current quota, please check your plan and billing details.",
70+
type: "insufficient_quota",
71+
code: "insufficient_quota",
72+
},
73+
}),
74+
).toBeUndefined();
75+
});
76+
77+
test("does not claim OpenAI rate_limit_exceeded copy as Codex", () => {
78+
expect(
79+
parseCodexUsageLimitError({
80+
error: {
81+
message: "Rate limit reached for gpt-4 in organization org-x on tokens per min",
82+
type: "tokens",
83+
code: "rate_limit_exceeded",
84+
},
85+
}),
86+
).toBeUndefined();
87+
});
88+
89+
test("does not match message-only 'limit reached' without a Codex code", () => {
90+
expect(
91+
parseCodexUsageLimitError({
92+
error: { message: "You have reached your usage limit." },
93+
}),
94+
).toBeUndefined();
95+
});
96+
});
97+
98+
describe("formatCodexUsageLimitMessage", () => {
99+
test("names plan, reset ETA, and profile switch path", () => {
100+
const parsed = parseCodexUsageLimitError(LIVE_USAGE_LIMIT_BODY);
101+
expect(parsed).toBeDefined();
102+
const line = formatCodexUsageLimitMessage(parsed!, { profile: "abk-labs" });
103+
expect(line).toContain('Codex profile "abk-labs"');
104+
expect(line).toContain("workspace member");
105+
expect(line).toMatch(/Resets in ~/);
106+
expect(line).toContain("/model");
107+
});
108+
109+
test("works without a profile name", () => {
110+
const line = formatCodexUsageLimitMessage({
111+
code: "usage_limit_reached",
112+
message: "limit",
113+
planType: "plus",
114+
resetsInSeconds: 120,
115+
});
116+
expect(line.startsWith("Codex usage limit reached")).toBe(true);
117+
expect(line).toContain("plus");
118+
expect(line).toContain("~2m");
119+
});
120+
});
121+
122+
describe("codexUsageLimitRetryAfterMs / formatResetETA", () => {
123+
test("converts seconds to ms", () => {
124+
expect(codexUsageLimitRetryAfterMs({ code: "usage_limit_reached", message: "", resetsInSeconds: 3435 })).toBe(
125+
3_435_000,
126+
);
127+
expect(codexUsageLimitRetryAfterMs({ code: "usage_limit_reached", message: "" })).toBeUndefined();
128+
});
129+
130+
test("formats human ETAs", () => {
131+
expect(formatResetETA(0)).toBe("now");
132+
expect(formatResetETA(45)).toBe("45s");
133+
expect(formatResetETA(120)).toBe("~2m");
134+
expect(formatResetETA(3435)).toBe("~58m");
135+
136+
});
137+
});
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
/**
2+
* Parse and format Codex Responses `usage_limit_reached` bodies.
3+
*
4+
* Live shape (HTTP 429):
5+
* { detail: { error: { code, message, plan_type, resets_in_seconds } } }
6+
*
7+
* Harness extractErrorMessage only unwraps top-level `{ error: { message } }`,
8+
* so the nested detail is left on `InferenceError.raw` while the classified
9+
* message falls back to statusText. Retry and transcript paths re-read raw here.
10+
*
11+
* Matchers stay Codex-narrow: exact `usage_limit_*` codes only. Generic OpenAI
12+
* codes (`insufficient_quota`, `rate_limit_exceeded`) and loose "limit reached"
13+
* copy must not rebrand other providers as Codex.
14+
*/
15+
16+
export type CodexUsageLimitError = {
17+
readonly code: string;
18+
readonly message: string;
19+
readonly planType?: string;
20+
readonly resetsInSeconds?: number;
21+
};
22+
23+
/** Exact codes observed / expected from the Codex ChatGPT backend. */
24+
const USAGE_LIMIT_CODES = new Set([
25+
"usage_limit_reached",
26+
"usage_limit_exceeded",
27+
]);
28+
29+
function asRecord(value: unknown): Record<string, unknown> | undefined {
30+
if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
31+
return value as Record<string, unknown>;
32+
}
33+
34+
function tryParseJSON(text: string): unknown {
35+
try {
36+
return JSON.parse(text) as unknown;
37+
} catch {
38+
return undefined;
39+
}
40+
}
41+
42+
function coerceBody(raw: unknown): unknown {
43+
if (typeof raw === "string") {
44+
const trimmed = raw.trim();
45+
if (trimmed.length === 0) return undefined;
46+
return tryParseJSON(trimmed) ?? raw;
47+
}
48+
return raw;
49+
}
50+
51+
function readErrorNode(body: unknown): Record<string, unknown> | undefined {
52+
const root = asRecord(body);
53+
if (root === undefined) return undefined;
54+
55+
const detail = asRecord(root["detail"]);
56+
if (detail !== undefined) {
57+
const nested = asRecord(detail["error"]);
58+
if (nested !== undefined) return nested;
59+
// Some gateways put the fields directly under detail.
60+
if (typeof detail["code"] === "string") return detail;
61+
}
62+
63+
const top = asRecord(root["error"]);
64+
if (top !== undefined) return top;
65+
66+
if (typeof root["code"] === "string") return root;
67+
return undefined;
68+
}
69+
70+
/**
71+
* Returns a structured usage-limit error when `raw` matches the Codex body.
72+
* Undefined for unrelated payloads (including other providers' quota 429s).
73+
*/
74+
export function parseCodexUsageLimitError(raw: unknown): CodexUsageLimitError | undefined {
75+
const body = coerceBody(raw);
76+
const node = readErrorNode(body);
77+
if (node === undefined) return undefined;
78+
79+
const code = typeof node["code"] === "string" ? node["code"] : undefined;
80+
// Exact code only — no regex, no message-only fallback. OpenAI uses
81+
// insufficient_quota / rate_limit_exceeded; those must stay non-Codex.
82+
if (code === undefined || !USAGE_LIMIT_CODES.has(code)) return undefined;
83+
84+
const message = typeof node["message"] === "string" ? node["message"] : "";
85+
86+
const planType =
87+
typeof node["plan_type"] === "string"
88+
? node["plan_type"]
89+
: typeof node["planType"] === "string"
90+
? node["planType"]
91+
: undefined;
92+
93+
const resetsRaw = node["resets_in_seconds"] ?? node["resetsInSeconds"] ?? node["reset_after_seconds"];
94+
const resetsInSeconds =
95+
typeof resetsRaw === "number" && Number.isFinite(resetsRaw) && resetsRaw >= 0
96+
? Math.floor(resetsRaw)
97+
: undefined;
98+
99+
return {
100+
code,
101+
message,
102+
...(planType !== undefined ? { planType } : {}),
103+
...(resetsInSeconds !== undefined ? { resetsInSeconds } : {}),
104+
};
105+
}
106+
107+
/** `retryAfterMs` for the default retry policy; undefined when the body omits reset. */
108+
export function codexUsageLimitRetryAfterMs(parsed: CodexUsageLimitError): number | undefined {
109+
if (parsed.resetsInSeconds === undefined) return undefined;
110+
if (parsed.resetsInSeconds <= 0) return 0;
111+
return parsed.resetsInSeconds * 1000;
112+
}
113+
114+
export function formatResetETA(seconds: number): string {
115+
if (seconds <= 0) return "now";
116+
if (seconds < 60) return `${String(Math.ceil(seconds))}s`;
117+
if (seconds < 3600) return `~${String(Math.ceil(seconds / 60))}m`;
118+
if (seconds < 86_400) {
119+
const h = Math.floor(seconds / 3600);
120+
const m = Math.ceil((seconds % 3600) / 60);
121+
return m > 0 ? `~${String(h)}h ${String(m)}m` : `~${String(h)}h`;
122+
}
123+
const d = Math.floor(seconds / 86_400);
124+
const h = Math.ceil((seconds % 86_400) / 3600);
125+
return h > 0 ? `~${String(d)}d ${String(h)}h` : `~${String(d)}d`;
126+
}
127+
128+
export type FormatCodexUsageLimitOpts = {
129+
/** Active Codex profile name (from `codex/<profile>` provider id) when known. */
130+
readonly profile?: string;
131+
};
132+
133+
/**
134+
* Operator-facing one-liner: which plan/profile hit the wall, when it resets,
135+
* and how to try another subscription.
136+
*/
137+
export function formatCodexUsageLimitMessage(
138+
parsed: CodexUsageLimitError,
139+
opts?: FormatCodexUsageLimitOpts,
140+
): string {
141+
const who =
142+
opts?.profile !== undefined && opts.profile.length > 0
143+
? `Codex profile "${opts.profile}"`
144+
: "Codex";
145+
const plan =
146+
parsed.planType !== undefined && parsed.planType.length > 0
147+
? ` (${parsed.planType.replace(/_/g, " ")})`
148+
: "";
149+
const reset =
150+
parsed.resetsInSeconds !== undefined
151+
? ` Resets in ${formatResetETA(parsed.resetsInSeconds)}.`
152+
: "";
153+
return `${who} usage limit reached${plan}.${reset} Switch profile with /model if another Codex subscription has quota.`;
154+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { describe, expect, test } from "bun:test";
2+
3+
import { inferenceErrorMessage } from "./inference-error-message.js";
4+
5+
const CODEX_BODY = {
6+
detail: {
7+
error: {
8+
code: "usage_limit_reached",
9+
message: "You have reached your usage limit.",
10+
plan_type: "workspace_member",
11+
resets_in_seconds: 3435,
12+
},
13+
},
14+
};
15+
16+
describe("inferenceErrorMessage", () => {
17+
test("surfaces Codex usage_limit_reached with reset ETA", () => {
18+
const line = inferenceErrorMessage({
19+
category: "quota_exhausted",
20+
message: "Too Many Requests",
21+
statusCode: 429,
22+
raw: CODEX_BODY,
23+
});
24+
expect(line).toContain("Codex usage limit reached");
25+
expect(line).toMatch(/Resets in ~/);
26+
expect(line).toContain("/model");
27+
});
28+
29+
test("does not brand a known non-Codex provider as Codex", () => {
30+
const line = inferenceErrorMessage({
31+
category: "quota_exhausted",
32+
message: "Too Many Requests",
33+
statusCode: 429,
34+
providerId: "openai",
35+
raw: CODEX_BODY,
36+
});
37+
expect(line).not.toContain("Codex");
38+
expect(line).toBe("Quota exhausted — usage limit reached.");
39+
});
40+
});

0 commit comments

Comments
 (0)