feat(models): wire /api/models dynamic discovery to model selector dialog

Fetches live Claude model list from /api/models on mount; falls back to
hardcoded CLAUDE_MODELS.OPTIONS when the endpoint is unavailable.
Closes #1
This commit is contained in:
Zac Gaetano 2026-05-29 09:14:34 -04:00
parent a1517e9f26
commit 18516fd488

View file

@ -11,6 +11,7 @@ import {
GEMINI_MODELS,
PROVIDERS,
} from "../../../../../shared/modelConstants";
import { authenticatedFetch } from "../../../../utils/api";
import type { ProjectSession, LLMProvider } from "../../../../types/app";
import { NextTaskBanner } from "../../../task-master";
import {
@ -30,6 +31,8 @@ import {
const MOD_KEY =
typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform) ? "⌘" : "Ctrl";
type ModelOption = { value: string; label: string };
type ProviderSelectionEmptyStateProps = {
selectedSession: ProjectSession | null;
currentSessionId: string | null;
@ -53,22 +56,15 @@ type ProviderSelectionEmptyStateProps = {
type ProviderGroup = {
id: LLMProvider;
name: string;
models: { value: string; label: string }[];
models: ModelOption[];
};
const PROVIDER_GROUPS: ProviderGroup[] = PROVIDERS.map((p) => ({
const STATIC_PROVIDER_GROUPS: ProviderGroup[] = PROVIDERS.map((p) => ({
id: p.id as LLMProvider,
name: p.name,
models: p.models.OPTIONS,
}));
function getModelConfig(p: LLMProvider) {
if (p === "claude") return CLAUDE_MODELS;
if (p === "codex") return CODEX_MODELS;
if (p === "gemini") return GEMINI_MODELS;
return CURSOR_MODELS;
}
function getCurrentModel(
p: LLMProvider,
c: string,
@ -111,10 +107,36 @@ export default function ProviderSelectionEmptyState({
const { t } = useTranslation("chat");
const { isWindowsServer } = useServerPlatform();
const [dialogOpen, setDialogOpen] = useState(false);
const [claudeModelOptions, setClaudeModelOptions] = useState<ModelOption[]>(CLAUDE_MODELS.OPTIONS);
// Fetch live Claude model list from the server; fall back to static constants on failure.
useEffect(() => {
authenticatedFetch("/api/models")
.then((res) => {
if (!res.ok) return;
return res.json();
})
.then((data) => {
if (Array.isArray(data?.claude) && data.claude.length > 0) {
setClaudeModelOptions(data.claude);
}
})
.catch(() => {
// Static fallback already set as initial state.
});
}, []);
const providerGroups = useMemo<ProviderGroup[]>(
() =>
STATIC_PROVIDER_GROUPS.map((g) =>
g.id === "claude" ? { ...g, models: claudeModelOptions } : g,
),
[claudeModelOptions],
);
const visibleProviderGroups = useMemo(
() => (isWindowsServer ? PROVIDER_GROUPS.filter((p) => p.id !== "cursor") : PROVIDER_GROUPS),
[isWindowsServer],
() => (isWindowsServer ? providerGroups.filter((p) => p.id !== "cursor") : providerGroups),
[isWindowsServer, providerGroups],
);
useEffect(() => {
@ -137,12 +159,16 @@ export default function ProviderSelectionEmptyState({
);
const currentModelLabel = useMemo(() => {
const config = getModelConfig(provider);
const found = config.OPTIONS.find(
(o: { value: string; label: string }) => o.value === currentModel,
);
return found?.label || currentModel;
}, [provider, currentModel]);
const options: ModelOption[] =
provider === "claude"
? claudeModelOptions
: provider === "codex"
? CODEX_MODELS.OPTIONS
: provider === "gemini"
? GEMINI_MODELS.OPTIONS
: CURSOR_MODELS.OPTIONS;
return options.find((o) => o.value === currentModel)?.label ?? currentModel;
}, [provider, currentModel, claudeModelOptions]);
const setModelForProvider = useCallback(
(providerId: LLMProvider, modelValue: string) => {