Merge pull request #738 from rowboatlabs/feat/settings-models-picker

feat: connect-only model settings with dynamic composer model picker
This commit is contained in:
PRAKHAR PANDEY 2026-07-14 13:27:46 +05:30 committed by GitHub
commit 1e8e36ebfb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 763 additions and 193 deletions

View file

@ -1,4 +1,4 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import {
ArrowUp,
@ -38,6 +38,7 @@ import {
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSub,
@ -45,6 +46,7 @@ import {
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { useProviderModels, type ProviderModelsFlavor } from '@/hooks/use-provider-models'
import {
type AttachmentIconKind,
getAttachmentDisplayName,
@ -101,6 +103,84 @@ interface ConfiguredModel {
model: string
}
// One picker group per connected provider. Catalog groups carry a resolved
// model list (models:list / saved config); live groups carry credentials and
// fetch their list from the provider inside the dropdown via
// useProviderModels (models:listForProvider).
const LIVE_PICKER_FLAVORS = new Set<string>(['openrouter', 'aigateway', 'ollama', 'openai-compatible'])
// Catalog-preferred flavors that degrade to a live fetch when models:list has
// no catalog for them (signed-in mode returns only the rowboat provider, or
// the models.dev cache is empty).
const LIVE_FALLBACK_FLAVORS = new Set<string>(['openai', 'anthropic', 'google'])
type ModelPickerGroup =
| { kind: 'catalog'; flavor: string; models: string[] }
| { kind: 'live'; flavor: ProviderModelsFlavor; apiKey: string; baseURL: string; savedModel: string }
// Rendered inside the dropdown's radio group: each live provider fetches its
// own list, so groups load and fail independently. Pinned models (the saved
// default / app default) render first — the model that actually runs is
// always pickable even while the fetch is pending or failed. Live-fetched
// ids carry no reasoning metadata, so the effort control stays hidden for
// them (reasoningByKey lookup misses default to off).
//
// The group owns its header so it can hide itself when the search filter
// matches none of its rows. Loading/error rows are status, not models — they
// render (with the header) regardless of the filter, and don't count toward
// the parent's "No models match" check (which is what gets reported up).
function LiveProviderGroupItems({ group, label, pinnedModels, filter, onModelRowsChange }: {
group: Extract<ModelPickerGroup, { kind: 'live' }>
label: string
pinnedModels: string[]
filter: string
onModelRowsChange: (flavor: string, hasModelRows: boolean) => void
}) {
const { status, models, error, refetch } = useProviderModels({
flavor: group.flavor,
apiKey: group.apiKey,
baseURL: group.baseURL,
})
const items = [...pinnedModels, ...models.filter((m) => !pinnedModels.includes(m))]
const visible = filter ? items.filter((m) => m.toLowerCase().includes(filter)) : items
const showStatus = status === 'loading' || status === 'error'
const hasModelRows = visible.length > 0
useEffect(() => {
onModelRowsChange(group.flavor, hasModelRows)
}, [group.flavor, hasModelRows, onModelRowsChange])
if (!hasModelRows && !showStatus) return null
return (
<>
<DropdownMenuLabel className="text-xs text-muted-foreground">{label}</DropdownMenuLabel>
{visible.map((m) => {
const key = `${group.flavor}/${m}`
return (
<DropdownMenuRadioItem key={key} value={key}>
<span className="truncate">{m}</span>
</DropdownMenuRadioItem>
)
})}
{status === 'loading' && (
<div className="flex items-center gap-2 px-2 py-1.5 text-xs text-muted-foreground">
<LoaderIcon className="h-3 w-3 animate-spin" />
Loading models
</div>
)}
{status === 'error' && (
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault()
refetch()
}}
className="text-xs"
>
<span className="truncate text-destructive">{error || 'Failed to load models'}</span>
<span className="ml-auto shrink-0 text-muted-foreground">Retry</span>
</DropdownMenuItem>
)}
</>
)
}
type RecentWorkDir = {
path: string
lastUsedAt: number
@ -318,7 +398,7 @@ function ChatInputInner({
const fileInputRef = useRef<HTMLInputElement>(null)
const canSubmit = (Boolean(message.trim()) || attachments.length > 0) && !isProcessing
const [configuredModels, setConfiguredModels] = useState<ConfiguredModel[]>([])
const [modelGroups, setModelGroups] = useState<ModelPickerGroup[]>([])
const [activeModelKey, setActiveModelKey] = useState('')
// The effective runtime default (what a run actually uses when the user
// hasn't picked a model) — shown in the picker instead of guessing from
@ -438,19 +518,9 @@ function ChatInputInner({
if (loadModelConfigEpoch.current === epoch) setDefaultModel(null)
}
try {
const models: ConfiguredModel[] = []
const seen = new Set<string>()
const push = (provider: string, model: string) => {
if (!model) return
const key = `${provider}/${model}`
if (seen.has(key)) return
seen.add(key)
models.push({ provider: provider as ProviderName, model })
}
const groups: ModelPickerGroup[] = []
// Full catalog per provider (gateway + cloud). Providers with no
// catalog (Ollama, OpenAI-compatible) fall back to the models saved in
// config below.
// Full catalog per provider (gateway + models.dev cloud providers).
const catalog: Record<string, string[]> = {}
const reasoningFlags: Record<string, boolean> = {}
try {
@ -463,55 +533,78 @@ function ChatInputInner({
}
}
}
} catch { /* offline / no catalog — fall back to saved config below */ }
} catch { /* offline / no catalog — groups fall back to saved config below */ }
if (loadModelConfigEpoch.current === epoch) setReasoningByKey(reasoningFlags)
if (isRowboatConnected) {
for (const m of catalog['rowboat'] || []) push('rowboat', m)
if (isRowboatConnected && (catalog['rowboat'] || []).length > 0) {
groups.push({ kind: 'catalog', flavor: 'rowboat', models: catalog['rowboat'] })
}
try {
const result = await window.ipc.invoke('workspace:readFile', { path: 'config/models.json' })
const parsed = JSON.parse(result.data)
// List the default provider first so its default model leads the
// BYOK section of the picker.
// List the default provider's group first.
const defaultFlavor = typeof parsed?.provider?.flavor === 'string' ? parsed.provider.flavor : ''
const flavors = Object.keys(parsed?.providers || {})
.sort((a, b) => (a === defaultFlavor ? -1 : b === defaultFlavor ? 1 : 0))
for (const flavor of flavors) {
const e = (parsed.providers[flavor] || {}) as Record<string, unknown>
const hasKey = typeof e.apiKey === 'string' && (e.apiKey as string).trim().length > 0
const hasBaseURL = typeof e.baseURL === 'string' && (e.baseURL as string).trim().length > 0
if (!hasKey && !hasBaseURL) continue // provider not configured
const apiKey = typeof e.apiKey === 'string' ? e.apiKey.trim() : ''
const baseURL = typeof e.baseURL === 'string' ? e.baseURL.trim() : ''
if (!apiKey && !baseURL) continue // provider not configured
const savedModel = typeof e.model === 'string' ? e.model : ''
// The provider's saved default model leads, then the rest of its catalog.
push(flavor, typeof e.model === 'string' ? e.model : '')
// Live flavors fetch their list from the provider inside the
// dropdown, with the credentials saved in config. Catalog flavors
// degrade to the same live fetch when models:list carried no
// catalog for them (signed in, or empty models.dev cache).
const catalogModels = catalog[flavor] || []
if (catalogModels.length > 0) {
for (const m of catalogModels) push(flavor, m)
} else {
// No catalog (local provider) — fall back to whatever is saved.
const saved = Array.isArray(e.models) ? e.models as string[] : []
for (const m of saved) push(flavor, m)
if (LIVE_PICKER_FLAVORS.has(flavor) || (catalogModels.length === 0 && LIVE_FALLBACK_FLAVORS.has(flavor))) {
groups.push({ kind: 'live', flavor: flavor as ProviderModelsFlavor, apiKey, baseURL, savedModel })
continue
}
// Catalog group: the saved default model leads, then the catalog.
// Saved models[] survives as the fallback for unknown flavors the
// live fetch doesn't support.
const models: string[] = []
const push = (model: string) => {
if (model && !models.includes(model)) models.push(model)
}
push(savedModel)
if (catalogModels.length > 0) {
for (const m of catalogModels) push(m)
} else {
const saved = Array.isArray(e.models) ? e.models as string[] : []
for (const m of saved) push(m)
}
groups.push({ kind: 'catalog', flavor, models })
}
// The user's explicit default selection leads the whole picker.
// The user's explicit default selection leads the picker: its group
// first and, within a catalog group, the model itself first. (Live
// groups pin the default at the top themselves.)
const sel = parsed?.defaultSelection
if (sel && typeof sel.provider === 'string' && typeof sel.model === 'string') {
const selKey = `${sel.provider}/${sel.model}`
const index = models.findIndex((m) => `${m.provider}/${m.model}` === selKey)
if (index > 0) {
const [entry] = models.splice(index, 1)
models.unshift(entry)
const index = groups.findIndex((g) => g.flavor === sel.provider)
if (index >= 0) {
const [group] = groups.splice(index, 1)
groups.unshift(group)
if (group.kind === 'catalog') {
const mi = group.models.indexOf(sel.model)
if (mi > 0) {
group.models.splice(mi, 1)
group.models.unshift(sel.model)
}
}
}
}
} catch { /* no BYOK config yet */ }
if (loadModelConfigEpoch.current !== epoch) return
setConfiguredModels(models)
setModelGroups(groups)
} catch (err) {
// No config yet — but surface unexpected failures for diagnosis.
console.error('[chat-input] failed to load model list', err)
@ -701,25 +794,58 @@ function ChatInputInner({
checkSearch()
}, [isActive, isRowboatConnected])
// The dropdown's items: always include the effective default so the picker
// is never empty (and never missing the model that actually runs) even
// while the full list is still loading.
const pickerModels = useMemo<ConfiguredModel[]>(() => {
if (!defaultModel) return configuredModels
const defaultKey = `${defaultModel.provider}/${defaultModel.model}`
if (configuredModels.some((m) => `${m.provider}/${m.model}` === defaultKey)) return configuredModels
return [defaultModel, ...configuredModels]
}, [configuredModels, defaultModel])
// Search filter for the model dropdown. Reset each time the menu opens;
// matching is a case-insensitive substring test on the model id. Live
// groups filter themselves and report whether they still have rows, so the
// parent can render the global "No models match" row.
const [modelFilter, setModelFilter] = useState('')
const modelFilterInputRef = useRef<HTMLInputElement>(null)
const [liveGroupHasRows, setLiveGroupHasRows] = useState<Record<string, boolean>>({})
const modelFilterValue = modelFilter.trim().toLowerCase()
const handleLiveGroupRows = useCallback((flavor: string, hasRows: boolean) => {
setLiveGroupHasRows((prev) => (prev[flavor] === hasRows ? prev : { ...prev, [flavor]: hasRows }))
}, [])
// Selecting a model affects only the *next* run created from this tab.
// Once a run exists, model is frozen on the run and the dropdown is read-only.
// The effective default always renders even when no group carries it (the
// gateway list failed, or its provider was removed from config) — the
// picker must never be missing the model that actually runs. Live groups
// pin the default themselves, so a flavor match is enough there.
const standaloneDefault = useMemo<ConfiguredModel | null>(() => {
if (!defaultModel) return null
const covered = modelGroups.some((g) =>
g.flavor === defaultModel.provider &&
(g.kind === 'live' || g.models.includes(defaultModel.model)))
return covered ? null : defaultModel
}, [modelGroups, defaultModel])
const standaloneVisible = standaloneDefault !== null &&
(!modelFilterValue || standaloneDefault.model.toLowerCase().includes(modelFilterValue))
// Nothing matches anywhere → "No models match". Live groups that haven't
// reported yet (first render after opening) count as having rows so the
// empty row never flashes.
const anyModelRowVisible = standaloneVisible || modelGroups.some((g) =>
g.kind === 'catalog'
? g.models.some((m) => m.toLowerCase().includes(modelFilterValue))
: liveGroupHasRows[g.flavor] !== false)
// Selecting a model affects the *next* run created from this tab (frozen
// once a run exists) AND persists as the app default so background agents
// and new tabs follow the last pick. The models-config-changed dispatch
// re-runs loadModelConfig here too — that re-read lands on the same
// selection (activeModelKey is untouched, the live lists come from the
// hook's cache), so it's visually a no-op.
const handleModelChange = useCallback((key: string) => {
if (lockedModel) return
const entry = pickerModels.find((m) => `${m.provider}/${m.model}` === key)
if (!entry) return
const slash = key.indexOf('/')
if (slash <= 0 || slash === key.length - 1) return
const provider = key.slice(0, slash)
const model = key.slice(slash + 1)
setActiveModelKey(key)
onSelectedModelChange?.({ provider: entry.provider, model: entry.model })
}, [pickerModels, lockedModel, onSelectedModelChange])
onSelectedModelChange?.({ provider, model })
void window.ipc.invoke('models:updateConfig', { defaultSelection: { provider, model } })
.then(() => { window.dispatchEvent(new Event('models-config-changed')) })
.catch(() => { toast.error('Failed to save default model') })
}, [lockedModel, onSelectedModelChange])
// Reasoning effort applies to the model the next message will actually use:
// the run's frozen model once one exists, else the picker selection, else
@ -1378,8 +1504,19 @@ function ChatInputInner({
{providerDisplayNames[lockedModel.provider] || lockedModel.provider} fixed for this chat
</TooltipContent>
</Tooltip>
) : pickerModels.length > 0 ? (
<DropdownMenu>
) : (
<DropdownMenu
onOpenChange={(open) => {
// The filter is per-opening, never sticky. Focus the search
// input once the content has mounted and Radix has run its own
// open-focus (DropdownMenu.Content has no onOpenAutoFocus).
if (open) {
setModelFilter('')
setLiveGroupHasRows({})
setTimeout(() => modelFilterInputRef.current?.focus(), 0)
}
}}
>
<DropdownMenuTrigger asChild>
<button
type="button"
@ -1387,33 +1524,103 @@ function ChatInputInner({
>
<span className="min-w-0 truncate">
{getSelectedModelDisplayName(
pickerModels.find((m) => `${m.provider}/${m.model}` === activeModelKey)?.model
(activeModelKey ? activeModelKey.slice(activeModelKey.indexOf('/') + 1) : '')
|| defaultModel?.model
|| pickerModels[0]?.model
|| 'Model'
)}
</span>
<ChevronDown className="h-3 w-3 shrink-0" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuRadioGroup
value={activeModelKey || (defaultModel ? `${defaultModel.provider}/${defaultModel.model}` : '')}
onValueChange={handleModelChange}
>
{pickerModels.map((m) => {
const key = `${m.provider}/${m.model}`
return (
<DropdownMenuRadioItem key={key} value={key}>
<span className="truncate">{m.model}</span>
<span className="ml-2 text-xs text-muted-foreground">{providerDisplayNames[m.provider] || m.provider}</span>
</DropdownMenuRadioItem>
)
})}
</DropdownMenuRadioGroup>
<DropdownMenuContent align="end" className="p-0 overflow-hidden">
{modelGroups.length === 0 && !standaloneDefault ? (
<div className="p-1">
<DropdownMenuItem disabled>Connect a provider in Settings</DropdownMenuItem>
</div>
) : (
<>
{/* Fixed search header lives OUTSIDE the scroll area (the
inner div below scrolls), so it's flush at the very top
and always visible without any scroll. */}
<div className="bg-popover p-1">
<input
ref={modelFilterInputRef}
value={modelFilter}
onChange={(e) => setModelFilter(e.target.value)}
onKeyDown={(e) => {
// Printable keys belong to the input, not the menu's
// typeahead; arrows and Escape stay with the menu.
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp' && e.key !== 'Escape') {
e.stopPropagation()
}
}}
placeholder="Search models…"
className="h-7 w-full rounded-sm border border-input bg-transparent px-2 text-xs outline-none placeholder:text-muted-foreground"
/>
</div>
<div className="max-h-80 overflow-y-auto p-1 pt-0">
<DropdownMenuRadioGroup
value={activeModelKey || (defaultModel ? `${defaultModel.provider}/${defaultModel.model}` : '')}
onValueChange={handleModelChange}
>
{standaloneDefault && standaloneVisible && (
<DropdownMenuRadioItem value={`${standaloneDefault.provider}/${standaloneDefault.model}`}>
<span className="truncate">{standaloneDefault.model}</span>
<span className="ml-2 text-xs text-muted-foreground">
{providerDisplayNames[standaloneDefault.provider] || standaloneDefault.provider}
</span>
</DropdownMenuRadioItem>
)}
{modelGroups.map((g) => {
const label = providerDisplayNames[g.flavor] || g.flavor
if (g.kind === 'live') {
// The app default leads its live group; the group's
// own saved model follows (both stay pickable through
// fetch loading/failure).
const pinned: string[] = []
if (defaultModel && defaultModel.provider === g.flavor) pinned.push(defaultModel.model)
if (g.savedModel && !pinned.includes(g.savedModel)) pinned.push(g.savedModel)
return (
<LiveProviderGroupItems
key={g.flavor}
group={g}
label={label}
pinnedModels={pinned}
filter={modelFilterValue}
onModelRowsChange={handleLiveGroupRows}
/>
)
}
const visibleModels = modelFilterValue
? g.models.filter((m) => m.toLowerCase().includes(modelFilterValue))
: g.models
if (visibleModels.length === 0) return null
return (
<Fragment key={g.flavor}>
<DropdownMenuLabel className="text-xs text-muted-foreground">
{label}
</DropdownMenuLabel>
{visibleModels.map((m) => {
const key = `${g.flavor}/${m}`
return (
<DropdownMenuRadioItem key={key} value={key}>
<span className="truncate">{m}</span>
</DropdownMenuRadioItem>
)
})}
</Fragment>
)
})}
{modelFilterValue && !anyModelRowVisible && (
<div className="px-2 py-1.5 text-xs text-muted-foreground">No models match</div>
)}
</DropdownMenuRadioGroup>
</div>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
) : null}
)}
{onStartCall && (
<div className="flex shrink-0 items-center">
<Tooltip delayDuration={CHAT_INPUT_TOOLTIP_DELAY_MS}>

View file

@ -42,14 +42,11 @@ export function LlmSetupStep({ state }: LlmSetupStepProps) {
} = state
const isMoreProvider = moreProviders.some(p => p.id === llmProvider)
// Hosted providers (openai/anthropic/google) get a default model, so we only
// ask for a model on providers that truly need one (local/custom/gateway),
// or as a fallback if no model is set yet.
// Hosted providers (openai/anthropic/google) fetch their models from the API
// key on test, so they never need a manual model field. Only local/custom/
// gateway providers, where the user must specify a model, show the input.
const hostedProviders: LlmProviderFlavor[] = ["openai", "anthropic", "google"]
const showModelInput = !hostedProviders.includes(llmProvider)
// Connect-only, mirroring Settings: entering a key (or base URL) is enough
// and the model is resolved silently at save. openai-compatible is the sole
// exception with a visible Model field — its /models endpoint often doesn't
// exist, so a typed value must be able to win.
const showModelInput = llmProvider === "openai-compatible"
const renderProviderCard = (provider: typeof primaryProviders[0], index: number) => {
const isSelected = llmProvider === provider.id
@ -150,9 +147,9 @@ export function LlmSetupStep({ state }: LlmSetupStepProps) {
{/* Provider configuration */}
<div className="space-y-4">
{/* Cloud providers get a default model auto-selected; only local/custom
providers (no catalog) need a model here. Users can pick any of the
provider's models later in the chat view. */}
{/* Every provider resolves its model silently at save. openai-compatible
alone keeps this field, since its /models is unreliable and a typed
value must win; leaving it blank auto-selects from the fetched list. */}
{showModelInput && (
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground">
@ -167,7 +164,7 @@ export function LlmSetupStep({ state }: LlmSetupStepProps) {
<Input
value={activeConfig.model}
onChange={(e) => updateProviderConfig(llmProvider, { model: e.target.value })}
placeholder="Enter model"
placeholder="Model ID (leave empty to auto-select)"
/>
)}
{modelsError && (

View file

@ -457,13 +457,24 @@ export function useOnboardingState(open: boolean, onComplete: (opts?: { startTou
return false
}
const preferred = preferredDefaults[llmProvider]
// A model the user explicitly entered always wins — this used to prefer
// catalog[0], which silently replaced the user's Ollama model with
// whatever model the local server happened to list first.
const model = modelInputShown
? (typed || catalog[0] || "")
: ((preferred && catalog.includes(preferred) && preferred) || catalog[0] || typed || "")
// Resolve the model silently — same precedence as the settings dialog's
// resolvedModel, so onboarding no longer asks users to type one.
// openai-compatible keeps a visible Model field (its /models is
// unreliable), so a typed value there wins; otherwise the typed/saved
// model if the fetched list still has it, else the flavor's preferred
// default, else the first fetched id. An empty list falls back to
// whatever was typed/seeded.
let model: string
if (llmProvider === "openai-compatible" && typed) {
model = typed
} else if (catalog.length > 0) {
const preferred = preferredDefaults[llmProvider]
model = (typed && catalog.includes(typed))
? typed
: ((preferred && catalog.includes(preferred)) ? preferred : catalog[0])
} else {
model = typed
}
// `models` is the user's curated assistant-model list (shown in Settings),
// NOT the full provider catalog. Onboarding seeds it with just the selected

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 = {
@ -351,18 +353,20 @@ type ProviderModelConfig = {
function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: boolean; rowboatConnected?: boolean }) {
const [provider, setProvider] = useState<LlmProviderFlavor>("openai")
const [defaultProvider, setDefaultProvider] = useState<LlmProviderFlavor | null>(null)
// Flavors present in the saved providers map — drives each card's
// "Connected" indicator, independent of which card is active.
const [savedProviders, setSavedProviders] = useState<Set<LlmProviderFlavor>>(new Set())
const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, ProviderModelConfig>>({
openai: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
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 +375,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"
@ -383,8 +397,28 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
const isMoreProvider = moreProviders.some(p => p.id === provider)
const primaryModel = activeConfig.models[0] || ""
// Settings no longer exposes model selection — the model is resolved
// silently when the user connects (the config schema still requires one;
// background agents/channels read it). Precedence: a typed escape-hatch
// value (openai-compatible Model field, offline manual input) > the saved
// model when the fetched list still has it > the flavor's preferred
// default > the first fetched id.
const resolvedModel = (() => {
if (provider === "openai-compatible" && customModel.trim()) return customModel.trim()
const saved = primaryModel.trim()
if (providerModels.status === "loaded" && providerModels.models.length > 0) {
if (saved && providerModels.models.includes(saved)) return saved
const preferred = preferredDefaults[provider]
if (preferred && providerModels.models.includes(preferred)) return preferred
return providerModels.models[0]
}
return saved
})()
// Gate Connect on credentials only, NOT on resolvedModel — that derives
// from the live fetch, which hasn't settled the instant a key is pasted, so
// gating on it made the first click a no-op (the model is resolved, fetching
// on demand if needed, inside handleTestAndSave).
const canTest =
primaryModel.trim().length > 0 &&
(!requiresApiKey || activeConfig.apiKey.trim().length > 0) &&
(!requiresBaseURL || activeConfig.baseURL.trim().length > 0)
@ -399,6 +433,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(() => {
@ -415,6 +462,10 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
const parsed = JSON.parse(result.data)
setDeferBackgroundTasks(parsed?.deferBackgroundTasks === true)
setDeferExplicit(typeof parsed?.deferBackgroundTasks === "boolean")
const knownFlavors = new Set<string>([...primaryProviders, ...moreProviders].map(p => p.id))
setSavedProviders(new Set(
Object.keys(parsed?.providers ?? {}).filter((k): k is LlmProviderFlavor => knownFlavors.has(k))
))
if (parsed?.provider?.flavor && parsed?.model) {
const flavor = parsed.provider.flavor as LlmProviderFlavor
setProvider(flavor)
@ -489,7 +540,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 +547,6 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
}
setModelsCatalog(catalog)
} catch {
setModelsError("Failed to load models list")
setModelsCatalog({})
} finally {
setModelsLoading(false)
@ -507,30 +556,49 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
loadModels()
}, [dialogOpen])
// Set default models from catalog when catalog loads
// A saved openai-compatible model that the server's list doesn't confirm
// (not listed, or /models unreachable) belongs in the visible Model field,
// where it stays editable and wins over the silent pick.
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 (provider !== "openai-compatible" || customModel || !primaryModel) return
if (providerModels.status === "error" || (providerModels.status === "loaded" && !providerModels.models.includes(primaryModel))) {
setCustomModel(primaryModel)
}
}, [provider, providerModels.status, providerModels.models, primaryModel, customModel])
const handleTestAndSave = useCallback(async () => {
if (!canTest) return
setTestState({ status: "testing" })
try {
const allModels = activeConfig.models.map(m => m.trim()).filter(Boolean)
// Normally providerModels has loaded by click time and resolvedModel is
// set. But a Connect click right after pasting a key can beat the
// debounced key-change fetch — so when nothing is resolved yet, fetch
// the list on demand (one fetch, then save) instead of forcing a second
// click. Same silent precedence as resolvedModel, minus the customModel
// branch (that path only runs when resolvedModel is already empty).
let model = resolvedModel
if (!model) {
const listRes = await window.ipc.invoke("models:listForProvider", {
provider: {
flavor: provider,
apiKey: activeConfig.apiKey.trim() || undefined,
baseURL: activeConfig.baseURL.trim() || undefined,
},
})
if (listRes.success && listRes.models && listRes.models.length > 0) {
const preferred = preferredDefaults[provider]
model = preferred && listRes.models.includes(preferred) ? preferred : listRes.models[0]
}
}
if (!model) {
setTestState({ status: "error", error: "Enter a model to connect" })
toast.error("Enter a model to connect")
return
}
// The silently resolved model takes the primary slot; the rest of the
// saved list is preserved (same semantics setPrimaryModel had).
const existing = activeConfig.models.map(m => m.trim())
const allModels = [model, ...existing.slice(1).filter(m => m && m !== model)]
const providerConfig = {
provider: {
flavor: provider,
@ -550,6 +618,11 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
if (result.success) {
await window.ipc.invoke("models:saveConfig", providerConfig)
setDefaultProvider(provider)
setProviderConfigs(prev => ({
...prev,
[provider]: { ...prev[provider], models: allModels },
}))
setSavedProviders(prev => new Set(prev).add(provider))
setTestState({ status: "success" })
window.dispatchEvent(new Event('models-config-changed'))
// Local models compete with background agents for the same hardware:
@ -577,7 +650,7 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
setTestState({ status: "error", error: "Connection test failed" })
toast.error("Connection test failed")
}
}, [canTest, provider, activeConfig, rowboatConnected, deferExplicit, deferBackgroundTasks, handleDeferToggle])
}, [canTest, resolvedModel, provider, activeConfig, rowboatConnected, deferExplicit, deferBackgroundTasks, handleDeferToggle])
const handleSetDefault = useCallback(async (prov: LlmProviderFlavor) => {
const config = providerConfigs[prov]
@ -608,12 +681,64 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
}, [providerConfigs, rowboatConnected])
const handleDeleteProvider = useCallback(async (prov: LlmProviderFlavor) => {
const isDefaultProv = defaultProvider === prov
try {
const result = await window.ipc.invoke("workspace:readFile", { path: "config/models.json" })
const parsed = JSON.parse(result.data)
let parsed = JSON.parse(result.data)
// Disconnecting the default provider: silently promote another
// connected provider first — the connect-only UI has no usable
// set-as-default step to send the user to. Prefer the provider the
// user's explicit defaultSelection points at; promotion goes through
// the same models:saveConfig path Set-as-default uses, so the repo
// writes a valid top-level provider/model.
let promoted: LlmProviderFlavor | null = null
if (isDefaultProv) {
const selProvider = parsed?.defaultSelection?.provider
const candidates = Object.keys(parsed?.providers ?? {})
.filter((k): k is LlmProviderFlavor => k !== prov && k in providerConfigs)
.sort((a, b) => (a === selProvider ? -1 : b === selProvider ? 1 : 0))
for (const candidate of candidates) {
const config = providerConfigs[candidate]
const allModels = config.models.map(m => m.trim()).filter(Boolean)
// Same silent precedence as connect, minus the live list we don't
// have here: the provider's saved model, else its preferred default.
const model = allModels[0] || preferredDefaults[candidate] || ""
if (!model) continue
await window.ipc.invoke("models:saveConfig", {
provider: {
flavor: candidate,
apiKey: config.apiKey.trim() || undefined,
baseURL: config.baseURL.trim() || undefined,
},
model,
models: allModels.length > 0 ? allModels : [model],
...(rowboatConnected ? {} : {
knowledgeGraphModel: config.knowledgeGraphModel.trim() || undefined,
meetingNotesModel: config.meetingNotesModel.trim() || undefined,
liveNoteAgentModel: config.liveNoteAgentModel.trim() || undefined,
autoPermissionDecisionModel: config.autoPermissionDecisionModel.trim() || undefined,
}),
})
promoted = candidate
break
}
if (promoted) {
// saveConfig rewrote top-level and the providers map — re-read so
// the deletion write below doesn't clobber the promotion.
const fresh = await window.ipc.invoke("workspace:readFile", { path: "config/models.json" })
parsed = JSON.parse(fresh.data)
}
}
if (parsed?.providers?.[prov]) {
delete parsed.providers[prov]
}
// A defaultSelection pointing at the removed provider is dangling —
// drop it so llm:getDefaultModel falls back cleanly.
if (parsed?.defaultSelection?.provider === prov) {
delete parsed.defaultSelection
}
// If the deleted provider is the current top-level active one,
// switch top-level config to the current default provider
if (parsed?.provider?.flavor === prov && defaultProvider && defaultProvider !== prov) {
@ -632,6 +757,24 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
parsed.liveNoteAgentModel = defConfig.liveNoteAgentModel.trim() || undefined
parsed.autoPermissionDecisionModel = defConfig.autoPermissionDecisionModel.trim() || undefined
}
} else if (parsed?.provider?.flavor === prov) {
// Removing the last connected provider: drop the top-level pair
// entirely. The schema requires it, so core's readConfig() treats
// the file as "no config" — signed-in falls back to the curated
// gateway default and signed-out llm:getDefaultModel rejects, which
// the composer already handles (it shows the connect hint).
delete parsed.provider
delete parsed.model
delete parsed.models
delete parsed.knowledgeGraphModel
delete parsed.meetingNotesModel
delete parsed.liveNoteAgentModel
delete parsed.autoPermissionDecisionModel
// With no BYOK providers left, any non-gateway selection is dangling.
if (parsed?.defaultSelection && parsed.defaultSelection.provider !== "rowboat"
&& Object.keys(parsed?.providers ?? {}).length === 0) {
delete parsed.defaultSelection
}
}
await window.ipc.invoke("workspace:writeFile", {
path: "config/models.json",
@ -641,9 +784,20 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
...prev,
[prov]: { apiKey: "", baseURL: defaultBaseURLs[prov] || "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
}))
setSavedProviders(prev => {
const next = new Set(prev)
next.delete(prov)
return next
})
if (isDefaultProv) setDefaultProvider(promoted)
setTestState({ status: "idle" })
window.dispatchEvent(new Event('models-config-changed'))
toast.success("Provider configuration removed")
if (promoted) {
const promotedName = [...primaryProviders, ...moreProviders].find(p => p.id === promoted)?.name || promoted
toast.success(`Disconnected · ${promotedName} is now the default`)
} else {
toast.success("Provider configuration removed")
}
} catch {
toast.error("Failed to remove provider")
}
@ -652,6 +806,7 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
const renderProviderCard = (p: { id: LlmProviderFlavor; name: string; description: string; icon: React.ElementType }) => {
const isDefault = defaultProvider === p.id
const isSelected = provider === p.id
const isConnected = savedProviders.has(p.id)
const hasModel = providerConfigs[p.id].models[0]?.trim().length > 0
return (
<button
@ -675,6 +830,11 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
Default
</span>
)}
{isConnected && (
<span className="rounded-full bg-green-500/10 px-1.5 py-0.5 text-[10px] font-medium leading-none text-green-600">
Connected
</span>
)}
</div>
<div className="text-xs text-muted-foreground mt-0.5">{p.description}</div>
{!isDefault && hasModel && isSelected && (
@ -738,48 +898,117 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
)}
</div>
{/* Model selection - side by side */}
<div className="grid grid-cols-2 gap-3">
{/* Assistant models (left column) */}
{/* 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">{rowboatConnected ? "Model" : "Assistant model"}</span>
{modelsLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading...
</div>
) : (
<div className="space-y-2">
{showModelInput ? (
<Input
value={primaryModel}
onChange={(e) => updateConfig(provider, { models: [e.target.value] })}
placeholder="Enter model"
/>
) : (
<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>
)}
<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>
)}
{/* Connection status — the model itself is resolved silently on save */}
<div className="space-y-2">
{providerModels.status === "idle" ? (
<div className="text-sm text-muted-foreground">
{isLocalProvider
? "Enter your base URL to connect"
: "Enter your API key to connect"}
</div>
) : providerModels.status === "loading" ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Checking connection
</div>
) : providerModels.status === "error" ? (
<div className="space-y-2">
<div className="text-xs text-destructive break-words">
{providerModels.error || "Connection check failed"}
</div>
<Button variant="outline" size="sm" onClick={() => providerModels.refetch()}>
Retry
</Button>
{provider !== "openai-compatible" && (
<Input
value={primaryModel}
onChange={(e) => setPrimaryModel(provider, e.target.value)}
placeholder="Enter a model to connect anyway"
/>
)}
</div>
) : providerModels.models.length === 0 && provider !== "openai-compatible" ? (
<div className="space-y-2">
<div className="text-sm text-muted-foreground">
Connected, but 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="flex items-center gap-1.5 text-sm text-green-600">
<CheckCircle2 className="size-4" />
Connected · {providerModels.models.length} model{providerModels.models.length === 1 ? "" : "s"} available
</div>
)}
{savedProviders.has(provider) && (
<Button
variant="outline"
size="sm"
className="border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={() => handleDeleteProvider(provider)}
>
Disconnect
</Button>
)}
</div>
{/* openai-compatible escape hatch: its /models often doesn't exist, and
a typed model always wins over the silent pick */}
{provider === "openai-compatible" && (
<div className="space-y-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Model</span>
<Input
value={customModel}
onChange={(e) => {
setCustomModel(e.target.value)
setPrimaryModel(provider, e.target.value)
}}
placeholder="Model ID (leave empty to auto-select)"
/>
</div>
)}
{/* Per-function model overrides */}
<div className="grid grid-cols-2 gap-3">
{!rowboatConnected && (<>
{/* Knowledge graph model (right column) */}
<div className="space-y-2">
@ -919,39 +1148,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">
@ -983,9 +1179,9 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
className="w-full"
>
{testState.status === "testing" ? (
<><Loader2 className="size-4 animate-spin mr-2" />Testing connection...</>
<><Loader2 className="size-4 animate-spin mr-2" />Connecting...</>
) : (
"Test & Save"
"Connect"
)}
</Button>
</div>

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

View file

@ -259,8 +259,20 @@ export async function listModelsForProvider(
url = `https://generativelanguage.googleapis.com/v1beta/models?key=${apiKey ?? ""}`;
break;
case "openrouter":
url = "https://openrouter.ai/api/v1/models";
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
// /api/v1/models is a public catalog — it returns the full
// list even with an invalid/absent key, so listing it can't
// tell a bad key from a good one (a false "Connected"). When
// a key is given, hit the account-scoped /models/user behind
// Bearer auth instead: a bad key 401s here and the shared
// throw below surfaces it. Same OpenAI-shaped { data:[{id}] }
// response, so the parse path is unchanged. No key → keep the
// public catalog so an unconfigured provider can still preview.
if (apiKey) {
url = "https://openrouter.ai/api/v1/models/user";
headers["Authorization"] = `Bearer ${apiKey}`;
} else {
url = "https://openrouter.ai/api/v1/models";
}
break;
case "ollama":
url = `${(baseURL ?? "http://localhost:11434").replace(/\/$/, "")}/api/tags`;