From 9bf19ebab4789235b0f13fd92d7e54b2b0843fd1 Mon Sep 17 00:00:00 2001 From: Cameron Date: Thu, 5 Mar 2026 10:16:35 -0800 Subject: [PATCH] feat: ChatGPT subscription connect flow (#487) --- README.md | 11 ++- docs/getting-started.md | 10 ++ scripts/no-console.sh | 2 + src/cli.ts | 16 +++- src/commands/letta-connect.ts | 176 ++++++++++++++++++++++++++++++++++ src/onboard.ts | 90 +++++++++++++---- src/utils/model-selection.ts | 110 ++++++++++++++------- 7 files changed, 360 insertions(+), 55 deletions(-) create mode 100644 src/commands/letta-connect.ts diff --git a/README.md b/README.md index 11395c7..19332a4 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ Your personal AI assistant that remembers everything across **Telegram, Slack, D - Node.js 20+ - A Letta API key from [app.letta.com](https://app.letta.com) (or a running [Letta Docker server](https://docs.letta.com/guides/docker/)) - A Telegram bot token from [@BotFather](https://t.me/BotFather) +- Optional: a ChatGPT subscription account you want to use for model credits ### Install @@ -97,6 +98,14 @@ npm install && npm run build && npm link lettabot onboard ``` +Prefer to use your ChatGPT subscription instead of another API key? After onboarding (or anytime), run: + +```bash +lettabot connect chatgpt +``` + +This opens a browser flow and enables connected subscription handles in the model picker. + ### Run ```bash @@ -167,13 +176,13 @@ Then ask your bot things like: | Command | Description | |---------|-------------| | `lettabot onboard` | Interactive setup wizard | +| `lettabot connect` | Connect model providers (for example, `chatgpt`) | | `lettabot server` | Start the bot server | | `lettabot configure` | View and edit configuration | | `lettabot skills status` | Show enabled and available skills | | `lettabot destroy` | Delete all local data and start fresh | | `lettabot help` | Show help | - ## Channel Setup By default, LettaBot uses a **single agent with a single shared conversation** across all channels: diff --git a/docs/getting-started.md b/docs/getting-started.md index d60423f..6b092f0 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -34,6 +34,16 @@ npm ci 3. Go to Settings > API Keys 4. Create a new API key and copy it +### 3b. Connect your ChatGPT subscription (optional) + +If you want connected provider models from your ChatGPT/ChatGPT Plus subscription, run: + +```bash +lettabot connect chatgpt +``` + +The command opens a browser-based flow for OAuth and then makes those handles available in `lettabot model` and onboarding. + ### 4. Configure LettaBot **Option A: Interactive Setup (Recommended)** diff --git a/scripts/no-console.sh b/scripts/no-console.sh index 0c1dc82..e80dc65 100755 --- a/scripts/no-console.sh +++ b/scripts/no-console.sh @@ -4,6 +4,7 @@ # # Excluded: # - CLI commands (src/cli*, src/cron/cli.ts, onboard, setup) -- user-facing terminal output +# - src/commands/letta-connect.ts -- interactive OAuth URL/terminal guidance output # - Test files (*.test.ts, mock-*) -- test output # - banner.ts -- ASCII art display # - JSDoc examples (lines starting with ' *') @@ -18,6 +19,7 @@ hits=$(grep -rEn 'console\.(log|error|warn|info|debug|trace)[[:space:]]*\(' src/ --exclude='setup.ts' \ --exclude='onboard.ts' \ --exclude='slack-wizard.ts' \ + --exclude='letta-connect.ts' \ --exclude='cli.ts' \ --exclude-dir='cli' \ | grep -Ev ':[0-9]+:[[:space:]]*\* ' \ diff --git a/src/cli.ts b/src/cli.ts index 02fd26d..1b865f9 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -229,6 +229,7 @@ Commands: configure View and edit configuration config encode Encode config file as base64 for LETTABOT_CONFIG_YAML config decode Decode and print LETTABOT_CONFIG_YAML env var + connect Connect model providers (e.g., chatgpt/codex) model Interactive model selector model show Show current agent model model set Set model by handle (e.g., anthropic/claude-sonnet-4-5-20250929) @@ -262,6 +263,7 @@ Examples: lettabot todo list --actionable lettabot pairing list telegram # Show pending Telegram pairings lettabot pairing approve telegram ABCD1234 # Approve a pairing code + lettabot connect chatgpt # Connect ChatGPT subscription (via OAuth) Environment: LETTABOT_CONFIG_YAML Inline YAML or base64-encoded config (for cloud deploys) @@ -371,6 +373,18 @@ async function main() { await modelCommand(subCommand, args[2]); break; } + + case 'connect': { + const { runLettaConnect } = await import('./commands/letta-connect.js'); + const requestedProvider = subCommand || 'chatgpt'; + const providers = requestedProvider === 'chatgpt' ? ['chatgpt', 'codex'] : [requestedProvider]; + const connected = await runLettaConnect(providers); + if (!connected) { + console.error(`Failed to run letta connect for provider: ${requestedProvider}`); + process.exit(1); + } + break; + } case 'channels': case 'channel': { @@ -617,7 +631,7 @@ async function main() { case undefined: console.log('Usage: lettabot \n'); - console.log('Commands: onboard, server, configure, model, channels, skills, reset-conversation, destroy, help\n'); + console.log('Commands: onboard, server, configure, connect, model, channels, skills, reset-conversation, destroy, help\n'); console.log('Run "lettabot help" for more information.'); break; diff --git a/src/commands/letta-connect.ts b/src/commands/letta-connect.ts new file mode 100644 index 0000000..8d60b29 --- /dev/null +++ b/src/commands/letta-connect.ts @@ -0,0 +1,176 @@ +/** + * Use Letta Code's provider connection flow from Lettabot. + */ + +import { existsSync } from 'node:fs'; +import { spawn, spawnSync } from 'node:child_process'; +import { resolve } from 'node:path'; +import { createRequire } from 'node:module'; + +interface CommandCandidate { + command: string; + args: string[]; +} + +const require = createRequire(import.meta.url); + +/** Lines that add noise without helping the user. */ +const SUPPRESSED_PATTERNS = [ + /^Checking account/i, + /^Starting OAuth/i, + /^Starting local OAuth/i, + /^A browser window will open/i, + /^Opening browser/i, + /^Waiting for authorization/i, + /^Please complete the sign-in/i, + /^The page will redirect/i, + /^Authorization received/i, + /^Exchanging code/i, + /^Extracting account/i, + /^Creating ChatGPT/i, +]; + +/** Lines we rewrite to something shorter. */ +const REWRITE_RULES: Array<{ pattern: RegExp; replacement: string }> = [ + { pattern: /^If the browser doesn't open automatically,? visit:$/i, replacement: 'If the browser doesn\'t open, visit:' }, + { pattern: /^If needed,? visit:$/i, replacement: '' }, // suppress the duplicate URL header +]; + +function filterOAuthLine(line: string, state: { urlPrinted: boolean }): string | null { + const trimmed = line.trim(); + if (!trimmed) return null; + + // Suppress known noise lines. + if (SUPPRESSED_PATTERNS.some(p => p.test(trimmed))) return null; + + // Rewrite rules. + for (const rule of REWRITE_RULES) { + if (rule.pattern.test(trimmed)) { + return rule.replacement || null; + } + } + + // URLs: print once, skip duplicates. + if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) { + if (state.urlPrinted) return null; + state.urlPrinted = true; + return ` ${trimmed}`; + } + + // Pass through everything else (e.g. success messages). + return trimmed; +} + +async function runLettaCodeCommand(candidate: CommandCandidate, providerAlias: string, env: NodeJS.ProcessEnv): Promise { + return new Promise((resolve) => { + const child = spawn(candidate.command, [...candidate.args, providerAlias], { + stdio: ['inherit', 'pipe', 'pipe'], + cwd: process.cwd(), + env, + }); + + const filterState = { urlPrinted: false }; + let headerPrinted = false; + + child.stdout?.on('data', (chunk: Buffer) => { + for (const raw of chunk.toString().split('\n')) { + const line = filterOAuthLine(raw, filterState); + if (line === null) continue; + if (!headerPrinted) { + console.log('Connecting ChatGPT subscription...\n'); + headerPrinted = true; + } + console.log(line); + } + }); + + // Suppress stderr entirely (hides "Unknown command" from old versions). + child.stderr?.resume(); + + child.on('error', () => resolve(false)); + child.on('close', (code) => resolve(code === 0)); + }); +} + +function getCandidateCommands(): CommandCandidate[] { + const commands: CommandCandidate[] = []; + const seen = new Set(); + + const addCandidate = (candidate: CommandCandidate): void => { + const key = `${candidate.command} ${candidate.args.join(' ')}`; + if (seen.has(key)) { + return; + } + commands.push(candidate); + seen.add(key); + }; + + // Resolve the bundled dependency from lettabot's install path, not only cwd. + try { + const resolvedScript = require.resolve('@letta-ai/letta-code/letta.js'); + if (existsSync(resolvedScript)) { + addCandidate({ + command: process.execPath, + args: [resolvedScript, 'connect'], + }); + } + } catch { + // Fall through to other discovery paths. + } + + // Direct package entrypoint when available. + const letCodeScript = resolve(process.cwd(), 'node_modules', '@letta-ai', 'letta-code', 'letta.js'); + if (existsSync(letCodeScript)) { + addCandidate({ + command: process.execPath, + args: [letCodeScript, 'connect'], + }); + } + + // npm-style binary from local node_modules/.bin + const localBinary = process.platform === 'win32' + ? resolve(process.cwd(), 'node_modules', '.bin', 'letta.cmd') + : resolve(process.cwd(), 'node_modules', '.bin', 'letta'); + if (existsSync(localBinary)) { + addCandidate({ + command: localBinary, + args: ['connect'], + }); + } + + // Fallback to npx from npm registry. + const npxCommand = process.platform === 'win32' ? 'npx.cmd' : 'npx'; + addCandidate({ + command: npxCommand, + args: ['-y', '@letta-ai/letta-code@latest', 'connect'], + }); + + return commands; +} + +export async function runLettaConnect(providers: string[], env: NodeJS.ProcessEnv = process.env): Promise { + const candidates = getCandidateCommands(); + const commandEnv: NodeJS.ProcessEnv = { ...process.env, ...env }; + + const attemptedAliases = new Set(); + for (const provider of providers) { + if (attemptedAliases.has(provider)) { + continue; + } + attemptedAliases.add(provider); + + for (const candidate of candidates) { + const ok = await runLettaCodeCommand(candidate, provider, commandEnv); + if (ok) { + return true; + } + } + } + + return false; +} + +export async function runChatgptConnect(env: NodeJS.ProcessEnv = process.env): Promise { + // Newer Letta Code versions use `chatgpt`; older versions use `codex`. + return runLettaConnect(['chatgpt', 'codex'], env); +} diff --git a/src/onboard.ts b/src/onboard.ts index 2b4aede..7d96148 100644 --- a/src/onboard.ts +++ b/src/onboard.ts @@ -7,9 +7,10 @@ import { resolve } from 'node:path'; import { spawnSync } from 'node:child_process'; import * as p from '@clack/prompts'; import { saveConfig, syncProviders, isApiServerMode } from './config/index.js'; -import type { AgentConfig, LettaBotConfig, ProviderConfig } from './config/types.js'; +import type { AgentConfig, LettaBotConfig } from './config/types.js'; import { isLettaApiUrl } from './utils/server.js'; import { parseCsvList, parseOptionalInt } from './utils/parse.js'; +import { runChatgptConnect } from './commands/letta-connect.js'; import { CHANNELS, getChannelHint, isSignalCliInstalled, setupTelegram, setupSlack, setupDiscord, setupWhatsApp, setupSignal } from './channels/setup.js'; // ============================================================================ @@ -221,8 +222,9 @@ interface OnboardConfig { // Model (only for new agents) model?: string; - // BYOK Providers (for free tier) + // BYOK/connected providers providers?: Array<{ id: string; name: string; apiKey: string }>; + chatgptConnected?: boolean; // Channels (with access control) telegram: { @@ -552,8 +554,17 @@ async function stepAgent(config: OnboardConfig, env: Record): Pr } } +type ByokProvider = { + id: string; + name: string; + displayName: string; + providerType: string; + isOAuth?: boolean; +}; + // BYOK Provider definitions (same as letta-code) -const BYOK_PROVIDERS = [ +const BYOK_PROVIDERS: ByokProvider[] = [ + { id: 'codex', name: 'chatgpt-plus-pro', displayName: 'ChatGPT / Codex', providerType: 'chatgpt_oauth', isOAuth: true }, { 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' }, @@ -563,34 +574,55 @@ const BYOK_PROVIDERS = [ ]; async function stepProviders(config: OnboardConfig, env: Record): Promise { - // Only for free tier users on Letta API (not Docker/custom servers, not paid) if (isDockerAuthMethod(config.authMethod)) return; - if (config.billingTier !== 'free') return; + const isFreeTier = config.billingTier === 'free'; + const providerDefs = BYOK_PROVIDERS.filter(provider => isFreeTier || provider.id === 'codex'); + if (providerDefs.length === 0) 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, - }); + // Paid users only see the ChatGPT OAuth option -- use a confirm instead of multiselect. + const oauthOnly = providerDefs.length === 1 && providerDefs[0].isOAuth; - if (p.isCancel(selectedProviders)) { p.cancel('Setup cancelled'); process.exit(0); } + let selectedProviders: string[]; + if (oauthOnly) { + const connect = await p.confirm({ + message: 'Connect your ChatGPT subscription? (via OAuth)', + initialValue: false, + }); + if (p.isCancel(connect)) { p.cancel('Setup cancelled'); process.exit(0); } + selectedProviders = connect ? [providerDefs[0].id] : []; + } else { + const result = await p.multiselect({ + message: 'Add connected providers (optional)', + options: providerDefs.map(provider => ({ + value: provider.id, + label: provider.displayName, + hint: provider.isOAuth ? 'Connect your ChatGPT subscription via OAuth' : `Connect your ${provider.displayName} API key`, + })), + required: false, + }); + if (p.isCancel(result)) { p.cancel('Setup cancelled'); process.exit(0); } + selectedProviders = (result as string[]) || []; + } // If no providers selected, skip - if (!selectedProviders || selectedProviders.length === 0) { + if (selectedProviders.length === 0) { return; } - config.providers = []; + const providersById = new Map((config.providers ?? []).map(provider => [provider.id, provider])); 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[]) { + for (const providerId of selectedProviders) { const provider = BYOK_PROVIDERS.find(p => p.id === providerId); if (!provider) continue; + if (provider.isOAuth) { + const connected = await runChatgptConnect({ LETTA_BASE_URL: config.baseUrl || 'https://api.letta.com' }); + if (connected) { + config.chatgptConnected = true; + } + continue; + } const providerKey = await p.text({ message: `${provider.displayName} API Key`, @@ -650,7 +682,7 @@ async function stepProviders(config: OnboardConfig, env: Record) if (response.ok) { spinner.stop(`Connected ${provider.displayName}`); - config.providers.push({ id: provider.id, name: provider.name, apiKey: providerKey }); + providersById.set(provider.id, { id: provider.id, name: provider.name, apiKey: providerKey }); // If OpenAI was just connected, offer to enable voice transcription if (provider.id === 'openai') { @@ -673,6 +705,13 @@ async function stepProviders(config: OnboardConfig, env: Record) } } } + + const mergedProviders = Array.from(providersById.values()); + if (mergedProviders.length > 0) { + config.providers = mergedProviders; + } else { + delete config.providers; + } } async function stepModel(config: OnboardConfig, env: Record): Promise { @@ -1198,6 +1237,19 @@ function showSummary(config: OnboardConfig): void { if (config.model) { lines.push(`Model: ${config.model}`); } + + // Providers + const providerNames: string[] = []; + if (config.chatgptConnected) providerNames.push('ChatGPT subscription'); + if (config.providers?.length) { + for (const prov of config.providers) { + const def = BYOK_PROVIDERS.find(b => b.id === prov.id); + if (def && !def.isOAuth) providerNames.push(def.displayName); + } + } + if (providerNames.length > 0) { + lines.push(`Providers: ${providerNames.join(', ')}`); + } // Channels const channels: string[] = []; diff --git a/src/utils/model-selection.ts b/src/utils/model-selection.ts index 161770e..6386bc9 100644 --- a/src/utils/model-selection.ts +++ b/src/utils/model-selection.ts @@ -105,12 +105,25 @@ async function fetchByokModels(apiKey?: string): Promise { } } +function addModelOption( + options: Array<{ value: string; label: string; hint: string }>, + seen: Set, + option: { value: string; label: string; hint: string }, +): void { + if (seen.has(option.value)) { + return; + } + options.push(option); + seen.add(option.value); +} + /** * 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 free users: Show free static models first. + * For paid users: Show featured models first, then full static list. + * For all users: show connected provider models (OAuth/API key providers). * For Docker/custom servers: fetch models from server */ export async function buildModelOptions(options?: { @@ -128,51 +141,80 @@ export async function buildModelOptions(options?: { } const result: Array<{ value: string; label: string; hint: string }> = []; + const seenHandles = new Set(); 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', + freeModels.forEach(model => { + addModelOption(result, seenHandles, { + value: model.handle, + label: model.label, + hint: `🆓 Free - ${model.description}`, }); - - 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}`, - }))); + featured.forEach(model => { + addModelOption(result, seenHandles, { + value: model.handle, + label: model.label, + hint: model.free ? `🆓 Free - ${model.description}` : `⭐ ${model.description}`, + }); + }); - result.push(...nonFeatured.map(m => ({ - value: m.handle, - label: m.label, - hint: m.description, - }))); + nonFeatured.forEach(model => { + addModelOption(result, seenHandles, { + value: model.handle, + label: model.label, + hint: model.description, + }); + }); + } + + // Include connected provider models for both free and paid users. + const byokModels = await fetchByokModels(options?.apiKey); + if (byokModels.length > 0) { + // ChatGPT subscription models get their own section at the top. + const chatgptModels = byokModels.filter(m => m.provider_type === 'chatgpt_oauth'); + const otherModels = byokModels.filter(m => m.provider_type !== 'chatgpt_oauth'); + + if (chatgptModels.length > 0) { + addModelOption(result, seenHandles, { + value: '__chatgpt_header__', + label: '── ChatGPT Subscription ──', + hint: 'Included with your ChatGPT plan', + }); + chatgptModels.forEach(model => { + addModelOption(result, seenHandles, { + value: model.handle, + label: model.display_name || model.name, + hint: 'ChatGPT', + }); + }); + } + + if (otherModels.length > 0) { + addModelOption(result, seenHandles, { + value: '__byok_header__', + label: '── Your API Keys ──', + hint: 'Models from your API keys', + }); + otherModels.forEach(model => { + addModelOption(result, seenHandles, { + value: model.handle, + label: model.display_name || model.name, + hint: `🔑 ${model.provider_name}`, + }); + }); + } } // Add custom option - result.push({ + addModelOption(result, seenHandles, { value: '__custom__', label: 'Other (specify handle)', hint: 'e.g. anthropic/claude-sonnet-4-5-20250929' @@ -226,7 +268,7 @@ export async function handleModelSelection( if (p.isCancel(selection)) return null; // Skip header selections - if (selection === '__byok_header__') return null; + if (selection === '__byok_header__' || selection === '__chatgpt_header__') return null; // Handle custom model input if (selection === '__custom__') {