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.
- package/README.md +238 -63
- package/cordis.patch.yml +22 -0
- package/docs/ext.md +28 -5
- package/docs/pool-sticky-block.md +158 -0
- package/docs/suppliers.md +28 -5
- package/lib/client-registry.js +1300 -549
- package/lib/client.js +1300 -549
- package/lib/ext-test/index.js +47 -0
- package/lib/ext-test/locale/en.json +6 -0
- package/lib/ext-test/locale/zh.json +6 -0
- package/lib/ext-test/package.json +5 -0
- package/lib/http-state-C1vb3gW2.js +48 -0
- package/lib/index.js +1169 -254
- package/lib/suppliers/{nvidia.js → nvidia/index.js} +14 -6
- package/lib/suppliers/nvidia/locale/en.json +6 -0
- package/lib/suppliers/nvidia/locale/zh.json +6 -0
- package/lib/suppliers/nvidia/package.json +5 -0
- package/lib/suppliers/{opencode.js → opencode/index.js} +14 -6
- package/lib/suppliers/opencode/locale/en.json +6 -0
- package/lib/suppliers/opencode/locale/zh.json +6 -0
- package/lib/suppliers/opencode/package.json +5 -0
- package/lib/suppliers/{openrouter.js → openrouter/index.js} +14 -6
- package/lib/suppliers/openrouter/locale/en.json +6 -0
- package/lib/suppliers/openrouter/locale/zh.json +6 -0
- package/lib/suppliers/openrouter/package.json +5 -0
- package/lib/types/ext/contract.d.ts +9 -7
- package/lib/types/ext-test/index.d.ts +21 -0
- package/lib/types/ext-test/plugin.d.ts +23 -0
- package/lib/types/host-version.d.ts +15 -0
- package/lib/types/index.d.ts +6 -0
- package/lib/types/llm/adapter.d.ts +35 -4
- package/lib/types/router/account-pool.d.ts +53 -18
- package/lib/types/router/index.d.ts +65 -15
- package/lib/types/router/prefix-affinity.d.ts +18 -1
- package/lib/types/router/types.d.ts +7 -4
- package/lib/types/router/usage-store.d.ts +12 -0
- package/lib/types/shared.d.ts +25 -21
- package/lib/types/supplier-config.d.ts +15 -3
- package/lib/types/suppliers/contract.d.ts +37 -4
- package/lib/types/suppliers/http-state.d.ts +23 -0
- package/lib/types/suppliers/loader.d.ts +6 -4
- package/lib/types/suppliers/nvidia/index.d.ts +18 -0
- package/lib/types/suppliers/nvidia/plugin.d.ts +13 -0
- package/lib/types/suppliers/opencode/index.d.ts +18 -0
- package/lib/types/suppliers/opencode/plugin.d.ts +7 -0
- package/lib/types/suppliers/openrouter/index.d.ts +18 -0
- package/lib/types/suppliers/openrouter/plugin.d.ts +11 -0
- package/lib/types/suppliers/probe.d.ts +160 -0
- package/lib/types/suppliers/registry.d.ts +3 -2
- package/package.json +50 -16
- package/src/client/CombosTab.tsx +8 -53
- package/src/client/ExtDetail.tsx +32 -196
- package/src/client/ExtTab.tsx +25 -42
- package/src/client/ExtTestPanel.tsx +392 -0
- package/src/client/LastHitDock.tsx +141 -0
- package/src/client/RouterComponentsSection.tsx +241 -0
- package/src/client/RouterView.tsx +12 -5
- package/src/client/StatsTab.tsx +8 -6
- package/src/client/SupplierDetail.tsx +2 -34
- package/src/client/comp-button-scope.test.ts +58 -0
- package/src/client/comp-panel-layout.test.ts +58 -0
- package/src/client/env.d.ts +38 -1
- package/src/client/ext-panels.test.ts +44 -0
- package/src/client/ext-panels.ts +49 -0
- package/src/client/index.tsx +86 -0
- package/src/client/last-hit-store.ts +89 -0
- package/src/client/model-choice.test.ts +41 -0
- package/src/client/model-choice.ts +37 -0
- package/src/client/model-hint-copy.test.ts +7 -2
- package/src/client/model-hint-copy.ts +2 -2
- package/src/client/router-components.test.ts +114 -0
- package/src/client/router-components.ts +104 -0
- package/src/client/router.css +2592 -1635
- package/src/client/supplier-visibility.test.ts +57 -0
- package/src/client/supplier-visibility.ts +63 -0
- package/src/ext/contract.ts +9 -7
- package/src/ext-test/ext-test.test.ts +60 -0
- package/src/ext-test/index.ts +35 -0
- package/src/ext-test/locale/en.json +6 -0
- package/src/ext-test/locale/zh.json +6 -0
- package/src/ext-test/plugin.ts +34 -0
- package/src/ext-test/row.json +5 -0
- package/src/host-version.test.ts +61 -0
- package/src/host-version.ts +101 -0
- package/src/index.ts +165 -68
- package/src/llm/adapter.test.ts +175 -4
- package/src/llm/adapter.ts +109 -16
- package/src/router/account-pool.test.ts +189 -83
- package/src/router/account-pool.ts +120 -31
- package/src/router/disconnect-leak.test.ts +7 -12
- package/src/router/first-byte-budget.test.ts +160 -0
- package/src/router/index.ts +276 -62
- package/src/router/models-cache.test.ts +54 -9
- package/src/router/precise-dispatch.test.ts +6 -6
- package/src/router/prefix-affinity.ts +22 -1
- package/src/router/supplier-switch.test.ts +220 -0
- package/src/router/types.ts +7 -5
- package/src/router/usage-store.ts +16 -0
- package/src/router/usage.test.ts +1 -1
- package/src/shared.ts +25 -24
- package/src/supplier-config.ts +33 -13
- package/src/suppliers/contract.ts +56 -4
- package/src/suppliers/loader.test.ts +38 -4
- package/src/suppliers/loader.ts +33 -18
- package/src/suppliers/nvidia/index.ts +27 -0
- package/src/suppliers/nvidia/locale/en.json +6 -0
- package/src/suppliers/nvidia/locale/zh.json +6 -0
- package/src/suppliers/nvidia/row.json +5 -0
- package/src/suppliers/opencode/index.ts +27 -0
- package/src/suppliers/opencode/locale/en.json +6 -0
- package/src/suppliers/opencode/locale/zh.json +6 -0
- package/src/suppliers/opencode/row.json +5 -0
- package/src/suppliers/openrouter/index.ts +27 -0
- package/src/suppliers/openrouter/locale/en.json +6 -0
- package/src/suppliers/openrouter/locale/zh.json +6 -0
- package/src/suppliers/openrouter/row.json +5 -0
- package/src/suppliers/probe.test.ts +266 -0
- package/src/suppliers/probe.ts +475 -0
- package/src/suppliers/registry.ts +103 -23
- 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',
|
|
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',
|
|
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',
|
|
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',
|
|
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',
|
|
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',
|
|
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',
|
|
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',
|
|
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',
|
|
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;
|
|
144
|
-
.customCombos.push({ id: 'legacy', name: 'legacy',
|
|
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',
|
|
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',
|
|
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',
|
|
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
|
+
})
|
package/src/router/types.ts
CHANGED
|
@@ -37,14 +37,10 @@ export interface ModelWithEnabled extends ModelInfo {
|
|
|
37
37
|
custom?: boolean
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
/**
|
|
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 = []
|
package/src/router/usage.test.ts
CHANGED
|
@@ -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',
|
|
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
|
-
|
|
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
|
|