fix(model): align free-tier model dropdown behavior and default to GLM-5 (#1263)

Co-authored-by: Letta Code <noreply@letta.com>
This commit is contained in:
Sarah Wooders
2026-03-04 17:43:47 -08:00
committed by GitHub
parent ee00ac7280
commit e942f7870b
7 changed files with 114 additions and 28 deletions

View File

@@ -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

View File

@@ -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<string | null> {
}
}
async function getCurrentBillingTier(): Promise<string | null> {
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<string>;
}): Promise<string | null> {
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<string> | null = options.availableHandles ?? null;
const isAvailable = async (handle: string): Promise<boolean> => {
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();

View File

@@ -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,
]);

View File

@@ -1597,7 +1597,7 @@ async function main(): Promise<void> {
// 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

View File

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

View File

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

View File

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