fix: clarify model availability and credit error guidance (#871)

Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
Charles Packer
2026-02-09 14:49:44 -08:00
committed by GitHub
parent 8e5bc3956f
commit faa022c317
4 changed files with 218 additions and 62 deletions

View File

@@ -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}`,
);
}
},
[

View File

@@ -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({
<Text dimColor>
{" "}
{currentList.length} models{isCached ? " · cached" : ""} · R to
refresh
refresh availability
</Text>
<Text dimColor>
{" "}Enter select · navigate · /Tab switch · Esc cancel

View File

@@ -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<string>();
const errorBody = e.error;
if (errorBody && typeof errorBody === "object") {
const body = errorBody as Record<string, unknown>;
for (const reason of extractReasonList(body.reasons)) {
reasons.add(reason);
}
if (body.error && typeof body.error === "object") {
const nested = body.error as Record<string, unknown>;
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<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;
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) {

View File

@@ -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");
});
});