fix(desktop): structured Fallback Models editor

Settings → Model rendered `fallback_providers` (a list of `{provider,
model}` objects) through the generic `list` config field, which does
`value.join(', ')` and stringified each entry to `[object Object],
[object Object]`.

Add a dedicated provider+model row editor (add/remove), sourced from the
same `getGlobalModelOptions()` the composer picker uses, that reads and
writes the `{provider, model}` chain. Half-filled rows are kept in local
state so the config autosave never persists a partial entry, and an
out-of-catalog model stays selectable so existing custom entries render.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Mark Vlcek 2026-06-16 17:35:04 -07:00 committed by Teknium
parent 68107ae9d0
commit 21781d54ec
5 changed files with 154 additions and 0 deletions

View file

@ -19,6 +19,7 @@ import { useOnProfileSwitch } from '../hooks/use-on-profile-switch'
import { PanelEmpty } from '../overlays/panel'
import { CONTROL_TEXT, EMPTY_SELECT_VALUE, FIELD_DESCRIPTIONS, FIELD_LABELS, SECTIONS } from './constants'
import { FallbackModelsField } from './fallback-models-field'
import { fieldCopyForSchemaKey } from './field-copy'
import { enumOptionsFor, getNested, prettyName, setNested } from './helpers'
import { MemoryConnect } from './memory/connect'
@ -100,6 +101,13 @@ function ConfigField({
<ListRow action={action} description={descriptionNode} title={label} wide={wide} />
)
// `fallback_providers` is a list of {provider, model} objects; the generic
// `list` branch below would stringify them to "[object Object]". Render the
// dedicated structured editor instead.
if (schemaKey === 'fallback_providers') {
return row(<FallbackModelsField onChange={onChange} value={value} />, true)
}
if (schema.type === 'boolean') {
return row(
<div className="flex items-center justify-end">

View file

@ -0,0 +1,137 @@
import { useQuery } from '@tanstack/react-query'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { getGlobalModelOptions } from '@/hermes'
import { useI18n } from '@/i18n'
import { Plus, X } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { CONTROL_TEXT } from './constants'
interface FallbackEntry {
provider: string
model: string
}
// Normalize the raw config value (`fallback_providers`: a list of
// `{provider, model}` dicts) into editor rows. Defensive against legacy string
// entries ("provider/model") so the editor never crashes on odd data.
function normalizeEntries(value: unknown): FallbackEntry[] {
if (!Array.isArray(value)) {
return []
}
return value.map(item => {
if (item && typeof item === 'object') {
const record = item as Record<string, unknown>
return { provider: String(record.provider ?? ''), model: String(record.model ?? '') }
}
if (typeof item === 'string') {
const slash = item.indexOf('/')
return slash > 0 ? { provider: item.slice(0, slash), model: item.slice(slash + 1) } : { provider: '', model: item }
}
return { provider: '', model: '' }
})
}
/**
* Structured editor for the top-level `fallback_providers` config list a
* chain of `{provider, model}` pairs tried in order when the default model
* fails. Replaces the generic comma-string `list` input, which stringified the
* objects to "[object Object], [object Object]".
*
* Mirrors the Auxiliary Models picker in `model-settings.tsx`: provider + model
* selects sourced from `getGlobalModelOptions()`. Half-filled rows are kept in
* local state and only complete pairs are emitted upward, so the config
* autosave never persists a partial `{provider, model: ''}`.
*/
export function FallbackModelsField({
value,
onChange
}: {
value: unknown
onChange: (next: FallbackEntry[]) => void
}) {
const { t } = useI18n()
const m = t.settings.model
const modelOptions = useQuery({
queryKey: ['model-options', 'global'],
queryFn: () => getGlobalModelOptions()
})
const providers = (modelOptions.data?.providers ?? []).filter(provider => provider.slug)
const [rows, setRows] = useState<FallbackEntry[]>(() => normalizeEntries(value))
const commit = (next: FallbackEntry[]) => {
setRows(next)
onChange(next.filter(entry => entry.provider && entry.model))
}
const updateRow = (index: number, patch: Partial<FallbackEntry>) =>
commit(rows.map((entry, i) => (i === index ? { ...entry, ...patch } : entry)))
return (
<div className="grid w-full gap-1.5">
{rows.length === 0 && <p className="text-xs text-muted-foreground">{m.fallbackEmpty}</p>}
{rows.map((entry, index) => {
const providerRow = providers.find(provider => provider.slug === entry.provider)
const catalog = providerRow?.models ?? []
// Keep an out-of-catalog model selectable so an existing custom
// provider/model renders instead of showing a blank box.
const modelItems = entry.model && !catalog.includes(entry.model) ? [entry.model, ...catalog] : catalog
return (
<div className="flex flex-wrap items-center gap-2" key={index}>
<span className="w-4 shrink-0 text-center font-mono text-[0.7rem] text-muted-foreground">{index + 1}</span>
<Select onValueChange={provider => updateRow(index, { provider, model: '' })} value={entry.provider}>
<SelectTrigger className={cn('min-w-36', CONTROL_TEXT)}>
<SelectValue placeholder={m.provider} />
</SelectTrigger>
<SelectContent>
{providers.map(provider => (
<SelectItem key={provider.slug} value={provider.slug}>
{provider.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select onValueChange={model => updateRow(index, { model })} value={entry.model}>
<SelectTrigger className={cn('min-w-52 flex-1', CONTROL_TEXT)}>
<SelectValue placeholder={m.model} />
</SelectTrigger>
<SelectContent>
{modelItems.map(model => (
<SelectItem key={model} value={model}>
{model}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
aria-label={t.common.remove}
onClick={() => commit(rows.filter((_, i) => i !== index))}
size="icon-xs"
variant="ghost"
>
<X className="size-3.5" />
</Button>
</div>
)
})}
<div>
<Button onClick={() => commit([...rows, { provider: '', model: '' }])} size="sm" variant="textStrong">
<Plus className="size-3.5" />
{m.fallbackAdd}
</Button>
</div>
</div>
)
}

View file

@ -707,6 +707,9 @@ export const en: Translations = {
change: 'Change',
autoUseMain: 'auto · use main model',
providerDefault: '(provider default)',
fallbackAdd: 'Add fallback',
fallbackEmpty: 'No fallback models — the default model is used unless it fails.',
notInCatalog: "isn't in this provider's model list — calls may fall back to a backup.",
tasks: {
vision: { label: 'Vision', hint: 'Image analysis' },
web_extract: { label: 'Web extract', hint: 'Page summarization' },

View file

@ -611,6 +611,9 @@ export interface Translations {
change: string
autoUseMain: string
providerDefault: string
fallbackAdd: string
fallbackEmpty: string
notInCatalog: string
tasks: Record<string, AuxTaskCopy>
}
providers: {

View file

@ -895,6 +895,9 @@ export const zh: Translations = {
change: '更改',
autoUseMain: '自动 · 使用主模型',
providerDefault: '(提供方默认)',
fallbackAdd: '添加备用模型',
fallbackEmpty: '未配置备用模型 — 默认模型失败时才会使用备用模型。',
notInCatalog: '不在该提供方的模型列表中 — 调用可能回退到备用模型。',
tasks: {
vision: { label: '视觉', hint: '图片分析' },
web_extract: { label: '网页提取', hint: '页面总结' },