fix: localhost improvements (#667)

Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
Charles Packer
2026-01-24 18:39:17 -08:00
committed by GitHub
parent 49e02e438c
commit 417e1bafdd
6 changed files with 381 additions and 24 deletions

View File

@@ -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:

View File

@@ -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;
}

View File

@@ -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");
})()}
/>
)}

View File

@@ -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"

View File

@@ -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<ProfileOption[]>([]);
@@ -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({
/>
<Box height={1} />
{failedAgentMessage && (
<>
<Text color="yellow">{failedAgentMessage}</Text>
<Box height={1} />
</>
)}
{loading ? (
<Text dimColor>Loading pinned agents...</Text>
) : selectingModel && serverModelsForNewAgent ? (
// Model selection mode
<Box flexDirection="column" gap={1}>
<Text bold color={colors.selector.title}>
Select a model
</Text>
<Text dimColor>
The default model ({defaultModelHandle || "unknown"}) is not
available on this server.
</Text>
{showModelSearch && (
<Box>
<Text dimColor>Search: </Text>
<Text>{modelSearchQuery || ""}</Text>
<Text dimColor></Text>
</Box>
)}
{allServerModels.length === 0 ? (
<Box flexDirection="column">
<Text color="yellow">No models found on server.</Text>
<Text dimColor>Server: {serverBaseUrl || "unknown"}</Text>
<Text dimColor>
Did you remember to start the server with your LLM API keys?
</Text>
</Box>
) : filteredModels.length === 0 ? (
<Text dimColor>No models matching "{modelSearchQuery}"</Text>
) : (
<Box flexDirection="column">
{visibleModels.map((handle, index) => {
const actualIndex = modelStartIndex + index;
const isSelected = actualIndex === modelSelectedIndex;
return (
<Box key={handle}>
<Text
color={
isSelected ? colors.selector.itemHighlighted : undefined
}
>
{isSelected ? "> " : " "}
{handle}
</Text>
</Box>
);
})}
{/* Phantom space or scroll indicator - always reserve the line */}
{showModelScrollDown ? (
<Text dimColor> {modelsBelow} more</Text>
) : modelCount > MAX_VISIBLE_MODELS ? (
<Text> </Text>
) : null}
</Box>
)}
<Box>
<Text dimColor>
navigate · Enter select
{showModelSearch ? " · Type to search" : ""} · Esc{" "}
{options.length > 0 ? "back" : "exit"}
</Text>
</Box>
</Box>
) : (
// Agent selection mode
<Box flexDirection="column" gap={1}>
<Text dimColor>{contextMessage}</Text>
<Text bold>Which agent would you like to use?</Text>
{options.length > 0 && (
<Text bold>Which agent would you like to use?</Text>
)}
<Box flexDirection="column" gap={1}>
{displayOptions.map((option, index) => {
@@ -218,7 +386,7 @@ function ProfileSelectionUI({
isSelected ? colors.selector.itemHighlighted : undefined
}
>
{isSelected ? " " : " "}
{isSelected ? "> " : " "}
</Text>
<Text
bold={isSelected}
@@ -260,7 +428,7 @@ function ProfileSelectionUI({
: undefined
}
>
{selectedIndex === displayOptions.length ? " " : " "}
{selectedIndex === displayOptions.length ? "> " : " "}
View all {options.length} profiles
</Text>
</Box>
@@ -274,14 +442,14 @@ function ProfileSelectionUI({
: undefined
}
>
{selectedIndex === totalItems - 1 ? " " : " "}
{selectedIndex === totalItems - 1 ? "> " : " "}
Create a new agent
</Text>
<Text dimColor> (--new)</Text>
</Box>
</Box>
<Box marginTop={1}>
<Box>
<Text dimColor> navigate · Enter select · Esc exit</Text>
</Box>
</Box>
@@ -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,
});
}

View File

@@ -999,6 +999,23 @@ async function main(): Promise<void> {
>(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<string | null>(
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<string | null>(
null,
);
// Release notes to display (checked once on mount)
const [releaseNotes, setReleaseNotes] = useState<string | null>(null);
@@ -1095,6 +1112,35 @@ async function main(): Promise<void> {
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 <id>
// Conversation ID is unique, so we can derive the agent from it
@@ -1158,12 +1204,12 @@ async function main(): Promise<void> {
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<void> {
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<void> {
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<void> {
// 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<void> {
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<void> {
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<void> {
setUserRequestedNewAgent(true);
setLoadingState("assembling");
},
onCreateNewWithModel: (modelHandle: string) => {
setUserRequestedNewAgent(true);
setSelectedServerModel(modelHandle);
setLoadingState("assembling");
},
onExit: () => {
process.exit(0);
},