@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 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 三策略;首响应超时 + 流中空闲超时(动态 = max(timeoutMs, min(120s, ttft×2)));单候选原地重试(指数退避)耗尽才换;全炸清冷却重试一轮;测速 ttft/latency/hybrid/smart 四键(smart = 0.5×ttft_norm + 0.3×(1−reliability) + 0.2×latency_norm,reliability 贝叶斯平滑 `(success+2.5)/(total+5)`);测速失败进冷却;请求隔离按组
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.1",
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
- if (ns === 'model-channels') return wrap(await remote.settings.update(NS_MCM, patch, undefined))
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) }, undefined))
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, undefined))
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 }, undefined))
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 }, undefined))
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
- const nonce = Date.now() % 1000000000
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(idx); try { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', String(idx)) } catch (_e) {} },
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
- const nonce = Date.now() % 1000000000
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(() => setSpeedState((s) => Object.assign({}, s, { [gid]: 'err' })))
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 cands = (g.candidates || []).map((c) => (c && c.provider === oldId) ? Object.assign({}, c, { provider: newId }) : c)
1375
- return Object.assign({}, g, { candidates: cands })
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
- setState({ providers, userProviders, providerOrder: savedOrder || [] })
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
- }).catch((e) => setNotice('加载失败: ' + String(e)))
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
- pObj.api = pVal.api || 'openai-completions'
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
- return apiRef.settings.mutate({ ns: 'llm-pi-ai', ops }).then((resp) => {
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) throw new Error((r.error && (r.error.message || r.error)) || 'llm-pi-ai save failed')
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) throw new Error((r.error && (r.error.message || r.error)) || 'model-channels save failed')
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
- Promise.all([p1, p2]).then(() => {
1584
- setNotice('已全部保存(即时生效)')
1585
- // 同步草稿与状态为刚保存的清洗版本(含默认请求头合并结果)
1586
- setDraft(clone(cleanProviders))
1587
- setState((s) => Object.assign({}, s, { providers: cleanProviders, userProviders: cleanProviders, providerOrder: Object.keys(cleanProviders) }))
1588
- setTimeout(() => setNotice(null), 3000)
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
- }).catch((e) => setNotice('保存失败: ' + String((e && e.message) || e))).finally(() => setSaving(false))
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
- btn(saving ? '保存中…' : '保存全部变更', save, 'primary')
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
- const inner = llm.stream(Object.assign({}, options, { provider: cand.provider, model: cand.model }));
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
- const closeInner = async () => { try {
346
- const c = inner.return ? inner.return() : null;
347
- if (c && c.then)
348
- await c;
349
- }
350
- catch (_e) { } };
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 (options.signal && options.signal.aborted)
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
- const order = orderedCandidates(cfg);
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
- let cursor = strategy === 'primary' ? 0 : rt.currentIndex % order.length;
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 === 'round-robin')
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 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()) });
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
- const closeInner = async () => { try {
541
- const c = inner.return ? inner.return() : null;
542
- if (c && c.then)
543
- await c;
544
- }
545
- catch (_e) { } };
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 guard = timed(Math.max(1, st.timeoutMs), () => ({ code: 'TIMEOUT', message: 'speedtest timed out after ' + st.timeoutMs + 'ms' }));
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
- const inner = llm.stream({ provider, model, messages: [{ role: 'user', content: [{ type: 'text', text: prompt || '你好' }] }], maxTokens: maxTokens || 512, sessionId: 'mcm-modeltest-' + Date.now() });
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
- const closeInner = async () => { try {
697
- const c = inner.return ? inner.return() : null;
698
- if (c && c.then)
699
- await c;
700
- }
701
- catch (_e) { } };
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 guard = timed(60000, () => ({ code: 'TIMEOUT', message: 'model test timed out after 60000ms' }));
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
- const last = Math.max(next.lastTestHandledNonce || 0, claimedTestNonce);
746
- if (!req || typeof req.provider !== 'string' || typeof req.model !== 'string' || typeof req.nonce !== 'number' || req.nonce === last)
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 = req.nonce;
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: req.nonce }).catch((e) => console.error('[model-channel-manager] lastTestHandledNonce 写入失败:', e));
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(req.nonce);
760
- const value = Object.assign({}, entry, { nonce: req.nonce, provider: req.provider, model: req.model });
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 = 0;
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
- const next = pullConfig().groups.map((g) => routeOfGroup(g.id));
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 = 0;
900
- let claimedSpeedNonce = 0;
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
- const last = Math.max(health.lastHandledNonce || 0, claimedSpeedNonce);
945
- if (req && typeof req.group === 'string' && typeof req.nonce === 'number' && req.nonce !== last) {
946
- claimedSpeedNonce = req.nonce;
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: req.nonce }).catch(() => { });
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 = 0;
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 = 0;
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 };