diff --git a/apps/desktop/src/app/settings/config-settings.tsx b/apps/desktop/src/app/settings/config-settings.tsx
index 6a87590b3cb1..fa9e00eb3497 100644
--- a/apps/desktop/src/app/settings/config-settings.tsx
+++ b/apps/desktop/src/app/settings/config-settings.tsx
@@ -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({
)
+ // `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(, true)
+ }
+
if (schema.type === 'boolean') {
return row(
diff --git a/apps/desktop/src/app/settings/fallback-models-field.tsx b/apps/desktop/src/app/settings/fallback-models-field.tsx
new file mode 100644
index 000000000000..b2b62011548b
--- /dev/null
+++ b/apps/desktop/src/app/settings/fallback-models-field.tsx
@@ -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
+
+ 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(() => normalizeEntries(value))
+
+ const commit = (next: FallbackEntry[]) => {
+ setRows(next)
+ onChange(next.filter(entry => entry.provider && entry.model))
+ }
+
+ const updateRow = (index: number, patch: Partial) =>
+ commit(rows.map((entry, i) => (i === index ? { ...entry, ...patch } : entry)))
+
+ return (
+
+ {rows.length === 0 &&
{m.fallbackEmpty}
}
+ {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 (
+
+ {index + 1}
+
+
+
+
+ )
+ })}
+
+
+
+
+ )
+}
diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts
index 552ce5a465f7..9e82820e83bc 100644
--- a/apps/desktop/src/i18n/en.ts
+++ b/apps/desktop/src/i18n/en.ts
@@ -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' },
diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts
index 1c84bcb7ab52..03f7b8949b51 100644
--- a/apps/desktop/src/i18n/types.ts
+++ b/apps/desktop/src/i18n/types.ts
@@ -611,6 +611,9 @@ export interface Translations {
change: string
autoUseMain: string
providerDefault: string
+ fallbackAdd: string
+ fallbackEmpty: string
+ notInCatalog: string
tasks: Record
}
providers: {
diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts
index 90d2732b94c3..b08a0927ed38 100644
--- a/apps/desktop/src/i18n/zh.ts
+++ b/apps/desktop/src/i18n/zh.ts
@@ -895,6 +895,9 @@ export const zh: Translations = {
change: '更改',
autoUseMain: '自动 · 使用主模型',
providerDefault: '(提供方默认)',
+ fallbackAdd: '添加备用模型',
+ fallbackEmpty: '未配置备用模型 — 默认模型失败时才会使用备用模型。',
+ notInCatalog: '不在该提供方的模型列表中 — 调用可能回退到备用模型。',
tasks: {
vision: { label: '视觉', hint: '图片分析' },
web_extract: { label: '网页提取', hint: '页面总结' },