diff --git a/apps/x/apps/renderer/src/components/model-selector.test.tsx b/apps/x/apps/renderer/src/components/model-selector.test.tsx
index ad39b5b8..dba96921 100644
--- a/apps/x/apps/renderer/src/components/model-selector.test.tsx
+++ b/apps/x/apps/renderer/src/components/model-selector.test.tsx
@@ -162,6 +162,34 @@ describe('ModelSelector', () => {
expect(onChange).toHaveBeenCalledWith({ provider: 'openai', model: 'my-local-model' })
})
+ it('liveCredentials live-fetches a provider that is not saved anywhere', async () => {
+ serveTwoProviders()
+ // openrouter is absent from models.json AND the static catalog — only
+ // the form's typed credentials can produce its list.
+ handlers['models:listForProvider'] = async () => ({
+ success: true,
+ models: ['meituan/longcat-2.0', 'qwen/qwen-3'],
+ })
+ const onChange = vi.fn()
+ render(
+ ,
+ )
+ await openMenu()
+ // 600ms debounce in useProviderModels before the fetch fires.
+ const row = await screen.findByText('meituan/longcat-2.0', undefined, { timeout: 3000 })
+ expect(screen.queryByText('gpt-5.4')).toBeNull()
+ fireEvent.click(row)
+ expect(onChange).toHaveBeenCalledWith({ provider: 'openrouter', model: 'meituan/longcat-2.0' })
+ })
+
it('staticOptions renders only the supplied rows and round-trips ids and null', async () => {
serveTwoProviders()
const onChange = vi.fn()
diff --git a/apps/x/apps/renderer/src/components/model-selector.tsx b/apps/x/apps/renderer/src/components/model-selector.tsx
index 37750dfc..4946c0a3 100644
--- a/apps/x/apps/renderer/src/components/model-selector.tsx
+++ b/apps/x/apps/renderer/src/components/model-selector.tsx
@@ -12,7 +12,7 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { useModels, type ModelPickerGroup, type ModelRef } from '@/hooks/use-models'
-import { useProviderModels } from '@/hooks/use-provider-models'
+import { useProviderModels, type ProviderModelsFlavor } from '@/hooks/use-provider-models'
import { cn } from '@/lib/utils'
export type { ModelRef } from '@/hooks/use-models'
@@ -148,6 +148,17 @@ export interface ModelSelectorProps {
* catalog so a provider mid-setup still lists models.
*/
providerFilter?: string
+ /**
+ * Live-fetch credentials for a provider being configured right now (typed
+ * but possibly unsaved). Store groups only exist for providers saved in
+ * models.json and the catalog fallback only covers openai/anthropic/
+ * google — this synthesizes the scoped live group from the form's current
+ * inputs instead (and wins over a saved group, whose stored key may be
+ * stale). Requires providerFilter set to the same flavor; ignored until
+ * some credential is typed. useProviderModels debounces + caches, so
+ * keystrokes don't spray fetches.
+ */
+ liveCredentials?: { flavor: ProviderModelsFlavor; apiKey: string; baseURL: string }
/**
* When the search text matches no rows, offer a `Use ""` row that
* selects the typed id — arbitrary ids for ollama / openai-compatible.
@@ -219,6 +230,7 @@ export function ModelSelector({
inheritDefault,
variant = 'pill',
providerFilter,
+ liveCredentials,
allowCustom = false,
staticOptions,
triggerTitle,
@@ -233,13 +245,21 @@ export function ModelSelector({
const sentinel = defaultOption ?? inheritDefault
const sentinelMuted = !defaultOption && Boolean(inheritDefault)
+ const liveFlavor = liveCredentials?.flavor
+ const liveApiKey = liveCredentials?.apiKey.trim() ?? ''
+ const liveBaseURL = liveCredentials?.baseURL.trim() ?? ''
const groups = useMemo(() => {
if (!providerFilter) return allGroups
+ // The form's typed credentials trump anything saved — same "configured"
+ // bar as the store (some credential present).
+ if (liveFlavor === providerFilter && (liveApiKey || liveBaseURL)) {
+ return [{ kind: 'live', flavor: liveFlavor, apiKey: liveApiKey, baseURL: liveBaseURL, savedModel: '' }]
+ }
const scoped = allGroups.filter((g) => g.flavor === providerFilter)
if (scoped.length > 0) return scoped
const catalogModels = catalogByProvider[providerFilter] || []
return catalogModels.length > 0 ? [{ kind: 'catalog', flavor: providerFilter, models: catalogModels }] : []
- }, [allGroups, providerFilter, catalogByProvider])
+ }, [allGroups, providerFilter, catalogByProvider, liveFlavor, liveApiKey, liveBaseURL])
// Search filter for the model dropdown. Reset each time the menu opens;
// matching is a case-insensitive substring test on the model id. Live
diff --git a/apps/x/apps/renderer/src/components/settings-dialog.tsx b/apps/x/apps/renderer/src/components/settings-dialog.tsx
index f7a78c7f..3bd29929 100644
--- a/apps/x/apps/renderer/src/components/settings-dialog.tsx
+++ b/apps/x/apps/renderer/src/components/settings-dialog.tsx
@@ -544,9 +544,6 @@ 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
@@ -567,23 +564,21 @@ 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
+ // What "Auto" resolves to right now: the flavor's preferred default when
+ // the fetched list carries it, else the first fetched id. Empty while the
+ // live list hasn't settled — the sentinel label and handleTestAndSave's
+ // on-demand fetch cover that window.
+ const autoResolvePreview = (() => {
+ if (providerModels.status !== "loaded" || providerModels.models.length === 0) return ""
+ const preferred = preferredDefaults[provider]
+ return preferred && providerModels.models.includes(preferred) ? preferred : providerModels.models[0]
})()
+ // The model chats run on. An explicit pick (the Assistant model field,
+ // including its typed-id escape hatch and the offline manual inputs)
+ // lives in models[0] and always wins — connecting must never silently
+ // swap a model the user chose; models:test reports honestly if it's bad.
+ // Empty ("Auto") keeps the silent resolve this card has always done.
+ const resolvedModel = primaryModel.trim() || autoResolvePreview
// 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
@@ -703,16 +698,6 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
}
}, [])
- // 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 (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" })
@@ -721,8 +706,8 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
// 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).
+ // click. Same silent precedence as the Auto path (this branch can only
+ // run when no explicit model is set — an explicit pick IS resolved).
let model = resolvedModel
if (!model) {
const listRes = await window.ipc.invoke("models:listForProvider", {
@@ -1185,21 +1170,27 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
)}
- {/* 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)"
- />
-
- )}
+ {/* The model chats run on — the anchor the category fields' "Same as
+ assistant" points at. Auto keeps the silent resolve; the picker
+ live-fetches with the card's CURRENT inputs (typed, maybe unsaved),
+ and allowCustom subsumes the old openai-compatible free-text field
+ (its /models often doesn't exist — type the id in the search).
+ Explicit picks land in models[0] via setPrimaryModel (preserving
+ models[1..]); the offline manual inputs above write the same slot. */}
+