From 789809b904e9e70806ff7f73ecb56db89bd52e17 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 10 Aug 2026 02:40:35 -0700 Subject: [PATCH] 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. --- src/agent/renderer.ts | 20 ++- src/agent/retry-policy.test.ts | 26 +++- src/auth/codex/usage-limit-error.test.ts | 137 ++++++++++++++++++++ src/auth/codex/usage-limit-error.ts | 154 +++++++++++++++++++++++ src/inference-error-message.test.ts | 40 ++++++ src/inference-error-message.ts | 48 ++++++- src/inference-gateway-error.test.ts | 87 +++++++++++++ src/inference-gateway-error.ts | 59 ++++++++- src/provider/codex-responses-adapter.ts | 6 +- src/renderer.test.ts | 32 ++++- src/tui/stream-event-map.test.ts | 22 ++++ src/tui/stream-event-map.ts | 3 + 12 files changed, 625 insertions(+), 9 deletions(-) create mode 100644 src/auth/codex/usage-limit-error.test.ts create mode 100644 src/auth/codex/usage-limit-error.ts create mode 100644 src/inference-error-message.test.ts diff --git a/src/agent/renderer.ts b/src/agent/renderer.ts index 6294c3e19..3befa448d 100644 --- a/src/agent/renderer.ts +++ b/src/agent/renderer.ts @@ -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; @@ -192,8 +193,23 @@ export function createRenderer(startedAt: number, modelId?: string, pricingCache } case "inference.error": { - const err = e.data?.error as Record; - writeErrorBlock(String(err?.message ?? e.data?.error ?? "inference error")); + const err = e.data?.error as Record | 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; } diff --git a/src/agent/retry-policy.test.ts b/src/agent/retry-policy.test.ts index ca8f011fc..badc29f35 100644 --- a/src/agent/retry-policy.test.ts +++ b/src/agent/retry-policy.test.ts @@ -31,4 +31,28 @@ describe("createCorbitsRetryPolicy", () => { }); expect(decision).toEqual({ kind: "abort" }); }); -}); \ No newline at end of file + + 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" }); + }); +}); diff --git a/src/auth/codex/usage-limit-error.test.ts b/src/auth/codex/usage-limit-error.test.ts new file mode 100644 index 000000000..ed17f8f8b --- /dev/null +++ b/src/auth/codex/usage-limit-error.test.ts @@ -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"); + + }); +}); diff --git a/src/auth/codex/usage-limit-error.ts b/src/auth/codex/usage-limit-error.ts new file mode 100644 index 000000000..2a34dd9b9 --- /dev/null +++ b/src/auth/codex/usage-limit-error.ts @@ -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 | undefined { + if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined; + return value as Record; +} + +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 | 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/` 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.`; +} diff --git a/src/inference-error-message.test.ts b/src/inference-error-message.test.ts new file mode 100644 index 000000000..ea55f9d23 --- /dev/null +++ b/src/inference-error-message.test.ts @@ -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."); + }); +}); diff --git a/src/inference-error-message.ts b/src/inference-error-message.ts index 7ab733f44..ef831151d 100644 --- a/src/inference-error-message.ts +++ b/src/inference-error-message.ts @@ -6,6 +6,11 @@ * happened and whether they can do anything about it. */ +import { + formatCodexUsageLimitMessage, + parseCodexUsageLimitError, +} from "./auth/codex/usage-limit-error.js"; +import { codexProfileFromProviderName, isCodexProviderName } from "./config/codex-providers.js"; import { gatewayOverloadUserMessage, isGatewayOverloadInferenceError, @@ -49,8 +54,49 @@ export function classifyInferenceErrorCategory(error: InferenceErrorLike): strin return looksLikeContextOverflow(error.message ?? "") ? "context_overflow" : error.category; } +function codexUsageLimitLine(error: InferenceErrorLike): string | undefined { + // Match normalizeCodexUsageLimitError: never brand a known non-Codex source. + if (error.providerId !== undefined && !isCodexProviderName(error.providerId)) { + return undefined; + } + + const candidates: unknown[] = []; + if (error.raw !== undefined) candidates.push(error.raw); + if (typeof error.message === "string" && error.message.trim().startsWith("{")) { + candidates.push(error.message); + } + // Already-normalized path: message is our formatted line. + if ( + typeof error.message === "string" && + /codex .*usage limit reached/i.test(error.message) && + error.message.includes("/model") + ) { + return error.message; + } + + for (const candidate of candidates) { + const parsed = parseCodexUsageLimitError(candidate); + if (parsed === undefined) continue; + const profile = + error.providerId !== undefined + ? codexProfileFromProviderName(error.providerId) + : undefined; + return formatCodexUsageLimitMessage(parsed, { + ...(profile !== undefined ? { profile } : {}), + }); + } + return undefined; +} + /** One line describing the failure, falling back to the provider's own message. */ export function inferenceErrorMessage(error: InferenceErrorLike): string { if (isGatewayOverloadInferenceError(error)) return gatewayOverloadUserMessage(error); - return FRIENDLY_BY_CATEGORY[classifyInferenceErrorCategory(error)] ?? error.message ?? "inference error"; + + const category = classifyInferenceErrorCategory(error); + if (category === "quota_exhausted") { + const codexLine = codexUsageLimitLine(error); + if (codexLine !== undefined) return codexLine; + } + + return FRIENDLY_BY_CATEGORY[category] ?? error.message ?? "inference error"; } diff --git a/src/inference-gateway-error.test.ts b/src/inference-gateway-error.test.ts index 718e27da5..62bc484f1 100644 --- a/src/inference-gateway-error.test.ts +++ b/src/inference-gateway-error.test.ts @@ -179,4 +179,91 @@ describe("normalizeInferenceErrorForRetry", () => { expect(normalized.category).toBe("quota_exhausted"); expect(normalized.message.toLowerCase()).toMatch(/usage limit|quota/); }); + + test("maps Codex usage_limit_reached detail.error body to quota_exhausted with reset ETA", () => { + const liveBody = { + detail: { + error: { + code: "usage_limit_reached", + message: "You have reached your usage limit. Try again later.", + plan_type: "workspace_member", + resets_in_seconds: 3435, + }, + }, + }; + const normalized = normalizeInferenceErrorForRetry({ + category: "quota_exhausted", + message: "Too Many Requests", + statusCode: 429, + raw: liveBody, + providerId: "codex/abk-labs", + }); + expect(normalized.category).toBe("quota_exhausted"); + expect(normalized.retryAfterMs).toBe(3_435_000); + expect(normalized.message).toContain('Codex profile "abk-labs"'); + expect(normalized.message).toContain("workspace member"); + expect(normalized.message).toMatch(/Resets in ~/); + expect(normalized.message).toContain("/model"); + }); + + test("Codex usage limit without profile still formats plan and switch path", () => { + const normalized = normalizeInferenceErrorForRetry({ + category: "retryable", + message: "Too Many Requests", + statusCode: 429, + raw: JSON.stringify({ + detail: { + error: { + code: "usage_limit_reached", + message: "limit", + plan_type: "plus", + resets_in_seconds: 120, + }, + }, + }), + }); + expect(normalized.category).toBe("quota_exhausted"); + expect(normalized.retryAfterMs).toBe(120_000); + expect(normalized.message.startsWith("Codex usage limit reached")).toBe(true); + expect(normalized.message).toContain("~2m"); + }); + + test("does not rebrand OpenAI insufficient_quota as Codex", () => { + const error = { + category: "quota_exhausted" as const, + message: "You exceeded your current quota, please check your plan and billing details.", + statusCode: 429, + raw: { + error: { + message: "You exceeded your current quota, please check your plan and billing details.", + type: "insufficient_quota", + code: "insufficient_quota", + }, + }, + }; + const normalized = normalizeInferenceErrorForRetry(error); + expect(normalized).toBe(error); + expect(normalized.message).not.toContain("Codex"); + }); + + test("skips Codex rebrand when providerId is known non-Codex", () => { + const error = { + category: "retryable" as const, + message: "Too Many Requests", + statusCode: 429, + providerId: "openai", + raw: { + detail: { + error: { + code: "usage_limit_reached", + message: "limit", + plan_type: "plus", + resets_in_seconds: 120, + }, + }, + }, + }; + const normalized = normalizeInferenceErrorForRetry(error); + expect(normalized).toBe(error); + }); }); diff --git a/src/inference-gateway-error.ts b/src/inference-gateway-error.ts index 2845e1d6e..acbbe9c02 100644 --- a/src/inference-gateway-error.ts +++ b/src/inference-gateway-error.ts @@ -4,6 +4,12 @@ import { isOpenCodeGoURL, parseGoAPIError, } from "../packages/opencode-go/src/index.js"; +import { + codexUsageLimitRetryAfterMs, + formatCodexUsageLimitMessage, + parseCodexUsageLimitError, +} from "./auth/codex/usage-limit-error.js"; +import { codexProfileFromProviderName, isCodexProviderName } from "./config/codex-providers.js"; export type InferenceErrorLike = { category: string; @@ -13,7 +19,7 @@ export type InferenceErrorLike = { retryAfterMs?: number; /** Optional request base/url when known — used to scope Go error reclassification. */ requestURL?: string; - /** Provider catalog id when known (e.g. opencode-go). */ + /** Provider catalog id when known (e.g. opencode-go, codex/abk-labs). */ providerId?: string; /** Explicit OpenCode Go provider flag when known. */ opencodeGo?: boolean; @@ -178,10 +184,56 @@ export function normalizeOpenCodeGoInferenceError( }; } +/** + * Lift Codex `usage_limit_reached` bodies onto quota_exhausted with a reset ETA + * and profile-switch hint. The harness leaves nested `detail.error` on `raw` + * while message falls back to statusText, so retry and transcript both re-read it. + * + * When providerId is known and not a Codex source, leave the error alone so + * OpenAI/Go/etc. quota bodies never get Codex-branded copy. + */ +function normalizeCodexUsageLimitError( + error: InferenceErrorWithGoContext, +): InferenceError { + if (error.providerId !== undefined && !isCodexProviderName(error.providerId)) { + return error; + } + + const candidates: unknown[] = []; + if (error.raw !== undefined) candidates.push(error.raw); + if (typeof error.message === "string" && error.message.trim().startsWith("{")) { + candidates.push(error.message); + } + + let parsed = undefined as ReturnType; + for (const candidate of candidates) { + parsed = parseCodexUsageLimitError(candidate); + if (parsed !== undefined) break; + } + if (parsed === undefined) return error; + + const profile = + error.providerId !== undefined + ? codexProfileFromProviderName(error.providerId) + : undefined; + const retryAfterMs = codexUsageLimitRetryAfterMs(parsed) ?? error.retryAfterMs; + + return { + category: "quota_exhausted", + message: formatCodexUsageLimitMessage(parsed, { + ...(profile !== undefined ? { profile } : {}), + }), + statusCode: error.statusCode ?? 429, + ...(error.raw !== undefined ? { raw: error.raw } : {}), + ...(retryAfterMs !== undefined ? { 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). + * Go quota/rate-limit shapes (including HTTP 400 mis-status) and Codex usage + * limits (nested detail.error with resets_in_seconds). */ export function normalizeInferenceErrorForRetry( error: InferenceErrorWithGoContext, @@ -189,6 +241,9 @@ export function normalizeInferenceErrorForRetry( const goNormalized = normalizeOpenCodeGoInferenceError(error); if (goNormalized !== error) return goNormalized; + const codexNormalized = normalizeCodexUsageLimitError(error); + if (codexNormalized !== error) return codexNormalized; + if (!isGatewayOverloadInferenceError(error)) return error; if (error.category === "retryable" || error.category === "timeout") return error; diff --git a/src/provider/codex-responses-adapter.ts b/src/provider/codex-responses-adapter.ts index 7f4646773..aa50b0832 100644 --- a/src/provider/codex-responses-adapter.ts +++ b/src/provider/codex-responses-adapter.ts @@ -281,8 +281,10 @@ function buildRequest( model, input, instructions: codexInstructions(), - // The Codex backend requires server-side storage off and streaming on, and - // asks for encrypted reasoning so it can be round-tripped across turns. + // The Codex ChatGPT backend requires `store: false` (store:true → 400) and + // rejects `previous_response_id` as an unsupported parameter. Multi-turn + // continuity is full input plus encrypted reasoning round-trip only — do + // not attempt response-id chaining on this surface. store: false, stream: true, include: ["reasoning.encrypted_content"], diff --git a/src/renderer.test.ts b/src/renderer.test.ts index 74eb33e9d..8614c216f 100644 --- a/src/renderer.test.ts +++ b/src/renderer.test.ts @@ -177,7 +177,37 @@ describe("renderer — error blocks", () => { const out = cap.stdout.join(""); expect(out).toContain("error"); expect(out).toContain("\x1b[31m"); // red - expect(out).toContain("request timed out"); + expect(out).toContain("Request timed out"); + + }); + + test("inference.error surfaces Codex usage_limit_reached with reset ETA", () => { + const cap = captureOutput(); + const renderer = createRenderer(Date.now()); + renderer.render(event("inference.error", { + 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, + }, + }, + }, + }, + partial: {}, + })); + cap.restore(); + const out = cap.stdout.join(""); + expect(out).toContain("Codex usage limit reached"); + expect(out).toMatch(/Resets in ~/); + expect(out).toContain("/model"); + expect(out).not.toContain("Too Many Requests"); }); test("reactor.error writes error block in red to stdout", () => { diff --git a/src/tui/stream-event-map.test.ts b/src/tui/stream-event-map.test.ts index f237a2e26..015836ec8 100644 --- a/src/tui/stream-event-map.test.ts +++ b/src/tui/stream-event-map.test.ts @@ -346,6 +346,28 @@ describe("inference.error text", () => { ).toContain("Context window full") }) + test("Codex usage_limit_reached raw body surfaces reset ETA and profile switch", () => { + const line = message({ + category: "quota_exhausted", + message: "Too Many Requests", + statusCode: 429, + providerId: "codex/abk-labs", + raw: { + detail: { + error: { + code: "usage_limit_reached", + message: "You have reached your usage limit.", + plan_type: "workspace_member", + resets_in_seconds: 3435, + }, + }, + }, + }) + expect(line).toContain('Codex profile "abk-labs"') + expect(line).toMatch(/Resets in ~/) + expect(line).toContain("/model") + }) + test("an unclassified failure keeps the provider's own words", () => { expect(message({ message: "socket hang up" })).toBe("socket hang up") expect(message({ category: "wat", message: "socket hang up" })).toBe("socket hang up") diff --git a/src/tui/stream-event-map.ts b/src/tui/stream-event-map.ts index 7eead67e6..d622668a7 100644 --- a/src/tui/stream-event-map.ts +++ b/src/tui/stream-event-map.ts @@ -478,6 +478,9 @@ function mapEvent( ? { statusCode: err.statusCode } : {}), ...(err.raw !== undefined ? { raw: err.raw } : {}), + ...(typeof err.providerId === "string" + ? { providerId: err.providerId } + : {}), }) : rawMessage // Hand the armed boundary to the next event rather than disarming: the