fix(dashboard): preserve agents.defaults.models entries during model self-heal

Dashboard normalization rebuilt defaults.models from only primary+fallbacks,
dropping valid per-model overrides for other configured models (unlike the
models page normalizeDefaultModelMap). Extract shared pure helpers and merge
orphan valid keys after rebuilding the chain.

Co-authored-by: 晴天 <1186258278@users.noreply.github.com>
This commit is contained in:
Cursor Agent
2026-05-17 11:04:14 +00:00
parent 230b5e6dca
commit 16e4f505e6
3 changed files with 134 additions and 58 deletions

View File

@@ -0,0 +1,74 @@
/**
* Pure helpers for repairing agents.defaults.model + agents.defaults.models
* when loading the dashboard. Kept in a standalone module so unit tests can
* cover normalization without pulling in the full dashboard page.
*/
export function collectConfigModels(config) {
const result = []
const providers = config?.models?.providers || {}
for (const [providerKey, provider] of Object.entries(providers)) {
for (const model of (provider?.models || [])) {
const id = typeof model === 'string' ? model : model?.id
if (id) result.push(`${providerKey}/${id}`)
}
}
return result
}
export function defaultModelNeedsNormalization(config) {
const validModels = new Set(collectConfigModels(config))
const modelConfig = config?.agents?.defaults?.model || {}
const primary = modelConfig.primary || ''
const fallbacks = Array.isArray(modelConfig.fallbacks) ? modelConfig.fallbacks : []
if (!validModels.size) return !!primary || fallbacks.length > 0 || Object.keys(config?.agents?.defaults?.models || {}).length > 0
if (!validModels.has(primary)) return true
if (fallbacks.some(f => f === primary || !validModels.has(f))) return true
return Object.keys(config?.agents?.defaults?.models || {}).some(key => !validModels.has(key))
}
/**
* Align primary / fallbacks with configured providers and rebuild defaults.models.
* Must preserve per-model blocks for any still-valid model id not on the chain
* (same rule as models.js normalizeDefaultModelMap); otherwise dashboard
* self-heal drops unrelated overrides when it only meant to fix a bad primary.
*/
export function normalizeDefaultModelConfig(config) {
const allModels = collectConfigModels(config)
const validModels = new Set(allModels)
if (!config.agents) config.agents = {}
if (!config.agents.defaults) config.agents.defaults = {}
if (!config.agents.defaults.model) config.agents.defaults.model = {}
const modelConfig = config.agents.defaults.model
if (!Array.isArray(modelConfig.fallbacks)) modelConfig.fallbacks = []
if (!allModels.length) {
modelConfig.primary = ''
modelConfig.fallbacks = []
config.agents.defaults.models = {}
return ''
}
if (!validModels.has(modelConfig.primary || '')) {
modelConfig.primary = modelConfig.fallbacks.find(f => validModels.has(f)) || allModels[0]
}
const seen = new Set([modelConfig.primary])
modelConfig.fallbacks = modelConfig.fallbacks
.filter(f => validModels.has(f))
.filter(f => {
if (seen.has(f)) return false
seen.add(f)
return true
})
const currentMap = config.agents.defaults.models && typeof config.agents.defaults.models === 'object' && !Array.isArray(config.agents.defaults.models) ? config.agents.defaults.models : {}
const nextMap = {}
nextMap[modelConfig.primary] = currentMap[modelConfig.primary] && typeof currentMap[modelConfig.primary] === 'object' && !Array.isArray(currentMap[modelConfig.primary]) ? currentMap[modelConfig.primary] : {}
for (const fallback of modelConfig.fallbacks) {
nextMap[fallback] = currentMap[fallback] && typeof currentMap[fallback] === 'object' && !Array.isArray(currentMap[fallback]) ? currentMap[fallback] : {}
}
for (const [key, value] of Object.entries(currentMap)) {
if (validModels.has(key) && !nextMap[key]) {
nextMap[key] = value && typeof value === 'object' && !Array.isArray(value) ? value : {}
}
}
config.agents.defaults.models = nextMap
return modelConfig.primary
}

View File

@@ -11,6 +11,10 @@ import { t } from '../lib/i18n.js'
import { wsClient } from '../lib/ws-client.js'
import { attachCliConflictBanner } from '../components/cli-conflict-banner.js'
import { icon } from '../lib/icons.js'
import {
defaultModelNeedsNormalization,
normalizeDefaultModelConfig,
} from '../lib/agent-default-model-normalize.js'
let _unsubGw = null
let _loadInFlight = false
@@ -139,64 +143,6 @@ function versionInfoIncomplete(version) {
return !version || !version.current || !version.source || version.source === 'unknown'
}
function collectConfigModels(config) {
const result = []
const providers = config?.models?.providers || {}
for (const [providerKey, provider] of Object.entries(providers)) {
for (const model of (provider?.models || [])) {
const id = typeof model === 'string' ? model : model?.id
if (id) result.push(`${providerKey}/${id}`)
}
}
return result
}
function defaultModelNeedsNormalization(config) {
const validModels = new Set(collectConfigModels(config))
const modelConfig = config?.agents?.defaults?.model || {}
const primary = modelConfig.primary || ''
const fallbacks = Array.isArray(modelConfig.fallbacks) ? modelConfig.fallbacks : []
if (!validModels.size) return !!primary || fallbacks.length > 0 || Object.keys(config?.agents?.defaults?.models || {}).length > 0
if (!validModels.has(primary)) return true
if (fallbacks.some(f => f === primary || !validModels.has(f))) return true
return Object.keys(config?.agents?.defaults?.models || {}).some(key => !validModels.has(key))
}
function normalizeDefaultModelConfig(config) {
const allModels = collectConfigModels(config)
const validModels = new Set(allModels)
if (!config.agents) config.agents = {}
if (!config.agents.defaults) config.agents.defaults = {}
if (!config.agents.defaults.model) config.agents.defaults.model = {}
const modelConfig = config.agents.defaults.model
if (!Array.isArray(modelConfig.fallbacks)) modelConfig.fallbacks = []
if (!allModels.length) {
modelConfig.primary = ''
modelConfig.fallbacks = []
config.agents.defaults.models = {}
return ''
}
if (!validModels.has(modelConfig.primary || '')) {
modelConfig.primary = modelConfig.fallbacks.find(f => validModels.has(f)) || allModels[0]
}
const seen = new Set([modelConfig.primary])
modelConfig.fallbacks = modelConfig.fallbacks
.filter(f => validModels.has(f))
.filter(f => {
if (seen.has(f)) return false
seen.add(f)
return true
})
const currentMap = config.agents.defaults.models && typeof config.agents.defaults.models === 'object' && !Array.isArray(config.agents.defaults.models) ? config.agents.defaults.models : {}
const nextMap = {}
nextMap[modelConfig.primary] = currentMap[modelConfig.primary] && typeof currentMap[modelConfig.primary] === 'object' && !Array.isArray(currentMap[modelConfig.primary]) ? currentMap[modelConfig.primary] : {}
for (const fallback of modelConfig.fallbacks) {
nextMap[fallback] = currentMap[fallback] && typeof currentMap[fallback] === 'object' && !Array.isArray(currentMap[fallback]) ? currentMap[fallback] : {}
}
config.agents.defaults.models = nextMap
return modelConfig.primary
}
async function loadDashboardData(page, fullRefresh = false) {
// 并发保护如果上一次加载仍在进行跳过本次fullRefresh 除外)
if (_loadInFlight && !fullRefresh) return

View File

@@ -0,0 +1,56 @@
/**
* 运行node --test tests/agent-default-model-normalize.test.js
*/
import test from 'node:test'
import assert from 'node:assert/strict'
import { normalizeDefaultModelConfig } from '../src/lib/agent-default-model-normalize.js'
test('normalizeDefaultModelConfig keeps valid per-model blocks off the fallback chain', () => {
const config = {
models: {
providers: {
openai: { models: [{ id: 'gpt-4' }, { id: 'gpt-4o-mini' }] },
anthropic: { models: [{ id: 'claude-3-5-sonnet' }] },
},
},
agents: {
defaults: {
model: {
primary: 'openai/deleted-model',
fallbacks: [],
},
models: {
'openai/deleted-model': { temperature: 0.1 },
'anthropic/claude-3-5-sonnet': { temperature: 0.7 },
},
},
},
}
normalizeDefaultModelConfig(config)
assert.equal(config.agents.defaults.model.primary, 'openai/gpt-4')
assert.deepEqual(config.agents.defaults.models['anthropic/claude-3-5-sonnet'], { temperature: 0.7 })
assert.equal(config.agents.defaults.models['openai/deleted-model'], undefined)
})
test('normalizeDefaultModelConfig still strips invalid model keys', () => {
const config = {
models: {
providers: {
openai: { models: [{ id: 'gpt-4' }] },
},
},
agents: {
defaults: {
model: { primary: 'openai/gpt-4', fallbacks: [] },
models: {
'openai/gpt-4': {},
'ghost/missing': { temperature: 1 },
},
},
},
}
normalizeDefaultModelConfig(config)
assert.equal(config.agents.defaults.models['ghost/missing'], undefined)
assert.ok(config.agents.defaults.models['openai/gpt-4'])
})