From 40e450f0ae6501f3bbf78d78c1c4f7612cf38cec Mon Sep 17 00:00:00 2001 From: PRAKHAR PANDEY Date: Wed, 22 Jul 2026 16:36:07 +0530 Subject: [PATCH] feat: explicit Assistant model picker for BYOK providers (#778) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(x): ModelSelector liveCredentials — scoped live group from unsaved creds A provider being configured right now has no store group (models.json not saved yet) and openrouter/aigateway/ollama/openai-compatible have no static catalog either. liveCredentials synthesizes the scoped live group from the form's typed credentials, winning over a saved group whose stored key may be stale. Same 'some credential present' bar as the store; useProviderModels' debounce + cache prevent fetch spray. Co-Authored-By: Claude Fable 5 * feat(x): explicit Assistant model picker on the BYOK provider card The four category fields said 'Same as assistant' while the assistant model itself was invisible (silently auto-resolved at connect). Adds an Assistant model ModelSelector above them: scoped to the card's flavor, live-fetching with the CURRENT typed credentials (liveCredentials, so unsaved keys work), allowCustom for arbitrary ids. The Auto sentinel keeps today's silent resolve and shows what it would pick right now ('Auto (currently gpt-5.4)') once the live list settles. An explicit pick writes through setPrimaryModel into models[0] (models[1..] preserved) and connect uses it verbatim — no silent swap; Auto follows exactly the old resolve-then-save flow including the on-demand fetch. Replaces the openai-compatible-only free-text Model field (customModel state + unconfirmed-model sync effect deleted): typing the id in the picker's search covers the no-/models servers, for every provider. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- .../src/components/model-selector.test.tsx | 28 +++++++ .../src/components/model-selector.tsx | 24 +++++- .../src/components/settings-dialog.tsx | 83 +++++++++---------- 3 files changed, 87 insertions(+), 48 deletions(-) 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