From faa022c317fc955eeb57dcd7cf8073f463029047 Mon Sep 17 00:00:00 2001 From: Charles Packer Date: Mon, 9 Feb 2026 14:49:44 -0800 Subject: [PATCH] fix: clarify model availability and credit error guidance (#871) Co-authored-by: Letta --- src/cli/App.tsx | 19 ++-- src/cli/components/ModelSelector.tsx | 16 +-- src/cli/helpers/errorFormatter.ts | 139 ++++++++++++++++++--------- src/tests/cli/errorFormatter.test.ts | 106 ++++++++++++++++++++ 4 files changed, 218 insertions(+), 62 deletions(-) create mode 100644 src/tests/cli/errorFormatter.test.ts diff --git a/src/cli/App.tsx b/src/cli/App.tsx index ce5a0db..5ce2410 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -9097,10 +9097,9 @@ ${SYSTEM_REMINDER_CLOSE} } if (!selectedModel) { - const cmd = - overlayCommand ?? - commandRunner.start("/model", `Model not found: ${modelId}`); - cmd.fail(`Model not found: ${modelId}`); + const output = `Model not found: ${modelId}. Run /model and press R to refresh available models.`; + const cmd = overlayCommand ?? commandRunner.start("/model", output); + cmd.fail(output); return; } const model = selectedModel; @@ -9191,10 +9190,18 @@ ${SYSTEM_REMINDER_CLOSE} }); } catch (error) { const errorDetails = formatErrorDetails(error, agentId); + const modelLabel = selectedModel?.label ?? modelId; + const guidance = + "Run /model and press R to refresh available models. If the model is still unavailable, choose another model or connect a provider with /connect."; const cmd = overlayCommand ?? - commandRunner.start("/model", "Failed to switch model."); - cmd.fail(`Failed to switch model: ${errorDetails}`); + commandRunner.start( + "/model", + `Failed to switch model to ${modelLabel}.`, + ); + cmd.fail( + `Failed to switch model to ${modelLabel}: ${errorDetails}\n${guidance}`, + ); } }, [ diff --git a/src/cli/components/ModelSelector.tsx b/src/cli/components/ModelSelector.tsx index 0a2520b..9d05b76 100644 --- a/src/cli/components/ModelSelector.tsx +++ b/src/cli/components/ModelSelector.tsx @@ -543,26 +543,26 @@ export function ModelSelector({ const getCategoryDescription = (cat: ModelCategory) => { if (cat === "server-recommended") { - return "Recommended models on the server"; + return "Recommended models currently available for this account"; } if (cat === "server-all") { - return "All models on the server"; + return "All models currently available for this account"; } if (cat === "supported") { return isFreeTier ? "Upgrade your account to access more models" - : "Recommended models on the Letta API"; + : "Recommended Letta API models currently available for this account"; } if (cat === "byok") - return "Recommended models via your API keys (use /connect to add more)"; + return "Recommended models via your connected API keys (use /connect to add more)"; if (cat === "byok-all") - return "All models via your API keys (use /connect to add more)"; + return "All models via your connected API keys (use /connect to add more)"; if (cat === "all") { return isFreeTier ? "Upgrade your account to access more models" - : "All models on the Letta API"; + : "All Letta API models currently available for this account"; } - return "All models on the Letta API"; + return "All Letta API models currently available for this account"; }; // Render tab bar (matches AgentSelector style) @@ -700,7 +700,7 @@ export function ModelSelector({ {" "} {currentList.length} models{isCached ? " · cached" : ""} · R to - refresh + refresh availability {" "}Enter select · ↑↓ navigate · ←→/Tab switch · Esc cancel diff --git a/src/cli/helpers/errorFormatter.ts b/src/cli/helpers/errorFormatter.ts index 5f58cbe..7eb40a4 100644 --- a/src/cli/helpers/errorFormatter.ts +++ b/src/cli/helpers/errorFormatter.ts @@ -5,6 +5,64 @@ const LETTA_USAGE_URL = "https://app.letta.com/settings/organization/usage"; const LETTA_AGENTS_URL = "https://app.letta.com/projects/default-project/agents"; +function extractReasonList(value: unknown): string[] { + if (!Array.isArray(value)) return []; + + return value + .filter((reason): reason is string => typeof reason === "string") + .map((reason) => reason.toLowerCase()); +} + +function getErrorReasons(e: APIError): string[] { + const reasons = new Set(); + + const errorBody = e.error; + if (errorBody && typeof errorBody === "object") { + const body = errorBody as Record; + + for (const reason of extractReasonList(body.reasons)) { + reasons.add(reason); + } + + if (body.error && typeof body.error === "object") { + const nested = body.error as Record; + for (const reason of extractReasonList(nested.reasons)) { + reasons.add(reason); + } + } + } + + // Fallback: infer known reasons from message text. + const message = e.message?.toLowerCase() ?? ""; + for (const knownReason of [ + "not-enough-credits", + "model-unknown", + "byok-not-available-on-free-tier", + "free-usage-exceeded", + "premium-usage-exceeded", + "standard-usage-exceeded", + "basic-usage-exceeded", + "context-window-size-not-supported", + "agents-limit-exceeded", + "exceeded-quota", + ]) { + if (message.includes(knownReason)) { + reasons.add(knownReason); + } + } + + return Array.from(reasons); +} + +function hasErrorReason( + e: APIError, + reason: string, + reasons?: string[], +): boolean { + const allReasons = reasons ?? getErrorReasons(e); + return allReasons.includes(reason.toLowerCase()); +} + /** * 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 @@ -94,53 +152,18 @@ function getResourceLimitMessage(e: APIError): string | undefined { /** * Check if the error is an agent limit error (429 with agents-limit-exceeded) */ -function isAgentLimitError(e: APIError): boolean { +function isAgentLimitError(e: APIError, reasons?: string[]): 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; + return hasErrorReason(e, "agents-limit-exceeded", reasons); } /** * Check if the error is a credit exhaustion error (402 with not-enough-credits) */ -function isCreditExhaustedError(e: APIError): boolean { +function isCreditExhaustedError(e: APIError, reasons?: string[]): 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; - 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; + return hasErrorReason(e, "not-enough-credits", reasons); } /** @@ -159,6 +182,8 @@ export function formatErrorDetails( // Handle APIError from streaming (event: error) if (e instanceof APIError) { + const reasons = getErrorReasons(e); + // Check for rate limit error first - provide a friendly message with reset time const rateLimitResetMs = getRateLimitResetMs(e); if (rateLimitResetMs !== undefined) { @@ -170,7 +195,7 @@ export function formatErrorDetails( } // Check for agent limit error (free tier agent count limit) - if (isAgentLimitError(e)) { + if (isAgentLimitError(e, reasons)) { const { billingTier } = getErrorContext(); if (billingTier?.toLowerCase() === "free") { @@ -181,6 +206,14 @@ export function formatErrorDetails( return `You've reached your agent limit. Delete agents at: ${LETTA_AGENTS_URL}\nOr check your plan at: ${LETTA_USAGE_URL}`; } + if (hasErrorReason(e, "model-unknown", reasons)) { + return `The selected model is not currently available for this account or provider. Run /model and press R to refresh available models, then choose an available model or connect a provider with /connect.`; + } + + if (hasErrorReason(e, "context-window-size-not-supported", reasons)) { + return `The selected context window is not supported for this model. Switch models with /model or pick a model with a larger context window.`; + } + // Check for resource limit error (e.g., "You have reached your limit for agents") const resourceLimitMsg = getResourceLimitMessage(e); if (resourceLimitMsg) { @@ -191,16 +224,26 @@ export function formatErrorDetails( } // Check for credit exhaustion error - provide a friendly message - if (isCreditExhaustedError(e)) { - const { billingTier, modelDisplayName } = getErrorContext(); + if (isCreditExhaustedError(e, reasons)) { + return `Your account is out of credits for hosted inference. Add credits, enable auto-recharge, or upgrade at ${LETTA_USAGE_URL}. You can also connect your own provider keys with /connect.`; + } - // 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.`; - } + if ( + hasErrorReason(e, "premium-usage-exceeded", reasons) || + hasErrorReason(e, "standard-usage-exceeded", reasons) || + hasErrorReason(e, "basic-usage-exceeded", reasons) + ) { + return `You've reached your hosted model usage limit. View your plan and usage at ${LETTA_USAGE_URL}, or connect your own provider keys with /connect.`; + } - return `Your account is out of credits. Redeem additional credits or configure auto-recharge on your account page: ${LETTA_USAGE_URL}`; + if (hasErrorReason(e, "byok-not-available-on-free-tier", reasons)) { + const { modelDisplayName } = getErrorContext(); + const modelInfo = modelDisplayName ? ` (${modelDisplayName})` : ""; + return `Selected BYOK model${modelInfo} is not available on the Free plan. Switch to a free hosted model with /model (glm-4.7 or minimax-m2.1), or upgrade at ${LETTA_USAGE_URL}.`; + } + + if (hasErrorReason(e, "free-usage-exceeded", reasons)) { + return `You've reached the Free plan hosted model usage limit. Switch to free hosted models with /model (glm-4.7 or minimax-m2.1), upgrade at ${LETTA_USAGE_URL}, or connect your own provider keys with /connect.`; } // Check for nested error structure: e.error.error if (e.error && typeof e.error === "object" && "error" in e.error) { diff --git a/src/tests/cli/errorFormatter.test.ts b/src/tests/cli/errorFormatter.test.ts new file mode 100644 index 0000000..8cab50b --- /dev/null +++ b/src/tests/cli/errorFormatter.test.ts @@ -0,0 +1,106 @@ +import { beforeEach, describe, expect, test } from "bun:test"; +import { APIError } from "@letta-ai/letta-client/core/error"; +import { + clearErrorContext, + setErrorContext, +} from "../../cli/helpers/errorContext"; +import { formatErrorDetails } from "../../cli/helpers/errorFormatter"; + +describe("formatErrorDetails", () => { + beforeEach(() => { + clearErrorContext(); + }); + + test("uses neutral credit exhaustion copy for free tier not-enough-credits", () => { + setErrorContext({ billingTier: "free", modelDisplayName: "Kimi K2.5" }); + + const error = new APIError( + 402, + { + error: "Rate limited", + reasons: ["not-enough-credits"], + }, + undefined, + new Headers(), + ); + + const message = formatErrorDetails(error); + + expect(message).toContain("out of credits"); + expect(message).toContain("/connect"); + expect(message).not.toContain("not available on Free plan"); + expect(message).not.toContain("Selected hosted model"); + }); + + test("handles nested reasons for credit exhaustion", () => { + const error = new APIError( + 402, + { + error: { + reasons: ["not-enough-credits"], + }, + }, + undefined, + new Headers(), + ); + + const message = formatErrorDetails(error); + expect(message).toContain("out of credits"); + }); + + test("shows explicit model availability guidance for model-unknown", () => { + const error = new APIError( + 429, + { + error: "Rate limited", + reasons: ["model-unknown"], + }, + undefined, + new Headers(), + ); + + const message = formatErrorDetails(error); + + expect(message).toContain("not currently available"); + expect(message).toContain("Run /model"); + expect(message).toContain("press R"); + }); + + test("keeps canonical free model pair for byok-not-available-on-free-tier", () => { + setErrorContext({ modelDisplayName: "GPT-5" }); + + const error = new APIError( + 403, + { + error: "Forbidden", + reasons: ["byok-not-available-on-free-tier"], + }, + undefined, + new Headers(), + ); + + const message = formatErrorDetails(error); + + expect(message).toContain("glm-4.7"); + expect(message).toContain("minimax-m2.1"); + expect(message).toContain("Free plan"); + }); + + test("keeps canonical free model pair for free-usage-exceeded", () => { + const error = new APIError( + 429, + { + error: "Rate limited", + reasons: ["free-usage-exceeded"], + }, + undefined, + new Headers(), + ); + + const message = formatErrorDetails(error); + + expect(message).toContain("glm-4.7"); + expect(message).toContain("minimax-m2.1"); + expect(message).toContain("/model"); + }); +});