diff --git a/src/agent/turn-recovery-policy.ts b/src/agent/turn-recovery-policy.ts index 6b1810c..5ccda64 100644 --- a/src/agent/turn-recovery-policy.ts +++ b/src/agent/turn-recovery-policy.ts @@ -60,6 +60,7 @@ const NON_RETRYABLE_429_REASONS = [ const NON_RETRYABLE_QUOTA_DETAIL_PATTERNS = [ "hosted model usage limit", "out of credits", + "usage_limit_reached", ]; const NON_RETRYABLE_4XX_PATTERN = /Error code:\s*4(0[0-8]|1\d|2\d|3\d|4\d|51)/i; const RETRYABLE_429_PATTERN = /Error code:\s*429|rate limit|too many requests/i; diff --git a/src/cli/helpers/errorFormatter.ts b/src/cli/helpers/errorFormatter.ts index 38a5b36..206ce3a 100644 --- a/src/cli/helpers/errorFormatter.ts +++ b/src/cli/helpers/errorFormatter.ts @@ -180,6 +180,93 @@ function getTierUsageLimitMessage(reasons: string[]): string | undefined { return undefined; } +const CHATGPT_USAGE_LIMIT_HINT = + "Switch models with /model, or connect your own provider keys with /connect."; + +/** + * Check if a string contains a ChatGPT usage_limit_reached error with optional + * reset timing, and return a friendly message. + * + * ChatGPT wraps the error as embedded JSON inside a detail string like: + * RATE_LIMIT_EXCEEDED: ChatGPT rate limit exceeded: {"error":{"type":"usage_limit_reached",...}} + */ +export function checkChatGptUsageLimitError(text: string): string | undefined { + if (!text.includes("usage_limit_reached")) return undefined; + + // Try to extract the embedded JSON object + const jsonStart = text.indexOf("{"); + if (jsonStart < 0) { + return `ChatGPT usage limit reached. ${CHATGPT_USAGE_LIMIT_HINT}`; + } + + try { + const parsed = JSON.parse(text.slice(jsonStart)); + const errorObj = parsed.error || parsed; + if (errorObj.type !== "usage_limit_reached") return undefined; + + // Extract plan type + const planType = errorObj.plan_type; + const planInfo = planType ? ` (${planType} plan)` : ""; + + // Extract reset timing — prefer resets_in_seconds, fall back to resets_at + let resetInfo = "Try again later"; + if ( + typeof errorObj.resets_in_seconds === "number" && + errorObj.resets_in_seconds > 0 + ) { + resetInfo = formatResetTime(errorObj.resets_in_seconds * 1000); + } else if (typeof errorObj.resets_at === "number") { + const resetMs = errorObj.resets_at * 1000 - Date.now(); + if (resetMs > 0) { + resetInfo = formatResetTime(resetMs); + } + } + + return `ChatGPT usage limit reached${planInfo}. ${resetInfo}.\n${CHATGPT_USAGE_LIMIT_HINT}`; + } catch { + // JSON parse failed — return generic message + return `ChatGPT usage limit reached. ${CHATGPT_USAGE_LIMIT_HINT}`; + } +} + +/** + * Walk an error object to find a ChatGPT usage_limit_reached detail string + * and format it. Handles APIError, nested run-metadata objects, and strings. + */ +function findAndFormatChatGptUsageLimit(e: unknown): string | undefined { + // Direct string + if (typeof e === "string") return checkChatGptUsageLimitError(e); + + if (typeof e !== "object" || e === null) return undefined; + + // APIError or Error — check .message + if (e instanceof Error) { + const msg = checkChatGptUsageLimitError(e.message); + if (msg) return msg; + } + + const obj = e as Record; + + // Check e.error.error.detail (run-metadata shape) + if (obj.error && typeof obj.error === "object") { + const errObj = obj.error as Record; + if (errObj.error && typeof errObj.error === "object") { + const inner = errObj.error as Record; + if (typeof inner.detail === "string") { + const msg = checkChatGptUsageLimitError(inner.detail); + if (msg) return msg; + } + } + // Check e.error.detail + if (typeof errObj.detail === "string") { + const msg = checkChatGptUsageLimitError(errObj.detail); + if (msg) return msg; + } + } + + return undefined; +} + const ENCRYPTED_CONTENT_HINT = [ "", "This occurs when the conversation contains messages with encrypted", @@ -307,6 +394,12 @@ export function formatErrorDetails( const encryptedContentMsg = checkEncryptedContentError(e); if (encryptedContentMsg) return encryptedContentMsg; + // Check for ChatGPT usage limit errors — walk nested error objects like + // checkEncryptedContentError does, since these arrive both as APIError + // and as plain run-metadata objects ({error: {error: {detail: "..."}}}) + const chatGptUsageLimitMsg = findAndFormatChatGptUsageLimit(e); + if (chatGptUsageLimitMsg) return chatGptUsageLimitMsg; + // Check for Z.ai provider errors (wrapped in generic "OpenAI" messages) const errorText = e instanceof APIError diff --git a/src/tests/cli/errorFormatter.test.ts b/src/tests/cli/errorFormatter.test.ts index f1a0b62..aae3610 100644 --- a/src/tests/cli/errorFormatter.test.ts +++ b/src/tests/cli/errorFormatter.test.ts @@ -4,7 +4,10 @@ import { clearErrorContext, setErrorContext, } from "../../cli/helpers/errorContext"; -import { formatErrorDetails } from "../../cli/helpers/errorFormatter"; +import { + checkChatGptUsageLimitError, + formatErrorDetails, +} from "../../cli/helpers/errorFormatter"; describe("formatErrorDetails", () => { beforeEach(() => { @@ -236,6 +239,91 @@ describe("formatErrorDetails", () => { expect(message).toContain("/model"); }); + describe("ChatGPT usage_limit_reached", () => { + const chatGptRateLimitDetail = + 'RATE_LIMIT_EXCEEDED: ChatGPT rate limit exceeded: {"error":{"type":"usage_limit_reached","message":"The usage limit has been reached","plan_type":"team","resets_at":1772074086,"eligible_promo":null,"resets_in_seconds":3032}}'; + + test("pretty-prints with reset time and plan type", () => { + const result = checkChatGptUsageLimitError(chatGptRateLimitDetail); + + expect(result).toBeDefined(); + expect(result).toContain("ChatGPT usage limit reached"); + expect(result).toContain("team plan"); + expect(result).toContain("Resets at"); + expect(result).toContain("/model"); + expect(result).toContain("/connect"); + // Should NOT contain raw JSON + expect(result).not.toContain('"type"'); + expect(result).not.toContain("RATE_LIMIT_EXCEEDED"); + }); + + test("handles error with only resets_at (no resets_in_seconds)", () => { + const futureTimestamp = Math.floor(Date.now() / 1000) + 3600; // 1 hour from now + const detail = `RATE_LIMIT_EXCEEDED: ChatGPT rate limit exceeded: {"error":{"type":"usage_limit_reached","message":"The usage limit has been reached","plan_type":"plus","resets_at":${futureTimestamp}}}`; + + const result = checkChatGptUsageLimitError(detail); + + expect(result).toBeDefined(); + expect(result).toContain("ChatGPT usage limit reached"); + expect(result).toContain("plus plan"); + expect(result).toContain("Resets at"); + }); + + test("handles error with no reset info gracefully", () => { + const detail = + 'RATE_LIMIT_EXCEEDED: ChatGPT rate limit exceeded: {"error":{"type":"usage_limit_reached","message":"The usage limit has been reached"}}'; + + const result = checkChatGptUsageLimitError(detail); + + expect(result).toBeDefined(); + expect(result).toContain("ChatGPT usage limit reached"); + expect(result).toContain("Try again later"); + expect(result).toContain("/model"); + }); + + test("handles malformed JSON gracefully", () => { + const detail = + "RATE_LIMIT_EXCEEDED: ChatGPT rate limit exceeded: usage_limit_reached {broken json"; + + const result = checkChatGptUsageLimitError(detail); + + expect(result).toBeDefined(); + expect(result).toContain("ChatGPT usage limit reached"); + }); + + test("returns undefined for non-matching errors", () => { + const result = checkChatGptUsageLimitError( + "ChatGPT API error: some other error", + ); + expect(result).toBeUndefined(); + }); + + test("formats correctly via formatErrorDetails from run metadata object", () => { + // Shape constructed in App.tsx from run.metadata.error + const errorObject = { + error: { + error: { + message_type: "error_message", + run_id: "run-abc123", + error_type: "llm_error", + message: "An error occurred during agent execution.", + detail: chatGptRateLimitDetail, + }, + run_id: "run-abc123", + }, + }; + + const result = formatErrorDetails(errorObject); + + expect(result).toContain("ChatGPT usage limit reached"); + expect(result).toContain("team plan"); + expect(result).toContain("/model"); + // Should NOT contain the raw detail + expect(result).not.toContain("RATE_LIMIT_EXCEEDED"); + expect(result).not.toContain("[usage_limit_reached]"); + }); + }); + test("formats Z.ai error from APIError with embedded error code", () => { const error = new APIError( 429, diff --git a/src/tests/turn-recovery-policy.test.ts b/src/tests/turn-recovery-policy.test.ts index bf8d521..67a967e 100644 --- a/src/tests/turn-recovery-policy.test.ts +++ b/src/tests/turn-recovery-policy.test.ts @@ -211,6 +211,16 @@ describe("provider detail retry helpers", () => { ).toBe(false); }); + test("ChatGPT usage_limit_reached is non-retryable", () => { + const detail = + 'RATE_LIMIT_EXCEEDED: ChatGPT rate limit exceeded: {"error":{"type":"usage_limit_reached","message":"The usage limit has been reached","plan_type":"team","resets_at":1772074086,"resets_in_seconds":3032}}'; + + expect(shouldRetryRunMetadataError("llm_error", detail)).toBe(false); + expect(shouldRetryPreStreamTransientError({ status: 429, detail })).toBe( + false, + ); + }); + test("pre-stream transient classifier handles status and detail", () => { expect( shouldRetryPreStreamTransientError({