fix(byok): decouple chat model picker from config.models (#642)

* fix(onboarding): BYOK saved entire model catalog as assistant models

testAndSaveActiveProvider persisted the provider's full fetched catalog
into config.models. That field is the user's curated assistant-model
list (rendered as one row per entry in Settings > Models), so every
available model showed up as a separate assistant-model dropdown. Seed
it with just the selected model; users add more from Settings.

* feat(byok): decouple chat model picker from config.models

BYOK config.models was doing double duty: it both seeded the in-chat
model picker AND was rendered as a per-entry dropdown stack in Settings.
That coupling forced a tradeoff between a clean Settings UI and full
model choice in chat, and baked a stale catalog snapshot into config.

Decouple them so BYOK matches the signed-in experience:
- Chat picker (signed-out) now lists the full live provider catalog from
  models:list, filtered to providers the user has a key/baseURL for, with
  the saved default model leading. No longer limited to config.models, so
  newly released models appear without re-saving.
- Settings assistant model collapses to a single default-model dropdown
  (removed the .map() stack + add/remove). config.models is now just the
  one default; save/load/delete logic is unchanged.
This commit is contained in:
gagan 2026-06-24 16:33:40 +05:30 committed by GitHub
parent caa80f7c07
commit 8b76c54295
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 74 additions and 89 deletions

View file

@ -401,18 +401,50 @@ function ChatInputInner({
} else {
const result = await window.ipc.invoke('workspace:readFile', { path: 'config/models.json' })
const parsed = JSON.parse(result.data)
// Offer every model the configured key supports — the same full catalog
// Settings uses for its dropdowns — so BYOK chat matches the signed-in
// gateway picker. The picker is no longer limited to a hand-curated
// config.models list. Providers with no catalog (Ollama, OpenAI-compatible)
// fall back to the model saved in config.
const catalog: Record<string, string[]> = {}
try {
const listResult = await window.ipc.invoke('models:list', null)
for (const p of listResult.providers || []) {
catalog[p.id] = (p.models || []).map((m: { id: string }) => m.id)
}
} catch { /* offline / no catalog — fall back to saved config below */ }
const models: ConfiguredModel[] = []
if (parsed?.providers) {
for (const [flavor, entry] of Object.entries(parsed.providers)) {
const e = entry as Record<string, unknown>
const modelList: string[] = Array.isArray(e.models) ? e.models as string[] : []
const singleModel = typeof e.model === 'string' ? e.model : ''
const allModels = modelList.length > 0 ? modelList : singleModel ? [singleModel] : []
for (const model of allModels) {
if (model) {
models.push({ provider: flavor as ProviderName, model })
}
}
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 })
}
// List the default provider first so its default model leads the picker.
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
// The provider's saved default model leads, then the rest of its catalog.
push(flavor, typeof e.model === 'string' ? e.model : '')
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)
}
}
setConfiguredModels(models)

View file

@ -429,13 +429,17 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
return false
}
const models: string[] = result.models ?? []
const catalog: string[] = result.models ?? []
const preferred = preferredDefaults[llmProvider]
const model =
(preferred && models.includes(preferred) && preferred) ||
models[0] || activeConfig.model.trim() || ""
(preferred && catalog.includes(preferred) && preferred) ||
catalog[0] || activeConfig.model.trim() || ""
await window.ipc.invoke("models:saveConfig", { provider, model, models })
// `models` is the user's curated assistant-model list (shown in Settings),
// NOT the full provider catalog. Onboarding seeds it with just the selected
// model; users add more from Settings. Persisting the whole catalog here
// rendered every model as a separate assistant-model row.
await window.ipc.invoke("models:saveConfig", { provider, model, models: model ? [model] : [] })
window.dispatchEvent(new Event('models-config-changed'))
setTestState({ status: "success" })
setConnectedFlavors(prev => new Set(prev).add(llmProvider))

View file

@ -384,38 +384,6 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
[]
)
const updateModelAt = useCallback(
(prov: LlmProviderFlavor, index: number, value: string) => {
setProviderConfigs(prev => {
const models = [...prev[prov].models]
models[index] = value
return { ...prev, [prov]: { ...prev[prov], models } }
})
setTestState({ status: "idle" })
},
[]
)
const addModel = useCallback(
(prov: LlmProviderFlavor) => {
setProviderConfigs(prev => ({
...prev,
[prov]: { ...prev[prov], models: [...prev[prov].models, ""] },
}))
},
[]
)
const removeModel = useCallback(
(prov: LlmProviderFlavor, index: number) => {
setProviderConfigs(prev => {
const models = prev[prov].models.filter((_, i) => i !== index)
return { ...prev, [prov]: { ...prev[prov], models: models.length > 0 ? models : [""] } }
})
setTestState({ status: "idle" })
},
[]
)
// Load current config from file
useEffect(() => {
@ -727,48 +695,29 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
</div>
) : (
<div className="space-y-2">
{activeConfig.models.map((model, index) => (
<div key={index} className="group/model relative">
{showModelInput ? (
<Input
value={model}
onChange={(e) => updateModelAt(provider, index, e.target.value)}
placeholder="Enter model"
/>
) : (
<Select
value={model}
onValueChange={(value) => updateModelAt(provider, index, 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>
)}
{activeConfig.models.length > 1 && (
<button
onClick={() => removeModel(provider, index)}
className="absolute right-8 top-1/2 -translate-y-1/2 flex size-6 items-center justify-center rounded text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover/model:opacity-100"
>
<X className="size-3.5" />
</button>
)}
</div>
))}
<button
onClick={() => addModel(provider)}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<Plus className="size-3.5" />
Add assistant model
</button>
{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 && (