@arcaneorion/dsh-model-channel-manager 0.3.1 → 0.3.12

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/src/index.js CHANGED
@@ -18,10 +18,14 @@ const HEALTH_SCHEMA = z.object({
18
18
  speedResults: z.dict(z.array(z.any())).default({}),
19
19
  runtime: z.dict(z.any()).default({}),
20
20
  speedRequest: z.any(),
21
- lastHandledNonce: z.number().default(0),
21
+ // nonce 必须容忍 number 与 string 两种历史形态:0.3.2 的 client 写过 UUID 字符串,
22
+ // 而 schema 当时声明 z.number() → 字符串一旦落盘,**整个 health 子树校验失败**,
23
+ // 被 .loose(true) 兜底替换成默认值:records/digest 在运行时全部消失、迁移空转。
24
+ // (0.3.11 线上事故根因;回归见 tests/health-schema-tolerance.test.cjs)
25
+ lastHandledNonce: z.union([z.number(), z.string()]).default(0),
22
26
  testRequest: z.any(),
23
27
  testResults: z.dict(z.any()).default({}),
24
- lastTestHandledNonce: z.number().default(0),
28
+ lastTestHandledNonce: z.union([z.number(), z.string()]).default(0),
25
29
  legacyMigrated: z.any(),
26
30
  healthMigrated: z.any(),
27
31
  // 小投影:host 聚合好的健康摘要(client 渲染用),digestAt 为生成时刻
@@ -34,6 +38,30 @@ export const Config = z.object({
34
38
  effortMemory: z.dict(z.string()).default({}).volatile(),
35
39
  health: HEALTH_SCHEMA.volatile(),
36
40
  }).loose(true);
41
+ // ---------- 任务请求的 consume-once 判定(纯函数,便于回归测试) ----------
42
+ // nonce 规范化为字符串键:number(现行 client 的随机 53-bit 整数)与 string(0.3.2–0.3.8 的 UUID)
43
+ // 都能比较;非法形态返回 null。
44
+ export const nonceKey = (v) => (typeof v === 'number' || typeof v === 'string') ? String(v) : null;
45
+ // schema 默认值 0 表示「从未处理过」,不能当一个真实 nonce 用
46
+ export const settledNonceKey = (v) => {
47
+ const k = nonceKey(v);
48
+ return (k === null || k === '0') ? null : k;
49
+ };
50
+ /**
51
+ * 是否应当消费这个 nonce(唯一入口,测试与测速共用)。
52
+ * 关键点:只用「严格等值 + 已消费集合」,不做任何数值大小推断——
53
+ * 旧实现用 Math.max(…) 比较数值大小来判等,而 nonce 是随机 53-bit,
54
+ * 「上一轮 > 本轮」时(≈50%)guard 失效,同一请求会在每次 volatile-update
55
+ * 被重新消费(重复上游请求 + 结果条目互相污染)。
56
+ * hasTerminal 为真(finishedAt / status ok|error)时也视为已消费:结果已落地,
57
+ * 重放只会得到重复计费与覆盖。
58
+ */
59
+ export function shouldConsumeNonce({ consumed, key, claimed, settled, hasTerminal }) {
60
+ if (key === null || key === undefined) return false;
61
+ if (hasTerminal) return false;
62
+ if (key === claimed || key === settled) return false;
63
+ return !consumed.has(key);
64
+ }
37
65
  export function apply(ctx, config) {
38
66
  // 本行在 profile 中的 entry id 就是 settings 命名空间;缺失时回落到包名。
39
67
  const SELF_NS = (ctx.fiber && ctx.fiber.entry && ctx.fiber.entry.options && ctx.fiber.entry.options.id) || 'model-channel-manager';
@@ -125,6 +153,10 @@ export function apply(ctx, config) {
125
153
  timeoutMs: Number.isFinite(rg.timeoutMs) && rg.timeoutMs > 0 ? rg.timeoutMs : 30000,
126
154
  cooldownMs: Number.isFinite(rg.cooldownMs) && rg.cooldownMs >= 0 ? rg.cooldownMs : 60000,
127
155
  maxRetriesPerCandidate: Number.isSafeInteger(rg.maxRetriesPerCandidate) && rg.maxRetriesPerCandidate >= 0 ? rg.maxRetriesPerCandidate : 2,
156
+ // F01 配套(审计 §5.3):整次请求的总预算。旧实现最坏 2×N×(R+1) 次尝试
157
+ // (默认 R=2 → 6N 次),4 候选组可拖数分钟。默认 10 分钟;超预算
158
+ // 后不再开新尝试,直接 CHANNEL_FULL_FAIL 终结。
159
+ totalBudgetMs: Number.isFinite(rg.totalBudgetMs) && rg.totalBudgetMs > 0 ? rg.totalBudgetMs : 600000,
128
160
  speedTest: {
129
161
  enabled: st.enabled !== false,
130
162
  sortKey: ['ttft', 'latency', 'hybrid', 'smart'].includes(st.sortKey) ? st.sortKey : 'ttft',
@@ -331,26 +363,56 @@ export function apply(ctx, config) {
331
363
  }
332
364
  };
333
365
  // ---------- 引擎:单候选尝试 ----------
366
+ // F01/F02(审计):超时必须能真正结束一次尝试。
367
+ // - 旧行为:Promise.race 超时后 await inner.return(),但 return() 排在挂起的
368
+ // next() 之后——上游挂着时 failover 被拖住(H02:15ms 超时 80ms 后仍未切换)。
369
+ // - 新行为:每次尝试独立 AbortController。pi-ai 适配器把 options.signal 经
370
+ // AbortSignal.any 融进 watchdog 并传给上游 HTTP(signal: watchdog.signal),
371
+ // abort 即真正取消网络请求。超时路径:先 abort → 有界等待(3s)排干 → 放弃
372
+ // 等待继续 failover(放弃的迭代器由适配器的 finally 自行清理)。
373
+ // - 生命周期(F02):成功 return / 失败 / 消费者提前退出统一走 finally 的
374
+ // 有界 closeInner,不再只在 catch 里清理。
375
+ const ATTEMPT_CLOSE_BUDGET_MS = 3000;
334
376
  async function* streamAttempt(cfg, cand, options, attemptStart) {
335
377
  if (cand.provider.startsWith(ROUTE_PREFIX))
336
378
  throw { code: 'INVALID_CANDIDATE', message: 'candidate provider must not be a virtual route' };
337
379
  const llm = ctx.llm;
338
- const inner = llm.stream(Object.assign({}, options, { provider: cand.provider, model: cand.model }));
380
+ // 用户取消信号透传 + 尝试自身的超时中止,二者融合为本次尝试的 signal
381
+ const attemptController = new AbortController();
382
+ const outerSignal = options.signal;
383
+ const relayAbort = () => { try { attemptController.abort(outerSignal.reason); } catch (_e) { } };
384
+ if (outerSignal) {
385
+ if (outerSignal.aborted)
386
+ relayAbort();
387
+ else
388
+ outerSignal.addEventListener('abort', relayAbort, { once: true });
389
+ }
390
+ const inner = llm.stream(Object.assign({}, options, { provider: cand.provider, model: cand.model, signal: attemptController.signal }));
339
391
  const timeoutMs = dynamicTimeoutMs(cfg, candKey(cand));
340
392
  let buffer = [];
341
393
  let started = false;
342
394
  let ttftMs = null;
343
395
  let lastAt = Date.now();
344
396
  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) { } };
397
+ let closed = false;
398
+ const closeInner = async () => {
399
+ if (closed)
400
+ return;
401
+ closed = true;
402
+ try {
403
+ const c = inner.return ? inner.return() : null;
404
+ if (c && c.then) {
405
+ await Promise.race([
406
+ c,
407
+ ctx.timeout(ATTEMPT_CLOSE_BUDGET_MS).then(() => { attemptController.abort({ code: 'TIMEOUT', message: 'attempt close budget exceeded' }); }, () => { }),
408
+ ]);
409
+ }
410
+ }
411
+ catch (_e) { }
412
+ };
351
413
  try {
352
414
  while (true) {
353
- if (options.signal && options.signal.aborted)
415
+ if (outerSignal && outerSignal.aborted)
354
416
  throw { code: 'ABORTED', message: 'request aborted' };
355
417
  const remaining = Math.max(0, timeoutMs - (Date.now() - (started ? lastAt : attemptStart)));
356
418
  if (remaining <= 0)
@@ -362,6 +424,13 @@ export function apply(ctx, config) {
362
424
  try {
363
425
  next = await Promise.race([inner.next(), guard.promise]);
364
426
  }
427
+ catch (raceErr) {
428
+ // 超时(或任何 next 失败):先中止本次尝试的上游请求再上抛——
429
+ // 这是 F01 的核心:abort 信号会穿透 pi-ai 取消真实 HTTP 请求
430
+ if (raceErr && raceErr.code === 'TIMEOUT')
431
+ attemptController.abort(raceErr);
432
+ throw raceErr;
433
+ }
365
434
  finally {
366
435
  // 超时路径同样要 dispose:ctx.effect 的注册表条目只有显式调用 disposer 才会移除
367
436
  guard.dispose();
@@ -416,33 +485,144 @@ export function apply(ctx, config) {
416
485
  }
417
486
  }
418
487
  catch (err) {
419
- await closeInner();
420
488
  if (started && err && typeof err === 'object')
421
489
  err.emitted = true;
422
490
  if (err && err.code === 'TIMEOUT')
423
491
  recordHealth(cand.provider, cand, { ok: false, ttftMs, latencyMs: null, code: 'TIMEOUT', usage: lastUsage });
424
492
  throw err;
425
493
  }
494
+ finally {
495
+ // F02:所有出口(成功 return / 失败 / 消费者提前 return)统一有界关闭;
496
+ // relayAbort 监听器随本次尝试终结移除(防泄漏:outerSignal 可能长命)
497
+ if (outerSignal)
498
+ outerSignal.removeEventListener('abort', relayAbort);
499
+ await closeInner();
500
+ }
426
501
  }
502
+ // ---------- F08 最小切片:候选能力过滤(防「成功路径改变用户输入」) ----------
503
+ // 审计 H14:虚拟模型声明 image,候选只支持 text——宿主把图片静默替换为
504
+ // "[image omitted...]" 后仍返回 stop 成功(用户输入被改变却报成功)。
505
+ // 审计 H06:非推理候选被强塞 effort → dispatch 前被拒 → CHANNEL_FULL_FAIL。
506
+ // 策略:请求含图/带 effort 时,过滤「明确声明不支持」的候选;能力未知
507
+ // (inputModalities 缺失 = 未知,只有显式排除才是负能力——宿主契约)不过滤,
508
+ // 保持原 failover 行为。解析失败不阻塞(同样视作未知)。
509
+ // 缓存 60s TTL:pi-ai 的 resolveModel 是内存快照解析(已核安装版源码),
510
+ // 但 async 接口 + 每次请求都调不划算;能力基本静态,TTL 足够。
511
+ const capabilityCache = new Map(); // key: provider::model → { at, info }
512
+ const CAPABILITY_TTL_MS = 60000;
513
+ const resolveCapability = async (cand) => {
514
+ const key = candKey(cand);
515
+ const hit = capabilityCache.get(key);
516
+ if (hit && Date.now() - hit.at < CAPABILITY_TTL_MS)
517
+ return hit.info;
518
+ try {
519
+ const info = await ctx.llm.resolveModelInfo(cand.provider, cand.model);
520
+ const entry = { at: Date.now(), info };
521
+ capabilityCache.set(key, entry);
522
+ return info;
523
+ }
524
+ catch (_e) {
525
+ return null; // 解析失败 = 未知,不过滤
526
+ }
527
+ };
528
+ /** 请求是否包含图片输入(粗判:消息 content 里有 image 块)。 */
529
+ const requestHasImage = (options) => {
530
+ try {
531
+ return (options.messages || []).some((m) => Array.isArray(m === null || m === void 0 ? void 0 : m.content) && m.content.some((p) => p && p.type === 'image'));
532
+ }
533
+ catch (_e) {
534
+ return false;
535
+ }
536
+ };
537
+ /** 过滤掉明确不支持本次请求能力的候选;全部被滤或未知时退回原列表(不因过滤而失去候选)。 */
538
+ const filterByCapability = async (cands, options) => {
539
+ const needImage = requestHasImage(options);
540
+ const needReasoning = typeof options.reasoningEffort === 'string' && options.reasoningEffort.length > 0 && options.reasoningEffort !== 'off';
541
+ if (!needImage && !needReasoning)
542
+ return cands;
543
+ const skipped = [];
544
+ const kept = [];
545
+ for (const cand of cands) {
546
+ const info = await resolveCapability(cand);
547
+ if (info === null) {
548
+ kept.push(cand); // 未知:不过滤
549
+ continue;
550
+ }
551
+ if (needImage) {
552
+ const mods = info.inputModalities;
553
+ if (Array.isArray(mods) && mods.length > 0 && !mods.includes('image')) {
554
+ skipped.push({ candidate: candKey(cand), reason: 'image input not supported' });
555
+ continue; // 明确排除 image
556
+ }
557
+ }
558
+ if (needReasoning && info.reasoning === undefined) {
559
+ skipped.push({ candidate: candKey(cand), reason: 'reasoning not supported' });
560
+ continue; // 明确无 reasoning 档位
561
+ }
562
+ kept.push(cand);
563
+ }
564
+ // 全被滤掉 = 声明与所有候选冲突:退回原列表(让请求去撞,报真实错误)
565
+ // 而不是直接 NO_CANDIDATES 假装没有渠道
566
+ if (skipped.length > 0 && kept.length > 0) {
567
+ // 记录到组事件(cfg 此时未知,用 provider 维度的 console 代替;组事件在
568
+ // streamGroup 拿到 cfg 后会补记 capability-filter)
569
+ console.log('[model-channel-manager] capability filter:', skipped.map((s) => s.candidate + ' (' + s.reason + ')').join(', '));
570
+ }
571
+ return kept.length > 0 ? kept : cands;
572
+ };
573
+
427
574
  // ---------- 引擎:组级故障转移循环 ----------
428
575
  async function* streamGroup(cfg, options) {
429
576
  const rt = groupRuntime(cfg.id);
430
- const order = orderedCandidates(cfg);
577
+ let order = orderedCandidates(cfg);
431
578
  if (order.length === 0) {
432
579
  pushEvent(cfg, 'no-candidates', {});
433
580
  yield failChunk('channel group "' + cfg.id + '" has no candidates', 'NO_CANDIDATES');
434
581
  return;
435
582
  }
583
+ // F14(审计 H07):enabled=true 但从未测过速的组,首次真实使用时后台触发一次
584
+ // 自动测速(不阻塞、不 await 当前请求)。旧实现只有 boot 时的 onFirstUse 分支,
585
+ // 常规请求路径完全没有自动测速入口——开启开关后从未生效。
586
+ if (cfg.speedTest.enabled && (state.speedResults[cfg.id] || []).length === 0 && !rt.speedTestRunning) {
587
+ rt.speedTestRunning = true; // 先占位防重入(runSpeedTest 内部还会再查)
588
+ rt.speedTestRunning = false;
589
+ runSpeedTest(cfg).catch(() => { });
590
+ }
591
+ // F08:按本次请求能力过滤(含图/带 effort 时)。过滤发生在测速排序之后,
592
+ // 保序不改变相对优先级。
593
+ const before = order.length;
594
+ order = await filterByCapability(order, options);
595
+ if (order.length < before)
596
+ pushEvent(cfg, 'capability-filter', { skipped: before - order.length, kept: order.length });
436
597
  const strategy = cfg.strategy || 'sticky';
437
- let cursor = strategy === 'primary' ? 0 : rt.currentIndex % order.length;
598
+ // F13(审计 H13):round-robin 的指针改为「选择时原子预留」——旧实现成功后才
599
+ // 推进 rt.currentIndex,两个并发请求都从 first 开始(并发不均分)。
600
+ // 现在:选定时立即占位推进(同步读改写,JS 单线程内原子),失败不回滚
601
+ // (失败反馈走冷却,指针保持前进语义——轮询分布比严格顺序更重要)。
602
+ if (strategy === 'round-robin') {
603
+ cursor = rt.currentIndex % order.length;
604
+ rt.currentIndex = (cursor + 1) % order.length; // 本次请求已占用 cursor 槽位
605
+ }
606
+ else {
607
+ cursor = strategy === 'primary' ? 0 : rt.currentIndex % order.length;
608
+ }
438
609
  let fullRounds = 0;
439
610
  let lastFail = null;
440
611
  const failedKeys = [];
612
+ // 总请求预算(F01 配套):组级起点。超预算不再开新尝试。
613
+ const groupStart = Date.now();
614
+ const budgetMs = cfg.totalBudgetMs || 600000;
615
+ const budgetLeft = () => budgetMs - (Date.now() - groupStart);
441
616
  while (true) {
442
617
  if (options.signal && options.signal.aborted) {
443
618
  yield abortedChunk('request aborted');
444
619
  return;
445
620
  }
621
+ if (budgetLeft() <= 0) {
622
+ pushEvent(cfg, 'budget-exhausted', { budgetMs, failures: failedKeys.slice() });
623
+ yield failChunk('channel group "' + cfg.id + '" exhausted its total budget of ' + budgetMs + 'ms: ' + (lastFail || 'multiple failures'), 'CHANNEL_BUDGET_EXCEEDED');
624
+ return;
625
+ }
446
626
  const round = [];
447
627
  for (let i = 0; i < order.length; i++)
448
628
  round.push(order[(cursor + i) % order.length]);
@@ -465,6 +645,8 @@ export function apply(ctx, config) {
465
645
  yield abortedChunk('request aborted');
466
646
  return;
467
647
  }
648
+ if (budgetLeft() <= 0)
649
+ break; // 预算耗尽:不开新尝试,交给组层终结
468
650
  const attemptStart = Date.now();
469
651
  try {
470
652
  const attempt = streamAttempt(cfg, cand, options, attemptStart);
@@ -473,12 +655,12 @@ export function apply(ctx, config) {
473
655
  succeeded = true;
474
656
  candidateOk = true;
475
657
  rt.cooldowns.delete(key);
658
+ // F13:round-robin 的推进已移到选定时(见上方原子预留);
659
+ // 成功路径只维护 sticky/primary 的语义。
476
660
  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')
661
+ if (strategy === 'sticky')
480
662
  rt.currentIndex = pos;
481
- else
663
+ else if (strategy === 'primary')
482
664
  rt.currentIndex = 0;
483
665
  if (failedKeys.length > 0)
484
666
  pushEvent(cfg, 'failover', { from: failedKeys[failedKeys.length - 1], to: key, reason: 'candidate change after ' + failedKeys.length + ' failure(s)' });
@@ -532,24 +714,47 @@ export function apply(ctx, config) {
532
714
  }
533
715
  }
534
716
  // ---------- 测速 ----------
717
+ // F15 修复:旧行为每 chunk 重置 guard(单次等待上限而非总时限,H12:timeoutMs=40
718
+ // 流每 20ms 输出,84ms 后仍成功)+ 超时不中止上游。现在:总时限 deadline +
719
+ // 超时 abort 上游(与 streamAttempt 同一模式)+ 统一 finally 关闭。
535
720
  async function measureCandidate(cfg, cand, st) {
536
721
  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()) });
722
+ const controller = new AbortController();
723
+ const deadline = Date.now() + (st.timeoutMs || 60000);
724
+ 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
725
  const start = Date.now();
539
726
  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) { } };
727
+ let closed = false;
728
+ const closeInner = async () => {
729
+ if (closed)
730
+ return;
731
+ closed = true;
732
+ try {
733
+ const c = inner.return ? inner.return() : null;
734
+ if (c && c.then) {
735
+ await Promise.race([
736
+ c,
737
+ ctx.timeout(ATTEMPT_CLOSE_BUDGET_MS).then(() => { try { controller.abort({ code: 'TIMEOUT', message: 'speedtest close budget exceeded' }); } catch (_e) { } }, () => { }),
738
+ ]);
739
+ }
740
+ }
741
+ catch (_e) { }
742
+ };
546
743
  try {
547
744
  while (true) {
548
- const guard = timed(Math.max(1, st.timeoutMs), () => ({ code: 'TIMEOUT', message: 'speedtest timed out after ' + st.timeoutMs + 'ms' }));
745
+ const remaining = deadline - Date.now();
746
+ if (remaining <= 0)
747
+ throw { code: 'TIMEOUT', message: 'speedtest timed out after ' + st.timeoutMs + 'ms (total)' };
748
+ const guard = timed(Math.max(1, remaining), () => ({ code: 'TIMEOUT', message: 'speedtest timed out after ' + st.timeoutMs + 'ms (total)' }));
549
749
  let next;
550
750
  try {
551
751
  next = await Promise.race([inner.next(), guard.promise]);
552
752
  }
753
+ catch (raceErr) {
754
+ if (raceErr && raceErr.code === 'TIMEOUT')
755
+ try { controller.abort(raceErr); } catch (_e) { }
756
+ throw raceErr;
757
+ }
553
758
  finally {
554
759
  guard.dispose();
555
760
  }
@@ -567,9 +772,11 @@ export function apply(ctx, config) {
567
772
  }
568
773
  }
569
774
  catch (err) {
570
- await closeInner();
571
775
  throw { code: (err && err.code) || 'STREAM_ERROR', message: (err && err.message) || 'speedtest failed' };
572
776
  }
777
+ finally {
778
+ await closeInner();
779
+ }
573
780
  }
574
781
  async function runSpeedTest(cfg) {
575
782
  const rt = groupRuntime(cfg.id);
@@ -687,25 +894,48 @@ export function apply(ctx, config) {
687
894
  // ---------- 单模型真实请求测试(client 经 settings 总线下发,走 DSH 真实 llm.stream 链路) ----------
688
895
  async function runModelTest(provider, model, prompt, maxTokens) {
689
896
  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() });
897
+ // F15(与 measureCandidate 同一模式):总时限 60s(旧行为每 chunk 重置 guard,
898
+ // 持续输出的请求永不超时而 client 已放弃)+ 超时 abort 上游 + 统一 finally 关闭。
899
+ const MODEL_TEST_TOTAL_MS = 60000;
900
+ const controller = new AbortController();
901
+ const deadline = Date.now() + MODEL_TEST_TOTAL_MS;
902
+ 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
903
  const start = Date.now();
692
904
  let ttft = null;
693
905
  let text = '';
694
906
  let reasoning = '';
695
907
  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) { } };
908
+ let closed = false;
909
+ const closeInner = async () => {
910
+ if (closed)
911
+ return;
912
+ closed = true;
913
+ try {
914
+ const c = inner.return ? inner.return() : null;
915
+ if (c && c.then) {
916
+ await Promise.race([
917
+ c,
918
+ ctx.timeout(ATTEMPT_CLOSE_BUDGET_MS).then(() => { try { controller.abort({ code: 'TIMEOUT', message: 'model test close budget exceeded' }); } catch (_e) { } }, () => { }),
919
+ ]);
920
+ }
921
+ }
922
+ catch (_e) { }
923
+ };
702
924
  try {
703
925
  while (true) {
704
- const guard = timed(60000, () => ({ code: 'TIMEOUT', message: 'model test timed out after 60000ms' }));
926
+ const remaining = deadline - Date.now();
927
+ if (remaining <= 0)
928
+ throw { code: 'TIMEOUT', message: 'model test timed out after ' + MODEL_TEST_TOTAL_MS + 'ms (total)' };
929
+ const guard = timed(Math.max(1, remaining), () => ({ code: 'TIMEOUT', message: 'model test timed out after ' + MODEL_TEST_TOTAL_MS + 'ms (total)' }));
705
930
  let next;
706
931
  try {
707
932
  next = await Promise.race([inner.next(), guard.promise]);
708
933
  }
934
+ catch (raceErr) {
935
+ if (raceErr && raceErr.code === 'TIMEOUT')
936
+ try { controller.abort(raceErr); } catch (_e) { }
937
+ throw raceErr;
938
+ }
709
939
  finally {
710
940
  guard.dispose();
711
941
  }
@@ -736,41 +966,60 @@ export function apply(ctx, config) {
736
966
  }
737
967
  }
738
968
  catch (err) {
739
- await closeInner();
740
969
  throw err;
741
970
  }
971
+ finally {
972
+ await closeInner();
973
+ }
742
974
  }
743
975
  function handleTestRequest(next) {
744
976
  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)
977
+ // nonce 接受 number(现行 client 的随机 53-bit 整数)与 string(0.3.2–0.3.8 的 UUID)
978
+ // 两种历史形态;去重一律按字符串键严格比较,不再做数值大小推断。
979
+ const nonceOf = (v) => (typeof v === 'number' || typeof v === 'string') ? v : null;
980
+ const reqNonce = nonceOf(req && req.nonce);
981
+ const reqKey = nonceKey(reqNonce);
982
+ const claimedKey = nonceKey(claimedTestNonce);
983
+ const settled = settledNonceKey(next.lastTestHandledNonce);
984
+ const entry = reqKey !== null ? (next.testResults || {})[reqKey] : null;
985
+ // 终态判定用 finishedAt(写入后不会被覆盖),不只信 status:
986
+ // 整树快照回写曾把 status 改回 running 却保留 finishedAt/code/error(0.3.11 事故)。
987
+ const hasTerminal = !!(entry && (entry.finishedAt || entry.status === 'ok' || entry.status === 'error'));
988
+ if (!req || typeof req.provider !== 'string' || typeof req.model !== 'string'
989
+ || !shouldConsumeNonce({ consumed: consumedTestNonces, key: reqKey, claimed: claimedKey, settled, hasTerminal }))
747
990
  return;
748
- claimedTestNonce = req.nonce;
991
+ markConsumed(consumedTestNonces, reqKey);
992
+ claimedTestNonce = reqNonce;
749
993
  // nonce 的落盘推迟到本次测试得出结论之后:只有「真跑过」才算 handled。
750
994
  // 启动早于凭据服务就绪时会以 MISSING_CREDENTIAL 失败,那要释放认领重试;
751
995
  // 若提前写了 nonce,重试会被自己的持久值挡掉。
752
996
  const settle = (entry) => {
753
997
  if (bus !== null)
754
- bus.writeHealth({ lastTestHandledNonce: req.nonce }).catch((e) => console.error('[model-channel-manager] lastTestHandledNonce 写入失败:', e));
998
+ bus.writeHealth({ lastTestHandledNonce: reqNonce }).catch((e) => console.error('[model-channel-manager] lastTestHandledNonce 写入失败:', e));
755
999
  return setResult(entry);
756
1000
  };
757
- // 0.2 的配置写入是「整字段落盘」,不再有 path-ops;结果集按当前值合并后整段写回。
1001
+ // 0.3.12:结果按「单键叶写」落盘(path-ops),不再把整个 testResults 读出来整段写回。
1002
+ // 整段写回的 payload 是调用时刻的快照,落盘晚于并发的终态写时会把该 nonce 的
1003
+ // status 改回 running(深合并保留 finishedAt/code/error)——线上事故的直接成因。
1004
+ // 叶写只动这一个 nonce 的条目,物理上不可能覆盖别人的、也不可能自我回退。
758
1005
  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 });
761
- const cur = healthOf().testResults || {};
1006
+ const key = String(reqNonce);
1007
+ const value = Object.assign({}, entry, { nonce: reqNonce, provider: req.provider, model: req.model });
762
1008
  if (bus !== null)
763
- await bus.writeHealth({ testResults: Object.assign({}, cur, { [key]: value }) }).catch((e) => console.error('[model-channel-manager] testResults 写入失败:', e));
1009
+ await bus.writeHealthLeaf(['testResults', key], value).catch((e) => console.error('[model-channel-manager] testResults 写入失败:', e));
764
1010
  };
765
- // 修剪低频执行:只在条目数超限时砍到 50(不在每次写入时整包重写)
1011
+ // 修剪低频执行:只在条目数超限时砍到 50。用 unset 叶删(update 的深合并删不掉键)
766
1012
  const maybePrune = () => {
767
1013
  const all = healthOf().testResults || {};
768
1014
  const entries = Object.entries(all);
769
1015
  if (entries.length <= 50) return;
770
- const kept = {};
771
- for (const [k, v] of entries.sort((a, b) => ((b[1] && b[1].finishedAt) || 0) - ((a[1] && a[1].finishedAt) || 0)).slice(0, 50)) kept[k] = v;
772
- if (bus !== null)
773
- bus.writeHealth({ testResults: kept }).catch((e) => console.error('[model-channel-manager] testResults 修剪失败:', e));
1016
+ const removed = entries
1017
+ .sort((a, b) => ((b[1] && b[1].finishedAt) || 0) - ((a[1] && a[1].finishedAt) || 0))
1018
+ .slice(50)
1019
+ .map(([k]) => k);
1020
+ if (removed.length === 0 || bus === null) return;
1021
+ bus.writeHealthOps(removed.map((k) => ({ op: 'unset', path: ['health', 'testResults', k] })))
1022
+ .catch((e) => console.error('[model-channel-manager] testResults 修剪失败:', e));
774
1023
  };
775
1024
  const done = async (r) => {
776
1025
  try {
@@ -786,7 +1035,8 @@ export function apply(ctx, config) {
786
1035
  }).catch(async (e) => {
787
1036
  if (notReady(e)) {
788
1037
  // 凭据服务尚未就绪(典型是启动瞬间的重放):不写成假 error,释放认领并延后重试
789
- claimedTestNonce = 0;
1038
+ claimedTestNonce = null;
1039
+ releaseConsumed(consumedTestNonces, reqKey); // 释放后才能靠重放再消费
790
1040
  scheduleReplay('test', () => reloadFromConfig(false));
791
1041
  return;
792
1042
  }
@@ -795,7 +1045,20 @@ export function apply(ctx, config) {
795
1045
  }
796
1046
  function rewireRoutes() {
797
1047
  const llm = ctx.llm;
798
- const next = pullConfig().groups.map((g) => routeOfGroup(g.id));
1048
+ // F16(审计 H08):normalizeConfig 对非法/重复 ID 是静默 continue——保存 3 组
1049
+ // 实际只注册 1 条路由,界面仍显示「已保存」。这里把被丢弃的组大声报出来:
1050
+ // 配置里有 N 组但只有 M 组成为路由时,用户能从日志看到哪组、为什么被丢。
1051
+ const rawGroups = Array.isArray(state.config && state.config.groups) ? state.config.groups : [];
1052
+ const normalized = pullConfig().groups;
1053
+ if (rawGroups.length > normalized.length) {
1054
+ const keptIds = new Set(normalized.map((g) => g.id));
1055
+ const dropped = rawGroups
1056
+ .map((g) => g && g.id)
1057
+ .filter((id) => typeof id === 'string' && !keptIds.has(id));
1058
+ if (dropped.length > 0)
1059
+ console.warn('[model-channel-manager] 以下轮询组未通过校验,已被丢弃(不会成为路由):', dropped.join(', '), '——组 ID 规则:小写字母/数字/连字符,且不重复');
1060
+ }
1061
+ const next = normalized.map((g) => routeOfGroup(g.id));
799
1062
  const current = llm.listProviders().map((p) => p.id).filter((id) => id.startsWith(ROUTE_PREFIX));
800
1063
  if (next.length === 0) {
801
1064
  // 空配置:LLM 服务拒绝注册零 provider 的适配器——推迟首次注册;已注册则清空路由
@@ -874,21 +1137,41 @@ export function apply(ctx, config) {
874
1137
  flushDigest();
875
1138
  }, 'model-channel health digest');
876
1139
  // settings 存量 records/speedResults → domain 一次性迁移(桶已存在即跳过,domain 权威)。
1140
+ // 清空旧键必须走 path-ops:update 是深合并,`records: {}` 清不掉已有键——
1141
+ // 0.3.8 用合并写「清空」实际没删,旧流水一直留在 profile 里(每 5s describe 都要带着它)。
877
1142
  const migrateHealthToDomain = async () => {
878
1143
  if (healthStore === null || bus === null) return;
879
1144
  const health = healthOf();
880
- if (health.healthMigrated === true) return;
1145
+ const alreadyMigrated = health.healthMigrated === true;
1146
+ const hasRecords = health.records !== undefined && Object.keys(health.records || {}).length > 0;
1147
+ const hasSpeed = health.speedResults !== undefined && Object.keys(health.speedResults || {}).length > 0;
1148
+ if (!hasRecords && !hasSpeed) {
1149
+ // 无存量:只补哨兵(幂等)
1150
+ if (!alreadyMigrated) {
1151
+ await bus.writeHealthOps([{ op: 'set', path: ['health', 'healthMigrated'], value: true }])
1152
+ .catch((e) => console.warn('[model-channel-manager] health marker write failed:', e && e.message));
1153
+ }
1154
+ return;
1155
+ }
1156
+ // 有存量就必须导入(合并幂等)——不能只看 marker:schema 缺陷期间 marker 可能已被
1157
+ // 误写而数据从未导入(0.3.11 线上事故),照 marker 跳过再清理 = 直接删掉旧账。
881
1158
  let ok = false;
882
1159
  try {
883
1160
  ok = await healthStore.migrateFrom(health.records || {}, health.speedResults || {});
884
1161
  }
885
- catch (_e) {
886
- ok = false; // 下次启动再试
1162
+ catch (e) {
1163
+ console.warn('[model-channel-manager] legacy health import failed (will retry next boot):', e && e.message);
1164
+ return;
887
1165
  }
888
- if (!ok) return; // store 已关闭(卸载竞态):不写标记,避免标记与数据永久不一致
889
- // 同一次合并写:迁移标记 + 清空 settings 里的旧存量(否则 writeHealth 的
890
- // Object.assign 会把 records/speedResults 永远带下去,profile 膨胀问题未真正解决)
891
- bus.writeHealth({ healthMigrated: true, records: {}, speedResults: {} }).catch(() => { });
1166
+ if (!ok) return; // store 已关闭(卸载竞态):不清理,避免标记与数据永久不一致
1167
+ // 导入成功才真删除旧键(path-ops;update 的深合并清不掉)+ 落哨兵
1168
+ const ops = [];
1169
+ if (hasRecords) ops.push({ op: 'unset', path: ['health', 'records'] });
1170
+ if (hasSpeed) ops.push({ op: 'unset', path: ['health', 'speedResults'] });
1171
+ if (!alreadyMigrated) ops.push({ op: 'set', path: ['health', 'healthMigrated'], value: true });
1172
+ if (ops.length === 0) return;
1173
+ await bus.writeHealthOps(ops).catch((e) => console.warn('[model-channel-manager] legacy health cleanup failed:', e && e.message));
1174
+ console.log('[model-channel-manager] legacy health imported into domain and cleared from settings');
892
1175
  };
893
1176
 
894
1177
  // ---------- settings 总线接入(响应式:settings 服务异步初始化,apply 时查询太早) ----------
@@ -896,8 +1179,27 @@ export function apply(ctx, config) {
896
1179
  let runtimeRestored = false;
897
1180
  // 非持久去重:回写要等当前 HMR 事务结束才落盘,这中间 volatile-update 可能带着同一个 nonce 再来,
898
1181
  // 内存里先认领,避免同一请求被重复执行(并顺带消掉重复的回写风暴)。
899
- let claimedTestNonce = 0;
900
- let claimedSpeedNonce = 0;
1182
+ //
1183
+ // 0.3.12:改为严格 consume-once(字符串键集合),不再依赖 nonce 的数值单调性。
1184
+ // 旧实现 last = Math.max(上一轮结算值, 本次认领值),而两个 nonce 都是随机 53-bit:
1185
+ // 「上一轮 > 本轮」时判等直接失效(≈50%),于是测试在每次 loader/volatile-update
1186
+ // (包括宿主自己每 5s 的 digest 回写)都被重新消费——重复上游请求(真实计费)
1187
+ // 且结果条目互相污染。线上物证:同 nonce 一条记录同时带
1188
+ // `code TIMEOUT / 60000ms` 与 `ok:true / text`(单次执行不可能)。
1189
+ // 回归见 tests/task-dedup.test.cjs。
1190
+ let claimedTestNonce = null; // string UUID 或旧数字;null = 未认领
1191
+ let claimedSpeedNonce = null; // string UUID 或旧数字;null = 未认领
1192
+ const consumedTestNonces = new Set(); // 已消费过的 nonce 字符串键(含本次)
1193
+ const consumedSpeedNonces = new Set();
1194
+ const CONSUMED_NONCE_CAP = 256; // 上限:只用于防重复消费,不需要无限历史
1195
+ const markConsumed = (set, key) => {
1196
+ set.add(key);
1197
+ if (set.size > CONSUMED_NONCE_CAP) {
1198
+ const oldest = set.values().next().value;
1199
+ set.delete(oldest);
1200
+ }
1201
+ };
1202
+ const releaseConsumed = (set, key) => { if (key !== null) set.delete(key); };
901
1203
  // 启动早于凭据服务就绪:此时重放测试/测速会以 MISSING_CREDENTIAL 失败。
902
1204
  // 这类错误是「环境还没准备好」而不是「渠道故障」,必须释放认领、延后重试,不能写成假 error。
903
1205
  const notReady = (e) => {
@@ -941,8 +1243,13 @@ export function apply(ctx, config) {
941
1243
  runtime.delete(id);
942
1244
  rewireRoutes();
943
1245
  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) {
1246
+ // 与 handleTestRequest 同一策略:nonce 兼容字符串/数字,去重按字符串键严格等值
1247
+ const speedKey = nonceKey(req && req.nonce);
1248
+ const speedClaimed = nonceKey(claimedSpeedNonce);
1249
+ const speedSettled = settledNonceKey(health.lastHandledNonce);
1250
+ if (req && typeof req.group === 'string'
1251
+ && shouldConsumeNonce({ consumed: consumedSpeedNonces, key: speedKey, claimed: speedClaimed, settled: speedSettled, hasTerminal: false })) {
1252
+ markConsumed(consumedSpeedNonces, speedKey);
946
1253
  claimedSpeedNonce = req.nonce;
947
1254
  const cfgRow = pullConfig().groups.find((g) => g.id === req.group);
948
1255
  // nonce 落盘推迟到本次测速得出结论之后(同 handleTestRequest 的理由):
@@ -954,7 +1261,8 @@ export function apply(ctx, config) {
954
1261
  if (cfgRow)
955
1262
  runSpeedTest(cfgRow).then((r) => {
956
1263
  if (r && r.deferred) {
957
- claimedSpeedNonce = 0;
1264
+ claimedSpeedNonce = null;
1265
+ releaseConsumed(consumedSpeedNonces, speedKey);
958
1266
  scheduleReplay('speed', () => reloadFromConfig(false));
959
1267
  return;
960
1268
  }
@@ -962,7 +1270,8 @@ export function apply(ctx, config) {
962
1270
  settleSpeed();
963
1271
  }).catch((e) => {
964
1272
  if (notReady(e)) {
965
- claimedSpeedNonce = 0;
1273
+ claimedSpeedNonce = null;
1274
+ releaseConsumed(consumedSpeedNonces, speedKey);
966
1275
  scheduleReplay('speed', () => reloadFromConfig(false));
967
1276
  return;
968
1277
  }
@@ -993,70 +1302,74 @@ export function apply(ctx, config) {
993
1302
  // 后写的直接覆盖先写的(实测 lastTestHandledNonce 与 records 就这样丢过)。
994
1303
  // 所以写入串行化:排队后逐个重新读当前值再合并,不让两次写入互相覆写。
995
1304
  let healthWrites = Promise.resolve();
996
- const writeHealth = (patch) => {
997
- const run = async () => {
998
- const cur = healthOf();
999
- const next = Object.assign({}, cur, patch);
1000
- return outsideTransaction(() => settings.update(SELF_NS, { health: next }));
1001
- };
1305
+ const enqueueHealthWrite = (run) => {
1002
1306
  const queued = healthWrites.then(run, run);
1003
1307
  healthWrites = queued.catch(() => { });
1004
1308
  return queued;
1005
1309
  };
1310
+ // 0.2 的 health 子树写入曾用「整树读-改-写」(cur = healthOf(); update({health:{...cur, patch}}))。
1311
+ // 只要内存视图落后于已提交的行(volatile 提交与事件之间有窗口),旧快照就会把新值写回去:
1312
+ // 线上物证是终态写入后 status 又被改回 running(同一 nonce 同时有 finishedAt/error 与 running)。
1313
+ // 0.3.12 起一律「叶写」:patch 里出现的每个顶层键单独 set,不再回灌未见过的值。
1314
+ // 需要只动某个子键时用 writeHealthLeaf(如 testResults.<nonce>)。
1315
+ const writeHealth = (patch) => enqueueHealthWrite(async () => {
1316
+ const ops = Object.keys(patch || {}).map((k) => ({ op: 'set', path: ['health', k], value: patch[k] }));
1317
+ if (ops.length === 0) return;
1318
+ return outsideTransaction(() => settings.mutate(SELF_NS, ops, undefined));
1319
+ });
1320
+ // path-ops 写入:update 是深合并,空对象清不掉旧键;需要真删除时走 mutate。
1321
+ const writeHealthOps = (ops) => enqueueHealthWrite(async () => outsideTransaction(() => settings.mutate(SELF_NS, ops, undefined)));
1322
+ // 单叶写入:把 value 精确写到 health.<path...>,不读、不带任何快照
1323
+ const writeHealthLeaf = (path, value) => enqueueHealthWrite(async () => outsideTransaction(() => settings.mutate(SELF_NS, [{ op: 'set', path: ['health', ...path], value }], undefined)));
1006
1324
  bus = {
1007
1325
  settings,
1008
1326
  writeHealth,
1327
+ writeHealthOps,
1328
+ writeHealthLeaf,
1009
1329
  cfgScope: { get: () => cfgOf() },
1010
1330
  healthScope: { get: () => healthOf() },
1011
1331
  };
1012
- // healthStore 可用(dsh-base 的 storage 栈在场)时:开 domain → 迁移存量 →
1013
- // 用 domain 权威数据重建内存镜像 → boot;否则同步走旧路径。
1014
- // ctx.inject 回调不是 async 函数,domain 初始化用 promise 链表达。
1015
- // 卸载竞态防护:then 回调先验 fiber 活性,inactive 时回滚 healthStore 并跳过
1016
- // effect 注册(对 inactive fiber 注册 effect 会抛 INACTIVE_EFFECT,若被吞掉则
1017
- // domain 永不 close,facility 名字被占 → HMR 重载后 already-open 静默降级)。
1018
- // boot 也挪进链尾:domain 路径下 reloadFromConfig(true) 异步排队,若 boot 先跑,
1019
- // state.speedResults 尚为空 → onFirstUse 组每次启动都重测;且 state.config 为空
1020
- // 时 rewireRoutes 推迟,启动早期虚拟路由短暂不存在。
1021
- const domainFacility = ctx.get('storageDomain');
1332
+ // 卸载竞态防护:异步链回到插件时先确认 fiber 仍存活(uid 被清除即已卸载)。
1022
1333
  const fiberUid = ctx.fiber && ctx.fiber.uid;
1023
- const fiberAlive = () => ctx.fiber !== undefined && ctx.fiber.uid === fiberUid && ctx.fiber.state !== 4 /* disposed */;
1024
- if (domainFacility !== undefined) {
1025
- const store = new HealthStore(ctx);
1334
+ const fiberAlive = () => ctx.fiber !== undefined && ctx.fiber.uid !== undefined && ctx.fiber.uid === fiberUid;
1335
+ // 启动不等待 domain:先用现有 settings 数据把路由与健康页带起来(旧 records 尚在
1336
+ // settings 时也能立即显示);domain 就绪后再以它为权威重载。
1337
+ reloadFromConfig(true);
1338
+ ctx.on('loader/volatile-update', () => {
1339
+ reloadFromConfig(false);
1340
+ console.log('[model-channel-manager] config hot-reloaded, routes:', pullConfig().groups.map((g) => g.id).join(', ') || '(none)');
1341
+ });
1342
+ boot();
1343
+ scheduleDigest(); // 首屏投影:让健康页不用等下一次真实请求
1344
+ // storageDomain 是可选服务,必须经响应式 inject 取得「已声明依赖」的上下文:
1345
+ // 直接 ctx.get('storageDomain') 只能拿到未注入的裸服务,随后任何属性访问都会被
1346
+ // Cordis 代理拒绝(实测 "cannot get property storageDomain without inject",
1347
+ // domain 永远打不开、健康流水静默降级为内存)。inject 回调在服务可用时触发;
1348
+ // profile 没有 storage 栈时它不触发,插件保持内存模式,路由与面板不受影响。
1349
+ ctx.inject(['storageDomain'], (dctx) => {
1350
+ const store = new HealthStore(dctx);
1026
1351
  Promise.resolve()
1027
1352
  .then(() => store.open())
1028
1353
  .then(() => {
1029
- if (!fiberAlive()) {
1030
- // 已卸载:回滚赋值,由 closeAll 兜底回收 domain
1031
- void store.close().catch(() => { });
1032
- return;
1033
- }
1034
- healthStore = store;
1035
- ctx.effect(() => () => { void store.close(); }, 'model-channel health domain close');
1036
- })
1354
+ if (!fiberAlive()) {
1355
+ void store.close().catch(() => { }); // 已卸载:由 facility closeAll 兜底回收
1356
+ return;
1357
+ }
1358
+ healthStore = store;
1359
+ ctx.effect(() => () => { void store.close(); }, 'model-channel health domain close');
1360
+ console.log('[model-channel-manager] health domain ready: model_channel_health');
1361
+ })
1037
1362
  .catch((e) => {
1038
- console.warn('[model-channel-manager] storageDomain open failed, health records stay in-memory only:', e && e.message);
1039
- })
1363
+ console.warn('[model-channel-manager] storageDomain open failed, health records stay in-memory only:', e && e.message);
1364
+ })
1040
1365
  .then(() => (healthStore !== null ? migrateHealthToDomain().catch(() => { }) : null))
1041
1366
  .then(() => {
1042
- if (fiberAlive())
1043
- reloadFromConfig(true);
1044
- })
1045
- .then(() => {
1046
- if (fiberAlive())
1047
- boot();
1048
- })
1049
- .catch(() => { }); // 卸载竞态下 reload/boot 内部可能 throw,链尾兜底防 unhandled rejection
1050
- }
1051
- else {
1052
- reloadFromConfig(true);
1053
- boot();
1054
- }
1055
- ctx.on('loader/volatile-update', () => {
1056
- reloadFromConfig(false);
1057
- console.log('[model-channel-manager] config hot-reloaded, routes:', pullConfig().groups.map((g) => g.id).join(', ') || '(none)');
1367
+ if (!fiberAlive()) return;
1368
+ reloadFromConfig(true); // domain 权威数据覆盖内存镜像
1369
+ scheduleDigest(); // 立刻把投影刷新到 settings(健康页首屏可读)
1370
+ })
1371
+ .catch(() => { }); // 卸载竞态下 reload 内部可能 throw,链尾兜底防 unhandled rejection
1058
1372
  });
1059
- // boot 已在 domain ready 链尾(或降级分支)调用,此处不再调
1060
1373
  });
1061
1374
  // ---------- 全局 LLM 请求健康拦截 (涵盖所有非虚拟路由的真实渠道模型调用) ----------
1062
1375
  ctx.on('llm/stream', async function* (options, next) {
@@ -1158,6 +1471,13 @@ export function apply(ctx, config) {
1158
1471
  const text = await fsSvc.readText(target);
1159
1472
  const legacy = JSON.parse(text);
1160
1473
  const migrated = normalizeConfig(legacy);
1474
+ // F10(审计 H15):读取是 await 的——这个窗口里用户可能已经保存了
1475
+ // 新组。写前重检:现在有组了就不迁(用户的新配置优先于旧文件复活)。
1476
+ if (pullConfig().groups.length !== 0) {
1477
+ console.log('[model-channel-manager] legacy migration skipped: user config arrived during read');
1478
+ bus.writeHealth({ legacyMigrated: true }).catch(() => { });
1479
+ return;
1480
+ }
1161
1481
  if (migrated.groups.length > 0) {
1162
1482
  await bus.settings.update(SELF_NS, { groups: migrated.groups });
1163
1483
  state.config = { groups: migrated.groups };