dsh-router-core 0.3.10 → 0.3.12

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 (43) hide show
  1. package/README.md +30 -1
  2. package/docs/ext.md +224 -0
  3. package/docs/pool-sticky-block.md +206 -0
  4. package/docs/suppliers.md +5 -1
  5. package/lib/client-registry.js +270 -96
  6. package/lib/client.js +270 -96
  7. package/lib/index.js +372 -13
  8. package/lib/suppliers/openrouter.js +6 -1
  9. package/lib/types/data-dir.d.ts +11 -0
  10. package/lib/types/ext/contract.d.ts +95 -0
  11. package/lib/types/ext/store.d.ts +16 -0
  12. package/lib/types/llm/adapter.d.ts +7 -0
  13. package/lib/types/router/account-pool.d.ts +47 -1
  14. package/lib/types/router/index.d.ts +15 -0
  15. package/lib/types/router/types.d.ts +2 -0
  16. package/lib/types/router/usage-store.d.ts +8 -0
  17. package/lib/types/shared.d.ts +20 -0
  18. package/lib/types/suppliers/contract.d.ts +21 -0
  19. package/package.json +1 -1
  20. package/src/client/ExtTab.tsx +143 -0
  21. package/src/client/RouterView.tsx +12 -4
  22. package/src/client/StatsTab.tsx +4 -2
  23. package/src/client/SupplierDetail.tsx +2 -2
  24. package/src/client/router.css +19 -0
  25. package/src/data-dir.test.ts +54 -0
  26. package/src/data-dir.ts +46 -0
  27. package/src/ext/contract.ts +115 -0
  28. package/src/ext/store.test.ts +144 -0
  29. package/src/ext/store.ts +125 -0
  30. package/src/index.ts +101 -6
  31. package/src/llm/adapter.test.ts +17 -0
  32. package/src/llm/adapter.ts +8 -1
  33. package/src/router/account-pool.test.ts +118 -1
  34. package/src/router/account-pool.ts +115 -3
  35. package/src/router/index.ts +41 -1
  36. package/src/router/models-cache.test.ts +38 -0
  37. package/src/router/types.ts +2 -0
  38. package/src/router/usage-store.ts +8 -0
  39. package/src/shared.ts +22 -0
  40. package/src/suppliers/contract.ts +22 -0
  41. package/src/suppliers/loader.test.ts +66 -1
  42. package/src/suppliers/loader.ts +22 -2
  43. package/src/suppliers/openrouter/plugin.ts +5 -0
@@ -36,12 +36,129 @@ test('选号:某模型冷却中的号对该模型跳过,全冷却返回 unde
36
36
  assert.equal(p.pick(list, [], 'fallback', 'm1'), undefined)
37
37
  })
38
38
 
39
- test('round-robin 在健康号间轮转(带 model)', () => {
39
+ test('round-robin 是块轮询:块内粘住不换号(带 model)', () => {
40
40
  const p = pool()
41
41
  const list = accs(['a', 'b'])
42
+ // 块没满 → 一直粘在 a(这就是缓存命中的来源)
42
43
  assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'a')
44
+ assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'a')
45
+ assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'a')
46
+ })
47
+
48
+ // ============ 块轮询(docs/pool-sticky-block.md) ============
49
+
50
+ /** 连打 n 次成功请求,全部命中缓存。 */
51
+ const serveHits = (p: AccountPool, uid: string, n: number, model = 'm1'): void => {
52
+ for (let i = 0; i < n; i++) p.noteCache(uid, model, 1000)
53
+ }
54
+ /** 连打 n 次成功请求,全部未命中(cold miss)。 */
55
+ const serveMiss = (p: AccountPool, uid: string, n: number, model = 'm1'): void => {
56
+ for (let i = 0; i < n; i++) p.noteCache(uid, model, 0)
57
+ }
58
+
59
+ test('【块】OR:计数<N(最短驻留)且没达标 → 留守,哪怕缓存已热', () => {
60
+ const p = pool()
61
+ const list = accs(['a', 'b'])
62
+ assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'a')
63
+ // 计数 15 < N(16),命中率 100% >= M → 留守(最短驻留没走完,不因热度提前跳)
64
+ serveHits(p, 'a', 15)
65
+ assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'a')
66
+ })
67
+
68
+ test('【块】OR:计数到 N 且命中率达标 → 换号(下一号也冷启动,让位均衡)', () => {
69
+ const p = pool()
70
+ const list = accs(['a', 'b'])
71
+ assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'a')
72
+ // 计数 16 >= N 且命中率 100% >= 85% → 换号
73
+ serveHits(p, 'a', 16)
74
+ assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'b')
75
+ })
76
+
77
+ test('【块】OR:命中率没达标 → 计数到 N 仍留守(继续热,不急换)', () => {
78
+ const p = pool()
79
+ const list = accs(['a', 'b'])
80
+ assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'a')
81
+ // 计数 20 全 miss → 命中率 0% < 85% → 留守(OR 语义:ratio<M 时留守)
82
+ serveMiss(p, 'a', 20)
83
+ assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'a')
84
+ })
85
+
86
+ test('【块】N_max 兜底:命中率一直不达标 → 到 N_max(48) 必换', () => {
87
+ const p = pool()
88
+ const list = accs(['a', 'b'])
89
+ assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'a')
90
+ // 一直 miss,命中率 0% 永不达标 → 不靠 N 换,靠 N_max 兜底
91
+ serveMiss(p, 'a', 48)
92
+ // 47 次后仍在 a;第 48 次触发 noteCache 的降权,随后 pick 跳过 a
93
+ assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'b')
94
+ })
95
+
96
+ test('【块】驻留满 N_max 仍不达标 → 该号降权,后续轮转跳过', () => {
97
+ const p = pool()
98
+ const list = accs(['a', 'b', 'c'])
99
+ assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'a')
100
+ serveMiss(p, 'a', 48) // a 判定缓存无效(降权)
101
+ assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'b') // 跳过 a
102
+ // b 正常服务并达标 → 切走后轮转也应跳过 a
103
+ serveHits(p, 'b', 16)
104
+ assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'c')
105
+ })
106
+
107
+ test('【块】降权不是永久判决:全池都降权时退回健康池兜底', () => {
108
+ const p = pool()
109
+ const list = accs(['a', 'b'])
110
+ assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'a')
111
+ serveMiss(p, 'a', 48)
112
+ assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'b')
113
+ serveMiss(p, 'b', 48)
114
+ // 两个都降权 → 不能返回 undefined(那等于服务挂了),退回健康池
115
+ const uid = p.pick(list, [], 'round-robin', 'm1')
116
+ assert.equal(uid !== undefined, true)
117
+ })
118
+
119
+ test('【块】故障切走作废块:dropBlock 后重新选号', () => {
120
+ const p = pool()
121
+ const list = accs(['a', 'b'])
122
+ assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'a')
123
+ serveHits(p, 'a', 3) // 块才 3 次,本来会留守
124
+ p.dropBlock('m1')
125
+ // 块作废 → 从 a 之后前进,选到 b(而不是带着旧计数继续粘 a)
126
+ assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'b')
127
+ })
128
+
129
+ test('【块】失败请求不入块统计(失败没产生缓存,计入会污染命中率)', () => {
130
+ const p = pool()
131
+ const list = accs(['a', 'b'])
132
+ assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'a')
133
+ // 16 次全命中 → 达标切走(失败不算,否则比例被稀释永不达标)
134
+ serveHits(p, 'a', 16)
43
135
  assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'b')
136
+ })
137
+
138
+ test('【块】块状态按模型隔离:m1 的驻留不影响 m2', () => {
139
+ const p = pool()
140
+ const list = accs(['a', 'b'])
44
141
  assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'a')
142
+ serveHits(p, 'a', 16) // m1 上 a 已达标
143
+ assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'b')
144
+ // m2 是独立的块 → 从头开始,选 a
145
+ assert.equal(p.pick(list, [], 'round-robin', 'm2'), 'a')
146
+ })
147
+
148
+ test('【块】多号间长期均衡:块大小有界 → 请求数均分', () => {
149
+ const p = pool()
150
+ const list = accs(['a', 'b', 'c'])
151
+ const count: Record<string, number> = { a: 0, b: 0, c: 0 }
152
+ // 每个块 16 次(全命中达标),跑 12 个块
153
+ for (let block = 0; block < 12; block++) {
154
+ const uid = p.pick(list, [], 'round-robin', 'm1')!
155
+ count[uid]! += 1
156
+ serveHits(p, uid, 16)
157
+ }
158
+ // 12 块 / 3 号 → 每号 4 块
159
+ assert.equal(count['a'], 4)
160
+ assert.equal(count['b'], 4)
161
+ assert.equal(count['c'], 4)
45
162
  })
46
163
 
47
164
  // ============ 核心新语义:冷却按 (模型, 连接) 分号 ============
@@ -86,6 +86,15 @@ const ERR_THRESHOLD = 3
86
86
  /** 攒够后的冷却时长。 */
87
87
  const ERR_COOLDOWN_MS = 5 * MINUTE
88
88
 
89
+ /** 块轮询:命中率达标就放行(`留守 = 计数<N OR 命中率<M`)。见 stickyBlock 说明。 */
90
+ const BLOCK_HIT_RATIO = 0.85
91
+ /** 块轮询:最短驻留。新接到这个号的最少连续服务次数(防冷启动误切)。 */
92
+ const BLOCK_MIN = 16
93
+ /** 块轮询:绝对兜底。轮次过多(max)无论命中率如何都让位,防死守 + 保均衡下限。 */
94
+ const BLOCK_MAX = 48
95
+ /** 块轮询:判定「缓存无效」后的跳过时长。 */
96
+ const BLOCK_DEMOTE_MS = 10 * MINUTE
97
+
89
98
  /** 冷却记录(键 = (supplier, model, uid))。 */
90
99
  interface CooldownEntry {
91
100
  until: number
@@ -100,6 +109,26 @@ interface UidEntry {
100
109
  reason: string
101
110
  }
102
111
 
112
+ /**
113
+ * 块轮询的驻留状态(键 = (supplier, model),与冷却同粒度)。
114
+ *
115
+ * 为什么按 (供应商, 模型) 而不是按号:块是「当前这一轮由谁服务」的概念,
116
+ * 一个模型上只有一个号在驻留;换模型意味着换前缀,块自然作废。
117
+ */
118
+ interface BlockState {
119
+ /** 当前驻留的号。 */
120
+ uid: string
121
+ /** 本块已服务的成功请求数。 */
122
+ served: number
123
+ /** 本块命中缓存(cachedTokens > 0)的请求数。 */
124
+ hits: number
125
+ }
126
+
127
+ /** 「缓存无效」降权记录(键 = (supplier, model, uid))。 */
128
+ interface DemoteEntry {
129
+ until: number
130
+ }
131
+
103
132
  /** 指数退避:等级 1 = BACKOFF_BASE_MS,每级翻倍,封顶 BACKOFF_MAX_MS。 */
104
133
  function backoffMs(level: number): number {
105
134
  return Math.min(BACKOFF_BASE_MS * 2 ** Math.max(0, level - 1), BACKOFF_MAX_MS)
@@ -116,6 +145,10 @@ export class AccountPool {
116
145
  /** uid → 连接级禁用 / 手动暂停。 */
117
146
  private byUid = new Map<string, UidEntry>()
118
147
  private rrCursor = 0
148
+ /** (supplier, model) → 块轮询驻留状态。 */
149
+ private blocks = new Map<string, BlockState>()
150
+ /** (supplier, model, uid) → 缓存无效降权。 */
151
+ private demoted = new Map<string, DemoteEntry>()
119
152
 
120
153
  constructor(supplierId = '') {
121
154
  this.supplierId = supplierId
@@ -169,9 +202,19 @@ export class AccountPool {
169
202
 
170
203
  /**
171
204
  * 按策略为「某个模型」选一个健康账号。
205
+ *
206
+ * round-robin 走**块轮询**:一次轮转内连续服务若干请求,而不是每请求换号。
207
+ * 设计见 docs/pool-sticky-block.md。要点:
208
+ * - 留守 = `计数 < N OR 命中率 < M`(OR,用户拍板):
209
+ * 命中率达标(缓存热了)→ 换号保均衡;计数到 N → 换号止损。
210
+ * 单号最多连续服务 N 次,不会因命中率低而死守到 N_max(打架场景关键)。
211
+ * - 切号因子的思路:命中率是**留守**条件不是切号条件。反接会正反馈死锁
212
+ * (切→cold miss→命中率 0%→触发切号→…),见文档 §3。
213
+ * - `N_max` 绝对兜底:命中率永远不达标的号不能无限驻留(防死锁 + 防均衡归零)。
214
+ *
172
215
  * @param accounts 插件报告的「现在状态」(顺序即插件的自然顺序)
173
216
  * @param poolOrder 用户在面板拖出来的顺序(核心管)
174
- * @param strategy fallback 取第一个健康 / round-robin 轮转健康号
217
+ * @param strategy fallback 取第一个健康 / round-robin 块轮转健康号
175
218
  * @param modelId 当前要路由的模型(决定查哪个 (model, uid) 冷却单元)
176
219
  * @returns 选中的 uid;无健康账号返回 undefined
177
220
  */
@@ -186,11 +229,80 @@ export class AccountPool {
186
229
  const healthy = ordered.filter((uid) => this.healthy(uid, modelId, now))
187
230
  if (healthy.length === 0) return undefined
188
231
  if (strategy !== 'round-robin') return healthy[0]
189
- const uid = healthy[this.rrCursor % healthy.length]
190
- this.rrCursor = (this.rrCursor + 1) % healthy.length
232
+ // 缓存无效降权:驻留满 N_max 命中率仍不达标的号,跳过它(可到期恢复)
233
+ const usable = healthy.filter((uid) => !this.isDemoted(uid, modelId, now))
234
+ const pool = usable.length > 0 ? usable : healthy
235
+ const block = this.blocks.get(this.blockKey(modelId))
236
+ if (block !== undefined && pool.includes(block.uid) && this.blockKeeps(block)) {
237
+ return block.uid
238
+ }
239
+ // 块满/块达标/块不存在 → 前进游标。从当前号之后开始找,保证轮转顺序稳定。
240
+ const from = block === undefined ? this.rrCursor : Math.max(0, pool.indexOf(block.uid) + 1)
241
+ const uid = pool[from % pool.length]!
242
+ this.rrCursor = (pool.indexOf(uid) + 1) % pool.length
243
+ this.blocks.set(this.blockKey(modelId), { uid, served: 0, hits: 0 })
191
244
  return uid
192
245
  }
193
246
 
247
+ /** 该号在本模型上是否被判定「缓存无效」(降权中)。 */
248
+ private isDemoted(uid: string, modelId: string, now: number): boolean {
249
+ const e = this.demoted.get(this.key(modelId, uid))
250
+ return e !== undefined && e.until > now
251
+ }
252
+
253
+ /**
254
+ * 块是否还应驻留在当前号上。
255
+ *
256
+ * OR 语义(用户拍板):`留守 = 计数 < N OR 命中率 < M`。
257
+ * 单一账号内部:一个号先被轮到(冷启动/热),然后
258
+ * - 命中率**爬上去**(>= M,缓存热了)→ 让位,轮到下一个号(均衡)
259
+ * - 命中率**没上去**(冷启动/短块)→ 留守,继续热(不急着跳)
260
+ * - 轮次**实在太多**(计数 >= N_max)→ 也换(防死守 + 保均衡下限)
261
+ *
262
+ * 关键:命中率是**留守**条件不是切号执行器(反接会正反馈死锁,
263
+ * 见文档 §3)。换号由「命中达标 OR 份额用够」触发,都不是「命中率低」。
264
+ *
265
+ * 每个号各自守着「这个会话在自己账号上的那段前缀」,轮流热;
266
+ * 命中率 = 块的命中占比。单会话下每个号块都从冷到热各付一次,
267
+ * 收益来自块内连续驻留(缺了它就跟每请求轮询一样)。
268
+ */
269
+ private blockKeeps(block: BlockState): boolean {
270
+ if (block.served >= BLOCK_MAX) return false // 轮次过多 → 让位(兜底)
271
+ const ratio = block.served === 0 ? 0 : block.hits / block.served
272
+ return block.served < BLOCK_MIN || ratio < BLOCK_HIT_RATIO // 计数<N OR 命中率<M
273
+ }
274
+
275
+ /**
276
+ * 记录一次成功请求的缓存命中情况(块轮询的反馈信号)。
277
+ *
278
+ * 只有**成功**请求才计入:失败没产生缓存,计入会污染命中率并导致错切。
279
+ * @param uid 实际服务的号
280
+ * @param modelId 模型(块粒度)
281
+ * @param cachedTokens 上游报的命中 token 数;0 = 全量重算
282
+ */
283
+ noteCache(uid: string, modelId: string, cachedTokens: number): void {
284
+ const k = this.blockKey(modelId)
285
+ const block = this.blocks.get(k)
286
+ // 块已被别处重置/换号(如故障切走)→ 这次结果不属于当前块,忽略
287
+ if (block === undefined || block.uid !== uid) return
288
+ block.served += 1
289
+ if (cachedTokens > 0) block.hits += 1
290
+ // 驻留满 N_max 仍不达标 → 判定缓存无效,降权一段时间(可恢复)
291
+ if (block.served >= BLOCK_MAX && block.hits / block.served < BLOCK_HIT_RATIO) {
292
+ this.demoted.set(this.key(modelId, uid), { until: Date.now() + BLOCK_DEMOTE_MS })
293
+ }
294
+ }
295
+
296
+ /** 故障切走时作废当前块:换号后前缀归属变了,块计数不能沿用。 */
297
+ dropBlock(modelId: string): void {
298
+ this.blocks.delete(this.blockKey(modelId))
299
+ }
300
+
301
+ /** 块状态键:与冷却同粒度 (supplier, model),不含 uid。 */
302
+ private blockKey(modelId: string): string {
303
+ return `${this.supplierId}${SEP}${modelId}`
304
+ }
305
+
194
306
  /** 记录一次失败:按状态处置(冷却/禁用/退避),落在「(模型, 连接)」这个冷却单元上。 */
195
307
  noteFailure(uid: string, modelId: string, state: AccountState, message: string): void {
196
308
  const rule = RULES[state]
@@ -615,6 +615,38 @@ export class Router {
615
615
  * 只有两种情况会真的等:`force`(用户主动刷新)和**从来没有过缓存值**
616
616
  * (冷启动,没有旧值可给)。后者只发生一次。
617
617
  */
618
+ /**
619
+ * 组合的上下文窗口(token):组合内各模型窗口的**最小值**,拿不到就 undefined。
620
+ *
621
+ * 只读缓存、绝不打上游:调用方是 llm adapter 的 `resolveModel`,它在每次
622
+ * 请求前后都可能被调用,不能为了报一个容量去触发按供应商的网络往返。
623
+ * 冷启动(还没缓存)时给 undefined —— 自动压缩会因此跳过这一轮,下次就
624
+ * 有了,比撒谎说一个窗口值安全。
625
+ *
626
+ * 单位:`ModelInfo.context_length` 存的是 **K**(供应商插件统一
627
+ * `Math.round(tokens / 1000)`,见 codebuddy plugin),这里换算回 token。
628
+ *
629
+ * 取最小而不是最大:组合会按策略 fallback 到任意一个模型,按最窄的声明
630
+ * 才能让「快满了」这件事在任何一条分支上都成立。
631
+ */
632
+ comboContextWindow(combo: Combo): number | undefined {
633
+ let min: number | undefined
634
+ for (const ref of combo.models) {
635
+ const at = ref.indexOf(',')
636
+ if (at < 0) continue
637
+ const supplierId = ref.slice(0, at)
638
+ const modelId = ref.slice(at + 1)
639
+ const hit = this.modelsCache.get(supplierId)
640
+ if (hit === undefined) return undefined // 这家还没缓存过,不猜
641
+ const m = hit.models.find((x) => x.id === modelId)
642
+ const k = m?.context_length
643
+ if (typeof k !== 'number' || !Number.isFinite(k) || k <= 0) continue
644
+ const tokens = Math.round(k * 1000)
645
+ if (min === undefined || tokens < min) min = tokens
646
+ }
647
+ return min
648
+ }
649
+
618
650
  async modelsOf(supplierId: string, force = false): Promise<ModelWithEnabled[]> {
619
651
  const s = this.suppliers.find((x) => x.id === supplierId)
620
652
  if (s === undefined) return []
@@ -813,6 +845,8 @@ export class Router {
813
845
  ok,
814
846
  durationMs: Date.now() - probe.startedAt,
815
847
  ttfbMs: probe.ttfbMs,
848
+ // 只有真被服务过才记 uid:失败请求的 uid 可能是空或上一次的残留
849
+ ...(ok && probe.uid !== '' ? { uid: probe.uid } : {}),
816
850
  ...(error === undefined ? {} : { error }),
817
851
  }, usage)
818
852
  }
@@ -986,6 +1020,8 @@ export class Router {
986
1020
  return false
987
1021
  }
988
1022
  pool.noteFailure(uid, req.model, r.state, r.message)
1023
+ // 故障切走:下次选号会换号,旧块的驻留计数不再适用(前缀归属变了)
1024
+ pool.dropBlock(req.model)
989
1025
  if (trace !== undefined) {
990
1026
  trace.attempts += 1
991
1027
  trace.lastState = r.state
@@ -1004,8 +1040,12 @@ export class Router {
1004
1040
  probe.uid = uid
1005
1041
  }
1006
1042
  const committed = await writeChatResult(res, r, req.stream, probe, probe?.startedAt ?? 0)
1043
+ if (committed) {
1044
+ // 块轮询反馈:只有成功请求才计入(失败没产生缓存,计入会污染命中率)
1045
+ pool.noteCache(uid, req.model, probe?.tokens?.cachedTokens ?? 0)
1046
+ return true
1047
+ }
1007
1048
  // 一个字节都没写(上游刚连上就断)→ 这个号不算数,换下一个重试
1008
- if (committed) return true
1009
1049
  pool.noteFailure(uid, req.model, 'transport', 'stream failed before first byte')
1010
1050
  if (trace !== undefined) {
1011
1051
  trace.attempts += 1
@@ -671,3 +671,41 @@ test('流式:剥掉上游多发一次的空白 tool_call name/id', async () =>
671
671
  assert.equal(callId, 'chatcmpl-tool-a7ee5a0d3c361c7f', 'callId 也不能被空串冲掉')
672
672
  assert.deepEqual(JSON.parse(args), { command: 'echo hello' }, 'arguments 正常累积')
673
673
  })
674
+
675
+ /**
676
+ * 组合上下文窗口 —— dsh 的自动压缩靠它算阈值(默认窗口用到 80% 触发)。
677
+ * adapter 报不出 contextWindow 时,dsh-compaction-basic 直接抛错并**静默
678
+ * 关闭自动压缩**,上下文于是一路涨到模型硬上限才炸。
679
+ */
680
+ test('组合窗口:取组合内模型的最小值,单位 K → token', async () => {
681
+ const router = new Router('')
682
+ addSupplier(router, supplier('a', [
683
+ { id: 'a-1', context_length: 128 }, // 128K
684
+ { id: 'a-2', context_length: 1000 }, // 1M
685
+ ]).s)
686
+ await router.modelsOf('a') // 填缓存
687
+ const w = router.comboContextWindow({ id: 'x', name: 'x', strategy: 'fallback', models: ['a,a-1', 'a,a-2'] })
688
+ assert.equal(w, 128_000, '取最小的那个(fallback 到任意一条分支都得成立)')
689
+ })
690
+
691
+ test('组合窗口:供应商还没缓存过 → undefined(不猜,绝不打上游)', () => {
692
+ const router = new Router('')
693
+ addSupplier(router, supplier('a', [{ id: 'a-1', context_length: 128 }]).s)
694
+ // 故意不调 modelsOf:模拟冷启动
695
+ assert.equal(
696
+ router.comboContextWindow({ id: 'x', name: 'x', strategy: 'fallback', models: ['a,a-1'] }),
697
+ undefined,
698
+ '没缓存就报 undefined —— 压缩这轮跳过,下次就有,比撒谎安全',
699
+ )
700
+ })
701
+
702
+ test('组合窗口:模型都没报 context_length → undefined', async () => {
703
+ const router = new Router('')
704
+ addSupplier(router, supplier('a', [{ id: 'a-1' }]).s)
705
+ await router.modelsOf('a')
706
+ assert.equal(
707
+ router.comboContextWindow({ id: 'x', name: 'x', strategy: 'fallback', models: ['a,a-1'] }),
708
+ undefined,
709
+ '一家都没报就别编一个数(traework/nvidia 目前就是如此)',
710
+ )
711
+ })
@@ -70,6 +70,8 @@ export interface Supplier {
70
70
  readonly priority: number
71
71
  /** 图标(URL 或 SVG data URI),面板供应商卡片显示。可选。 */
72
72
  readonly icon?: string
73
+ /** 「添加 API key」弹窗提示文案(缺省前端用通用文案)。可选。 */
74
+ readonly apiKeyHint?: string
73
75
  status(): SupplierStatus
74
76
  listModels(force?: boolean): Promise<ModelInfo[]> | ModelInfo[]
75
77
  /** 面板展示的模型(含启用/自定义标记)。 */
@@ -44,6 +44,14 @@ export interface UsageRecord {
44
44
  durationMs: number
45
45
  /** 首字节延迟(ms);非流式为 0。 */
46
46
  ttfbMs: number
47
+ /**
48
+ * 实际服务的账号 uid(成功时回填)。
49
+ *
50
+ * 有了它才能把命中率按**号**下钻——prompt cache 是 per-account 命名空间,
51
+ * 只看供应商/模型无法判断「是某个号的缓存没热,还是策略在乱切号」。
52
+ * 块轮询(docs/pool-sticky-block.md)的调参完全依赖这个归因。
53
+ */
54
+ uid?: string
47
55
  /** 失败原因(ok=false 时)。 */
48
56
  error?: string
49
57
  }
package/src/shared.ts CHANGED
@@ -70,6 +70,8 @@ export interface RouterHealthResponse {
70
70
  name: string
71
71
  /** 图标(URL 或 SVG data URI)。 */
72
72
  icon?: string
73
+ /** 「添加 API key」弹窗提示文案(缺省前端用通用文案)。 */
74
+ apiKeyHint?: string
73
75
  /** 面板能力集合(登录/签到/连接池/模型管理等,按存在性)。 */
74
76
  capabilities?: string[]
75
77
  /** 来源:内置 / 用户目录 / 外部插件。 */
@@ -137,6 +139,26 @@ export interface RouterSettingsResponse {
137
139
  requireApiKey?: boolean
138
140
  }
139
141
 
142
+ /** 一条扩展器(`/router/api/ext`)。 */
143
+ export interface RouterExtItem {
144
+ id: string
145
+ name: string
146
+ description?: string
147
+ /** 是否启用改写(面板开关)。 */
148
+ enabled?: boolean
149
+ /** 运行时是否就绪(如 RTK 二进制是否可用)。 */
150
+ ready?: boolean
151
+ /** 一句话状态说明(如「本机未装 rtk」)。 */
152
+ detail?: string
153
+ }
154
+
155
+ /** `/router/api/ext` response(扩展插件列表 + 开关)。 */
156
+ export interface RouterExtResponse {
157
+ ok: boolean
158
+ error?: string
159
+ enhancers?: RouterExtItem[]
160
+ }
161
+
140
162
  /** 供应商模型(含启用状态)。 */
141
163
  export interface RouterSupplierModel {
142
164
  id: string
@@ -23,6 +23,21 @@ export interface SupplierEnv {
23
23
  store: SupplierConfigStore
24
24
  /** 通用凭证存储(账号凭证,核心统一管)。 */
25
25
  credentials: CredentialStore
26
+ /**
27
+ * 迟到的失败上报(可选)——**只在响应已提交之后**才用。
28
+ *
29
+ * 流式请求一旦写出第一个字节就绑死(HTTP 语义),此时上游再报错已经换不了
30
+ * 号、也改不了状态码。但「这个号坏了」这件事对**后续**请求仍然有价值:
31
+ * 不报上来,它就会继续留在池里被轮转选中,每次都白撞一次同一个错误。
32
+ *
33
+ * 核心实现 = `pool.noteFailure(uid, model, state, message)`,按该状态的规则
34
+ * 冷却/禁用。真实案例:TRAE 免费通道对某账号拒绝某模型时发 HTTP 200 +
35
+ * 流内 `code=4008`,三个号里两个如此,round-robin 下 2/3 请求直接失败
36
+ * (2026-09-08 实测)——有了这条通道,坏号被冷一次就退出轮转。
37
+ *
38
+ * 插件拿不到也不用兜底:不调用就退化成今天的行为,不影响正确性。
39
+ */
40
+ onLateFailure?: (uid: string, model: string, state: AccountState, message: string) => void
26
41
  }
27
42
 
28
43
  /** 通用供应商工厂:一个 js 模块 export 它(或 default export 它),loader 调用得到实例。 */
@@ -97,6 +112,13 @@ export interface SupplierModule {
97
112
  /** 图标(URL 或 SVG data URI),面板供应商卡片显示。可选。 */
98
113
  readonly icon?: string
99
114
 
115
+ /**
116
+ * 「添加 API key」弹窗的提示文案(HTML 未转义前是纯文本)。可选。
117
+ * 缺省时前端用通用文案。供应商若走 `addApiKey` 且有自己的 key 获取方式,
118
+ * 应提供它——否则所有供应商弹窗都显示同一句通用提示(如默认会写 OpenRouter)。
119
+ */
120
+ readonly apiKeyHint?: string
121
+
100
122
  // ---- 核心(必须) ----
101
123
  /** 报账号「现在状态」。冷却/禁用/错误累计由核心叠加,插件不算这些。 */
102
124
  status(): SupplierStatusNow
@@ -15,7 +15,7 @@ import { join } from 'node:path'
15
15
  import { wrapModule } from './loader.ts'
16
16
  import { SupplierConfigStore } from '../supplier-config.ts'
17
17
  import type { CredentialStore } from '../credential-store.ts'
18
- import type { SupplierModule, SupplierAccountNow, ChatOnceResult } from './contract.ts'
18
+ import type { SupplierEnv, SupplierModule, SupplierAccountNow, ChatOnceResult } from './contract.ts'
19
19
  import type { ChatRequest } from '../router/types.ts'
20
20
 
21
21
  const UNKNOWN = -1
@@ -116,3 +116,68 @@ test('不持久化(store fp 为空)时也不炸', async () => {
116
116
  const loaded = wrapModule(plugin('codebuddy', [[acct('cb-1', 50)]]), e, 'test')
117
117
  assert.equal(loaded.supplier.status().accounts[0]?.credits, 50)
118
118
  })
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // onLateFailure:流式响应已提交后才发现的失败,要落回本供应商的池
122
+ // ---------------------------------------------------------------------------
123
+
124
+ /**
125
+ * 造一个会调用 env.onLateFailure 的插件(模拟 traework 的流式 4008 上报)。
126
+ * 记录它拿到的 env —— 用于验证「factory 先跑、回调后挂」这个顺序下插件仍能用。
127
+ */
128
+ function latePlugin(id: string, seen: SupplierEnv[]): SupplierModule {
129
+ let captured: SupplierEnv | undefined
130
+ return {
131
+ id,
132
+ name: id,
133
+ status: () => ({ id, name: id, accounts: [acct('u1', 0), acct('u2', 0)] }),
134
+ listModels: () => [],
135
+ getAlias: () => id,
136
+ chatOnce: async (_uid: string, _req: ChatRequest): Promise<ChatOnceResult> => ({ ok: true, status: 200, body: '{}' }),
137
+ dispose: () => {},
138
+ // 真实插件(traework)在 factory 里存下 env,调用时才读 onLateFailure
139
+ __capture: (env: SupplierEnv) => { captured = env; seen.push(env) },
140
+ __report: (uid: string, model: string) => captured?.onLateFailure?.(uid, model, 'quota', 'boom'),
141
+ } as unknown as SupplierModule
142
+ }
143
+
144
+ test('onLateFailure 挂到 env 上,且冷却作用于本供应商的池(坏号退出选号)', () => {
145
+ const { env: e } = env()
146
+ const seen: SupplierEnv[] = []
147
+ const p = latePlugin('supA', seen)
148
+ const loaded = wrapModule(p, e, 'test')
149
+ assert.notEqual(e.onLateFailure, undefined, 'wrapModule 之后回调已挂上(插件必须调用时才读)')
150
+ // 模拟插件在 factory 里存下 env —— 此刻再读 onLateFailure 才有值
151
+ ;(p as unknown as { __capture(env: SupplierEnv): void }).__capture(e)
152
+
153
+ // 上报前:两个号都健康
154
+ const before = loaded.supplier.status().accounts.map((a) => a.cooling)
155
+ assert.deepEqual(before, [false, false])
156
+ // 上报 u1 在模型 m1 上的配额失败
157
+ ;(p as unknown as { __report(uid: string, model: string): void }).__report('u1', 'm1')
158
+ const after = new Map(loaded.supplier.status().accounts.map((a) => [a.uid, a.cooling]))
159
+ assert.equal(after.get('u1'), true, '上报后该号应进入冷却')
160
+ assert.equal(after.get('u2'), false, '冷却是 (模型,号) 粒度,不能连坐同供应商其它号')
161
+ // 且真的退出选号:只剩 u2
162
+ assert.equal(loaded.supplier.pool.pick(loaded.supplier.accounts(), [], 'fallback', 'm1'), 'u2')
163
+ assert.equal(loaded.supplier.pool.pick(loaded.supplier.accounts(), [], 'fallback', 'm2'), 'u1', '换个模型不受影响')
164
+ })
165
+
166
+ test('onLateFailure 不串供应商:各自落到自己的池', () => {
167
+ const { env: ea } = env()
168
+ const { env: eb } = env()
169
+ const loadedA = wrapModule(latePlugin('supA', []), ea, 'testA')
170
+ const loadedB = wrapModule(latePlugin('supB', []), eb, 'testB')
171
+ ea.onLateFailure?.('u1', 'm1', 'quota', 'boom')
172
+ const coolA = new Map(loadedA.supplier.status().accounts.map((a) => [a.uid, a.cooling]))
173
+ const coolB = new Map(loadedB.supplier.status().accounts.map((a) => [a.uid, a.cooling]))
174
+ assert.equal(coolA.get('u1'), true)
175
+ assert.equal(coolB.get('u1'), false, 'A 的失败不能冷到 B 的号')
176
+ })
177
+
178
+ test('onLateFailure 空 uid(无账号供应商)→ 不记,不炸', () => {
179
+ const { env: e } = env()
180
+ const loaded = wrapModule(latePlugin('supA', []), e, 'test')
181
+ assert.doesNotThrow(() => e.onLateFailure?.('', 'm1', 'quota', 'boom'))
182
+ assert.deepEqual(loaded.supplier.status().accounts.map((a) => a.cooling), [false, false])
183
+ })
@@ -83,13 +83,23 @@ const DIFF_CAPS = [
83
83
  'checkinNow',
84
84
  ] as const
85
85
 
86
+ /**
87
+ * 每个供应商一份 env 副本:`onLateFailure` 是**按供应商**绑到本供应商的池上
88
+ * 的(见 wrapModule),共用一个 env 对象会让所有插件的上报都落到最后一个
89
+ * 供应商的池里。副本是浅拷贝,store/credentials 仍是同一实例。
90
+ */
91
+ function ownEnv(env: SupplierEnv): SupplierEnv {
92
+ return { ...env }
93
+ }
94
+
86
95
  /** 从文件构造 LoadedSupplier。 */
87
96
  async function loadOne(file: string, env: SupplierEnv, source: 'builtin' | 'user'): Promise<LoadedSupplier> {
88
97
  const mod = await importSupplierFile(file)
98
+ const mine = ownEnv(env)
89
99
  // 支持 factory: 模块本身就是工厂函数
90
100
  let instance: SupplierModule
91
101
  if (typeof mod === 'function') {
92
- instance = (mod as unknown as SupplierFactory)(env)
102
+ instance = (mod as unknown as SupplierFactory)(mine)
93
103
  if (typeof instance !== 'object' || instance === null || typeof instance.id !== 'string' || instance.id === '') {
94
104
  throw new Error(`供应商 factory 返回无效: ${file}`)
95
105
  }
@@ -97,7 +107,7 @@ async function loadOne(file: string, env: SupplierEnv, source: 'builtin' | 'user
97
107
  } else {
98
108
  instance = mod
99
109
  }
100
- return wrapModule(instance, env, file, source)
110
+ return wrapModule(instance, mine, file, source)
101
111
  }
102
112
 
103
113
  /**
@@ -119,11 +129,21 @@ export function wrapModule(instance: SupplierModule, env: SupplierEnv, source: s
119
129
  // 冷却键带 supplierId(跨供应商同名模型不串),故构造时注入本供应商 id。
120
130
  const pool = new AccountPool(instance.id)
121
131
 
132
+ // 迟到失败的上报通道:流式响应已提交后插件才发现的错误,经它落回同一个池。
133
+ // **直接挂在插件持有的那个 env 对象上**(不新建副本)——factory 在建 pool
134
+ // 之前就跑完了,插件若构造时读了 env 就永远拿不到这个字段。插件必须在
135
+ // **调用时**读 `env.onLateFailure`(见契约注释),此刻它已经挂好。
136
+ env.onLateFailure = (uid, model, state, message): void => {
137
+ if (uid === '') return // 无账号供应商(如 opencode)没号可记
138
+ pool.noteFailure(uid, model, state, message)
139
+ }
140
+
122
141
  const supplier: Supplier = {
123
142
  id: instance.id,
124
143
  name: instance.name,
125
144
  priority: instance.priority ?? 0,
126
145
  icon: (instance as { icon?: string }).icon,
146
+ apiKeyHint: (instance as { apiKeyHint?: string }).apiKeyHint,
127
147
  status: (): SupplierStatus => {
128
148
  const now = instance.status()
129
149
  // 连接池显示别名覆盖:uid 不变,只改面板展示名