fix: clarify model availability and credit error guidance (#871)
Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
@@ -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}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
[
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
106
src/tests/cli/errorFormatter.test.ts
Normal file
106
src/tests/cli/errorFormatter.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user