dsh-router-core 0.3.21 → 0.4.1

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.
Files changed (120) hide show
  1. package/README.md +238 -63
  2. package/cordis.patch.yml +22 -0
  3. package/docs/ext.md +28 -5
  4. package/docs/pool-sticky-block.md +158 -0
  5. package/docs/suppliers.md +28 -5
  6. package/lib/client-registry.js +1300 -549
  7. package/lib/client.js +1300 -549
  8. package/lib/ext-test/index.js +47 -0
  9. package/lib/ext-test/locale/en.json +6 -0
  10. package/lib/ext-test/locale/zh.json +6 -0
  11. package/lib/ext-test/package.json +5 -0
  12. package/lib/http-state-C1vb3gW2.js +48 -0
  13. package/lib/index.js +1169 -254
  14. package/lib/suppliers/{nvidia.js → nvidia/index.js} +14 -6
  15. package/lib/suppliers/nvidia/locale/en.json +6 -0
  16. package/lib/suppliers/nvidia/locale/zh.json +6 -0
  17. package/lib/suppliers/nvidia/package.json +5 -0
  18. package/lib/suppliers/{opencode.js → opencode/index.js} +14 -6
  19. package/lib/suppliers/opencode/locale/en.json +6 -0
  20. package/lib/suppliers/opencode/locale/zh.json +6 -0
  21. package/lib/suppliers/opencode/package.json +5 -0
  22. package/lib/suppliers/{openrouter.js → openrouter/index.js} +14 -6
  23. package/lib/suppliers/openrouter/locale/en.json +6 -0
  24. package/lib/suppliers/openrouter/locale/zh.json +6 -0
  25. package/lib/suppliers/openrouter/package.json +5 -0
  26. package/lib/types/ext/contract.d.ts +9 -7
  27. package/lib/types/ext-test/index.d.ts +21 -0
  28. package/lib/types/ext-test/plugin.d.ts +23 -0
  29. package/lib/types/host-version.d.ts +15 -0
  30. package/lib/types/index.d.ts +6 -0
  31. package/lib/types/llm/adapter.d.ts +35 -4
  32. package/lib/types/router/account-pool.d.ts +53 -18
  33. package/lib/types/router/index.d.ts +65 -15
  34. package/lib/types/router/prefix-affinity.d.ts +18 -1
  35. package/lib/types/router/types.d.ts +7 -4
  36. package/lib/types/router/usage-store.d.ts +12 -0
  37. package/lib/types/shared.d.ts +25 -21
  38. package/lib/types/supplier-config.d.ts +15 -3
  39. package/lib/types/suppliers/contract.d.ts +37 -4
  40. package/lib/types/suppliers/http-state.d.ts +23 -0
  41. package/lib/types/suppliers/loader.d.ts +6 -4
  42. package/lib/types/suppliers/nvidia/index.d.ts +18 -0
  43. package/lib/types/suppliers/nvidia/plugin.d.ts +13 -0
  44. package/lib/types/suppliers/opencode/index.d.ts +18 -0
  45. package/lib/types/suppliers/opencode/plugin.d.ts +7 -0
  46. package/lib/types/suppliers/openrouter/index.d.ts +18 -0
  47. package/lib/types/suppliers/openrouter/plugin.d.ts +11 -0
  48. package/lib/types/suppliers/probe.d.ts +160 -0
  49. package/lib/types/suppliers/registry.d.ts +3 -2
  50. package/package.json +50 -16
  51. package/src/client/CombosTab.tsx +8 -53
  52. package/src/client/ExtDetail.tsx +32 -196
  53. package/src/client/ExtTab.tsx +25 -42
  54. package/src/client/ExtTestPanel.tsx +392 -0
  55. package/src/client/LastHitDock.tsx +141 -0
  56. package/src/client/RouterComponentsSection.tsx +241 -0
  57. package/src/client/RouterView.tsx +12 -5
  58. package/src/client/StatsTab.tsx +8 -6
  59. package/src/client/SupplierDetail.tsx +2 -34
  60. package/src/client/comp-button-scope.test.ts +58 -0
  61. package/src/client/comp-panel-layout.test.ts +58 -0
  62. package/src/client/env.d.ts +38 -1
  63. package/src/client/ext-panels.test.ts +44 -0
  64. package/src/client/ext-panels.ts +49 -0
  65. package/src/client/index.tsx +86 -0
  66. package/src/client/last-hit-store.ts +89 -0
  67. package/src/client/model-choice.test.ts +41 -0
  68. package/src/client/model-choice.ts +37 -0
  69. package/src/client/model-hint-copy.test.ts +7 -2
  70. package/src/client/model-hint-copy.ts +2 -2
  71. package/src/client/router-components.test.ts +114 -0
  72. package/src/client/router-components.ts +104 -0
  73. package/src/client/router.css +2592 -1635
  74. package/src/client/supplier-visibility.test.ts +57 -0
  75. package/src/client/supplier-visibility.ts +63 -0
  76. package/src/ext/contract.ts +9 -7
  77. package/src/ext-test/ext-test.test.ts +60 -0
  78. package/src/ext-test/index.ts +35 -0
  79. package/src/ext-test/locale/en.json +6 -0
  80. package/src/ext-test/locale/zh.json +6 -0
  81. package/src/ext-test/plugin.ts +34 -0
  82. package/src/ext-test/row.json +5 -0
  83. package/src/host-version.test.ts +61 -0
  84. package/src/host-version.ts +101 -0
  85. package/src/index.ts +165 -68
  86. package/src/llm/adapter.test.ts +175 -4
  87. package/src/llm/adapter.ts +109 -16
  88. package/src/router/account-pool.test.ts +189 -83
  89. package/src/router/account-pool.ts +120 -31
  90. package/src/router/disconnect-leak.test.ts +7 -12
  91. package/src/router/first-byte-budget.test.ts +160 -0
  92. package/src/router/index.ts +276 -62
  93. package/src/router/models-cache.test.ts +54 -9
  94. package/src/router/precise-dispatch.test.ts +6 -6
  95. package/src/router/prefix-affinity.ts +22 -1
  96. package/src/router/supplier-switch.test.ts +220 -0
  97. package/src/router/types.ts +7 -5
  98. package/src/router/usage-store.ts +16 -0
  99. package/src/router/usage.test.ts +1 -1
  100. package/src/shared.ts +25 -24
  101. package/src/supplier-config.ts +33 -13
  102. package/src/suppliers/contract.ts +56 -4
  103. package/src/suppliers/loader.test.ts +38 -4
  104. package/src/suppliers/loader.ts +33 -18
  105. package/src/suppliers/nvidia/index.ts +27 -0
  106. package/src/suppliers/nvidia/locale/en.json +6 -0
  107. package/src/suppliers/nvidia/locale/zh.json +6 -0
  108. package/src/suppliers/nvidia/row.json +5 -0
  109. package/src/suppliers/opencode/index.ts +27 -0
  110. package/src/suppliers/opencode/locale/en.json +6 -0
  111. package/src/suppliers/opencode/locale/zh.json +6 -0
  112. package/src/suppliers/opencode/row.json +5 -0
  113. package/src/suppliers/openrouter/index.ts +27 -0
  114. package/src/suppliers/openrouter/locale/en.json +6 -0
  115. package/src/suppliers/openrouter/locale/zh.json +6 -0
  116. package/src/suppliers/openrouter/row.json +5 -0
  117. package/src/suppliers/probe.test.ts +266 -0
  118. package/src/suppliers/probe.ts +475 -0
  119. package/src/suppliers/registry.ts +103 -23
  120. package/src/types.d.ts +2 -1
@@ -66,6 +66,12 @@ function addSupplier(router: Router, s: unknown): void {
66
66
  ;(router as unknown as { suppliers: unknown[] }).suppliers.push(s)
67
67
  }
68
68
 
69
+ /** 禁用某供应商的一个模型(store 是私有的,测试按同文件惯例断言访问)。 */
70
+ function disableModel(router: Router, supplierId: string, modelId: string): void {
71
+ ;(router as unknown as { store: { setModelEnabled(id: string, m: string, enabled: boolean): void } })
72
+ .store.setModelEnabled(supplierId, modelId, false)
73
+ }
74
+
69
75
  /**
70
76
  * 把某供应商的缓存标记成「已过期」(TTL 现在 10 分钟,测试不能真等)。
71
77
  * 白盒改 fetchedAt,模拟插件重启/TTL 到期的冷路径。
@@ -357,7 +363,7 @@ test('每次 chat 记一行:组合名 → 供应商/模型 (账号) 结果', a
357
363
  const router = new Router('', undefined, (m) => lines.push(m))
358
364
  const ok = supplier('supA', [{ id: 'a-m' }])
359
365
  addSupplier(router, ok.s)
360
- assert.equal(router.createCombo('combo1', 'fallback', ['supA,a-m']).ok, true)
366
+ assert.equal(router.createCombo('combo1', ['supA,a-m']).ok, true)
361
367
 
362
368
  const res = {
363
369
  writeHead: (): void => {},
@@ -370,7 +376,7 @@ test('每次 chat 记一行:组合名 → 供应商/模型 (账号) 结果', a
370
376
  await router.chatCompletions({ model: 'combo1', stream: false, rawBody: JSON.stringify({ model: 'combo1', messages: [] }) }, res)
371
377
 
372
378
  assert.equal(lines.length, 1)
373
- assert.match(lines[0] ?? '', /^chat "combo1" → supA\/a-m \(u1\) ok \d+ms$/, `日志格式不对: ${lines[0]}`)
379
+ assert.match(lines[0] ?? '', /^chat "combo1" → supA\/a-m \(u1\) ok( \[选号: .+?\])? \d+ms$/, `日志格式不对: ${lines[0]}`)
374
380
  })
375
381
 
376
382
  test('失败的成员也要记,且带真实原因(不能笼统写 no account)', async () => {
@@ -384,7 +390,7 @@ test('失败的成员也要记,且带真实原因(不能笼统写 no account
384
390
  const live = supplier('live', [{ id: 'live-m' }])
385
391
  addSupplier(router, dead.s)
386
392
  addSupplier(router, live.s)
387
- assert.equal(router.createCombo('c1', 'fallback', ['dead,dead-m', 'live,live-m']).ok, true)
393
+ assert.equal(router.createCombo('c1', ['dead,dead-m', 'live,live-m']).ok, true)
388
394
 
389
395
  const res = {
390
396
  writeHead: (): void => {},
@@ -428,7 +434,7 @@ test('流在写出第一个字节前就断 → 组合回退到下一个模型',
428
434
  })
429
435
  addSupplier(router, dead.s)
430
436
  addSupplier(router, live.s)
431
- assert.equal(router.createCombo('c1', 'fallback', ['dead,dead-m', 'live,live-m']).ok, true)
437
+ assert.equal(router.createCombo('c1', ['dead,dead-m', 'live,live-m']).ok, true)
432
438
 
433
439
  let out = ''
434
440
  let heads = 0
@@ -479,7 +485,7 @@ test('已写出字节后断流 → 截断并补 [DONE],不回退也不二次
479
485
  })
480
486
  addSupplier(router, half.s)
481
487
  addSupplier(router, live.s)
482
- assert.equal(router.createCombo('c2', 'fallback', ['half,half-m', 'live,live-m']).ok, true)
488
+ assert.equal(router.createCombo('c2', ['half,half-m', 'live,live-m']).ok, true)
483
489
 
484
490
  let out = ''
485
491
  let heads = 0
@@ -552,7 +558,7 @@ test('首字节前就断 → 不补 [DONE],留给组合回退', async () => {
552
558
  })
553
559
  addSupplier(router, dead.s)
554
560
  addSupplier(router, live.s)
555
- assert.equal(router.createCombo('c3', 'fallback', ['dead,dead-m', 'live,live-m']).ok, true)
561
+ assert.equal(router.createCombo('c3', ['dead,dead-m', 'live,live-m']).ok, true)
556
562
 
557
563
  let out = ''
558
564
  const res = {
@@ -683,7 +689,7 @@ test('组合窗口:取组合内模型的最小值,单位 K → token', async
683
689
  { id: 'a-2', context_length: 1000 }, // 1M
684
690
  ]).s)
685
691
  await router.modelsOf('a') // 填缓存
686
- const w = router.comboContextWindow({ id: 'x', name: 'x', strategy: 'fallback', models: ['a,a-1', 'a,a-2'] })
692
+ const w = router.comboContextWindow({ id: 'x', name: 'x', models: ['a,a-1', 'a,a-2'] })
687
693
  assert.equal(w, 128_000, '取最小的那个(fallback 到任意一条分支都得成立)')
688
694
  })
689
695
 
@@ -692,7 +698,7 @@ test('组合窗口:供应商还没缓存过 → undefined(不猜,绝不打
692
698
  addSupplier(router, supplier('a', [{ id: 'a-1', context_length: 128 }]).s)
693
699
  // 故意不调 modelsOf:模拟冷启动
694
700
  assert.equal(
695
- router.comboContextWindow({ id: 'x', name: 'x', strategy: 'fallback', models: ['a,a-1'] }),
701
+ router.comboContextWindow({ id: 'x', name: 'x', models: ['a,a-1'] }),
696
702
  undefined,
697
703
  '没缓存就报 undefined —— 压缩这轮跳过,下次就有,比撒谎安全',
698
704
  )
@@ -703,8 +709,47 @@ test('组合窗口:模型都没报 context_length → undefined', async () =>
703
709
  addSupplier(router, supplier('a', [{ id: 'a-1' }]).s)
704
710
  await router.modelsOf('a')
705
711
  assert.equal(
706
- router.comboContextWindow({ id: 'x', name: 'x', strategy: 'fallback', models: ['a,a-1'] }),
712
+ router.comboContextWindow({ id: 'x', name: 'x', models: ['a,a-1'] }),
707
713
  undefined,
708
714
  '一家都没报就别编一个数(traework/nvidia 目前就是如此)',
709
715
  )
710
716
  })
717
+
718
+
719
+ /* ---- 核心不替违约插件兜底 ----
720
+ *
721
+ * 起因:插件在 listModels 里把用户禁用的模型过滤掉(越权 —— 启用状态归核心合并),
722
+ * 面板的「已禁用」列表于是空了。曾想过在核心按配置把缺的 id 补回来,**这是错的**:
723
+ *
724
+ * 1. 那是替有问题的插件擦屁股,插件永远不会被修;
725
+ * 2. 更糟的是**它会把体检的越权检测一起掩盖掉** —— 模型被补全之后,
726
+ * probe.ts 的 hiddenDisabledIds 找不到「少报的 id」,报告变成 OK,
727
+ * 第三方那个 bug 就再也没人知道了。
728
+ *
729
+ * 所以核心只负责:自己不合并不掩饰(插件少报就是少报),并让体检把违约报出来。
730
+ * 下面两条判据钉住这个方向,防止以后有人(我)再犯同样的错。
731
+ */
732
+
733
+ test('核心不替违约插件补回已禁用的模型(要留着证据给体检)', async () => {
734
+ const router = new Router('')
735
+ const a = supplier('a', [{ id: 'a-1' }, { id: 'a-2' }])
736
+ // 越权实现:listModels 自己过滤掉了已禁用的
737
+ a.s.listModels = async (): Promise<ModelInfo[]> => [{ id: 'a-1' }]
738
+ addSupplier(router, a.s)
739
+ disableModel(router, 'a', 'a-2')
740
+
741
+ const models = await router.modelsOf('a')
742
+ assert.deepEqual(models.map((m) => m.id), ['a-1'],
743
+ '核心不该把插件藏掉的模型补回来 —— 补回来就等于替它兜底,越权检测也会失效')
744
+ })
745
+
746
+ test('正常供应商(listModels 全量返回):列表内容与启用状态照旧', async () => {
747
+ const router = new Router('')
748
+ const a = supplier('a', [{ id: 'a-1' }, { id: 'a-2' }])
749
+ addSupplier(router, a.s)
750
+ disableModel(router, 'a', 'a-2')
751
+
752
+ const models = await router.modelsOf('a')
753
+ assert.deepEqual(models.map((m) => m.id).sort(), ['a-1', 'a-2'])
754
+ assert.deepEqual(models.map((m) => m.enabled).sort(), [false, true], '启用状态仍由核心合并')
755
+ })
@@ -87,7 +87,7 @@ test('组合只调目标供应商,其它供应商一次都不会被问', async
87
87
  const b = spy('supB', 'b-model')
88
88
  const c = spy('supC', 'c-model')
89
89
  add(router, a.s); add(router, b.s); add(router, c.s)
90
- assert.equal(router.createCombo('my-combo', 'fallback', ['supB,b-model']).ok, true, '组合必须创建成功')
90
+ assert.equal(router.createCombo('my-combo', ['supB,b-model']).ok, true, '组合必须创建成功')
91
91
 
92
92
  await router.chatCompletions(reqWith('my-combo'), fakeRes())
93
93
 
@@ -140,8 +140,8 @@ test('组合存了缺供应商前缀的裸模型 id → 直接失败,不降级
140
140
  const b = spy('supB', 'b-model')
141
141
  add(router, a.s); add(router, b.s)
142
142
  // 模拟旧存储:裸 id,没有 supplierId 前缀 —— 图6 下这是配置错误,不遍历兜底
143
- ;(router as unknown as { customCombos: Array<{ id: string; name: string; strategy: 'fallback'; models: string[] }> })
144
- .customCombos.push({ id: 'legacy', name: 'legacy', strategy: 'fallback', models: ['b-model'] })
143
+ ;(router as unknown as { customCombos: Array<{ id: string; name: string; models: string[] }> })
144
+ .customCombos.push({ id: 'legacy', name: 'legacy', models: ['b-model'] })
145
145
 
146
146
  const errs: unknown[] = []
147
147
  const res = { ...fakeRes(), end: (x?: string): unknown => { if (x) errs.push(x); return undefined } } as unknown as ServerResponse
@@ -156,7 +156,7 @@ test('组合存了不存在的供应商 id → 调用失败,不静默落到别
156
156
  const router = new Router('')
157
157
  const a = spy('supA', 'a-model')
158
158
  add(router, a.s)
159
- assert.equal(router.createCombo('broken', 'fallback', ['ghost,a-model']).ok, true, '组合必须创建成功')
159
+ assert.equal(router.createCombo('broken', ['ghost,a-model']).ok, true, '组合必须创建成功')
160
160
 
161
161
  const errs: unknown[] = []
162
162
  const res = { ...fakeRes(), end: (b?: string): unknown => { if (b) errs.push(b); return undefined } } as unknown as ServerResponse
@@ -274,7 +274,7 @@ test('组合:瞬时故障降级前会等一等,不是毫秒级打穿整个
274
274
  const m1 = failingSpy('s1', 'm1', 'transport')
275
275
  const m2 = failingSpy('s2', 'm2', 'unavailable')
276
276
  add(router, m1.s); add(router, m2.s)
277
- router.createCombo('combo', 'fallback', ['s1,m1', 's2,m2'])
277
+ router.createCombo('combo', ['s1,m1', 's2,m2'])
278
278
 
279
279
  const t0 = Date.now()
280
280
  await router.chatCompletions(reqWith('combo'), fakeRes())
@@ -291,7 +291,7 @@ test('组合:no_such_model 不是故障,降级不该等', async () => {
291
291
  const m1 = failingSpy('s1', 'm1', 'no_such_model')
292
292
  const m2 = failingSpy('s2', 'm2', 'no_such_model')
293
293
  add(router, m1.s); add(router, m2.s)
294
- router.createCombo('combo', 'fallback', ['s1,m1', 's2,m2'])
294
+ router.createCombo('combo', ['s1,m1', 's2,m2'])
295
295
 
296
296
  const t0 = Date.now()
297
297
  await router.chatCompletions(reqWith('combo'), fakeRes())
@@ -59,7 +59,28 @@ function textOf(content: unknown): string {
59
59
  }
60
60
 
61
61
  /**
62
- * 算请求的前缀指纹。
62
+ * 算请求的亲和指纹(选号主键)。
63
+ *
64
+ * **身份优先,内容兜底**:
65
+ * 1. 宿主给了会话身份(`session`,来自 dsh-llm 的 `sessionId`)→ 直接用它。
66
+ * 精确、零撞车 —— 同一会话永不漂移,不同会话(含 team 各成员)天然隔离。
67
+ * 2. 没有身份(外部 OpenAI 客户端)→ 回落 {@link prefixFingerprint}。
68
+ *
69
+ * 为什么优先身份而不是内容指纹:DSH 的 system prompt 是同一个 agent preset
70
+ * 生成的几 KB 文本,内容指纹只取前 512 字符 → 各会话高度雷同 → 撞车导致
71
+ * 绑定互相覆盖、同会话漂移(team 多成员时实测过)。
72
+ *
73
+ * @param session 宿主会话身份;无则 undefined
74
+ * @param messages OpenAI 请求体的 `messages`(无身份时才用)
75
+ * @returns 指纹字符串;**空串 = 算不出来(无亲和,回退到块轮询)**
76
+ */
77
+ export function sessionFingerprint(session: string | undefined, messages: unknown): string {
78
+ if (session !== undefined && session !== '') return `sid:${session}`
79
+ return prefixFingerprint(messages)
80
+ }
81
+
82
+ /**
83
+ * 算请求的前缀指纹(内容兜底)。
63
84
  * @param messages OpenAI 请求体的 `messages` 字段(未校验,来自外部输入)
64
85
  * @returns 指纹字符串;**空串 = 算不出来(无亲和,回退到块轮询)**
65
86
  */
@@ -0,0 +1,220 @@
1
+ /**
2
+ * 供应商开关测试 —— 锁住「关掉 = 真的不参与路由」,而且是**全部入口**都拦。
3
+ *
4
+ * 为什么要有这个文件:这个开关的唯一实现是 `Router` 里的活跃集合
5
+ * (`active` = 装载清单里开关为开的那一份)。而路由有 15 处读供应商清单,
6
+ * 少收一处就出现「面板显示已关闭、请求照样打过去」——那是最坏的一种:开关说了
7
+ * 假话,用户以为断了流量其实在烧额度。所以这里逐个入口钉住:
8
+ *
9
+ * 1. 组合腿(`supplierId,modelId` 形态)
10
+ * 2. 直调(`alias/model` 形态)
11
+ * 3. 模型目录 / 别名前缀(关掉的不能留下「前缀存在但必然 503」的死前缀)
12
+ * 4. status() 仍带出关掉的(否则关掉后就没法再开,开关失去对象)
13
+ * 5. 关掉不能连带卸载(`removeSupplier` 是插件卸载用的另一码事)
14
+ */
15
+ import test from 'node:test'
16
+ import assert from 'node:assert/strict'
17
+ import type { ServerResponse } from 'node:http'
18
+ import { Router } from './index.ts'
19
+ import type { ModelInfo } from './types.ts'
20
+ import { AccountPool } from './account-pool.ts'
21
+ import { SupplierConfigStore } from '../supplier-config.ts'
22
+ import type { AccountPool as Pool } from './account-pool.ts'
23
+ import type { ChatOnceResult, SupplierAccountNow, SupplierStatusNow } from '../suppliers/contract.ts'
24
+
25
+ interface Spy {
26
+ calls: number
27
+ seen: string[]
28
+ s: {
29
+ id: string
30
+ name: string
31
+ priority: number
32
+ pool: Pool
33
+ status: () => SupplierStatusNow
34
+ listModels: () => Promise<ModelInfo[]>
35
+ modelsWithEnabled: () => Promise<Array<ModelInfo & { enabled: boolean }>>
36
+ customModelIds: () => string[]
37
+ getAlias: () => string
38
+ accounts: () => SupplierAccountNow[]
39
+ chatOnce: (uid: string, lv: string, req: { rawBody: string }) => Promise<ChatOnceResult>
40
+ dispose: () => void
41
+ }
42
+ }
43
+
44
+ /** 带探针的供应商:只服务自己的模型,记录被问过什么。 */
45
+ function spy(id: string, modelId: string, alias = id): Spy {
46
+ const calls = { n: 0 }
47
+ const seen: string[] = []
48
+ return {
49
+ get calls() { return calls.n },
50
+ seen,
51
+ s: {
52
+ id, name: id, priority: 0, pool: new AccountPool(),
53
+ status: () => ({ id, name: id, accounts: [{ uid: `${id}-u1`, credits: 0, state: 'ok' }] }),
54
+ listModels: async () => [{ id: modelId }],
55
+ modelsWithEnabled: async () => [{ id: modelId, enabled: true }],
56
+ customModelIds: () => [`${id}-custom`],
57
+ getAlias: () => alias,
58
+ accounts: () => [{ uid: `${id}-u1`, credits: 0, state: 'ok' }],
59
+ chatOnce: async (_uid, _lv, req) => {
60
+ calls.n += 1
61
+ const m = (JSON.parse(req.rawBody) as { model?: string }).model ?? ''
62
+ seen.push(m)
63
+ const base = m.startsWith(`${alias}/`) ? m.slice(alias.length + 1) : m
64
+ if (base !== modelId) return { ok: false, state: 'no_such_model', message: `unknown model ${m}` }
65
+ return { ok: true, status: 200, body: '{"ok":1}' }
66
+ },
67
+ dispose: () => {},
68
+ },
69
+ }
70
+ }
71
+
72
+ function add(router: Router, s: unknown): void {
73
+ router.add(s as Parameters<Router['add']>[0])
74
+ }
75
+
76
+ function fakeRes(): ServerResponse {
77
+ return {
78
+ writeHead: (): unknown => undefined,
79
+ end: (): unknown => undefined,
80
+ write: (): boolean => true,
81
+ once: (): unknown => undefined,
82
+ removeListener: (): unknown => undefined,
83
+ destroy: (): unknown => undefined,
84
+ } as unknown as ServerResponse
85
+ }
86
+
87
+ function reqWith(model: string) {
88
+ return { model, stream: false, rawBody: JSON.stringify({ model, messages: [] }) }
89
+ }
90
+
91
+ /** 路由器 + 共享 store(开关要能从 store 落盘/读回,所以显式传同一个实例)。 */
92
+ function rig() {
93
+ const store = new SupplierConfigStore('')
94
+ return { router: new Router('', store, () => {}), store }
95
+ }
96
+
97
+ test('关掉的供应商:组合腿不会打到它(唯一的路由入口之一)', async () => {
98
+ const { router, store } = rig()
99
+ const a = spy('supA', 'a-model')
100
+ const b = spy('supB', 'b-model')
101
+ add(router, a.s); add(router, b.s)
102
+ assert.equal(router.createCombo('c', ['supA,a-model', 'supB,b-model']).ok, true)
103
+
104
+ store.setEnabled('supA', false)
105
+ await router.chatCompletions(reqWith('c'), fakeRes())
106
+
107
+ assert.equal(a.calls, 0, '已关闭的供应商不该被组合腿问到')
108
+ assert.equal(b.calls, 1, '开着的供应商照常服务')
109
+ })
110
+
111
+ test('关掉的供应商:alias 直调也进不去(第二条入口)', async () => {
112
+ const { router, store } = rig()
113
+ const a = spy('supA', 'a-model', 'aaa')
114
+ add(router, a.s)
115
+
116
+ store.setEnabled('supA', false)
117
+ await router.chatCompletions(reqWith('aaa/a-model'), fakeRes())
118
+
119
+ assert.equal(a.calls, 0, '已关闭的供应商不该被直调命中')
120
+ assert.equal(router.supplierByAlias('aaa'), undefined, '别名反查也不该命中')
121
+ })
122
+
123
+ test('关掉再打开,路由立刻恢复(开关不是卸载)', async () => {
124
+ const { router, store } = rig()
125
+ const a = spy('supA', 'a-model', 'aaa')
126
+ add(router, a.s)
127
+
128
+ store.setEnabled('supA', false)
129
+ assert.equal(a.calls, 0)
130
+ store.setEnabled('supA', true)
131
+ await router.chatCompletions(reqWith('aaa/a-model'), fakeRes())
132
+ assert.equal(a.calls, 1, '重新打开后应当照常服务')
133
+ })
134
+
135
+ test('关掉的供应商仍出现在 status()(关掉后要能再开)且带 enabled=false', () => {
136
+ const { router, store } = rig()
137
+ add(router, spy('supA', 'a-model').s)
138
+ store.setEnabled('supA', false)
139
+
140
+ const { suppliers } = router.status()
141
+ assert.equal(suppliers.length, 1, '关掉不等于卸载,列表里还得有它')
142
+ assert.equal(suppliers[0]?.enabled, false)
143
+ assert.equal(router.isEnabled('supA'), false)
144
+ })
145
+
146
+ test('关掉的供应商不留死前缀、不进模型目录(否则是「存在但必然 503」)', async () => {
147
+ const { router, store } = rig()
148
+ add(router, spy('supA', 'a-model', 'aaa').s)
149
+ store.setEnabled('supA', false)
150
+
151
+ assert.deepEqual(router.aliases(), [], '关掉的供应商不该再提供模型前缀')
152
+ assert.deepEqual(await router.modelsOf('supA'), [], '关掉的供应商不该还能拉模型')
153
+ const models = await router.listModels()
154
+ assert.equal(
155
+ models.some((m) => m.id.includes('supA-custom')),
156
+ false,
157
+ '关掉的供应商的自定义模型不该进模型目录',
158
+ )
159
+ })
160
+
161
+ test('没记录过的供应商默认开着(升级不把全灭变成全开)', () => {
162
+ const { router } = rig()
163
+ add(router, spy('supA', 'a-model').s)
164
+ assert.equal(router.isEnabled('supA'), true)
165
+ })
166
+
167
+ // ---- 指定连接自检(插件自检面板的连接测试用它) ----
168
+ // 面板「供应商」详情里的「测试」按钮走账号池(任选可用号、失败自动换号);
169
+ // 面板的连接测试可以**钉死一个连接**,用来诊断单个链接到底通不通。关键性质:
170
+ // 指定了连接就**不回退**到别的号,否则「测这个连接」测出来的是另一个号的结果。
171
+
172
+ test('指定连接:只试这一个连接', async () => {
173
+ const { router } = rig()
174
+ const a = spy('supA', 'a-model')
175
+ a.s.accounts = () => [
176
+ { uid: 'u1', credits: 0, state: 'ok' },
177
+ { uid: 'u2', credits: 0, state: 'ok' },
178
+ ]
179
+ a.s.status = () => ({ id: 'supA', name: 'supA', accounts: a.s.accounts() })
180
+ add(router, a.s)
181
+
182
+ const r = await router.testModel('supA', 'a-model', 'u1')
183
+ assert.equal(r.ok, true)
184
+ assert.equal(a.calls, 1, '只该试一次')
185
+ })
186
+
187
+ test('指定连接失败时不回退到别的号(否则诊断结论是假的)', async () => {
188
+ const { router } = rig()
189
+ const a = spy('supA', 'a-model')
190
+ a.s.accounts = () => [
191
+ { uid: 'u1', credits: 0, state: 'ok' },
192
+ { uid: 'u2', credits: 0, state: 'ok' },
193
+ ]
194
+ a.s.status = () => ({ id: 'supA', name: 'supA', accounts: a.s.accounts() })
195
+ // 自己记账:spy 的 calls 计数封在原 chatOnce 里,换掉实现就得自己数。
196
+ const seen: string[] = []
197
+ a.s.chatOnce = async (uid) => {
198
+ seen.push(uid)
199
+ return { ok: false, state: 'transport', message: `link ${uid} down` }
200
+ }
201
+ add(router, a.s)
202
+
203
+ const r = await router.testModel('supA', 'a-model', 'u1')
204
+ assert.equal(r.ok, false)
205
+ assert.deepEqual(seen, ['u1'], '失败也不该换号重试')
206
+ assert.match(r.error ?? '', /u1/, '错误信息要点名是哪个连接挂了')
207
+ })
208
+
209
+ test('指定不存在的连接:明确报「连接不存在」,而不是静默测了别的号', async () => {
210
+ const { router } = rig()
211
+ const a = spy('supA', 'a-model')
212
+ a.s.accounts = () => [{ uid: 'u1', credits: 0, state: 'ok' }]
213
+ a.s.status = () => ({ id: 'supA', name: 'supA', accounts: a.s.accounts() })
214
+ add(router, a.s)
215
+
216
+ const r = await router.testModel('supA', 'a-model', 'gone')
217
+ assert.equal(r.ok, false)
218
+ assert.equal(a.calls, 0, '一个号都不该被试')
219
+ assert.match(r.error ?? '', /不存在/)
220
+ })
@@ -37,14 +37,10 @@ export interface ModelWithEnabled extends ModelInfo {
37
37
  custom?: boolean
38
38
  }
39
39
 
40
- /** 组合策略:fallback = 按顺序尝试;round-robin = 轮转。 */
41
- export type ComboStrategy = 'fallback' | 'round-robin'
42
-
43
- /** 组合:命名的一组模型,路由时按策略(回退/轮询)命中其中一个。 */
40
+ /** 组合:命名的一组模型,路由时作为**降级链**按序尝试(无策略旋钮)。 */
44
41
  export interface Combo {
45
42
  id: string
46
43
  name: string
47
- strategy: ComboStrategy
48
44
  models: string[]
49
45
  }
50
46
 
@@ -56,6 +52,12 @@ export interface ChatRequest {
56
52
  model: string
57
53
  /** 推理等级(reasoning effort),供应商各自决定怎么映射到上游。缺省 = 'auto'。 */
58
54
  lv?: string
55
+ /**
56
+ * 宿主盖的会话身份(dsh-llm `GenerateOptions.sessionId`),由 adapter 转成
57
+ * 内部头带进来。用于把用量记录**按会话归因**(「最近命中」徽章要按当前会话
58
+ * 显示,而不是显示别的会话的命中)。缺省 = 非宿主调用(外部 OpenAI 客户端)。
59
+ */
60
+ session?: string
59
61
  }
60
62
 
61
63
  /**
@@ -54,6 +54,11 @@ export interface UsageRecord {
54
54
  uid?: string
55
55
  /** 失败原因(ok=false 时)。 */
56
56
  error?: string
57
+ /**
58
+ * 宿主会话身份(有则记)。用于「最近命中」按当前会话归因 —— 徽章要显示
59
+ * 当前会话的命中,而不是全局最近(team 多会话下后者会张冠李戴)。
60
+ */
61
+ session?: string
57
62
  }
58
63
 
59
64
  /** 按某个维度(供应商/模型/请求名)聚合的小计。 */
@@ -389,6 +394,17 @@ export class UsageStore {
389
394
  return this.recent.slice(0, limit)
390
395
  }
391
396
 
397
+ /**
398
+ * 最近一次命中。给了 `session` 就只在该会话的记录里找(「最近命中」徽章
399
+ * 要显示**当前会话**的命中,team 多会话下全局最近会张冠李戴);
400
+ * 不给则退回全局最近一条。
401
+ * @param session 宿主会话身份;undefined = 全局
402
+ */
403
+ lastHit(session?: string): UsageRecord | undefined {
404
+ if (session === undefined) return this.recent[0]
405
+ return this.recent.find((r) => r.session === session)
406
+ }
407
+
392
408
  clear(): void {
393
409
  this.days.clear()
394
410
  this.recent = []
@@ -674,7 +674,7 @@ test('请求路径:组合回退试了 3 个模型,只记 1 条(不是 3
674
674
  add(router, spy('s1', 'm1', { ok: false, state: 'quota', message: 'no quota' }).s)
675
675
  add(router, spy('s2', 'm2', { ok: false, state: 'quota', message: 'no quota' }).s)
676
676
  add(router, spy('s3', 'm3', { ok: true, status: 200, body: '{"usage":{"prompt_tokens":1,"completion_tokens":1}}' }).s)
677
- assert.equal(router.createCombo('c1', 'fallback', ['s1,m1', 's2,m2', 's3,m3']).ok, true)
677
+ assert.equal(router.createCombo('c1', ['s1,m1', 's2,m2', 's3,m3']).ok, true)
678
678
  await router.chatCompletions(
679
679
  { model: 'c1', stream: false, rawBody: JSON.stringify({ model: 'c1', messages: [] }) },
680
680
  sinkRes(),
package/src/shared.ts CHANGED
@@ -64,6 +64,10 @@ export interface RouterCheckinResponse {
64
64
  /** `/router/api/health` response. */
65
65
  export interface RouterHealthResponse {
66
66
  ok: boolean
67
+ /** 宿主 DSH 版本(读不到为空串)。 */
68
+ hostVersion?: string
69
+ /** 「最近命中」徽章是否受支持(宿主 >= 0.1.7 才有 composer.dock 正确位置)。 */
70
+ lastHitDock?: boolean
67
71
  suppliers?: Array<{
68
72
  id: string
69
73
  name: string
@@ -75,18 +79,20 @@ export interface RouterHealthResponse {
75
79
  capabilities?: string[]
76
80
  /** 来源:内置 / 用户目录 / 外部插件。 */
77
81
  source?: 'builtin' | 'user' | 'external'
82
+ /**
83
+ * 供应商开关(是否参与路由)。**关掉的仍出现在这个列表里** —— 否则关掉之后
84
+ * 就没有可指的对象、再也开不回来。缺省按开启读(老版本核心没有这个字段)。
85
+ */
86
+ enabled?: boolean
78
87
  }>
79
88
  error?: string
80
89
  }
81
90
 
82
- /** 组合策略。 */
83
- export type RouterComboStrategy = 'fallback' | 'round-robin'
84
-
85
- /** 组合。models = 该组合命中的模型列表(裸模型 id,展示时动态拼前缀)。 */
91
+ /** 组合。models = 该组合命中的模型列表(裸模型 id,展示时动态拼前缀)。
92
+ * 路由时作为降级链按序尝试,无策略字段。 */
86
93
  export interface RouterCombo {
87
94
  id: string
88
95
  name: string
89
- strategy: RouterComboStrategy
90
96
  models: string[]
91
97
  }
92
98
 
@@ -151,6 +157,13 @@ export interface RouterExtItem {
151
157
  ready?: boolean
152
158
  /** 一句话状态说明(如「本机未装 rtk」)。 */
153
159
  detail?: string
160
+ /**
161
+ * 来源:随核心分发的内置扩展标 `builtin`,独立安装的不标(= plugin)。
162
+ *
163
+ * 插件页那个自绘的「路由组件」节据此**只列独立安装的**:内置的已经在原生
164
+ * 「包含的组件」里占一行了,两处都列就是同一个东西显示两遍(与内置供应商同理)。
165
+ */
166
+ source?: 'builtin'
154
167
  }
155
168
 
156
169
  /** `/router/api/ext` response(扩展插件列表 + 开关)。 */
@@ -160,25 +173,6 @@ export interface RouterExtResponse {
160
173
  enhancers?: RouterExtItem[]
161
174
  }
162
175
 
163
- /** 一行 watch 进程(`/router/api/ext/watch/processes`)。 */
164
- export interface RouterWatchProcess {
165
- pid: number
166
- ppid: number
167
- /** %cpu 快照 */ cpu: number
168
- /** %mem 快照 */ mem: number
169
- command: string
170
- /** 命中的已记录 agent 命令。 */
171
- match: string
172
- recordedAt: number
173
- }
174
-
175
- /** `/router/api/ext/{id}/processes` response。 */
176
- export interface RouterWatchResponse {
177
- ok: boolean
178
- error?: string
179
- processes?: RouterWatchProcess[]
180
- }
181
-
182
176
  /** 供应商模型(含启用状态)。 */
183
177
  export interface RouterSupplierModel {
184
178
  id: string
@@ -227,6 +221,13 @@ export interface RouterUsageRecord {
227
221
  durationMs: number
228
222
  /** 首字节延迟(ms);非流式为 0。 */
229
223
  ttfbMs: number
224
+ /**
225
+ * 连接名(uid → 显示别名,后端解析好)。空串 = 无连接(失败/未服务)。
226
+ * 「最近请求」的「连接」列用它,而不是 supplier。
227
+ */
228
+ connection?: string
229
+ /** 实际服务的连接 uid(成功且有账号时)。 */
230
+ uid?: string
230
231
  error?: string
231
232
  }
232
233