diff --git a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx index 15ddcd48..9e6d76b4 100644 --- a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx +++ b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx @@ -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(['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(['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 + 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 ( + <> + {label} + {visible.map((m) => { + const key = `${group.flavor}/${m}` + return ( + + {m} + + ) + })} + {status === 'loading' && ( +
+ + Loading models… +
+ )} + {status === 'error' && ( + { + e.preventDefault() + refetch() + }} + className="text-xs" + > + {error || 'Failed to load models'} + Retry + + )} + + ) +} + type RecentWorkDir = { path: string lastUsedAt: number @@ -318,7 +398,7 @@ function ChatInputInner({ const fileInputRef = useRef(null) const canSubmit = (Boolean(message.trim()) || attachments.length > 0) && !isProcessing - const [configuredModels, setConfiguredModels] = useState([]) + const [modelGroups, setModelGroups] = useState([]) 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() - 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 = {} const reasoningFlags: Record = {} 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 - 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(() => { - 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(null) + const [liveGroupHasRows, setLiveGroupHasRows] = useState>({}) + 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(() => { + 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 - ) : pickerModels.length > 0 ? ( - + ) : ( + { + // 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) + } + }} + > - - - {pickerModels.map((m) => { - const key = `${m.provider}/${m.model}` - return ( - - {m.model} - {providerDisplayNames[m.provider] || m.provider} - - ) - })} - + + {modelGroups.length === 0 && !standaloneDefault ? ( +
+ Connect a provider in Settings +
+ ) : ( + <> + {/* 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. */} +
+ 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" + /> +
+
+ + {standaloneDefault && standaloneVisible && ( + + {standaloneDefault.model} + + {providerDisplayNames[standaloneDefault.provider] || standaloneDefault.provider} + + + )} + {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 ( + + ) + } + const visibleModels = modelFilterValue + ? g.models.filter((m) => m.toLowerCase().includes(modelFilterValue)) + : g.models + if (visibleModels.length === 0) return null + return ( + + + {label} + + {visibleModels.map((m) => { + const key = `${g.flavor}/${m}` + return ( + + {m} + + ) + })} + + ) + })} + {modelFilterValue && !anyModelRowVisible && ( +
No models match
+ )} +
+
+ + )}
- ) : null} + )} {onStartCall && (
diff --git a/apps/x/apps/renderer/src/components/onboarding/steps/llm-setup-step.tsx b/apps/x/apps/renderer/src/components/onboarding/steps/llm-setup-step.tsx index 4ece02c9..86787753 100644 --- a/apps/x/apps/renderer/src/components/onboarding/steps/llm-setup-step.tsx +++ b/apps/x/apps/renderer/src/components/onboarding/steps/llm-setup-step.tsx @@ -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 */}
- {/* 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 && (
{p.description}
{!isDefault && hasModel && isSelected && ( @@ -738,48 +898,117 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b )}
- {/* Model selection - side by side */} -
- {/* Assistant models (left column) */} + {/* API Key — key-first: the model list is fetched from it */} + {showApiKey && (
- {rowboatConnected ? "Model" : "Assistant model"} - {modelsLoading ? ( -
- - Loading... -
- ) : ( -
- {showModelInput ? ( - updateConfig(provider, { models: [e.target.value] })} - placeholder="Enter model" - /> - ) : ( - - )} -
- )} - {modelsError && ( -
{modelsError}
- )} + + {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" + } + /> +
+ )} + + {/* Connection status — the model itself is resolved silently on save */} +
+ {providerModels.status === "idle" ? ( +
+ {isLocalProvider + ? "Enter your base URL to connect" + : "Enter your API key to connect"} +
+ ) : providerModels.status === "loading" ? ( +
+ + Checking connection… +
+ ) : providerModels.status === "error" ? ( +
+
+ {providerModels.error || "Connection check failed"} +
+ + {provider !== "openai-compatible" && ( + setPrimaryModel(provider, e.target.value)} + placeholder="Enter a model to connect anyway" + /> + )} +
+ ) : providerModels.models.length === 0 && provider !== "openai-compatible" ? ( +
+
+ Connected, but the provider reported no models — enter one manually +
+ setPrimaryModel(provider, e.target.value)} + placeholder="Enter model" + /> +
+ ) : ( +
+ + Connected · {providerModels.models.length} model{providerModels.models.length === 1 ? "" : "s"} available +
+ )} + {savedProviders.has(provider) && ( + + )} +
+ + {/* openai-compatible escape hatch: its /models often doesn't exist, and + a typed model always wins over the silent pick */} + {provider === "openai-compatible" && ( +
+ Model + { + setCustomModel(e.target.value) + setPrimaryModel(provider, e.target.value) + }} + placeholder="Model ID (leave empty to auto-select)" + /> +
+ )} + + {/* Per-function model overrides */} +
{!rowboatConnected && (<> {/* Knowledge graph model (right column) */}
@@ -919,39 +1148,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" && (
@@ -983,9 +1179,9 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b className="w-full" > {testState.status === "testing" ? ( - <>Testing connection... + <>Connecting... ) : ( - "Test & Save" + "Connect" )}
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 } +} diff --git a/apps/x/packages/core/src/models/models.ts b/apps/x/packages/core/src/models/models.ts index ad26ea71..e124c924 100644 --- a/apps/x/packages/core/src/models/models.ts +++ b/apps/x/packages/core/src/models/models.ts @@ -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`;