@arcaneorion/dsh-model-channel-manager 0.3.1 → 0.3.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +45 -2
- package/package.json +7 -1
- package/src/client.js +135 -29
- package/src/index.js +281 -49
package/README.md
CHANGED
|
@@ -31,6 +31,13 @@ dsh plugin --profile web add @arcaneorion/dsh-model-channel-manager
|
|
|
31
31
|
- `dsh.profile.bundles` 加 `"@arcaneorion/dsh-model-channel-manager"`
|
|
32
32
|
- `pnpm install` 后重启 `dsh --profile web`
|
|
33
33
|
|
|
34
|
+
> **link: 方式的模块解析坑(0.3.8 实测)**:pnpm 对 link: 包不安装其依赖;且 Node ESM
|
|
35
|
+
> import 会把 symlink **realpath 化**——host 从 profile 路径加载插件时,`import 'zod'`
|
|
36
|
+
> 实际从**工作区真实路径**向上解析,工作区没有 node_modules 就报
|
|
37
|
+
> `Cannot find package 'zod'`。解法:工作区 `node_modules/` 里软链宿主侧已有实体
|
|
38
|
+
> (`zod` ← profile 顶层;`@deepseek-ai/dsh-storage-domain` ← pnpm `.pnpm` 实体;
|
|
39
|
+
> `.gitignore` 已含 `node_modules/`)。npm 安装方式(dependencies 正常解析)无此问题。
|
|
40
|
+
|
|
34
41
|
验证:
|
|
35
42
|
- host 日志出现 `[model-channel-manager] booted, groups: ...`
|
|
36
43
|
- `llm.providers` 出现 `roundrobin/<组id>`
|
|
@@ -134,11 +141,21 @@ dsh plugin --profile web add @arcaneorion/dsh-model-channel-manager
|
|
|
134
141
|
|
|
135
142
|
供应商卡片头部「改名」按钮可重命名 Provider ID(约束:小写字母开头,仅小写字母/数字/连字符):
|
|
136
143
|
|
|
137
|
-
- 轮询组候选池中引用该 ID 的 candidate 会自动同步为新 ID
|
|
144
|
+
- 轮询组候选池中引用该 ID 的 candidate 会自动同步为新 ID——**主 candidates 与全部 presets 都同步**(0.3.6 修复审计 F17/C05:宿主优先使用 `activePreset.candidates`,漏改它 = 删旧 provider 后组悬空)
|
|
138
145
|
- `apiKeyEnv` 凭据引用**保持不变**——凭据是 write-only 无法搬移,保持引用名原地不动即可让已存储 Key 继续生效
|
|
139
146
|
- 历史健康流水保留在原 ID 名下(历史存档不受影响)
|
|
140
147
|
- 改名后仍需点击右上「保存全部变更」落盘
|
|
141
148
|
|
|
149
|
+
## 组配置保存校验(0.3.6,审计 F16/H08)
|
|
150
|
+
|
|
151
|
+
保存前 client 预检,以下问题**直接拒绝提交**并列出:
|
|
152
|
+
|
|
153
|
+
- 组 ID 非法(仅小写字母/数字/连字符,字母或数字开头)
|
|
154
|
+
- 组 ID 重复
|
|
155
|
+
- 组无可用候选(候选需同时选 Provider 和模型)
|
|
156
|
+
|
|
157
|
+
host 侧兜底:`rewireRoutes` 发现配置组数 > 实际路由数时 `console.warn` 列出被丢弃的组 ID(此前是静默丢弃——H08:3 组保存、路由只有 1 条,界面仍显示「已保存」)。允许清空全部组(空列表合法)。
|
|
158
|
+
|
|
142
159
|
## 密钥写入(凭据引用虚拟化)
|
|
143
160
|
|
|
144
161
|
- 面板主视图只出现「API Key」输入框:**粘贴或输入后失焦即自动写入** DSH 凭据存储(`~/.dsh/.credentials.yaml`,0600,write-only 读不回),无手动按钮;清空输入框不会删除已存 key。上游 llm-pi-ai 的供应商 profile 只有 `apiKeyEnv` 一个密钥字段(凭据引用名),不存在内联 key 的选项——secrets 不进 settings.yaml、不随 `settings.describe` 下发,是有意的安全设计。
|
|
@@ -151,7 +168,8 @@ dsh plugin --profile web add @arcaneorion/dsh-model-channel-manager
|
|
|
151
168
|
|
|
152
169
|
- settings 接入用 **`ctx.inject(['settings'], (sctx) => {...})`**(settings 服务异步初始化,apply 时 `ctx.get('settings')` 为 undefined——曾经整个引擎静默失效,命名空间从未注册)
|
|
153
170
|
- 配置 schema(schemastery):`model-channels` 的虚模型/candidates/strategy/timeoutMs/cooldownMs/maxRetriesPerCandidate/speedTest;`model-channel-health` 的 runtime/speedRequest+lastHandledNonce/testRequest+testResults+lastTestHandledNonce/digest 小投影(records/speedResults 仅作 0.3.1 迁移的读取源,权威在 storageDomain)
|
|
154
|
-
- 引擎:sticky/round-robin/primary
|
|
171
|
+
- 引擎:sticky/round-robin/primary 三策略;**round-robin 选择时原子预留**(0.3.5:指针选定即推进,并发请求均分;旧实现成功后才推进,并发全打同一候选——审计 F13/H13);首响应超时 + 流中空闲超时(动态 = max(timeoutMs, min(120s, ttft×2)));单候选原地重试(指数退避)耗尽才换;全炸清冷却重试一轮;组级总预算 `totalBudgetMs`(默认 10 分钟,0.3.3);测速 ttft/latency/hybrid/smart 四键(smart = 0.5×ttft_norm + 0.3×(1−reliability) + 0.2×latency_norm,reliability 贝叶斯平滑 `(success+2.5)/(total+5)`);测速失败进冷却;请求隔离按组
|
|
172
|
+
- 自动测速(0.3.5,审计 F14/H07):`speedTest.enabled` 且组无测速结果时,**首次真实使用触发一次后台测速**(不阻塞请求);此前只有 boot 时的 `onFirstUse` 分支,常规路径无入口——开启开关后从未生效
|
|
155
173
|
- 虚拟模型元数据:`reasoning.efforts` 七档(off…max)、**defaultEffort=max**——原生 `/model` 弹窗对新模型的自动填档与展示跟随该声明;会话内显式档位的跨会话恢复由 selector 插件的档位记忆层负责(`modelDirectories` 拦截,存 `model-channels.effortMemory`)
|
|
156
174
|
- 迁移:startup 时从工作区 `.channel-manager/config.json` 一次性迁入 `model-channels`(无遗留则忽略);完成后写 `legacyMigrated` 哨兵防止「清空组后重启复活」;fs 未就绪时 5s×6 重试
|
|
157
175
|
- 遗留 `.channel-manager/` 目录不再使用
|
|
@@ -165,6 +183,11 @@ react 经 `require('react')`;样式用 `ctx.effect` 自管理;`dsh.client: {
|
|
|
165
183
|
|
|
166
184
|
## 已知限制
|
|
167
185
|
|
|
186
|
+
- **虚拟模型能力声明与候选实际能力无联动(审计 F08,0.3.4 已做最小切片)**:请求含图
|
|
187
|
+
或带 effort 时,宿主 `resolveModelInfo` 明确声明不支持的候选会被过滤(60s TTL 缓存;
|
|
188
|
+
`inputModalities` 缺失=未知不过滤,保持 failover;全滤退回原列表报真实错误)——
|
|
189
|
+
防住 H14(图片静默替换后假成功)与 H06(effort 强塞被拒)。**仍未做**:虚拟模型声明
|
|
190
|
+
元数据(vision/efforts/窗口)与候选能力的联动聚合,属后续专项
|
|
168
191
|
- 动态超时实现了首响应 + 流中空闲;全炸后「清冷却重试一轮」回溯,未实现「等待最早冷却」的睡眠分支
|
|
169
192
|
- 测速结果不入健康流水(pi 记);smart 键只统计真实请求
|
|
170
193
|
- **Token 字段只在新记录上出现**:host 升级重启前的存量健康记录无 token 字段,7 天视图对重启前的调用会低估 token(请求数/可用率不受影响);数据自重启后开始累积
|
|
@@ -172,6 +195,26 @@ react 经 `require('react')`;样式用 `ctx.effect` 自管理;`dsh.client: {
|
|
|
172
195
|
- 轮询渠道/健康统计面板需要 host 新代码(重启后生效);健康流水的数据在**实际请求过轮询组**后才出现
|
|
173
196
|
- `reasoningEfforts` 缺失(undefined)的 model 正确渲染(`|| {}` 兜底)
|
|
174
197
|
- 会话模型选择器搜索版已拆出为独立插件 `@arcaneorion/dsh-model-selector-search`(原生座位遮蔽、搜索、向上展开菜单、effort 档位未实现等边界见该仓 README);本插件不再注册任何座位
|
|
198
|
+
- `makeLegacyApi` 0.1 兼容门面仍保留(94 行、11 调用点):拆除要动 6 个功能路径的双层
|
|
199
|
+
信封,待 0.3.2 真实环境验证后再决定
|
|
200
|
+
- 30m/24h 健康视图是近似口径(按最近活跃过滤,数值为 7 天累计,UI 已标注);精确分窗口
|
|
201
|
+
需 host 出多份 digest
|
|
202
|
+
|
|
203
|
+
## 引擎超时与生命周期(0.3.3 重构:审计 F01/F02/F15 已修)
|
|
204
|
+
|
|
205
|
+
- **per-attempt AbortController**:每次候选尝试独立 signal,用户取消转发(`relayAbort`,
|
|
206
|
+
finally 移除防泄漏)+ 超时 abort(`attemptController.abort(raceErr)`)。pi-ai 适配器把
|
|
207
|
+
`options.signal` 经 `AbortSignal.any` 融进 watchdog 并传给上游 HTTP——abort 即真正
|
|
208
|
+
取消网络请求,不再有「return() 排在挂起的 next() 之后拖住 failover」(H02 复现的根因)
|
|
209
|
+
- **统一 finally 有界关闭**:streamAttempt / measureCandidate / runModelTest 三处流消费
|
|
210
|
+
路径,成功 return / 失败 / 消费者提前退出都走 `closeInner`(closed 防重入 + 3s 关闭
|
|
211
|
+
预算 race,预算超时补一发 abort)
|
|
212
|
+
- **组级总预算 `totalBudgetMs`**(默认 10 分钟,组配置可调):超预算不开新尝试,以
|
|
213
|
+
`CHANNEL_BUDGET_EXCEEDED` 终结——旧实现最坏 `2×N×(R+1)` 次尝试(默认 R=2 → 6N)
|
|
214
|
+
- **测速/测试改总时限**(F15):旧实现每 chunk 重置 guard(H12:timeoutMs=40 流每
|
|
215
|
+
20ms 输出,84ms 后仍成功),现在 `deadline` 固定总时限,超时 abort
|
|
216
|
+
- 回归:`tests/timeout-abort.test.cjs`(契约断言 + 挂起流行为级验证——3s 关闭预算内
|
|
217
|
+
完成,不等满 5s 挂起)
|
|
175
218
|
|
|
176
219
|
## 踩坑速记(本项目,按严重程度)
|
|
177
220
|
|
package/package.json
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arcaneorion/dsh-model-channel-manager",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.8",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "src/index.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"test": "node --test \"tests/*.test.cjs\""
|
|
8
|
+
},
|
|
6
9
|
"exports": {
|
|
7
10
|
".": "./src/index.js",
|
|
8
11
|
"./client": "./src/client.js",
|
|
@@ -33,6 +36,9 @@
|
|
|
33
36
|
"publishConfig": {
|
|
34
37
|
"access": "public"
|
|
35
38
|
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"yaml": "^2.9.0"
|
|
41
|
+
},
|
|
36
42
|
"peerDependencies": {
|
|
37
43
|
"@deepseek-ai/cordis": "^4.0.4",
|
|
38
44
|
"@deepseek-ai/dsh-llm": "0.2.0-rc.1",
|
package/src/client.js
CHANGED
|
@@ -38,22 +38,25 @@ window.__ModuleLoader__.load({
|
|
|
38
38
|
{ ns: 'model-channel-health', value: value.health || {}, user: (user && user.health) || {}, revision: (mcm && mcm.revision) || 0 },
|
|
39
39
|
] } } }
|
|
40
40
|
},
|
|
41
|
-
// 旧签名:update({ ns, patch })
|
|
41
|
+
// 旧签名:update({ ns, patch, expectedRevision })——0.3.2 起透传 revision
|
|
42
|
+
// (宿主 settings.update 第三参;冲突抛 settings/conflict,由调用方提示重载)
|
|
42
43
|
update: async (args) => {
|
|
43
44
|
const ns = (args && args.ns) || NS_MCM
|
|
44
45
|
const patch = (args && args.patch) || {}
|
|
45
|
-
|
|
46
|
+
const expected = (args && args.expectedRevision !== undefined) ? args.expectedRevision : undefined
|
|
47
|
+
if (ns === 'model-channels') return wrap(await remote.settings.update(NS_MCM, patch, expected))
|
|
46
48
|
const row = await readMcm()
|
|
47
49
|
const health = (row && row.value && row.value.health) || {}
|
|
48
|
-
return wrap(await remote.settings.update(NS_MCM, { health: Object.assign({}, health, patch) },
|
|
50
|
+
return wrap(await remote.settings.update(NS_MCM, { health: Object.assign({}, health, patch) }, expected))
|
|
49
51
|
},
|
|
50
52
|
// 旧签名:mutate({ ns, ops })——0.2 只对真实行命名空间提供 path-ops;
|
|
51
53
|
// 本插件自身的健康子树用合并写实现等价语义。
|
|
52
54
|
mutate: async (args) => {
|
|
53
55
|
const ns = (args && args.ns) || NS_MCM
|
|
54
56
|
const ops = (args && args.ops) || []
|
|
57
|
+
const expected = (args && args.expectedRevision !== undefined) ? args.expectedRevision : undefined
|
|
55
58
|
if (ns !== 'model-channels' && ns !== 'model-channel-health' && ns !== NS_MCM) {
|
|
56
|
-
return wrap(await remote.settings.mutate(ns, ops,
|
|
59
|
+
return wrap(await remote.settings.mutate(ns, ops, expected))
|
|
57
60
|
}
|
|
58
61
|
const row = await readMcm()
|
|
59
62
|
const health = Object.assign({}, (row && row.value && row.value.health) || {})
|
|
@@ -78,9 +81,9 @@ window.__ModuleLoader__.load({
|
|
|
78
81
|
}
|
|
79
82
|
}
|
|
80
83
|
if (ns === 'model-channels') {
|
|
81
|
-
return wrap(await remote.settings.update(NS_MCM, { groups: top.groups, providerOrder: top.providerOrder },
|
|
84
|
+
return wrap(await remote.settings.update(NS_MCM, { groups: top.groups, providerOrder: top.providerOrder }, expected))
|
|
82
85
|
}
|
|
83
|
-
return wrap(await remote.settings.update(NS_MCM, { health },
|
|
86
|
+
return wrap(await remote.settings.update(NS_MCM, { health }, expected))
|
|
84
87
|
},
|
|
85
88
|
},
|
|
86
89
|
credentials: {
|
|
@@ -589,13 +592,18 @@ window.__ModuleLoader__.load({
|
|
|
589
592
|
if (!apiRef) return
|
|
590
593
|
const key = provider + '::' + model
|
|
591
594
|
setTestStates((s) => Object.assign({}, s, { [key]: { status: 'running' } }))
|
|
592
|
-
|
|
595
|
+
// F03:Date.now()%1e9 同毫秒可碰撞(碰撞 = 两次测试互相认领/覆盖结果),
|
|
596
|
+
// 换 UUID;host 侧 testRequest.nonce 消费方是 !== 比较,字符串/数字都行
|
|
597
|
+
const nonce = (crypto.randomUUID && crypto.randomUUID()) || (String(Date.now()) + '-' + Math.random().toString(36).slice(2, 10))
|
|
593
598
|
const prompt = localStorage.getItem('mcm_test_prompt') || '用一句话介绍你自己'
|
|
594
599
|
const maxTokens = Number(localStorage.getItem('mcm_test_max_tokens')) || 256
|
|
595
600
|
apiRef.settings.update({
|
|
596
601
|
ns: 'model-channel-health',
|
|
597
602
|
patch: { testRequest: { nonce, provider, model, prompt, maxTokens } }
|
|
598
|
-
}).then(() => {
|
|
603
|
+
}).then((resp) => {
|
|
604
|
+
// F19:RPC resolve ≠ 业务成功——检查信封(旧实现把 ok:false 当「已触发」)
|
|
605
|
+
const r = resp && resp.result ? resp.result : resp
|
|
606
|
+
if (r && r.ok === false) throw new Error((r.error && (r.error.message || r.error)) || 'test request rejected')
|
|
599
607
|
pollTest(nonce, provider, model)
|
|
600
608
|
}).catch((e) => {
|
|
601
609
|
setTestStates((s) => Object.assign({}, s, { [key]: { status: 'error', error: String((e && e.message) || e) } }))
|
|
@@ -704,7 +712,7 @@ window.__ModuleLoader__.load({
|
|
|
704
712
|
el('span', {
|
|
705
713
|
title: '拖动此手柄调整供应商顺序(保存后生效)',
|
|
706
714
|
draggable: true,
|
|
707
|
-
onDragStart: (e) => { e.stopPropagation(); setDragIdx(
|
|
715
|
+
onDragStart: (e) => { e.stopPropagation(); setDragIdx(realIdx); try { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', String(realIdx)) } catch (_e) {} },
|
|
708
716
|
onDragEnd: () => { setDragIdx(null); setOverIdx(null) },
|
|
709
717
|
onClick: (e) => e.stopPropagation(),
|
|
710
718
|
style: { cursor: 'grab', color: 'var(--dsw-alias-label-tertiary)', fontSize: 14, padding: '0 6px', userSelect: 'none' }
|
|
@@ -931,6 +939,8 @@ window.__ModuleLoader__.load({
|
|
|
931
939
|
const providers = props._providers || {}
|
|
932
940
|
const [speedState, setSpeedState] = useState({})
|
|
933
941
|
const [expanded, setExpanded] = useState({})
|
|
942
|
+
// 顶层 notice 的写入通道(ModelConfigView 传入;测速失败要上浮显示)
|
|
943
|
+
const setNotice = props._setNotice || (() => {})
|
|
934
944
|
|
|
935
945
|
const addGroup = () => props.setChannelsDraft((d) => {
|
|
936
946
|
const base = d || []
|
|
@@ -965,12 +975,19 @@ window.__ModuleLoader__.load({
|
|
|
965
975
|
|
|
966
976
|
const speedtest = (gid) => {
|
|
967
977
|
if (!apiRef) return
|
|
968
|
-
|
|
978
|
+
// F03/F19 同 fireTest:UUID 防碰撞 + 信封检查(ok:false 不再当「已触发」)
|
|
979
|
+
const nonce = (crypto.randomUUID && crypto.randomUUID()) || (String(Date.now()) + '-' + Math.random().toString(36).slice(2, 10))
|
|
969
980
|
setSpeedState((s) => Object.assign({}, s, { [gid]: 'running' }))
|
|
970
|
-
apiRef.settings.update({ ns: 'model-channel-health', patch: { speedRequest: { group: gid, nonce } } }).then(() => {
|
|
981
|
+
apiRef.settings.update({ ns: 'model-channel-health', patch: { speedRequest: { group: gid, nonce } } }).then((resp) => {
|
|
982
|
+
const r = resp && resp.result ? resp.result : resp
|
|
983
|
+
if (r && r.ok === false) throw new Error((r.error && (r.error.message || r.error)) || 'speed request rejected')
|
|
971
984
|
setSpeedState((s) => Object.assign({}, s, { [gid]: 'sent' }))
|
|
972
985
|
setTimeout(() => setSpeedState((s) => Object.assign({}, s, { [gid]: null })), 3000)
|
|
973
|
-
}).catch(() =>
|
|
986
|
+
}).catch((e) => {
|
|
987
|
+
setSpeedState((s) => Object.assign({}, s, { [gid]: 'err' }))
|
|
988
|
+
setNotice('测速触发失败: ' + String((e && e.message) || e))
|
|
989
|
+
setTimeout(() => setNotice(null), 5000)
|
|
990
|
+
})
|
|
974
991
|
}
|
|
975
992
|
|
|
976
993
|
if (groups.length === 0) return el('div', { className: 'mcm-empty' }, '暂无轮询组,点击下方按钮创建', el('div', { style: { marginTop: 12 } }, btn('+新增轮询组', addGroup, 'primary')))
|
|
@@ -1368,11 +1385,23 @@ window.__ModuleLoader__.load({
|
|
|
1368
1385
|
const [notice, setNotice] = useState(null)
|
|
1369
1386
|
const [saving, setSaving] = useState(false)
|
|
1370
1387
|
const [tab, setTab] = useState('config')
|
|
1388
|
+
// ready 门(审计 F04):首次 describe 完成前保存不可用——旧实现
|
|
1389
|
+
// channelsDraft || [] 把「还没读到」当「空」,加载前点保存会把轮询组清空
|
|
1390
|
+
const [ready, setReady] = useState(false)
|
|
1371
1391
|
|
|
1372
1392
|
const renameProviderInChannels = (oldId, newId) => {
|
|
1393
|
+
// F17(审计 C05):改名要同步全部引用——主 candidates + 每个 preset 的
|
|
1394
|
+
// candidates。宿主优先使用 activePreset.candidates,漏改它 = 删旧 provider
|
|
1395
|
+
// 后组仍访问不存在的 ID。
|
|
1396
|
+
const renameCands = (cands) => (cands || []).map((c) => (c && c.provider === oldId) ? Object.assign({}, c, { provider: newId }) : c)
|
|
1373
1397
|
setChannelsDraft((d) => (d || []).map((g) => {
|
|
1374
|
-
const
|
|
1375
|
-
|
|
1398
|
+
const next = Object.assign({}, g, { candidates: renameCands(g.candidates) })
|
|
1399
|
+
if (Array.isArray(g.presets)) {
|
|
1400
|
+
next.presets = g.presets.map((p) => (p && Array.isArray(p.candidates))
|
|
1401
|
+
? Object.assign({}, p, { candidates: renameCands(p.candidates) })
|
|
1402
|
+
: p)
|
|
1403
|
+
}
|
|
1404
|
+
return next
|
|
1376
1405
|
}))
|
|
1377
1406
|
}
|
|
1378
1407
|
const [channels, setChannels] = useState(null)
|
|
@@ -1412,7 +1441,9 @@ window.__ModuleLoader__.load({
|
|
|
1412
1441
|
const providers = applyProviderOrder((ns && ns.value && ns.value.providers) || {}, savedOrder)
|
|
1413
1442
|
// user 层 = 仅用户手工声明的路由(用于真正删除与重排序的 mutate 依据)
|
|
1414
1443
|
const userProviders = (ns && ns.user && ns.user.providers) || {}
|
|
1415
|
-
|
|
1444
|
+
// revision 记录(审计 F05):保存时作为 expectedRevision 传给宿主,
|
|
1445
|
+
// 别处(另一个标签页/host 侧)改过配置则宿主拒绝,防止旧快照覆盖
|
|
1446
|
+
setState({ providers, userProviders, providerOrder: savedOrder || [], nsRevisions: { piAi: (ns && ns.revision) || 0, channels: (cn && cn.revision) || 0 } })
|
|
1416
1447
|
setDraft((prev) => prev || clone(providers))
|
|
1417
1448
|
const ch = (cn && cn.value && cn.value.groups) || []
|
|
1418
1449
|
setChannels(ch)
|
|
@@ -1424,7 +1455,8 @@ window.__ModuleLoader__.load({
|
|
|
1424
1455
|
setHealth(hv.digest || (hv.digestAt !== undefined)
|
|
1425
1456
|
? { digest: hv.digest || [], digestAt: hv.digestAt || 0, speedResults: hv.speedResults || {}, runtime: hv.runtime || {} }
|
|
1426
1457
|
: (hn ? { records: hv.records || {}, speedResults: hv.speedResults || {}, runtime: hv.runtime || {} } : null))
|
|
1427
|
-
|
|
1458
|
+
setReady(true) // describe 成功:保存解禁(F04 的门)
|
|
1459
|
+
}).catch((e) => { setNotice('加载失败: ' + String(e)); setReady(false) })
|
|
1428
1460
|
}
|
|
1429
1461
|
|
|
1430
1462
|
useEffect(() => {
|
|
@@ -1453,6 +1485,36 @@ window.__ModuleLoader__.load({
|
|
|
1453
1485
|
|
|
1454
1486
|
const save = () => {
|
|
1455
1487
|
if (!apiRef) return
|
|
1488
|
+
// F04 门:describe 未完成时 draft/channelsDraft 都是 null——旧实现
|
|
1489
|
+
// (channelsDraft || []) 把「还没读到」当「空」,这里直接拦截而不是兜底空数组
|
|
1490
|
+
if (!ready) {
|
|
1491
|
+
setNotice('配置尚未加载完成,请稍候再保存')
|
|
1492
|
+
setTimeout(() => setNotice(null), 4000)
|
|
1493
|
+
return
|
|
1494
|
+
}
|
|
1495
|
+
// F16 预检:非法/重复组 ID、空候选直接拒绝提交(旧实现保存后 host 静默丢弃,
|
|
1496
|
+
// 界面仍显示「已保存」——H08:3 组保存实际只注册 1 条路由)
|
|
1497
|
+
const groupsToSave = channelsDraft || []
|
|
1498
|
+
const problems = []
|
|
1499
|
+
const seenIds = new Set()
|
|
1500
|
+
for (const g of groupsToSave) {
|
|
1501
|
+
const gid = (g && g.id) || ''
|
|
1502
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(gid)) {
|
|
1503
|
+
problems.push('组「' + (gid || '(未命名)') + '」ID 非法(仅小写字母/数字/连字符,字母或数字开头)')
|
|
1504
|
+
} else if (seenIds.has(gid)) {
|
|
1505
|
+
problems.push('组 ID「' + gid + '」重复')
|
|
1506
|
+
} else {
|
|
1507
|
+
seenIds.add(gid)
|
|
1508
|
+
}
|
|
1509
|
+
const candCount = (g && Array.isArray(g.candidates)) ? g.candidates.filter((c) => c && c.provider && c.model).length : 0
|
|
1510
|
+
if (candCount === 0 && gid)
|
|
1511
|
+
problems.push('组「' + gid + '」没有可用候选(候选需同时选择 Provider 和模型)')
|
|
1512
|
+
}
|
|
1513
|
+
if (problems.length > 0) {
|
|
1514
|
+
setNotice('保存被拒绝:' + problems[0] + (problems.length > 1 ? '(共 ' + problems.length + ' 个问题)' : ''))
|
|
1515
|
+
setTimeout(() => setNotice(null), 8000)
|
|
1516
|
+
return
|
|
1517
|
+
}
|
|
1456
1518
|
setSaving(true)
|
|
1457
1519
|
setNotice(null)
|
|
1458
1520
|
// Compat 全量透传:rc.2 PiAiCompatProfile 的 20 个字段全部真实生效——此前
|
|
@@ -1494,6 +1556,10 @@ window.__ModuleLoader__.load({
|
|
|
1494
1556
|
if (Array.isArray(cleaned.input) && cleaned.input.length > 0) cleaned.input = cleaned.input.slice()
|
|
1495
1557
|
else delete cleaned.input
|
|
1496
1558
|
if (cleaned.reasoningEfforts != null && typeof cleaned.reasoningEfforts === 'object' && Object.keys(cleaned.reasoningEfforts).length > 0) cleaned.reasoningEfforts = Object.assign({}, cleaned.reasoningEfforts)
|
|
1559
|
+
else if (cleaned.reasoningEfforts === false) {
|
|
1560
|
+
// F07(审计 C02):false = 显式关闭推理(继承 catalog 默认的对立面)。
|
|
1561
|
+
// 删掉它 = 静默恢复继承,语义被改变。原样保留。
|
|
1562
|
+
}
|
|
1497
1563
|
else delete cleaned.reasoningEfforts
|
|
1498
1564
|
const mc = cleanCompat(cleaned.compat)
|
|
1499
1565
|
if (mc) cleaned.compat = mc
|
|
@@ -1501,7 +1567,12 @@ window.__ModuleLoader__.load({
|
|
|
1501
1567
|
return cleaned
|
|
1502
1568
|
})
|
|
1503
1569
|
const pObj = Object.assign({}, pVal)
|
|
1504
|
-
|
|
1570
|
+
// F07(审计 C08):api 缺省 = 继承 catalog 协议;强塞 openai-completions
|
|
1571
|
+
// 会把「继承」变成「显式指定」,materialize 时显式 api 优先于 catalog——
|
|
1572
|
+
// 协议被静默切换。UI 下拉仍显示默认值(渲染默认与持久化默认分离),
|
|
1573
|
+
// 但保存不写显式值。新增供应商时 addGroup 路径已显式给 api。
|
|
1574
|
+
if (typeof pVal.api === 'string' && pVal.api) pObj.api = pVal.api
|
|
1575
|
+
else delete pObj.api
|
|
1505
1576
|
pObj.models = cleanModels
|
|
1506
1577
|
if (!(pVal.baseURL && String(pVal.baseURL).trim())) delete pObj.baseURL
|
|
1507
1578
|
else pObj.baseURL = String(pVal.baseURL).trim()
|
|
@@ -1561,9 +1632,14 @@ window.__ModuleLoader__.load({
|
|
|
1561
1632
|
for (const k of allKeys) ops.push({ op: 'unset', path: ['providers', k] })
|
|
1562
1633
|
for (const k of keys) ops.push({ op: 'set', path: ['providers', k], value: cleanProviders[k] })
|
|
1563
1634
|
if (ops.length === 0) return Promise.resolve()
|
|
1564
|
-
|
|
1635
|
+
// F05:带 revision 保存——llm-pi-ai 是独立 settings 行,冲突由宿主以
|
|
1636
|
+
// settings/conflict 拒绝;提示里给「刷新后重试」指引而不是静默覆盖
|
|
1637
|
+
return apiRef.settings.mutate({ ns: 'llm-pi-ai', ops, expectedRevision: (state && state.nsRevisions && state.nsRevisions.piAi) || undefined }).then((resp) => {
|
|
1565
1638
|
const r = resp && resp.result ? resp.result : resp
|
|
1566
|
-
if (r && r.ok === false)
|
|
1639
|
+
if (r && r.ok === false) {
|
|
1640
|
+
const conflict = r.error && (r.error.code === 'settings/conflict' || /conflict/i.test(String(r.error.message || '')))
|
|
1641
|
+
throw new Error(conflict ? 'llm-pi-ai 配置已被其他页面修改(版本冲突),请点「刷新」后重试' : ((r.error && (r.error.message || r.error)) || 'llm-pi-ai save failed'))
|
|
1642
|
+
}
|
|
1567
1643
|
return r
|
|
1568
1644
|
})
|
|
1569
1645
|
})() : Promise.resolve()
|
|
@@ -1572,22 +1648,46 @@ window.__ModuleLoader__.load({
|
|
|
1572
1648
|
const orderPatch = draft ? { providerOrder: Object.keys(cleanProviders) } : {}
|
|
1573
1649
|
const p2 = apiRef.settings.update({
|
|
1574
1650
|
ns: 'model-channels',
|
|
1651
|
+
expectedRevision: (state && state.nsRevisions && state.nsRevisions.channels) || undefined,
|
|
1575
1652
|
// 命名单一身份:写入时统一 virtualModel.name = 组 id——旧的独立呈现名
|
|
1576
1653
|
// (如遗留的 group-1)在下一次保存时自动归一,无需迁移
|
|
1577
1654
|
patch: Object.assign({ groups: (channelsDraft || []).map((g) => Object.assign({}, g, { virtualModel: Object.assign({}, g.virtualModel, { name: g.id }) })) }, orderPatch)
|
|
1578
1655
|
}).then((resp) => {
|
|
1579
1656
|
const r = resp && resp.result ? resp.result : resp
|
|
1580
|
-
if (r && r.ok === false)
|
|
1657
|
+
if (r && r.ok === false) {
|
|
1658
|
+
const conflict = r.error && (r.error.code === 'settings/conflict' || /conflict/i.test(String(r.error.message || '')))
|
|
1659
|
+
throw new Error(conflict ? '轮询组配置已被其他页面修改(版本冲突),请点「刷新」后重试' : ((r.error && (r.error.message || r.error)) || 'model-channels save failed'))
|
|
1660
|
+
}
|
|
1581
1661
|
return r
|
|
1582
1662
|
})
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1663
|
+
// F06(审计 C10,0.3.8):两域用 allSettled 而非 all——半成功不再「整单失败」
|
|
1664
|
+
// 掩盖已提交的那半。各自报告,失败域给出明确指引;部分成功时提示里列明
|
|
1665
|
+
// 哪域成功哪域失败,用户知道实际落盘了什么(宿主无跨命名空间事务,
|
|
1666
|
+
// 这是审计建议的务实路线:可观察的分域成败 + 冲突重试)。
|
|
1667
|
+
const settleOf = (label, p) => p.then(() => ({ label, ok: true }), (e) => ({ label, ok: false, error: String((e && e.message) || e) }))
|
|
1668
|
+
Promise.allSettled([settleOf('提供商配置', p1), settleOf('轮询组配置', p2)]).then((results) => {
|
|
1669
|
+
const done = results.map((r) => r.value)
|
|
1670
|
+
const failed = done.filter((d) => d && !d.ok)
|
|
1671
|
+
const okCount = done.length - failed.length
|
|
1672
|
+
if (failed.length === 0) {
|
|
1673
|
+
setNotice('已全部保存(即时生效)')
|
|
1674
|
+
}
|
|
1675
|
+
else if (okCount > 0) {
|
|
1676
|
+
// 部分成功:明确告知哪域已落盘、哪域失败(旧实现统一「保存失败」,
|
|
1677
|
+
// 用户不知道 providers 其实已经提交了)
|
|
1678
|
+
setNotice('部分保存:' + done.filter((d) => d && d.ok).map((d) => d.label).join('、') + ' 已生效;' + failed.map((d) => d.label).join('、') + ' 失败——' + failed[0].error)
|
|
1679
|
+
}
|
|
1680
|
+
else {
|
|
1681
|
+
setNotice('保存失败: ' + failed[0].error)
|
|
1682
|
+
}
|
|
1683
|
+
// 两域都成功才同步本地镜像(半成功时以 refresh 拉回真实状态为准)
|
|
1684
|
+
if (failed.length === 0) {
|
|
1685
|
+
setDraft(clone(cleanProviders))
|
|
1686
|
+
setState((s) => Object.assign({}, s, { providers: cleanProviders, userProviders: cleanProviders, providerOrder: Object.keys(cleanProviders) }))
|
|
1687
|
+
}
|
|
1688
|
+
setTimeout(() => setNotice(null), failed.length > 0 ? 8000 : 3000)
|
|
1589
1689
|
refresh()
|
|
1590
|
-
}).
|
|
1690
|
+
}).finally(() => setSaving(false))
|
|
1591
1691
|
}
|
|
1592
1692
|
|
|
1593
1693
|
return el('div', { className: 'mcm-root' },
|
|
@@ -1600,12 +1700,18 @@ window.__ModuleLoader__.load({
|
|
|
1600
1700
|
el('div', { className: 'mcm-actions' },
|
|
1601
1701
|
notice ? el('span', { style: { fontSize: 12, color: notice.includes('失败') ? 'var(--dsw-alias-state-error-primary)' : 'var(--dsw-alias-state-success-primary)' } }, notice) : null,
|
|
1602
1702
|
btn('刷新', refresh),
|
|
1603
|
-
|
|
1703
|
+
el('button', {
|
|
1704
|
+
className: 'mcm-btn primary',
|
|
1705
|
+
// F04:未就绪/保存中禁用;disabled 态样式弱化
|
|
1706
|
+
disabled: !ready || saving,
|
|
1707
|
+
style: (!ready || saving) ? { opacity: 0.55, cursor: 'not-allowed' } : undefined,
|
|
1708
|
+
onClick: save
|
|
1709
|
+
}, !ready ? '加载中…' : saving ? '保存中…' : '保存全部变更')
|
|
1604
1710
|
)
|
|
1605
1711
|
),
|
|
1606
1712
|
el('div', { className: 'mcm-body' },
|
|
1607
1713
|
tab === 'config' ? el(ModelConfigPanel, { _state: state, _draft: draft, _setDraft: setDraft, _setNotice: setNotice, _renameProviderInChannels: renameProviderInChannels }) :
|
|
1608
|
-
tab === 'roundrobin' ? el(RoundrobinPanel, { channels, channelsDraft, setChannelsDraft, _providers: (draft || (state ? state.providers : {})) }) :
|
|
1714
|
+
tab === 'roundrobin' ? el(RoundrobinPanel, { channels, channelsDraft, setChannelsDraft, _providers: (draft || (state ? state.providers : {})), _setNotice: setNotice }) :
|
|
1609
1715
|
el(HealthPanel, { health, _providers: (draft || (state ? state.providers : {})) })
|
|
1610
1716
|
)
|
|
1611
1717
|
)
|
package/src/index.js
CHANGED
|
@@ -125,6 +125,10 @@ export function apply(ctx, config) {
|
|
|
125
125
|
timeoutMs: Number.isFinite(rg.timeoutMs) && rg.timeoutMs > 0 ? rg.timeoutMs : 30000,
|
|
126
126
|
cooldownMs: Number.isFinite(rg.cooldownMs) && rg.cooldownMs >= 0 ? rg.cooldownMs : 60000,
|
|
127
127
|
maxRetriesPerCandidate: Number.isSafeInteger(rg.maxRetriesPerCandidate) && rg.maxRetriesPerCandidate >= 0 ? rg.maxRetriesPerCandidate : 2,
|
|
128
|
+
// F01 配套(审计 §5.3):整次请求的总预算。旧实现最坏 2×N×(R+1) 次尝试
|
|
129
|
+
// (默认 R=2 → 6N 次),4 候选组可拖数分钟。默认 10 分钟;超预算
|
|
130
|
+
// 后不再开新尝试,直接 CHANNEL_FULL_FAIL 终结。
|
|
131
|
+
totalBudgetMs: Number.isFinite(rg.totalBudgetMs) && rg.totalBudgetMs > 0 ? rg.totalBudgetMs : 600000,
|
|
128
132
|
speedTest: {
|
|
129
133
|
enabled: st.enabled !== false,
|
|
130
134
|
sortKey: ['ttft', 'latency', 'hybrid', 'smart'].includes(st.sortKey) ? st.sortKey : 'ttft',
|
|
@@ -331,26 +335,56 @@ export function apply(ctx, config) {
|
|
|
331
335
|
}
|
|
332
336
|
};
|
|
333
337
|
// ---------- 引擎:单候选尝试 ----------
|
|
338
|
+
// F01/F02(审计):超时必须能真正结束一次尝试。
|
|
339
|
+
// - 旧行为:Promise.race 超时后 await inner.return(),但 return() 排在挂起的
|
|
340
|
+
// next() 之后——上游挂着时 failover 被拖住(H02:15ms 超时 80ms 后仍未切换)。
|
|
341
|
+
// - 新行为:每次尝试独立 AbortController。pi-ai 适配器把 options.signal 经
|
|
342
|
+
// AbortSignal.any 融进 watchdog 并传给上游 HTTP(signal: watchdog.signal),
|
|
343
|
+
// abort 即真正取消网络请求。超时路径:先 abort → 有界等待(3s)排干 → 放弃
|
|
344
|
+
// 等待继续 failover(放弃的迭代器由适配器的 finally 自行清理)。
|
|
345
|
+
// - 生命周期(F02):成功 return / 失败 / 消费者提前退出统一走 finally 的
|
|
346
|
+
// 有界 closeInner,不再只在 catch 里清理。
|
|
347
|
+
const ATTEMPT_CLOSE_BUDGET_MS = 3000;
|
|
334
348
|
async function* streamAttempt(cfg, cand, options, attemptStart) {
|
|
335
349
|
if (cand.provider.startsWith(ROUTE_PREFIX))
|
|
336
350
|
throw { code: 'INVALID_CANDIDATE', message: 'candidate provider must not be a virtual route' };
|
|
337
351
|
const llm = ctx.llm;
|
|
338
|
-
|
|
352
|
+
// 用户取消信号透传 + 尝试自身的超时中止,二者融合为本次尝试的 signal
|
|
353
|
+
const attemptController = new AbortController();
|
|
354
|
+
const outerSignal = options.signal;
|
|
355
|
+
const relayAbort = () => { try { attemptController.abort(outerSignal.reason); } catch (_e) { } };
|
|
356
|
+
if (outerSignal) {
|
|
357
|
+
if (outerSignal.aborted)
|
|
358
|
+
relayAbort();
|
|
359
|
+
else
|
|
360
|
+
outerSignal.addEventListener('abort', relayAbort, { once: true });
|
|
361
|
+
}
|
|
362
|
+
const inner = llm.stream(Object.assign({}, options, { provider: cand.provider, model: cand.model, signal: attemptController.signal }));
|
|
339
363
|
const timeoutMs = dynamicTimeoutMs(cfg, candKey(cand));
|
|
340
364
|
let buffer = [];
|
|
341
365
|
let started = false;
|
|
342
366
|
let ttftMs = null;
|
|
343
367
|
let lastAt = Date.now();
|
|
344
368
|
let lastUsage = null;
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
if (
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
369
|
+
let closed = false;
|
|
370
|
+
const closeInner = async () => {
|
|
371
|
+
if (closed)
|
|
372
|
+
return;
|
|
373
|
+
closed = true;
|
|
374
|
+
try {
|
|
375
|
+
const c = inner.return ? inner.return() : null;
|
|
376
|
+
if (c && c.then) {
|
|
377
|
+
await Promise.race([
|
|
378
|
+
c,
|
|
379
|
+
ctx.timeout(ATTEMPT_CLOSE_BUDGET_MS).then(() => { attemptController.abort({ code: 'TIMEOUT', message: 'attempt close budget exceeded' }); }, () => { }),
|
|
380
|
+
]);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
catch (_e) { }
|
|
384
|
+
};
|
|
351
385
|
try {
|
|
352
386
|
while (true) {
|
|
353
|
-
if (
|
|
387
|
+
if (outerSignal && outerSignal.aborted)
|
|
354
388
|
throw { code: 'ABORTED', message: 'request aborted' };
|
|
355
389
|
const remaining = Math.max(0, timeoutMs - (Date.now() - (started ? lastAt : attemptStart)));
|
|
356
390
|
if (remaining <= 0)
|
|
@@ -362,6 +396,13 @@ export function apply(ctx, config) {
|
|
|
362
396
|
try {
|
|
363
397
|
next = await Promise.race([inner.next(), guard.promise]);
|
|
364
398
|
}
|
|
399
|
+
catch (raceErr) {
|
|
400
|
+
// 超时(或任何 next 失败):先中止本次尝试的上游请求再上抛——
|
|
401
|
+
// 这是 F01 的核心:abort 信号会穿透 pi-ai 取消真实 HTTP 请求
|
|
402
|
+
if (raceErr && raceErr.code === 'TIMEOUT')
|
|
403
|
+
attemptController.abort(raceErr);
|
|
404
|
+
throw raceErr;
|
|
405
|
+
}
|
|
365
406
|
finally {
|
|
366
407
|
// 超时路径同样要 dispose:ctx.effect 的注册表条目只有显式调用 disposer 才会移除
|
|
367
408
|
guard.dispose();
|
|
@@ -416,33 +457,144 @@ export function apply(ctx, config) {
|
|
|
416
457
|
}
|
|
417
458
|
}
|
|
418
459
|
catch (err) {
|
|
419
|
-
await closeInner();
|
|
420
460
|
if (started && err && typeof err === 'object')
|
|
421
461
|
err.emitted = true;
|
|
422
462
|
if (err && err.code === 'TIMEOUT')
|
|
423
463
|
recordHealth(cand.provider, cand, { ok: false, ttftMs, latencyMs: null, code: 'TIMEOUT', usage: lastUsage });
|
|
424
464
|
throw err;
|
|
425
465
|
}
|
|
466
|
+
finally {
|
|
467
|
+
// F02:所有出口(成功 return / 失败 / 消费者提前 return)统一有界关闭;
|
|
468
|
+
// relayAbort 监听器随本次尝试终结移除(防泄漏:outerSignal 可能长命)
|
|
469
|
+
if (outerSignal)
|
|
470
|
+
outerSignal.removeEventListener('abort', relayAbort);
|
|
471
|
+
await closeInner();
|
|
472
|
+
}
|
|
426
473
|
}
|
|
474
|
+
// ---------- F08 最小切片:候选能力过滤(防「成功路径改变用户输入」) ----------
|
|
475
|
+
// 审计 H14:虚拟模型声明 image,候选只支持 text——宿主把图片静默替换为
|
|
476
|
+
// "[image omitted...]" 后仍返回 stop 成功(用户输入被改变却报成功)。
|
|
477
|
+
// 审计 H06:非推理候选被强塞 effort → dispatch 前被拒 → CHANNEL_FULL_FAIL。
|
|
478
|
+
// 策略:请求含图/带 effort 时,过滤「明确声明不支持」的候选;能力未知
|
|
479
|
+
// (inputModalities 缺失 = 未知,只有显式排除才是负能力——宿主契约)不过滤,
|
|
480
|
+
// 保持原 failover 行为。解析失败不阻塞(同样视作未知)。
|
|
481
|
+
// 缓存 60s TTL:pi-ai 的 resolveModel 是内存快照解析(已核安装版源码),
|
|
482
|
+
// 但 async 接口 + 每次请求都调不划算;能力基本静态,TTL 足够。
|
|
483
|
+
const capabilityCache = new Map(); // key: provider::model → { at, info }
|
|
484
|
+
const CAPABILITY_TTL_MS = 60000;
|
|
485
|
+
const resolveCapability = async (cand) => {
|
|
486
|
+
const key = candKey(cand);
|
|
487
|
+
const hit = capabilityCache.get(key);
|
|
488
|
+
if (hit && Date.now() - hit.at < CAPABILITY_TTL_MS)
|
|
489
|
+
return hit.info;
|
|
490
|
+
try {
|
|
491
|
+
const info = await ctx.llm.resolveModelInfo(cand.provider, cand.model);
|
|
492
|
+
const entry = { at: Date.now(), info };
|
|
493
|
+
capabilityCache.set(key, entry);
|
|
494
|
+
return info;
|
|
495
|
+
}
|
|
496
|
+
catch (_e) {
|
|
497
|
+
return null; // 解析失败 = 未知,不过滤
|
|
498
|
+
}
|
|
499
|
+
};
|
|
500
|
+
/** 请求是否包含图片输入(粗判:消息 content 里有 image 块)。 */
|
|
501
|
+
const requestHasImage = (options) => {
|
|
502
|
+
try {
|
|
503
|
+
return (options.messages || []).some((m) => Array.isArray(m === null || m === void 0 ? void 0 : m.content) && m.content.some((p) => p && p.type === 'image'));
|
|
504
|
+
}
|
|
505
|
+
catch (_e) {
|
|
506
|
+
return false;
|
|
507
|
+
}
|
|
508
|
+
};
|
|
509
|
+
/** 过滤掉明确不支持本次请求能力的候选;全部被滤或未知时退回原列表(不因过滤而失去候选)。 */
|
|
510
|
+
const filterByCapability = async (cands, options) => {
|
|
511
|
+
const needImage = requestHasImage(options);
|
|
512
|
+
const needReasoning = typeof options.reasoningEffort === 'string' && options.reasoningEffort.length > 0 && options.reasoningEffort !== 'off';
|
|
513
|
+
if (!needImage && !needReasoning)
|
|
514
|
+
return cands;
|
|
515
|
+
const skipped = [];
|
|
516
|
+
const kept = [];
|
|
517
|
+
for (const cand of cands) {
|
|
518
|
+
const info = await resolveCapability(cand);
|
|
519
|
+
if (info === null) {
|
|
520
|
+
kept.push(cand); // 未知:不过滤
|
|
521
|
+
continue;
|
|
522
|
+
}
|
|
523
|
+
if (needImage) {
|
|
524
|
+
const mods = info.inputModalities;
|
|
525
|
+
if (Array.isArray(mods) && mods.length > 0 && !mods.includes('image')) {
|
|
526
|
+
skipped.push({ candidate: candKey(cand), reason: 'image input not supported' });
|
|
527
|
+
continue; // 明确排除 image
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
if (needReasoning && info.reasoning === undefined) {
|
|
531
|
+
skipped.push({ candidate: candKey(cand), reason: 'reasoning not supported' });
|
|
532
|
+
continue; // 明确无 reasoning 档位
|
|
533
|
+
}
|
|
534
|
+
kept.push(cand);
|
|
535
|
+
}
|
|
536
|
+
// 全被滤掉 = 声明与所有候选冲突:退回原列表(让请求去撞,报真实错误)
|
|
537
|
+
// 而不是直接 NO_CANDIDATES 假装没有渠道
|
|
538
|
+
if (skipped.length > 0 && kept.length > 0) {
|
|
539
|
+
// 记录到组事件(cfg 此时未知,用 provider 维度的 console 代替;组事件在
|
|
540
|
+
// streamGroup 拿到 cfg 后会补记 capability-filter)
|
|
541
|
+
console.log('[model-channel-manager] capability filter:', skipped.map((s) => s.candidate + ' (' + s.reason + ')').join(', '));
|
|
542
|
+
}
|
|
543
|
+
return kept.length > 0 ? kept : cands;
|
|
544
|
+
};
|
|
545
|
+
|
|
427
546
|
// ---------- 引擎:组级故障转移循环 ----------
|
|
428
547
|
async function* streamGroup(cfg, options) {
|
|
429
548
|
const rt = groupRuntime(cfg.id);
|
|
430
|
-
|
|
549
|
+
let order = orderedCandidates(cfg);
|
|
431
550
|
if (order.length === 0) {
|
|
432
551
|
pushEvent(cfg, 'no-candidates', {});
|
|
433
552
|
yield failChunk('channel group "' + cfg.id + '" has no candidates', 'NO_CANDIDATES');
|
|
434
553
|
return;
|
|
435
554
|
}
|
|
555
|
+
// F14(审计 H07):enabled=true 但从未测过速的组,首次真实使用时后台触发一次
|
|
556
|
+
// 自动测速(不阻塞、不 await 当前请求)。旧实现只有 boot 时的 onFirstUse 分支,
|
|
557
|
+
// 常规请求路径完全没有自动测速入口——开启开关后从未生效。
|
|
558
|
+
if (cfg.speedTest.enabled && (state.speedResults[cfg.id] || []).length === 0 && !rt.speedTestRunning) {
|
|
559
|
+
rt.speedTestRunning = true; // 先占位防重入(runSpeedTest 内部还会再查)
|
|
560
|
+
rt.speedTestRunning = false;
|
|
561
|
+
runSpeedTest(cfg).catch(() => { });
|
|
562
|
+
}
|
|
563
|
+
// F08:按本次请求能力过滤(含图/带 effort 时)。过滤发生在测速排序之后,
|
|
564
|
+
// 保序不改变相对优先级。
|
|
565
|
+
const before = order.length;
|
|
566
|
+
order = await filterByCapability(order, options);
|
|
567
|
+
if (order.length < before)
|
|
568
|
+
pushEvent(cfg, 'capability-filter', { skipped: before - order.length, kept: order.length });
|
|
436
569
|
const strategy = cfg.strategy || 'sticky';
|
|
437
|
-
|
|
570
|
+
// F13(审计 H13):round-robin 的指针改为「选择时原子预留」——旧实现成功后才
|
|
571
|
+
// 推进 rt.currentIndex,两个并发请求都从 first 开始(并发不均分)。
|
|
572
|
+
// 现在:选定时立即占位推进(同步读改写,JS 单线程内原子),失败不回滚
|
|
573
|
+
// (失败反馈走冷却,指针保持前进语义——轮询分布比严格顺序更重要)。
|
|
574
|
+
if (strategy === 'round-robin') {
|
|
575
|
+
cursor = rt.currentIndex % order.length;
|
|
576
|
+
rt.currentIndex = (cursor + 1) % order.length; // 本次请求已占用 cursor 槽位
|
|
577
|
+
}
|
|
578
|
+
else {
|
|
579
|
+
cursor = strategy === 'primary' ? 0 : rt.currentIndex % order.length;
|
|
580
|
+
}
|
|
438
581
|
let fullRounds = 0;
|
|
439
582
|
let lastFail = null;
|
|
440
583
|
const failedKeys = [];
|
|
584
|
+
// 总请求预算(F01 配套):组级起点。超预算不再开新尝试。
|
|
585
|
+
const groupStart = Date.now();
|
|
586
|
+
const budgetMs = cfg.totalBudgetMs || 600000;
|
|
587
|
+
const budgetLeft = () => budgetMs - (Date.now() - groupStart);
|
|
441
588
|
while (true) {
|
|
442
589
|
if (options.signal && options.signal.aborted) {
|
|
443
590
|
yield abortedChunk('request aborted');
|
|
444
591
|
return;
|
|
445
592
|
}
|
|
593
|
+
if (budgetLeft() <= 0) {
|
|
594
|
+
pushEvent(cfg, 'budget-exhausted', { budgetMs, failures: failedKeys.slice() });
|
|
595
|
+
yield failChunk('channel group "' + cfg.id + '" exhausted its total budget of ' + budgetMs + 'ms: ' + (lastFail || 'multiple failures'), 'CHANNEL_BUDGET_EXCEEDED');
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
446
598
|
const round = [];
|
|
447
599
|
for (let i = 0; i < order.length; i++)
|
|
448
600
|
round.push(order[(cursor + i) % order.length]);
|
|
@@ -465,6 +617,8 @@ export function apply(ctx, config) {
|
|
|
465
617
|
yield abortedChunk('request aborted');
|
|
466
618
|
return;
|
|
467
619
|
}
|
|
620
|
+
if (budgetLeft() <= 0)
|
|
621
|
+
break; // 预算耗尽:不开新尝试,交给组层终结
|
|
468
622
|
const attemptStart = Date.now();
|
|
469
623
|
try {
|
|
470
624
|
const attempt = streamAttempt(cfg, cand, options, attemptStart);
|
|
@@ -473,12 +627,12 @@ export function apply(ctx, config) {
|
|
|
473
627
|
succeeded = true;
|
|
474
628
|
candidateOk = true;
|
|
475
629
|
rt.cooldowns.delete(key);
|
|
630
|
+
// F13:round-robin 的推进已移到选定时(见上方原子预留);
|
|
631
|
+
// 成功路径只维护 sticky/primary 的语义。
|
|
476
632
|
const pos = order.findIndex((c) => candKey(c) === key);
|
|
477
|
-
if (strategy === '
|
|
478
|
-
rt.currentIndex = (pos + 1) % order.length;
|
|
479
|
-
else if (strategy === 'sticky')
|
|
633
|
+
if (strategy === 'sticky')
|
|
480
634
|
rt.currentIndex = pos;
|
|
481
|
-
else
|
|
635
|
+
else if (strategy === 'primary')
|
|
482
636
|
rt.currentIndex = 0;
|
|
483
637
|
if (failedKeys.length > 0)
|
|
484
638
|
pushEvent(cfg, 'failover', { from: failedKeys[failedKeys.length - 1], to: key, reason: 'candidate change after ' + failedKeys.length + ' failure(s)' });
|
|
@@ -532,24 +686,47 @@ export function apply(ctx, config) {
|
|
|
532
686
|
}
|
|
533
687
|
}
|
|
534
688
|
// ---------- 测速 ----------
|
|
689
|
+
// F15 修复:旧行为每 chunk 重置 guard(单次等待上限而非总时限,H12:timeoutMs=40
|
|
690
|
+
// 流每 20ms 输出,84ms 后仍成功)+ 超时不中止上游。现在:总时限 deadline +
|
|
691
|
+
// 超时 abort 上游(与 streamAttempt 同一模式)+ 统一 finally 关闭。
|
|
535
692
|
async function measureCandidate(cfg, cand, st) {
|
|
536
693
|
const llm = ctx.llm;
|
|
537
|
-
const
|
|
694
|
+
const controller = new AbortController();
|
|
695
|
+
const deadline = Date.now() + (st.timeoutMs || 60000);
|
|
696
|
+
const inner = llm.stream({ provider: cand.provider, model: cand.model, messages: [{ role: 'user', content: [{ type: 'text', text: st.prompt }] }], maxTokens: st.maxTokens, sessionId: 'mcm-speedtest-' + (st.nonce ?? Date.now()), signal: controller.signal });
|
|
538
697
|
const start = Date.now();
|
|
539
698
|
let ttft = null;
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
if (
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
699
|
+
let closed = false;
|
|
700
|
+
const closeInner = async () => {
|
|
701
|
+
if (closed)
|
|
702
|
+
return;
|
|
703
|
+
closed = true;
|
|
704
|
+
try {
|
|
705
|
+
const c = inner.return ? inner.return() : null;
|
|
706
|
+
if (c && c.then) {
|
|
707
|
+
await Promise.race([
|
|
708
|
+
c,
|
|
709
|
+
ctx.timeout(ATTEMPT_CLOSE_BUDGET_MS).then(() => { try { controller.abort({ code: 'TIMEOUT', message: 'speedtest close budget exceeded' }); } catch (_e) { } }, () => { }),
|
|
710
|
+
]);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
catch (_e) { }
|
|
714
|
+
};
|
|
546
715
|
try {
|
|
547
716
|
while (true) {
|
|
548
|
-
const
|
|
717
|
+
const remaining = deadline - Date.now();
|
|
718
|
+
if (remaining <= 0)
|
|
719
|
+
throw { code: 'TIMEOUT', message: 'speedtest timed out after ' + st.timeoutMs + 'ms (total)' };
|
|
720
|
+
const guard = timed(Math.max(1, remaining), () => ({ code: 'TIMEOUT', message: 'speedtest timed out after ' + st.timeoutMs + 'ms (total)' }));
|
|
549
721
|
let next;
|
|
550
722
|
try {
|
|
551
723
|
next = await Promise.race([inner.next(), guard.promise]);
|
|
552
724
|
}
|
|
725
|
+
catch (raceErr) {
|
|
726
|
+
if (raceErr && raceErr.code === 'TIMEOUT')
|
|
727
|
+
try { controller.abort(raceErr); } catch (_e) { }
|
|
728
|
+
throw raceErr;
|
|
729
|
+
}
|
|
553
730
|
finally {
|
|
554
731
|
guard.dispose();
|
|
555
732
|
}
|
|
@@ -567,9 +744,11 @@ export function apply(ctx, config) {
|
|
|
567
744
|
}
|
|
568
745
|
}
|
|
569
746
|
catch (err) {
|
|
570
|
-
await closeInner();
|
|
571
747
|
throw { code: (err && err.code) || 'STREAM_ERROR', message: (err && err.message) || 'speedtest failed' };
|
|
572
748
|
}
|
|
749
|
+
finally {
|
|
750
|
+
await closeInner();
|
|
751
|
+
}
|
|
573
752
|
}
|
|
574
753
|
async function runSpeedTest(cfg) {
|
|
575
754
|
const rt = groupRuntime(cfg.id);
|
|
@@ -687,25 +866,48 @@ export function apply(ctx, config) {
|
|
|
687
866
|
// ---------- 单模型真实请求测试(client 经 settings 总线下发,走 DSH 真实 llm.stream 链路) ----------
|
|
688
867
|
async function runModelTest(provider, model, prompt, maxTokens) {
|
|
689
868
|
const llm = ctx.llm;
|
|
690
|
-
|
|
869
|
+
// F15(与 measureCandidate 同一模式):总时限 60s(旧行为每 chunk 重置 guard,
|
|
870
|
+
// 持续输出的请求永不超时而 client 已放弃)+ 超时 abort 上游 + 统一 finally 关闭。
|
|
871
|
+
const MODEL_TEST_TOTAL_MS = 60000;
|
|
872
|
+
const controller = new AbortController();
|
|
873
|
+
const deadline = Date.now() + MODEL_TEST_TOTAL_MS;
|
|
874
|
+
const inner = llm.stream({ provider, model, messages: [{ role: 'user', content: [{ type: 'text', text: prompt || '你好' }] }], maxTokens: maxTokens || 512, sessionId: 'mcm-modeltest-' + Date.now(), signal: controller.signal });
|
|
691
875
|
const start = Date.now();
|
|
692
876
|
let ttft = null;
|
|
693
877
|
let text = '';
|
|
694
878
|
let reasoning = '';
|
|
695
879
|
let lastUsage = null;
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
if (
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
880
|
+
let closed = false;
|
|
881
|
+
const closeInner = async () => {
|
|
882
|
+
if (closed)
|
|
883
|
+
return;
|
|
884
|
+
closed = true;
|
|
885
|
+
try {
|
|
886
|
+
const c = inner.return ? inner.return() : null;
|
|
887
|
+
if (c && c.then) {
|
|
888
|
+
await Promise.race([
|
|
889
|
+
c,
|
|
890
|
+
ctx.timeout(ATTEMPT_CLOSE_BUDGET_MS).then(() => { try { controller.abort({ code: 'TIMEOUT', message: 'model test close budget exceeded' }); } catch (_e) { } }, () => { }),
|
|
891
|
+
]);
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
catch (_e) { }
|
|
895
|
+
};
|
|
702
896
|
try {
|
|
703
897
|
while (true) {
|
|
704
|
-
const
|
|
898
|
+
const remaining = deadline - Date.now();
|
|
899
|
+
if (remaining <= 0)
|
|
900
|
+
throw { code: 'TIMEOUT', message: 'model test timed out after ' + MODEL_TEST_TOTAL_MS + 'ms (total)' };
|
|
901
|
+
const guard = timed(Math.max(1, remaining), () => ({ code: 'TIMEOUT', message: 'model test timed out after ' + MODEL_TEST_TOTAL_MS + 'ms (total)' }));
|
|
705
902
|
let next;
|
|
706
903
|
try {
|
|
707
904
|
next = await Promise.race([inner.next(), guard.promise]);
|
|
708
905
|
}
|
|
906
|
+
catch (raceErr) {
|
|
907
|
+
if (raceErr && raceErr.code === 'TIMEOUT')
|
|
908
|
+
try { controller.abort(raceErr); } catch (_e) { }
|
|
909
|
+
throw raceErr;
|
|
910
|
+
}
|
|
709
911
|
finally {
|
|
710
912
|
guard.dispose();
|
|
711
913
|
}
|
|
@@ -736,28 +938,35 @@ export function apply(ctx, config) {
|
|
|
736
938
|
}
|
|
737
939
|
}
|
|
738
940
|
catch (err) {
|
|
739
|
-
await closeInner();
|
|
740
941
|
throw err;
|
|
741
942
|
}
|
|
943
|
+
finally {
|
|
944
|
+
await closeInner();
|
|
945
|
+
}
|
|
742
946
|
}
|
|
743
947
|
function handleTestRequest(next) {
|
|
744
948
|
const req = next.testRequest;
|
|
745
|
-
|
|
746
|
-
|
|
949
|
+
// nonce 统一为字符串 UUID(0.3.2 起 client 换 randomUUID,防同毫秒碰撞);
|
|
950
|
+
// 旧数字 nonce(Date.now()%1e9)仍被接受:typeof 兼容两种,比较用 !==。
|
|
951
|
+
const nonceOf = (v) => (typeof v === 'number' || typeof v === 'string') ? v : null;
|
|
952
|
+
const reqNonce = nonceOf(req && req.nonce);
|
|
953
|
+
const lastNum = typeof next.lastTestHandledNonce === 'number' ? next.lastTestHandledNonce : null;
|
|
954
|
+
const last = lastNum !== null ? Math.max(lastNum, typeof claimedTestNonce === 'number' ? claimedTestNonce : 0) : (claimedTestNonce || null);
|
|
955
|
+
if (!req || typeof req.provider !== 'string' || typeof req.model !== 'string' || reqNonce === null || reqNonce === last)
|
|
747
956
|
return;
|
|
748
|
-
claimedTestNonce =
|
|
957
|
+
claimedTestNonce = reqNonce;
|
|
749
958
|
// nonce 的落盘推迟到本次测试得出结论之后:只有「真跑过」才算 handled。
|
|
750
959
|
// 启动早于凭据服务就绪时会以 MISSING_CREDENTIAL 失败,那要释放认领重试;
|
|
751
960
|
// 若提前写了 nonce,重试会被自己的持久值挡掉。
|
|
752
961
|
const settle = (entry) => {
|
|
753
962
|
if (bus !== null)
|
|
754
|
-
bus.writeHealth({ lastTestHandledNonce:
|
|
963
|
+
bus.writeHealth({ lastTestHandledNonce: reqNonce }).catch((e) => console.error('[model-channel-manager] lastTestHandledNonce 写入失败:', e));
|
|
755
964
|
return setResult(entry);
|
|
756
965
|
};
|
|
757
966
|
// 0.2 的配置写入是「整字段落盘」,不再有 path-ops;结果集按当前值合并后整段写回。
|
|
758
967
|
const setResult = async (entry) => {
|
|
759
|
-
const key = String(
|
|
760
|
-
const value = Object.assign({}, entry, { nonce:
|
|
968
|
+
const key = String(reqNonce);
|
|
969
|
+
const value = Object.assign({}, entry, { nonce: reqNonce, provider: req.provider, model: req.model });
|
|
761
970
|
const cur = healthOf().testResults || {};
|
|
762
971
|
if (bus !== null)
|
|
763
972
|
await bus.writeHealth({ testResults: Object.assign({}, cur, { [key]: value }) }).catch((e) => console.error('[model-channel-manager] testResults 写入失败:', e));
|
|
@@ -786,7 +995,7 @@ export function apply(ctx, config) {
|
|
|
786
995
|
}).catch(async (e) => {
|
|
787
996
|
if (notReady(e)) {
|
|
788
997
|
// 凭据服务尚未就绪(典型是启动瞬间的重放):不写成假 error,释放认领并延后重试
|
|
789
|
-
claimedTestNonce =
|
|
998
|
+
claimedTestNonce = null;
|
|
790
999
|
scheduleReplay('test', () => reloadFromConfig(false));
|
|
791
1000
|
return;
|
|
792
1001
|
}
|
|
@@ -795,7 +1004,20 @@ export function apply(ctx, config) {
|
|
|
795
1004
|
}
|
|
796
1005
|
function rewireRoutes() {
|
|
797
1006
|
const llm = ctx.llm;
|
|
798
|
-
|
|
1007
|
+
// F16(审计 H08):normalizeConfig 对非法/重复 ID 是静默 continue——保存 3 组
|
|
1008
|
+
// 实际只注册 1 条路由,界面仍显示「已保存」。这里把被丢弃的组大声报出来:
|
|
1009
|
+
// 配置里有 N 组但只有 M 组成为路由时,用户能从日志看到哪组、为什么被丢。
|
|
1010
|
+
const rawGroups = Array.isArray(state.config && state.config.groups) ? state.config.groups : [];
|
|
1011
|
+
const normalized = pullConfig().groups;
|
|
1012
|
+
if (rawGroups.length > normalized.length) {
|
|
1013
|
+
const keptIds = new Set(normalized.map((g) => g.id));
|
|
1014
|
+
const dropped = rawGroups
|
|
1015
|
+
.map((g) => g && g.id)
|
|
1016
|
+
.filter((id) => typeof id === 'string' && !keptIds.has(id));
|
|
1017
|
+
if (dropped.length > 0)
|
|
1018
|
+
console.warn('[model-channel-manager] 以下轮询组未通过校验,已被丢弃(不会成为路由):', dropped.join(', '), '——组 ID 规则:小写字母/数字/连字符,且不重复');
|
|
1019
|
+
}
|
|
1020
|
+
const next = normalized.map((g) => routeOfGroup(g.id));
|
|
799
1021
|
const current = llm.listProviders().map((p) => p.id).filter((id) => id.startsWith(ROUTE_PREFIX));
|
|
800
1022
|
if (next.length === 0) {
|
|
801
1023
|
// 空配置:LLM 服务拒绝注册零 provider 的适配器——推迟首次注册;已注册则清空路由
|
|
@@ -896,8 +1118,8 @@ export function apply(ctx, config) {
|
|
|
896
1118
|
let runtimeRestored = false;
|
|
897
1119
|
// 非持久去重:回写要等当前 HMR 事务结束才落盘,这中间 volatile-update 可能带着同一个 nonce 再来,
|
|
898
1120
|
// 内存里先认领,避免同一请求被重复执行(并顺带消掉重复的回写风暴)。
|
|
899
|
-
let claimedTestNonce =
|
|
900
|
-
let claimedSpeedNonce =
|
|
1121
|
+
let claimedTestNonce = null; // string UUID 或旧数字;null = 未认领
|
|
1122
|
+
let claimedSpeedNonce = null; // string UUID 或旧数字;null = 未认领
|
|
901
1123
|
// 启动早于凭据服务就绪:此时重放测试/测速会以 MISSING_CREDENTIAL 失败。
|
|
902
1124
|
// 这类错误是「环境还没准备好」而不是「渠道故障」,必须释放认领、延后重试,不能写成假 error。
|
|
903
1125
|
const notReady = (e) => {
|
|
@@ -941,20 +1163,23 @@ export function apply(ctx, config) {
|
|
|
941
1163
|
runtime.delete(id);
|
|
942
1164
|
rewireRoutes();
|
|
943
1165
|
const req = health.speedRequest;
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
1166
|
+
// nonce 兼容字符串 UUID / 旧数字(与 handleTestRequest 同一策略)
|
|
1167
|
+
const speedNonce = (typeof (req && req.nonce) === 'number' || typeof (req && req.nonce) === 'string') ? req.nonce : null;
|
|
1168
|
+
const lastNum = typeof health.lastHandledNonce === 'number' ? health.lastHandledNonce : null;
|
|
1169
|
+
const last = lastNum !== null ? Math.max(lastNum, typeof claimedSpeedNonce === 'number' ? claimedSpeedNonce : 0) : (claimedSpeedNonce || null);
|
|
1170
|
+
if (req && typeof req.group === 'string' && speedNonce !== null && speedNonce !== last) {
|
|
1171
|
+
claimedSpeedNonce = speedNonce;
|
|
947
1172
|
const cfgRow = pullConfig().groups.find((g) => g.id === req.group);
|
|
948
1173
|
// nonce 落盘推迟到本次测速得出结论之后(同 handleTestRequest 的理由):
|
|
949
1174
|
// 提前写会把「没就绪」的重试用自己的持久值挡掉。
|
|
950
1175
|
const settleSpeed = () => {
|
|
951
1176
|
if (bus !== null)
|
|
952
|
-
bus.writeHealth({ lastHandledNonce:
|
|
1177
|
+
bus.writeHealth({ lastHandledNonce: speedNonce }).catch(() => { });
|
|
953
1178
|
};
|
|
954
1179
|
if (cfgRow)
|
|
955
1180
|
runSpeedTest(cfgRow).then((r) => {
|
|
956
1181
|
if (r && r.deferred) {
|
|
957
|
-
claimedSpeedNonce =
|
|
1182
|
+
claimedSpeedNonce = null;
|
|
958
1183
|
scheduleReplay('speed', () => reloadFromConfig(false));
|
|
959
1184
|
return;
|
|
960
1185
|
}
|
|
@@ -962,7 +1187,7 @@ export function apply(ctx, config) {
|
|
|
962
1187
|
settleSpeed();
|
|
963
1188
|
}).catch((e) => {
|
|
964
1189
|
if (notReady(e)) {
|
|
965
|
-
claimedSpeedNonce =
|
|
1190
|
+
claimedSpeedNonce = null;
|
|
966
1191
|
scheduleReplay('speed', () => reloadFromConfig(false));
|
|
967
1192
|
return;
|
|
968
1193
|
}
|
|
@@ -1158,6 +1383,13 @@ export function apply(ctx, config) {
|
|
|
1158
1383
|
const text = await fsSvc.readText(target);
|
|
1159
1384
|
const legacy = JSON.parse(text);
|
|
1160
1385
|
const migrated = normalizeConfig(legacy);
|
|
1386
|
+
// F10(审计 H15):读取是 await 的——这个窗口里用户可能已经保存了
|
|
1387
|
+
// 新组。写前重检:现在有组了就不迁(用户的新配置优先于旧文件复活)。
|
|
1388
|
+
if (pullConfig().groups.length !== 0) {
|
|
1389
|
+
console.log('[model-channel-manager] legacy migration skipped: user config arrived during read');
|
|
1390
|
+
bus.writeHealth({ legacyMigrated: true }).catch(() => { });
|
|
1391
|
+
return;
|
|
1392
|
+
}
|
|
1161
1393
|
if (migrated.groups.length > 0) {
|
|
1162
1394
|
await bus.settings.update(SELF_NS, { groups: migrated.groups });
|
|
1163
1395
|
state.config = { groups: migrated.groups };
|