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.
Files changed (37) hide show
  1. package/README.md +6 -2
  2. package/docs/suppliers.md +87 -30
  3. package/lib/client-registry.js +20 -11
  4. package/lib/client.js +20 -11
  5. package/lib/index.js +751 -264
  6. package/lib/types/llm/adapter.d.ts +7 -1
  7. package/lib/types/router/account-pool.d.ts +54 -19
  8. package/lib/types/router/index.d.ts +28 -5
  9. package/lib/types/router/types.d.ts +0 -2
  10. package/lib/types/router/usage-store.d.ts +20 -3
  11. package/lib/types/shared.d.ts +2 -0
  12. package/lib/types/supplier-config.d.ts +23 -0
  13. package/lib/types/suppliers/contract.d.ts +9 -6
  14. package/package.json +1 -1
  15. package/src/client/RouterView.tsx +1 -1
  16. package/src/client/SupplierDetail.tsx +15 -10
  17. package/src/llm/adapter.test.ts +404 -1
  18. package/src/llm/adapter.ts +242 -34
  19. package/src/router/account-pool.test.ts +163 -61
  20. package/src/router/account-pool.ts +200 -68
  21. package/src/router/index.ts +225 -66
  22. package/src/router/models-cache.test.ts +224 -6
  23. package/src/router/precise-dispatch.test.ts +110 -7
  24. package/src/router/types.ts +0 -2
  25. package/src/router/usage-store.ts +174 -74
  26. package/src/router/usage.test.ts +115 -0
  27. package/src/shared.ts +2 -0
  28. package/src/supplier-config.test.ts +98 -0
  29. package/src/supplier-config.ts +60 -1
  30. package/src/suppliers/contract.ts +9 -9
  31. package/src/suppliers/loader.test.ts +118 -0
  32. package/src/suppliers/loader.ts +21 -5
  33. package/src/suppliers/nvidia/plugin.ts +12 -15
  34. package/src/suppliers/opencode/plugin.ts +10 -13
  35. package/src/suppliers/openrouter/plugin.ts +12 -15
  36. package/src/suppliers/registry.test.ts +23 -3
  37. 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
- test('未知供应商返回空,不抛错', async () => {
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
- assert.deepEqual(await router.modelsOf('nonexistent'), [])
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
- test('已写出字节后断流 → 诚实截断,不回退也不二次写头', async () => {
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(!out.includes('[DONE]'), '已提交就不该再接第二个模型的内容(会串流)')
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
- const req = { model: 'want-m', stream: false, rawBody: JSON.stringify({ model: 'want-m', messages: [] }) }
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
- const req = { model: 'want-m', stream: false, rawBody: JSON.stringify({ model: 'want-m', messages: [] }) }
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
- if (m !== modelId) return { ok: false, state: 'no_such_model', message: `unknown model ${m}` }
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('旧数据裸模型 id 仍能工作(降级为遍历)', async () => {
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
- await router.chatCompletions(reqWith('legacy'), fakeRes())
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, 1, '降级路径会先问 supA(它报 no_such_model)')
146
- assert.equal(b.calls, 1, '最终由 supB 服务')
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
+ })
@@ -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