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
20 changes: 18 additions & 2 deletions src/agent/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { ReactorEmittedEvent } from "@intx/inference";

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

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

case "inference.error": {
const err = e.data?.error as Record<string, unknown>;
writeErrorBlock(String(err?.message ?? e.data?.error ?? "inference error"));
const err = e.data?.error as Record<string, unknown> | undefined;
const rawMessage = String(err?.message ?? e.data?.error ?? "inference error");
const message =
typeof err?.category === "string"
? inferenceErrorMessage({
category: err.category,
message: rawMessage,
...(typeof err.statusCode === "number"
? { statusCode: err.statusCode }
: {}),
...(err.raw !== undefined ? { raw: err.raw } : {}),
...(typeof err.providerId === "string"
? { providerId: err.providerId }
: {}),
})
: rawMessage;
writeErrorBlock(message);
break;
}

Expand Down
26 changes: 25 additions & 1 deletion src/agent/retry-policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,28 @@ describe("createCorbitsRetryPolicy", () => {
});
expect(decision).toEqual({ kind: "abort" });
});
});

test("aborts Codex usage_limit_reached when resets_in_seconds is a long window", async () => {
const policy = createCorbitsRetryPolicy();
const decision = await policy({
attempt: 1,
elapsedMs: 0,
error: {
category: "quota_exhausted",
message: "Too Many Requests",
statusCode: 429,
raw: {
detail: {
error: {
code: "usage_limit_reached",
message: "You have reached your usage limit.",
plan_type: "workspace_member",
resets_in_seconds: 3435,
},
},
},
},
});
expect(decision).toEqual({ kind: "abort" });
});
});
137 changes: 137 additions & 0 deletions src/auth/codex/usage-limit-error.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { describe, expect, test } from "bun:test";
import {
codexUsageLimitRetryAfterMs,
formatCodexUsageLimitMessage,
formatResetETA,
parseCodexUsageLimitError,
} from "./usage-limit-error.js";

/** Live body captured from chatgpt.com/backend-api/codex/responses. */
const LIVE_USAGE_LIMIT_BODY = {
detail: {
error: {
code: "usage_limit_reached",
message: "You have reached your usage limit. Try again later.",
plan_type: "workspace_member",
resets_in_seconds: 3435,
},
},
};

describe("parseCodexUsageLimitError", () => {
test("parses the live nested detail.error body", () => {
const parsed = parseCodexUsageLimitError(LIVE_USAGE_LIMIT_BODY);
expect(parsed).toEqual({
code: "usage_limit_reached",
message: "You have reached your usage limit. Try again later.",
planType: "workspace_member",
resetsInSeconds: 3435,
});
});

test("parses a JSON string of the same shape", () => {
const parsed = parseCodexUsageLimitError(JSON.stringify(LIVE_USAGE_LIMIT_BODY));
expect(parsed?.code).toBe("usage_limit_reached");
expect(parsed?.resetsInSeconds).toBe(3435);
});

test("parses a top-level error object", () => {
const parsed = parseCodexUsageLimitError({
error: {
code: "usage_limit_reached",
message: "limit",
plan_type: "plus",
resets_in_seconds: 90,
},
});
expect(parsed).toEqual({
code: "usage_limit_reached",
message: "limit",
planType: "plus",
resetsInSeconds: 90,
});
});

test("returns undefined for unrelated 429 bodies", () => {
expect(
parseCodexUsageLimitError({
error: { message: "Too Many Requests", code: "rate_limit_exceeded" },
}),
).toBeUndefined();
expect(parseCodexUsageLimitError({ ok: true })).toBeUndefined();
expect(parseCodexUsageLimitError(undefined)).toBeUndefined();
});

test("does not claim OpenAI insufficient_quota as Codex", () => {
expect(
parseCodexUsageLimitError({
error: {
message: "You exceeded your current quota, please check your plan and billing details.",
type: "insufficient_quota",
code: "insufficient_quota",
},
}),
).toBeUndefined();
});

test("does not claim OpenAI rate_limit_exceeded copy as Codex", () => {
expect(
parseCodexUsageLimitError({
error: {
message: "Rate limit reached for gpt-4 in organization org-x on tokens per min",
type: "tokens",
code: "rate_limit_exceeded",
},
}),
).toBeUndefined();
});

test("does not match message-only 'limit reached' without a Codex code", () => {
expect(
parseCodexUsageLimitError({
error: { message: "You have reached your usage limit." },
}),
).toBeUndefined();
});
});

describe("formatCodexUsageLimitMessage", () => {
test("names plan, reset ETA, and profile switch path", () => {
const parsed = parseCodexUsageLimitError(LIVE_USAGE_LIMIT_BODY);
expect(parsed).toBeDefined();
const line = formatCodexUsageLimitMessage(parsed!, { profile: "abk-labs" });
expect(line).toContain('Codex profile "abk-labs"');
expect(line).toContain("workspace member");
expect(line).toMatch(/Resets in ~/);
expect(line).toContain("/model");
});

test("works without a profile name", () => {
const line = formatCodexUsageLimitMessage({
code: "usage_limit_reached",
message: "limit",
planType: "plus",
resetsInSeconds: 120,
});
expect(line.startsWith("Codex usage limit reached")).toBe(true);
expect(line).toContain("plus");
expect(line).toContain("~2m");
});
});

describe("codexUsageLimitRetryAfterMs / formatResetETA", () => {
test("converts seconds to ms", () => {
expect(codexUsageLimitRetryAfterMs({ code: "usage_limit_reached", message: "", resetsInSeconds: 3435 })).toBe(
3_435_000,
);
expect(codexUsageLimitRetryAfterMs({ code: "usage_limit_reached", message: "" })).toBeUndefined();
});

test("formats human ETAs", () => {
expect(formatResetETA(0)).toBe("now");
expect(formatResetETA(45)).toBe("45s");
expect(formatResetETA(120)).toBe("~2m");
expect(formatResetETA(3435)).toBe("~58m");

});
});
154 changes: 154 additions & 0 deletions src/auth/codex/usage-limit-error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
/**
* Parse and format Codex Responses `usage_limit_reached` bodies.
*
* Live shape (HTTP 429):
* { detail: { error: { code, message, plan_type, resets_in_seconds } } }
*
* Harness extractErrorMessage only unwraps top-level `{ error: { message } }`,
* so the nested detail is left on `InferenceError.raw` while the classified
* message falls back to statusText. Retry and transcript paths re-read raw here.
*
* Matchers stay Codex-narrow: exact `usage_limit_*` codes only. Generic OpenAI
* codes (`insufficient_quota`, `rate_limit_exceeded`) and loose "limit reached"
* copy must not rebrand other providers as Codex.
*/

export type CodexUsageLimitError = {
readonly code: string;
readonly message: string;
readonly planType?: string;
readonly resetsInSeconds?: number;
};

/** Exact codes observed / expected from the Codex ChatGPT backend. */
const USAGE_LIMIT_CODES = new Set([
"usage_limit_reached",
"usage_limit_exceeded",
]);

function asRecord(value: unknown): Record<string, unknown> | undefined {
if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
return value as Record<string, unknown>;
}

function tryParseJSON(text: string): unknown {
try {
return JSON.parse(text) as unknown;
} catch {
return undefined;
}
}

function coerceBody(raw: unknown): unknown {
if (typeof raw === "string") {
const trimmed = raw.trim();
if (trimmed.length === 0) return undefined;
return tryParseJSON(trimmed) ?? raw;
}
return raw;
}

function readErrorNode(body: unknown): Record<string, unknown> | undefined {
const root = asRecord(body);
if (root === undefined) return undefined;

const detail = asRecord(root["detail"]);
if (detail !== undefined) {
const nested = asRecord(detail["error"]);
if (nested !== undefined) return nested;
// Some gateways put the fields directly under detail.
if (typeof detail["code"] === "string") return detail;
}

const top = asRecord(root["error"]);
if (top !== undefined) return top;

if (typeof root["code"] === "string") return root;
return undefined;
}

/**
* Returns a structured usage-limit error when `raw` matches the Codex body.
* Undefined for unrelated payloads (including other providers' quota 429s).
*/
export function parseCodexUsageLimitError(raw: unknown): CodexUsageLimitError | undefined {
const body = coerceBody(raw);
const node = readErrorNode(body);
if (node === undefined) return undefined;

const code = typeof node["code"] === "string" ? node["code"] : undefined;
// Exact code only — no regex, no message-only fallback. OpenAI uses
// insufficient_quota / rate_limit_exceeded; those must stay non-Codex.
if (code === undefined || !USAGE_LIMIT_CODES.has(code)) return undefined;

const message = typeof node["message"] === "string" ? node["message"] : "";

const planType =
typeof node["plan_type"] === "string"
? node["plan_type"]
: typeof node["planType"] === "string"
? node["planType"]
: undefined;

const resetsRaw = node["resets_in_seconds"] ?? node["resetsInSeconds"] ?? node["reset_after_seconds"];
const resetsInSeconds =
typeof resetsRaw === "number" && Number.isFinite(resetsRaw) && resetsRaw >= 0
? Math.floor(resetsRaw)
: undefined;

return {
code,
message,
...(planType !== undefined ? { planType } : {}),
...(resetsInSeconds !== undefined ? { resetsInSeconds } : {}),
};
}

/** `retryAfterMs` for the default retry policy; undefined when the body omits reset. */
export function codexUsageLimitRetryAfterMs(parsed: CodexUsageLimitError): number | undefined {
if (parsed.resetsInSeconds === undefined) return undefined;
if (parsed.resetsInSeconds <= 0) return 0;
return parsed.resetsInSeconds * 1000;
}

export function formatResetETA(seconds: number): string {
if (seconds <= 0) return "now";
if (seconds < 60) return `${String(Math.ceil(seconds))}s`;
if (seconds < 3600) return `~${String(Math.ceil(seconds / 60))}m`;
if (seconds < 86_400) {
const h = Math.floor(seconds / 3600);
const m = Math.ceil((seconds % 3600) / 60);
return m > 0 ? `~${String(h)}h ${String(m)}m` : `~${String(h)}h`;
}
const d = Math.floor(seconds / 86_400);
const h = Math.ceil((seconds % 86_400) / 3600);
return h > 0 ? `~${String(d)}d ${String(h)}h` : `~${String(d)}d`;
}

export type FormatCodexUsageLimitOpts = {
/** Active Codex profile name (from `codex/<profile>` provider id) when known. */
readonly profile?: string;
};

/**
* Operator-facing one-liner: which plan/profile hit the wall, when it resets,
* and how to try another subscription.
*/
export function formatCodexUsageLimitMessage(
parsed: CodexUsageLimitError,
opts?: FormatCodexUsageLimitOpts,
): string {
const who =
opts?.profile !== undefined && opts.profile.length > 0
? `Codex profile "${opts.profile}"`
: "Codex";
const plan =
parsed.planType !== undefined && parsed.planType.length > 0
? ` (${parsed.planType.replace(/_/g, " ")})`
: "";
const reset =
parsed.resetsInSeconds !== undefined
? ` Resets in ${formatResetETA(parsed.resetsInSeconds)}.`
: "";
return `${who} usage limit reached${plan}.${reset} Switch profile with /model if another Codex subscription has quota.`;
}
40 changes: 40 additions & 0 deletions src/inference-error-message.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { describe, expect, test } from "bun:test";

import { inferenceErrorMessage } from "./inference-error-message.js";

const CODEX_BODY = {
detail: {
error: {
code: "usage_limit_reached",
message: "You have reached your usage limit.",
plan_type: "workspace_member",
resets_in_seconds: 3435,
},
},
};

describe("inferenceErrorMessage", () => {
test("surfaces Codex usage_limit_reached with reset ETA", () => {
const line = inferenceErrorMessage({
category: "quota_exhausted",
message: "Too Many Requests",
statusCode: 429,
raw: CODEX_BODY,
});
expect(line).toContain("Codex usage limit reached");
expect(line).toMatch(/Resets in ~/);
expect(line).toContain("/model");
});

test("does not brand a known non-Codex provider as Codex", () => {
const line = inferenceErrorMessage({
category: "quota_exhausted",
message: "Too Many Requests",
statusCode: 429,
providerId: "openai",
raw: CODEX_BODY,
});
expect(line).not.toContain("Codex");
expect(line).toBe("Quota exhausted — usage limit reached.");
});
});
Loading
Loading