dsh-router-core 0.4.0 → 0.4.2

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 (99) hide show
  1. package/README.md +174 -38
  2. package/cordis.patch.yml +22 -0
  3. package/docs/ext.md +29 -6
  4. package/docs/suppliers.md +28 -5
  5. package/lib/client-registry.js +939 -132
  6. package/lib/client.js +939 -132
  7. package/lib/ext-test/index.js +47 -0
  8. package/lib/ext-test/locale/en.json +6 -0
  9. package/lib/ext-test/locale/zh.json +6 -0
  10. package/lib/ext-test/package.json +5 -0
  11. package/lib/http-state-C1vb3gW2.js +48 -0
  12. package/lib/index.js +838 -89
  13. package/lib/suppliers/{nvidia.js → nvidia/index.js} +14 -6
  14. package/lib/suppliers/nvidia/locale/en.json +6 -0
  15. package/lib/suppliers/nvidia/locale/zh.json +6 -0
  16. package/lib/suppliers/nvidia/package.json +5 -0
  17. package/lib/suppliers/{opencode.js → opencode/index.js} +14 -6
  18. package/lib/suppliers/opencode/locale/en.json +6 -0
  19. package/lib/suppliers/opencode/locale/zh.json +6 -0
  20. package/lib/suppliers/opencode/package.json +5 -0
  21. package/lib/suppliers/{openrouter.js → openrouter/index.js} +14 -6
  22. package/lib/suppliers/openrouter/locale/en.json +6 -0
  23. package/lib/suppliers/openrouter/locale/zh.json +6 -0
  24. package/lib/suppliers/openrouter/package.json +5 -0
  25. package/lib/types/ext/contract.d.ts +9 -0
  26. package/lib/types/ext-test/index.d.ts +21 -0
  27. package/lib/types/ext-test/plugin.d.ts +23 -0
  28. package/lib/types/host-base-url.d.ts +31 -0
  29. package/lib/types/llm/adapter.d.ts +40 -4
  30. package/lib/types/router/index.d.ts +61 -8
  31. package/lib/types/shared.d.ts +12 -0
  32. package/lib/types/supplier-config.d.ts +15 -0
  33. package/lib/types/suppliers/contract.d.ts +35 -2
  34. package/lib/types/suppliers/http-state.d.ts +23 -0
  35. package/lib/types/suppliers/loader.d.ts +6 -4
  36. package/lib/types/suppliers/nvidia/index.d.ts +18 -0
  37. package/lib/types/suppliers/nvidia/plugin.d.ts +13 -0
  38. package/lib/types/suppliers/opencode/index.d.ts +18 -0
  39. package/lib/types/suppliers/opencode/plugin.d.ts +7 -0
  40. package/lib/types/suppliers/openrouter/index.d.ts +18 -0
  41. package/lib/types/suppliers/openrouter/plugin.d.ts +11 -0
  42. package/lib/types/suppliers/probe.d.ts +160 -0
  43. package/lib/types/suppliers/registry.d.ts +3 -1
  44. package/package.json +39 -4
  45. package/src/client/ExtDetail.tsx +21 -22
  46. package/src/client/ExtTab.tsx +25 -42
  47. package/src/client/ExtTestPanel.tsx +392 -0
  48. package/src/client/RouterComponentsSection.tsx +241 -0
  49. package/src/client/RouterView.tsx +12 -5
  50. package/src/client/comp-button-scope.test.ts +58 -0
  51. package/src/client/comp-panel-layout.test.ts +58 -0
  52. package/src/client/env.d.ts +38 -1
  53. package/src/client/ext-panels.test.ts +44 -0
  54. package/src/client/ext-panels.ts +49 -0
  55. package/src/client/index.tsx +41 -0
  56. package/src/client/model-choice.test.ts +41 -0
  57. package/src/client/model-choice.ts +37 -0
  58. package/src/client/router-components.test.ts +114 -0
  59. package/src/client/router-components.ts +104 -0
  60. package/src/client/router.css +490 -9
  61. package/src/client/supplier-visibility.test.ts +57 -0
  62. package/src/client/supplier-visibility.ts +63 -0
  63. package/src/ext/contract.ts +9 -0
  64. package/src/ext-test/ext-test.test.ts +60 -0
  65. package/src/ext-test/index.ts +35 -0
  66. package/src/ext-test/locale/en.json +6 -0
  67. package/src/ext-test/locale/zh.json +6 -0
  68. package/src/ext-test/plugin.ts +34 -0
  69. package/src/ext-test/row.json +5 -0
  70. package/src/host-base-url.test.ts +129 -0
  71. package/src/host-base-url.ts +43 -0
  72. package/src/index.ts +46 -11
  73. package/src/llm/adapter.test.ts +101 -4
  74. package/src/llm/adapter.ts +89 -20
  75. package/src/router/disconnect-leak.test.ts +7 -12
  76. package/src/router/first-byte-budget.test.ts +160 -0
  77. package/src/router/index.ts +255 -35
  78. package/src/router/models-cache.test.ts +45 -0
  79. package/src/router/supplier-switch.test.ts +220 -0
  80. package/src/shared.ts +12 -0
  81. package/src/supplier-config.ts +32 -1
  82. package/src/suppliers/contract.ts +54 -2
  83. package/src/suppliers/loader.test.ts +36 -2
  84. package/src/suppliers/loader.ts +33 -18
  85. package/src/suppliers/nvidia/index.ts +27 -0
  86. package/src/suppliers/nvidia/locale/en.json +6 -0
  87. package/src/suppliers/nvidia/locale/zh.json +6 -0
  88. package/src/suppliers/nvidia/row.json +5 -0
  89. package/src/suppliers/opencode/index.ts +27 -0
  90. package/src/suppliers/opencode/locale/en.json +6 -0
  91. package/src/suppliers/opencode/locale/zh.json +6 -0
  92. package/src/suppliers/opencode/row.json +5 -0
  93. package/src/suppliers/openrouter/index.ts +27 -0
  94. package/src/suppliers/openrouter/locale/en.json +6 -0
  95. package/src/suppliers/openrouter/locale/zh.json +6 -0
  96. package/src/suppliers/openrouter/row.json +5 -0
  97. package/src/suppliers/probe.test.ts +266 -0
  98. package/src/suppliers/probe.ts +475 -0
  99. package/src/suppliers/registry.ts +102 -3
@@ -81,6 +81,103 @@ function isTransient(state: AccountState): boolean {
81
81
  return state === 'transport' || state === 'unavailable'
82
82
  }
83
83
 
84
+ /**
85
+ * **每条降级腿的首字节预算**:从进入这条腿起,多久内上游必须吐出第一个字节。
86
+ *
87
+ * 为什么必须有(2026-09-24 夜里 money 组合实测):上游(codebuddy 系)额度用完 /
88
+ * 过载时,连接照常建立、HTTP 200 响应头照常回来,**body 却一个字节都不吐**。
89
+ * 这既不是成功也不是失败,于是
90
+ * - `chatOnce` 的 120s 只守「连接+响应头」,`fetch` 一返回就撤表 → 守不到 body
91
+ * - 核心写响应的路径也没有任何计时器 → 组合链卡在这一条腿上不动
92
+ * - 客户端(dsh-router 自己的 adapter)有 300s 流空闲超时,但那是「整条请求」
93
+ * 的兜底,与降级无关
94
+ * 实测用户干等 200 多秒手动暂停:ttfb 84s / 122s / 151s / 244s / 246s / 250s /
95
+ * 254s / 255s,8 次全部 `ok: true`——**全都最终成功了**,只是晚了四分钟。同一批
96
+ * 账号因为「没失败」也不冷却,下一次请求继续挑它(cb-8 连吃 248s + 155s)。
97
+ *
98
+ * 60s 的来历(本机 500 条真实请求的 ttfb 分布):p50 4.1s / p95 14.9s,
99
+ * 最大的**非停滞**值 47.7s,停滞簇从 84.5s 起 —— 60s 正落在两簇之间的空档,
100
+ * 砍得动停滞、砍不到真实慢请求。首个字节之后**不再计时**:长生成可以跑很久。
101
+ *
102
+ * 为什么按「腿」而不是按「账号」计:停滞是上游级的(同一供应商的所有号一起
103
+ * 挂)。按账号计的话,codebuddy 有 11 个号,一次请求能 11×60s = 11 分钟才轮到
104
+ * 下一个模型;按腿计,最坏 3 条腿 × 60s ≈ 3 分钟,且期间一定会走到下一个模型。
105
+ */
106
+ const FIRST_BYTE_BUDGET_MS = 60_000
107
+
108
+ /**
109
+ * 给上游流套一层**首字节截止**:截止前一个字节都没来,就把这条流判失败
110
+ * (error 出去,调用方看到「一个字节都没写」→ 换号/换模型)。
111
+ * 第一个字节到手后立刻撤表,之后多久都不管。
112
+ *
113
+ * 为什么套在核心而不是插件:首字节迟迟不来时,**只有核心**能做「还没提交响应
114
+ * → 换一条腿」的决定(插件在 `fetch` 拿到响应头那一刻就把流交出去了,
115
+ * 谁也没法再改结果)。核心也是所有供应商唯一的收口点,套一次全都受益。
116
+ */
117
+ export function withFirstByteDeadline(stream: ReadableStream<Uint8Array>, ms: number): ReadableStream<Uint8Array> {
118
+ const reader = stream.getReader()
119
+ let first = true
120
+ /** 消费方已取消:这之后任何 ctrl.* 都会抛(流已死),必须全部跳过。 */
121
+ let cancelled = false
122
+ /**
123
+ * 上游 reader 只解绑一次。wrapper 替核心持有了这个 reader,所以**必须由
124
+ * wrapper 负责收尾**:上游正常读完 / 上游自己 error / 被取消时都解锁,
125
+ * 否则流永久锁着,上层想 cancel 会抛 `Invalid state: ReadableStream is locked`
126
+ * (disconnect-leak 那条判据守的就是这个;历史上由 aggregateSSE 的 finally 解锁,
127
+ * 现在读的是 wrapper,解绑责任跟着搬过来)。
128
+ */
129
+ let released = false
130
+ const release = (): void => {
131
+ if (released) return
132
+ released = true
133
+ try { reader.releaseLock() } catch { /* 有待定 read 时会抛,忽略 */ }
134
+ }
135
+ /** 取消要传到底下的上游(cancel 本身会顺带解锁)。 */
136
+ const cancelUpstream = (reason: unknown): Promise<void> => {
137
+ if (released) return Promise.resolve()
138
+ released = true
139
+ return reader.cancel(reason).then(() => {}, () => {})
140
+ }
141
+ return new ReadableStream<Uint8Array>({
142
+ async pull(ctrl) {
143
+ // 超时标志由计时器置位:置位后**不再碰 ctrl**(流已经 error 掉了,
144
+ // 再 close/enqueue 会抛 ERR_INVALID_STATE,把真正的失败原因盖掉)。
145
+ let expired = false
146
+ const timer = first
147
+ ? setTimeout(() => {
148
+ expired = true
149
+ void cancelUpstream(new Error('first byte timeout'))
150
+ try { ctrl.error(new Error(`no first byte within ${ms}ms`)) } catch { /* 已被取消 */ }
151
+ }, ms)
152
+ : undefined
153
+ try {
154
+ const { done, value } = await reader.read()
155
+ if (expired || cancelled) return
156
+ if (done) {
157
+ release()
158
+ ctrl.close()
159
+ return
160
+ }
161
+ first = false
162
+ ctrl.enqueue(value)
163
+ } catch (e) {
164
+ release()
165
+ if (!expired && !cancelled) {
166
+ try { ctrl.error(e) } catch { /* 已被取消 */ }
167
+ }
168
+ } finally {
169
+ if (timer !== undefined) clearTimeout(timer)
170
+ }
171
+ },
172
+ // 消费方取消必须穿过这层 wrapper 传到上游(与 normalizeSSEStream 同理,
173
+ // 不实现就等于每次客户端放弃都漏一条上游连接)。
174
+ cancel: (reason) => {
175
+ cancelled = true
176
+ return cancelUpstream(reason)
177
+ },
178
+ })
179
+ }
180
+
84
181
  /**
85
182
  * 从请求体里取 `messages`(前缀亲和的指纹输入)。解析失败/字段缺失返回
86
183
  * undefined —— 亲和拿不到输入就自动回退到块轮询,不影响路由本身。
@@ -355,8 +452,18 @@ function fixFrame(frame: string): string {
355
452
  *
356
453
  * 天花板:一旦写出第一个字节就绑死(HTTP 语义),中途断流只能截断,
357
454
  * 不能回退。要彻底解决得在写头前缓冲判定,代价是首字节延迟——未做。
455
+ *
456
+ * @param firstByteTimeoutMs 上游多久内必须吐出第一个字节(见 FIRST_BYTE_BUDGET_MS)。
457
+ * 超时按「一个字节都没写」返回 false,调用方照常换号/换模型。
358
458
  */
359
- async function writeChatResult(res: ServerResponse, r: ChatOnceResult, wantsStream: boolean, probe?: UsageProbe, startedAt = 0): Promise<boolean> {
459
+ async function writeChatResult(
460
+ res: ServerResponse,
461
+ r: ChatOnceResult,
462
+ wantsStream: boolean,
463
+ probe?: UsageProbe,
464
+ startedAt = 0,
465
+ firstByteTimeoutMs = FIRST_BYTE_BUDGET_MS,
466
+ ): Promise<boolean> {
360
467
  if (!r.ok) return false // 失败不该走到这里(核心先判 ok 才写)
361
468
  if (!('stream' in r)) {
362
469
  if (probe !== undefined) probe.tokens = usageFromJsonBody(r.body)
@@ -364,19 +471,21 @@ async function writeChatResult(res: ServerResponse, r: ChatOnceResult, wantsStre
364
471
  res.end(r.body)
365
472
  return true
366
473
  }
474
+ // 唯一收口:两条路径(聚合 / 透传)读的都是这层「套了首字节截止」的流。
475
+ const src = withFirstByteDeadline(r.stream, firstByteTimeoutMs)
367
476
  // 客户端要 JSON 但供应商只给流(如 codebuddy 强制 stream:true)→ 聚合成
368
477
  // 一次非流式响应。核心独占响应写入权,这个协议错配必须由核心吸收,
369
478
  // 否则客户端按 JSON 解析会在 SSE 的 delta 里找不到工具名。
370
479
  if (!wantsStream) {
371
480
  let body: string
372
481
  try {
373
- body = await aggregateSSE(r.stream)
482
+ body = await aggregateSSE(src)
374
483
  } catch {
375
484
  // 聚合失败 = 一个字节都没写,调用方可以换号重试。
376
485
  // **但底层流必须 cancel**:aggregateSSE 内部 getReader() 消费到一半抛错时,
377
486
  // reader 只是 releaseLock(),传输层毫无察觉 —— 那条上游连接会一直挂着。
378
487
  // 不 cancel 就退化成「每次聚合失败漏一条连接」(issue #6 的路径 1)。
379
- await r.stream.cancel().catch(() => {})
488
+ await src.cancel().catch(() => {})
380
489
  return false
381
490
  }
382
491
  if (probe !== undefined) probe.tokens = usageFromJsonBody(body)
@@ -386,7 +495,7 @@ async function writeChatResult(res: ServerResponse, r: ChatOnceResult, wantsStre
386
495
  }
387
496
 
388
497
  // 流式:边透传边统计(统计在 tee 之外的旁路做,首字节延迟不受影响)
389
- const tapped = probe === undefined ? undefined : tapStreamUsage(r.stream, startedAt)
498
+ const tapped = probe === undefined ? undefined : tapStreamUsage(src, startedAt)
390
499
  let wroteAny = false
391
500
  /** 响应已死(客户端断开/res 已销毁):等 drain 的写入要立刻放弃,别吊死。 */
392
501
  let dead = false
@@ -449,7 +558,7 @@ async function writeChatResult(res: ServerResponse, r: ChatOnceResult, wantsStre
449
558
  abort.abort()
450
559
  }
451
560
  res.once('close', onClose)
452
- const norm = normalizeSSEStream(tapped?.stream ?? r.stream)
561
+ const norm = normalizeSSEStream(tapped?.stream ?? src)
453
562
  try {
454
563
  // signal 让 abort 时 pipeTo 立刻收尾(而不是等 drain);其收尾路径会
455
564
  // cancel 源流,顺着 wrapper 的 cancel 一路传到上游 fetch body。
@@ -526,6 +635,12 @@ interface UsageProbe {
526
635
 
527
636
  /** 路由器。 */
528
637
  export class Router {
638
+ /**
639
+ * **已装载**的全部供应商(含被开关关掉的)。关掉的仍留在列表里 —— 否则用户关掉
640
+ * 之后它在 `/health`、面板、插件页里一起消失,开关就没有可指的对象、也没法再开。
641
+ *
642
+ * 真正参与路由的只有 `active` 那一份。
643
+ */
529
644
  private suppliers: Supplier[] = []
530
645
  private combosFp = ''
531
646
  private customCombos: Combo[] = []
@@ -540,13 +655,21 @@ export class Router {
540
655
  private modelsInflight = new Map<string, Promise<ModelWithEnabled[]>>()
541
656
  /** 请求日志出口(面板/宿主 logger)。 */
542
657
  private log: (msg: string) => void
658
+ /** 每条降级腿的首字节预算(ms)。只给测试一个入口,生产恒为 FIRST_BYTE_BUDGET_MS。 */
659
+ private firstByteBudgetMs: number
543
660
  /** 用量统计(概览看板)。 */
544
661
  usage: UsageStore
545
662
 
546
- constructor(stateFile = '', store?: SupplierConfigStore, log?: (msg: string) => void) {
663
+ constructor(
664
+ stateFile = '',
665
+ store?: SupplierConfigStore,
666
+ log?: (msg: string) => void,
667
+ opts?: { firstByteBudgetMs?: number },
668
+ ) {
547
669
  this.combosFp = stateFile ? join(dirname(stateFile), 'combos.json') : ''
548
670
  this.store = store ?? new SupplierConfigStore(stateFile)
549
671
  this.log = log ?? ((): void => {})
672
+ this.firstByteBudgetMs = opts?.firstByteBudgetMs ?? FIRST_BYTE_BUDGET_MS
550
673
  this.usage = new UsageStore(stateFile)
551
674
  this.loadCombos()
552
675
  }
@@ -589,7 +712,7 @@ export class Router {
589
712
  if (slash <= 0) return m // 裸 id:保持,路由降级为遍历
590
713
  const alias = m.slice(0, slash)
591
714
  const modelId = m.slice(slash + 1)
592
- const s = this.suppliers.find((x) => x.getAlias() === alias)
715
+ const s = this.active.find((x) => x.getAlias() === alias)
593
716
  return s === undefined ? modelId : `${s.id},${modelId}`
594
717
  })
595
718
  return {
@@ -626,6 +749,22 @@ export class Router {
626
749
  this.store.sync(this.suppliers.map((s) => s.id))
627
750
  }
628
751
 
752
+ /**
753
+ * **参与路由的供应商** —— 装载清单里开关为开的那一份,按 priority 排好。
754
+ *
755
+ * 这是「供应商开关」唯一的收口点:请求分发、别名反查、模型列表、组合、面板状态
756
+ * 全部读它。关掉一个供应商 = 它从这里消失,而不是在每个调用点各写一遍判断 ——
757
+ * 后者一定会漏(本次实测路由有 15 处读 `this.suppliers`)。
758
+ */
759
+ private get active(): Supplier[] {
760
+ return this.suppliers.filter((s) => this.store.isEnabled(s.id))
761
+ }
762
+
763
+ /** 某供应商当前是否参与路由(面板与 API 用)。 */
764
+ isEnabled(supplierId: string): boolean {
765
+ return this.store.isEnabled(supplierId)
766
+ }
767
+
629
768
  /** 移除供应商(外部插件卸载时注销)。 */
630
769
  removeSupplier(id: string): boolean {
631
770
  const i = this.suppliers.findIndex((s) => s.id === id)
@@ -636,14 +775,20 @@ export class Router {
636
775
  return true
637
776
  }
638
777
 
639
- /** 返回全部供应商状态(面板用)。 */
640
- status(): { suppliers: SupplierStatus[] } {
641
- return { suppliers: this.suppliers.map((s) => s.status()) }
778
+ /**
779
+ * 返回全部供应商状态(面板用)。**含被开关关掉的** —— 面板要能显示它们
780
+ * (「已关闭」),否则用户在面板上就再也看不到自己装过的供应商。
781
+ * 开关状态由 `enabled` 字段带出,调用方自己决定要不要只留开着的。
782
+ */
783
+ status(): { suppliers: (SupplierStatus & { enabled: boolean })[] } {
784
+ return {
785
+ suppliers: this.suppliers.map((s) => ({ ...s.status(), enabled: this.store.isEnabled(s.id) })),
786
+ }
642
787
  }
643
788
 
644
- /** 供应商前缀信息(组合模型全名 = alias/id,展示时动态拼接)。 */
789
+ /** 供应商前缀信息(组合模型全名 = alias/id,展示时动态拼接)。只含开着的。 */
645
790
  aliases(): Array<{ id: string; name: string; alias: string }> {
646
- return this.suppliers.map((s) => ({ id: s.id, name: s.name, alias: s.getAlias() }))
791
+ return this.active.map((s) => ({ id: s.id, name: s.name, alias: s.getAlias() }))
647
792
  }
648
793
 
649
794
  /**
@@ -660,7 +805,7 @@ export class Router {
660
805
  out.push({ id: c.name })
661
806
  }
662
807
  }
663
- for (const s of this.suppliers) {
808
+ for (const s of this.active) {
664
809
  try {
665
810
  const ids = s.customModelIds?.()
666
811
  for (const id of ids ?? []) {
@@ -729,7 +874,7 @@ export class Router {
729
874
  }
730
875
 
731
876
  async modelsOf(supplierId: string, force = false): Promise<ModelWithEnabled[]> {
732
- const s = this.suppliers.find((x) => x.id === supplierId)
877
+ const s = this.active.find((x) => x.id === supplierId)
733
878
  if (s === undefined) return []
734
879
  const hit = this.modelsCache.get(supplierId)
735
880
  const fresh = hit !== undefined && Date.now() - hit.fetchedAt < MODELS_TTL_MS
@@ -764,7 +909,14 @@ export class Router {
764
909
  enabled: !disabled.has(mm.id),
765
910
  custom: custom.has(mm.id) ? true : undefined,
766
911
  }))
767
- // 自定义模型(listModels 之外的)并入显示
912
+ // 自定义模型(listModels 之外的)并入显示。
913
+ //
914
+ // 注意这里**刻意不**把「用户禁用过的模型」也补回来:插件的 listModels 只该报
915
+ // 「模型来源」,启用状态由核心合并(见 loader.ts 的 modelsWithEnabled)。
916
+ // 插件若在 listModels 里就把自己禁用的过滤掉,那是它违约 —— 该修的是插件,
917
+ // 核心不该反过来替它兜着。兜着的后果比表面症状更糟:面板看着正常了,
918
+ // 而体检的越权检测(probe.ts 的 hiddenDisabledIds)会因为模型已被补全而
919
+ // 永远报 OK,那个 bug 就再也没人发现了。
768
920
  const seen = new Set(models.map((mm) => mm.id))
769
921
  for (const id of custom) {
770
922
  if (!seen.has(id)) {
@@ -801,7 +953,7 @@ export class Router {
801
953
  /** 可用模型(按供应商分组,仅启用),面板加模型用。
802
954
  * 各供应商**并行**拉取——串行会把每个上游的延迟累加起来。 */
803
955
  async supplierModels(): Promise<Array<{ supplier: { id: string; name: string; alias: string }; models: ModelWithEnabled[] }>> {
804
- const groups = await Promise.all(this.suppliers.map(async (s) => {
956
+ const groups = await Promise.all(this.active.map(async (s) => {
805
957
  try {
806
958
  const models = (await this.modelsOf(s.id)).filter((m) => m.enabled)
807
959
  return { supplier: { id: s.id, name: s.name, alias: s.getAlias() }, models }
@@ -839,7 +991,7 @@ export class Router {
839
991
  if (slash <= 0) return m
840
992
  const alias = m.slice(0, slash)
841
993
  const modelId = m.slice(slash + 1)
842
- const s = this.suppliers.find((x) => x.getAlias() === alias)
994
+ const s = this.active.find((x) => x.getAlias() === alias)
843
995
  return s === undefined ? modelId : `${s.id},${modelId}`
844
996
  })
845
997
  }
@@ -933,7 +1085,12 @@ export class Router {
933
1085
  const model = combo.models[i]
934
1086
  if (model === undefined) continue
935
1087
  const trace: ChatTrace = { attempts: 0 }
936
- const served = await this.chatWithModel(req, res, model, probe, trace)
1088
+ // 每条腿**独立**一份首字节预算:上一条腿已经等满 60s 也不该让下一条腿
1089
+ // 只剩 0ms(否则组合越多越没法用),但同一条腿内的多个号共享一份
1090
+ // (停滞是上游级的,按号各等一份会 11×60s)。
1091
+ const served = await this.chatWithModel(
1092
+ req, res, model, probe, trace, Date.now() + this.firstByteBudgetMs,
1093
+ )
937
1094
  if (served) {
938
1095
  settle(true)
939
1096
  return
@@ -992,8 +1149,18 @@ export class Router {
992
1149
  * @param outTrace 可选出参:把这次调用的追踪信息带回去(组合据此判断
993
1150
  * 要不要「喘口气」再降级)。裸 id 遍历路径不填(多供应商混在一起,
994
1151
  * 失败状态没有单一归属)。
1152
+ * @param firstByteDeadlineAt 这条腿的首字节截止时刻(绝对 ms)。同一条腿内的
1153
+ * 多个账号共享它——停滞是上游级的,按号各给一份会 11×60s 才轮到下一个模型。
1154
+ * 缺省 = 此刻起一份全新预算(直接调用路径只有这一条腿)。
995
1155
  */
996
- private async chatWithModel(req: ChatRequest, res: ServerResponse, model: string, probe?: UsageProbe, outTrace?: ChatTrace): Promise<boolean> {
1156
+ private async chatWithModel(
1157
+ req: ChatRequest,
1158
+ res: ServerResponse,
1159
+ model: string,
1160
+ probe?: UsageProbe,
1161
+ outTrace?: ChatTrace,
1162
+ firstByteDeadlineAt = Date.now() + this.firstByteBudgetMs,
1163
+ ): Promise<boolean> {
997
1164
  const comma = model.indexOf(',')
998
1165
  const supplierId = comma > 0 ? model.slice(0, comma) : undefined
999
1166
  // 组合存的是供应商 id,但插件认的是自己的模型 id
@@ -1017,14 +1184,19 @@ export class Router {
1017
1184
  return false
1018
1185
  }
1019
1186
  // 精准:只调这一个供应商。查不到就是配置错了,直接失败(不遍历兜底)
1020
- const s = this.suppliers.find((x) => x.id === supplierId)
1187
+ const s = this.active.find((x) => x.id === supplierId)
1021
1188
  if (s === undefined) {
1022
- this.logChat(req.model, `supplier ${JSON.stringify(supplierId)} 不存在`, 0)
1189
+ // 「被开关关掉」与「不存在」对请求是同一件事:都没有可用的上游。分开说
1190
+ // 是为了日志能直接看出是用户关了它。
1191
+ const reason = this.suppliers.some((x) => x.id === supplierId)
1192
+ ? `supplier ${JSON.stringify(supplierId)} 已关闭`
1193
+ : `supplier ${JSON.stringify(supplierId)} 不存在`
1194
+ this.logChat(req.model, reason, 0)
1023
1195
  return false
1024
1196
  }
1025
1197
  const t0 = Date.now()
1026
1198
  const trace: ChatTrace = { attempts: 0 }
1027
- const served = await this.chatWithSupplier(s, clone, res, trace, probe)
1199
+ const served = await this.chatWithSupplier(s, clone, res, trace, probe, firstByteDeadlineAt)
1028
1200
  if (outTrace !== undefined) {
1029
1201
  outTrace.attempts = trace.attempts
1030
1202
  outTrace.lastError = trace.lastError
@@ -1038,7 +1210,7 @@ export class Router {
1038
1210
 
1039
1211
  /** 按别名找供应商(对外模型全名 = alias/model)。别名唯一,故最多命中一个。 */
1040
1212
  supplierByAlias(alias: string): Supplier | undefined {
1041
- return this.suppliers.find((s) => s.getAlias() === alias)
1213
+ return this.active.find((s) => s.getAlias() === alias)
1042
1214
  }
1043
1215
 
1044
1216
  /**
@@ -1049,8 +1221,24 @@ export class Router {
1049
1221
  * 还能换号重试;一旦写出第一个字节就绑死(HTTP 语义,9router 同样如此),
1050
1222
  * 之后出错不再换号——要彻底能回退就得在写头前缓冲判定,代价是首字节
1051
1223
  * 延迟,目前未做。
1224
+ *
1225
+ * @param firstByteDeadlineAt 这条腿的首字节截止时刻(见 chatWithModel)。
1226
+ * 预算耗尽后不再调任何号:剩下的号大概率同样停滞(上游级故障),
1227
+ * 挨个等下去只会把 60s 乘以号数,组合永远轮不到下一个模型。
1052
1228
  */
1053
- private async chatWithSupplier(s: Supplier, req: ChatRequest, res: ServerResponse, trace?: ChatTrace, probe?: UsageProbe): Promise<boolean> {
1229
+ /**
1230
+ * @param onlyUid - 只试这一个连接,**不做账号池回退**。给面板「指定连接」那档用
1231
+ * (诊断单个链接到底通不通);正常请求与普通模型测试都不传,走账号池。
1232
+ */
1233
+ private async chatWithSupplier(
1234
+ s: Supplier,
1235
+ req: ChatRequest,
1236
+ res: ServerResponse,
1237
+ trace?: ChatTrace,
1238
+ probe?: UsageProbe,
1239
+ firstByteDeadlineAt = Date.now() + this.firstByteBudgetMs,
1240
+ onlyUid?: string,
1241
+ ): Promise<boolean> {
1054
1242
  const pool = s.pool
1055
1243
  const cfg = this.store.get(s.id)
1056
1244
  // 无账号供应商:直接调一次(uid 传空,插件忽略)
@@ -1074,7 +1262,9 @@ export class Router {
1074
1262
  probe.model = `${s.getAlias()}/${stripAlias(req.model, s.getAlias())}`
1075
1263
  probe.uid = ''
1076
1264
  }
1077
- return await writeChatResult(res, r, req.stream, probe, probe?.startedAt ?? 0)
1265
+ return await writeChatResult(
1266
+ res, r, req.stream, probe, probe?.startedAt ?? 0, firstByteDeadlineAt - Date.now(),
1267
+ )
1078
1268
  }
1079
1269
  // 试过的号不再选:某些失败状态既不冷却也不计数(如模型不属于本供应商),
1080
1270
  // 不排除试过的就会原地打转——死循环等于整个服务挂住。
@@ -1084,8 +1274,32 @@ export class Router {
1084
1274
  const tried = new Set<string>()
1085
1275
  let pickWhy = ''
1086
1276
  for (;;) {
1087
- const uid = pool.pick(s.accounts().filter((a) => !tried.has(a.uid)), cfg.poolOrder, req.model, messages, req.session)
1088
- if (uid === undefined) return false
1277
+ // 指定连接:只试它。选不中就直接失败并说明原因(连接已不存在/不在池里),
1278
+ // **不回退到别的号** —— 否则「测这个连接」测出来的是另一个号的结果,
1279
+ // 诊断就变成了假话。
1280
+ const uid = onlyUid !== undefined
1281
+ ? (s.accounts().some((a) => a.uid === onlyUid) && !tried.has(onlyUid) ? onlyUid : undefined)
1282
+ : pool.pick(s.accounts().filter((a) => !tried.has(a.uid)), cfg.poolOrder, req.model, messages, req.session)
1283
+ if (uid === undefined) {
1284
+ if (onlyUid !== undefined && trace !== undefined) {
1285
+ // 不给 AccountState 加「连接不存在」这个值:那是给**账号的失败**分类用的
1286
+ // (决定要不要冷却),而链接不存在不是任何账号的错,混进去会让所有分类逻辑
1287
+ // 都要认识它。具体原因放 lastError 就够。
1288
+ trace.lastState = 'unknown'
1289
+ trace.lastError = `连接 ${JSON.stringify(onlyUid)} 不存在(可能已被删除)`
1290
+ }
1291
+ return false
1292
+ }
1293
+ // 腿的首字节预算已经耗尽:不再调下一个号。停滞是上游级的,剩下那些号
1294
+ // 挨个等下去 = 60s × 号数,组合永远轮不到下一个模型(2026-09-24 实测)。
1295
+ const left = firstByteDeadlineAt - Date.now()
1296
+ if (left <= 0) {
1297
+ if (trace !== undefined) {
1298
+ trace.lastState = 'transport'
1299
+ trace.lastError = 'first-byte budget exhausted'
1300
+ }
1301
+ return false
1302
+ }
1089
1303
  pickWhy = pool.lastWhy
1090
1304
  tried.add(uid)
1091
1305
  const r = await s.chatOnce(uid, req.lv ?? 'auto', req)
@@ -1124,13 +1338,13 @@ export class Router {
1124
1338
  probe.model = `${s.getAlias()}/${stripAlias(req.model, s.getAlias())}`
1125
1339
  probe.uid = uid
1126
1340
  }
1127
- const committed = await writeChatResult(res, r, req.stream, probe, probe?.startedAt ?? 0)
1341
+ const committed = await writeChatResult(res, r, req.stream, probe, probe?.startedAt ?? 0, firstByteDeadlineAt - Date.now())
1128
1342
  if (committed) {
1129
1343
  // 块轮询反馈:只有成功请求才计入(失败没产生缓存,计入会污染命中率)
1130
1344
  pool.noteCache(uid, req.model, probe?.tokens?.cachedTokens ?? 0, req.session)
1131
1345
  return true
1132
1346
  }
1133
- // 一个字节都没写(上游刚连上就断)→ 这个号不算数,换下一个重试
1347
+ // 一个字节都没写(上游刚连上就断 / 首字节超时)→ 这个号不算数,换下一个重试
1134
1348
  pool.noteFailure(uid, req.model, 'transport', 'stream failed before first byte')
1135
1349
  if (trace !== undefined) {
1136
1350
  trace.attempts += 1
@@ -1140,11 +1354,17 @@ export class Router {
1140
1354
  }
1141
1355
  }
1142
1356
 
1143
- /** 测试某供应商的某模型是否可用。
1144
- * 走真实的账号遍历 + chatOnce 路径:账号池回退/冷却由核心实现,自动生效;
1145
- * 响应丢弃到 sink,限定单一供应商(不跨供应商回退)。 */
1146
- async testModel(supplierId: string, model: string): Promise<{ ok: boolean; error?: string }> {
1147
- const s = this.suppliers.find((x) => x.id === supplierId)
1357
+ /**
1358
+ * 测试某供应商的某模型是否可用。
1359
+ *
1360
+ * 走真实的账号遍历 + chatOnce 路径:账号池回退/冷却由核心实现,自动生效;
1361
+ * 响应丢弃到 sink,限定单一供应商(不跨供应商回退)。
1362
+ *
1363
+ * @param onlyUid - 限定只测这一个连接(诊断单个链接),**不做账号池回退**。
1364
+ * 不传 = 与面板「测试」按钮完全同一条路径(池内任选可用号,失败自动换号)。
1365
+ */
1366
+ async testModel(supplierId: string, model: string, onlyUid?: string): Promise<{ ok: boolean; error?: string }> {
1367
+ const s = this.active.find((x) => x.id === supplierId)
1148
1368
  if (s === undefined) return { ok: false, error: `unknown supplier ${JSON.stringify(supplierId)}` }
1149
1369
  const sink = sinkRes()
1150
1370
  const req: ChatRequest = {
@@ -1155,7 +1375,7 @@ export class Router {
1155
1375
  let served = false
1156
1376
  const trace: ChatTrace = { attempts: 0 }
1157
1377
  try {
1158
- served = await this.chatWithSupplier(s, req, sink, trace)
1378
+ served = await this.chatWithSupplier(s, req, sink, trace, undefined, Date.now() + this.firstByteBudgetMs, onlyUid)
1159
1379
  } catch (err) {
1160
1380
  return { ok: false, error: (err as Error).message }
1161
1381
  }
@@ -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 到期的冷路径。
@@ -708,3 +714,42 @@ test('组合窗口:模型都没报 context_length → undefined', async () =>
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
+ })