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.
This commit is contained in:
Prakhar Pandey 2026-07-12 13:36:16 +05:30
parent 2e45bc0213
commit 75805e2d67
2 changed files with 289 additions and 80 deletions

View file

@ -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<Record<LlmProviderFlavor, string>> = {
const defaultBaseURLs: Partial<Record<LlmProviderFlavor, string>> = {
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<Record<string, LlmModelOption[]>>({})
const [modelsLoading, setModelsLoading] = useState(false)
const [modelsError, setModelsError] = useState<string | null>(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<string, LlmModelOption[]> = {}
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
)}
</div>
{/* API Key — key-first: the model list is fetched from it */}
{showApiKey && (
<div className="space-y-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
{provider === "openai-compatible" ? "API Key (optional)" : "API Key"}
</span>
<Input
type="password"
value={activeConfig.apiKey}
onChange={(e) => updateConfig(provider, { apiKey: e.target.value })}
onBlur={() => providerModels.refetch()}
placeholder="Paste your API key"
/>
</div>
)}
{/* Base URL */}
{showBaseURL && (
<div className="space-y-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Base URL</span>
<Input
value={activeConfig.baseURL}
onChange={(e) => 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"
}
/>
</div>
)}
{/* Model selection - side by side */}
<div className="grid grid-cols-2 gap-3">
{/* Assistant models (left column) */}
{/* Assistant model (left column) — live list fetched from the key */}
<div className="space-y-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">{rowboatConnected ? "Model" : "Assistant model"}</span>
{modelsLoading ? (
{providerModels.status === "idle" ? (
<div className="text-sm text-muted-foreground">
{isLocalProvider
? "Enter your base URL to load available models"
: "Enter your API key to load available models"}
</div>
) : providerModels.status === "loading" ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading...
Loading models...
</div>
) : providerModels.status === "error" ? (
<div className="space-y-2">
<div className="text-xs text-destructive break-words">
{providerModels.error || "Failed to load models"}
</div>
<Button variant="outline" size="sm" onClick={() => providerModels.refetch()}>
Retry
</Button>
<Input
value={primaryModel}
onChange={(e) => setPrimaryModel(provider, e.target.value)}
placeholder="Enter model manually"
/>
</div>
) : providerModels.models.length === 0 ? (
<div className="space-y-2">
<div className="text-xs text-muted-foreground">
The provider reported no models enter one manually
</div>
<Input
value={primaryModel}
onChange={(e) => setPrimaryModel(provider, e.target.value)}
placeholder="Enter model"
/>
</div>
) : (
<div className="space-y-2">
{showModelInput ? (
<Select
value={provider === "openai-compatible" && !providerModels.models.includes(primaryModel) ? "" : primaryModel}
onValueChange={(value) => {
setPrimaryModel(provider, value)
if (provider === "openai-compatible") setCustomModel("")
}}
>
<SelectTrigger>
<SelectValue placeholder="Select a model" />
</SelectTrigger>
<SelectContent>
{provider !== "openai-compatible" && primaryModel && !providerModels.models.includes(primaryModel) && (
<SelectItem value={primaryModel}>{primaryModel} (saved)</SelectItem>
)}
{providerModels.models.map((id) => (
<SelectItem key={id} value={id}>
{id}
</SelectItem>
))}
</SelectContent>
</Select>
{provider === "openai-compatible" && (
<Input
value={primaryModel}
onChange={(e) => 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)"
/>
) : (
<Select
value={primaryModel}
onValueChange={(value) => updateConfig(provider, { models: [value] })}
>
<SelectTrigger>
<SelectValue placeholder="Select a model" />
</SelectTrigger>
<SelectContent>
{modelsForProvider.map((m) => (
<SelectItem key={m.id} value={m.id}>
{m.name || m.id}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
)}
{modelsError && (
<div className="text-xs text-destructive">{modelsError}</div>
)}
</div>
{!rowboatConnected && (<>
@ -919,39 +1014,6 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
</>)}
</div>
{/* API Key */}
{showApiKey && (
<div className="space-y-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
{provider === "openai-compatible" ? "API Key (optional)" : "API Key"}
</span>
<Input
type="password"
value={activeConfig.apiKey}
onChange={(e) => updateConfig(provider, { apiKey: e.target.value })}
placeholder="Paste your API key"
/>
</div>
)}
{/* Base URL */}
{showBaseURL && (
<div className="space-y-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Base URL</span>
<Input
value={activeConfig.baseURL}
onChange={(e) => 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"
}
/>
</div>
)}
{/* Test status */}
{testState.status === "error" && (
<div className="text-sm text-destructive">

View file

@ -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<string, string[]>()
// De-dupes concurrent requests for the same key (debounce firing + field blur).
const inFlight = new Map<string, Promise<string[]>>()
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<string[]> {
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 }
}