From 75805e2d676b497655dd790a7a35848d46ead214 Mon Sep 17 00:00:00 2001 From: Prakhar Pandey Date: Sun, 12 Jul 2026 13:36:16 +0530 Subject: [PATCH] feat: fetch provider models live from API key in settings Settings > Models is now key-first, matching the onboarding flow: entering an API key (or base URL for local providers) fetches the provider's live model list via the existing models:listForProvider IPC, shown in a dropdown. Free-text model entry survives only as the fetch-failure fallback and the openai-compatible custom-model escape hatch. Unkeyed providers no longer show auto-seeded models. Also fixes the models[] wipe: editing the primary model previously discarded every saved model after the first. --- .../src/components/settings-dialog.tsx | 222 +++++++++++------- .../renderer/src/hooks/use-provider-models.ts | 147 ++++++++++++ 2 files changed, 289 insertions(+), 80 deletions(-) create mode 100644 apps/x/apps/renderer/src/hooks/use-provider-models.ts diff --git a/apps/x/apps/renderer/src/components/settings-dialog.tsx b/apps/x/apps/renderer/src/components/settings-dialog.tsx index 4b56eb51..d1a89555 100644 --- a/apps/x/apps/renderer/src/components/settings-dialog.tsx +++ b/apps/x/apps/renderer/src/components/settings-dialog.tsx @@ -29,6 +29,7 @@ import { ConnectedAccountsSettings } from "@/components/settings/connected-accou import { MobileChannelsSettings } from "@/components/settings/mobile-channels-settings" import type { ApprovalPolicy } from "@x/shared/src/code-mode.js" import { startProvisioning, useProvisioning, enabledOptimistic, type AgentStatus, type CodeModeAgentStatus } from "@/lib/code-mode-provisioning" +import { useProviderModels } from "@/hooks/use-provider-models" type ConfigTab = "account" | "connections" | "mobile" | "models" | "mcp" | "security" | "code-mode" | "appearance" | "notifications" | "note-tagging" | "help" @@ -336,6 +337,7 @@ const preferredDefaults: Partial> = { const defaultBaseURLs: Partial> = { ollama: "http://localhost:11434", "openai-compatible": "http://localhost:1234/v1", + aigateway: "https://ai-gateway.vercel.sh/v1", } type ProviderModelConfig = { @@ -356,13 +358,12 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b anthropic: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" }, google: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" }, openrouter: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" }, - aigateway: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" }, + aigateway: { apiKey: "", baseURL: "https://ai-gateway.vercel.sh/v1", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" }, ollama: { apiKey: "", baseURL: "http://localhost:11434", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" }, "openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" }, }) const [modelsCatalog, setModelsCatalog] = useState>({}) const [modelsLoading, setModelsLoading] = useState(false) - const [modelsError, setModelsError] = useState(null) const [testState, setTestState] = useState<{ status: "idle" | "testing" | "success" | "error"; error?: string }>({ status: "idle" }) const [configLoading, setConfigLoading] = useState(true) const [showMoreProviders, setShowMoreProviders] = useState(false) @@ -371,8 +372,18 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b // auto-enable) has ever set it, so we only auto-enable once. const [deferBackgroundTasks, setDeferBackgroundTasks] = useState(false) const [deferExplicit, setDeferExplicit] = useState(false) + // openai-compatible only: free-text model that takes precedence over the + // fetched list (many such servers don't implement /models at all). + const [customModel, setCustomModel] = useState("") const activeConfig = providerConfigs[provider] + // Live per-key model list for the active provider — drives the primary + // model area. The per-function fields below still use the static catalog. + const providerModels = useProviderModels({ + flavor: provider, + apiKey: activeConfig.apiKey, + baseURL: activeConfig.baseURL, + }) const showApiKey = provider === "openai" || provider === "anthropic" || provider === "google" || provider === "openrouter" || provider === "aigateway" || provider === "openai-compatible" const requiresApiKey = provider === "openai" || provider === "anthropic" || provider === "google" || provider === "openrouter" || provider === "aigateway" const showBaseURL = provider === "ollama" || provider === "openai-compatible" || provider === "aigateway" @@ -399,6 +410,19 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b [] ) + // All primary-model writes go through here: the old `models: [value]` form + // silently dropped every saved model after the first. + const setPrimaryModel = useCallback((prov: LlmProviderFlavor, value: string) => { + setProviderConfigs(prev => { + const existing = prev[prov].models + return { + ...prev, + [prov]: { ...prev[prov], models: [value, ...existing.slice(1).filter(m => m && m !== value)] }, + } + }) + setTestState({ status: "idle" }) + }, []) + // Load current config from file useEffect(() => { @@ -489,7 +513,6 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b async function loadModels() { try { setModelsLoading(true) - setModelsError(null) const result = await window.ipc.invoke("models:list", null) const catalog: Record = {} for (const p of result.providers || []) { @@ -497,7 +520,6 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b } setModelsCatalog(catalog) } catch { - setModelsError("Failed to load models list") setModelsCatalog({}) } finally { setModelsLoading(false) @@ -507,24 +529,26 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b loadModels() }, [dialogOpen]) - // Set default models from catalog when catalog loads + // Preselect a model once the live per-key list arrives and nothing is + // chosen yet. Only the active provider is touched — unkeyed providers no + // longer get a model auto-seeded, so they can't look configured. useEffect(() => { - if (Object.keys(modelsCatalog).length === 0) return - setProviderConfigs(prev => { - const next = { ...prev } - const cloudProviders: LlmProviderFlavor[] = ["openai", "anthropic", "google"] - for (const prov of cloudProviders) { - const catalog = modelsCatalog[prov] - if (catalog?.length && !next[prov].models[0]) { - const preferred = preferredDefaults[prov] - const hasPreferred = preferred && catalog.some(m => m.id === preferred) - const defaultModel = hasPreferred ? preferred! : (catalog[0]?.id || "") - next[prov] = { ...next[prov], models: [defaultModel] } - } - } - return next - }) - }, [modelsCatalog]) + if (providerModels.status !== "loaded" || providerModels.models.length === 0) return + if (primaryModel) return + const preferred = preferredDefaults[provider] + const choice = preferred && providerModels.models.includes(preferred) ? preferred : providerModels.models[0] + setPrimaryModel(provider, choice) + }, [providerModels.status, providerModels.models, provider, primaryModel, setPrimaryModel]) + + // A saved openai-compatible model that isn't in the server's list belongs + // in the custom-model field, where it stays visible and editable. + useEffect(() => { + if (provider !== "openai-compatible" || providerModels.status !== "loaded") return + if (customModel) return + if (primaryModel && !providerModels.models.includes(primaryModel)) { + setCustomModel(primaryModel) + } + }, [provider, providerModels.status, providerModels.models, primaryModel, customModel]) const handleTestAndSave = useCallback(async () => { if (!canTest) return @@ -738,46 +762,117 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b )} + {/* API Key — key-first: the model list is fetched from it */} + {showApiKey && ( +
+ + {provider === "openai-compatible" ? "API Key (optional)" : "API Key"} + + updateConfig(provider, { apiKey: e.target.value })} + onBlur={() => providerModels.refetch()} + placeholder="Paste your API key" + /> +
+ )} + + {/* Base URL */} + {showBaseURL && ( +
+ Base URL + updateConfig(provider, { baseURL: e.target.value })} + onBlur={() => providerModels.refetch()} + placeholder={ + provider === "ollama" + ? "http://localhost:11434" + : provider === "openai-compatible" + ? "http://localhost:1234/v1" + : "https://ai-gateway.vercel.sh/v1" + } + /> +
+ )} + {/* Model selection - side by side */}
- {/* Assistant models (left column) */} + {/* Assistant model (left column) — live list fetched from the key */}
{rowboatConnected ? "Model" : "Assistant model"} - {modelsLoading ? ( + {providerModels.status === "idle" ? ( +
+ {isLocalProvider + ? "Enter your base URL to load available models" + : "Enter your API key to load available models"} +
+ ) : providerModels.status === "loading" ? (
- Loading... + Loading models... +
+ ) : providerModels.status === "error" ? ( +
+
+ {providerModels.error || "Failed to load models"} +
+ + setPrimaryModel(provider, e.target.value)} + placeholder="Enter model manually" + /> +
+ ) : providerModels.models.length === 0 ? ( +
+
+ The provider reported no models — enter one manually +
+ setPrimaryModel(provider, e.target.value)} + placeholder="Enter model" + />
) : (
- {showModelInput ? ( + + {provider === "openai-compatible" && ( updateConfig(provider, { models: [e.target.value] })} - placeholder="Enter model" + value={customModel} + onChange={(e) => { + setCustomModel(e.target.value) + setPrimaryModel(provider, e.target.value) + }} + placeholder="Custom model (overrides selection)" /> - ) : ( - )}
)} - {modelsError && ( -
{modelsError}
- )}
{!rowboatConnected && (<> @@ -919,39 +1014,6 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b )}
- {/* API Key */} - {showApiKey && ( -
- - {provider === "openai-compatible" ? "API Key (optional)" : "API Key"} - - updateConfig(provider, { apiKey: e.target.value })} - placeholder="Paste your API key" - /> -
- )} - - {/* Base URL */} - {showBaseURL && ( -
- Base URL - updateConfig(provider, { baseURL: e.target.value })} - placeholder={ - provider === "ollama" - ? "http://localhost:11434" - : provider === "openai-compatible" - ? "http://localhost:1234/v1" - : "https://ai-gateway.vercel.sh/v1" - } - /> -
- )} - {/* Test status */} {testState.status === "error" && (
diff --git a/apps/x/apps/renderer/src/hooks/use-provider-models.ts b/apps/x/apps/renderer/src/hooks/use-provider-models.ts new file mode 100644 index 00000000..4b737442 --- /dev/null +++ b/apps/x/apps/renderer/src/hooks/use-provider-models.ts @@ -0,0 +1,147 @@ +import { useCallback, useEffect, useRef, useState } from "react" + +// Flavors the live model-list fetch (models:listForProvider) supports. +// "rowboat" (the signed-in gateway) is deliberately absent — its catalog +// comes from models:list, and core throws on the flavor. +export type ProviderModelsFlavor = + | "openai" + | "anthropic" + | "google" + | "openrouter" + | "aigateway" + | "ollama" + | "openai-compatible" + +export type ProviderModelsStatus = "idle" | "loading" | "loaded" | "error" + +export interface UseProviderModelsResult { + /** idle = credentials are insufficient to attempt a fetch. */ + status: ProviderModelsStatus + models: string[] + error: string | null + /** Bypass the cache and fetch now (key-field blur / Retry). No-op while idle. */ + refetch: () => void +} + +const AIGATEWAY_DEFAULT_BASE_URL = "https://ai-gateway.vercel.sh/v1" +// The automatic fetch fires only once the credential inputs stop changing — +// never per keystroke, which would spray partial API keys at the provider. +const FETCH_DEBOUNCE_MS = 600 + +// Module-level so provider switches and dialog reopens don't refetch. +// Successful results only, keyed on `${flavor}|${apiKey}|${baseURL}`. +const listCache = new Map() +// De-dupes concurrent requests for the same key (debounce firing + field blur). +const inFlight = new Map>() + +function credentialsSufficient(flavor: ProviderModelsFlavor, apiKey: string, baseURL: string): boolean { + if (flavor === "ollama" || flavor === "openai-compatible") return baseURL.length > 0 + return apiKey.length > 0 +} + +function fetchProviderModels( + cacheKey: string, + provider: { flavor: ProviderModelsFlavor; apiKey?: string; baseURL?: string }, +): Promise { + const pending = inFlight.get(cacheKey) + if (pending) return pending + const request = window.ipc + .invoke("models:listForProvider", { provider }) + .then((result) => { + if (!result.success) throw new Error(result.error || "Failed to list models") + const models = result.models ?? [] + listCache.set(cacheKey, models) + return models + }) + .finally(() => { + inFlight.delete(cacheKey) + }) + inFlight.set(cacheKey, request) + return request +} + +export function useProviderModels(input: { + flavor: ProviderModelsFlavor + apiKey: string + baseURL: string +}): UseProviderModelsResult { + const { flavor } = input + const apiKey = input.apiKey.trim() + const baseURL = input.baseURL.trim() || (flavor === "aigateway" ? AIGATEWAY_DEFAULT_BASE_URL : "") + const cacheKey = `${flavor}|${apiKey}|${baseURL}` + const sufficient = credentialsSufficient(flavor, apiKey, baseURL) + + const [state, setState] = useState<{ + key: string + status: ProviderModelsStatus + models: string[] + error: string | null + }>({ key: "", status: "idle", models: [], error: null }) + // Bumped whenever the inputs change (and on unmount) so completions of + // superseded fetches never write state. + const epochRef = useRef(0) + + const startFetch = useCallback(() => { + const epoch = ++epochRef.current + setState({ key: cacheKey, status: "loading", models: [], error: null }) + fetchProviderModels(cacheKey, { + flavor, + apiKey: apiKey || undefined, + baseURL: baseURL || undefined, + }) + .then((models) => { + if (epochRef.current !== epoch) return + setState({ key: cacheKey, status: "loaded", models, error: null }) + }) + .catch((err: unknown) => { + if (epochRef.current !== epoch) return + const message = err instanceof Error ? err.message : "Failed to list models" + setState({ key: cacheKey, status: "error", models: [], error: message }) + }) + }, [cacheKey, flavor, apiKey, baseURL]) + + useEffect(() => { + epochRef.current++ + if (!sufficient) { + setState({ key: cacheKey, status: "idle", models: [], error: null }) + return + } + const cached = listCache.get(cacheKey) + if (cached) { + setState({ key: cacheKey, status: "loaded", models: cached, error: null }) + return + } + setState({ key: cacheKey, status: "loading", models: [], error: null }) + const timer = setTimeout(() => { + // A blur-triggered refetch may have already filled the cache while the + // debounce was pending — don't fetch the same key twice. + const nowCached = listCache.get(cacheKey) + if (nowCached) { + setState({ key: cacheKey, status: "loaded", models: nowCached, error: null }) + return + } + startFetch() + }, FETCH_DEBOUNCE_MS) + return () => clearTimeout(timer) + }, [cacheKey, sufficient, startFetch]) + + useEffect(() => () => { + epochRef.current++ + }, []) + + const refetch = useCallback(() => { + if (!sufficient) return + listCache.delete(cacheKey) + startFetch() + }, [sufficient, cacheKey, startFetch]) + + // State lags the inputs by one render (the effect above reconciles), so + // derive the answer for the *current* inputs — a provider switch must never + // flash the previous provider's list. + if (state.key !== cacheKey) { + const cached = sufficient ? listCache.get(cacheKey) : undefined + if (cached) return { status: "loaded", models: cached, error: null, refetch } + return { status: sufficient ? "loading" : "idle", models: [], error: null, refetch } + } + return { status: state.status, models: state.models, error: state.error, refetch } +}