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:
61
src/models.json
Normal file
61
src/models.json
Normal 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
|
||||
}
|
||||
]
|
||||
@@ -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');
|
||||
|
||||
// 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: 10,
|
||||
maxItems: 12,
|
||||
});
|
||||
if (p.isCancel(modelChoice)) { p.cancel('Setup cancelled'); process.exit(0); }
|
||||
|
||||
const selectedModel = await handleModelSelection(modelChoice, p.text);
|
||||
if (selectedModel) {
|
||||
config.model = selectedModel;
|
||||
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> {
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
const BYOK_LABEL = '🔑 BYOK';
|
||||
|
||||
/**
|
||||
* Build model selection options
|
||||
* Returns array ready for @clack/prompts select()
|
||||
* Get billing tier from Letta API
|
||||
*/
|
||||
export async function buildModelOptions(): Promise<Array<{ value: string; label: string; hint: string }>> {
|
||||
const { listModels } = await import('../tools/letta-api.js');
|
||||
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;
|
||||
|
||||
// Fetch both base and BYOK models
|
||||
const [baseModels, byokModels] = await Promise.all([
|
||||
listModels({ providerCategory: 'base' }),
|
||||
listModels({ providerCategory: 'byok' }),
|
||||
]);
|
||||
// Self-hosted servers don't have billing tiers
|
||||
if (baseUrl !== 'https://api.letta.com') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 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');
|
||||
if (!apiKey) return 'free';
|
||||
|
||||
const response = await fetch(`${baseUrl}/v1/users/me/balance`, {
|
||||
headers: { 'Authorization': `Bearer ${apiKey}` },
|
||||
});
|
||||
|
||||
// Sort BYOK models alphabetically
|
||||
const sortedByok = byokModels.sort((a, b) =>
|
||||
(a.display_name || a.name).localeCompare(b.display_name || b.name)
|
||||
);
|
||||
if (!response.ok) return 'free';
|
||||
|
||||
const data = await response.json() as { billing_tier?: string };
|
||||
return data.billing_tier?.toLowerCase() ?? 'free';
|
||||
} catch {
|
||||
return 'free';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default model for a billing tier
|
||||
*/
|
||||
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<Array<{ value: string; label: string; hint: string }>> {
|
||||
const billingTier = options?.billingTier;
|
||||
const isSelfHosted = options?.isSelfHosted;
|
||||
const isFreeTier = billingTier?.toLowerCase() === 'free';
|
||||
|
||||
// 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 => ({
|
||||
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.display_name || m.name,
|
||||
hint: TIER_LABELS[m.tier || 'free'] || '',
|
||||
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({
|
||||
|
||||
Reference in New Issue
Block a user