fix: pretty-print ChatGPT usage_limit_reached errors with reset time (#1177)
Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
// Check e.error.error.detail (run-metadata shape)
|
||||
if (obj.error && typeof obj.error === "object") {
|
||||
const errObj = obj.error as Record<string, unknown>;
|
||||
if (errObj.error && typeof errObj.error === "object") {
|
||||
const inner = errObj.error as Record<string, unknown>;
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user