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:
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
19
src/tests/agent/default-model-for-tier.test.ts
Normal file
19
src/tests/agent/default-model-for-tier.test.ts
Normal 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());
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
28
src/tests/cli/model-selector-categories.test.ts
Normal file
28
src/tests/cli/model-selector-categories.test.ts
Normal 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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user