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. */} +
+ + {rowboatConnected ? "Model" : "Assistant model"} + + setPrimaryModel(provider, ref ? ref.model : "")} + /> +
{/* Per-function model overrides. Persisted as bare model-id strings inside providers[flavor] ('' = "Same as assistant"), so the