fix: clean up error messages (#659)
Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
@@ -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<string | null>(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 () => {
|
||||
|
||||
32
src/cli/helpers/errorContext.ts
Normal file
32
src/cli/helpers/errorContext.ts
Normal file
@@ -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<ErrorContext>): void {
|
||||
currentContext = { ...currentContext, ...context };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current error context
|
||||
*/
|
||||
export function getErrorContext(): ErrorContext {
|
||||
return currentContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the error context
|
||||
*/
|
||||
export function clearErrorContext(): void {
|
||||
currentContext = {};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user