Add billing tier-aware model selection for free plan users

- Add models.json with featured models and free models (GLM-4.7, MiniMax M2.1)
- Free plan users see free models first, then BYOK options
- Paid users see featured models first
- Self-hosted servers fetch models from server API
- Show billing tier during onboard

🤖 Generated with [Letta Code](https://letta.com)

Co-Authored-By: Letta <noreply@letta.com>
This commit is contained in:
Sarah Wooders
2026-01-28 21:32:47 -08:00
parent 4a024082cc
commit 10aa377776
3 changed files with 238 additions and 54 deletions

61
src/models.json Normal file
View File

@@ -0,0 +1,61 @@
[
{
"id": "sonnet-4.5",
"handle": "anthropic/claude-sonnet-4-5-20250929",
"label": "Sonnet 4.5",
"description": "The recommended default model",
"isDefault": true,
"isFeatured": true
},
{
"id": "opus",
"handle": "anthropic/claude-opus-4-5-20251101",
"label": "Opus 4.5",
"description": "Anthropic's best model",
"isFeatured": true
},
{
"id": "haiku",
"handle": "anthropic/claude-haiku-4-5-20251001",
"label": "Haiku 4.5",
"description": "Anthropic's fastest model",
"isFeatured": true
},
{
"id": "gpt-5.2-medium",
"handle": "openai/gpt-5.2",
"label": "GPT-5.2",
"description": "Latest general-purpose GPT (med reasoning)",
"isFeatured": true
},
{
"id": "gemini-3",
"handle": "google_ai/gemini-3-pro-preview",
"label": "Gemini 3 Pro",
"description": "Google's smartest model",
"isFeatured": true
},
{
"id": "gemini-3-flash",
"handle": "google_ai/gemini-3-flash-preview",
"label": "Gemini 3 Flash",
"description": "Google's fastest Gemini 3 model",
"isFeatured": true
},
{
"id": "glm-4.7",
"handle": "zai/glm-4.7",
"label": "GLM-4.7",
"description": "zAI's latest coding model",
"isFeatured": true,
"free": true
},
{
"id": "minimax-m2.1",
"handle": "minimax/MiniMax-M2.1",
"label": "MiniMax 2.1",
"description": "MiniMax's latest coding model",
"isFeatured": true,
"free": true
}
]

View File

@@ -19,6 +19,7 @@ interface OnboardConfig {
authMethod: 'keep' | 'oauth' | 'apikey' | 'selfhosted' | 'skip';
apiKey?: string;
baseUrl?: string;
billingTier?: string;
// Agent
agentChoice: 'new' | 'existing' | 'env' | 'skip';
@@ -360,24 +361,45 @@ async function stepModel(config: OnboardConfig, env: Record<string, string>): Pr
// Only for new agents
if (config.agentChoice !== 'new') return;
const { buildModelOptions, handleModelSelection } = await import('./utils/model-selection.js');
const { buildModelOptions, handleModelSelection, getBillingTier } = await import('./utils/model-selection.js');
const spinner = p.spinner();
// Determine if self-hosted (not Letta Cloud)
const isSelfHosted = config.authMethod === 'selfhosted';
// Fetch billing tier for Letta Cloud users
let billingTier: string | null = null;
if (!isSelfHosted) {
spinner.start('Checking account...');
billingTier = await getBillingTier();
config.billingTier = billingTier ?? undefined;
spinner.stop(billingTier === 'free' ? 'Free plan' : `Plan: ${billingTier || 'unknown'}`);
}
spinner.start('Fetching models...');
const modelOptions = await buildModelOptions();
const modelOptions = await buildModelOptions({ billingTier, isSelfHosted });
spinner.stop('Models loaded');
const modelChoice = await p.select({
message: 'Select model',
options: modelOptions,
maxItems: 10,
});
if (p.isCancel(modelChoice)) { p.cancel('Setup cancelled'); process.exit(0); }
const selectedModel = await handleModelSelection(modelChoice, p.text);
if (selectedModel) {
config.model = selectedModel;
// Show appropriate message for free tier
if (billingTier === 'free') {
p.log.info('Free plan: GLM and MiniMax models are free. Other models require BYOK (Bring Your Own Key).');
}
let selectedModel: string | null = null;
while (!selectedModel) {
const modelChoice = await p.select({
message: 'Select model',
options: modelOptions,
maxItems: 12,
});
if (p.isCancel(modelChoice)) { p.cancel('Setup cancelled'); process.exit(0); }
selectedModel = await handleModelSelection(modelChoice, p.text);
// If null (e.g., header selected), loop again
}
config.model = selectedModel;
}
async function stepChannels(config: OnboardConfig, env: Record<string, string>): Promise<void> {

View File

@@ -1,62 +1,162 @@
/**
* Shared utilities for model selection UI
*
* Follows letta-code approach:
* - Free plan users see free models (GLM, MiniMax) + BYOK options
* - Paid users see all models with featured/recommended at top
*/
import type * as p from '@clack/prompts';
import modelsData from '../models.json' with { type: 'json' };
export interface ModelOption {
export const models = modelsData as ModelInfo[];
export interface ModelInfo {
id: string;
handle: string;
name: string;
display_name?: string;
tier?: string;
label: string;
description: string;
isDefault?: boolean;
isFeatured?: boolean;
free?: boolean;
}
const TIER_LABELS: Record<string, string> = {
'free': '🆓 Free',
'premium': '⭐ Premium',
'per-inference': '💰 Pay-per-use',
};
/**
* Get billing tier from Letta API
*/
export async function getBillingTier(): Promise<string | null> {
try {
const baseUrl = process.env.LETTA_BASE_URL || 'https://api.letta.com';
const apiKey = process.env.LETTA_API_KEY;
const BYOK_LABEL = '🔑 BYOK';
// Self-hosted servers don't have billing tiers
if (baseUrl !== 'https://api.letta.com') {
return null;
}
if (!apiKey) return 'free';
const response = await fetch(`${baseUrl}/v1/users/me/balance`, {
headers: { 'Authorization': `Bearer ${apiKey}` },
});
if (!response.ok) return 'free';
const data = await response.json() as { billing_tier?: string };
return data.billing_tier?.toLowerCase() ?? 'free';
} catch {
return 'free';
}
}
/**
* Build model selection options
* Returns array ready for @clack/prompts select()
* Get the default model for a billing tier
*/
export async function buildModelOptions(): Promise<Array<{ value: string; label: string; hint: string }>> {
const { listModels } = await import('../tools/letta-api.js');
export function getDefaultModelForTier(billingTier?: string | null): string {
// Free tier gets glm-4.7 (a free model)
if (billingTier?.toLowerCase() === 'free') {
const freeDefault = models.find(m => m.id === 'glm-4.7');
if (freeDefault) return freeDefault.handle;
}
// Everyone else gets the standard default
const defaultModel = models.find(m => m.isDefault);
return defaultModel?.handle ?? models[0]?.handle ?? 'anthropic/claude-sonnet-4-5-20250929';
}
// Fetch both base and BYOK models
const [baseModels, byokModels] = await Promise.all([
listModels({ providerCategory: 'base' }),
listModels({ providerCategory: 'byok' }),
]);
/**
* Build model selection options based on billing tier
* Returns array ready for @clack/prompts select()
*
* For free users: Show free models first, then BYOK option
* For paid users: Show featured models first, then all models
* For self-hosted: Fetch models from server
*/
export async function buildModelOptions(options?: {
billingTier?: string | null;
isSelfHosted?: boolean;
}): Promise<Array<{ value: string; label: string; hint: string }>> {
const billingTier = options?.billingTier;
const isSelfHosted = options?.isSelfHosted;
const isFreeTier = billingTier?.toLowerCase() === 'free';
// Sort base models: free first, then premium, then per-inference
const sortedBase = baseModels.sort((a, b) => {
const tierOrder = ['free', 'premium', 'per-inference'];
return tierOrder.indexOf(a.tier || 'free') - tierOrder.indexOf(b.tier || 'free');
});
// Sort BYOK models alphabetically
const sortedByok = byokModels.sort((a, b) =>
(a.display_name || a.name).localeCompare(b.display_name || b.name)
);
// For self-hosted servers, fetch models from server
if (isSelfHosted) {
return buildServerModelOptions();
}
const result: Array<{ value: string; label: string; hint: string }> = [];
// Add base models
result.push(...sortedBase.map(m => ({
value: m.handle,
label: m.display_name || m.name,
hint: TIER_LABELS[m.tier || 'free'] || '',
})));
if (isFreeTier) {
// Free tier: Show free models first
const freeModels = models.filter(m => m.free);
result.push(...freeModels.map(m => ({
value: m.handle,
label: m.label,
hint: `🆓 Free - ${m.description}`,
})));
// Add top 3 BYOK models inline
result.push(...sortedByok.map(m => ({
// Add BYOK header and options
result.push({
value: '__byok_header__',
label: '── BYOK (Bring Your Own Key) ──',
hint: 'Connect your own API keys',
});
// Show featured non-free models as BYOK options
const byokModels = models.filter(m => m.isFeatured && !m.free);
result.push(...byokModels.map(m => ({
value: m.handle,
label: m.label,
hint: `🔑 BYOK - ${m.description}`,
})));
} else {
// Paid tier: Show featured models first
const featured = models.filter(m => m.isFeatured);
const nonFeatured = models.filter(m => !m.isFeatured);
result.push(...featured.map(m => ({
value: m.handle,
label: m.label,
hint: m.free ? `🆓 Free - ${m.description}` : `${m.description}`,
})));
result.push(...nonFeatured.map(m => ({
value: m.handle,
label: m.label,
hint: m.description,
})));
}
// Add custom option
result.push({
value: '__custom__',
label: 'Custom model',
hint: 'Enter handle: provider/model-name'
});
return result;
}
/**
* Build model options from self-hosted server
*/
async function buildServerModelOptions(): Promise<Array<{ value: string; label: string; hint: string }>> {
const { listModels } = await import('../tools/letta-api.js');
// Fetch all models from server
const serverModels = await listModels();
const result: Array<{ value: string; label: string; hint: string }> = [];
// Sort by display name
const sorted = serverModels.sort((a, b) =>
(a.display_name || a.name).localeCompare(b.display_name || b.name)
);
result.push(...sorted.map(m => ({
value: m.handle,
label: m.display_name || m.name,
hint: BYOK_LABEL,
hint: m.handle,
})));
// Add custom option
@@ -69,8 +169,6 @@ export async function buildModelOptions(): Promise<Array<{ value: string; label:
return result;
}
/**
* Handle model selection including custom input
* Returns the selected model handle or null if cancelled/header selected
@@ -83,6 +181,9 @@ export async function handleModelSelection(
const p = await import('@clack/prompts');
if (p.isCancel(selection)) return null;
// Skip header selections
if (selection === '__byok_header__') return null;
// Handle custom model input
if (selection === '__custom__') {
const custom = await promptFn({