feat: Only show available models in selector (#238)

This commit is contained in:
Kevin Lin
2025-12-16 11:31:54 -08:00
committed by GitHub
parent 90a66ee94b
commit ea175e1dbb
3 changed files with 260 additions and 26 deletions

View File

@@ -0,0 +1,101 @@
import { getClient } from "./client";
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
type CacheEntry = {
handles: Set<string>;
fetchedAt: number;
};
let cache: CacheEntry | null = null;
let inflight: Promise<CacheEntry> | null = null;
function isFresh(now = Date.now()) {
return cache !== null && now - cache.fetchedAt < CACHE_TTL_MS;
}
export type AvailableModelHandlesResult = {
handles: Set<string>;
source: "cache" | "network";
fetchedAt: number;
};
export function clearAvailableModelsCache() {
cache = null;
}
export function getAvailableModelsCacheInfo(): {
hasCache: boolean;
isFresh: boolean;
fetchedAt: number | null;
ageMs: number | null;
ttlMs: number;
} {
const now = Date.now();
return {
hasCache: cache !== null,
isFresh: isFresh(now),
fetchedAt: cache?.fetchedAt ?? null,
ageMs: cache ? now - cache.fetchedAt : null,
ttlMs: CACHE_TTL_MS,
};
}
async function fetchFromNetwork(): Promise<CacheEntry> {
const client = await getClient();
const modelsList = await client.models.list();
const handles = new Set(
modelsList.map((m) => m.handle).filter((h): h is string => !!h),
);
return { handles, fetchedAt: Date.now() };
}
export async function getAvailableModelHandles(options?: {
forceRefresh?: boolean;
}): Promise<AvailableModelHandlesResult> {
const forceRefresh = options?.forceRefresh === true;
const now = Date.now();
if (!forceRefresh && isFresh(now) && cache) {
return {
handles: cache.handles,
source: "cache",
fetchedAt: cache.fetchedAt,
};
}
if (!forceRefresh && inflight) {
const entry = await inflight;
return {
handles: entry.handles,
source: "network",
fetchedAt: entry.fetchedAt,
};
}
inflight = fetchFromNetwork()
.then((entry) => {
cache = entry;
return entry;
})
.finally(() => {
inflight = null;
});
const entry = await inflight;
return {
handles: entry.handles,
source: "network",
fetchedAt: entry.fetchedAt,
};
}
/**
* Best-effort prefetch to warm the cache (no throw).
* This is intentionally fire-and-forget.
*/
export function prefetchAvailableModelHandles(): void {
void getAvailableModelHandles().catch(() => {
// Ignore failures; UI will handle errors on-demand.
});
}

View File

@@ -14,6 +14,7 @@ import type { LlmConfig } from "@letta-ai/letta-client/resources/models/models";
import { Box, Static, Text } from "ink";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ApprovalResult } from "../agent/approval-execution";
import { prefetchAvailableModelHandles } from "../agent/available-models";
import { getResumeData } from "../agent/check-approval";
import { getClient } from "../agent/client";
import { setCurrentAgentId } from "../agent/context";
@@ -276,6 +277,11 @@ export default function App({
tokenStreaming?: boolean;
agentProvenance?: AgentProvenance | null;
}) {
// Warm the model-access cache in the background so /model is fast on first open.
useEffect(() => {
prefetchAvailableModelHandles();
}, []);
// Track current agent (can change when swapping)
const [agentId, setAgentId] = useState(initialAgentId);
const [agentState, setAgentState] = useState(initialAgentState);

View File

@@ -1,6 +1,11 @@
// Import useInput from vendored Ink for bracketed paste support
import { Box, Text, useInput } from "ink";
import { useEffect, useMemo, useRef, useState } from "react";
import {
clearAvailableModelsCache,
getAvailableModelHandles,
getAvailableModelsCacheInfo,
} from "../../agent/available-models";
import { models } from "../../agent/model";
import { colors } from "./colors";
@@ -30,17 +35,79 @@ export function ModelSelector({
const typedModels = models as UiModel[];
const [showAll, setShowAll] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(0);
// undefined: not loaded yet (show spinner)
// Set<string>: loaded and filtered
// null: error fallback (show all models + warning)
const [availableModels, setAvailableModels] = useState<
Set<string> | null | undefined
>(undefined);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isCached, setIsCached] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const mountedRef = useRef(true);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
// Fetch available models from the API (with caching + inflight dedupe)
const loadModels = useRef(async (forceRefresh = false) => {
try {
if (forceRefresh) {
clearAvailableModelsCache();
if (mountedRef.current) {
setRefreshing(true);
setError(null);
}
}
const cacheInfoBefore = getAvailableModelsCacheInfo();
const result = await getAvailableModelHandles({ forceRefresh });
if (!mountedRef.current) return;
setAvailableModels(result.handles);
setIsCached(!forceRefresh && cacheInfoBefore.isFresh);
setIsLoading(false);
setRefreshing(false);
} catch (err) {
if (!mountedRef.current) return;
setError(err instanceof Error ? err.message : "Failed to load models");
setIsLoading(false);
setRefreshing(false);
// Fallback: show all models if API fails
setAvailableModels(null);
}
});
useEffect(() => {
loadModels.current(false);
}, []);
// Filter models based on availability
const filteredModels = useMemo(() => {
// Not loaded yet: render nothing (avoid briefly showing unfiltered models)
if (availableModels === undefined) return [];
// Error fallback: show all models with warning
if (availableModels === null) return typedModels;
// Loaded: filter to only show models the user has access to
return typedModels.filter((model) => availableModels.has(model.handle));
}, [typedModels, availableModels]);
const featuredModels = useMemo(
() => typedModels.filter((model) => model.isFeatured),
[typedModels],
() => filteredModels.filter((model) => model.isFeatured),
[filteredModels],
);
const visibleModels = useMemo(() => {
if (showAll) return typedModels;
if (showAll) return filteredModels;
if (featuredModels.length > 0) return featuredModels;
return typedModels.slice(0, 5);
}, [featuredModels, showAll, typedModels]);
return filteredModels.slice(0, 5);
}, [featuredModels, showAll, filteredModels]);
// Set initial selection to current model on mount
const initializedRef = useRef(false);
@@ -54,36 +121,94 @@ export function ModelSelector({
}
}, [visibleModels, currentModel]);
const totalItems = showAll ? visibleModels.length : visibleModels.length + 1;
const hasMoreModels =
!showAll && filteredModels.length > visibleModels.length;
const totalItems = hasMoreModels
? visibleModels.length + 1
: visibleModels.length;
useInput((_input, key) => {
if (key.upArrow) {
setSelectedIndex((prev) => Math.max(0, prev - 1));
} else if (key.downArrow) {
setSelectedIndex((prev) => Math.min(totalItems - 1, prev + 1));
} else if (key.return) {
if (!showAll && selectedIndex === visibleModels.length) {
setShowAll(true);
setSelectedIndex(0);
} else {
const selectedModel = visibleModels[selectedIndex];
if (selectedModel) {
onSelect(selectedModel.id);
useInput(
(input, key) => {
// Allow ESC even while loading
if (key.escape) {
onCancel();
return;
}
// Allow 'r' to refresh even while loading (but not while already refreshing)
if (input === "r" && !refreshing) {
loadModels.current(true);
return;
}
// Disable other inputs while loading
if (isLoading || refreshing || visibleModels.length === 0) {
return;
}
if (key.upArrow) {
setSelectedIndex((prev) => Math.max(0, prev - 1));
} else if (key.downArrow) {
setSelectedIndex((prev) => Math.min(totalItems - 1, prev + 1));
} else if (key.return) {
if (hasMoreModels && selectedIndex === visibleModels.length) {
setShowAll(true);
setSelectedIndex(0);
} else {
const selectedModel = visibleModels[selectedIndex];
if (selectedModel) {
onSelect(selectedModel.id);
}
}
}
} else if (key.escape) {
onCancel();
}
});
},
// Keep active so ESC and 'r' work while loading.
{ isActive: true },
);
return (
<Box flexDirection="column" gap={1}>
<Box>
<Box flexDirection="column">
<Text bold color={colors.selector.title}>
Select Model ( to navigate, Enter to select, ESC to cancel)
</Text>
{!isLoading && !refreshing && (
<Text dimColor>
{isCached
? "Cached models (press 'r' to refresh)"
: "Press 'r' to refresh"}
</Text>
)}
</Box>
{isLoading && (
<Box>
<Text dimColor>Loading available models...</Text>
</Box>
)}
{refreshing && (
<Box>
<Text dimColor>Refreshing models...</Text>
</Box>
)}
{error && (
<Box>
<Text color="yellow">
Warning: Could not fetch available models. Showing all models.
</Text>
</Box>
)}
{!isLoading && visibleModels.length === 0 && (
<Box>
<Text color="red">
No models available. Please check your Letta configuration.
</Text>
</Box>
)}
<Box flexDirection="column">
{visibleModels.map((model, index) => {
const isSelected = index === selectedIndex;
@@ -132,7 +257,7 @@ export function ModelSelector({
</Box>
);
})}
{!showAll && (
{!showAll && filteredModels.length > visibleModels.length && (
<Box flexDirection="row" gap={1}>
<Text
color={
@@ -143,7 +268,9 @@ export function ModelSelector({
>
{selectedIndex === visibleModels.length ? "" : " "}
</Text>
<Text dimColor>Show all models</Text>
<Text dimColor>
Show all models ({filteredModels.length} available)
</Text>
</Box>
)}
</Box>