dsh-router-core 0.3.2 → 0.3.4
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 +6 -2
- package/docs/suppliers.md +87 -30
- package/lib/client-registry.js +20 -11
- package/lib/client.js +20 -11
- package/lib/index.js +751 -264
- package/lib/types/llm/adapter.d.ts +7 -1
- package/lib/types/router/account-pool.d.ts +54 -19
- package/lib/types/router/index.d.ts +28 -5
- package/lib/types/router/types.d.ts +0 -2
- package/lib/types/router/usage-store.d.ts +20 -3
- package/lib/types/shared.d.ts +2 -0
- package/lib/types/supplier-config.d.ts +23 -0
- package/lib/types/suppliers/contract.d.ts +9 -6
- package/package.json +1 -1
- package/src/client/RouterView.tsx +1 -1
- package/src/client/SupplierDetail.tsx +15 -10
- package/src/llm/adapter.test.ts +404 -1
- package/src/llm/adapter.ts +242 -34
- package/src/router/account-pool.test.ts +163 -61
- package/src/router/account-pool.ts +200 -68
- package/src/router/index.ts +225 -66
- package/src/router/models-cache.test.ts +224 -6
- package/src/router/precise-dispatch.test.ts +110 -7
- package/src/router/types.ts +0 -2
- package/src/router/usage-store.ts +174 -74
- package/src/router/usage.test.ts +115 -0
- package/src/shared.ts +2 -0
- package/src/supplier-config.test.ts +98 -0
- package/src/supplier-config.ts +60 -1
- package/src/suppliers/contract.ts +9 -9
- package/src/suppliers/loader.test.ts +118 -0
- package/src/suppliers/loader.ts +21 -5
- package/src/suppliers/nvidia/plugin.ts +12 -15
- package/src/suppliers/opencode/plugin.ts +10 -13
- package/src/suppliers/openrouter/plugin.ts +12 -15
- package/src/suppliers/registry.test.ts +23 -3
- package/src/suppliers/registry.ts +11 -3
|
@@ -66,6 +66,21 @@ function addSupplier(router: Router, s: unknown): void {
|
|
|
66
66
|
;(router as unknown as { suppliers: unknown[] }).suppliers.push(s)
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
/**
|
|
70
|
+
* 把某供应商的缓存标记成「已过期」(TTL 现在 10 分钟,测试不能真等)。
|
|
71
|
+
* 白盒改 fetchedAt,模拟插件重启/TTL 到期的冷路径。
|
|
72
|
+
*/
|
|
73
|
+
function expire(router: Router, id: string): void {
|
|
74
|
+
const cache = (router as unknown as { modelsCache: Map<string, { fetchedAt: number }> }).modelsCache
|
|
75
|
+
const hit = cache.get(id)
|
|
76
|
+
if (hit !== undefined) hit.fetchedAt = 0
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** 等一轮 microtask/定时器,让 fire-and-forget 的后台刷新跑完。 */
|
|
80
|
+
function tick(ms = 20): Promise<void> {
|
|
81
|
+
return new Promise((r) => setTimeout(r, ms))
|
|
82
|
+
}
|
|
83
|
+
|
|
69
84
|
test('组合面板连开两次只拉一次上游(缓存生效)', async () => {
|
|
70
85
|
const router = new Router('')
|
|
71
86
|
const a = supplier('a', [{ id: 'a-1' }])
|
|
@@ -137,9 +152,129 @@ test('单个供应商拉模型失败不影响其它供应商', async () => {
|
|
|
137
152
|
assert.equal(groups[0]?.supplier.id, 'good')
|
|
138
153
|
})
|
|
139
154
|
|
|
140
|
-
|
|
155
|
+
/**
|
|
156
|
+
* 冷启动慢的回归(2026-09-03 实测):插件的 listModels 大多无条件打上游
|
|
157
|
+
* (0.3~1.0s/供应商),TTL 一过就穿透到插件,组合页取最慢那家 = 1s+ 白屏。
|
|
158
|
+
* 现在核心走 stale-while-revalidate:过期先返回旧值,后台刷新。
|
|
159
|
+
*/
|
|
160
|
+
test('过期后不等上游:立刻返回旧值,后台刷新', async () => {
|
|
161
|
+
const router = new Router('')
|
|
162
|
+
// 慢供应商:真等它就是 300ms
|
|
163
|
+
const a = supplier('a', [{ id: 'a-1' }], 300)
|
|
164
|
+
addSupplier(router, a.s)
|
|
165
|
+
|
|
166
|
+
await router.modelsOf('a')
|
|
167
|
+
assert.equal(a.state.calls, 1)
|
|
168
|
+
expire(router, 'a')
|
|
169
|
+
|
|
170
|
+
const t0 = Date.now()
|
|
171
|
+
const models = await router.modelsOf('a')
|
|
172
|
+
const dt = Date.now() - t0
|
|
173
|
+
|
|
174
|
+
assert.ok(dt < 100, `过期后不该同步等上游(实测 ${dt}ms)`)
|
|
175
|
+
assert.deepEqual(models.map((m) => m.id), ['a-1'], '旧值要立刻返回,面板不空')
|
|
176
|
+
await tick(400)
|
|
177
|
+
assert.equal(a.state.calls, 2, '后台要真的刷新一次')
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
test('后台刷新后缓存变新:再取不再触发拉取', async () => {
|
|
181
|
+
const router = new Router('')
|
|
182
|
+
const a = supplier('a', [{ id: 'a-1' }])
|
|
183
|
+
addSupplier(router, a.s)
|
|
184
|
+
|
|
185
|
+
await router.modelsOf('a')
|
|
186
|
+
expire(router, 'a')
|
|
187
|
+
await router.modelsOf('a') // 触发后台刷新
|
|
188
|
+
await tick()
|
|
189
|
+
assert.equal(a.state.calls, 2)
|
|
190
|
+
|
|
191
|
+
await router.modelsOf('a') // 已刷新过,不该再拉
|
|
192
|
+
await tick()
|
|
193
|
+
assert.equal(a.state.calls, 2)
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
test('并发刷新共享一次上游请求(不去重会把上游按 N 倍打)', async () => {
|
|
197
|
+
const router = new Router('')
|
|
198
|
+
const a = supplier('a', [{ id: 'a-1' }], 50)
|
|
199
|
+
addSupplier(router, a.s)
|
|
200
|
+
await router.modelsOf('a')
|
|
201
|
+
expire(router, 'a')
|
|
202
|
+
|
|
203
|
+
// 组合页的实际情况:多个入口同时触发同一个供应商
|
|
204
|
+
await Promise.all([router.modelsOf('a'), router.modelsOf('a'), router.modelsOf('a')])
|
|
205
|
+
await tick(200)
|
|
206
|
+
assert.equal(a.state.calls, 2, `并发只应多拉一次,实测 ${a.state.calls}`)
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
test('force 仍然同步等(用户主动点「获取模型」,要看到结果)', async () => {
|
|
210
|
+
const router = new Router('')
|
|
211
|
+
const a = supplier('a', [{ id: 'a-1' }], 150)
|
|
212
|
+
addSupplier(router, a.s)
|
|
213
|
+
await router.modelsOf('a')
|
|
214
|
+
expire(router, 'a')
|
|
215
|
+
|
|
216
|
+
const t0 = Date.now()
|
|
217
|
+
await router.modelsOf('a', true)
|
|
218
|
+
assert.ok(Date.now() - t0 >= 100, 'force 必须等上游拉完')
|
|
219
|
+
assert.equal(a.state.calls, 2)
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
test('拉到空列表不覆盖旧值(上游抖一下不该清空面板)', async () => {
|
|
223
|
+
const router = new Router('')
|
|
224
|
+
const a = supplier('a', [{ id: 'a-1' }])
|
|
225
|
+
addSupplier(router, a.s)
|
|
226
|
+
await router.modelsOf('a')
|
|
227
|
+
|
|
228
|
+
a.s.listModels = async (): Promise<ModelInfo[]> => []
|
|
229
|
+
expire(router, 'a')
|
|
230
|
+
const models = await router.modelsOf('a', true)
|
|
231
|
+
|
|
232
|
+
assert.deepEqual(models.map((m) => m.id), ['a-1'], '空结果不该冲掉旧列表')
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
test('后台刷新失败 → 保留旧值,不抛(fire-and-forget 没人接异常)', async () => {
|
|
236
|
+
const router = new Router('')
|
|
237
|
+
const a = supplier('a', [{ id: 'a-1' }])
|
|
238
|
+
addSupplier(router, a.s)
|
|
239
|
+
await router.modelsOf('a')
|
|
240
|
+
|
|
241
|
+
a.s.listModels = async (): Promise<ModelInfo[]> => {
|
|
242
|
+
throw new Error('upstream down')
|
|
243
|
+
}
|
|
244
|
+
expire(router, 'a')
|
|
245
|
+
const models = await router.modelsOf('a')
|
|
246
|
+
assert.deepEqual(models.map((m) => m.id), ['a-1'], '失败也要保住旧值')
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
test('首次就失败且无旧值 → 抛错(让 supplierModels 剔除该供应商,别显示空壳)', async () => {
|
|
250
|
+
const router = new Router('')
|
|
251
|
+
const a = supplier('a', [])
|
|
252
|
+
a.s.listModels = async (): Promise<ModelInfo[]> => {
|
|
253
|
+
throw new Error('upstream down')
|
|
254
|
+
}
|
|
255
|
+
addSupplier(router, a.s)
|
|
256
|
+
|
|
257
|
+
await assert.rejects(() => router.modelsOf('a'), /upstream down/)
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* 回归(2026-09-03 契约精简):删了插件 lastError() 后,testModel 的失败原因
|
|
262
|
+
* 必须从 chatOnce 返回值的 message 取(经 trace.lastError 透传)。以前问插件
|
|
263
|
+
* 的 lastError() 是个「最后一次失败」的可变全局,可能串别的请求,也有插件
|
|
264
|
+
* 压根没实现照样能走到。锁住:chatOnce 报的 message 能作为诊断呈现。
|
|
265
|
+
*/
|
|
266
|
+
test('testModel 失败 → 用 chatOnce 返回的 message 作诊断,不依赖插件 lastError()', async () => {
|
|
141
267
|
const router = new Router('')
|
|
142
|
-
|
|
268
|
+
const a = supplier('a', [{ id: 'a-1' }])
|
|
269
|
+
// 明确不实现 lastError()——合约删了它
|
|
270
|
+
a.s.chatOnce = async (): Promise<ChatOnceResult> =>
|
|
271
|
+
({ ok: false, state: 'quota', message: 'no quota for this plan' })
|
|
272
|
+
addSupplier(router, a.s)
|
|
273
|
+
|
|
274
|
+
const r = await router.testModel('a', 'a-1')
|
|
275
|
+
assert.equal(r.ok, false)
|
|
276
|
+
assert.match(r.error ?? '', /no quota for this plan/, '失败原因要来自 chatOnce 的 message')
|
|
277
|
+
assert.match(r.error ?? '', /账号\/额度\/限流问题/, '要带上可诊断的归类提示')
|
|
143
278
|
})
|
|
144
279
|
|
|
145
280
|
/**
|
|
@@ -311,7 +446,15 @@ test('流在写出第一个字节前就断 → 组合回退到下一个模型',
|
|
|
311
446
|
assert.equal(heads, 1, '响应头只应提交一次')
|
|
312
447
|
})
|
|
313
448
|
|
|
314
|
-
|
|
449
|
+
/**
|
|
450
|
+
* 已写出字节后断流:诚实截断,不回退也不二次写头 —— 但要**补一个终止帧**。
|
|
451
|
+
*
|
|
452
|
+
* 客户端(dsh-llm adapter)严格等 `[DONE]` 才认为流正常结束;缺了就抛
|
|
453
|
+
* `SSE payload stream ended without [DONE]`,整轮判失败,已经吐出去的内容
|
|
454
|
+
* 全部作废。所以核心补终止帧:断流降级为「拿到截断内容并正常收尾」,
|
|
455
|
+
* 而不是「整轮白干」。
|
|
456
|
+
*/
|
|
457
|
+
test('已写出字节后断流 → 截断并补 [DONE],不回退也不二次写头', async () => {
|
|
315
458
|
const router = new Router('')
|
|
316
459
|
const half = supplier('half', [{ id: 'half-m' }])
|
|
317
460
|
half.s.chatOnce = async (): Promise<ChatOnceResult> => ({
|
|
@@ -351,10 +494,81 @@ test('已写出字节后断流 → 诚实截断,不回退也不二次写头',
|
|
|
351
494
|
await router.chatCompletions({ model: 'c2', stream: true, rawBody: JSON.stringify({ model: 'c2', messages: [] }) }, res)
|
|
352
495
|
|
|
353
496
|
assert.ok(out.includes('半截'), '已投递的字节要送达客户端')
|
|
354
|
-
assert.ok(
|
|
497
|
+
assert.ok(out.includes('data: [DONE]'), '断流要补终止帧,否则客户端整轮判失败')
|
|
498
|
+
assert.equal(out.match(/\[DONE\]/g)?.length ?? 0, 1, '终止帧只能有一个(重复会坑客户端)')
|
|
499
|
+
assert.ok(!out.includes('兜底'), '已提交就不该再接第二个模型的内容(会串流)')
|
|
355
500
|
assert.equal(heads, 1, '响应头只能提交一次')
|
|
356
501
|
})
|
|
357
502
|
|
|
503
|
+
/** 上游自己发了终止帧就别再补一个(重复 [DONE] 同样坑客户端)。 */
|
|
504
|
+
test('上游已发 [DONE] → 不重复补', async () => {
|
|
505
|
+
const router = new Router('')
|
|
506
|
+
const s = supplier('sup', [{ id: 'm' }])
|
|
507
|
+
s.s.chatOnce = async (): Promise<ChatOnceResult> => ({
|
|
508
|
+
ok: true,
|
|
509
|
+
stream: new ReadableStream<Uint8Array>({
|
|
510
|
+
start(c) {
|
|
511
|
+
c.enqueue(new TextEncoder().encode('data: {"choices":[{"delta":{"content":"完整"}}]}\n\ndata: [DONE]\n\n'))
|
|
512
|
+
c.close()
|
|
513
|
+
},
|
|
514
|
+
}),
|
|
515
|
+
})
|
|
516
|
+
addSupplier(router, s.s)
|
|
517
|
+
|
|
518
|
+
let out = ''
|
|
519
|
+
const res = {
|
|
520
|
+
writeHead: (): void => {},
|
|
521
|
+
write: (b: Uint8Array | string): boolean => { out += Buffer.from(b).toString(); return true },
|
|
522
|
+
end: (): void => {},
|
|
523
|
+
once: (): void => {},
|
|
524
|
+
removeListener: (): void => {},
|
|
525
|
+
destroy: (): void => {},
|
|
526
|
+
} as unknown as ServerResponse
|
|
527
|
+
await router.chatCompletions({ model: 'sup/m', stream: true, rawBody: JSON.stringify({ model: 'sup/m', messages: [] }) }, res)
|
|
528
|
+
|
|
529
|
+
assert.equal(out.match(/\[DONE\]/g)?.length ?? 0, 1, `终止帧不能重复,实际: ${JSON.stringify(out)}`)
|
|
530
|
+
})
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* 一个字节都没写出就断 → 绝不能补 [DONE]。
|
|
534
|
+
* 补了就等于把这次失败坐实成一个空响应,组合回退(换号/换模型)就废了。
|
|
535
|
+
*/
|
|
536
|
+
test('首字节前就断 → 不补 [DONE],留给组合回退', async () => {
|
|
537
|
+
const router = new Router('')
|
|
538
|
+
const dead = supplier('dead', [{ id: 'dead-m' }])
|
|
539
|
+
dead.s.chatOnce = async (): Promise<ChatOnceResult> => ({
|
|
540
|
+
ok: true,
|
|
541
|
+
stream: new ReadableStream<Uint8Array>({ start(c) { c.error(new Error('上游刚连上就断')) } }),
|
|
542
|
+
})
|
|
543
|
+
const live = supplier('live', [{ id: 'live-m' }])
|
|
544
|
+
live.s.chatOnce = async (): Promise<ChatOnceResult> => ({
|
|
545
|
+
ok: true,
|
|
546
|
+
stream: new ReadableStream<Uint8Array>({
|
|
547
|
+
start(c) {
|
|
548
|
+
c.enqueue(new TextEncoder().encode('data: {"choices":[{"delta":{"content":"兜底"}}]}\n\ndata: [DONE]\n\n'))
|
|
549
|
+
c.close()
|
|
550
|
+
},
|
|
551
|
+
}),
|
|
552
|
+
})
|
|
553
|
+
addSupplier(router, dead.s)
|
|
554
|
+
addSupplier(router, live.s)
|
|
555
|
+
assert.equal(router.createCombo('c3', 'fallback', ['dead,dead-m', 'live,live-m']).ok, true)
|
|
556
|
+
|
|
557
|
+
let out = ''
|
|
558
|
+
const res = {
|
|
559
|
+
writeHead: (): void => {},
|
|
560
|
+
write: (b: Uint8Array | string): boolean => { out += Buffer.from(b).toString(); return true },
|
|
561
|
+
end: (): void => {},
|
|
562
|
+
once: (): void => {},
|
|
563
|
+
removeListener: (): void => {},
|
|
564
|
+
destroy: (): void => {},
|
|
565
|
+
} as unknown as ServerResponse
|
|
566
|
+
await router.chatCompletions({ model: 'c3', stream: true, rawBody: JSON.stringify({ model: 'c3', messages: [] }) }, res)
|
|
567
|
+
|
|
568
|
+
assert.equal(out.match(/\[DONE\]/g)?.length ?? 0, 1, '只有兜底那个流该有终止帧')
|
|
569
|
+
assert.ok(out.includes('兜底'), '必须回退到下一个模型')
|
|
570
|
+
})
|
|
571
|
+
|
|
358
572
|
/**
|
|
359
573
|
* 对照组:插件若用 unavailable 表示「不是我的模型」就会污染账号——
|
|
360
574
|
* 这正是当初把 no_such_model 加进契约的原因(unavailable 在 RULES 里计数)。
|
|
@@ -368,7 +582,9 @@ test('对照:用 unavailable 表示「不是我的模型」会污染账号', a
|
|
|
368
582
|
({ ok: false, state: 'unavailable', message: 'not my model' })
|
|
369
583
|
addSupplier(router, other.s)
|
|
370
584
|
|
|
371
|
-
|
|
585
|
+
// 严格图6拓扑:路由必须带供应商前缀/别名,裸 model 不再遍历。
|
|
586
|
+
// 这里用 alias/model 精准命中 other(别名默认 = 供应商 id)。
|
|
587
|
+
const req = { model: 'other/other-m', stream: false, rawBody: JSON.stringify({ model: 'other/other-m', messages: [] }) }
|
|
372
588
|
for (let i = 0; i < 5; i++) await router.chatCompletions(req, fakeRes())
|
|
373
589
|
|
|
374
590
|
const acc = other.s.pool.decorate([{ uid: 'u1', credits: 0, state: 'ok' }])[0]
|
|
@@ -387,7 +603,9 @@ test('组合请求不污染无关供应商的账号(no_such_model 不记账)
|
|
|
387
603
|
addSupplier(router, other.s)
|
|
388
604
|
addSupplier(router, target.s)
|
|
389
605
|
|
|
390
|
-
|
|
606
|
+
// 严格图6拓扑:用 alias/model 精准命中 target(别名默认 = 供应商 id),
|
|
607
|
+
// other 完全不该被问到——这正是这条回归要守的。
|
|
608
|
+
const req = { model: 'target/want-m', stream: false, rawBody: JSON.stringify({ model: 'target/want-m', messages: [] }) }
|
|
391
609
|
const res = fakeRes()
|
|
392
610
|
// 连打 5 次(远超错误阈值 3)
|
|
393
611
|
for (let i = 0; i < 5; i++) await router.chatCompletions(req, res)
|
|
@@ -49,8 +49,11 @@ function spy(id: string, modelId: string, alias = id): Spy {
|
|
|
49
49
|
chatOnce: async (_uid, req) => {
|
|
50
50
|
calls.n += 1
|
|
51
51
|
const m = (JSON.parse(req.rawBody) as { model?: string }).model ?? ''
|
|
52
|
-
seen.push(m)
|
|
53
|
-
|
|
52
|
+
seen.push(m) // 记核心传来的**原样**全名,剥前缀是插件内部的事
|
|
53
|
+
// 真实插件会先剥掉自己的 alias 再认模型(各 suppliers/*/plugin.ts
|
|
54
|
+
// 都有一份 stripAlias);这里照做,否则直接调用路径永远匹配不上。
|
|
55
|
+
const base = m.startsWith(`${alias}/`) ? m.slice(alias.length + 1) : m
|
|
56
|
+
if (base !== modelId) return { ok: false, state: 'no_such_model', message: `unknown model ${m}` }
|
|
54
57
|
return { ok: true, status: 200, body: '{"ok":1}' }
|
|
55
58
|
},
|
|
56
59
|
dispose: () => {},
|
|
@@ -131,19 +134,22 @@ test('模型 id 含斜杠时不被吃掉(如 deepseek-ai/xxx)', async () =>
|
|
|
131
134
|
assert.deepEqual(a.seen, ['aaa/deepseek-ai/deepseek-v3'])
|
|
132
135
|
})
|
|
133
136
|
|
|
134
|
-
test('
|
|
137
|
+
test('组合存了缺供应商前缀的裸模型 id → 直接失败,不降级遍历(严格图6拓扑)', async () => {
|
|
135
138
|
const router = new Router('')
|
|
136
139
|
const a = spy('supA', 'a-model')
|
|
137
140
|
const b = spy('supB', 'b-model')
|
|
138
141
|
add(router, a.s); add(router, b.s)
|
|
139
|
-
// 模拟旧存储:裸 id,没有 supplierId 前缀
|
|
142
|
+
// 模拟旧存储:裸 id,没有 supplierId 前缀 —— 图6 下这是配置错误,不遍历兜底
|
|
140
143
|
;(router as unknown as { customCombos: Array<{ id: string; name: string; strategy: 'fallback'; models: string[] }> })
|
|
141
144
|
.customCombos.push({ id: 'legacy', name: 'legacy', strategy: 'fallback', models: ['b-model'] })
|
|
142
145
|
|
|
143
|
-
|
|
146
|
+
const errs: unknown[] = []
|
|
147
|
+
const res = { ...fakeRes(), end: (x?: string): unknown => { if (x) errs.push(x); return undefined } } as unknown as ServerResponse
|
|
148
|
+
await router.chatCompletions(reqWith('legacy'), res)
|
|
144
149
|
|
|
145
|
-
assert.equal(a.calls,
|
|
146
|
-
assert.equal(b.calls,
|
|
150
|
+
assert.equal(a.calls, 0, '不降级遍历,不该问 supA')
|
|
151
|
+
assert.equal(b.calls, 0, '裸 id 无前缀,不该被 supB 认领')
|
|
152
|
+
assert.equal(errs.length, 1, '缺前缀的模型应判失败(组合 503)')
|
|
147
153
|
})
|
|
148
154
|
|
|
149
155
|
test('组合存了不存在的供应商 id → 调用失败,不静默落到别的供应商', async () => {
|
|
@@ -196,3 +202,100 @@ test('空别名 → 生效别名回落到供应商 id', () => {
|
|
|
196
202
|
store.setAlias('supA', 'custom')
|
|
197
203
|
assert.equal(store.effectiveAliases().get('supA'), 'custom')
|
|
198
204
|
})
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* 为什么要有这一组:用量统计记的 model 名曾把 alias 前缀套了两层
|
|
208
|
+
* (`tx/tx/hy4-preview`、`trae/trae/DeepSeek-V4-Flash-Official`)。
|
|
209
|
+
*
|
|
210
|
+
* 成因:直接调 `alias/model` 时核心**原样**把全名传给插件(剥前缀是插件
|
|
211
|
+
* 的活,见上面那条断言),但记 `probe.model` 时又拼了一次 `${alias}/`
|
|
212
|
+
* ——于是统计里出现双前缀。功能不受影响(插件拿到的名字是对的),但
|
|
213
|
+
* Top 榜会把同一个模型**分裂成两行**(`tx/hy4-preview` 和
|
|
214
|
+
* `tx/tx/hy4-preview` 各占一行),看板数据失真。
|
|
215
|
+
*/
|
|
216
|
+
test('用量统计:直接调 alias/model 时 model 名不重复套前缀', async () => {
|
|
217
|
+
const router = new Router('')
|
|
218
|
+
const b = spy('supB', 'b-model', 'bbb')
|
|
219
|
+
add(router, b.s)
|
|
220
|
+
|
|
221
|
+
await router.chatCompletions(reqWith('bbb/b-model'), fakeRes())
|
|
222
|
+
|
|
223
|
+
const rec = router.usage.recentList(1)[0]
|
|
224
|
+
assert.equal(rec?.model, 'bbb/b-model', '统计名必须是 alias/model,不能是 alias/alias/model')
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
test('直接调裸模型 id(无前缀)→ 判失败,不再遍历兜底(严格图6拓扑)', async () => {
|
|
228
|
+
const router = new Router('')
|
|
229
|
+
const b = spy('supB', 'b-model', 'bbb')
|
|
230
|
+
add(router, b.s)
|
|
231
|
+
|
|
232
|
+
const errs: unknown[] = []
|
|
233
|
+
const res = { ...fakeRes(), end: (x?: string): unknown => { if (x) errs.push(x); return undefined } } as unknown as ServerResponse
|
|
234
|
+
await router.chatCompletions(reqWith('b-model'), res)
|
|
235
|
+
|
|
236
|
+
assert.equal(b.calls, 0, '裸 id 不该遍历到 supB')
|
|
237
|
+
assert.equal(errs.length, 1, '无前缀的裸模型应判失败(503)')
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* 为什么要有这一组:学 9router combo.js 的「瞬时故障降级前先喘口气」
|
|
242
|
+
* (其注释原话:fixes: combo falls through on transient 503)。
|
|
243
|
+
*
|
|
244
|
+
* 过载的上游往往几百毫秒内就恢复。立刻换下一个模型,会让整个组合在几
|
|
245
|
+
* 毫秒内被依次打穿 —— 最后一个模型也失败时,用户看到的就是全灭 503。
|
|
246
|
+
* 等待只针对**瞬时**故障:模型不属于本供应商(no_such_model)不是故障,
|
|
247
|
+
* 等它纯属浪费时间。
|
|
248
|
+
*/
|
|
249
|
+
|
|
250
|
+
/** 造一个总是以指定状态失败的供应商(可记调用时刻,用于验证等待)。 */
|
|
251
|
+
function failingSpy(id: string, modelId: string, state: 'transport' | 'unavailable' | 'no_such_model') {
|
|
252
|
+
const at: number[] = []
|
|
253
|
+
return {
|
|
254
|
+
calls: 0,
|
|
255
|
+
at,
|
|
256
|
+
s: {
|
|
257
|
+
id, name: id, priority: 0, pool: new AccountPool(),
|
|
258
|
+
status: () => ({ id, name: id, accounts: [{ uid: `${id}-u1`, credits: 0, state: 'ok' }] }),
|
|
259
|
+
listModels: async () => [{ id: modelId }],
|
|
260
|
+
modelsWithEnabled: async () => [{ id: modelId, enabled: true }],
|
|
261
|
+
getAlias: () => id,
|
|
262
|
+
accounts: () => [{ uid: `${id}-u1`, credits: 0, state: 'ok' }],
|
|
263
|
+
chatOnce: async (_uid: string, _req: { rawBody: string }) => {
|
|
264
|
+
at.push(Date.now())
|
|
265
|
+
return { ok: false, state, message: `${state} boom` } as ChatOnceResult
|
|
266
|
+
},
|
|
267
|
+
dispose: () => {},
|
|
268
|
+
},
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
test('组合:瞬时故障降级前会等一等,不是毫秒级打穿整个组合', async () => {
|
|
273
|
+
const router = new Router('')
|
|
274
|
+
const m1 = failingSpy('s1', 'm1', 'transport')
|
|
275
|
+
const m2 = failingSpy('s2', 'm2', 'unavailable')
|
|
276
|
+
add(router, m1.s); add(router, m2.s)
|
|
277
|
+
router.createCombo('combo', 'fallback', ['s1,m1', 's2,m2'])
|
|
278
|
+
|
|
279
|
+
const t0 = Date.now()
|
|
280
|
+
await router.chatCompletions(reqWith('combo'), fakeRes())
|
|
281
|
+
const elapsed = Date.now() - t0
|
|
282
|
+
|
|
283
|
+
// 两个都失败 → 全灭;但 s1 瞬时失败后应等一个窗口再试 s2
|
|
284
|
+
assert.equal(m1.at.length, 1)
|
|
285
|
+
assert.equal(m2.at.length, 1)
|
|
286
|
+
assert.ok(elapsed >= 1000, `瞬时故障降级前应等待(实际 ${elapsed}ms),否则组合会被瞬间打穿`)
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
test('组合:no_such_model 不是故障,降级不该等', async () => {
|
|
290
|
+
const router = new Router('')
|
|
291
|
+
const m1 = failingSpy('s1', 'm1', 'no_such_model')
|
|
292
|
+
const m2 = failingSpy('s2', 'm2', 'no_such_model')
|
|
293
|
+
add(router, m1.s); add(router, m2.s)
|
|
294
|
+
router.createCombo('combo', 'fallback', ['s1,m1', 's2,m2'])
|
|
295
|
+
|
|
296
|
+
const t0 = Date.now()
|
|
297
|
+
await router.chatCompletions(reqWith('combo'), fakeRes())
|
|
298
|
+
const elapsed = Date.now() - t0
|
|
299
|
+
|
|
300
|
+
assert.ok(elapsed < 1000, `模型不存在是配置问题不是故障,不该等(实际 ${elapsed}ms)`)
|
|
301
|
+
})
|
package/src/router/types.ts
CHANGED
|
@@ -80,8 +80,6 @@ export interface Supplier {
|
|
|
80
80
|
accounts(): SupplierAccountNow[]
|
|
81
81
|
/** 对单个账号调一次上游。不遍历账号、不管冷却、不写 res。 */
|
|
82
82
|
chatOnce(uid: string, req: ChatRequest): Promise<ChatOnceResult>
|
|
83
|
-
/** 上次 chatOnce 失败原因(诊断用,可选)。 */
|
|
84
|
-
lastError?(): string | undefined
|
|
85
83
|
/** 删除链接(清理凭证;内部能力,js 契约不要求实现)。 */
|
|
86
84
|
removeLink?(uid: string): Promise<boolean>
|
|
87
85
|
dispose(): void
|