From 417e1bafdd554efda6189205781f0b3ea091ab57 Mon Sep 17 00:00:00 2001 From: Charles Packer Date: Sat, 24 Jan 2026 18:39:17 -0800 Subject: [PATCH] fix: localhost improvements (#667) Co-authored-by: Letta --- src/agent/create.ts | 8 +- src/agent/model.ts | 6 + src/cli/App.tsx | 8 ++ src/cli/components/ModelSelector.tsx | 90 +++++++++++- src/cli/profile-selection.tsx | 204 +++++++++++++++++++++++++-- src/index.ts | 89 ++++++++++-- 6 files changed, 381 insertions(+), 24 deletions(-) diff --git a/src/agent/create.ts b/src/agent/create.ts index 351db53..a2dccaa 100644 --- a/src/agent/create.ts +++ b/src/agent/create.ts @@ -281,9 +281,11 @@ export async function createAgent( blockProvenance.push({ label: blockId, source: "shared" }); } - // Get the model's context window from its configuration + // Get the model's context window from its configuration (if known) + // For unknown models (e.g., from self-hosted servers), don't set a context window + // and let the server use its default const modelUpdateArgs = getModelUpdateArgs(modelHandle); - const contextWindow = (modelUpdateArgs?.context_window as number) || 200_000; + const contextWindow = modelUpdateArgs?.context_window as number | undefined; // Resolve system prompt content: // 1. If systemPromptCustom is provided, use it as-is @@ -319,7 +321,7 @@ export async function createAgent( description: agentDescription, embedding: embeddingModelVal || undefined, model: modelHandle, - context_window_limit: contextWindow, + ...(contextWindow && { context_window_limit: contextWindow }), tools: toolNames, // New blocks created inline with agent (saves ~2s of sequential API calls) memory_blocks: diff --git a/src/agent/model.ts b/src/agent/model.ts index 33774c6..dcd2fb9 100644 --- a/src/agent/model.ts +++ b/src/agent/model.ts @@ -17,6 +17,12 @@ export function resolveModel(modelIdentifier: string): string | null { const byHandle = models.find((m) => m.handle === modelIdentifier); if (byHandle) return byHandle.handle; + // For self-hosted servers: if it looks like a handle (contains /), pass it through + // This allows using models not in models.json (e.g., from server's /v1/models) + if (modelIdentifier.includes("/")) { + return modelIdentifier; + } + return null; } diff --git a/src/cli/App.tsx b/src/cli/App.tsx index 450ff67..fe78164 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -9143,6 +9143,14 @@ Plan file path: ${planFilePath}`; filterProvider={modelSelectorOptions.filterProvider} forceRefresh={modelSelectorOptions.forceRefresh} billingTier={billingTier ?? undefined} + isSelfHosted={(() => { + const settings = settingsManager.getSettings(); + const baseURL = + process.env.LETTA_BASE_URL || + settings.env?.LETTA_BASE_URL || + "https://api.letta.com"; + return !baseURL.includes("api.letta.com"); + })()} /> )} diff --git a/src/cli/components/ModelSelector.tsx b/src/cli/components/ModelSelector.tsx index 09f4cb8..8adea6f 100644 --- a/src/cli/components/ModelSelector.tsx +++ b/src/cli/components/ModelSelector.tsx @@ -15,13 +15,26 @@ const SOLID_LINE = "─"; const VISIBLE_ITEMS = 8; -type ModelCategory = "supported" | "byok" | "byok-all" | "all"; +type ModelCategory = + | "supported" + | "byok" + | "byok-all" + | "all" + | "server-recommended" + | "server-all"; // BYOK provider prefixes (ChatGPT OAuth + lc-* providers from /connect) const BYOK_PROVIDER_PREFIXES = ["chatgpt-plus-pro/", "lc-"]; // Get tab order based on billing tier (free = BYOK first, paid = BYOK last) -function getModelCategories(billingTier?: string): ModelCategory[] { +// For self-hosted servers, only show server-specific tabs +function getModelCategories( + billingTier?: string, + isSelfHosted?: boolean, +): ModelCategory[] { + if (isSelfHosted) { + return ["server-recommended", "server-all"]; + } const isFreeTier = billingTier?.toLowerCase() === "free"; return isFreeTier ? ["byok", "byok-all", "supported", "all"] @@ -49,6 +62,8 @@ interface ModelSelectorProps { forceRefresh?: boolean; /** User's billing tier - affects tab ordering (free = BYOK first) */ billingTier?: string; + /** Whether connected to a self-hosted server (not api.letta.com) */ + isSelfHosted?: boolean; } export function ModelSelector({ @@ -58,15 +73,17 @@ export function ModelSelector({ filterProvider, forceRefresh: forceRefreshOnMount, billingTier, + isSelfHosted, }: ModelSelectorProps) { const terminalWidth = useTerminalWidth(); const solidLine = SOLID_LINE.repeat(Math.max(terminalWidth, 10)); const typedModels = models as UiModel[]; // Tab order depends on billing tier (free = BYOK first) + // For self-hosted, only show server-specific tabs const modelCategories = useMemo( - () => getModelCategories(billingTier), - [billingTier], + () => getModelCategories(billingTier, isSelfHosted), + [billingTier, isSelfHosted], ); const defaultCategory = modelCategories[0] ?? "supported"; @@ -296,6 +313,40 @@ export function ModelSelector({ toBaseHandle, ]); + // Server-recommended models: models.json entries available on the server (for self-hosted) + // Filter out letta/letta-free legacy model + const serverRecommendedModels = useMemo(() => { + if (!isSelfHosted || availableHandles === undefined) return []; + const available = typedModels.filter( + (m) => + availableHandles !== null && + availableHandles.has(m.handle) && + m.handle !== "letta/letta-free", + ); + if (searchQuery) { + const query = searchQuery.toLowerCase(); + return available.filter( + (m) => + m.label.toLowerCase().includes(query) || + m.description.toLowerCase().includes(query) || + m.handle.toLowerCase().includes(query), + ); + } + return available; + }, [isSelfHosted, typedModels, availableHandles, searchQuery]); + + // Server-all models: ALL handles from the server (for self-hosted) + // Filter out letta/letta-free legacy model + const serverAllModels = useMemo(() => { + if (!isSelfHosted) return []; + let handles = allApiHandles.filter((h) => h !== "letta/letta-free"); + if (searchQuery) { + const query = searchQuery.toLowerCase(); + handles = handles.filter((h) => h.toLowerCase().includes(query)); + } + return handles; + }, [isSelfHosted, allApiHandles, searchQuery]); + // Get the list for current category const currentList: UiModel[] = useMemo(() => { if (category === "supported") { @@ -313,6 +364,18 @@ export function ModelSelector({ description: "", })); } + if (category === "server-recommended") { + return serverRecommendedModels; + } + if (category === "server-all") { + // Convert raw handles to UiModel + return serverAllModels.map((handle) => ({ + id: handle, + handle, + label: handle, + description: "", + })); + } // For "all" category, convert handles to simple UiModel objects return otherModelHandles.map((handle) => ({ id: handle, @@ -320,7 +383,15 @@ export function ModelSelector({ label: handle, description: "", })); - }, [category, supportedModels, byokModels, byokAllModels, otherModelHandles]); + }, [ + category, + supportedModels, + byokModels, + byokAllModels, + otherModelHandles, + serverRecommendedModels, + serverAllModels, + ]); // Show 1 fewer item because Search line takes space const visibleCount = VISIBLE_ITEMS - 1; @@ -466,10 +537,19 @@ export function ModelSelector({ if (cat === "supported") return `Letta API [${supportedModels.length}]`; if (cat === "byok") return `BYOK [${byokModels.length}]`; if (cat === "byok-all") return `BYOK (all) [${byokAllModels.length}]`; + if (cat === "server-recommended") + return `Recommended [${serverRecommendedModels.length}]`; + if (cat === "server-all") return `All models [${serverAllModels.length}]`; return `Letta API (all) [${otherModelHandles.length}]`; }; const getCategoryDescription = (cat: ModelCategory) => { + if (cat === "server-recommended") { + return "Recommended models on the server"; + } + if (cat === "server-all") { + return "All models on the server"; + } if (cat === "supported") { return isFreeTier ? "Upgrade your account to access more models" diff --git a/src/cli/profile-selection.tsx b/src/cli/profile-selection.tsx index 8b9d622..ac09bdb 100644 --- a/src/cli/profile-selection.tsx +++ b/src/cli/profile-selection.tsx @@ -20,12 +20,15 @@ interface ProfileOption { } interface ProfileSelectionResult { - type: "select" | "new" | "exit"; + type: "select" | "new" | "new_with_model" | "exit"; agentId?: string; profileName?: string | null; + model?: string; } const MAX_DISPLAY = 3; +const MAX_VISIBLE_MODELS = 8; +const MODEL_SEARCH_THRESHOLD = 10; // Show search input when more than this many models function formatRelativeTime(dateStr: string | null | undefined): string { if (!dateStr) return "Never"; @@ -65,11 +68,19 @@ function ProfileSelectionUI({ lruAgentId, externalLoading, externalFreshRepoMode, + failedAgentMessage, + serverModelsForNewAgent, + defaultModelHandle, + serverBaseUrl, onComplete, }: { lruAgentId: string | null; externalLoading?: boolean; externalFreshRepoMode?: boolean; + failedAgentMessage?: string; + serverModelsForNewAgent?: string[]; + defaultModelHandle?: string; + serverBaseUrl?: string; onComplete: (result: ProfileSelectionResult) => void; }) { const [options, setOptions] = useState([]); @@ -77,6 +88,13 @@ function ProfileSelectionUI({ const loading = externalLoading || internalLoading; const [selectedIndex, setSelectedIndex] = useState(0); const [showAll, setShowAll] = useState(false); + // Model selection mode for self-hosted servers + // Start in model selection mode if serverModelsForNewAgent is provided and no agents to show + const [selectingModel, setSelectingModel] = useState( + !!(serverModelsForNewAgent && serverModelsForNewAgent.length > 0), + ); + const [modelSelectedIndex, setModelSelectedIndex] = useState(0); + const [modelSearchQuery, setModelSearchQuery] = useState(""); const loadOptions = useCallback(async () => { setInternalLoading(true); @@ -146,9 +164,78 @@ function ProfileSelectionUI({ const hasMore = options.length > MAX_DISPLAY; const totalItems = displayOptions.length + 1 + (hasMore && !showAll ? 1 : 0); + // Model selection - filter out legacy models and apply search + const allServerModels = + serverModelsForNewAgent?.filter((h) => h !== "letta/letta-free") ?? []; + const showModelSearch = allServerModels.length > MODEL_SEARCH_THRESHOLD; + const filteredModels = modelSearchQuery + ? allServerModels.filter((h) => + h.toLowerCase().includes(modelSearchQuery.toLowerCase()), + ) + : allServerModels; + const modelCount = filteredModels.length; + + // Model selection scrolling + const modelStartIndex = Math.max( + 0, + Math.min( + modelSelectedIndex - MAX_VISIBLE_MODELS + 1, + modelCount - MAX_VISIBLE_MODELS, + ), + ); + const visibleModels = filteredModels.slice( + modelStartIndex, + modelStartIndex + MAX_VISIBLE_MODELS, + ); + const showModelScrollDown = modelStartIndex + MAX_VISIBLE_MODELS < modelCount; + const modelsBelow = modelCount - modelStartIndex - MAX_VISIBLE_MODELS; + useInput((_input, key) => { if (loading) return; + // Model selection mode + if (selectingModel && serverModelsForNewAgent) { + if (key.upArrow) { + setModelSelectedIndex((prev) => Math.max(0, prev - 1)); + } else if (key.downArrow) { + setModelSelectedIndex((prev) => + Math.min(filteredModels.length - 1, prev + 1), + ); + } else if (key.return) { + const selected = filteredModels[modelSelectedIndex]; + if (selected) { + onComplete({ type: "new_with_model", model: selected }); + } + } else if (key.escape || (key.ctrl && _input === "c")) { + // Go back to agent selection or exit + if (options.length > 0) { + setSelectingModel(false); + setModelSearchQuery(""); + setModelSelectedIndex(0); + } else { + onComplete({ type: "exit" }); + } + } else if (key.backspace || key.delete) { + // Handle backspace for search + if (showModelSearch && modelSearchQuery.length > 0) { + setModelSearchQuery((prev) => prev.slice(0, -1)); + setModelSelectedIndex(0); + } + } else if ( + showModelSearch && + _input && + _input.length === 1 && + !key.ctrl && + !key.meta + ) { + // Handle typing for search + setModelSearchQuery((prev) => prev + _input); + setModelSelectedIndex(0); + } + return; + } + + // Agent selection mode if (key.upArrow) { setSelectedIndex((prev) => Math.max(0, prev - 1)); } else if (key.downArrow) { @@ -171,9 +258,16 @@ function ProfileSelectionUI({ setShowAll(true); setSelectedIndex(0); } else { - onComplete({ type: "new" }); + // "Create new agent" selected + if (serverModelsForNewAgent && serverModelsForNewAgent.length > 0) { + // Need to pick a model first + setSelectingModel(true); + setModelSelectedIndex(0); + } else { + onComplete({ type: "new" }); + } } - } else if (key.escape) { + } else if (key.escape || (key.ctrl && _input === "c")) { onComplete({ type: "exit" }); } }); @@ -196,12 +290,86 @@ function ProfileSelectionUI({ /> + {failedAgentMessage && ( + <> + {failedAgentMessage} + + + )} + {loading ? ( Loading pinned agents... + ) : selectingModel && serverModelsForNewAgent ? ( + // Model selection mode + + + Select a model + + + The default model ({defaultModelHandle || "unknown"}) is not + available on this server. + + + {showModelSearch && ( + + Search: + {modelSearchQuery || ""} + + + )} + + {allServerModels.length === 0 ? ( + + No models found on server. + Server: {serverBaseUrl || "unknown"} + + Did you remember to start the server with your LLM API keys? + + + ) : filteredModels.length === 0 ? ( + No models matching "{modelSearchQuery}" + ) : ( + + {visibleModels.map((handle, index) => { + const actualIndex = modelStartIndex + index; + const isSelected = actualIndex === modelSelectedIndex; + return ( + + + {isSelected ? "> " : " "} + {handle} + + + ); + })} + {/* Phantom space or scroll indicator - always reserve the line */} + {showModelScrollDown ? ( + ↓ {modelsBelow} more + ) : modelCount > MAX_VISIBLE_MODELS ? ( + + ) : null} + + )} + + + + ↑↓ navigate · Enter select + {showModelSearch ? " · Type to search" : ""} · Esc{" "} + {options.length > 0 ? "back" : "exit"} + + + ) : ( + // Agent selection mode {contextMessage} - Which agent would you like to use? + {options.length > 0 && ( + Which agent would you like to use? + )} {displayOptions.map((option, index) => { @@ -218,7 +386,7 @@ function ProfileSelectionUI({ isSelected ? colors.selector.itemHighlighted : undefined } > - {isSelected ? "→ " : " "} + {isSelected ? "> " : " "} - {selectedIndex === displayOptions.length ? "→ " : " "} + {selectedIndex === displayOptions.length ? "> " : " "} View all {options.length} profiles @@ -274,14 +442,14 @@ function ProfileSelectionUI({ : undefined } > - {selectedIndex === totalItems - 1 ? "→ " : " "} + {selectedIndex === totalItems - 1 ? "> " : " "} Create a new agent (--new) - + ↑↓ navigate · Enter select · Esc exit @@ -297,15 +465,29 @@ export function ProfileSelectionInline({ lruAgentId, loading: externalLoading, freshRepoMode, + failedAgentMessage, + serverModelsForNewAgent, + defaultModelHandle, + serverBaseUrl, onSelect, onCreateNew, + onCreateNewWithModel, onExit, }: { lruAgentId: string | null; loading?: boolean; freshRepoMode?: boolean; + failedAgentMessage?: string; + /** If provided, show model selector when user clicks "Create new" */ + serverModelsForNewAgent?: string[]; + /** The default model handle that wasn't available */ + defaultModelHandle?: string; + /** The server base URL for error messages */ + serverBaseUrl?: string; onSelect: (agentId: string) => void; onCreateNew: () => void; + /** Called when user selects a model from serverModelsForNewAgent */ + onCreateNewWithModel?: (model: string) => void; onExit: () => void; }) { const handleComplete = (result: ProfileSelectionResult) => { @@ -313,6 +495,8 @@ export function ProfileSelectionInline({ onExit(); } else if (result.type === "select" && result.agentId) { onSelect(result.agentId); + } else if (result.type === "new_with_model" && result.model) { + onCreateNewWithModel?.(result.model); } else { onCreateNew(); } @@ -322,6 +506,10 @@ export function ProfileSelectionInline({ lruAgentId, externalLoading, externalFreshRepoMode: freshRepoMode, + failedAgentMessage, + serverModelsForNewAgent, + defaultModelHandle, + serverBaseUrl, onComplete: handleComplete, }); } diff --git a/src/index.ts b/src/index.ts index 73f6732..a28b75d 100755 --- a/src/index.ts +++ b/src/index.ts @@ -999,6 +999,23 @@ async function main(): Promise { >(null); // Track when user explicitly requested new agent from selector (not via --new flag) const [userRequestedNewAgent, setUserRequestedNewAgent] = useState(false); + // Message to show when LRU/selected agent failed to load + const [failedAgentMessage, setFailedAgentMessage] = useState( + null, + ); + // For self-hosted: available model handles from server and user's selection + const [availableServerModels, setAvailableServerModels] = useState< + string[] + >([]); + const [selectedServerModel, setSelectedServerModel] = useState< + string | null + >(null); + const [selfHostedDefaultModel, setSelfHostedDefaultModel] = useState< + string | null + >(null); + const [selfHostedBaseUrl, setSelfHostedBaseUrl] = useState( + null, + ); // Release notes to display (checked once on mount) const [releaseNotes, setReleaseNotes] = useState(null); @@ -1095,6 +1112,35 @@ async function main(): Promise { let globalPinned = settingsManager.getGlobalPinnedAgents(); const client = await getClient(); + // For self-hosted servers, pre-fetch available models + // This is needed so ProfileSelectionInline can show model picker + // if the default model isn't available + const baseURL = + process.env.LETTA_BASE_URL || + settings.env?.LETTA_BASE_URL || + LETTA_CLOUD_API_URL; + const isSelfHosted = !baseURL.includes("api.letta.com"); + + if (isSelfHosted) { + setSelfHostedBaseUrl(baseURL); + try { + const { getDefaultModel } = await import("./agent/model"); + const defaultModel = getDefaultModel(); + setSelfHostedDefaultModel(defaultModel); + const modelsList = await client.models.list(); + const handles = modelsList + .map((m) => m.handle) + .filter((h): h is string => typeof h === "string"); + + // Only set if default model isn't available + if (!handles.includes(defaultModel)) { + setAvailableServerModels(handles); + } + } catch { + // Ignore errors - will fail naturally during agent creation if needed + } + } + // ===================================================================== // TOP-LEVEL PATH: --conversation // Conversation ID is unique, so we can derive the agent from it @@ -1158,12 +1204,12 @@ async function main(): Promise { return; } catch { // Local agent doesn't exist, try global - console.log( + setFailedAgentMessage( `Unable to locate agent ${localAgentId} in .letta/, checking global (~/.letta)`, ); } } else { - console.log("No recent agent in .letta/, using global (~/.letta)"); + // No recent agent locally, silently fall through to global } // Try global LRU @@ -1209,7 +1255,7 @@ async function main(): Promise { return; } catch { // Local agent doesn't exist, try global - console.log( + setFailedAgentMessage( `Unable to locate agent ${localAgentId} in .letta/, checking global (~/.letta)`, ); } @@ -1288,7 +1334,7 @@ async function main(): Promise { return; } catch { // LRU agent doesn't exist, show message and fall through to selector - console.log( + setFailedAgentMessage( `Unable to locate recently used agent ${localSettings.lastAgent}`, ); } @@ -1462,10 +1508,18 @@ async function main(): Promise { // Priority 3: Check if --new flag was passed or user requested new from selector if (!agent && shouldCreateNew) { - const updateArgs = getModelUpdateArgs(model); + // For self-hosted: if default model unavailable and no model selected yet, show picker + if (availableServerModels.length > 0 && !selectedServerModel) { + setLoadingState("selecting_global"); + return; + } + + // Use selected server model (from self-hosted model picker) if available + const effectiveModel = selectedServerModel || model; + const updateArgs = getModelUpdateArgs(effectiveModel); const result = await createAgent( undefined, - model, + effectiveModel, undefined, updateArgs, skillsDirectory, @@ -1818,9 +1872,17 @@ async function main(): Promise { return null; } - // Don't render anything during initial "selecting" phase - wait for checkAndStart + // During initial "selecting" phase, render ProfileSelectionInline with loading state + // to prevent component tree switch whitespace artifacts if (loadingState === "selecting") { - return null; + return React.createElement(ProfileSelectionInline, { + lruAgentId: null, + loading: true, // Show loading state while checking + freshRepoMode: true, + onSelect: () => {}, + onCreateNew: () => {}, + onExit: () => process.exit(0), + }); } // Show conversation selector for --resume flag @@ -1849,6 +1911,12 @@ async function main(): Promise { lruAgentId: null, // No LRU in fresh repo loading: false, freshRepoMode: true, // Hides "(global)" labels and simplifies context message + failedAgentMessage: failedAgentMessage ?? undefined, + // For self-hosted: pass available models so user can pick one when creating new agent + serverModelsForNewAgent: + availableServerModels.length > 0 ? availableServerModels : undefined, + defaultModelHandle: selfHostedDefaultModel ?? undefined, + serverBaseUrl: selfHostedBaseUrl ?? undefined, onSelect: (agentId: string) => { setSelectedGlobalAgentId(agentId); setLoadingState("assembling"); @@ -1857,6 +1925,11 @@ async function main(): Promise { setUserRequestedNewAgent(true); setLoadingState("assembling"); }, + onCreateNewWithModel: (modelHandle: string) => { + setUserRequestedNewAgent(true); + setSelectedServerModel(modelHandle); + setLoadingState("assembling"); + }, onExit: () => { process.exit(0); },