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
@@ -8,6 +8,7 @@ import { dirname, join } from 'node:path'
8
8
  import type { ServerResponse } from 'node:http'
9
9
  import type { Supplier, ChatRequest, ModelInfo, ModelWithEnabled, SupplierStatus, Combo } from './types.ts'
10
10
  import type { ChatOnceResult } from '../suppliers/contract.ts'
11
+ import type { AccountState } from '../suppliers/contract.ts'
11
12
  import { AccountPool } from './account-pool.ts'
12
13
  import { SupplierConfigStore } from '../supplier-config.ts'
13
14
  import { UsageStore } from './usage-store.ts'
@@ -21,10 +22,19 @@ interface ChatTrace {
21
22
  attempts: number
22
23
  /** 最后一次失败原因。 */
23
24
  lastError?: string
25
+ /** 最后一次失败的状态(组合据此判断要不要「喘口气」再降级)。 */
26
+ lastState?: AccountState
24
27
  }
25
28
 
26
- /** 模型列表缓存有效期。 */
27
- const MODELS_TTL_MS = 60 * 1000
29
+ /**
30
+ * 模型列表缓存有效期。
31
+ *
32
+ * 取 10 分钟而不是 60s:模型列表本来很少变(上游上新/下线才动),而每次
33
+ * TTL 过期都会穿透到插件真打一次上游——实测冷路径 0.3~1.0s/供应商,组合页
34
+ * 取最慢那家就是 1s+ 的白屏。拉长 TTL 直接把这个穿透频率降一个量级;真要
35
+ * 立刻看新模型,点「获取模型」走 force 即可。
36
+ */
37
+ const MODELS_TTL_MS = 10 * 60 * 1000
28
38
 
29
39
  function openAIError(code: string, msg: string): Record<string, unknown> {
30
40
  return { error: { message: msg, type: 'api_error', code } }
@@ -38,6 +48,36 @@ function writeJson(res: ServerResponse, status: number, body: unknown): void {
38
48
  res.end(JSON.stringify(body))
39
49
  }
40
50
 
51
+ /**
52
+ * 剥掉本供应商自己的 alias 前缀(只剥一层,且只认完整 `alias/` 开头)。
53
+ *
54
+ * 为什么不能用 lastIndexOf:模型 id 本身可以含斜杠(nvidia 的
55
+ * `deepseek-ai/deepseek-v4-flash-0731`),剥最后一段会把命名空间吃掉,
56
+ * 后面请求必然 404。只认「已知 alias + /」开头,其他原样返回。
57
+ *
58
+ * 注意:核心**只在记统计名时**用它。传给插件的 model 全名保持原样——
59
+ * 剥前缀是插件自己的事(各供应商插件都有一份同名实现)。
60
+ */
61
+ function stripAlias(model: string, alias: string): string {
62
+ return alias !== '' && model.startsWith(`${alias}/`) ? model.slice(alias.length + 1) : model
63
+ }
64
+
65
+ /**
66
+ * 组合降级前的「喘口气」等待:瞬时故障给上游一个恢复窗口再换下一个模型。
67
+ *
68
+ * 学 9router combo.js 的同名处理(注释原话:fixes: combo falls through on
69
+ * transient 503)—— 过载的上游往往几百毫秒内就恢复,立刻换下一个模型会
70
+ * 让整个组合在几毫秒内被打穿,最后全灭返回 503。
71
+ *
72
+ * 只对**瞬时**故障等:连接层失败 / 上游不可用(对应 9router 的 502/503/504)。
73
+ * 模型不属于本供应商(no_such_model)绝不能等 —— 它不是故障,等纯属浪费。
74
+ */
75
+ const TRANSIENT_SETTLE_MS = 2_000
76
+
77
+ function isTransient(state: AccountState): boolean {
78
+ return state === 'transport' || state === 'unavailable'
79
+ }
80
+
41
81
  /** 丢弃响应的假 ServerResponse(测试模型用:记录状态+内容,用于判定成败)。 */
42
82
  function sinkRes(): ServerResponse & { status(): number; body(): string } {
43
83
  let status = 200
@@ -163,7 +203,9 @@ async function aggregateSSE(stream: ReadableStream<Uint8Array>): Promise<string>
163
203
  }
164
204
 
165
205
  /**
166
- * 流式 SSE 逐帧归一化:剥掉上游多发一次的**空** tool_call 字段。
206
+ * 流式 SSE 逐帧归一化:剥掉上游多发一次的**空** tool_call 字段,
207
+ * 并记住上游有没有发终止帧(`data: [DONE]`)—— 断流时核心要据此补发,
208
+ * 见 `writeChatResult`。
167
209
  *
168
210
  * 为什么必须做:CodeBuddy 上游的工具调用第二个 delta 会**显式发空串**
169
211
  * `{"function":{"name":"","arguments":"{...}"},"index":0}`(以及 `"id":""`)。
@@ -178,11 +220,16 @@ async function aggregateSSE(stream: ReadableStream<Uint8Array>): Promise<string>
178
220
  * 只动 tool_calls 的空 name/id,其余原样透传;逐帧流式处理,不缓冲整条流
179
221
  * (首字节延迟不受影响)。解析失败一律原样透传——宁可不改,也不能改坏。
180
222
  */
181
- function normalizeSSEStream(stream: ReadableStream<Uint8Array>): ReadableStream<Uint8Array> {
223
+ function normalizeSSEStream(stream: ReadableStream<Uint8Array>): {
224
+ stream: ReadableStream<Uint8Array>
225
+ /** 上游(或最后一帧残留)里出现过终止帧?调用方在流结束后读。 */
226
+ sawDone: () => boolean
227
+ } {
182
228
  const enc = new TextEncoder()
183
229
  const dec = new TextDecoder()
184
230
  let buf = ''
185
- return new ReadableStream<Uint8Array>({
231
+ let sawDone = false
232
+ const out = new ReadableStream<Uint8Array>({
186
233
  start: async (ctrl) => {
187
234
  const reader = stream.getReader()
188
235
  try {
@@ -195,16 +242,26 @@ function normalizeSSEStream(stream: ReadableStream<Uint8Array>): ReadableStream<
195
242
  while ((sep = buf.indexOf('\n\n')) >= 0) {
196
243
  const frame = buf.slice(0, sep + 2)
197
244
  buf = buf.slice(sep + 2)
245
+ if (isDoneFrame(frame)) sawDone = true
198
246
  ctrl.enqueue(enc.encode(fixFrame(frame)))
199
247
  }
200
248
  }
201
- if (buf !== '') ctrl.enqueue(enc.encode(fixFrame(buf)))
249
+ if (buf !== '') {
250
+ if (isDoneFrame(buf)) sawDone = true
251
+ ctrl.enqueue(enc.encode(fixFrame(buf)))
252
+ }
202
253
  } finally {
203
254
  reader.releaseLock()
204
255
  }
205
256
  ctrl.close()
206
257
  },
207
258
  })
259
+ return { stream: out, sawDone: () => sawDone }
260
+ }
261
+
262
+ /** 一帧 SSE 里有没有终止帧(宽松匹配:跨 chunk 的残留尾帧也认)。 */
263
+ function isDoneFrame(frame: string): boolean {
264
+ return /(^|\n)data:[ \t]*\[DONE\][ \t]*(\n|$)/.test(frame)
208
265
  }
209
266
 
210
267
  /** 修一帧 SSE:只剥空的 name/id,出任何问题都原样返回。 */
@@ -246,6 +303,9 @@ function fixFrame(frame: string): string {
246
303
  * 先写头就等于把自己锁死在这一个账号上,组合就没法回退了——而客户端只拿到
247
304
  * 一个空/截断的 SSE,表现为工具调用名是空串(`unknown tool ""`)。
248
305
  *
306
+ * 截断时**补发终止帧**(见末尾 finally):客户端严格等 `[DONE]`,缺了就
307
+ * 整轮判失败,已流式吐出的内容全废。
308
+ *
249
309
  * 天花板:一旦写出第一个字节就绑死(HTTP 语义),中途断流只能截断,
250
310
  * 不能回退。要彻底解决得在写头前缓冲判定,代价是首字节延迟——未做。
251
311
  */
@@ -276,8 +336,11 @@ async function writeChatResult(res: ServerResponse, r: ChatOnceResult, wantsStre
276
336
  // 流式:边透传边统计(统计在 tee 之外的旁路做,首字节延迟不受影响)
277
337
  const tapped = probe === undefined ? undefined : tapStreamUsage(r.stream, startedAt)
278
338
  let wroteAny = false
339
+ /** 响应已死(客户端断开/res 已销毁):等 drain 的写入要立刻放弃,别吊死。 */
340
+ let dead = false
279
341
  const writeChunk = (chunk: Uint8Array): Promise<void> =>
280
342
  new Promise<void>((resolve) => {
343
+ if (dead) { resolve(); return }
281
344
  if (!wroteAny) {
282
345
  // 第一个字节到手,此刻才提交响应头
283
346
  res.writeHead(200, {
@@ -292,20 +355,37 @@ async function writeChatResult(res: ServerResponse, r: ChatOnceResult, wantsStre
292
355
  wroteAny = true
293
356
  }
294
357
  if (res.write(chunk)) resolve()
295
- else res.once('drain', resolve)
358
+ else {
359
+ // 背压:等 drain。但连接可能在这期间断掉 —— 那时 'drain' 永不到来,
360
+ // 得靠 dead 标志(onError 里置位)兜住,否则这次请求会永远挂着。
361
+ const onDrain = (): void => { res.removeListener('error', onDrain); resolve() }
362
+ res.once('drain', onDrain)
363
+ }
296
364
  })
297
365
 
298
366
  // 流中途出错(客户端断开/上游断流)也要结束响应,否则连接悬挂
299
367
  const onError = (): void => {
368
+ dead = true
300
369
  res.destroy()
301
370
  }
302
371
  res.once('error', onError)
372
+ const norm = normalizeSSEStream(tapped?.stream ?? r.stream)
303
373
  try {
304
- await normalizeSSEStream(tapped?.stream ?? r.stream).pipeTo(new WritableStream<Uint8Array>({ write: writeChunk }))
374
+ await norm.stream.pipeTo(new WritableStream<Uint8Array>({ write: writeChunk }))
305
375
  } catch {
306
376
  // 流断了:一个字节都没写的话,调用方还能换号重试——交给上层决定
307
377
  } finally {
308
378
  res.removeListener('error', onError)
379
+ // 断流也要给客户端一个终止帧。客户端(dsh-llm adapter)严格等
380
+ // `[DONE]`,缺了就整轮判失败(`SSE payload stream ended without
381
+ // [DONE]`),已经流式吐出去的内容全部作废。上游自己发了就不重复发
382
+ // (多发一个 [DONE] 同样坑客户端)。
383
+ //
384
+ // 只在**已提交响应**时补:一个字节都没写 = 还能换号重试,补了就等于
385
+ // 把这次失败坐实成一个空响应,组合回退就没了。
386
+ if (wroteAny && !norm.sawDone() && !dead && !res.writableEnded && !res.destroyed) {
387
+ await writeChunk(DONE_FRAME)
388
+ }
309
389
  if (wroteAny) res.end()
310
390
  // 流走完才出得了 usage(上游多在最后一帧才发 usage)
311
391
  if (probe !== undefined && tapped !== undefined) {
@@ -318,6 +398,9 @@ async function writeChatResult(res: ServerResponse, r: ChatOnceResult, wantsStre
318
398
  return wroteAny
319
399
  }
320
400
 
401
+ /** SSE 终止帧(断流兜底补发用)。 */
402
+ const DONE_FRAME = new TextEncoder().encode('data: [DONE]\n\n')
403
+
321
404
  /** 从上游响应体提取错误信息(OpenAI {error:{message}} 或 {code,msg}/{message})。 */
322
405
  function extractUpstreamError(body: string): string {
323
406
  try {
@@ -368,6 +451,11 @@ export class Router {
368
451
  private store: SupplierConfigStore
369
452
  /** 模型列表缓存(supplierId → 模型 + 拉取时间)。插件只管拉,不缓存。 */
370
453
  private modelsCache = new Map<string, { models: ModelWithEnabled[]; fetchedAt: number }>()
454
+ /**
455
+ * 正在拉模型的供应商 → in-flight promise。
456
+ * 组合页会同时从多个入口触发同一个供应商的刷新,不去重上游就被按 N 倍打。
457
+ */
458
+ private modelsInflight = new Map<string, Promise<ModelWithEnabled[]>>()
371
459
  /** 请求日志出口(面板/宿主 logger)。 */
372
460
  private log: (msg: string) => void
373
461
  /** 用量统计(概览看板)。 */
@@ -516,37 +604,80 @@ export class Router {
516
604
  * 取某供应商的模型(核心统一缓存)。
517
605
  *
518
606
  * 模型列表只在这里缓存一次:`/suppliers/:id/models`(供应商详情)和
519
- * `/combos`(组合加模型)共用。组合面板**不主动打上游**——它只是读缓存,
520
- * 冷启动(缓存还没建)时才拉一次;真正刷新由详情页打开或「获取模型」
521
- * 按钮(force)触发。
607
+ * `/combos`(组合加模型)共用。
608
+ *
609
+ * **过期后走 stale-while-revalidate**:先返回旧值让面板立刻有内容,后台
610
+ * 重新拉,拉完下次请求就是新的。为什么必须这样:插件的 `listModels` 大多
611
+ * 无条件打上游(实测冷路径 0.3~1.0s/供应商),而组合页要拉全部供应商、
612
+ * 取最慢那家——同步等就是 1s+ 的白屏。旧值只可能「少列了新模型」,
613
+ * 比白屏划算;想立刻看新模型点「获取模型」(force)即可。
522
614
  *
523
- * 天花板:TTL 60s 是拍的。若上游模型列表变更很频繁,可调小;要彻底实时
524
- * 就得让供应商暴露 etag/版本号,目前没有这个需求。
615
+ * 只有两种情况会真的等:`force`(用户主动刷新)和**从来没有过缓存值**
616
+ * (冷启动,没有旧值可给)。后者只发生一次。
525
617
  */
526
618
  async modelsOf(supplierId: string, force = false): Promise<ModelWithEnabled[]> {
527
619
  const s = this.suppliers.find((x) => x.id === supplierId)
528
620
  if (s === undefined) return []
529
621
  const hit = this.modelsCache.get(supplierId)
530
- if (!force && hit !== undefined && Date.now() - hit.fetchedAt < MODELS_TTL_MS) return hit.models
531
- const cfg = this.store.get(supplierId)
532
- const custom = new Set(cfg.custom)
533
- const disabled = new Set(cfg.disabled)
534
- const list = await Promise.resolve(s.listModels())
535
- const models: ModelWithEnabled[] = list.map((mm) => ({
536
- ...mm,
537
- enabled: !disabled.has(mm.id),
538
- custom: custom.has(mm.id) ? true : undefined,
539
- }))
540
- // 自定义模型(listModels 之外的)并入显示
541
- const seen = new Set(models.map((mm) => mm.id))
542
- for (const id of custom) {
543
- if (!seen.has(id)) {
544
- seen.add(id)
545
- models.push({ id, enabled: !disabled.has(id), custom: true })
546
- }
622
+ const fresh = hit !== undefined && Date.now() - hit.fetchedAt < MODELS_TTL_MS
623
+ if (!force && fresh) return hit!.models
624
+ // 有旧值(只是过期了):立刻返回旧值,后台刷新
625
+ if (!force && hit !== undefined) {
626
+ // 后台刷新的失败不能冒出来:这是 fire-and-forget,没人接
627
+ void this.refreshModels(supplierId, s).catch(() => {})
628
+ return hit.models
547
629
  }
548
- this.modelsCache.set(supplierId, { models, fetchedAt: Date.now() })
549
- return models
630
+ return await this.refreshModels(supplierId, s)
631
+ }
632
+
633
+ /**
634
+ * 真拉一次上游并写缓存。并发调用共享同一个 in-flight promise——
635
+ * 组合页会同时触发多个入口,不去重会把上游按 N 倍打(各家插件自己没做这层)。
636
+ *
637
+ * 失败时:**有旧值就退回旧值**(宁可显示几分钟前的列表,也别让面板空掉),
638
+ * **没旧值就把错误抛回去**——调用方(`supplierModels`)据此把该供应商整个
639
+ * 剔除,而不是显示一个「有 0 个模型」的空壳。
640
+ */
641
+ private refreshModels(supplierId: string, s: Supplier): Promise<ModelWithEnabled[]> {
642
+ const inflight = this.modelsInflight.get(supplierId)
643
+ if (inflight !== undefined) return inflight
644
+ const p = (async (): Promise<ModelWithEnabled[]> => {
645
+ const cfg = this.store.get(supplierId)
646
+ const custom = new Set(cfg.custom)
647
+ const disabled = new Set(cfg.disabled)
648
+ const list = await Promise.resolve(s.listModels())
649
+ const models: ModelWithEnabled[] = list.map((mm) => ({
650
+ ...mm,
651
+ enabled: !disabled.has(mm.id),
652
+ custom: custom.has(mm.id) ? true : undefined,
653
+ }))
654
+ // 自定义模型(listModels 之外的)并入显示
655
+ const seen = new Set(models.map((mm) => mm.id))
656
+ for (const id of custom) {
657
+ if (!seen.has(id)) {
658
+ seen.add(id)
659
+ models.push({ id, enabled: !disabled.has(id), custom: true })
660
+ }
661
+ }
662
+ const stale = this.modelsCache.get(supplierId)
663
+ // 拉到空列表当「上游抖了一下」:有旧值就保住旧值并返回它,别把面板清空。
664
+ // 真的一家模型都没有的供应商,第一次(无旧值)就该拿到空,不受影响。
665
+ if (models.length === 0 && stale !== undefined && stale.models.length > 0) {
666
+ return stale.models
667
+ }
668
+ this.modelsCache.set(supplierId, { models, fetchedAt: Date.now() })
669
+ return models
670
+ })()
671
+ .catch((err: unknown) => {
672
+ const stale = this.modelsCache.get(supplierId)
673
+ if (stale !== undefined) return stale.models
674
+ throw err instanceof Error ? err : new Error(String(err))
675
+ })
676
+ .finally(() => {
677
+ this.modelsInflight.delete(supplierId)
678
+ })
679
+ this.modelsInflight.set(supplierId, p)
680
+ return p
550
681
  }
551
682
 
552
683
  /** 失效某供应商的模型缓存(增删改模型后调用)。 */
@@ -696,18 +827,26 @@ export class Router {
696
827
  for (let i = 0; i < combo.models.length; i++) {
697
828
  const model = combo.models[(start + i) % combo.models.length]
698
829
  if (model === undefined) continue
699
- const served = await this.chatWithModel(req, res, model, probe)
830
+ const trace: ChatTrace = { attempts: 0 }
831
+ const served = await this.chatWithModel(req, res, model, probe, trace)
700
832
  if (served) {
701
833
  settle(true)
702
834
  return
703
835
  }
836
+ // 瞬时故障:先给上游一个恢复窗口再换下一个模型。否则过载的上游
837
+ // 会被整个组合在几毫秒内依次打穿 —— 最后一次失败就是全灭 503。
838
+ if (trace.lastState !== undefined && isTransient(trace.lastState) && i < combo.models.length - 1) {
839
+ await new Promise((r) => setTimeout(r, TRANSIENT_SETTLE_MS))
840
+ }
704
841
  }
705
842
  settle(false, 'combo: all models unavailable')
706
843
  writeJson(res, 503, openAIError('no_healthy_supplier', `combo ${JSON.stringify(req.model)}: all models unavailable`))
707
844
  return
708
845
  }
709
- // 直接调用:模型全名 = alias/model。用别名反查供应商,精准调用;
710
- // 别名查不到(拼错 / 供应商没加载)才退化为遍历,保持旧行为可用。
846
+ // 直接调用:模型全名 = alias/model。用别名反查供应商,精准调用。
847
+ // 查不到别名(无 `alias/` 前缀)就走 chatWithModel,那里会要求
848
+ // `supplierId,modelId` 形态;两者都不满足 = 缺供应商前缀 → 直接 503,
849
+ // 不再遍历所有供应商兜底(严格图6拓扑)。
711
850
  const slash = req.model.indexOf('/')
712
851
  const alias = slash > 0 ? req.model.slice(0, slash) : ''
713
852
  const target = alias === '' ? undefined : this.supplierByAlias(alias)
@@ -744,7 +883,12 @@ export class Router {
744
883
  * 不再挨个问供应商「这是不是你的模型」——精准调用,也避免同名模型串台。
745
884
  * 兼容旧的裸 `modelId`(没有逗号):降级为遍历,行为同以前。
746
885
  */
747
- private async chatWithModel(req: ChatRequest, res: ServerResponse, model: string, probe?: UsageProbe): Promise<boolean> {
886
+ /**
887
+ * @param outTrace 可选出参:把这次调用的追踪信息带回去(组合据此判断
888
+ * 要不要「喘口气」再降级)。裸 id 遍历路径不填(多供应商混在一起,
889
+ * 失败状态没有单一归属)。
890
+ */
891
+ private async chatWithModel(req: ChatRequest, res: ServerResponse, model: string, probe?: UsageProbe, outTrace?: ChatTrace): Promise<boolean> {
748
892
  const comma = model.indexOf(',')
749
893
  const supplierId = comma > 0 ? model.slice(0, comma) : undefined
750
894
  // 组合存的是供应商 id,但插件认的是自己的模型 id
@@ -759,29 +903,32 @@ export class Router {
759
903
  clone.rawBody = req.rawBody
760
904
  }
761
905
 
762
- if (supplierId !== undefined) {
763
- // 精准:只调这一个供应商。查不到就是配置错了,直接失败(不遍历兜底——
764
- // 遍历会让拼错的 id 静默落到别的供应商上,更难查)
765
- const s = this.suppliers.find((x) => x.id === supplierId)
766
- if (s === undefined) {
767
- this.logChat(req.model, `supplier ${JSON.stringify(supplierId)} 不存在`, 0)
768
- return false
769
- }
770
- const t0 = Date.now()
771
- const trace: ChatTrace = { attempts: 0 }
772
- const served = await this.chatWithSupplier(s, clone, res, trace, probe)
773
- this.logChat(req.model, served
774
- ? `${s.id}/${modelId} (${trace.uid ?? '?'}) ok${trace.attempts > 0 ? ` 重试${trace.attempts}次` : ''}`
775
- : `${s.id}/${modelId} 失败 ${trace.lastError ?? 'no account'}`, Date.now() - t0)
776
- return served
906
+ // 图6 严格拓扑:组合/路由里的每个模型都必须带 `supplierId,modelId` 前缀。
907
+ // 无逗号 = 配置缺供应商前缀(旧版裸 id 遗留),不再遍历所有供应商兜底——
908
+ // 遍历会让拼错的/缺前缀的模型静默落到某个供应商上,更难查。直接判失败。
909
+ if (supplierId === undefined) {
910
+ const msg = `model ${JSON.stringify(model)} 缺供应商前缀(应为 supplierId,modelId)`
911
+ this.logChat(req.model, msg, 0)
912
+ return false
777
913
  }
778
-
779
- // 旧格式裸模型 id:退化为遍历
780
- for (const s of this.suppliers) {
781
- const served = await this.chatWithSupplier(s, clone, res, undefined, probe)
782
- if (served) return true
914
+ // 精准:只调这一个供应商。查不到就是配置错了,直接失败(不遍历兜底)
915
+ const s = this.suppliers.find((x) => x.id === supplierId)
916
+ if (s === undefined) {
917
+ this.logChat(req.model, `supplier ${JSON.stringify(supplierId)} 不存在`, 0)
918
+ return false
919
+ }
920
+ const t0 = Date.now()
921
+ const trace: ChatTrace = { attempts: 0 }
922
+ const served = await this.chatWithSupplier(s, clone, res, trace, probe)
923
+ if (outTrace !== undefined) {
924
+ outTrace.attempts = trace.attempts
925
+ outTrace.lastError = trace.lastError
926
+ outTrace.lastState = trace.lastState
783
927
  }
784
- return false
928
+ this.logChat(req.model, served
929
+ ? `${s.id}/${modelId} (${trace.uid ?? '?'}) ok${trace.attempts > 0 ? ` 重试${trace.attempts}次` : ''}`
930
+ : `${s.id}/${modelId} 失败 ${trace.lastError ?? 'no account'}`, Date.now() - t0)
931
+ return served
785
932
  }
786
933
 
787
934
  /** 按别名找供应商(对外模型全名 = alias/model)。别名唯一,故最多命中一个。 */
@@ -806,7 +953,8 @@ export class Router {
806
953
  const r = await s.chatOnce('', req)
807
954
  if (!r.ok) {
808
955
  if (r.state === 'no_such_model') return false // 不是我的模型:换供应商,不记账
809
- pool.noteFailure('', r.state, r.message)
956
+ if (trace !== undefined) trace.lastState = r.state
957
+ pool.noteFailure('', req.model, r.state, r.message)
810
958
  return false
811
959
  }
812
960
  if (trace !== undefined) trace.uid = '(no-account)'
@@ -814,7 +962,10 @@ export class Router {
814
962
  probe.supplier = s.id
815
963
  // 记对外全名 alias/model:与直接调用路径一致。组合里存的是裸
816
964
  // modelId,直接记会把同一个模型在 Top 榜上分裂成两行。
817
- probe.model = `${s.getAlias()}/${req.model}`
965
+ // 直接调 `alias/model` 时 req.model 已带前缀,先剥掉再拼——
966
+ // 否则会套成 `alias/alias/model`(插件会再剥一次,功能是好的,
967
+ // 只是统计名多一层,Top 榜照样分裂)。
968
+ probe.model = `${s.getAlias()}/${stripAlias(req.model, s.getAlias())}`
818
969
  probe.uid = ''
819
970
  }
820
971
  return await writeChatResult(res, r, req.stream, probe, probe?.startedAt ?? 0)
@@ -823,7 +974,7 @@ export class Router {
823
974
  // 不排除试过的就会原地打转——死循环等于整个服务挂住。
824
975
  const tried = new Set<string>()
825
976
  for (;;) {
826
- const uid = pool.pick(s.accounts().filter((a) => !tried.has(a.uid)), cfg.poolOrder, cfg.poolStrategy)
977
+ const uid = pool.pick(s.accounts().filter((a) => !tried.has(a.uid)), cfg.poolOrder, cfg.poolStrategy, req.model)
827
978
  if (uid === undefined) return false
828
979
  tried.add(uid)
829
980
  const r = await s.chatOnce(uid, req)
@@ -834,28 +985,31 @@ export class Router {
834
985
  if (trace !== undefined) trace.lastError = 'no_such_model'
835
986
  return false
836
987
  }
837
- pool.noteFailure(uid, r.state, r.message)
988
+ pool.noteFailure(uid, req.model, r.state, r.message)
838
989
  if (trace !== undefined) {
839
990
  trace.attempts += 1
991
+ trace.lastState = r.state
840
992
  trace.lastError = `${r.state}: ${r.message}`
841
993
  }
842
994
  continue
843
995
  }
844
- pool.noteSuccess(uid)
996
+ pool.noteSuccess(uid, req.model)
845
997
  if (trace !== undefined) trace.uid = uid
846
998
  if (probe !== undefined) {
847
999
  probe.supplier = s.id
848
1000
  // 记对外全名 alias/model:与直接调用路径一致。组合里存的是裸
849
1001
  // modelId,直接记会把同一个模型在 Top 榜上分裂成两行。
850
- probe.model = `${s.getAlias()}/${req.model}`
1002
+ // 剥一次自己的前缀:直接调 `alias/model` 时 req.model 已带前缀。
1003
+ probe.model = `${s.getAlias()}/${stripAlias(req.model, s.getAlias())}`
851
1004
  probe.uid = uid
852
1005
  }
853
1006
  const committed = await writeChatResult(res, r, req.stream, probe, probe?.startedAt ?? 0)
854
1007
  // 一个字节都没写(上游刚连上就断)→ 这个号不算数,换下一个重试
855
1008
  if (committed) return true
856
- pool.noteFailure(uid, 'transport', 'stream failed before first byte')
1009
+ pool.noteFailure(uid, req.model, 'transport', 'stream failed before first byte')
857
1010
  if (trace !== undefined) {
858
1011
  trace.attempts += 1
1012
+ trace.lastState = 'transport'
859
1013
  trace.lastError = 'stream failed before first byte'
860
1014
  }
861
1015
  }
@@ -874,15 +1028,20 @@ export class Router {
874
1028
  rawBody: JSON.stringify({ model, messages: [{ role: 'user', content: 'ping' }], stream: false, max_tokens: 1 }),
875
1029
  }
876
1030
  let served = false
1031
+ const trace: ChatTrace = { attempts: 0 }
877
1032
  try {
878
- served = await this.chatWithSupplier(s, req, sink)
1033
+ served = await this.chatWithSupplier(s, req, sink, trace)
879
1034
  } catch (err) {
880
1035
  return { ok: false, error: (err as Error).message }
881
1036
  }
882
1037
  // 供应商可能「服务了但写出错误响应」(如 opencode:唯一能处理该模型的供应商,错误自己上报)
883
1038
  if (served && sink.status() < 400) return { ok: true }
884
1039
  const fromSink = served && sink.status() >= 400 ? extractUpstreamError(sink.body()) : ''
885
- const detail = fromSink !== '' ? fromSink : s.lastError?.()
1040
+ // 失败原因优先从上游响应体取,其次用 chatOnce 返回的 message(trace.lastError)。
1041
+ // 以前这里问插件的 lastError()——那是「最后一次失败」的可变全局,既可能串到
1042
+ // 别的账号/别的请求,也有插件压根没实现(codebuddy 就漏了,于是永远拿不到兜底)。
1043
+ // chatOnce 的返回值本身就带 message,用它更准,插件也不必再维护一份状态。
1044
+ const detail = fromSink !== '' ? fromSink : trace.lastError
886
1045
  return {
887
1046
  ok: false,
888
1047
  error: detail !== undefined && detail !== ''