fix: fix server terminology with mode aliases (#277)

This commit is contained in:
Charles Packer
2026-02-10 20:34:29 -08:00
committed by GitHub
parent 83569d968e
commit de1adcf4fe
21 changed files with 198 additions and 133 deletions

View File

@@ -386,7 +386,7 @@ Check the [ADE](https://app.letta.com) to see if your agent is attempting to use
## Documentation
- [Getting Started](docs/getting-started.md)
- [Self-Hosted Setup](docs/selfhosted-setup.md) - Run with your own Letta server
- [Docker Server Setup](docs/selfhosted-setup.md) - Run with your own Letta server
- [Configuration Reference](docs/configuration.md)
- [Slack Setup](docs/slack-setup.md)
- [Discord Setup](docs/discord-setup.md)

View File

@@ -61,7 +61,7 @@ The wizard will guide you through:
| Variable | Description | Default |
|----------|-------------|---------|
| `LETTA_API_KEY` | API key from app.letta.com | Required (unless self-hosted) |
| `LETTA_API_KEY` | API key from app.letta.com | Required (unless using a Docker server) |
| `LETTA_BASE_URL` | API endpoint | `https://api.letta.com` |
### Agent Selection
@@ -329,9 +329,9 @@ The agent can verify success by checking:
- Config file exists at `~/.lettabot/config.yaml`
- User can message bot on configured channel(s)
## Self-Hosted Letta
## Docker Server Letta
To use a self-hosted Letta server:
To use a Letta Docker server:
```bash
# Run Letta Docker

View File

@@ -58,7 +58,7 @@ describe('myFunction', () => {
## E2E Tests
E2E tests verify the full message flow against a real Letta Cloud agent.
E2E tests verify the full message flow against a real Letta API agent.
### Setup
@@ -73,7 +73,7 @@ Without these, E2E tests are automatically skipped.
### Test Agent
We use a dedicated test agent named "greg" on Letta Cloud. This agent:
We use a dedicated test agent named "greg" on Letta API. This agent:
- Has minimal configuration
- Is only used for automated testing
- Should not have any sensitive data
@@ -137,7 +137,7 @@ Tests run automatically via GitHub Actions (`.github/workflows/test.yml`):
| Job | Trigger | What it tests |
|-----|---------|---------------|
| `unit` | All PRs and pushes | Unit tests only |
| `e2e` | Pushes to main | Full E2E with Letta Cloud |
| `e2e` | Pushes to main | Full E2E with Letta API |
E2E tests only run on `main` because they require secrets that aren't available to fork PRs.

View File

@@ -5,7 +5,7 @@ LettaBot is a multi-channel AI assistant powered by [Letta](https://letta.com) t
## Guides
- [Getting Started](./getting-started.md) - Installation and basic setup
- [Self-Hosted Setup](./selfhosted-setup.md) - Run with your own Letta server
- [Docker Server Setup](./selfhosted-setup.md) - Run with your own Letta server
- [Configuration Reference](./configuration.md) - All config options
- [Commands Reference](./commands.md) - Bot commands reference
- [CLI Tools](./cli-tools.md) - Agent/operator CLI tools
@@ -62,7 +62,7 @@ LettaBot uses a **single agent with unified memory** across all channels:
┌──────────────────────────┐
│ Letta Server │
│ (api.letta.com or │
self-hosted Docker)
Docker/custom)
│ │
│ • Agent Memory │
│ • LLM Inference │

View File

@@ -21,8 +21,8 @@ For global installs (`npm install -g`), either:
```yaml
# Server connection
server:
mode: cloud # 'cloud' or 'selfhosted'
apiKey: letta_... # Required for cloud mode
mode: api # 'api' or 'docker' (legacy: 'cloud'/'selfhosted')
apiKey: letta_... # Required for api mode
# Agent settings (single agent mode)
# For multiple agents, use `agents:` array instead -- see Multi-Agent section
@@ -98,15 +98,15 @@ api:
| Option | Type | Description |
|--------|------|-------------|
| `server.mode` | `'cloud'` \| `'selfhosted'` | Connection mode |
| `server.apiKey` | string | API key for Letta Cloud |
| `server.baseUrl` | string | URL for self-hosted server (e.g., `http://localhost:8283`) |
| `server.mode` | `'api'` \| `'docker'` | Connection mode (legacy aliases: `'cloud'`, `'selfhosted'`) |
| `server.apiKey` | string | API key for Letta API |
| `server.baseUrl` | string | URL for Docker/custom server (e.g., `http://localhost:8283`) |
### Self-Hosted Mode
### Docker Server Mode
```yaml
server:
mode: selfhosted
mode: docker
baseUrl: http://localhost:8283
```
@@ -142,7 +142,7 @@ Use the `agents:` array instead of the top-level `agent:` and `channels:` keys:
```yaml
server:
mode: cloud
mode: api
apiKey: letta_...
agents:

View File

@@ -17,7 +17,7 @@ Deploy LettaBot to [Railway](https://railway.app) for always-on hosting.
| Variable | Description |
|----------|-------------|
| `LETTA_API_KEY` | Your Letta Cloud API key ([get one here](https://app.letta.com)) |
| `LETTA_API_KEY` | Your Letta API key ([get one here](https://app.letta.com)) |
### Channel Configuration (at least one required)
@@ -59,7 +59,7 @@ SLACK_APP_TOKEN=xapp-...
On startup, LettaBot:
1. Checks for `LETTA_AGENT_ID` env var - uses if set
2. Otherwise, searches Letta Cloud for an agent named `LETTA_AGENT_NAME` (or legacy `AGENT_NAME`, default: "LettaBot")
2. Otherwise, searches Letta API for an agent named `LETTA_AGENT_NAME` (or legacy `AGENT_NAME`, default: "LettaBot")
3. If found, uses the existing agent (preserves memory!)
4. If not found, creates a new agent on first message

View File

@@ -1,6 +1,6 @@
# Self-Hosted Letta Server Setup
# Docker Server Setup
Run LettaBot with your own Letta server instead of Letta Cloud.
Run LettaBot with your own Letta Docker/custom server instead of Letta API.
## Prerequisites
@@ -42,7 +42,7 @@ curl http://localhost:8283/v1/health
lettabot onboard
```
Select "Enter self-hosted URL" and enter `http://localhost:8283`.
Select "Enter Docker server URL" and enter `http://localhost:8283`.
### Option B: Manual Configuration
@@ -50,7 +50,7 @@ Create `lettabot.yaml`:
```yaml
server:
mode: selfhosted
mode: docker
baseUrl: http://localhost:8283
# apiKey: optional-if-server-requires-auth
@@ -79,7 +79,7 @@ lettabot server
You should see:
```
[Config] Loaded from /path/to/lettabot.yaml
[Config] Mode: selfhosted, Agent: LettaBot, Model: gpt-4o
[Config] Mode: docker, Agent: LettaBot, Model: gpt-4o
Starting LettaBot...
LettaBot initialized. Agent ID: (new)
[Telegram] Bot started as @YourBotName

View File

@@ -1,7 +1,7 @@
/**
* E2E Tests for LettaBot
*
* These tests use a real Letta Cloud agent to verify the full message flow.
* These tests use a real Letta API agent to verify the full message flow.
* Requires LETTA_API_KEY and LETTA_E2E_AGENT_ID environment variables.
*
* Run with: npm run test:e2e
@@ -17,7 +17,7 @@ import { join } from 'node:path';
// Skip if no API key (local dev without secrets)
const SKIP_E2E = !process.env.LETTA_API_KEY || !process.env.LETTA_E2E_AGENT_ID;
describe.skipIf(SKIP_E2E)('e2e: LettaBot with Letta Cloud', () => {
describe.skipIf(SKIP_E2E)('e2e: LettaBot with Letta API', () => {
let bot: LettaBot;
let mockAdapter: MockChannelAdapter;
let tempDir: string;

View File

@@ -1,7 +1,7 @@
/**
* E2E Tests for Model API
*
* Tests model listing and retrieval against Letta Cloud.
* Tests model listing and retrieval against Letta API.
* Requires LETTA_API_KEY and LETTA_E2E_AGENT_ID environment variables.
*
* Run with: npm run test:e2e
@@ -13,10 +13,10 @@ import { listModels, getAgentModel } from '../src/tools/letta-api.js';
const SKIP_E2E = !process.env.LETTA_API_KEY || !process.env.LETTA_E2E_AGENT_ID;
describe.skipIf(SKIP_E2E)('e2e: Model API', () => {
it('lists available models from Letta Cloud', async () => {
it('lists available models from Letta API', async () => {
const models = await listModels();
expect(models.length).toBeGreaterThan(0);
// Known providers should always exist on Letta Cloud
// Known providers should always exist on Letta API
const handles = models.map(m => m.handle);
expect(handles.some(h => h.includes('anthropic') || h.includes('openai'))).toBe(true);
}, 30000);

View File

@@ -2,15 +2,16 @@
# 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
# - 'api': Use Letta API (api.letta.com) with API key
# - 'docker': Use a Docker/custom Letta server
# Legacy aliases still accepted: 'cloud', 'selfhosted'
server:
mode: cloud
# For cloud mode, set your API key (get one at https://app.letta.com):
mode: api
# For api 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
# For docker mode, uncomment and set the base URL:
# mode: docker
# baseUrl: http://localhost:8283
agent:
@@ -19,8 +20,8 @@ agent:
# Note: model is configured on the Letta agent server-side.
# Select a model during `lettabot onboard` or change it with `lettabot model set <handle>`.
# BYOK Providers (optional, cloud mode only)
# These will be synced to Letta Cloud on startup
# BYOK Providers (optional, api mode only)
# These will be synced to Letta API on startup
# providers:
# - id: anthropic
# name: lc-anthropic

View File

@@ -1,5 +1,5 @@
/**
* OAuth 2.0 utilities for Letta Cloud authentication
* OAuth 2.0 utilities for Letta API authentication
* Uses Device Code Flow for CLI authentication
*
* Ported from @letta-ai/letta-code
@@ -7,13 +7,15 @@
import Letta from "@letta-ai/letta-client";
export const LETTA_CLOUD_API_URL = "https://api.letta.com";
export const LETTA_API_URL = "https://api.letta.com";
// Backward-compatible alias for older imports.
export const LETTA_CLOUD_API_URL = LETTA_API_URL;
export const OAUTH_CONFIG = {
clientId: "ci-let-724dea7e98f4af6f8f370f4b1466200c",
clientSecret: "", // Not needed for device code flow
authBaseUrl: "https://app.letta.com",
apiBaseUrl: LETTA_CLOUD_API_URL,
apiBaseUrl: LETTA_API_URL,
} as const;
export interface DeviceCodeResponse {

View File

@@ -9,7 +9,7 @@
*/
// Config loaded from lettabot.yaml
import { loadConfig, applyConfigToEnv } from './config/index.js';
import { loadConfig, applyConfigToEnv, serverModeLabel } from './config/index.js';
const config = loadConfig();
applyConfigToEnv(config);
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
@@ -48,7 +48,7 @@ async function configure() {
// Show current config from YAML
const configRows = [
['Server Mode', config.server.mode],
['Server Mode', serverModeLabel(config.server.mode)],
['API Key', config.server.apiKey ? '✓ Set' : '✗ Not set'],
['Agent Name', config.agent.name],
['Telegram', config.channels.telegram?.enabled ? '✓ Enabled' : '✗ Disabled'],

View File

@@ -9,7 +9,7 @@
import { getAgentModel, updateAgentModel } from '../tools/letta-api.js';
import { buildModelOptions, handleModelSelection, getBillingTier } from '../utils/model-selection.js';
import { isLettaCloudUrl } from '../utils/server.js';
import { isLettaApiUrl } from '../utils/server.js';
import { Store } from '../core/store.js';
/**
@@ -79,11 +79,11 @@ export async function modelInteractive(): Promise<void> {
p.log.info(`Current model: ${currentModel}`);
}
// Determine if self-hosted
// Determine if using Docker/custom server
const baseUrl = process.env.LETTA_BASE_URL;
const isSelfHosted = !!baseUrl && !isLettaCloudUrl(baseUrl);
const isSelfHosted = !!baseUrl && !isLettaApiUrl(baseUrl);
// Get billing tier for cloud users
// Get billing tier for Letta API users
let billingTier: string | null = null;
if (!isSelfHosted) {
const spinner = p.spinner();

View File

@@ -9,7 +9,8 @@ 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';
import { DEFAULT_CONFIG, canonicalizeServerMode, isApiServerMode, isDockerServerMode } from './types.js';
import { LETTA_API_URL } from '../auth/oauth.js';
// Config file locations (checked in order)
const CONFIG_PATHS = [
@@ -64,14 +65,22 @@ export function loadConfig(): LettaBotConfig {
// Re-extract from document AST to preserve the original string representation.
fixLargeGroupIds(content, parsed);
// Merge with defaults
return {
// Merge with defaults and canonicalize server mode.
const merged = {
...DEFAULT_CONFIG,
...parsed,
server: { ...DEFAULT_CONFIG.server, ...parsed.server },
agent: { ...DEFAULT_CONFIG.agent, ...parsed.agent },
channels: { ...DEFAULT_CONFIG.channels, ...parsed.channels },
};
return {
...merged,
server: {
...merged.server,
mode: canonicalizeServerMode(merged.server.mode),
},
};
} catch (err) {
console.error(`[Config] Failed to load ${configPath}:`, err);
return { ...DEFAULT_CONFIG };
@@ -107,7 +116,7 @@ export function configToEnv(config: LettaBotConfig): Record<string, string> {
const env: Record<string, string> = {};
// Server
if (config.server.mode === 'selfhosted' && config.server.baseUrl) {
if (isDockerServerMode(config.server.mode) && config.server.baseUrl) {
env.LETTA_BASE_URL = config.server.baseUrl;
}
if (config.server.apiKey) {
@@ -309,10 +318,10 @@ export function applyConfigToEnv(config: LettaBotConfig): void {
}
/**
* Create BYOK providers on Letta Cloud
* Create BYOK providers on Letta API
*/
export async function syncProviders(config: Partial<LettaBotConfig> & Pick<LettaBotConfig, 'server'>): Promise<void> {
if (config.server.mode !== 'cloud' || !config.server.apiKey) {
if (!isApiServerMode(config.server.mode) || !config.server.apiKey) {
return;
}
@@ -321,7 +330,7 @@ export async function syncProviders(config: Partial<LettaBotConfig> & Pick<Letta
}
const apiKey = config.server.apiKey;
const baseUrl = 'https://api.letta.com';
const baseUrl = LETTA_API_URL;
// List existing providers
const listResponse = await fetch(`${baseUrl}/v1/providers`, {

View File

@@ -1,7 +1,23 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { normalizeAgents, type LettaBotConfig, type AgentConfig } from './types.js';
import {
normalizeAgents,
canonicalizeServerMode,
isApiServerMode,
isDockerServerMode,
type LettaBotConfig,
type AgentConfig,
} from './types.js';
describe('normalizeAgents', () => {
it('canonicalizes legacy server mode aliases', () => {
expect(canonicalizeServerMode('cloud')).toBe('api');
expect(canonicalizeServerMode('api')).toBe('api');
expect(canonicalizeServerMode('selfhosted')).toBe('docker');
expect(canonicalizeServerMode('docker')).toBe('docker');
expect(isApiServerMode('cloud')).toBe(true);
expect(isDockerServerMode('selfhosted')).toBe(true);
});
it('should normalize legacy single-agent config to one-entry array', () => {
const config: LettaBotConfig = {
server: { mode: 'cloud' },

View File

@@ -2,10 +2,29 @@
* 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
* 1. Docker server: Uses baseUrl (e.g., http://localhost:8283), no API key
* 2. Letta API: Uses apiKey, optional BYOK providers
*/
export type ServerMode = 'api' | 'docker' | 'cloud' | 'selfhosted';
export type CanonicalServerMode = 'api' | 'docker';
export function canonicalizeServerMode(mode?: ServerMode): CanonicalServerMode {
return mode === 'docker' || mode === 'selfhosted' ? 'docker' : 'api';
}
export function isDockerServerMode(mode?: ServerMode): boolean {
return canonicalizeServerMode(mode) === 'docker';
}
export function isApiServerMode(mode?: ServerMode): boolean {
return canonicalizeServerMode(mode) === 'api';
}
export function serverModeLabel(mode?: ServerMode): string {
return canonicalizeServerMode(mode);
}
/**
* Configuration for a single agent in multi-agent mode.
* Each agent has its own name, channels, and features.
@@ -50,11 +69,12 @@ export interface AgentConfig {
export interface LettaBotConfig {
// Server connection
server: {
// 'cloud' (api.letta.com) or 'selfhosted'
mode: 'cloud' | 'selfhosted';
// Only for selfhosted mode
// Canonical values: 'api' or 'docker'
// Legacy aliases accepted for compatibility: 'cloud', 'selfhosted'
mode: ServerMode;
// Only for docker mode
baseUrl?: string;
// Only for cloud mode
// Only for api mode
apiKey?: string;
};
@@ -71,7 +91,7 @@ export interface LettaBotConfig {
model?: string;
};
// BYOK providers (cloud mode only)
// BYOK providers (api mode only)
providers?: ProviderConfig[];
// Channel configurations
@@ -272,7 +292,7 @@ export interface GoogleConfig {
// Default config
export const DEFAULT_CONFIG: LettaBotConfig = {
server: {
mode: 'cloud',
mode: 'api',
},
agent: {
name: 'LettaBot',

View File

@@ -14,16 +14,23 @@ import { createApiServer } from './api/server.js';
import { loadOrGenerateApiKey } from './api/auth.js';
// Load YAML config and apply to process.env (overrides .env values)
import { loadConfig, applyConfigToEnv, syncProviders, resolveConfigPath } from './config/index.js';
import { isLettaCloudUrl } from './utils/server.js';
import {
loadConfig,
applyConfigToEnv,
syncProviders,
resolveConfigPath,
isDockerServerMode,
serverModeLabel,
} from './config/index.js';
import { isLettaApiUrl } from './utils/server.js';
import { getDataDir, getWorkingDir, hasRailwayVolume } from './utils/paths.js';
const yamlConfig = loadConfig();
const configSource = existsSync(resolveConfigPath()) ? resolveConfigPath() : 'defaults + environment variables';
console.log(`[Config] Loaded from ${configSource}`);
if (yamlConfig.agents?.length) {
console.log(`[Config] Mode: ${yamlConfig.server.mode}, Agents: ${yamlConfig.agents.map(a => a.name).join(', ')}`);
console.log(`[Config] Mode: ${serverModeLabel(yamlConfig.server.mode)}, Agents: ${yamlConfig.agents.map(a => a.name).join(', ')}`);
} else {
console.log(`[Config] Mode: ${yamlConfig.server.mode}, Agent: ${yamlConfig.agent.name}`);
console.log(`[Config] Mode: ${serverModeLabel(yamlConfig.server.mode)}, Agent: ${yamlConfig.agent.name}`);
}
if (yamlConfig.agent?.model) {
console.warn('[Config] WARNING: agent.model in lettabot.yaml is deprecated and ignored. Use `lettabot model set <handle>` instead.');
@@ -98,8 +105,8 @@ async function refreshTokensIfNeeded(): Promise<void> {
return;
}
// OAuth tokens only work with Letta Cloud - skip if using custom server
if (!isLettaCloudUrl(process.env.LETTA_BASE_URL)) {
// OAuth tokens only work with Letta API - skip if using custom server
if (!isLettaApiUrl(process.env.LETTA_BASE_URL)) {
return;
}
@@ -435,11 +442,11 @@ const globalConfig = {
cronEnabled: process.env.CRON_ENABLED === 'true', // Legacy env var fallback
};
// Validate LETTA_API_KEY is set for cloud mode (selfhosted mode doesn't require it)
if (yamlConfig.server.mode !== 'selfhosted' && !process.env.LETTA_API_KEY) {
console.error('\n Error: LETTA_API_KEY is required for Letta Cloud.');
// Validate LETTA_API_KEY is set for API mode (docker mode doesn't require it)
if (!isDockerServerMode(yamlConfig.server.mode) && !process.env.LETTA_API_KEY) {
console.error('\n Error: LETTA_API_KEY is required for Letta API.');
console.error(' Get your API key from https://app.letta.com and set it as an environment variable.');
console.error(' Or use selfhosted mode: run "lettabot onboard" and select "Enter self-hosted URL".\n');
console.error(' Or use docker mode: run "lettabot onboard" and select "Enter Docker server URL".\n');
process.exit(1);
}

View File

@@ -6,9 +6,9 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { spawnSync } from 'node:child_process';
import * as p from '@clack/prompts';
import { saveConfig, syncProviders } from './config/index.js';
import { saveConfig, syncProviders, isApiServerMode } from './config/index.js';
import type { AgentConfig, LettaBotConfig, ProviderConfig } from './config/types.js';
import { isLettaCloudUrl } from './utils/server.js';
import { isLettaApiUrl } from './utils/server.js';
import { CHANNELS, getChannelHint, isSignalCliInstalled, setupTelegram, setupSlack, setupDiscord, setupWhatsApp, setupSignal } from './channels/setup.js';
// ============================================================================
@@ -118,7 +118,7 @@ async function saveConfigFromEnv(config: any, configPath: string): Promise<void>
const lettabotConfig: Partial<LettaBotConfig> & Pick<LettaBotConfig, 'server'> = {
server: {
mode: isLettaCloudUrl(config.baseUrl) ? 'cloud' : 'selfhosted',
mode: isLettaApiUrl(config.baseUrl) ? 'api' : 'docker',
baseUrl: config.baseUrl,
apiKey: config.apiKey,
},
@@ -207,7 +207,7 @@ async function saveConfigFromEnv(config: any, configPath: string): Promise<void>
interface OnboardConfig {
// Auth
authMethod: 'keep' | 'oauth' | 'apikey' | 'selfhosted' | 'skip';
authMethod: 'keep' | 'oauth' | 'apikey' | 'docker' | 'selfhosted' | 'skip';
apiKey?: string;
baseUrl?: string;
billingTier?: string;
@@ -288,6 +288,7 @@ interface OnboardConfig {
}
const isPlaceholder = (val?: string) => !val || /^(your_|sk-\.\.\.|placeholder|example)/i.test(val);
const isDockerAuthMethod = (method: OnboardConfig['authMethod']) => method === 'docker' || method === 'selfhosted';
// ============================================================================
// Step Functions
@@ -298,12 +299,12 @@ async function stepAuth(config: OnboardConfig, env: Record<string, string>): Pro
const { saveTokens, loadTokens, getOrCreateDeviceId, getDeviceName } = await import('./auth/tokens.js');
const baseUrl = config.baseUrl || env.LETTA_BASE_URL || process.env.LETTA_BASE_URL;
const isLettaCloud = isLettaCloudUrl(baseUrl);
const isLettaApi = isLettaApiUrl(baseUrl);
const existingTokens = loadTokens();
// 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 validOAuthToken = isLettaApi ? existingTokens?.accessToken : undefined;
const hasExistingAuth = !!realApiKey || !!validOAuthToken;
const displayKey = realApiKey || validOAuthToken;
@@ -316,9 +317,9 @@ async function stepAuth(config: OnboardConfig, env: Record<string, string>): Pro
const authOptions = [
...(hasExistingAuth ? [{ value: 'keep', label: getAuthLabel(), hint: displayKey?.slice(0, 20) + '...' }] : []),
...(isLettaCloud ? [{ value: 'oauth', label: 'Login to Letta Platform', hint: 'Opens browser' }] : []),
...(isLettaApi ? [{ value: 'oauth', label: 'Login to Letta Platform', hint: 'Opens browser' }] : []),
{ value: 'apikey', label: 'Enter API Key manually', hint: 'Paste your key' },
{ value: 'selfhosted', label: 'Enter self-hosted URL', hint: 'Local Letta server' },
{ value: 'docker', label: 'Enter Docker server URL', hint: 'Local/custom Letta server' },
{ value: 'skip', label: 'Skip', hint: 'Continue without auth' },
];
@@ -390,7 +391,7 @@ async function stepAuth(config: OnboardConfig, env: Record<string, string>): Pro
config.apiKey = apiKey;
env.LETTA_API_KEY = apiKey;
}
} else if (authMethod === 'selfhosted') {
} else if (authMethod === 'docker' || authMethod === 'selfhosted') {
const serverUrl = await p.text({
message: 'Letta server URL',
placeholder: 'http://localhost:8283',
@@ -403,7 +404,7 @@ async function stepAuth(config: OnboardConfig, env: Record<string, string>): Pro
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
// Clear API key since we're using a Docker/custom server.
delete env.LETTA_API_KEY;
delete process.env.LETTA_API_KEY;
} else if (authMethod === 'keep') {
@@ -458,14 +459,14 @@ async function stepAuth(config: OnboardConfig, env: Record<string, string>): Pro
}
const spinner = p.spinner();
const serverLabel = config.baseUrl || 'Letta Cloud';
const serverLabel = config.baseUrl || 'Letta API';
spinner.start(`Checking connection to ${serverLabel}...`);
try {
const { testConnection } = await import('./tools/letta-api.js');
const ok = await testConnection();
spinner.stop(ok ? `Connected to ${serverLabel}` : 'Connection issue');
if (!ok && config.authMethod === 'selfhosted') {
if (!ok && isDockerAuthMethod(config.authMethod)) {
p.log.warn(`Could not connect to ${config.baseUrl}. Make sure the server is running.`);
}
} catch {
@@ -561,8 +562,8 @@ const BYOK_PROVIDERS = [
];
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;
// 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 selectedProviders = await p.multiselect({
@@ -680,10 +681,10 @@ async function stepModel(config: OnboardConfig, env: Record<string, string>): Pr
const spinner = p.spinner();
// Determine if self-hosted (not Letta Cloud)
const isSelfHosted = config.authMethod === 'selfhosted';
// Determine if Docker/custom server (not Letta API)
const isSelfHosted = isDockerAuthMethod(config.authMethod);
// Fetch billing tier for Letta Cloud users (if not already fetched)
// Fetch billing tier for Letta API users (if not already fetched)
let billingTier: string | null = config.billingTier || null;
if (!isSelfHosted && !billingTier) {
spinner.start('Checking account...');
@@ -1155,7 +1156,8 @@ function showSummary(config: OnboardConfig): void {
keep: 'Keep existing',
oauth: 'OAuth login',
apikey: config.apiKey ? `API Key (${config.apiKey.slice(0, 10)}...)` : 'API Key',
selfhosted: config.baseUrl ? `Self-hosted (${config.baseUrl})` : 'Self-hosted',
docker: config.baseUrl ? `Docker server (${config.baseUrl})` : 'Docker server',
selfhosted: config.baseUrl ? `Docker server (${config.baseUrl})` : 'Docker server',
skip: 'None',
}[config.authMethod];
lines.push(`Auth: ${authLabel}`);
@@ -1320,12 +1322,12 @@ export async function onboard(options?: { nonInteractive?: boolean }): Promise<v
console.log('');
// Validate required fields
if (!config.apiKey && isLettaCloudUrl(config.baseUrl)) {
if (!config.apiKey && isLettaApiUrl(config.baseUrl)) {
console.error('❌ Error: LETTA_API_KEY is required');
console.error(' Get your API key from: https://app.letta.com/settings');
console.error(' Then run: export LETTA_API_KEY="letta_..."');
console.error('');
console.error(' Or use self-hosted Letta:');
console.error(' Or use a Docker server:');
console.error(' export LETTA_BASE_URL="http://localhost:8283"');
process.exit(1);
}
@@ -1385,8 +1387,8 @@ export async function onboard(options?: { nonInteractive?: boolean }): Promise<v
// Pre-populate from existing config
const baseUrl = existingConfig.server.baseUrl || process.env.LETTA_BASE_URL || 'https://api.letta.com';
const isLocal = !isLettaCloudUrl(baseUrl);
p.note(`${baseUrl}\n${isLocal ? 'Self-hosted' : 'Letta Cloud'}`, 'Server');
const isLocal = !isLettaApiUrl(baseUrl);
p.note(`${baseUrl}\n${isLocal ? 'Docker server' : 'Letta API'}`, 'Server');
// Test server connection
const spinner = p.spinner();
@@ -1479,8 +1481,8 @@ export async function onboard(options?: { nonInteractive?: boolean }): Promise<v
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') {
// Fetch billing tier for free plan detection (only for Letta API)
if (!isDockerAuthMethod(config.authMethod) && config.agentChoice === 'new') {
const { getBillingTier } = await import('./utils/model-selection.js');
const spinner = p.spinner();
spinner.start('Checking account...');
@@ -1762,8 +1764,8 @@ export async function onboard(options?: { nonInteractive?: boolean }): Promise<v
// Convert to YAML config (multi-agent format)
const yamlConfig: Partial<LettaBotConfig> & Pick<LettaBotConfig, 'server'> = {
server: {
mode: config.authMethod === 'selfhosted' ? 'selfhosted' : 'cloud',
...(config.authMethod === 'selfhosted' && config.baseUrl ? { baseUrl: config.baseUrl } : {}),
mode: isDockerAuthMethod(config.authMethod) ? 'docker' : 'api',
...(isDockerAuthMethod(config.authMethod) && config.baseUrl ? { baseUrl: config.baseUrl } : {}),
...(config.apiKey ? { apiKey: config.apiKey } : {}),
},
agents: [agentConfig],
@@ -1789,10 +1791,10 @@ export async function onboard(options?: { nonInteractive?: boolean }): Promise<v
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') {
// Sync BYOK providers to Letta API.
if (yamlConfig.providers && yamlConfig.providers.length > 0 && isApiServerMode(yamlConfig.server.mode)) {
const spinner = p.spinner();
spinner.start('Syncing BYOK providers to Letta Cloud...');
spinner.start('Syncing BYOK providers to Letta API...');
try {
await syncProviders(yamlConfig);
spinner.stop('BYOK providers synced');

View File

@@ -26,11 +26,11 @@ export interface ModelInfo {
* 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)
* @param isSelfHosted - If true, skip billing check (Docker/custom servers have no tiers)
*/
export async function getBillingTier(apiKey?: string, isSelfHosted?: boolean): Promise<string | null> {
try {
// Self-hosted servers don't have billing tiers
// Docker/custom servers don't have billing tiers.
if (isSelfHosted) {
return null;
}
@@ -39,7 +39,7 @@ export async function getBillingTier(apiKey?: string, isSelfHosted?: boolean): P
return 'free';
}
// Always use Letta Cloud for billing check (not process.env.LETTA_BASE_URL)
// Always use Letta API 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',
@@ -111,7 +111,7 @@ async function fetchByokModels(apiKey?: string): Promise<ByokModel[]> {
*
* 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
* For Docker/custom servers: fetch models from server
*/
export async function buildModelOptions(options?: {
billingTier?: string | null;
@@ -122,7 +122,7 @@ export async function buildModelOptions(options?: {
const isSelfHosted = options?.isSelfHosted;
const isFreeTier = billingTier?.toLowerCase() === 'free';
// For self-hosted servers, fetch models from server
// For Docker/custom servers, fetch models from server
if (isSelfHosted) {
return buildServerModelOptions();
}
@@ -182,7 +182,7 @@ export async function buildModelOptions(options?: {
}
/**
* Build model options from self-hosted server
* Build model options from Docker/custom server
*/
async function buildServerModelOptions(): Promise<Array<{ value: string; label: string; hint: string }>> {
const { listModels } = await import('../tools/letta-api.js');

View File

@@ -1,45 +1,50 @@
import { describe, it, expect } from 'vitest';
import { isLettaCloudUrl } from './server.js';
import { isLettaApiUrl, isLettaCloudUrl } from './server.js';
describe('isLettaCloudUrl', () => {
it('returns true for undefined (default is cloud)', () => {
expect(isLettaCloudUrl(undefined)).toBe(true);
describe('isLettaApiUrl', () => {
it('returns true for undefined (default is Letta API)', () => {
expect(isLettaApiUrl(undefined)).toBe(true);
});
it('returns true for Letta Cloud URL', () => {
expect(isLettaCloudUrl('https://api.letta.com')).toBe(true);
it('returns true for Letta API URL', () => {
expect(isLettaApiUrl('https://api.letta.com')).toBe(true);
});
it('returns true for Letta Cloud URL with trailing slash', () => {
expect(isLettaCloudUrl('https://api.letta.com/')).toBe(true);
it('returns true for Letta API URL with trailing slash', () => {
expect(isLettaApiUrl('https://api.letta.com/')).toBe(true);
});
it('returns true for Letta Cloud URL with path', () => {
expect(isLettaCloudUrl('https://api.letta.com/v1/agents')).toBe(true);
it('returns true for Letta API URL with path', () => {
expect(isLettaApiUrl('https://api.letta.com/v1/agents')).toBe(true);
});
it('returns false for localhost', () => {
expect(isLettaCloudUrl('http://localhost:8283')).toBe(false);
expect(isLettaApiUrl('http://localhost:8283')).toBe(false);
});
it('returns false for 127.0.0.1', () => {
expect(isLettaCloudUrl('http://127.0.0.1:8283')).toBe(false);
expect(isLettaApiUrl('http://127.0.0.1:8283')).toBe(false);
});
it('returns false for custom server', () => {
expect(isLettaCloudUrl('https://custom.server.com')).toBe(false);
expect(isLettaApiUrl('https://custom.server.com')).toBe(false);
});
it('returns false for docker network URL', () => {
expect(isLettaCloudUrl('http://letta:8283')).toBe(false);
expect(isLettaApiUrl('http://letta:8283')).toBe(false);
});
it('returns false for invalid URL', () => {
expect(isLettaCloudUrl('not-a-url')).toBe(false);
expect(isLettaApiUrl('not-a-url')).toBe(false);
});
it('returns true for empty string (treated as default)', () => {
// Empty string is falsy, so it's treated like undefined (default to cloud)
expect(isLettaCloudUrl('')).toBe(true);
// Empty string is falsy, so it's treated like undefined (default to Letta API)
expect(isLettaApiUrl('')).toBe(true);
});
it('keeps backward-compatible alias behavior', () => {
expect(isLettaCloudUrl('https://api.letta.com')).toBe(true);
expect(isLettaCloudUrl('http://localhost:8283')).toBe(false);
});
});

View File

@@ -1,24 +1,27 @@
/**
* Letta server URL utilities
*
* The heuristic is simple: Letta Cloud lives at a known URL.
* Everything else is self-hosted.
* The heuristic is simple: Letta API lives at a known URL.
* Everything else is a Docker/custom server.
*/
import { LETTA_CLOUD_API_URL } from '../auth/oauth.js';
import { LETTA_API_URL } from '../auth/oauth.js';
/**
* Check if a URL points at Letta Cloud (api.letta.com)
* Check if a URL points at Letta API (api.letta.com)
*
* @param url - The base URL to check. When absent, assumes cloud (the default).
* @param url - The base URL to check. When absent, assumes Letta API (the default).
*/
export function isLettaCloudUrl(url?: string): boolean {
if (!url) return true; // no URL means the default (cloud)
export function isLettaApiUrl(url?: string): boolean {
if (!url) return true; // no URL means the default (Letta API)
try {
const given = new URL(url);
const cloud = new URL(LETTA_CLOUD_API_URL);
return given.hostname === cloud.hostname;
const api = new URL(LETTA_API_URL);
return given.hostname === api.hostname;
} catch {
return false;
}
}
// Backward-compatible alias.
export const isLettaCloudUrl = isLettaApiUrl;