From 0323e5e423bf2db5e1164f48a5958962dfa340ba Mon Sep 17 00:00:00 2001 From: Charles Packer Date: Sat, 24 Jan 2026 14:53:33 -0800 Subject: [PATCH] fix: clean up error messages (#659) Co-authored-by: Letta --- src/cli/App.tsx | 11 ++- src/cli/helpers/errorContext.ts | 32 ++++++++ src/cli/helpers/errorFormatter.ts | 118 +++++++++++++++++++++++++++++- 3 files changed, 159 insertions(+), 2 deletions(-) create mode 100644 src/cli/helpers/errorContext.ts diff --git a/src/cli/App.tsx b/src/cli/App.tsx index 3a2b288..450ff67 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -150,6 +150,7 @@ import { computeAdvancedDiff, parsePatchToAdvancedDiff, } from "./helpers/diff"; +import { setErrorContext } from "./helpers/errorContext"; import { formatErrorDetails } from "./helpers/errorFormatter"; import { parsePatchOperations } from "./helpers/formatArgsDisplay"; import { @@ -1055,9 +1056,17 @@ export default function App({ : null; const currentModelProvider = llmConfig?.provider_name ?? null; - // Billing tier for conditional UI (fetched once on mount) + // Billing tier for conditional UI and error context (fetched once on mount) const [billingTier, setBillingTier] = useState(null); + // Update error context when model or billing tier changes + useEffect(() => { + setErrorContext({ + modelDisplayName: currentModelDisplay ?? undefined, + billingTier: billingTier ?? undefined, + }); + }, [currentModelDisplay, billingTier]); + // Fetch billing tier once on mount useEffect(() => { (async () => { diff --git a/src/cli/helpers/errorContext.ts b/src/cli/helpers/errorContext.ts new file mode 100644 index 0000000..b770f82 --- /dev/null +++ b/src/cli/helpers/errorContext.ts @@ -0,0 +1,32 @@ +/** + * Global context for error formatting. + * Allows the error formatter to access user/agent context without threading it through every call site. + */ + +interface ErrorContext { + billingTier?: string; + modelDisplayName?: string; +} + +let currentContext: ErrorContext = {}; + +/** + * Set the error context (call when agent loads or billing info is fetched) + */ +export function setErrorContext(context: Partial): void { + currentContext = { ...currentContext, ...context }; +} + +/** + * Get the current error context + */ +export function getErrorContext(): ErrorContext { + return currentContext; +} + +/** + * Clear the error context + */ +export function clearErrorContext(): void { + currentContext = {}; +} diff --git a/src/cli/helpers/errorFormatter.ts b/src/cli/helpers/errorFormatter.ts index fbc13c8..b9fb274 100644 --- a/src/cli/helpers/errorFormatter.ts +++ b/src/cli/helpers/errorFormatter.ts @@ -1,6 +1,95 @@ import { APIError } from "@letta-ai/letta-client/core/error"; +import { getErrorContext } from "./errorContext"; const LETTA_USAGE_URL = "https://app.letta.com/settings/organization/usage"; +const LETTA_AGENTS_URL = + "https://app.letta.com/projects/default-project/agents"; + +/** + * Check if the error is a rate limit error (429 with exceeded-quota) + * Returns the timeToQuotaResetMs if it's a rate limit error, undefined otherwise + */ +function getRateLimitResetMs(e: APIError): number | undefined { + if (e.status !== 429) return undefined; + + const errorBody = e.error; + if (errorBody && typeof errorBody === "object") { + // Check for reasons array with "exceeded-quota" + if ("reasons" in errorBody && Array.isArray(errorBody.reasons)) { + if (errorBody.reasons.includes("exceeded-quota")) { + if ( + "timeToQuotaResetMs" in errorBody && + typeof errorBody.timeToQuotaResetMs === "number" + ) { + return errorBody.timeToQuotaResetMs; + } + // Return 0 to indicate rate limited but no reset time available + return 0; + } + } + } + return undefined; +} + +/** + * Format a time duration in milliseconds to a human-readable string + */ +function formatResetTime(ms: number): string { + const now = new Date(); + const resetTime = new Date(now.getTime() + ms); + + // Format the reset time + const timeStr = resetTime.toLocaleTimeString([], { + hour: "numeric", + minute: "2-digit", + }); + + // Calculate human-readable duration + const totalMinutes = Math.ceil(ms / 60000); + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + + let durationStr: string; + if (hours > 0 && minutes > 0) { + durationStr = `${hours}h ${minutes}m`; + } else if (hours > 0) { + durationStr = `${hours}h`; + } else { + durationStr = `${minutes}m`; + } + + return `Resets at ${timeStr} (${durationStr})`; +} + +/** + * Check if the error is a resource limit error (402 with "You have reached your limit for X") + * Returns the error message if it matches, undefined otherwise + */ +function getResourceLimitMessage(e: APIError): string | undefined { + if (e.status !== 402) return undefined; + + const errorBody = e.error; + if (errorBody && typeof errorBody === "object") { + if ( + "error" in errorBody && + typeof errorBody.error === "string" && + errorBody.error.includes("You have reached your limit for") + ) { + return errorBody.error; + } + } + + // Also check the message directly + if (e.message?.includes("You have reached your limit for")) { + // Extract just the error message part, not the full "402 {...}" string + const match = e.message.match(/"error":"([^"]+)"/); + if (match) { + return match[1]; + } + } + + return undefined; +} /** * Check if the error is a credit exhaustion error (402 with not-enough-credits) @@ -53,8 +142,35 @@ export function formatErrorDetails( // Handle APIError from streaming (event: error) if (e instanceof APIError) { - // Check for credit exhaustion error first - provide a friendly message + // Check for rate limit error first - provide a friendly message with reset time + const rateLimitResetMs = getRateLimitResetMs(e); + if (rateLimitResetMs !== undefined) { + const resetInfo = + rateLimitResetMs > 0 + ? formatResetTime(rateLimitResetMs) + : "Try again later"; + return `You've hit your usage limit. ${resetInfo}. View usage: ${LETTA_USAGE_URL}`; + } + + // Check for resource limit error (e.g., "You have reached your limit for agents") + const resourceLimitMsg = getResourceLimitMessage(e); + if (resourceLimitMsg) { + // Extract the resource type (agents, tools, etc.) from the message + const match = resourceLimitMsg.match(/limit for (\w+)/); + const resourceType = match ? match[1] : "resources"; + return `${resourceLimitMsg}\nUpgrade at: ${LETTA_USAGE_URL}\nDelete ${resourceType} at: ${LETTA_AGENTS_URL}`; + } + + // Check for credit exhaustion error - provide a friendly message if (isCreditExhaustedError(e)) { + const { billingTier, modelDisplayName } = getErrorContext(); + + // Free plan users get a special message about BYOK and free models + if (billingTier?.toLowerCase() === "free") { + const modelInfo = modelDisplayName ? ` (${modelDisplayName})` : ""; + return `Selected hosted model${modelInfo} not available on Free plan. Switch to a free model with /model glm-4.7, upgrade your account at ${LETTA_USAGE_URL}, or connect your own API keys with /connect.`; + } + return `Your account is out of credits. Redeem additional credits or configure auto-recharge on your account page: ${LETTA_USAGE_URL}`; } // Check for nested error structure: e.error.error