dsh-router-core 0.3.21 → 0.4.0

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 (49) hide show
  1. package/README.md +77 -34
  2. package/docs/pool-sticky-block.md +158 -0
  3. package/lib/client-registry.js +351 -407
  4. package/lib/client.js +351 -407
  5. package/lib/index.js +383 -170
  6. package/lib/types/ext/contract.d.ts +0 -7
  7. package/lib/types/host-version.d.ts +15 -0
  8. package/lib/types/index.d.ts +6 -0
  9. package/lib/types/router/account-pool.d.ts +53 -18
  10. package/lib/types/router/index.d.ts +4 -7
  11. package/lib/types/router/prefix-affinity.d.ts +18 -1
  12. package/lib/types/router/types.d.ts +7 -4
  13. package/lib/types/router/usage-store.d.ts +12 -0
  14. package/lib/types/shared.d.ts +13 -21
  15. package/lib/types/supplier-config.d.ts +0 -3
  16. package/lib/types/suppliers/contract.d.ts +2 -2
  17. package/lib/types/suppliers/registry.d.ts +0 -1
  18. package/package.json +12 -13
  19. package/src/client/CombosTab.tsx +8 -53
  20. package/src/client/ExtDetail.tsx +15 -178
  21. package/src/client/LastHitDock.tsx +141 -0
  22. package/src/client/StatsTab.tsx +8 -6
  23. package/src/client/SupplierDetail.tsx +2 -34
  24. package/src/client/index.tsx +45 -0
  25. package/src/client/last-hit-store.ts +89 -0
  26. package/src/client/model-hint-copy.test.ts +7 -2
  27. package/src/client/model-hint-copy.ts +2 -2
  28. package/src/client/router.css +2106 -1630
  29. package/src/ext/contract.ts +0 -7
  30. package/src/host-version.test.ts +61 -0
  31. package/src/host-version.ts +101 -0
  32. package/src/index.ts +133 -59
  33. package/src/llm/adapter.test.ts +74 -0
  34. package/src/llm/adapter.ts +33 -0
  35. package/src/router/account-pool.test.ts +189 -83
  36. package/src/router/account-pool.ts +120 -31
  37. package/src/router/index.ts +22 -28
  38. package/src/router/models-cache.test.ts +9 -9
  39. package/src/router/precise-dispatch.test.ts +6 -6
  40. package/src/router/prefix-affinity.ts +22 -1
  41. package/src/router/types.ts +7 -5
  42. package/src/router/usage-store.ts +16 -0
  43. package/src/router/usage.test.ts +1 -1
  44. package/src/shared.ts +13 -24
  45. package/src/supplier-config.ts +2 -13
  46. package/src/suppliers/contract.ts +2 -2
  47. package/src/suppliers/loader.test.ts +2 -2
  48. package/src/suppliers/registry.ts +1 -20
  49. package/src/types.d.ts +2 -1
@@ -24,7 +24,7 @@
24
24
  * 得落盘,届时加 stateFile 即可,接口不用变。
25
25
  */
26
26
  import type { AccountState, SupplierAccountNow } from '../suppliers/contract.ts'
27
- import { prefixFingerprint } from './prefix-affinity.ts'
27
+ import { sessionFingerprint } from './prefix-affinity.ts'
28
28
 
29
29
  /** 各 AccountState 的处置:冷却策略 / 是否计入连续错误。 */
30
30
  interface Rule {
@@ -182,9 +182,9 @@ export class AccountPool {
182
182
  private rrCursor = 0
183
183
  /** (supplier, model) → 当前驻留的号(只有**无亲和**的回退路径用它)。 */
184
184
  private blocks = new Map<string, string>()
185
- /** (supplier, model, uid) → 该号的缓存质量样本(降权判据,跨驻留累计)。 */
185
+ /** (supplier, model, uid[, session]) → 该号在某会话下的缓存质量样本。 */
186
186
  private samples = new Map<string, Sample>()
187
- /** (supplier, model, uid) → 缓存无效降权。 */
187
+ /** 同上键 → 缓存无效降权。 */
188
188
  private demoted = new Map<string, DemoteEntry>()
189
189
  /** (supplier, model) → Map<前缀指纹, uid>:前缀亲和的选号记忆(LRU 封顶)。 */
190
190
  private affinity = new Map<string, Map<string, string>>()
@@ -197,6 +197,23 @@ export class AccountPool {
197
197
  return `${this.supplierId}${SEP}${model}${SEP}${uid}`
198
198
  }
199
199
 
200
+ /**
201
+ * 缓存质量样本/降权的键。
202
+ *
203
+ * **按会话隔离**(team 场景的关键):同一个号被多个会话共用时,各会话的
204
+ * 前缀会互相驱逐,把它们混在一起算命中率,会把「多会话互踩」误判成
205
+ * 「这个号缓存坏了」→ 错误降权 → 只剩一个号 → 那个号又被打低 → 降权串。
206
+ * 所以有会话身份时按 (model, uid, session) 记,降权只反映**该会话自己**
207
+ * 在这个号上的真实缓存表现。
208
+ *
209
+ * 无会话身份(外部 OpenAI 客户端,走无指纹回退路径)→ 保持 (model, uid),
210
+ * 与块轮询的原语义一致(那时本就是单会话视角)。
211
+ */
212
+ private sampleKey(model: string, uid: string, session?: string): string {
213
+ const base = this.key(model, uid)
214
+ return session === undefined || session === '' ? base : `${base}${SEP}${session}`
215
+ }
216
+
200
217
  private entry(model: string, uid: string): CooldownEntry {
201
218
  const k = this.key(model, uid)
202
219
  let e = this.cooldowns.get(k)
@@ -236,30 +253,32 @@ export class AccountPool {
236
253
  }
237
254
 
238
255
  /**
239
- * 按策略为「某个模型」选一个健康账号。
256
+ * 为「某个模型」选一个健康账号 —— **会话亲和选号,无策略旋钮**。
240
257
  *
241
- * round-robin = **前缀亲和定号 + 块统计只用来判定缓存无效**:
258
+ * 旧的两策略(fallback 取第一个 / round-robin 轮转)都删了:前者让所有
259
+ * 会话挤同一个号(team/多会话必互踩),后者的"轮转"与亲和"同会话永不换号"
260
+ * 直接冲突。正确模型只有一条:
242
261
  *
243
262
  * 1. 算请求的前缀指纹(messages 前几条),查绑定表 → 命中且该号健康 → 用它。
244
- * 这是第一优先:同一个会话的连续请求**永远落回同一个号**,前缀在该号的
245
- * 缓存里只写一份。块轮询治不了跨会话驱逐(多会话大前缀在同一号互踩,
246
- * 实测 3 会话 3.8% vs 单会话 82.5%),亲和才是根治。
263
+ * 同一个会话的连续请求**永远落回同一个号**,前缀在该号缓存里只写一份。
247
264
  * 2. 新指纹(或绑定的号不健康)→ 按游标挑一个号并**记下绑定**,之后就固定。
248
265
  * 新会话用游标分发,所以不同会话天然散到不同号上(均衡)。
249
- * 3. 块统计不再驱动轮转(亲和已经保证驻留,再轮转只会把会话踢出已热前缀),
250
- * 只保留 `BLOCK_MAX` 次不达标 → 降权该号(缓存无效,可恢复)。
266
+ * 3. 指纹算不出(拿不到 messages)→ **块轮询兜底**(§2 旧行为)。这不是
267
+ * "策略",是无会话身份时的替身。
268
+ * 4. 块统计不驱动轮转(亲和已保证驻留),只保留 `BLOCK_MAX` 次不达标 →
269
+ * 降权该号(缓存无效,可恢复)。
251
270
  *
252
- * 亲和失效(拿不到 messages / 指纹算不出)时**回退到块轮询**,行为与之前一致。
253
- * 设计见 docs/pool-sticky-block.md §8。
271
+ * 天花板:号数 < 并发会话数时必然有会话共用号 → 互踩,无法用选号手法消除
272
+ * (缓存空间是账号侧物理限制)。见 docs/pool-sticky-block.md §10。
254
273
  *
255
274
  * @param accounts 插件报告的「现在状态」(顺序即插件的自然顺序)
256
275
  * @param poolOrder 用户在面板拖出来的顺序(核心管)
257
- * @param strategy fallback 取第一个健康 / round-robin 前缀亲和
258
276
  * @param modelId 当前要路由的模型(决定查哪个 (model, uid) 冷却单元)
259
- * @param messages 请求体的 messages(可选;给了才能算前缀指纹)
277
+ * @param messages 请求体的 messages(算亲和指纹的内容兜底)
278
+ * @param session 宿主会话身份;给了就以它为主键(精确、零撞车)
260
279
  * @returns 选中的 uid;无健康账号返回 undefined
261
280
  */
262
- pick(accounts: SupplierAccountNow[], poolOrder: string[], strategy: string, modelId: string, messages?: unknown): string | undefined {
281
+ pick(accounts: SupplierAccountNow[], poolOrder: string[], modelId: string, messages?: unknown, session?: string): string | undefined {
263
282
  const now = Date.now()
264
283
  const byUid = new Map(accounts.map((a) => [a.uid, a]))
265
284
  // 池顺序优先,未配置的按插件自然顺序追加
@@ -268,35 +287,98 @@ export class AccountPool {
268
287
  ...accounts.map((a) => a.uid).filter((uid) => !poolOrder.includes(uid)),
269
288
  ]
270
289
  const healthy = ordered.filter((uid) => this.healthy(uid, modelId, now))
271
- if (healthy.length === 0) return undefined
272
- if (strategy !== 'round-robin') return healthy[0]
290
+ if (healthy.length === 0) {
291
+ this.lastWhy = '全池无健康号(都在冷却)'
292
+ return undefined
293
+ }
273
294
  // 缓存无效降权:该号缓存质量样本长期不达标 → 跳过它(可到期恢复)
274
- const usable = healthy.filter((uid) => !this.isDemoted(uid, modelId, now))
295
+ const usable = healthy.filter((uid) => !this.isDemoted(uid, modelId, now, session))
275
296
  const pool = usable.length > 0 ? usable : healthy
276
297
  // 前缀亲和:指纹有绑定且该号仍可用 → 直接复用(不进驻留逻辑)
277
- const fp = messages === undefined ? '' : prefixFingerprint(messages)
278
- if (fp !== '' && pool.includes(this.affinity.get(this.blockKey(modelId))?.get(fp) ?? '')) {
279
- return this.affinity.get(this.blockKey(modelId))!.get(fp)!
298
+ const fp = sessionFingerprint(session, messages)
299
+ const bound = fp === '' ? undefined : this.affinity.get(this.blockKey(modelId))?.get(fp)
300
+ if (bound !== undefined && pool.includes(bound)) {
301
+ this.lastWhy = `亲和命中 ${bound}(会话/前缀已绑定)`
302
+ return bound
303
+ }
304
+ // 走到这里说明没粘住:把「为什么没用原绑定」记清楚,否则每次换号都要靠猜
305
+ const why: string[] = []
306
+ if (fp === '') {
307
+ why.push('无指纹(拿不到 messages/会话)')
308
+ } else if (bound === undefined) {
309
+ why.push('该指纹无绑定(会话首次出现)')
310
+ } else {
311
+ if (!healthy.includes(bound)) {
312
+ const c = this.cooldowns.get(this.key(modelId, bound))
313
+ why.push(`原绑定 ${bound} 正在冷却${c?.reason ? `(${c.reason})` : ''}`)
314
+ } else if (!usable.includes(bound)) {
315
+ why.push(`原绑定 ${bound} 被降权(本会话样本不达标)`)
316
+ } else {
317
+ why.push(`原绑定 ${bound} 不在当前健康池`)
318
+ }
280
319
  }
320
+ // 无指纹:沿用块轮询语义 —— 块内粘住(blockKeeps)则留守当前 block。
281
321
  const block = this.blocks.get(this.blockKey(modelId))
282
322
  if (fp === '' && block !== undefined && pool.includes(block) && this.blockKeeps(modelId, block)) {
323
+ this.lastWhy = `块驻留 ${block}(无指纹路径)`
283
324
  return block
284
325
  }
285
- // 无亲和(或块满/块达标)→ 前进游标。从当前号之后开始找,保证轮转顺序稳定。
286
- const from = block === undefined ? this.rrCursor : Math.max(0, pool.indexOf(block) + 1)
287
- const uid = pool[from % pool.length]!
326
+ // 分配一个号并记住绑定。
327
+ //
328
+ // 有指纹(新会话):**优先铺开到未被其它会话占用的号**——同 model 下
329
+ // 每个 fresh 会话尽量独占一个号(缓存空间隔离、互不驱逐),号不够时
330
+ // 才回绕复用(共用号无害:样本按会话语义隔离,不会误降权)。
331
+ // 候选顺序:先「空闲号」,用完后才是「已被占用的号」。
332
+ // 无指纹:沿用块轮询(从 block 之后前进,原语义不变)。
333
+ const uid = fp !== ''
334
+ ? this.allocSession(pool, modelId, fp)
335
+ : pool[(block === undefined ? this.rrCursor : Math.max(0, pool.indexOf(block) + 1)) % pool.length]!
288
336
  this.rrCursor = (pool.indexOf(uid) + 1) % pool.length
289
337
  this.blocks.set(this.blockKey(modelId), uid)
290
338
  if (fp !== '') {
291
339
  this.bind(modelId, fp, uid)
340
+ const free = this.freeUids(pool, modelId, fp)
341
+ why.push(free.length === 0 ? `号已铺满,按游标复用 ${uid}` : `铺开到空闲号 ${uid}`)
292
342
  } else {
293
343
  // 新驻留 = 新观察窗口(旧行为:块计数从 0 开始)。样本是跨驻留累计的,
294
344
  // 不清的话上一个大样本会立刻把新驻留判走。
295
345
  this.samples.delete(this.key(modelId, uid))
346
+ why.push(`块推进到 ${uid}`)
296
347
  }
348
+ this.lastWhy = why.join(';')
349
+ return uid
350
+ }
351
+
352
+ /**
353
+ * 新会话分配:优先返回**未被其它会话占用**的号(按游标推进),
354
+ * 空闲号用尽才回绕到已占用的号(此时按游标复用,语义等同旧行为)。
355
+ *
356
+ * 「占用」= 该 model 的绑定表里,某个 **其它** 指纹当前指向该 uid。
357
+ * 同一会话自己的旧绑定不算占用(它已经在上面的亲和分支处理过,走到这里
358
+ * 说明原绑定不可用,允许它改绑到自己的其它号或空闲号)。
359
+ */
360
+ private allocSession(pool: string[], modelId: string, fp: string): string {
361
+ const free = this.freeUids(pool, modelId, fp)
362
+ const candidates = free.length > 0 ? free : pool
363
+ const uid = candidates[this.rrCursor % candidates.length]!
297
364
  return uid
298
365
  }
299
366
 
367
+ /** 当前 model 下、本指纹之外没有任何其它会话绑定的号(按 pool 原顺序)。 */
368
+ private freeUids(pool: string[], modelId: string, fp: string): string[] {
369
+ const taken = new Set<string>()
370
+ const m = this.affinity.get(this.blockKey(modelId))
371
+ if (m !== undefined) {
372
+ for (const [k, v] of m) {
373
+ if (k !== fp) taken.add(v)
374
+ }
375
+ }
376
+ return pool.filter((uid) => !taken.has(uid))
377
+ }
378
+
379
+ /** 上一次 pick 的选号理由(诊断用:解释"为什么没粘住原号")。 */
380
+ lastWhy = ''
381
+
300
382
  /** 记下「前缀指纹 → 号」的绑定(LRU:超上限丢最久未用的)。 */
301
383
  private bind(modelId: string, fp: string, uid: string): void {
302
384
  const k = this.blockKey(modelId)
@@ -314,9 +396,14 @@ export class AccountPool {
314
396
  }
315
397
  }
316
398
 
317
- /** 该号在本模型上是否被判定「缓存无效」(降权中)。 */
318
- private isDemoted(uid: string, modelId: string, now: number): boolean {
319
- const e = this.demoted.get(this.key(modelId, uid))
399
+ /**
400
+ * 该号在**本会话**(有身份时)本模型上是否被判定「缓存无效」(降权中)。
401
+ *
402
+ * 判据按会话隔离(见 sampleKey):降权只惩罚「这个会话在这个号上真的
403
+ * 反复不命中」,不会因别的会话共用同号互踩而被牵连。
404
+ */
405
+ private isDemoted(uid: string, modelId: string, now: number, session?: string): boolean {
406
+ const e = this.demoted.get(this.sampleKey(modelId, uid, session))
320
407
  return e !== undefined && e.until > now
321
408
  }
322
409
 
@@ -344,14 +431,16 @@ export class AccountPool {
344
431
  * 记录一次成功请求的缓存命中情况(降权判据的反馈信号)。
345
432
  *
346
433
  * 只有**成功**请求才计入:失败没产生缓存,计入会污染命中率并导致错降权。
347
- * 统计按 (model, uid) 记,**不管这个号是不是「当前驻留」**——亲和开启后
348
- * 多个会话各绑各的号交错服务,按驻留记会漏掉大部分真实反馈。
434
+ * 统计按 (model, uid[, session]) 记,**不管这个号是不是「当前驻留」**——
435
+ * 亲和开启后多个会话各绑各的号交错服务,按驻留记会漏掉大部分真实反馈。
436
+ * 按会话隔离(见 sampleKey)则保证「多会话共用同号互踩」不会误伤降权。
349
437
  * @param uid 实际服务的号
350
438
  * @param modelId 模型(块粒度)
351
439
  * @param cachedTokens 上游报的命中 token 数;0 = 全量重算
440
+ * @param session 宿主会话身份;有则按会话隔离统计
352
441
  */
353
- noteCache(uid: string, modelId: string, cachedTokens: number): void {
354
- const k = this.key(modelId, uid)
442
+ noteCache(uid: string, modelId: string, cachedTokens: number, session?: string): void {
443
+ const k = this.sampleKey(modelId, uid, session)
355
444
  const s = this.samples.get(k) ?? { served: 0, hits: 0 }
356
445
  s.served += 1
357
446
  if (cachedTokens > 0) s.hits += 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
  /**
@@ -527,9 +529,7 @@ export class Router {
527
529
  private suppliers: Supplier[] = []
528
530
  private combosFp = ''
529
531
  private customCombos: Combo[] = []
530
- /** round-robin 轮转游标(按组合 id 记忆)。 */
531
- private rrCursors = new Map<string, number>()
532
- /** 通用供应商配置(连接池顺序/策略、模型启用、别名)。 */
532
+ /** 通用供应商配置(连接池顺序、模型启用、别名)。 */
533
533
  private store: SupplierConfigStore
534
534
  /** 模型列表缓存(supplierId → 模型 + 拉取时间)。插件只管拉,不缓存。 */
535
535
  private modelsCache = new Map<string, { models: ModelWithEnabled[]; fetchedAt: number }>()
@@ -565,7 +565,7 @@ export class Router {
565
565
  try {
566
566
  const f = JSON.parse(readFileSync(this.combosFp, 'utf8')) as { combos?: Combo[] }
567
567
  if (Array.isArray(f.combos)) {
568
- // 兼容旧格式:steps(供应商) → models(模型);无 strategy 默认 fallback。
568
+ // 兼容旧格式:steps(供应商) → models(模型);旧 strategy 字段静默忽略。
569
569
  this.customCombos = f.combos
570
570
  .filter((c) => typeof c.id === 'string' && c.id !== '')
571
571
  .map((c) => {
@@ -595,7 +595,6 @@ export class Router {
595
595
  return {
596
596
  id: c.id,
597
597
  name: typeof c.name === 'string' ? c.name : c.id,
598
- strategy: c.strategy === 'round-robin' ? 'round-robin' as const : 'fallback' as const,
599
598
  models,
600
599
  }
601
600
  })
@@ -819,10 +818,6 @@ export class Router {
819
818
  typeof m === 'string' && m !== '')
820
819
  }
821
820
 
822
- private validStrategy(strategy: string | undefined): strategy is 'fallback' | 'round-robin' {
823
- return strategy === 'fallback' || strategy === 'round-robin'
824
- }
825
-
826
821
  /** 组合模型统一存裸 id:剥掉 alias/ 前缀(前缀随供应商动态变)。
827
822
  * 只剥「已知 alias + /」开头的前缀——模型 id 本身可以含斜杠
828
823
  * (如 nvidia 的 `deepseek-ai/deepseek-v4-flash-0731`),用 lastIndexOf 会把
@@ -849,22 +844,21 @@ export class Router {
849
844
  })
850
845
  }
851
846
 
852
- /** 创建组合(name 唯一,非 default)。 */
853
- createCombo(name: string, strategy: string, models: string[]): { ok: boolean; error?: string; combo?: Combo } {
847
+ /** 创建组合(name 唯一,非 default)。选模型是降级链(按序尝试),无策略。 */
848
+ createCombo(name: string, models: string[]): { ok: boolean; error?: string; combo?: Combo } {
854
849
  const clean = name.trim()
855
850
  if (clean === '' || clean === 'default') return { ok: false, error: '组合名无效' }
856
851
  if (!/^[A-Za-z0-9._-]+$/.test(clean)) return { ok: false, error: '组合名只能含字母、数字、-、_ 和 .' }
857
852
  if (this.customCombos.some((c) => c.name === clean)) return { ok: false, error: `组合 ${clean} 已存在` }
858
853
  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) }
854
+ const combo: Combo = { id: clean, name: clean, models: this.normalizeModelIds(models) }
861
855
  this.customCombos.push(combo)
862
856
  this.saveCombos()
863
857
  return { ok: true, combo }
864
858
  }
865
859
 
866
860
  /** 更新组合(按 id)。 */
867
- updateCombo(id: string, name: string, strategy: string, models: string[]): { ok: boolean; error?: string } {
861
+ updateCombo(id: string, name: string, models: string[]): { ok: boolean; error?: string } {
868
862
  const target = this.customCombos.find((c) => c.id === id)
869
863
  if (!target) return { ok: false, error: '组合不存在' }
870
864
  const clean = name.trim()
@@ -872,9 +866,7 @@ export class Router {
872
866
  if (!/^[A-Za-z0-9._-]+$/.test(clean)) return { ok: false, error: '组合名只能含字母、数字、-、_ 和 .' }
873
867
  if (this.customCombos.some((c) => c.id !== id && c.name === clean)) return { ok: false, error: `组合 ${clean} 已存在` }
874
868
  if (!this.validModels(models)) return { ok: false, error: '至少需要一个模型' }
875
- if (!this.validStrategy(strategy)) return { ok: false, error: '策略无效' }
876
869
  target.name = clean
877
- target.strategy = strategy
878
870
  target.models = this.normalizeModelIds(models)
879
871
  this.saveCombos()
880
872
  return { ok: true }
@@ -929,19 +921,16 @@ export class Router {
929
921
  ttfbMs: probe.ttfbMs,
930
922
  // 只有真被服务过才记 uid:失败请求的 uid 可能是空或上一次的残留
931
923
  ...(ok && probe.uid !== '' ? { uid: probe.uid } : {}),
924
+ ...(req.session === undefined ? {} : { session: req.session }),
932
925
  ...(error === undefined ? {} : { error }),
933
926
  }, usage)
934
927
  }
935
928
 
936
929
  const combo = this.comboByName(req.model)
937
930
  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)
931
+ // 组合 = 降级链:从第一个模型起顺序尝试,谁先服务成功就停。
943
932
  for (let i = 0; i < combo.models.length; i++) {
944
- const model = combo.models[(start + i) % combo.models.length]
933
+ const model = combo.models[i]
945
934
  if (model === undefined) continue
946
935
  const trace: ChatTrace = { attempts: 0 }
947
936
  const served = await this.chatWithModel(req, res, model, probe, trace)
@@ -1042,7 +1031,7 @@ export class Router {
1042
1031
  outTrace.lastState = trace.lastState
1043
1032
  }
1044
1033
  this.logChat(req.model, served
1045
- ? `${s.id}/${modelId} (${trace.uid ?? '?'}) ok${trace.attempts > 0 ? ` 重试${trace.attempts}次` : ''}`
1034
+ ? `${s.id}/${modelId} (${trace.uid ?? '?'}) ok${trace.attempts > 0 ? ` 重试${trace.attempts}次` : ''}${trace.pickWhy !== undefined ? ` [选号: ${trace.pickWhy}]` : ''}`
1046
1035
  : `${s.id}/${modelId} 失败 ${trace.lastError ?? 'no account'}`, Date.now() - t0)
1047
1036
  return served
1048
1037
  }
@@ -1093,9 +1082,11 @@ export class Router {
1093
1082
  // 否则同一会话的两次尝试会绑到不同的号上)。
1094
1083
  const messages = reqMessages(req.rawBody)
1095
1084
  const tried = new Set<string>()
1085
+ let pickWhy = ''
1096
1086
  for (;;) {
1097
- const uid = pool.pick(s.accounts().filter((a) => !tried.has(a.uid)), cfg.poolOrder, cfg.poolStrategy, req.model, messages)
1087
+ const uid = pool.pick(s.accounts().filter((a) => !tried.has(a.uid)), cfg.poolOrder, req.model, messages, req.session)
1098
1088
  if (uid === undefined) return false
1089
+ pickWhy = pool.lastWhy
1099
1090
  tried.add(uid)
1100
1091
  const r = await s.chatOnce(uid, req.lv ?? 'auto', req)
1101
1092
  if (!r.ok) {
@@ -1121,7 +1112,10 @@ export class Router {
1121
1112
  continue
1122
1113
  }
1123
1114
  pool.noteSuccess(uid, req.model)
1124
- if (trace !== undefined) trace.uid = uid
1115
+ if (trace !== undefined) {
1116
+ trace.uid = uid
1117
+ trace.pickWhy = pickWhy
1118
+ }
1125
1119
  if (probe !== undefined) {
1126
1120
  probe.supplier = s.id
1127
1121
  // 记对外全名 alias/model:与直接调用路径一致。组合里存的是裸
@@ -1133,7 +1127,7 @@ export class Router {
1133
1127
  const committed = await writeChatResult(res, r, req.stream, probe, probe?.startedAt ?? 0)
1134
1128
  if (committed) {
1135
1129
  // 块轮询反馈:只有成功请求才计入(失败没产生缓存,计入会污染命中率)
1136
- pool.noteCache(uid, req.model, probe?.tokens?.cachedTokens ?? 0)
1130
+ pool.noteCache(uid, req.model, probe?.tokens?.cachedTokens ?? 0, req.session)
1137
1131
  return true
1138
1132
  }
1139
1133
  // 一个字节都没写(上游刚连上就断)→ 这个号不算数,换下一个重试
@@ -357,7 +357,7 @@ test('每次 chat 记一行:组合名 → 供应商/模型 (账号) 结果', a
357
357
  const router = new Router('', undefined, (m) => lines.push(m))
358
358
  const ok = supplier('supA', [{ id: 'a-m' }])
359
359
  addSupplier(router, ok.s)
360
- assert.equal(router.createCombo('combo1', 'fallback', ['supA,a-m']).ok, true)
360
+ assert.equal(router.createCombo('combo1', ['supA,a-m']).ok, true)
361
361
 
362
362
  const res = {
363
363
  writeHead: (): void => {},
@@ -370,7 +370,7 @@ test('每次 chat 记一行:组合名 → 供应商/模型 (账号) 结果', a
370
370
  await router.chatCompletions({ model: 'combo1', stream: false, rawBody: JSON.stringify({ model: 'combo1', messages: [] }) }, res)
371
371
 
372
372
  assert.equal(lines.length, 1)
373
- assert.match(lines[0] ?? '', /^chat "combo1" → supA\/a-m \(u1\) ok \d+ms$/, `日志格式不对: ${lines[0]}`)
373
+ assert.match(lines[0] ?? '', /^chat "combo1" → supA\/a-m \(u1\) ok( \[选号: .+?\])? \d+ms$/, `日志格式不对: ${lines[0]}`)
374
374
  })
375
375
 
376
376
  test('失败的成员也要记,且带真实原因(不能笼统写 no account)', async () => {
@@ -384,7 +384,7 @@ test('失败的成员也要记,且带真实原因(不能笼统写 no account
384
384
  const live = supplier('live', [{ id: 'live-m' }])
385
385
  addSupplier(router, dead.s)
386
386
  addSupplier(router, live.s)
387
- assert.equal(router.createCombo('c1', 'fallback', ['dead,dead-m', 'live,live-m']).ok, true)
387
+ assert.equal(router.createCombo('c1', ['dead,dead-m', 'live,live-m']).ok, true)
388
388
 
389
389
  const res = {
390
390
  writeHead: (): void => {},
@@ -428,7 +428,7 @@ test('流在写出第一个字节前就断 → 组合回退到下一个模型',
428
428
  })
429
429
  addSupplier(router, dead.s)
430
430
  addSupplier(router, live.s)
431
- assert.equal(router.createCombo('c1', 'fallback', ['dead,dead-m', 'live,live-m']).ok, true)
431
+ assert.equal(router.createCombo('c1', ['dead,dead-m', 'live,live-m']).ok, true)
432
432
 
433
433
  let out = ''
434
434
  let heads = 0
@@ -479,7 +479,7 @@ test('已写出字节后断流 → 截断并补 [DONE],不回退也不二次
479
479
  })
480
480
  addSupplier(router, half.s)
481
481
  addSupplier(router, live.s)
482
- assert.equal(router.createCombo('c2', 'fallback', ['half,half-m', 'live,live-m']).ok, true)
482
+ assert.equal(router.createCombo('c2', ['half,half-m', 'live,live-m']).ok, true)
483
483
 
484
484
  let out = ''
485
485
  let heads = 0
@@ -552,7 +552,7 @@ test('首字节前就断 → 不补 [DONE],留给组合回退', async () => {
552
552
  })
553
553
  addSupplier(router, dead.s)
554
554
  addSupplier(router, live.s)
555
- assert.equal(router.createCombo('c3', 'fallback', ['dead,dead-m', 'live,live-m']).ok, true)
555
+ assert.equal(router.createCombo('c3', ['dead,dead-m', 'live,live-m']).ok, true)
556
556
 
557
557
  let out = ''
558
558
  const res = {
@@ -683,7 +683,7 @@ test('组合窗口:取组合内模型的最小值,单位 K → token', async
683
683
  { id: 'a-2', context_length: 1000 }, // 1M
684
684
  ]).s)
685
685
  await router.modelsOf('a') // 填缓存
686
- const w = router.comboContextWindow({ id: 'x', name: 'x', strategy: 'fallback', models: ['a,a-1', 'a,a-2'] })
686
+ const w = router.comboContextWindow({ id: 'x', name: 'x', models: ['a,a-1', 'a,a-2'] })
687
687
  assert.equal(w, 128_000, '取最小的那个(fallback 到任意一条分支都得成立)')
688
688
  })
689
689
 
@@ -692,7 +692,7 @@ test('组合窗口:供应商还没缓存过 → undefined(不猜,绝不打
692
692
  addSupplier(router, supplier('a', [{ id: 'a-1', context_length: 128 }]).s)
693
693
  // 故意不调 modelsOf:模拟冷启动
694
694
  assert.equal(
695
- router.comboContextWindow({ id: 'x', name: 'x', strategy: 'fallback', models: ['a,a-1'] }),
695
+ router.comboContextWindow({ id: 'x', name: 'x', models: ['a,a-1'] }),
696
696
  undefined,
697
697
  '没缓存就报 undefined —— 压缩这轮跳过,下次就有,比撒谎安全',
698
698
  )
@@ -703,7 +703,7 @@ test('组合窗口:模型都没报 context_length → undefined', async () =>
703
703
  addSupplier(router, supplier('a', [{ id: 'a-1' }]).s)
704
704
  await router.modelsOf('a')
705
705
  assert.equal(
706
- router.comboContextWindow({ id: 'x', name: 'x', strategy: 'fallback', models: ['a,a-1'] }),
706
+ router.comboContextWindow({ id: 'x', name: 'x', models: ['a,a-1'] }),
707
707
  undefined,
708
708
  '一家都没报就别编一个数(traework/nvidia 目前就是如此)',
709
709
  )
@@ -87,7 +87,7 @@ test('组合只调目标供应商,其它供应商一次都不会被问', async
87
87
  const b = spy('supB', 'b-model')
88
88
  const c = spy('supC', 'c-model')
89
89
  add(router, a.s); add(router, b.s); add(router, c.s)
90
- assert.equal(router.createCombo('my-combo', 'fallback', ['supB,b-model']).ok, true, '组合必须创建成功')
90
+ assert.equal(router.createCombo('my-combo', ['supB,b-model']).ok, true, '组合必须创建成功')
91
91
 
92
92
  await router.chatCompletions(reqWith('my-combo'), fakeRes())
93
93
 
@@ -140,8 +140,8 @@ test('组合存了缺供应商前缀的裸模型 id → 直接失败,不降级
140
140
  const b = spy('supB', 'b-model')
141
141
  add(router, a.s); add(router, b.s)
142
142
  // 模拟旧存储:裸 id,没有 supplierId 前缀 —— 图6 下这是配置错误,不遍历兜底
143
- ;(router as unknown as { customCombos: Array<{ id: string; name: string; strategy: 'fallback'; models: string[] }> })
144
- .customCombos.push({ id: 'legacy', name: 'legacy', strategy: 'fallback', models: ['b-model'] })
143
+ ;(router as unknown as { customCombos: Array<{ id: string; name: string; models: string[] }> })
144
+ .customCombos.push({ id: 'legacy', name: 'legacy', models: ['b-model'] })
145
145
 
146
146
  const errs: unknown[] = []
147
147
  const res = { ...fakeRes(), end: (x?: string): unknown => { if (x) errs.push(x); return undefined } } as unknown as ServerResponse
@@ -156,7 +156,7 @@ test('组合存了不存在的供应商 id → 调用失败,不静默落到别
156
156
  const router = new Router('')
157
157
  const a = spy('supA', 'a-model')
158
158
  add(router, a.s)
159
- assert.equal(router.createCombo('broken', 'fallback', ['ghost,a-model']).ok, true, '组合必须创建成功')
159
+ assert.equal(router.createCombo('broken', ['ghost,a-model']).ok, true, '组合必须创建成功')
160
160
 
161
161
  const errs: unknown[] = []
162
162
  const res = { ...fakeRes(), end: (b?: string): unknown => { if (b) errs.push(b); return undefined } } as unknown as ServerResponse
@@ -274,7 +274,7 @@ test('组合:瞬时故障降级前会等一等,不是毫秒级打穿整个
274
274
  const m1 = failingSpy('s1', 'm1', 'transport')
275
275
  const m2 = failingSpy('s2', 'm2', 'unavailable')
276
276
  add(router, m1.s); add(router, m2.s)
277
- router.createCombo('combo', 'fallback', ['s1,m1', 's2,m2'])
277
+ router.createCombo('combo', ['s1,m1', 's2,m2'])
278
278
 
279
279
  const t0 = Date.now()
280
280
  await router.chatCompletions(reqWith('combo'), fakeRes())
@@ -291,7 +291,7 @@ test('组合:no_such_model 不是故障,降级不该等', async () => {
291
291
  const m1 = failingSpy('s1', 'm1', 'no_such_model')
292
292
  const m2 = failingSpy('s2', 'm2', 'no_such_model')
293
293
  add(router, m1.s); add(router, m2.s)
294
- router.createCombo('combo', 'fallback', ['s1,m1', 's2,m2'])
294
+ router.createCombo('combo', ['s1,m1', 's2,m2'])
295
295
 
296
296
  const t0 = Date.now()
297
297
  await router.chatCompletions(reqWith('combo'), fakeRes())
@@ -59,7 +59,28 @@ function textOf(content: unknown): string {
59
59
  }
60
60
 
61
61
  /**
62
- * 算请求的前缀指纹。
62
+ * 算请求的亲和指纹(选号主键)。
63
+ *
64
+ * **身份优先,内容兜底**:
65
+ * 1. 宿主给了会话身份(`session`,来自 dsh-llm 的 `sessionId`)→ 直接用它。
66
+ * 精确、零撞车 —— 同一会话永不漂移,不同会话(含 team 各成员)天然隔离。
67
+ * 2. 没有身份(外部 OpenAI 客户端)→ 回落 {@link prefixFingerprint}。
68
+ *
69
+ * 为什么优先身份而不是内容指纹:DSH 的 system prompt 是同一个 agent preset
70
+ * 生成的几 KB 文本,内容指纹只取前 512 字符 → 各会话高度雷同 → 撞车导致
71
+ * 绑定互相覆盖、同会话漂移(team 多成员时实测过)。
72
+ *
73
+ * @param session 宿主会话身份;无则 undefined
74
+ * @param messages OpenAI 请求体的 `messages`(无身份时才用)
75
+ * @returns 指纹字符串;**空串 = 算不出来(无亲和,回退到块轮询)**
76
+ */
77
+ export function sessionFingerprint(session: string | undefined, messages: unknown): string {
78
+ if (session !== undefined && session !== '') return `sid:${session}`
79
+ return prefixFingerprint(messages)
80
+ }
81
+
82
+ /**
83
+ * 算请求的前缀指纹(内容兜底)。
63
84
  * @param messages OpenAI 请求体的 `messages` 字段(未校验,来自外部输入)
64
85
  * @returns 指纹字符串;**空串 = 算不出来(无亲和,回退到块轮询)**
65
86
  */
@@ -37,14 +37,10 @@ export interface ModelWithEnabled extends ModelInfo {
37
37
  custom?: boolean
38
38
  }
39
39
 
40
- /** 组合策略:fallback = 按顺序尝试;round-robin = 轮转。 */
41
- export type ComboStrategy = 'fallback' | 'round-robin'
42
-
43
- /** 组合:命名的一组模型,路由时按策略(回退/轮询)命中其中一个。 */
40
+ /** 组合:命名的一组模型,路由时作为**降级链**按序尝试(无策略旋钮)。 */
44
41
  export interface Combo {
45
42
  id: string
46
43
  name: string
47
- strategy: ComboStrategy
48
44
  models: string[]
49
45
  }
50
46
 
@@ -56,6 +52,12 @@ export interface ChatRequest {
56
52
  model: string
57
53
  /** 推理等级(reasoning effort),供应商各自决定怎么映射到上游。缺省 = 'auto'。 */
58
54
  lv?: string
55
+ /**
56
+ * 宿主盖的会话身份(dsh-llm `GenerateOptions.sessionId`),由 adapter 转成
57
+ * 内部头带进来。用于把用量记录**按会话归因**(「最近命中」徽章要按当前会话
58
+ * 显示,而不是显示别的会话的命中)。缺省 = 非宿主调用(外部 OpenAI 客户端)。
59
+ */
60
+ session?: string
59
61
  }
60
62
 
61
63
  /**
@@ -54,6 +54,11 @@ export interface UsageRecord {
54
54
  uid?: string
55
55
  /** 失败原因(ok=false 时)。 */
56
56
  error?: string
57
+ /**
58
+ * 宿主会话身份(有则记)。用于「最近命中」按当前会话归因 —— 徽章要显示
59
+ * 当前会话的命中,而不是全局最近(team 多会话下后者会张冠李戴)。
60
+ */
61
+ session?: string
57
62
  }
58
63
 
59
64
  /** 按某个维度(供应商/模型/请求名)聚合的小计。 */
@@ -389,6 +394,17 @@ export class UsageStore {
389
394
  return this.recent.slice(0, limit)
390
395
  }
391
396
 
397
+ /**
398
+ * 最近一次命中。给了 `session` 就只在该会话的记录里找(「最近命中」徽章
399
+ * 要显示**当前会话**的命中,team 多会话下全局最近会张冠李戴);
400
+ * 不给则退回全局最近一条。
401
+ * @param session 宿主会话身份;undefined = 全局
402
+ */
403
+ lastHit(session?: string): UsageRecord | undefined {
404
+ if (session === undefined) return this.recent[0]
405
+ return this.recent.find((r) => r.session === session)
406
+ }
407
+
392
408
  clear(): void {
393
409
  this.days.clear()
394
410
  this.recent = []
@@ -674,7 +674,7 @@ test('请求路径:组合回退试了 3 个模型,只记 1 条(不是 3
674
674
  add(router, spy('s1', 'm1', { ok: false, state: 'quota', message: 'no quota' }).s)
675
675
  add(router, spy('s2', 'm2', { ok: false, state: 'quota', message: 'no quota' }).s)
676
676
  add(router, spy('s3', 'm3', { ok: true, status: 200, body: '{"usage":{"prompt_tokens":1,"completion_tokens":1}}' }).s)
677
- assert.equal(router.createCombo('c1', 'fallback', ['s1,m1', 's2,m2', 's3,m3']).ok, true)
677
+ assert.equal(router.createCombo('c1', ['s1,m1', 's2,m2', 's3,m3']).ok, true)
678
678
  await router.chatCompletions(
679
679
  { model: 'c1', stream: false, rawBody: JSON.stringify({ model: 'c1', messages: [] }) },
680
680
  sinkRes(),