From e942f7870b74f72ac05e30f4df7332fae46d217e Mon Sep 17 00:00:00 2001 From: Sarah Wooders Date: Wed, 4 Mar 2026 17:43:47 -0800 Subject: [PATCH] fix(model): align free-tier model dropdown behavior and default to GLM-5 (#1263) Co-authored-by: Letta Code --- src/agent/model.ts | 6 ++-- src/agent/subagents/manager.ts | 26 +++++++++++++-- src/cli/components/ModelSelector.tsx | 29 ++++------------- src/index.ts | 2 +- .../agent/default-model-for-tier.test.ts | 19 +++++++++++ .../agent/subagent-model-resolution.test.ts | 32 +++++++++++++++++++ .../cli/model-selector-categories.test.ts | 28 ++++++++++++++++ 7 files changed, 114 insertions(+), 28 deletions(-) create mode 100644 src/tests/agent/default-model-for-tier.test.ts create mode 100644 src/tests/cli/model-selector-categories.test.ts diff --git a/src/agent/model.ts b/src/agent/model.ts index a651641..2830372 100644 --- a/src/agent/model.ts +++ b/src/agent/model.ts @@ -89,14 +89,14 @@ export function getDefaultModel(): string { /** * Get the default model handle based on billing tier. - * Free tier users get glm-4.7, everyone else gets the standard default. + * Free tier users get GLM-5, everyone else gets the standard default. * @param billingTier - The user's billing tier (e.g., "free", "pro", "enterprise") * @returns The model handle to use as default */ export function getDefaultModelForTier(billingTier?: string | null): string { - // Free tier gets minimax-m2.5 (a free model) + // Free tier gets GLM-5. if (billingTier?.toLowerCase() === "free") { - const freeDefault = models.find((m) => m.id === "minimax-m2.5"); + const freeDefault = models.find((m) => m.id === "glm-5"); if (freeDefault) return freeDefault.handle; } // Everyone else (pro, enterprise, unknown) gets the standard default diff --git a/src/agent/subagents/manager.ts b/src/agent/subagents/manager.ts index b1b8fb3..f6800a9 100644 --- a/src/agent/subagents/manager.ts +++ b/src/agent/subagents/manager.ts @@ -28,7 +28,7 @@ import { getErrorMessage } from "../../utils/error"; import { getAvailableModelHandles } from "../available-models"; import { getClient } from "../client"; import { getCurrentAgentId } from "../context"; -import { resolveModel } from "../model"; +import { getDefaultModelForTier, resolveModel } from "../model"; import { getAllSubagentConfigs, type SubagentConfig } from "."; @@ -91,6 +91,18 @@ async function getPrimaryAgentModelHandle(): Promise { } } +async function getCurrentBillingTier(): Promise { + try { + const client = await getClient(); + const balance = await client.get<{ billing_tier?: string }>( + "/v1/metadata/balance", + ); + return balance.billing_tier ?? null; + } catch { + return null; + } +} + /** * Check if an error message indicates an unsupported provider */ @@ -140,9 +152,11 @@ export async function resolveSubagentModel(options: { userModel?: string; recommendedModel?: string; parentModelHandle?: string | null; + billingTier?: string | null; availableHandles?: Set; }): Promise { - const { userModel, recommendedModel, parentModelHandle } = options; + const { userModel, recommendedModel, parentModelHandle, billingTier } = + options; if (userModel) return userModel; @@ -151,6 +165,12 @@ export async function resolveSubagentModel(options: { recommendedHandle = resolveModel(recommendedModel); } + // Free-tier users should default subagents to GLM-5 instead of provider-specific + // recommendations like Sonnet. + if (recommendedModel !== "inherit" && billingTier?.toLowerCase() === "free") { + recommendedHandle = getDefaultModelForTier(billingTier); + } + let availableHandles: Set | null = options.availableHandles ?? null; const isAvailable = async (handle: string): Promise => { try { @@ -869,6 +889,7 @@ export async function spawnSubagent( ); const parentModelHandle = await getPrimaryAgentModelHandle(); + const billingTier = await getCurrentBillingTier(); // For existing agents, don't override model; for new agents, use provided or config default const model = isDeployingExisting @@ -877,6 +898,7 @@ export async function spawnSubagent( userModel, recommendedModel: config.recommendedModel, parentModelHandle, + billingTier, }); const baseURL = getBaseURL(); diff --git a/src/cli/components/ModelSelector.tsx b/src/cli/components/ModelSelector.tsx index a6c80e0..bcafc1b 100644 --- a/src/cli/components/ModelSelector.tsx +++ b/src/cli/components/ModelSelector.tsx @@ -28,19 +28,17 @@ type ModelCategory = // BYOK provider prefixes (ChatGPT OAuth + lc-* providers from /connect) const BYOK_PROVIDER_PREFIXES = ["chatgpt-plus-pro/", "lc-"]; -// Get tab order based on billing tier (free = BYOK first, paid = BYOK last) -// For self-hosted servers, only show server-specific tabs -function getModelCategories( - billingTier?: string, +// Get tab order for model categories. +// For self-hosted servers, only show server-specific tabs. +// For Letta-hosted, keep ordering consistent across billing tiers. +export function getModelCategories( + _billingTier?: string, isSelfHosted?: boolean, ): ModelCategory[] { if (isSelfHosted) { return ["server-recommended", "server-all"]; } - const isFreeTier = billingTier?.toLowerCase() === "free"; - return isFreeTier - ? ["byok", "byok-all", "supported", "all"] - : ["supported", "all", "byok", "byok-all"]; + return ["supported", "all", "byok", "byok-all"]; } type UiModel = { @@ -83,7 +81,7 @@ interface ModelSelectorProps { filterProvider?: string; /** Force refresh the models list on mount */ forceRefresh?: boolean; - /** User's billing tier - affects tab ordering (free = BYOK first) */ + /** User's billing tier (kept for compatibility and future gating logic) */ billingTier?: string; /** Whether connected to a self-hosted server (not api.letta.com) */ isSelfHosted?: boolean; @@ -102,7 +100,6 @@ export function ModelSelector({ const solidLine = SOLID_LINE.repeat(Math.max(terminalWidth, 10)); const typedModels = models as UiModel[]; - // Tab order depends on billing tier (free = BYOK first) // For self-hosted, only show server-specific tabs const modelCategories = useMemo( () => getModelCategories(billingTier, isSelfHosted), @@ -198,8 +195,6 @@ export function ModelSelector({ // Supported models: models.json entries that are available // Featured models first, then non-featured, preserving JSON order within each group // If filterProvider is set, only show models from that provider - // For free tier, free models go first - const isFreeTier = billingTier?.toLowerCase() === "free"; const supportedModels = useMemo(() => { if (availableHandles === undefined) return []; let available = filterModelsByAvailabilityForSelector( @@ -235,15 +230,6 @@ export function ModelSelector({ deduped.push(pickPreferredStaticModel(m.handle) ?? m); } - // For free tier, put free models first, then others with standard ordering - if (isFreeTier) { - const freeModels = deduped.filter((m) => m.free); - const paidModels = deduped.filter((m) => !m.free); - const featured = paidModels.filter((m) => m.isFeatured); - const nonFeatured = paidModels.filter((m) => !m.isFeatured); - return [...freeModels, ...featured, ...nonFeatured]; - } - const featured = deduped.filter((m) => m.isFeatured); const nonFeatured = deduped.filter((m) => !m.isFeatured); return [...featured, ...nonFeatured]; @@ -253,7 +239,6 @@ export function ModelSelector({ allApiHandles, filterProvider, searchQuery, - isFreeTier, pickPreferredStaticModel, ]); diff --git a/src/index.ts b/src/index.ts index ad3dd01..4defa4d 100755 --- a/src/index.ts +++ b/src/index.ts @@ -1597,7 +1597,7 @@ async function main(): Promise { // Determine effective model: // 1. Use selectedServerModel if user picked from self-hosted picker // 2. Use model if --model flag was passed - // 3. Otherwise, use billing-tier-aware default (free tier gets glm-4.7) + // 3. Otherwise, use billing-tier-aware default (free tier gets GLM-5) let effectiveModel = selectedServerModel || model; if (!effectiveModel && !selfHostedBaseUrl) { // On Letta API without explicit model - check billing tier for appropriate default diff --git a/src/tests/agent/default-model-for-tier.test.ts b/src/tests/agent/default-model-for-tier.test.ts new file mode 100644 index 0000000..11d7545 --- /dev/null +++ b/src/tests/agent/default-model-for-tier.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, test } from "bun:test"; + +import { getDefaultModel, getDefaultModelForTier } from "../../agent/model"; + +describe("getDefaultModelForTier", () => { + test("returns GLM-5 for free tier", () => { + expect(getDefaultModelForTier("free")).toBe("zai/glm-5"); + }); + + test("is case-insensitive for free tier", () => { + expect(getDefaultModelForTier("FrEe")).toBe("zai/glm-5"); + }); + + test("returns standard default for non-free tiers", () => { + expect(getDefaultModelForTier("pro")).toBe(getDefaultModel()); + expect(getDefaultModelForTier("enterprise")).toBe(getDefaultModel()); + expect(getDefaultModelForTier(null)).toBe(getDefaultModel()); + }); +}); diff --git a/src/tests/agent/subagent-model-resolution.test.ts b/src/tests/agent/subagent-model-resolution.test.ts index 46ac369..4eaa941 100644 --- a/src/tests/agent/subagent-model-resolution.test.ts +++ b/src/tests/agent/subagent-model-resolution.test.ts @@ -206,4 +206,36 @@ describe("resolveSubagentModel", () => { expect(result).toBe("lc-anthropic/parent-model"); }); + + test("uses GLM-5 default for free tier even when subagent recommends another model", async () => { + const result = await resolveSubagentModel({ + recommendedModel: "sonnet-4.5", + billingTier: "free", + availableHandles: new Set(["zai/glm-5"]), + }); + + expect(result).toBe("zai/glm-5"); + }); + + test("keeps inherit behavior for free tier", async () => { + const result = await resolveSubagentModel({ + recommendedModel: "inherit", + parentModelHandle: "openai/gpt-5", + billingTier: "free", + availableHandles: new Set(["openai/gpt-5"]), + }); + + expect(result).toBe("openai/gpt-5"); + }); + + test("user-provided model still overrides free-tier default", async () => { + const result = await resolveSubagentModel({ + userModel: "openai/gpt-5", + recommendedModel: "sonnet-4.5", + billingTier: "free", + availableHandles: new Set(["zai/glm-5", "openai/gpt-5"]), + }); + + expect(result).toBe("openai/gpt-5"); + }); }); diff --git a/src/tests/cli/model-selector-categories.test.ts b/src/tests/cli/model-selector-categories.test.ts new file mode 100644 index 0000000..f1f679d --- /dev/null +++ b/src/tests/cli/model-selector-categories.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from "bun:test"; + +import { getModelCategories } from "../../cli/components/ModelSelector"; + +describe("getModelCategories", () => { + test("uses the same hosted category order for free and paid tiers", () => { + expect(getModelCategories("free", false)).toEqual([ + "supported", + "all", + "byok", + "byok-all", + ]); + + expect(getModelCategories("pro", false)).toEqual([ + "supported", + "all", + "byok", + "byok-all", + ]); + }); + + test("keeps self-hosted categories unchanged", () => { + expect(getModelCategories("free", true)).toEqual([ + "server-recommended", + "server-all", + ]); + }); +});