Merge pull request #5 from letta-ai/feat/selfhosted-onboard-option
Add self-hosted server option to onboard wizard along and refactor to use config YAML
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -41,3 +41,7 @@ letta-code-sdk/
|
||||
|
||||
# WhatsApp session (contains credentials)
|
||||
data/whatsapp-session/
|
||||
|
||||
# Config with secrets
|
||||
lettabot.yaml
|
||||
lettabot.yml
|
||||
|
||||
52
lettabot.example.yaml
Normal file
52
lettabot.example.yaml
Normal file
@@ -0,0 +1,52 @@
|
||||
# LettaBot Configuration
|
||||
# Copy this to lettabot.yaml and fill in your values.
|
||||
#
|
||||
# Server modes:
|
||||
# - 'cloud': Use Letta Cloud (api.letta.com) with API key
|
||||
# - 'selfhosted': Use self-hosted Letta server
|
||||
|
||||
server:
|
||||
mode: cloud
|
||||
# For cloud mode, set your API key (get one at https://app.letta.com):
|
||||
apiKey: sk-let-YOUR-API-KEY
|
||||
# For selfhosted mode, uncomment and set the base URL:
|
||||
# mode: selfhosted
|
||||
# baseUrl: http://localhost:8283
|
||||
|
||||
agent:
|
||||
name: LettaBot
|
||||
# Model to use:
|
||||
# - Free plan: zai/glm-4.7, minimax/MiniMax-M1-80k
|
||||
# - BYOK: lc-anthropic/claude-sonnet-4-5-20250929, lc-openai/gpt-5.2
|
||||
model: zai/glm-4.7
|
||||
|
||||
# BYOK Providers (optional, cloud mode only)
|
||||
# These will be synced to Letta Cloud on startup
|
||||
# providers:
|
||||
# - id: anthropic
|
||||
# name: lc-anthropic
|
||||
# type: anthropic
|
||||
# apiKey: sk-ant-YOUR-ANTHROPIC-KEY
|
||||
# - id: openai
|
||||
# name: lc-openai
|
||||
# type: openai
|
||||
# apiKey: sk-YOUR-OPENAI-KEY
|
||||
|
||||
channels:
|
||||
telegram:
|
||||
enabled: true
|
||||
token: YOUR-TELEGRAM-BOT-TOKEN
|
||||
dmPolicy: pairing # 'pairing', 'allowlist', or 'open'
|
||||
# slack:
|
||||
# enabled: true
|
||||
# appToken: xapp-...
|
||||
# botToken: xoxb-...
|
||||
# whatsapp:
|
||||
# enabled: true
|
||||
# selfChat: false
|
||||
|
||||
features:
|
||||
cron: false
|
||||
heartbeat:
|
||||
enabled: false
|
||||
intervalMin: 30
|
||||
18
package-lock.json
generated
18
package-lock.json
generated
@@ -26,7 +26,8 @@
|
||||
"qrcode-terminal": "^0.12.0",
|
||||
"telegram-markdown-v2": "^0.0.4",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3"
|
||||
"typescript": "^5.9.3",
|
||||
"yaml": "^2.8.2"
|
||||
},
|
||||
"bin": {
|
||||
"lettabot": "dist/cli.js",
|
||||
@@ -6384,6 +6385,21 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.8.2",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
|
||||
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/eemeli"
|
||||
}
|
||||
},
|
||||
"node_modules/yauzl": {
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",
|
||||
|
||||
@@ -52,7 +52,8 @@
|
||||
"qrcode-terminal": "^0.12.0",
|
||||
"telegram-markdown-v2": "^0.0.4",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3"
|
||||
"typescript": "^5.9.3",
|
||||
"yaml": "^2.8.2"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@slack/bolt": "^4.6.0",
|
||||
|
||||
190
src/cli.ts
190
src/cli.ts
@@ -8,7 +8,10 @@
|
||||
* lettabot configure - Configure settings
|
||||
*/
|
||||
|
||||
import 'dotenv/config';
|
||||
// Config loaded from lettabot.yaml
|
||||
import { loadConfig, applyConfigToEnv } from './config/index.js';
|
||||
const config = loadConfig();
|
||||
applyConfigToEnv(config);
|
||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
@@ -18,90 +21,30 @@ const args = process.argv.slice(2);
|
||||
const command = args[0];
|
||||
const subCommand = args[1];
|
||||
|
||||
const ENV_PATH = resolve(process.cwd(), '.env');
|
||||
const ENV_EXAMPLE_PATH = resolve(process.cwd(), '.env.example');
|
||||
|
||||
// Check if value is a placeholder
|
||||
const isPlaceholder = (val?: string) => !val || /^(your_|sk-\.\.\.|placeholder|example)/i.test(val);
|
||||
|
||||
// Simple prompt helper
|
||||
function prompt(question: string): Promise<string> {
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
return new Promise((resolve) => {
|
||||
rl.question(question, (answer) => {
|
||||
rl.close();
|
||||
resolve(answer.trim());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Load current env values
|
||||
function loadEnv(): Record<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
if (existsSync(ENV_PATH)) {
|
||||
const content = readFileSync(ENV_PATH, 'utf-8');
|
||||
for (const line of content.split('\n')) {
|
||||
if (line.startsWith('#') || !line.includes('=')) continue;
|
||||
const [key, ...valueParts] = line.split('=');
|
||||
env[key.trim()] = valueParts.join('=').trim();
|
||||
}
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
// Save env values
|
||||
function saveEnv(env: Record<string, string>): void {
|
||||
// Start with example if no .env exists
|
||||
let content = '';
|
||||
if (existsSync(ENV_EXAMPLE_PATH)) {
|
||||
content = readFileSync(ENV_EXAMPLE_PATH, 'utf-8');
|
||||
}
|
||||
|
||||
// Update values
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
const regex = new RegExp(`^#?\\s*${key}=.*$`, 'm');
|
||||
if (regex.test(content)) {
|
||||
content = content.replace(regex, `${key}=${value}`);
|
||||
} else {
|
||||
content += `\n${key}=${value}`;
|
||||
}
|
||||
}
|
||||
|
||||
writeFileSync(ENV_PATH, content);
|
||||
}
|
||||
|
||||
|
||||
// Import onboard from separate module
|
||||
import { onboard } from './onboard.js';
|
||||
|
||||
async function configure() {
|
||||
const p = await import('@clack/prompts');
|
||||
const { resolveConfigPath } = await import('./config/index.js');
|
||||
|
||||
p.intro('🤖 LettaBot Configuration');
|
||||
|
||||
const env = loadEnv();
|
||||
|
||||
// Check both .env file and shell environment, filtering placeholders
|
||||
const checkVar = (key: string) => {
|
||||
const fileValue = env[key];
|
||||
const envValue = process.env[key];
|
||||
const value = fileValue || envValue;
|
||||
return isPlaceholder(value) ? undefined : value;
|
||||
};
|
||||
|
||||
// Show current config from YAML
|
||||
const configRows = [
|
||||
['LETTA_API_KEY', checkVar('LETTA_API_KEY') ? '✓ Set' : '✗ Not set'],
|
||||
['TELEGRAM_BOT_TOKEN', checkVar('TELEGRAM_BOT_TOKEN') ? '✓ Set' : '✗ Not set'],
|
||||
['SLACK_BOT_TOKEN', checkVar('SLACK_BOT_TOKEN') ? '✓ Set' : '✗ Not set'],
|
||||
['SLACK_APP_TOKEN', checkVar('SLACK_APP_TOKEN') ? '✓ Set' : '✗ Not set'],
|
||||
['HEARTBEAT_INTERVAL_MIN', checkVar('HEARTBEAT_INTERVAL_MIN') || 'Not set'],
|
||||
['CRON_ENABLED', checkVar('CRON_ENABLED') || 'false'],
|
||||
['WORKING_DIR', checkVar('WORKING_DIR') || '/tmp/lettabot'],
|
||||
['AGENT_NAME', checkVar('AGENT_NAME') || 'LettaBot'],
|
||||
['MODEL', checkVar('MODEL') || '(default)'],
|
||||
['Server Mode', config.server.mode],
|
||||
['API Key', config.server.apiKey ? '✓ Set' : '✗ Not set'],
|
||||
['Agent Name', config.agent.name],
|
||||
['Model', config.agent.model],
|
||||
['Telegram', config.channels.telegram?.enabled ? '✓ Enabled' : '✗ Disabled'],
|
||||
['Slack', config.channels.slack?.enabled ? '✓ Enabled' : '✗ Disabled'],
|
||||
['Cron', config.features?.cron ? '✓ Enabled' : '✗ Disabled'],
|
||||
['Heartbeat', config.features?.heartbeat?.enabled ? `✓ ${config.features.heartbeat.intervalMin}min` : '✗ Disabled'],
|
||||
['BYOK Providers', config.providers?.length ? config.providers.map(p => p.name).join(', ') : 'None'],
|
||||
];
|
||||
|
||||
const maxKeyLength = Math.max(...configRows.map(([key]) => key.length));
|
||||
@@ -109,20 +52,14 @@ async function configure() {
|
||||
.map(([key, value]) => `${(key + ':').padEnd(maxKeyLength + 1)} ${value}`)
|
||||
.join('\n');
|
||||
|
||||
p.note(summary, 'Current Configuration');
|
||||
p.note(summary, `Current Configuration (${resolveConfigPath()})`);
|
||||
|
||||
const choice = await p.select({
|
||||
message: 'What would you like to configure?',
|
||||
message: 'What would you like to do?',
|
||||
options: [
|
||||
{ value: '1', label: 'Letta API Key', hint: '' },
|
||||
{ value: '2', label: 'Telegram', hint: '' },
|
||||
{ value: '3', label: 'Slack', hint: '' },
|
||||
{ value: '4', label: 'Heartbeat', hint: '' },
|
||||
{ value: '5', label: 'Cron', hint: '' },
|
||||
{ value: '6', label: 'Working Directory', hint: '' },
|
||||
{ value: '7', label: 'Agent Name & Model', hint: '' },
|
||||
{ value: '8', label: 'Edit .env directly', hint: '' },
|
||||
{ value: '9', label: 'Exit', hint: '' },
|
||||
{ value: 'onboard', label: 'Run setup wizard', hint: 'lettabot onboard' },
|
||||
{ value: 'edit', label: 'Edit config file', hint: resolveConfigPath() },
|
||||
{ value: 'exit', label: 'Exit', hint: '' },
|
||||
],
|
||||
});
|
||||
|
||||
@@ -132,89 +69,28 @@ async function configure() {
|
||||
}
|
||||
|
||||
switch (choice) {
|
||||
case '1':
|
||||
env.LETTA_API_KEY = await prompt('Enter Letta API Key: ');
|
||||
saveEnv(env);
|
||||
console.log('✓ Saved');
|
||||
case 'onboard':
|
||||
await onboard();
|
||||
break;
|
||||
case '2':
|
||||
env.TELEGRAM_BOT_TOKEN = await prompt('Enter Telegram Bot Token: ');
|
||||
saveEnv(env);
|
||||
console.log('✓ Saved');
|
||||
break;
|
||||
case '3':
|
||||
env.SLACK_BOT_TOKEN = await prompt('Enter Slack Bot Token: ');
|
||||
env.SLACK_APP_TOKEN = await prompt('Enter Slack App Token: ');
|
||||
saveEnv(env);
|
||||
console.log('✓ Saved');
|
||||
break;
|
||||
case '4':
|
||||
env.HEARTBEAT_INTERVAL_MIN = await prompt('Heartbeat interval (minutes, 0 to disable): ');
|
||||
saveEnv(env);
|
||||
console.log('✓ Saved');
|
||||
break;
|
||||
case '5':
|
||||
env.CRON_ENABLED = (await prompt('Enable cron? (y/n): ')).toLowerCase() === 'y' ? 'true' : 'false';
|
||||
saveEnv(env);
|
||||
console.log('✓ Saved');
|
||||
break;
|
||||
case '6':
|
||||
env.WORKING_DIR = await prompt('Working directory: ');
|
||||
saveEnv(env);
|
||||
console.log('✓ Saved');
|
||||
break;
|
||||
case '7': {
|
||||
const p = await import('@clack/prompts');
|
||||
const { buildModelOptions, handleModelSelection } = await import('./utils/model-selection.js');
|
||||
|
||||
const currentName = env.AGENT_NAME || 'LettaBot';
|
||||
const name = await p.text({
|
||||
message: 'Agent name',
|
||||
placeholder: currentName,
|
||||
initialValue: currentName,
|
||||
});
|
||||
if (!p.isCancel(name) && name) env.AGENT_NAME = name;
|
||||
|
||||
const currentModel = env.MODEL || 'default';
|
||||
p.log.info(`Current model: ${currentModel}\n`);
|
||||
|
||||
const spinner = p.spinner();
|
||||
spinner.start('Fetching available models...');
|
||||
const modelOptions = await buildModelOptions();
|
||||
spinner.stop('Models loaded');
|
||||
|
||||
const modelChoice = await p.select({
|
||||
message: 'Select model',
|
||||
options: modelOptions,
|
||||
maxItems: 10,
|
||||
});
|
||||
|
||||
if (!p.isCancel(modelChoice)) {
|
||||
const selectedModel = await handleModelSelection(modelChoice, p.text);
|
||||
if (selectedModel) {
|
||||
env.MODEL = selectedModel;
|
||||
}
|
||||
}
|
||||
|
||||
saveEnv(env);
|
||||
p.log.success('Saved');
|
||||
case 'edit': {
|
||||
const configPath = resolveConfigPath();
|
||||
const editor = process.env.EDITOR || 'nano';
|
||||
console.log(`Opening ${configPath} in ${editor}...`);
|
||||
spawnSync(editor, [configPath], { stdio: 'inherit' });
|
||||
break;
|
||||
}
|
||||
case '8':
|
||||
const editor = process.env.EDITOR || 'nano';
|
||||
spawnSync(editor, [ENV_PATH], { stdio: 'inherit' });
|
||||
case 'exit':
|
||||
break;
|
||||
case '9':
|
||||
return;
|
||||
default:
|
||||
console.log('Invalid choice');
|
||||
}
|
||||
}
|
||||
|
||||
async function server() {
|
||||
const { resolveConfigPath } = await import('./config/index.js');
|
||||
const configPath = resolveConfigPath();
|
||||
|
||||
// Check if configured
|
||||
if (!existsSync(ENV_PATH)) {
|
||||
console.log('No .env found. Run "lettabot onboard" first.\n');
|
||||
if (!existsSync(configPath)) {
|
||||
console.log(`No config found at ${configPath}. Run "lettabot onboard" first.\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,10 @@
|
||||
* (heartbeats, cron jobs) or to send to different channels during conversations.
|
||||
*/
|
||||
|
||||
import 'dotenv/config';
|
||||
// Config loaded from lettabot.yaml
|
||||
import { loadConfig, applyConfigToEnv } from '../config/index.js';
|
||||
const config = loadConfig();
|
||||
applyConfigToEnv(config);
|
||||
import { resolve } from 'node:path';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
|
||||
|
||||
2
src/config/index.ts
Normal file
2
src/config/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './types.js';
|
||||
export * from './io.js';
|
||||
219
src/config/io.ts
Normal file
219
src/config/io.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* LettaBot Configuration I/O
|
||||
*
|
||||
* Config file location: ~/.lettabot/config.yaml (or ./lettabot.yaml in project)
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import YAML from 'yaml';
|
||||
import type { LettaBotConfig, ProviderConfig } from './types.js';
|
||||
import { DEFAULT_CONFIG } from './types.js';
|
||||
|
||||
// Config file locations (checked in order)
|
||||
const CONFIG_PATHS = [
|
||||
resolve(process.cwd(), 'lettabot.yaml'), // Project-local
|
||||
resolve(process.cwd(), 'lettabot.yml'), // Project-local alt
|
||||
join(homedir(), '.lettabot', 'config.yaml'), // User global
|
||||
join(homedir(), '.lettabot', 'config.yml'), // User global alt
|
||||
];
|
||||
|
||||
const DEFAULT_CONFIG_PATH = join(homedir(), '.lettabot', 'config.yaml');
|
||||
|
||||
/**
|
||||
* Find the config file path (first existing, or default)
|
||||
*/
|
||||
export function resolveConfigPath(): string {
|
||||
for (const p of CONFIG_PATHS) {
|
||||
if (existsSync(p)) {
|
||||
return p;
|
||||
}
|
||||
}
|
||||
return DEFAULT_CONFIG_PATH;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load config from YAML file
|
||||
*/
|
||||
export function loadConfig(): LettaBotConfig {
|
||||
const configPath = resolveConfigPath();
|
||||
|
||||
if (!existsSync(configPath)) {
|
||||
return { ...DEFAULT_CONFIG };
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(configPath, 'utf-8');
|
||||
const parsed = YAML.parse(content) as Partial<LettaBotConfig>;
|
||||
|
||||
// Merge with defaults
|
||||
return {
|
||||
...DEFAULT_CONFIG,
|
||||
...parsed,
|
||||
server: { ...DEFAULT_CONFIG.server, ...parsed.server },
|
||||
agent: { ...DEFAULT_CONFIG.agent, ...parsed.agent },
|
||||
channels: { ...DEFAULT_CONFIG.channels, ...parsed.channels },
|
||||
};
|
||||
} catch (err) {
|
||||
console.error(`[Config] Failed to load ${configPath}:`, err);
|
||||
return { ...DEFAULT_CONFIG };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save config to YAML file
|
||||
*/
|
||||
export function saveConfig(config: LettaBotConfig, path?: string): void {
|
||||
const configPath = path || resolveConfigPath();
|
||||
|
||||
// Ensure directory exists
|
||||
const dir = dirname(configPath);
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
// Convert to YAML with comments
|
||||
const content = YAML.stringify(config, {
|
||||
indent: 2,
|
||||
lineWidth: 0, // Don't wrap lines
|
||||
});
|
||||
|
||||
writeFileSync(configPath, content, 'utf-8');
|
||||
console.log(`[Config] Saved to ${configPath}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get environment variables from config (for backwards compatibility)
|
||||
*/
|
||||
export function configToEnv(config: LettaBotConfig): Record<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
|
||||
// Server
|
||||
if (config.server.mode === 'selfhosted' && config.server.baseUrl) {
|
||||
env.LETTA_BASE_URL = config.server.baseUrl;
|
||||
}
|
||||
if (config.server.apiKey) {
|
||||
env.LETTA_API_KEY = config.server.apiKey;
|
||||
}
|
||||
|
||||
// Agent
|
||||
if (config.agent.id) {
|
||||
env.LETTA_AGENT_ID = config.agent.id;
|
||||
}
|
||||
if (config.agent.name) {
|
||||
env.AGENT_NAME = config.agent.name;
|
||||
}
|
||||
if (config.agent.model) {
|
||||
env.MODEL = config.agent.model;
|
||||
}
|
||||
|
||||
// Channels
|
||||
if (config.channels.telegram?.token) {
|
||||
env.TELEGRAM_BOT_TOKEN = config.channels.telegram.token;
|
||||
if (config.channels.telegram.dmPolicy) {
|
||||
env.TELEGRAM_DM_POLICY = config.channels.telegram.dmPolicy;
|
||||
}
|
||||
}
|
||||
if (config.channels.slack?.appToken) {
|
||||
env.SLACK_APP_TOKEN = config.channels.slack.appToken;
|
||||
}
|
||||
if (config.channels.slack?.botToken) {
|
||||
env.SLACK_BOT_TOKEN = config.channels.slack.botToken;
|
||||
}
|
||||
if (config.channels.whatsapp?.enabled) {
|
||||
env.WHATSAPP_ENABLED = 'true';
|
||||
if (config.channels.whatsapp.selfChat) {
|
||||
env.WHATSAPP_SELF_CHAT_MODE = 'true';
|
||||
}
|
||||
}
|
||||
if (config.channels.signal?.phone) {
|
||||
env.SIGNAL_PHONE_NUMBER = config.channels.signal.phone;
|
||||
}
|
||||
|
||||
// Features
|
||||
if (config.features?.cron) {
|
||||
env.CRON_ENABLED = 'true';
|
||||
}
|
||||
if (config.features?.heartbeat?.enabled) {
|
||||
env.HEARTBEAT_INTERVAL_MIN = String(config.features.heartbeat.intervalMin || 30);
|
||||
}
|
||||
|
||||
return env;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply config to process.env (YAML config takes priority over .env)
|
||||
*/
|
||||
export function applyConfigToEnv(config: LettaBotConfig): void {
|
||||
const env = configToEnv(config);
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
// YAML config always takes priority
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create BYOK providers on Letta Cloud
|
||||
*/
|
||||
export async function syncProviders(config: LettaBotConfig): Promise<void> {
|
||||
if (config.server.mode !== 'cloud' || !config.server.apiKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!config.providers || config.providers.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const apiKey = config.server.apiKey;
|
||||
const baseUrl = 'https://api.letta.com';
|
||||
|
||||
// List existing providers
|
||||
const listResponse = await fetch(`${baseUrl}/v1/providers`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
},
|
||||
});
|
||||
|
||||
const existingProviders = listResponse.ok
|
||||
? await listResponse.json() as Array<{ id: string; name: string }>
|
||||
: [];
|
||||
|
||||
// Create or update each provider
|
||||
for (const provider of config.providers) {
|
||||
const existing = existingProviders.find(p => p.name === provider.name);
|
||||
|
||||
try {
|
||||
if (existing) {
|
||||
// Update existing
|
||||
await fetch(`${baseUrl}/v1/providers/${existing.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({ api_key: provider.apiKey }),
|
||||
});
|
||||
console.log(`[Config] Updated provider: ${provider.name}`);
|
||||
} else {
|
||||
// Create new
|
||||
await fetch(`${baseUrl}/v1/providers`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: provider.name,
|
||||
provider_type: provider.type,
|
||||
api_key: provider.apiKey,
|
||||
}),
|
||||
});
|
||||
console.log(`[Config] Created provider: ${provider.name}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[Config] Failed to sync provider ${provider.name}:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
93
src/config/types.ts
Normal file
93
src/config/types.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* LettaBot Configuration Types
|
||||
*
|
||||
* Two modes:
|
||||
* 1. Self-hosted: Uses baseUrl (e.g., http://localhost:8283), no API key
|
||||
* 2. Letta Cloud: Uses apiKey, optional BYOK providers
|
||||
*/
|
||||
|
||||
export interface LettaBotConfig {
|
||||
// Server connection
|
||||
server: {
|
||||
// 'cloud' (api.letta.com) or 'selfhosted'
|
||||
mode: 'cloud' | 'selfhosted';
|
||||
// Only for selfhosted mode
|
||||
baseUrl?: string;
|
||||
// Only for cloud mode
|
||||
apiKey?: string;
|
||||
};
|
||||
|
||||
// Agent configuration
|
||||
agent: {
|
||||
id?: string;
|
||||
name: string;
|
||||
model: string;
|
||||
};
|
||||
|
||||
// BYOK providers (cloud mode only)
|
||||
providers?: ProviderConfig[];
|
||||
|
||||
// Channel configurations
|
||||
channels: {
|
||||
telegram?: TelegramConfig;
|
||||
slack?: SlackConfig;
|
||||
whatsapp?: WhatsAppConfig;
|
||||
signal?: SignalConfig;
|
||||
};
|
||||
|
||||
// Features
|
||||
features?: {
|
||||
cron?: boolean;
|
||||
heartbeat?: {
|
||||
enabled: boolean;
|
||||
intervalMin?: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface ProviderConfig {
|
||||
id: string; // e.g., 'anthropic', 'openai'
|
||||
name: string; // e.g., 'lc-anthropic'
|
||||
type: string; // e.g., 'anthropic', 'openai'
|
||||
apiKey: string;
|
||||
}
|
||||
|
||||
export interface TelegramConfig {
|
||||
enabled: boolean;
|
||||
token?: string;
|
||||
dmPolicy?: 'pairing' | 'allowlist' | 'open';
|
||||
allowedUsers?: string[];
|
||||
}
|
||||
|
||||
export interface SlackConfig {
|
||||
enabled: boolean;
|
||||
appToken?: string;
|
||||
botToken?: string;
|
||||
allowedUsers?: string[];
|
||||
}
|
||||
|
||||
export interface WhatsAppConfig {
|
||||
enabled: boolean;
|
||||
selfChat?: boolean;
|
||||
dmPolicy?: 'pairing' | 'allowlist' | 'open';
|
||||
allowedUsers?: string[];
|
||||
}
|
||||
|
||||
export interface SignalConfig {
|
||||
enabled: boolean;
|
||||
phone?: string;
|
||||
dmPolicy?: 'pairing' | 'allowlist' | 'open';
|
||||
allowedUsers?: string[];
|
||||
}
|
||||
|
||||
// Default config
|
||||
export const DEFAULT_CONFIG: LettaBotConfig = {
|
||||
server: {
|
||||
mode: 'cloud',
|
||||
},
|
||||
agent: {
|
||||
name: 'LettaBot',
|
||||
model: 'zai/glm-4.7', // Free model default
|
||||
},
|
||||
channels: {},
|
||||
};
|
||||
@@ -190,19 +190,64 @@ export class LettaBot {
|
||||
console.log(`[Bot] Session _agentId:`, (session as any)._agentId);
|
||||
console.log(`[Bot] Session options.permissionMode:`, (session as any).options?.permissionMode);
|
||||
|
||||
// Hook into transport errors
|
||||
// Hook into transport errors and stdout
|
||||
const transport = (session as any).transport;
|
||||
if (transport?.process) {
|
||||
console.log('[Bot] Transport process PID:', transport.process.pid);
|
||||
transport.process.stdout?.on('data', (data: Buffer) => {
|
||||
console.log('[Bot] CLI stdout:', data.toString().slice(0, 500));
|
||||
});
|
||||
transport.process.stderr?.on('data', (data: Buffer) => {
|
||||
console.error('[Bot] CLI stderr:', data.toString());
|
||||
});
|
||||
transport.process.on('exit', (code: number) => {
|
||||
console.log('[Bot] CLI process exited with code:', code);
|
||||
});
|
||||
transport.process.on('error', (err: Error) => {
|
||||
console.error('[Bot] CLI process error:', err);
|
||||
});
|
||||
} else {
|
||||
console.log('[Bot] No transport process found');
|
||||
}
|
||||
|
||||
// Initialize session explicitly (so we can log timing/failures)
|
||||
console.log('[Bot] About to initialize session...');
|
||||
console.log('[Bot] LETTA_API_KEY in env:', process.env.LETTA_API_KEY ? `${process.env.LETTA_API_KEY.slice(0, 30)}...` : 'NOT SET');
|
||||
console.log('[Bot] LETTA_CLI_PATH:', process.env.LETTA_CLI_PATH || 'not set (will use default)');
|
||||
|
||||
const initTimeoutMs = 30000; // Increased to 30s
|
||||
const withTimeout = async <T>(promise: Promise<T>, label: string): Promise<T> => {
|
||||
let timeoutId: NodeJS.Timeout;
|
||||
const timeoutPromise = new Promise<T>((_, reject) => {
|
||||
timeoutId = setTimeout(() => {
|
||||
reject(new Error(`${label} timed out after ${initTimeoutMs}ms`));
|
||||
}, initTimeoutMs);
|
||||
});
|
||||
try {
|
||||
return await Promise.race([promise, timeoutPromise]);
|
||||
} finally {
|
||||
clearTimeout(timeoutId!);
|
||||
}
|
||||
};
|
||||
|
||||
console.log('[Bot] Initializing session...');
|
||||
const initInfo = await withTimeout(session.initialize(), 'Session initialize');
|
||||
console.log('[Bot] Session initialized:', initInfo);
|
||||
|
||||
// Send message to agent with metadata envelope
|
||||
const formattedMessage = formatMessageEnvelope(msg);
|
||||
console.log('[Bot] Sending message...');
|
||||
await session.send(formattedMessage);
|
||||
console.log('[Bot] Message sent, starting stream...');
|
||||
console.log('[Bot] Formatted message:', formattedMessage.slice(0, 200));
|
||||
console.log('[Bot] Target server:', process.env.LETTA_BASE_URL || 'https://api.letta.com (default)');
|
||||
console.log('[Bot] API key:', process.env.LETTA_API_KEY ? `${process.env.LETTA_API_KEY.slice(0, 20)}...` : '(not set)');
|
||||
console.log('[Bot] Agent ID:', this.store.agentId || '(new agent)');
|
||||
console.log('[Bot] Sending message to session...');
|
||||
try {
|
||||
await withTimeout(session.send(formattedMessage), 'Session send');
|
||||
console.log('[Bot] Message sent successfully, starting stream...');
|
||||
} catch (sendError) {
|
||||
console.error('[Bot] Error in session.send():', sendError);
|
||||
throw sendError;
|
||||
}
|
||||
|
||||
// Stream response
|
||||
let response = '';
|
||||
@@ -214,8 +259,12 @@ export class LettaBot {
|
||||
adapter.sendTypingIndicator(msg.chatId).catch(() => {});
|
||||
}, 4000);
|
||||
|
||||
let streamCount = 0;
|
||||
try {
|
||||
console.log('[Bot] Entering stream loop...');
|
||||
for await (const streamMsg of session.stream()) {
|
||||
streamCount++;
|
||||
console.log(`[Bot] Stream msg #${streamCount}: type=${streamMsg.type}, content=${streamMsg.type === 'assistant' ? streamMsg.content?.slice(0, 50) + '...' : '(n/a)'}`);
|
||||
if (streamMsg.type === 'assistant') {
|
||||
response += streamMsg.content;
|
||||
|
||||
@@ -260,32 +309,44 @@ export class LettaBot {
|
||||
clearInterval(typingInterval);
|
||||
}
|
||||
|
||||
console.log(`[Bot] Stream complete. Total messages: ${streamCount}, Response length: ${response.length}`);
|
||||
console.log(`[Bot] Response preview: ${response.slice(0, 100)}...`);
|
||||
|
||||
// Send final response
|
||||
if (response) {
|
||||
console.log(`[Bot] Sending final response (messageId=${messageId})`);
|
||||
try {
|
||||
if (messageId) {
|
||||
await adapter.editMessage(msg.chatId, messageId, response);
|
||||
console.log('[Bot] Edited existing message');
|
||||
} else {
|
||||
await adapter.sendMessage({ chatId: msg.chatId, text: response, threadId: msg.threadId });
|
||||
console.log('[Bot] Sent new message');
|
||||
}
|
||||
} catch {
|
||||
} catch (sendError) {
|
||||
console.error('[Bot] Error sending final message:', sendError);
|
||||
// If we already sent a streamed message, don't duplicate — the user already saw it.
|
||||
if (!messageId) {
|
||||
await adapter.sendMessage({ chatId: msg.chatId, text: response, threadId: msg.threadId });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log('[Bot] No response from agent, sending placeholder');
|
||||
await adapter.sendMessage({ chatId: msg.chatId, text: '(No response from agent)', threadId: msg.threadId });
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error processing message:', error);
|
||||
console.error('[Bot] Error processing message:', error);
|
||||
if (error instanceof Error) {
|
||||
console.error('[Bot] Error stack:', error.stack);
|
||||
}
|
||||
await adapter.sendMessage({
|
||||
chatId: msg.chatId,
|
||||
text: `Error: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
threadId: msg.threadId,
|
||||
});
|
||||
} finally {
|
||||
console.log('[Bot] Closing session');
|
||||
session!?.close();
|
||||
}
|
||||
}
|
||||
|
||||
23
src/main.ts
23
src/main.ts
@@ -5,12 +5,21 @@
|
||||
* Chat continues seamlessly between Telegram, Slack, and WhatsApp.
|
||||
*/
|
||||
|
||||
import 'dotenv/config';
|
||||
import { createServer } from 'node:http';
|
||||
import { existsSync, mkdirSync, readFileSync, readdirSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
// Load YAML config and apply to process.env (overrides .env values)
|
||||
import { loadConfig, applyConfigToEnv, syncProviders, resolveConfigPath } from './config/index.js';
|
||||
const yamlConfig = loadConfig();
|
||||
console.log(`[Config] Loaded from ${resolveConfigPath()}`);
|
||||
console.log(`[Config] Mode: ${yamlConfig.server.mode}, Agent: ${yamlConfig.agent.name}, Model: ${yamlConfig.agent.model}`);
|
||||
applyConfigToEnv(yamlConfig);
|
||||
|
||||
// Sync BYOK providers on startup (async, don't block)
|
||||
syncProviders(yamlConfig).catch(err => console.error('[Config] Failed to sync providers:', err));
|
||||
|
||||
// Load agent ID from store and set as env var (SDK needs this)
|
||||
// Load agent ID from store file, or use LETTA_AGENT_ID env var as fallback
|
||||
const STORE_PATH = resolve(process.cwd(), 'lettabot-agent.json');
|
||||
@@ -110,13 +119,11 @@ import { PollingService } from './polling/service.js';
|
||||
import { agentExists } from './tools/letta-api.js';
|
||||
import { installSkillsToWorkingDir } from './skills/loader.js';
|
||||
|
||||
// Check if setup is needed
|
||||
const ENV_PATH = resolve(process.cwd(), '.env');
|
||||
if (!existsSync(ENV_PATH)) {
|
||||
console.log('\n No .env file found. Running setup wizard...\n');
|
||||
const setupPath = new URL('./setup.ts', import.meta.url).pathname;
|
||||
spawn('npx', ['tsx', setupPath], { stdio: 'inherit', cwd: process.cwd() });
|
||||
process.exit(0);
|
||||
// Check if config exists
|
||||
const configPath = resolveConfigPath();
|
||||
if (!existsSync(configPath)) {
|
||||
console.log(`\n No config found at ${configPath}. Run "lettabot onboard" first.\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Parse heartbeat target (format: "telegram:123456789" or "slack:C1234567890")
|
||||
|
||||
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
|
||||
}
|
||||
]
|
||||
422
src/onboard.ts
422
src/onboard.ts
@@ -6,9 +6,8 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import * as p from '@clack/prompts';
|
||||
|
||||
const ENV_PATH = resolve(process.cwd(), '.env');
|
||||
const ENV_EXAMPLE_PATH = resolve(process.cwd(), '.env.example');
|
||||
import { saveConfig, syncProviders } from './config/index.js';
|
||||
import type { LettaBotConfig, ProviderConfig } from './config/types.js';
|
||||
|
||||
// ============================================================================
|
||||
// Config Types
|
||||
@@ -16,8 +15,10 @@ const ENV_EXAMPLE_PATH = resolve(process.cwd(), '.env.example');
|
||||
|
||||
interface OnboardConfig {
|
||||
// Auth
|
||||
authMethod: 'keep' | 'oauth' | 'apikey' | 'skip';
|
||||
authMethod: 'keep' | 'oauth' | 'apikey' | 'selfhosted' | 'skip';
|
||||
apiKey?: string;
|
||||
baseUrl?: string;
|
||||
billingTier?: string;
|
||||
|
||||
// Agent
|
||||
agentChoice: 'new' | 'existing' | 'env' | 'skip';
|
||||
@@ -27,6 +28,9 @@ interface OnboardConfig {
|
||||
// Model (only for new agents)
|
||||
model?: string;
|
||||
|
||||
// BYOK Providers (for free tier)
|
||||
providers?: Array<{ id: string; name: string; apiKey: string }>;
|
||||
|
||||
// Channels (with access control)
|
||||
telegram: { enabled: boolean; token?: string; dmPolicy?: 'pairing' | 'allowlist' | 'open'; allowedUsers?: string[] };
|
||||
slack: { enabled: boolean; appToken?: string; botToken?: string; allowedUsers?: string[] };
|
||||
@@ -39,60 +43,6 @@ interface OnboardConfig {
|
||||
cron: boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Env Helpers
|
||||
// ============================================================================
|
||||
|
||||
function loadEnv(): Record<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
if (existsSync(ENV_PATH)) {
|
||||
const content = readFileSync(ENV_PATH, 'utf-8');
|
||||
for (const line of content.split('\n')) {
|
||||
if (line.startsWith('#') || !line.includes('=')) continue;
|
||||
const [key, ...valueParts] = line.split('=');
|
||||
env[key.trim()] = valueParts.join('=').trim();
|
||||
}
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
function saveEnv(env: Record<string, string>): void {
|
||||
// Start with .env.example as template, fall back to existing .env if example doesn't exist
|
||||
let content = '';
|
||||
if (existsSync(ENV_EXAMPLE_PATH)) {
|
||||
content = readFileSync(ENV_EXAMPLE_PATH, 'utf-8');
|
||||
} else if (existsSync(ENV_PATH)) {
|
||||
content = readFileSync(ENV_PATH, 'utf-8');
|
||||
}
|
||||
|
||||
// Track which keys we've seen in the template to detect deletions
|
||||
const keysInTemplate = new Set<string>();
|
||||
for (const line of content.split('\n')) {
|
||||
const match = line.match(/^#?\s*(\w+)=/);
|
||||
if (match) keysInTemplate.add(match[1]);
|
||||
}
|
||||
|
||||
// Update or add keys that exist in env
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
const regex = new RegExp(`^#?\\s*${key}=.*$`, 'm');
|
||||
if (regex.test(content)) {
|
||||
content = content.replace(regex, `${key}=${value}`);
|
||||
} else {
|
||||
content += `\n${key}=${value}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Comment out keys that were in template but deleted from env
|
||||
for (const key of keysInTemplate) {
|
||||
if (!(key in env)) {
|
||||
const regex = new RegExp(`^(${key}=.*)$`, 'm');
|
||||
content = content.replace(regex, '# $1');
|
||||
}
|
||||
}
|
||||
|
||||
writeFileSync(ENV_PATH, content);
|
||||
}
|
||||
|
||||
const isPlaceholder = (val?: string) => !val || /^(your_|sk-\.\.\.|placeholder|example)/i.test(val);
|
||||
|
||||
// ============================================================================
|
||||
@@ -103,11 +53,12 @@ async function stepAuth(config: OnboardConfig, env: Record<string, string>): Pro
|
||||
const { requestDeviceCode, pollForToken, LETTA_CLOUD_API_URL } = await import('./auth/oauth.js');
|
||||
const { saveTokens, loadTokens, getOrCreateDeviceId, getDeviceName } = await import('./auth/tokens.js');
|
||||
|
||||
const baseUrl = env.LETTA_BASE_URL || process.env.LETTA_BASE_URL;
|
||||
const baseUrl = config.baseUrl || env.LETTA_BASE_URL || process.env.LETTA_BASE_URL;
|
||||
const isLettaCloud = !baseUrl || baseUrl === LETTA_CLOUD_API_URL || baseUrl === 'https://api.letta.com';
|
||||
|
||||
const existingTokens = loadTokens();
|
||||
const realApiKey = isPlaceholder(env.LETTA_API_KEY) ? undefined : env.LETTA_API_KEY;
|
||||
// Check both env and config for existing API key
|
||||
const realApiKey = config.apiKey || (isPlaceholder(env.LETTA_API_KEY) ? undefined : env.LETTA_API_KEY);
|
||||
const validOAuthToken = isLettaCloud ? existingTokens?.accessToken : undefined;
|
||||
const hasExistingAuth = !!realApiKey || !!validOAuthToken;
|
||||
const displayKey = realApiKey || validOAuthToken;
|
||||
@@ -123,7 +74,8 @@ async function stepAuth(config: OnboardConfig, env: Record<string, string>): Pro
|
||||
...(hasExistingAuth ? [{ value: 'keep', label: getAuthLabel(), hint: displayKey?.slice(0, 20) + '...' }] : []),
|
||||
...(isLettaCloud ? [{ value: 'oauth', label: 'Login to Letta Platform', hint: 'Opens browser' }] : []),
|
||||
{ value: 'apikey', label: 'Enter API Key manually', hint: 'Paste your key' },
|
||||
{ value: 'skip', label: 'Skip', hint: 'Local server without auth' },
|
||||
{ value: 'selfhosted', label: 'Enter self-hosted URL', hint: 'Local Letta server' },
|
||||
{ value: 'skip', label: 'Skip', hint: 'Continue without auth' },
|
||||
];
|
||||
|
||||
const authMethod = await p.select({
|
||||
@@ -194,6 +146,22 @@ async function stepAuth(config: OnboardConfig, env: Record<string, string>): Pro
|
||||
config.apiKey = apiKey;
|
||||
env.LETTA_API_KEY = apiKey;
|
||||
}
|
||||
} else if (authMethod === 'selfhosted') {
|
||||
const serverUrl = await p.text({
|
||||
message: 'Letta server URL',
|
||||
placeholder: 'http://localhost:8283',
|
||||
initialValue: config.baseUrl || 'http://localhost:8283',
|
||||
});
|
||||
if (p.isCancel(serverUrl)) { p.cancel('Setup cancelled'); process.exit(0); }
|
||||
|
||||
const url = serverUrl || 'http://localhost:8283';
|
||||
config.baseUrl = url;
|
||||
env.LETTA_BASE_URL = url;
|
||||
process.env.LETTA_BASE_URL = url; // Set immediately so model listing works
|
||||
|
||||
// Clear any cloud API key since we're using self-hosted
|
||||
delete env.LETTA_API_KEY;
|
||||
delete process.env.LETTA_API_KEY;
|
||||
} else if (authMethod === 'keep') {
|
||||
// For OAuth tokens, refresh if needed
|
||||
if (existingTokens?.refreshToken) {
|
||||
@@ -238,7 +206,7 @@ async function stepAuth(config: OnboardConfig, env: Record<string, string>): Pro
|
||||
}
|
||||
}
|
||||
|
||||
// Validate connection (only if not skipping auth)
|
||||
// Validate connection (skip if 'skip' was chosen)
|
||||
if (config.authMethod !== 'skip') {
|
||||
const keyToValidate = config.apiKey || env.LETTA_API_KEY;
|
||||
if (keyToValidate) {
|
||||
@@ -246,11 +214,16 @@ async function stepAuth(config: OnboardConfig, env: Record<string, string>): Pro
|
||||
}
|
||||
|
||||
const spinner = p.spinner();
|
||||
spinner.start('Checking connection...');
|
||||
const serverLabel = config.baseUrl || 'Letta Cloud';
|
||||
spinner.start(`Checking connection to ${serverLabel}...`);
|
||||
try {
|
||||
const { testConnection } = await import('./tools/letta-api.js');
|
||||
const ok = await testConnection();
|
||||
spinner.stop(ok ? 'Connected to server' : 'Connection issue');
|
||||
spinner.stop(ok ? `Connected to ${serverLabel}` : 'Connection issue');
|
||||
|
||||
if (!ok && config.authMethod === 'selfhosted') {
|
||||
p.log.warn(`Could not connect to ${config.baseUrl}. Make sure the server is running.`);
|
||||
}
|
||||
} catch {
|
||||
spinner.stop('Connection check skipped');
|
||||
}
|
||||
@@ -333,28 +306,161 @@ async function stepAgent(config: OnboardConfig, env: Record<string, string>): Pr
|
||||
}
|
||||
}
|
||||
|
||||
// BYOK Provider definitions (same as letta-code)
|
||||
const BYOK_PROVIDERS = [
|
||||
{ id: 'anthropic', name: 'lc-anthropic', displayName: 'Anthropic (Claude)', providerType: 'anthropic' },
|
||||
{ id: 'openai', name: 'lc-openai', displayName: 'OpenAI', providerType: 'openai' },
|
||||
{ id: 'gemini', name: 'lc-gemini', displayName: 'Google Gemini', providerType: 'google_ai' },
|
||||
{ id: 'zai', name: 'lc-zai', displayName: 'zAI', providerType: 'zai' },
|
||||
{ id: 'minimax', name: 'lc-minimax', displayName: 'MiniMax', providerType: 'minimax' },
|
||||
{ id: 'openrouter', name: 'lc-openrouter', displayName: 'OpenRouter', providerType: 'openrouter' },
|
||||
];
|
||||
|
||||
async function stepProviders(config: OnboardConfig, env: Record<string, string>): Promise<void> {
|
||||
// Only for free tier users on Letta Cloud (not self-hosted, not paid)
|
||||
if (config.authMethod === 'selfhosted') return;
|
||||
if (config.billingTier !== 'free') return;
|
||||
|
||||
const selectedProviders = await p.multiselect({
|
||||
message: 'Add LLM provider keys (optional - for BYOK models)',
|
||||
options: BYOK_PROVIDERS.map(provider => ({
|
||||
value: provider.id,
|
||||
label: provider.displayName,
|
||||
hint: `Connect your ${provider.displayName} API key`,
|
||||
})),
|
||||
required: false,
|
||||
});
|
||||
|
||||
if (p.isCancel(selectedProviders)) { p.cancel('Setup cancelled'); process.exit(0); }
|
||||
|
||||
// If no providers selected, skip
|
||||
if (!selectedProviders || selectedProviders.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
config.providers = [];
|
||||
const apiKey = config.apiKey || env.LETTA_API_KEY || process.env.LETTA_API_KEY;
|
||||
|
||||
// Collect API keys for each selected provider
|
||||
for (const providerId of selectedProviders as string[]) {
|
||||
const provider = BYOK_PROVIDERS.find(p => p.id === providerId);
|
||||
if (!provider) continue;
|
||||
|
||||
const providerKey = await p.text({
|
||||
message: `${provider.displayName} API Key`,
|
||||
placeholder: 'sk-...',
|
||||
});
|
||||
|
||||
if (p.isCancel(providerKey)) { p.cancel('Setup cancelled'); process.exit(0); }
|
||||
|
||||
if (providerKey) {
|
||||
// Create or update provider via Letta API
|
||||
const spinner = p.spinner();
|
||||
spinner.start(`Connecting ${provider.displayName}...`);
|
||||
|
||||
try {
|
||||
// First check if provider already exists
|
||||
const listResponse = await fetch('https://api.letta.com/v1/providers', {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
},
|
||||
});
|
||||
|
||||
let existingProvider: { id: string; name: string } | undefined;
|
||||
if (listResponse.ok) {
|
||||
const providers = await listResponse.json() as Array<{ id: string; name: string }>;
|
||||
existingProvider = providers.find(p => p.name === provider.name);
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
if (existingProvider) {
|
||||
// Update existing provider
|
||||
response = await fetch(`https://api.letta.com/v1/providers/${existingProvider.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
api_key: providerKey,
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
// Create new provider
|
||||
response = await fetch('https://api.letta.com/v1/providers', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: provider.name,
|
||||
provider_type: provider.providerType,
|
||||
api_key: providerKey,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
spinner.stop(`Connected ${provider.displayName}`);
|
||||
config.providers.push({ id: provider.id, name: provider.name, apiKey: providerKey });
|
||||
} else {
|
||||
const error = await response.text();
|
||||
spinner.stop(`Failed to connect ${provider.displayName}: ${error}`);
|
||||
}
|
||||
} catch (err) {
|
||||
spinner.stop(`Failed to connect ${provider.displayName}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function stepModel(config: OnboardConfig, env: Record<string, string>): Promise<void> {
|
||||
// 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 (if not already fetched)
|
||||
let billingTier: string | null = config.billingTier || null;
|
||||
if (!isSelfHosted && !billingTier) {
|
||||
spinner.start('Checking account...');
|
||||
const apiKey = config.apiKey || env.LETTA_API_KEY || process.env.LETTA_API_KEY;
|
||||
billingTier = await getBillingTier(apiKey, isSelfHosted);
|
||||
config.billingTier = billingTier ?? undefined;
|
||||
spinner.stop(billingTier === 'free' ? 'Free plan' : `Plan: ${billingTier || 'unknown'}`);
|
||||
}
|
||||
|
||||
spinner.start('Fetching models...');
|
||||
const modelOptions = await buildModelOptions();
|
||||
const apiKey = config.apiKey || env.LETTA_API_KEY || process.env.LETTA_API_KEY;
|
||||
const modelOptions = await buildModelOptions({ billingTier, isSelfHosted, apiKey });
|
||||
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> {
|
||||
@@ -623,7 +729,8 @@ function showSummary(config: OnboardConfig): void {
|
||||
keep: 'Keep existing',
|
||||
oauth: 'OAuth login',
|
||||
apikey: config.apiKey ? `API Key (${config.apiKey.slice(0, 10)}...)` : 'API Key',
|
||||
skip: 'None (local server)',
|
||||
selfhosted: config.baseUrl ? `Self-hosted (${config.baseUrl})` : 'Self-hosted',
|
||||
skip: 'None',
|
||||
}[config.authMethod];
|
||||
lines.push(`Auth: ${authLabel}`);
|
||||
|
||||
@@ -681,7 +788,10 @@ async function reviewLoop(config: OnboardConfig, env: Record<string, string>): P
|
||||
if (choice === 'auth') await stepAuth(config, env);
|
||||
else if (choice === 'agent') {
|
||||
await stepAgent(config, env);
|
||||
if (config.agentChoice === 'new') await stepModel(config, env);
|
||||
if (config.agentChoice === 'new') {
|
||||
await stepProviders(config, env);
|
||||
await stepModel(config, env);
|
||||
}
|
||||
}
|
||||
else if (choice === 'channels') await stepChannels(config, env);
|
||||
else if (choice === 'features') await stepFeatures(config);
|
||||
@@ -693,12 +803,23 @@ async function reviewLoop(config: OnboardConfig, env: Record<string, string>): P
|
||||
// ============================================================================
|
||||
|
||||
export async function onboard(): Promise<void> {
|
||||
const env = loadEnv();
|
||||
// Temporary storage for wizard values
|
||||
const env: Record<string, string> = {};
|
||||
|
||||
// Load existing config if available
|
||||
const { loadConfig, resolveConfigPath } = await import('./config/index.js');
|
||||
const existingConfig = loadConfig();
|
||||
const configPath = resolveConfigPath();
|
||||
const hasExistingConfig = existsSync(configPath);
|
||||
|
||||
p.intro('🤖 LettaBot Setup');
|
||||
|
||||
// Show server info
|
||||
const baseUrl = env.LETTA_BASE_URL || process.env.LETTA_BASE_URL || 'https://api.letta.com';
|
||||
if (hasExistingConfig) {
|
||||
p.log.info(`Loading existing config from ${configPath}`);
|
||||
}
|
||||
|
||||
// Pre-populate from existing config
|
||||
const baseUrl = existingConfig.server.baseUrl || process.env.LETTA_BASE_URL || 'https://api.letta.com';
|
||||
const isLocal = baseUrl.includes('localhost') || baseUrl.includes('127.0.0.1');
|
||||
p.note(`${baseUrl}\n${isLocal ? 'Local Docker' : 'Letta Cloud'}`, 'Server');
|
||||
|
||||
@@ -724,39 +845,62 @@ export async function onboard(): Promise<void> {
|
||||
}
|
||||
|
||||
// Initialize config from existing env
|
||||
// Pre-populate from existing YAML config
|
||||
const config: OnboardConfig = {
|
||||
authMethod: 'skip',
|
||||
authMethod: hasExistingConfig ? 'keep' : 'skip',
|
||||
apiKey: existingConfig.server.apiKey,
|
||||
baseUrl: existingConfig.server.baseUrl,
|
||||
telegram: {
|
||||
enabled: !!env.TELEGRAM_BOT_TOKEN && !isPlaceholder(env.TELEGRAM_BOT_TOKEN),
|
||||
token: isPlaceholder(env.TELEGRAM_BOT_TOKEN) ? undefined : env.TELEGRAM_BOT_TOKEN,
|
||||
enabled: existingConfig.channels.telegram?.enabled || false,
|
||||
token: existingConfig.channels.telegram?.token,
|
||||
dmPolicy: existingConfig.channels.telegram?.dmPolicy,
|
||||
allowedUsers: existingConfig.channels.telegram?.allowedUsers?.map(String),
|
||||
},
|
||||
slack: {
|
||||
enabled: !!env.SLACK_BOT_TOKEN,
|
||||
appToken: env.SLACK_APP_TOKEN,
|
||||
botToken: env.SLACK_BOT_TOKEN,
|
||||
enabled: existingConfig.channels.slack?.enabled || false,
|
||||
appToken: existingConfig.channels.slack?.appToken,
|
||||
botToken: existingConfig.channels.slack?.botToken,
|
||||
allowedUsers: existingConfig.channels.slack?.allowedUsers,
|
||||
},
|
||||
whatsapp: {
|
||||
enabled: env.WHATSAPP_ENABLED === 'true',
|
||||
selfChat: env.WHATSAPP_SELF_CHAT_MODE === 'true',
|
||||
enabled: existingConfig.channels.whatsapp?.enabled || false,
|
||||
selfChat: existingConfig.channels.whatsapp?.selfChat,
|
||||
dmPolicy: existingConfig.channels.whatsapp?.dmPolicy,
|
||||
},
|
||||
signal: {
|
||||
enabled: !!env.SIGNAL_PHONE_NUMBER,
|
||||
phone: env.SIGNAL_PHONE_NUMBER,
|
||||
enabled: existingConfig.channels.signal?.enabled || false,
|
||||
phone: existingConfig.channels.signal?.phone,
|
||||
dmPolicy: existingConfig.channels.signal?.dmPolicy,
|
||||
},
|
||||
gmail: { enabled: false },
|
||||
heartbeat: {
|
||||
enabled: !!env.HEARTBEAT_INTERVAL_MIN,
|
||||
interval: env.HEARTBEAT_INTERVAL_MIN,
|
||||
enabled: existingConfig.features?.heartbeat?.enabled || false,
|
||||
interval: existingConfig.features?.heartbeat?.intervalMin?.toString(),
|
||||
},
|
||||
cron: env.CRON_ENABLED === 'true',
|
||||
agentChoice: 'skip',
|
||||
agentName: env.AGENT_NAME,
|
||||
model: env.MODEL,
|
||||
cron: existingConfig.features?.cron || false,
|
||||
agentChoice: hasExistingConfig ? 'env' : 'skip',
|
||||
agentName: existingConfig.agent.name,
|
||||
agentId: existingConfig.agent.id,
|
||||
model: existingConfig.agent.model,
|
||||
providers: existingConfig.providers?.map(p => ({ id: p.id, name: p.name, apiKey: p.apiKey })),
|
||||
};
|
||||
|
||||
// Run through all steps
|
||||
await stepAuth(config, env);
|
||||
await stepAgent(config, env);
|
||||
|
||||
// Fetch billing tier for free plan detection (only for Letta Cloud)
|
||||
if (config.authMethod !== 'selfhosted' && config.agentChoice === 'new') {
|
||||
const { getBillingTier } = await import('./utils/model-selection.js');
|
||||
const spinner = p.spinner();
|
||||
spinner.start('Checking account...');
|
||||
const apiKey = config.apiKey || env.LETTA_API_KEY || process.env.LETTA_API_KEY;
|
||||
const billingTier = await getBillingTier(apiKey, false);
|
||||
config.billingTier = billingTier ?? undefined;
|
||||
spinner.stop(billingTier === 'free' ? 'Free plan' : `Plan: ${billingTier || 'Pro'}`);
|
||||
}
|
||||
|
||||
await stepProviders(config, env);
|
||||
await stepModel(config, env);
|
||||
await stepChannels(config, env);
|
||||
await stepFeatures(config);
|
||||
@@ -865,9 +1009,87 @@ export async function onboard(): Promise<void> {
|
||||
|
||||
p.note(summary, 'Configuration Summary');
|
||||
|
||||
// Save
|
||||
saveEnv(env);
|
||||
p.log.success('Configuration saved to .env');
|
||||
// Convert to YAML config
|
||||
const yamlConfig: LettaBotConfig = {
|
||||
server: {
|
||||
mode: config.authMethod === 'selfhosted' ? 'selfhosted' : 'cloud',
|
||||
...(config.authMethod === 'selfhosted' && config.baseUrl ? { baseUrl: config.baseUrl } : {}),
|
||||
...(config.apiKey ? { apiKey: config.apiKey } : {}),
|
||||
},
|
||||
agent: {
|
||||
name: config.agentName || 'LettaBot',
|
||||
model: config.model || 'zai/glm-4.7',
|
||||
...(config.agentId ? { id: config.agentId } : {}),
|
||||
},
|
||||
channels: {
|
||||
...(config.telegram.enabled ? {
|
||||
telegram: {
|
||||
enabled: true,
|
||||
token: config.telegram.token,
|
||||
dmPolicy: config.telegram.dmPolicy,
|
||||
allowedUsers: config.telegram.allowedUsers,
|
||||
}
|
||||
} : {}),
|
||||
...(config.slack.enabled ? {
|
||||
slack: {
|
||||
enabled: true,
|
||||
appToken: config.slack.appToken,
|
||||
botToken: config.slack.botToken,
|
||||
allowedUsers: config.slack.allowedUsers,
|
||||
}
|
||||
} : {}),
|
||||
...(config.whatsapp.enabled ? {
|
||||
whatsapp: {
|
||||
enabled: true,
|
||||
selfChat: config.whatsapp.selfChat,
|
||||
dmPolicy: config.whatsapp.dmPolicy,
|
||||
allowedUsers: config.whatsapp.allowedUsers,
|
||||
}
|
||||
} : {}),
|
||||
...(config.signal.enabled ? {
|
||||
signal: {
|
||||
enabled: true,
|
||||
phone: config.signal.phone,
|
||||
dmPolicy: config.signal.dmPolicy,
|
||||
allowedUsers: config.signal.allowedUsers,
|
||||
}
|
||||
} : {}),
|
||||
},
|
||||
features: {
|
||||
cron: config.cron,
|
||||
heartbeat: {
|
||||
enabled: config.heartbeat.enabled,
|
||||
intervalMin: config.heartbeat.interval ? parseInt(config.heartbeat.interval) : undefined,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Add BYOK providers if configured
|
||||
if (config.providers && config.providers.length > 0) {
|
||||
yamlConfig.providers = config.providers.map(p => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
type: p.id, // id is the type (anthropic, openai, etc.)
|
||||
apiKey: p.apiKey,
|
||||
}));
|
||||
}
|
||||
|
||||
// Save YAML config (use project-local path)
|
||||
const savePath = resolve(process.cwd(), 'lettabot.yaml');
|
||||
saveConfig(yamlConfig, savePath);
|
||||
p.log.success('Configuration saved to lettabot.yaml');
|
||||
|
||||
// Sync BYOK providers to Letta Cloud
|
||||
if (yamlConfig.providers && yamlConfig.providers.length > 0 && yamlConfig.server.mode === 'cloud') {
|
||||
const spinner = p.spinner();
|
||||
spinner.start('Syncing BYOK providers to Letta Cloud...');
|
||||
try {
|
||||
await syncProviders(yamlConfig);
|
||||
spinner.stop('BYOK providers synced');
|
||||
} catch (err) {
|
||||
spinner.stop('Failed to sync providers (will retry on startup)');
|
||||
}
|
||||
}
|
||||
|
||||
// Save agent ID with server URL
|
||||
if (config.agentId) {
|
||||
|
||||
@@ -1,75 +1,217 @@
|
||||
/**
|
||||
* 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;
|
||||
label: string;
|
||||
description: string;
|
||||
isDefault?: boolean;
|
||||
isFeatured?: boolean;
|
||||
free?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get billing tier from Letta API
|
||||
* Uses /v1/metadata/balance endpoint (same as letta-code)
|
||||
*
|
||||
* @param apiKey - The API key to use
|
||||
* @param isSelfHosted - If true, skip billing check (self-hosted has no tiers)
|
||||
*/
|
||||
export async function getBillingTier(apiKey?: string, isSelfHosted?: boolean): Promise<string | null> {
|
||||
try {
|
||||
// Self-hosted servers don't have billing tiers
|
||||
if (isSelfHosted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!apiKey) {
|
||||
return 'free';
|
||||
}
|
||||
|
||||
// Always use Letta Cloud for billing check (not process.env.LETTA_BASE_URL)
|
||||
const response = await fetch('https://api.letta.com/v1/metadata/balance', {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return 'free';
|
||||
}
|
||||
|
||||
const data = await response.json() as { billing_tier?: string };
|
||||
const tier = data.billing_tier?.toLowerCase() ?? 'free';
|
||||
return tier;
|
||||
} 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';
|
||||
}
|
||||
|
||||
interface ByokModel {
|
||||
handle: string;
|
||||
name: string;
|
||||
display_name?: string;
|
||||
tier?: string;
|
||||
provider_name: string;
|
||||
provider_type: string;
|
||||
}
|
||||
|
||||
const TIER_LABELS: Record<string, string> = {
|
||||
'free': '🆓 Free',
|
||||
'premium': '⭐ Premium',
|
||||
'per-inference': '💰 Pay-per-use',
|
||||
};
|
||||
|
||||
const BYOK_LABEL = '🔑 BYOK';
|
||||
/**
|
||||
* Fetch BYOK models from Letta API
|
||||
*/
|
||||
async function fetchByokModels(apiKey?: string): Promise<ByokModel[]> {
|
||||
try {
|
||||
const key = apiKey || process.env.LETTA_API_KEY;
|
||||
if (!key) return [];
|
||||
|
||||
const response = await fetch('https://api.letta.com/v1/models?provider_category=byok', {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${key}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) return [];
|
||||
|
||||
const models = await response.json() as ByokModel[];
|
||||
return models;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build model selection options
|
||||
* Build model selection options based on billing tier
|
||||
* Returns array ready for @clack/prompts select()
|
||||
*
|
||||
* For free users: Show free models first, then BYOK models from API
|
||||
* For paid users: Show featured models first, then all models
|
||||
* For self-hosted: Fetch models from server
|
||||
*/
|
||||
export async function buildModelOptions(): Promise<Array<{ value: string; label: string; hint: string }>> {
|
||||
const { listModels } = await import('../tools/letta-api.js');
|
||||
export async function buildModelOptions(options?: {
|
||||
billingTier?: string | null;
|
||||
isSelfHosted?: boolean;
|
||||
apiKey?: string;
|
||||
}): Promise<Array<{ value: string; label: string; hint: string }>> {
|
||||
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'] || '',
|
||||
})));
|
||||
|
||||
// Add top 3 BYOK models inline
|
||||
result.push(...sortedByok.map(m => ({
|
||||
value: m.handle,
|
||||
label: m.display_name || m.name,
|
||||
hint: BYOK_LABEL,
|
||||
})));
|
||||
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}`,
|
||||
})));
|
||||
|
||||
// Fetch BYOK models from API
|
||||
const byokModels = await fetchByokModels(options?.apiKey);
|
||||
if (byokModels.length > 0) {
|
||||
result.push({
|
||||
value: '__byok_header__',
|
||||
label: '── Your Connected Providers ──',
|
||||
hint: 'Models from your API keys',
|
||||
});
|
||||
|
||||
result.push(...byokModels.map(m => ({
|
||||
value: m.handle,
|
||||
label: m.display_name || m.name,
|
||||
hint: `🔑 ${m.provider_name}`,
|
||||
})));
|
||||
}
|
||||
} 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'
|
||||
label: 'Other (specify handle)',
|
||||
hint: 'e.g. anthropic/claude-sonnet-4-5-20250929'
|
||||
});
|
||||
|
||||
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: m.handle,
|
||||
})));
|
||||
|
||||
// Add custom option
|
||||
result.push({
|
||||
value: '__custom__',
|
||||
label: 'Other (specify handle)',
|
||||
hint: 'e.g. anthropic/claude-sonnet-4-5-20250929'
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle model selection including custom input
|
||||
@@ -83,6 +225,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