From 10aa377776fac250ba257123c09d61267db52b1b Mon Sep 17 00:00:00 2001 From: Sarah Wooders Date: Wed, 28 Jan 2026 21:32:47 -0800 Subject: [PATCH] Add billing tier-aware model selection for free plan users MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/models.json | 61 ++++++++++++ src/onboard.ts | 46 ++++++--- src/utils/model-selection.ts | 185 +++++++++++++++++++++++++++-------- 3 files changed, 238 insertions(+), 54 deletions(-) create mode 100644 src/models.json diff --git a/src/models.json b/src/models.json new file mode 100644 index 0000000..984857d --- /dev/null +++ b/src/models.json @@ -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 + } +] diff --git a/src/onboard.ts b/src/onboard.ts index 76c6efd..793d269 100644 --- a/src/onboard.ts +++ b/src/onboard.ts @@ -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): 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): Promise { diff --git a/src/utils/model-selection.ts b/src/utils/model-selection.ts index 50498f6..e808714 100644 --- a/src/utils/model-selection.ts +++ b/src/utils/model-selection.ts @@ -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 = { - 'free': '🆓 Free', - 'premium': '⭐ Premium', - 'per-inference': '💰 Pay-per-use', -}; - -const BYOK_LABEL = '🔑 BYOK'; +/** + * Get billing tier from Letta API + */ +export async function getBillingTier(): Promise { + try { + const baseUrl = process.env.LETTA_BASE_URL || 'https://api.letta.com'; + const apiKey = process.env.LETTA_API_KEY; + + // 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> { - 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'; +} + +/** + * 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> { + const billingTier = options?.billingTier; + const isSelfHosted = options?.isSelfHosted; + const isFreeTier = billingTier?.toLowerCase() === 'free'; - // Fetch both base and BYOK models - const [baseModels, byokModels] = await Promise.all([ - listModels({ providerCategory: 'base' }), - listModels({ providerCategory: 'byok' }), - ]); - - // 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 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 top 3 BYOK models inline - result.push(...sortedByok.map(m => ({ + // 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> { + 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