Files
letta-code/src/cli/helpers/errorFormatter.ts
2026-01-26 22:10:58 -08:00

291 lines
9.7 KiB
TypeScript

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 an agent limit error (429 with agents-limit-exceeded)
*/
function isAgentLimitError(e: APIError): boolean {
if (e.status !== 429) return false;
const errorBody = e.error;
if (errorBody && typeof errorBody === "object") {
if ("reasons" in errorBody && Array.isArray(errorBody.reasons)) {
if (errorBody.reasons.includes("agents-limit-exceeded")) {
return true;
}
}
}
return false;
}
/**
* Check if the error is a credit exhaustion error (402 with not-enough-credits)
*/
function isCreditExhaustedError(e: APIError): boolean {
// Check status code
if (e.status !== 402) return false;
// Check for "not-enough-credits" in various places it could appear
const errorBody = e.error;
if (errorBody && typeof errorBody === "object") {
// Check reasons array: {"error":"Rate limited","reasons":["not-enough-credits"]}
if ("reasons" in errorBody && Array.isArray(errorBody.reasons)) {
if (errorBody.reasons.includes("not-enough-credits")) {
return true;
}
}
// Check nested error.reasons
if ("error" in errorBody && typeof errorBody.error === "object") {
const nested = errorBody.error as Record<string, unknown>;
if ("reasons" in nested && Array.isArray(nested.reasons)) {
if (nested.reasons.includes("not-enough-credits")) {
return true;
}
}
}
}
// Also check the message for "not-enough-credits" as a fallback
if (e.message?.includes("not-enough-credits")) {
return true;
}
return false;
}
/**
* Extract comprehensive error details from any error object
* Handles APIError, Error, and other error types consistently
* @param e The error object to format
* @param agentId Optional agent ID to create hyperlinks to the Letta dashboard
* @param conversationId Optional conversation ID to include in agent links
*/
export function formatErrorDetails(
e: unknown,
agentId?: string,
conversationId?: string,
): string {
let runId: string | undefined;
// Handle APIError from streaming (event: error)
if (e instanceof APIError) {
// 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 agent limit error (free tier agent count limit)
if (isAgentLimitError(e)) {
const { billingTier } = getErrorContext();
if (billingTier?.toLowerCase() === "free") {
return `You've reached the agent limit (3) for the Free Plan. Delete agents at: ${LETTA_AGENTS_URL}\nOr upgrade to Pro for unlimited agents at: ${LETTA_USAGE_URL}`;
}
// Fallback for paid tiers (shouldn't normally hit this, but just in case)
return `You've reached your agent limit. Delete agents at: ${LETTA_AGENTS_URL}\nOr check your plan at: ${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 or minimax-m2.1), 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
if (e.error && typeof e.error === "object" && "error" in e.error) {
const errorData = e.error.error;
if (errorData && typeof errorData === "object") {
const type = "type" in errorData ? errorData.type : undefined;
const message =
"message" in errorData ? errorData.message : "An error occurred";
const detail = "detail" in errorData ? errorData.detail : undefined;
const errorType = type ? `[${type}] ` : "";
const errorDetail = detail ? `\nDetail: ${detail}` : "";
// Extract run_id from e.error
if ("run_id" in e.error && typeof e.error.run_id === "string") {
runId = e.error.run_id;
}
const baseError = `${errorType}${message}${errorDetail}`;
return runId && agentId
? `${baseError}\n${createAgentLink(runId, agentId, conversationId)}`
: baseError;
}
}
// Handle APIError with direct error structure: e.error.detail
if (e.error && typeof e.error === "object") {
const detail = "detail" in e.error ? e.error.detail : undefined;
if ("run_id" in e.error && typeof e.error.run_id === "string") {
runId = e.error.run_id;
}
// When detail is available, prefer showing just the detail to avoid redundancy
// (e.message often contains the full JSON body like '409 {"detail":"CONFLICT: ..."}')
const baseError =
detail && typeof detail === "string" ? detail : e.message;
return runId && agentId
? `${baseError}\n${createAgentLink(runId, agentId, conversationId)}`
: baseError;
}
// Fallback for APIError with just message
return e.message;
}
// Handle regular Error objects
if (e instanceof Error) {
return e.message;
}
// Fallback for any other type (e.g., plain objects thrown by SDK or other code)
if (typeof e === "object" && e !== null) {
const obj = e as Record<string, unknown>;
// Check common error-like properties
if (typeof obj.message === "string") {
return obj.message;
}
if (typeof obj.error === "string") {
return obj.error;
}
if (typeof obj.detail === "string") {
return obj.detail;
}
// Last resort: JSON stringify
try {
return JSON.stringify(e, null, 2);
} catch {
return "[Error: Unable to serialize error object]";
}
}
return String(e);
}
/**
* Create a terminal hyperlink to the agent with run ID displayed
*/
function createAgentLink(
runId: string,
agentId: string,
conversationId?: string,
): string {
const url = `https://app.letta.com/agents/${agentId}${conversationId ? `?conversation=${conversationId}` : ""}`;
return `View agent: \x1b]8;;${url}\x1b\\${agentId}\x1b]8;;\x1b\\ (run: ${runId})`;
}