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.
Files changed (120) hide show
  1. package/README.md +238 -63
  2. package/cordis.patch.yml +22 -0
  3. package/docs/ext.md +28 -5
  4. package/docs/pool-sticky-block.md +158 -0
  5. package/docs/suppliers.md +28 -5
  6. package/lib/client-registry.js +1300 -549
  7. package/lib/client.js +1300 -549
  8. package/lib/ext-test/index.js +47 -0
  9. package/lib/ext-test/locale/en.json +6 -0
  10. package/lib/ext-test/locale/zh.json +6 -0
  11. package/lib/ext-test/package.json +5 -0
  12. package/lib/http-state-C1vb3gW2.js +48 -0
  13. package/lib/index.js +1169 -254
  14. package/lib/suppliers/{nvidia.js → nvidia/index.js} +14 -6
  15. package/lib/suppliers/nvidia/locale/en.json +6 -0
  16. package/lib/suppliers/nvidia/locale/zh.json +6 -0
  17. package/lib/suppliers/nvidia/package.json +5 -0
  18. package/lib/suppliers/{opencode.js → opencode/index.js} +14 -6
  19. package/lib/suppliers/opencode/locale/en.json +6 -0
  20. package/lib/suppliers/opencode/locale/zh.json +6 -0
  21. package/lib/suppliers/opencode/package.json +5 -0
  22. package/lib/suppliers/{openrouter.js → openrouter/index.js} +14 -6
  23. package/lib/suppliers/openrouter/locale/en.json +6 -0
  24. package/lib/suppliers/openrouter/locale/zh.json +6 -0
  25. package/lib/suppliers/openrouter/package.json +5 -0
  26. package/lib/types/ext/contract.d.ts +9 -7
  27. package/lib/types/ext-test/index.d.ts +21 -0
  28. package/lib/types/ext-test/plugin.d.ts +23 -0
  29. package/lib/types/host-version.d.ts +15 -0
  30. package/lib/types/index.d.ts +6 -0
  31. package/lib/types/llm/adapter.d.ts +35 -4
  32. package/lib/types/router/account-pool.d.ts +53 -18
  33. package/lib/types/router/index.d.ts +65 -15
  34. package/lib/types/router/prefix-affinity.d.ts +18 -1
  35. package/lib/types/router/types.d.ts +7 -4
  36. package/lib/types/router/usage-store.d.ts +12 -0
  37. package/lib/types/shared.d.ts +25 -21
  38. package/lib/types/supplier-config.d.ts +15 -3
  39. package/lib/types/suppliers/contract.d.ts +37 -4
  40. package/lib/types/suppliers/http-state.d.ts +23 -0
  41. package/lib/types/suppliers/loader.d.ts +6 -4
  42. package/lib/types/suppliers/nvidia/index.d.ts +18 -0
  43. package/lib/types/suppliers/nvidia/plugin.d.ts +13 -0
  44. package/lib/types/suppliers/opencode/index.d.ts +18 -0
  45. package/lib/types/suppliers/opencode/plugin.d.ts +7 -0
  46. package/lib/types/suppliers/openrouter/index.d.ts +18 -0
  47. package/lib/types/suppliers/openrouter/plugin.d.ts +11 -0
  48. package/lib/types/suppliers/probe.d.ts +160 -0
  49. package/lib/types/suppliers/registry.d.ts +3 -2
  50. package/package.json +50 -16
  51. package/src/client/CombosTab.tsx +8 -53
  52. package/src/client/ExtDetail.tsx +32 -196
  53. package/src/client/ExtTab.tsx +25 -42
  54. package/src/client/ExtTestPanel.tsx +392 -0
  55. package/src/client/LastHitDock.tsx +141 -0
  56. package/src/client/RouterComponentsSection.tsx +241 -0
  57. package/src/client/RouterView.tsx +12 -5
  58. package/src/client/StatsTab.tsx +8 -6
  59. package/src/client/SupplierDetail.tsx +2 -34
  60. package/src/client/comp-button-scope.test.ts +58 -0
  61. package/src/client/comp-panel-layout.test.ts +58 -0
  62. package/src/client/env.d.ts +38 -1
  63. package/src/client/ext-panels.test.ts +44 -0
  64. package/src/client/ext-panels.ts +49 -0
  65. package/src/client/index.tsx +86 -0
  66. package/src/client/last-hit-store.ts +89 -0
  67. package/src/client/model-choice.test.ts +41 -0
  68. package/src/client/model-choice.ts +37 -0
  69. package/src/client/model-hint-copy.test.ts +7 -2
  70. package/src/client/model-hint-copy.ts +2 -2
  71. package/src/client/router-components.test.ts +114 -0
  72. package/src/client/router-components.ts +104 -0
  73. package/src/client/router.css +2592 -1635
  74. package/src/client/supplier-visibility.test.ts +57 -0
  75. package/src/client/supplier-visibility.ts +63 -0
  76. package/src/ext/contract.ts +9 -7
  77. package/src/ext-test/ext-test.test.ts +60 -0
  78. package/src/ext-test/index.ts +35 -0
  79. package/src/ext-test/locale/en.json +6 -0
  80. package/src/ext-test/locale/zh.json +6 -0
  81. package/src/ext-test/plugin.ts +34 -0
  82. package/src/ext-test/row.json +5 -0
  83. package/src/host-version.test.ts +61 -0
  84. package/src/host-version.ts +101 -0
  85. package/src/index.ts +165 -68
  86. package/src/llm/adapter.test.ts +175 -4
  87. package/src/llm/adapter.ts +109 -16
  88. package/src/router/account-pool.test.ts +189 -83
  89. package/src/router/account-pool.ts +120 -31
  90. package/src/router/disconnect-leak.test.ts +7 -12
  91. package/src/router/first-byte-budget.test.ts +160 -0
  92. package/src/router/index.ts +276 -62
  93. package/src/router/models-cache.test.ts +54 -9
  94. package/src/router/precise-dispatch.test.ts +6 -6
  95. package/src/router/prefix-affinity.ts +22 -1
  96. package/src/router/supplier-switch.test.ts +220 -0
  97. package/src/router/types.ts +7 -5
  98. package/src/router/usage-store.ts +16 -0
  99. package/src/router/usage.test.ts +1 -1
  100. package/src/shared.ts +25 -24
  101. package/src/supplier-config.ts +33 -13
  102. package/src/suppliers/contract.ts +56 -4
  103. package/src/suppliers/loader.test.ts +38 -4
  104. package/src/suppliers/loader.ts +33 -18
  105. package/src/suppliers/nvidia/index.ts +27 -0
  106. package/src/suppliers/nvidia/locale/en.json +6 -0
  107. package/src/suppliers/nvidia/locale/zh.json +6 -0
  108. package/src/suppliers/nvidia/row.json +5 -0
  109. package/src/suppliers/opencode/index.ts +27 -0
  110. package/src/suppliers/opencode/locale/en.json +6 -0
  111. package/src/suppliers/opencode/locale/zh.json +6 -0
  112. package/src/suppliers/opencode/row.json +5 -0
  113. package/src/suppliers/openrouter/index.ts +27 -0
  114. package/src/suppliers/openrouter/locale/en.json +6 -0
  115. package/src/suppliers/openrouter/locale/zh.json +6 -0
  116. package/src/suppliers/openrouter/row.json +5 -0
  117. package/src/suppliers/probe.test.ts +266 -0
  118. package/src/suppliers/probe.ts +475 -0
  119. package/src/suppliers/registry.ts +103 -23
  120. package/src/types.d.ts +2 -1
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * dsh-router 路由器本体:供应商注册表 + OpenAI 兼容 /v1/* 处理。
3
- * 仿 9router:组合 = 一组模型,请求 model 命中组合名时,按策略
4
- * (fallback 顺序尝试 / round-robin 轮转)选中一个模型路由。
3
+ * 仿 9router:组合 = 一组模型,请求 model 命中组合名时,作为降级链
4
+ * 按序尝试选中一个模型路由(无策略旋钮,见 docs/pool-sticky-block.md §10)。
5
5
  */
6
6
  import { mkdirSync, readFileSync, writeFileSync, renameSync } from 'node:fs'
7
7
  import { dirname, join } from 'node:path'
@@ -24,6 +24,8 @@ interface ChatTrace {
24
24
  lastError?: string
25
25
  /** 最后一次失败的状态(组合据此判断要不要「喘口气」再降级)。 */
26
26
  lastState?: AccountState
27
+ /** 本次选号的理由(诊断「为什么没粘住原号」)。 */
28
+ pickWhy?: string
27
29
  }
28
30
 
29
31
  /**
@@ -79,6 +81,103 @@ function isTransient(state: AccountState): boolean {
79
81
  return state === 'transport' || state === 'unavailable'
80
82
  }
81
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
+
82
181
  /**
83
182
  * 从请求体里取 `messages`(前缀亲和的指纹输入)。解析失败/字段缺失返回
84
183
  * undefined —— 亲和拿不到输入就自动回退到块轮询,不影响路由本身。
@@ -353,8 +452,18 @@ function fixFrame(frame: string): string {
353
452
  *
354
453
  * 天花板:一旦写出第一个字节就绑死(HTTP 语义),中途断流只能截断,
355
454
  * 不能回退。要彻底解决得在写头前缓冲判定,代价是首字节延迟——未做。
455
+ *
456
+ * @param firstByteTimeoutMs 上游多久内必须吐出第一个字节(见 FIRST_BYTE_BUDGET_MS)。
457
+ * 超时按「一个字节都没写」返回 false,调用方照常换号/换模型。
356
458
  */
357
- 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> {
358
467
  if (!r.ok) return false // 失败不该走到这里(核心先判 ok 才写)
359
468
  if (!('stream' in r)) {
360
469
  if (probe !== undefined) probe.tokens = usageFromJsonBody(r.body)
@@ -362,19 +471,21 @@ async function writeChatResult(res: ServerResponse, r: ChatOnceResult, wantsStre
362
471
  res.end(r.body)
363
472
  return true
364
473
  }
474
+ // 唯一收口:两条路径(聚合 / 透传)读的都是这层「套了首字节截止」的流。
475
+ const src = withFirstByteDeadline(r.stream, firstByteTimeoutMs)
365
476
  // 客户端要 JSON 但供应商只给流(如 codebuddy 强制 stream:true)→ 聚合成
366
477
  // 一次非流式响应。核心独占响应写入权,这个协议错配必须由核心吸收,
367
478
  // 否则客户端按 JSON 解析会在 SSE 的 delta 里找不到工具名。
368
479
  if (!wantsStream) {
369
480
  let body: string
370
481
  try {
371
- body = await aggregateSSE(r.stream)
482
+ body = await aggregateSSE(src)
372
483
  } catch {
373
484
  // 聚合失败 = 一个字节都没写,调用方可以换号重试。
374
485
  // **但底层流必须 cancel**:aggregateSSE 内部 getReader() 消费到一半抛错时,
375
486
  // reader 只是 releaseLock(),传输层毫无察觉 —— 那条上游连接会一直挂着。
376
487
  // 不 cancel 就退化成「每次聚合失败漏一条连接」(issue #6 的路径 1)。
377
- await r.stream.cancel().catch(() => {})
488
+ await src.cancel().catch(() => {})
378
489
  return false
379
490
  }
380
491
  if (probe !== undefined) probe.tokens = usageFromJsonBody(body)
@@ -384,7 +495,7 @@ async function writeChatResult(res: ServerResponse, r: ChatOnceResult, wantsStre
384
495
  }
385
496
 
386
497
  // 流式:边透传边统计(统计在 tee 之外的旁路做,首字节延迟不受影响)
387
- const tapped = probe === undefined ? undefined : tapStreamUsage(r.stream, startedAt)
498
+ const tapped = probe === undefined ? undefined : tapStreamUsage(src, startedAt)
388
499
  let wroteAny = false
389
500
  /** 响应已死(客户端断开/res 已销毁):等 drain 的写入要立刻放弃,别吊死。 */
390
501
  let dead = false
@@ -447,7 +558,7 @@ async function writeChatResult(res: ServerResponse, r: ChatOnceResult, wantsStre
447
558
  abort.abort()
448
559
  }
449
560
  res.once('close', onClose)
450
- const norm = normalizeSSEStream(tapped?.stream ?? r.stream)
561
+ const norm = normalizeSSEStream(tapped?.stream ?? src)
451
562
  try {
452
563
  // signal 让 abort 时 pipeTo 立刻收尾(而不是等 drain);其收尾路径会
453
564
  // cancel 源流,顺着 wrapper 的 cancel 一路传到上游 fetch body。
@@ -524,12 +635,16 @@ interface UsageProbe {
524
635
 
525
636
  /** 路由器。 */
526
637
  export class Router {
638
+ /**
639
+ * **已装载**的全部供应商(含被开关关掉的)。关掉的仍留在列表里 —— 否则用户关掉
640
+ * 之后它在 `/health`、面板、插件页里一起消失,开关就没有可指的对象、也没法再开。
641
+ *
642
+ * 真正参与路由的只有 `active` 那一份。
643
+ */
527
644
  private suppliers: Supplier[] = []
528
645
  private combosFp = ''
529
646
  private customCombos: Combo[] = []
530
- /** round-robin 轮转游标(按组合 id 记忆)。 */
531
- private rrCursors = new Map<string, number>()
532
- /** 通用供应商配置(连接池顺序/策略、模型启用、别名)。 */
647
+ /** 通用供应商配置(连接池顺序、模型启用、别名)。 */
533
648
  private store: SupplierConfigStore
534
649
  /** 模型列表缓存(supplierId → 模型 + 拉取时间)。插件只管拉,不缓存。 */
535
650
  private modelsCache = new Map<string, { models: ModelWithEnabled[]; fetchedAt: number }>()
@@ -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
  }
@@ -565,7 +688,7 @@ export class Router {
565
688
  try {
566
689
  const f = JSON.parse(readFileSync(this.combosFp, 'utf8')) as { combos?: Combo[] }
567
690
  if (Array.isArray(f.combos)) {
568
- // 兼容旧格式:steps(供应商) → models(模型);无 strategy 默认 fallback。
691
+ // 兼容旧格式:steps(供应商) → models(模型);旧 strategy 字段静默忽略。
569
692
  this.customCombos = f.combos
570
693
  .filter((c) => typeof c.id === 'string' && c.id !== '')
571
694
  .map((c) => {
@@ -589,13 +712,12 @@ 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 {
596
719
  id: c.id,
597
720
  name: typeof c.name === 'string' ? c.name : c.id,
598
- strategy: c.strategy === 'round-robin' ? 'round-robin' as const : 'fallback' as const,
599
721
  models,
600
722
  }
601
723
  })
@@ -627,6 +749,22 @@ export class Router {
627
749
  this.store.sync(this.suppliers.map((s) => s.id))
628
750
  }
629
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
+
630
768
  /** 移除供应商(外部插件卸载时注销)。 */
631
769
  removeSupplier(id: string): boolean {
632
770
  const i = this.suppliers.findIndex((s) => s.id === id)
@@ -637,14 +775,20 @@ export class Router {
637
775
  return true
638
776
  }
639
777
 
640
- /** 返回全部供应商状态(面板用)。 */
641
- status(): { suppliers: SupplierStatus[] } {
642
- 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
+ }
643
787
  }
644
788
 
645
- /** 供应商前缀信息(组合模型全名 = alias/id,展示时动态拼接)。 */
789
+ /** 供应商前缀信息(组合模型全名 = alias/id,展示时动态拼接)。只含开着的。 */
646
790
  aliases(): Array<{ id: string; name: string; alias: string }> {
647
- 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() }))
648
792
  }
649
793
 
650
794
  /**
@@ -661,7 +805,7 @@ export class Router {
661
805
  out.push({ id: c.name })
662
806
  }
663
807
  }
664
- for (const s of this.suppliers) {
808
+ for (const s of this.active) {
665
809
  try {
666
810
  const ids = s.customModelIds?.()
667
811
  for (const id of ids ?? []) {
@@ -730,7 +874,7 @@ export class Router {
730
874
  }
731
875
 
732
876
  async modelsOf(supplierId: string, force = false): Promise<ModelWithEnabled[]> {
733
- const s = this.suppliers.find((x) => x.id === supplierId)
877
+ const s = this.active.find((x) => x.id === supplierId)
734
878
  if (s === undefined) return []
735
879
  const hit = this.modelsCache.get(supplierId)
736
880
  const fresh = hit !== undefined && Date.now() - hit.fetchedAt < MODELS_TTL_MS
@@ -765,7 +909,14 @@ export class Router {
765
909
  enabled: !disabled.has(mm.id),
766
910
  custom: custom.has(mm.id) ? true : undefined,
767
911
  }))
768
- // 自定义模型(listModels 之外的)并入显示
912
+ // 自定义模型(listModels 之外的)并入显示。
913
+ //
914
+ // 注意这里**刻意不**把「用户禁用过的模型」也补回来:插件的 listModels 只该报
915
+ // 「模型来源」,启用状态由核心合并(见 loader.ts 的 modelsWithEnabled)。
916
+ // 插件若在 listModels 里就把自己禁用的过滤掉,那是它违约 —— 该修的是插件,
917
+ // 核心不该反过来替它兜着。兜着的后果比表面症状更糟:面板看着正常了,
918
+ // 而体检的越权检测(probe.ts 的 hiddenDisabledIds)会因为模型已被补全而
919
+ // 永远报 OK,那个 bug 就再也没人发现了。
769
920
  const seen = new Set(models.map((mm) => mm.id))
770
921
  for (const id of custom) {
771
922
  if (!seen.has(id)) {
@@ -802,7 +953,7 @@ export class Router {
802
953
  /** 可用模型(按供应商分组,仅启用),面板加模型用。
803
954
  * 各供应商**并行**拉取——串行会把每个上游的延迟累加起来。 */
804
955
  async supplierModels(): Promise<Array<{ supplier: { id: string; name: string; alias: string }; models: ModelWithEnabled[] }>> {
805
- const groups = await Promise.all(this.suppliers.map(async (s) => {
956
+ const groups = await Promise.all(this.active.map(async (s) => {
806
957
  try {
807
958
  const models = (await this.modelsOf(s.id)).filter((m) => m.enabled)
808
959
  return { supplier: { id: s.id, name: s.name, alias: s.getAlias() }, models }
@@ -819,10 +970,6 @@ export class Router {
819
970
  typeof m === 'string' && m !== '')
820
971
  }
821
972
 
822
- private validStrategy(strategy: string | undefined): strategy is 'fallback' | 'round-robin' {
823
- return strategy === 'fallback' || strategy === 'round-robin'
824
- }
825
-
826
973
  /** 组合模型统一存裸 id:剥掉 alias/ 前缀(前缀随供应商动态变)。
827
974
  * 只剥「已知 alias + /」开头的前缀——模型 id 本身可以含斜杠
828
975
  * (如 nvidia 的 `deepseek-ai/deepseek-v4-flash-0731`),用 lastIndexOf 会把
@@ -844,27 +991,26 @@ export class Router {
844
991
  if (slash <= 0) return m
845
992
  const alias = m.slice(0, slash)
846
993
  const modelId = m.slice(slash + 1)
847
- const s = this.suppliers.find((x) => x.getAlias() === alias)
994
+ const s = this.active.find((x) => x.getAlias() === alias)
848
995
  return s === undefined ? modelId : `${s.id},${modelId}`
849
996
  })
850
997
  }
851
998
 
852
- /** 创建组合(name 唯一,非 default)。 */
853
- createCombo(name: string, strategy: string, models: string[]): { ok: boolean; error?: string; combo?: Combo } {
999
+ /** 创建组合(name 唯一,非 default)。选模型是降级链(按序尝试),无策略。 */
1000
+ createCombo(name: string, models: string[]): { ok: boolean; error?: string; combo?: Combo } {
854
1001
  const clean = name.trim()
855
1002
  if (clean === '' || clean === 'default') return { ok: false, error: '组合名无效' }
856
1003
  if (!/^[A-Za-z0-9._-]+$/.test(clean)) return { ok: false, error: '组合名只能含字母、数字、-、_ 和 .' }
857
1004
  if (this.customCombos.some((c) => c.name === clean)) return { ok: false, error: `组合 ${clean} 已存在` }
858
1005
  if (!this.validModels(models)) return { ok: false, error: '至少需要一个模型' }
859
- if (!this.validStrategy(strategy)) return { ok: false, error: '策略无效' }
860
- const combo: Combo = { id: clean, name: clean, strategy, models: this.normalizeModelIds(models) }
1006
+ const combo: Combo = { id: clean, name: clean, models: this.normalizeModelIds(models) }
861
1007
  this.customCombos.push(combo)
862
1008
  this.saveCombos()
863
1009
  return { ok: true, combo }
864
1010
  }
865
1011
 
866
1012
  /** 更新组合(按 id)。 */
867
- updateCombo(id: string, name: string, strategy: string, models: string[]): { ok: boolean; error?: string } {
1013
+ updateCombo(id: string, name: string, models: string[]): { ok: boolean; error?: string } {
868
1014
  const target = this.customCombos.find((c) => c.id === id)
869
1015
  if (!target) return { ok: false, error: '组合不存在' }
870
1016
  const clean = name.trim()
@@ -872,9 +1018,7 @@ export class Router {
872
1018
  if (!/^[A-Za-z0-9._-]+$/.test(clean)) return { ok: false, error: '组合名只能含字母、数字、-、_ 和 .' }
873
1019
  if (this.customCombos.some((c) => c.id !== id && c.name === clean)) return { ok: false, error: `组合 ${clean} 已存在` }
874
1020
  if (!this.validModels(models)) return { ok: false, error: '至少需要一个模型' }
875
- if (!this.validStrategy(strategy)) return { ok: false, error: '策略无效' }
876
1021
  target.name = clean
877
- target.strategy = strategy
878
1022
  target.models = this.normalizeModelIds(models)
879
1023
  this.saveCombos()
880
1024
  return { ok: true }
@@ -929,22 +1073,24 @@ export class Router {
929
1073
  ttfbMs: probe.ttfbMs,
930
1074
  // 只有真被服务过才记 uid:失败请求的 uid 可能是空或上一次的残留
931
1075
  ...(ok && probe.uid !== '' ? { uid: probe.uid } : {}),
1076
+ ...(req.session === undefined ? {} : { session: req.session }),
932
1077
  ...(error === undefined ? {} : { error }),
933
1078
  }, usage)
934
1079
  }
935
1080
 
936
1081
  const combo = this.comboByName(req.model)
937
1082
  if (combo) {
938
- // 组合:按策略选起点,然后按组合模型顺序回退
939
- const start = combo.strategy === 'round-robin'
940
- ? (this.rrCursors.get(combo.id) ?? 0) % combo.models.length
941
- : 0
942
- if (combo.strategy === 'round-robin') this.rrCursors.set(combo.id, (this.rrCursors.get(combo.id) ?? 0) + 1)
1083
+ // 组合 = 降级链:从第一个模型起顺序尝试,谁先服务成功就停。
943
1084
  for (let i = 0; i < combo.models.length; i++) {
944
- const model = combo.models[(start + i) % combo.models.length]
1085
+ const model = combo.models[i]
945
1086
  if (model === undefined) continue
946
1087
  const trace: ChatTrace = { attempts: 0 }
947
- 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
+ )
948
1094
  if (served) {
949
1095
  settle(true)
950
1096
  return
@@ -1003,8 +1149,18 @@ export class Router {
1003
1149
  * @param outTrace 可选出参:把这次调用的追踪信息带回去(组合据此判断
1004
1150
  * 要不要「喘口气」再降级)。裸 id 遍历路径不填(多供应商混在一起,
1005
1151
  * 失败状态没有单一归属)。
1152
+ * @param firstByteDeadlineAt 这条腿的首字节截止时刻(绝对 ms)。同一条腿内的
1153
+ * 多个账号共享它——停滞是上游级的,按号各给一份会 11×60s 才轮到下一个模型。
1154
+ * 缺省 = 此刻起一份全新预算(直接调用路径只有这一条腿)。
1006
1155
  */
1007
- 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> {
1008
1164
  const comma = model.indexOf(',')
1009
1165
  const supplierId = comma > 0 ? model.slice(0, comma) : undefined
1010
1166
  // 组合存的是供应商 id,但插件认的是自己的模型 id
@@ -1028,28 +1184,33 @@ export class Router {
1028
1184
  return false
1029
1185
  }
1030
1186
  // 精准:只调这一个供应商。查不到就是配置错了,直接失败(不遍历兜底)
1031
- const s = this.suppliers.find((x) => x.id === supplierId)
1187
+ const s = this.active.find((x) => x.id === supplierId)
1032
1188
  if (s === undefined) {
1033
- 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)
1034
1195
  return false
1035
1196
  }
1036
1197
  const t0 = Date.now()
1037
1198
  const trace: ChatTrace = { attempts: 0 }
1038
- const served = await this.chatWithSupplier(s, clone, res, trace, probe)
1199
+ const served = await this.chatWithSupplier(s, clone, res, trace, probe, firstByteDeadlineAt)
1039
1200
  if (outTrace !== undefined) {
1040
1201
  outTrace.attempts = trace.attempts
1041
1202
  outTrace.lastError = trace.lastError
1042
1203
  outTrace.lastState = trace.lastState
1043
1204
  }
1044
1205
  this.logChat(req.model, served
1045
- ? `${s.id}/${modelId} (${trace.uid ?? '?'}) ok${trace.attempts > 0 ? ` 重试${trace.attempts}次` : ''}`
1206
+ ? `${s.id}/${modelId} (${trace.uid ?? '?'}) ok${trace.attempts > 0 ? ` 重试${trace.attempts}次` : ''}${trace.pickWhy !== undefined ? ` [选号: ${trace.pickWhy}]` : ''}`
1046
1207
  : `${s.id}/${modelId} 失败 ${trace.lastError ?? 'no account'}`, Date.now() - t0)
1047
1208
  return served
1048
1209
  }
1049
1210
 
1050
1211
  /** 按别名找供应商(对外模型全名 = alias/model)。别名唯一,故最多命中一个。 */
1051
1212
  supplierByAlias(alias: string): Supplier | undefined {
1052
- return this.suppliers.find((s) => s.getAlias() === alias)
1213
+ return this.active.find((s) => s.getAlias() === alias)
1053
1214
  }
1054
1215
 
1055
1216
  /**
@@ -1060,8 +1221,24 @@ export class Router {
1060
1221
  * 还能换号重试;一旦写出第一个字节就绑死(HTTP 语义,9router 同样如此),
1061
1222
  * 之后出错不再换号——要彻底能回退就得在写头前缓冲判定,代价是首字节
1062
1223
  * 延迟,目前未做。
1224
+ *
1225
+ * @param firstByteDeadlineAt 这条腿的首字节截止时刻(见 chatWithModel)。
1226
+ * 预算耗尽后不再调任何号:剩下的号大概率同样停滞(上游级故障),
1227
+ * 挨个等下去只会把 60s 乘以号数,组合永远轮不到下一个模型。
1063
1228
  */
1064
- 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> {
1065
1242
  const pool = s.pool
1066
1243
  const cfg = this.store.get(s.id)
1067
1244
  // 无账号供应商:直接调一次(uid 传空,插件忽略)
@@ -1085,7 +1262,9 @@ export class Router {
1085
1262
  probe.model = `${s.getAlias()}/${stripAlias(req.model, s.getAlias())}`
1086
1263
  probe.uid = ''
1087
1264
  }
1088
- 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
+ )
1089
1268
  }
1090
1269
  // 试过的号不再选:某些失败状态既不冷却也不计数(如模型不属于本供应商),
1091
1270
  // 不排除试过的就会原地打转——死循环等于整个服务挂住。
@@ -1093,9 +1272,35 @@ export class Router {
1093
1272
  // 否则同一会话的两次尝试会绑到不同的号上)。
1094
1273
  const messages = reqMessages(req.rawBody)
1095
1274
  const tried = new Set<string>()
1275
+ let pickWhy = ''
1096
1276
  for (;;) {
1097
- const uid = pool.pick(s.accounts().filter((a) => !tried.has(a.uid)), cfg.poolOrder, cfg.poolStrategy, req.model, messages)
1098
- 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
+ }
1303
+ pickWhy = pool.lastWhy
1099
1304
  tried.add(uid)
1100
1305
  const r = await s.chatOnce(uid, req.lv ?? 'auto', req)
1101
1306
  if (!r.ok) {
@@ -1121,7 +1326,10 @@ export class Router {
1121
1326
  continue
1122
1327
  }
1123
1328
  pool.noteSuccess(uid, req.model)
1124
- if (trace !== undefined) trace.uid = uid
1329
+ if (trace !== undefined) {
1330
+ trace.uid = uid
1331
+ trace.pickWhy = pickWhy
1332
+ }
1125
1333
  if (probe !== undefined) {
1126
1334
  probe.supplier = s.id
1127
1335
  // 记对外全名 alias/model:与直接调用路径一致。组合里存的是裸
@@ -1130,13 +1338,13 @@ export class Router {
1130
1338
  probe.model = `${s.getAlias()}/${stripAlias(req.model, s.getAlias())}`
1131
1339
  probe.uid = uid
1132
1340
  }
1133
- 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())
1134
1342
  if (committed) {
1135
1343
  // 块轮询反馈:只有成功请求才计入(失败没产生缓存,计入会污染命中率)
1136
- pool.noteCache(uid, req.model, probe?.tokens?.cachedTokens ?? 0)
1344
+ pool.noteCache(uid, req.model, probe?.tokens?.cachedTokens ?? 0, req.session)
1137
1345
  return true
1138
1346
  }
1139
- // 一个字节都没写(上游刚连上就断)→ 这个号不算数,换下一个重试
1347
+ // 一个字节都没写(上游刚连上就断 / 首字节超时)→ 这个号不算数,换下一个重试
1140
1348
  pool.noteFailure(uid, req.model, 'transport', 'stream failed before first byte')
1141
1349
  if (trace !== undefined) {
1142
1350
  trace.attempts += 1
@@ -1146,11 +1354,17 @@ export class Router {
1146
1354
  }
1147
1355
  }
1148
1356
 
1149
- /** 测试某供应商的某模型是否可用。
1150
- * 走真实的账号遍历 + chatOnce 路径:账号池回退/冷却由核心实现,自动生效;
1151
- * 响应丢弃到 sink,限定单一供应商(不跨供应商回退)。 */
1152
- async testModel(supplierId: string, model: string): Promise<{ ok: boolean; error?: string }> {
1153
- 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)
1154
1368
  if (s === undefined) return { ok: false, error: `unknown supplier ${JSON.stringify(supplierId)}` }
1155
1369
  const sink = sinkRes()
1156
1370
  const req: ChatRequest = {
@@ -1161,7 +1375,7 @@ export class Router {
1161
1375
  let served = false
1162
1376
  const trace: ChatTrace = { attempts: 0 }
1163
1377
  try {
1164
- served = await this.chatWithSupplier(s, req, sink, trace)
1378
+ served = await this.chatWithSupplier(s, req, sink, trace, undefined, Date.now() + this.firstByteBudgetMs, onlyUid)
1165
1379
  } catch (err) {
1166
1380
  return { ok: false, error: (err as Error).message }
1167
1381
  }