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
@@ -1,7 +1,10 @@
1
1
  /**
2
- * 账号池(核心策略)测试 —— 选号/冷却/遍历回退。
2
+ * 账号池(核心)测试 —— 会话亲和选号/冷却/降级。
3
3
  *
4
- * 关键语义(v2):
4
+ * 选号统一为**会话亲和**(无策略旋钮):有指纹按会话粘号,无指纹走块轮询。
5
+ * 见 docs/pool-sticky-block.md §10。
6
+ *
7
+ * 关键语义:
5
8
  * - 冷却单元 = (supplierId, modelId, uid)。某连接在模型 A 上失败,只冷
6
9
  * (A, 该连接),该连接调模型 B 不受影响。
7
10
  * - session_dead = 连接级(uid)可恢复冷却:该号所有模型都不可用,到期自愈。
@@ -19,31 +22,33 @@ const accs = (uids: string[]): SupplierAccountNow[] => uids.map((uid) => ({ uid,
19
22
  /** 构造带供应商的池,与 loader 一致(真实使用会传 supplierId)。 */
20
23
  const pool = (sid = 'supA'): AccountPool => new AccountPool(sid)
21
24
 
22
- test('选号:无冷却时按 poolOrder 优先,未配置的追加在后(带 model)', () => {
25
+ test('选号:无指纹时按 poolOrder 优先,未配置的追加在后(带 model)', () => {
23
26
  const p = pool()
24
27
  const list = accs(['a', 'b', 'c'])
25
- assert.equal(p.pick(list, ['c', 'a'], 'fallback', 'm1'), 'c')
26
- assert.equal(p.pick(list, ['b'], 'fallback', 'm1'), 'b')
27
- // 未配置顺序的按自然顺序补上
28
- assert.equal(p.pick(list, [], 'fallback', 'm1'), 'a')
28
+ assert.equal(p.pick(list, ['c', 'a'], 'm1'), 'c')
29
+ // 无指纹 → 块粘住:poolOrder 里没有 c,但 c 仍是当前块且健康 → 留守
30
+ assert.equal(p.pick(list, ['b'], 'm1'), 'c')
31
+ // 换模型 → 该模型尚无块,按游标从 poolOrder 顺序里分一个健康号
32
+ const m2 = p.pick(list, ['b'], 'm2')
33
+ assert.ok(m2 !== undefined && ['a', 'b', 'c'].includes(m2))
29
34
  })
30
35
 
31
36
  test('选号:某模型冷却中的号对该模型跳过,全冷却返回 undefined', () => {
32
37
  const p = pool()
33
38
  const list = accs(['a', 'b'])
34
39
  p.noteFailure('a', 'm1', 'rate_limit', '429')
35
- assert.equal(p.pick(list, [], 'fallback', 'm1'), 'b')
40
+ assert.equal(p.pick(list, [], 'm1'), 'b')
36
41
  p.noteFailure('b', 'm1', 'rate_limit', '429')
37
- assert.equal(p.pick(list, [], 'fallback', 'm1'), undefined)
42
+ assert.equal(p.pick(list, [], 'm1'), undefined)
38
43
  })
39
44
 
40
- test('round-robin 是块轮询:块内粘住不换号(带 model)', () => {
45
+ test('选号:无指纹时块内粘住不换号(带 model)', () => {
41
46
  const p = pool()
42
47
  const list = accs(['a', 'b'])
43
48
  // 块没满 → 一直粘在 a(这就是缓存命中的来源)
44
- assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'a')
45
- assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'a')
46
- assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'a')
49
+ assert.equal(p.pick(list, [], 'm1'), 'a')
50
+ assert.equal(p.pick(list, [], 'm1'), 'a')
51
+ assert.equal(p.pick(list, [], 'm1'), 'a')
47
52
  })
48
53
 
49
54
  // ============ 块轮询(docs/pool-sticky-block.md) ============
@@ -56,94 +61,98 @@ const serveHits = (p: AccountPool, uid: string, n: number, model = 'm1'): void =
56
61
  const serveMiss = (p: AccountPool, uid: string, n: number, model = 'm1'): void => {
57
62
  for (let i = 0; i < n; i++) p.noteCache(uid, model, 0)
58
63
  }
64
+ /** 同上,但指定会话(按会话隔离的样本)。 */
65
+ const serveMissSession = (p: AccountPool, session: string, uid: string, n: number, model = 'm1'): void => {
66
+ for (let i = 0; i < n; i++) p.noteCache(uid, model, 0, session)
67
+ }
59
68
 
60
69
  test('【块】OR:计数<N(最短驻留)且没达标 → 留守,哪怕缓存已热', () => {
61
70
  const p = pool()
62
71
  const list = accs(['a', 'b'])
63
- assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'a')
72
+ assert.equal(p.pick(list, [], 'm1'), 'a')
64
73
  // 计数 15 < N(16),命中率 100% >= M → 留守(最短驻留没走完,不因热度提前跳)
65
74
  serveHits(p, 'a', 15)
66
- assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'a')
75
+ assert.equal(p.pick(list, [], 'm1'), 'a')
67
76
  })
68
77
 
69
78
  test('【块】OR:计数到 N 且命中率达标 → 换号(下一号也冷启动,让位均衡)', () => {
70
79
  const p = pool()
71
80
  const list = accs(['a', 'b'])
72
- assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'a')
81
+ assert.equal(p.pick(list, [], 'm1'), 'a')
73
82
  // 计数 16 >= N 且命中率 100% >= 85% → 换号
74
83
  serveHits(p, 'a', 16)
75
- assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'b')
84
+ assert.equal(p.pick(list, [], 'm1'), 'b')
76
85
  })
77
86
 
78
87
  test('【块】OR:命中率没达标 → 计数到 N 仍留守(继续热,不急换)', () => {
79
88
  const p = pool()
80
89
  const list = accs(['a', 'b'])
81
- assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'a')
90
+ assert.equal(p.pick(list, [], 'm1'), 'a')
82
91
  // 计数 20 全 miss → 命中率 0% < 85% → 留守(OR 语义:ratio<M 时留守)
83
92
  serveMiss(p, 'a', 20)
84
- assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'a')
93
+ assert.equal(p.pick(list, [], 'm1'), 'a')
85
94
  })
86
95
 
87
96
  test('【块】N_max 兜底:命中率一直不达标 → 到 N_max(48) 必换', () => {
88
97
  const p = pool()
89
98
  const list = accs(['a', 'b'])
90
- assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'a')
99
+ assert.equal(p.pick(list, [], 'm1'), 'a')
91
100
  // 一直 miss,命中率 0% 永不达标 → 不靠 N 换,靠 N_max 兜底
92
101
  serveMiss(p, 'a', 48)
93
102
  // 47 次后仍在 a;第 48 次触发 noteCache 的降权,随后 pick 跳过 a
94
- assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'b')
103
+ assert.equal(p.pick(list, [], 'm1'), 'b')
95
104
  })
96
105
 
97
106
  test('【块】驻留满 N_max 仍不达标 → 该号降权,后续轮转跳过', () => {
98
107
  const p = pool()
99
108
  const list = accs(['a', 'b', 'c'])
100
- assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'a')
109
+ assert.equal(p.pick(list, [], 'm1'), 'a')
101
110
  serveMiss(p, 'a', 48) // a 判定缓存无效(降权)
102
- assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'b') // 跳过 a
111
+ assert.equal(p.pick(list, [], 'm1'), 'b') // 跳过 a
103
112
  // b 正常服务并达标 → 切走后轮转也应跳过 a
104
113
  serveHits(p, 'b', 16)
105
- assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'c')
114
+ assert.equal(p.pick(list, [], 'm1'), 'c')
106
115
  })
107
116
 
108
117
  test('【块】降权不是永久判决:全池都降权时退回健康池兜底', () => {
109
118
  const p = pool()
110
119
  const list = accs(['a', 'b'])
111
- assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'a')
120
+ assert.equal(p.pick(list, [], 'm1'), 'a')
112
121
  serveMiss(p, 'a', 48)
113
- assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'b')
122
+ assert.equal(p.pick(list, [], 'm1'), 'b')
114
123
  serveMiss(p, 'b', 48)
115
124
  // 两个都降权 → 不能返回 undefined(那等于服务挂了),退回健康池
116
- const uid = p.pick(list, [], 'round-robin', 'm1')
125
+ const uid = p.pick(list, [], 'm1')
117
126
  assert.equal(uid !== undefined, true)
118
127
  })
119
128
 
120
129
  test('【块】故障切走作废块:dropBlock 后重新选号', () => {
121
130
  const p = pool()
122
131
  const list = accs(['a', 'b'])
123
- assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'a')
132
+ assert.equal(p.pick(list, [], 'm1'), 'a')
124
133
  serveHits(p, 'a', 3) // 块才 3 次,本来会留守
125
134
  p.dropBlock('m1')
126
135
  // 块作废 → 从 a 之后前进,选到 b(而不是带着旧计数继续粘 a)
127
- assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'b')
136
+ assert.equal(p.pick(list, [], 'm1'), 'b')
128
137
  })
129
138
 
130
139
  test('【块】失败请求不入块统计(失败没产生缓存,计入会污染命中率)', () => {
131
140
  const p = pool()
132
141
  const list = accs(['a', 'b'])
133
- assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'a')
142
+ assert.equal(p.pick(list, [], 'm1'), 'a')
134
143
  // 16 次全命中 → 达标切走(失败不算,否则比例被稀释永不达标)
135
144
  serveHits(p, 'a', 16)
136
- assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'b')
145
+ assert.equal(p.pick(list, [], 'm1'), 'b')
137
146
  })
138
147
 
139
148
  test('【块】块状态按模型隔离:m1 的驻留不影响 m2', () => {
140
149
  const p = pool()
141
150
  const list = accs(['a', 'b'])
142
- assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'a')
151
+ assert.equal(p.pick(list, [], 'm1'), 'a')
143
152
  serveHits(p, 'a', 16) // m1 上 a 已达标
144
- assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'b')
153
+ assert.equal(p.pick(list, [], 'm1'), 'b')
145
154
  // m2 是独立的块 → 从头开始,选 a
146
- assert.equal(p.pick(list, [], 'round-robin', 'm2'), 'a')
155
+ assert.equal(p.pick(list, [], 'm2'), 'a')
147
156
  })
148
157
 
149
158
  test('【块】多号间长期均衡:块大小有界 → 请求数均分', () => {
@@ -152,7 +161,7 @@ test('【块】多号间长期均衡:块大小有界 → 请求数均分', ()
152
161
  const count: Record<string, number> = { a: 0, b: 0, c: 0 }
153
162
  // 每个块 16 次(全命中达标),跑 12 个块
154
163
  for (let block = 0; block < 12; block++) {
155
- const uid = p.pick(list, [], 'round-robin', 'm1')!
164
+ const uid = p.pick(list, [], 'm1')!
156
165
  count[uid]! += 1
157
166
  serveHits(p, uid, 16)
158
167
  }
@@ -175,100 +184,197 @@ test('【亲和】同会话粘同一号:指纹相同 → 每次都返回同一
175
184
  const p = pool()
176
185
  const list = accs(['a', 'b', 'c'])
177
186
  const m = msgs('session-1')
178
- const first = p.pick(list, [], 'round-robin', 'm1', m)
187
+ const first = p.pick(list, [], 'm1', m)
179
188
  // 连续多轮(会话增长,但前缀不变)→ 永远是第一个号
180
189
  for (let i = 0; i < 10; i++) {
181
- assert.equal(p.pick(list, [], 'round-robin', 'm1', msgs('session-1', i + 2)), first)
190
+ assert.equal(p.pick(list, [], 'm1', msgs('session-1', i + 2)), first)
182
191
  }
183
192
  })
184
193
 
185
194
  test('【亲和】不同会话散到不同号:新指纹按游标分发', () => {
186
195
  const p = pool()
187
196
  const list = accs(['a', 'b'])
188
- const s1 = p.pick(list, [], 'round-robin', 'm1', msgs('s1'))
189
- const s2 = p.pick(list, [], 'round-robin', 'm1', msgs('s2'))
197
+ const s1 = p.pick(list, [], 'm1', msgs('s1'))
198
+ const s2 = p.pick(list, [], 'm1', msgs('s2'))
190
199
  assert.notEqual(s1, s2, '两个会话应落到不同的号(缓存空间隔离)')
191
200
  // 各自回访仍粘各自的号
192
- assert.equal(p.pick(list, [], 'round-robin', 'm1', msgs('s1', 5)), s1)
193
- assert.equal(p.pick(list, [], 'round-robin', 'm1', msgs('s2', 5)), s2)
201
+ assert.equal(p.pick(list, [], 'm1', msgs('s1', 5)), s1)
202
+ assert.equal(p.pick(list, [], 'm1', msgs('s2', 5)), s2)
203
+ })
204
+
205
+ test('【亲和】给了 session 就以它为主键:即使内容指纹会漂移也粘住同一号', () => {
206
+ const p = pool()
207
+ const list = accs(['a', 'b'])
208
+ // 同一 sessionId,但 messages 每轮都不同(模拟 system 前缀变化)
209
+ const first = p.pick(list, [], 'm1', msgs('round-1'), 'sid-1')
210
+ for (let i = 2; i <= 8; i++) {
211
+ assert.equal(p.pick(list, [], 'm1', msgs(`round-${i}`), 'sid-1'), first, '同会话不得漂移')
212
+ }
213
+ })
214
+
215
+ test('【亲和】不同 session 天然隔离:内容雷同也分号(team 成员场景)', () => {
216
+ const p = pool()
217
+ const list = accs(['a', 'b', 'c'])
218
+ // 三个会话的首条 user 前缀几乎逐字相同(模拟 team 各成员的身份前缀)
219
+ const same = msgs('you are teammate "x"')
220
+ const a = p.pick(list, [], 'm1', same, 'sid-lead')
221
+ const b = p.pick(list, [], 'm1', same, 'sid-surveyor')
222
+ const c = p.pick(list, [], 'm1', same, 'sid-gitwatch')
223
+ assert.equal(new Set([a, b, c]).size, 3, '三个会话应散到三个号,内容雷同也不撞车')
224
+ })
225
+
226
+ test('【铺开】号充足时 fresh 会话各占一个号,不撞车', () => {
227
+ const p = pool()
228
+ const list = accs(['a', 'b', 'c', 'd'])
229
+ // 交替、乱序到来(team 成员先后不定)也必须各占一个
230
+ const s1 = p.pick(list, [], 'm1', msgs('x'), 'sid-1')!
231
+ const s2 = p.pick(list, [], 'm1', msgs('x'), 'sid-2')!
232
+ const s3 = p.pick(list, [], 'm1', msgs('x'), 'sid-3')!
233
+ assert.equal(new Set([s1, s2, s3]).size, 3, '三个会话应各占一个空闲号')
234
+ })
235
+
236
+ test('【铺开】号不够时才复用:成员数 > 号数,复用也不互踩(回归现状)', () => {
237
+ const p = pool()
238
+ const list = accs(['a', 'b']) // 只有 2 个号
239
+ const lead = p.pick(list, [], 'm1', msgs('x'), 'sid-lead')!
240
+ const t1 = p.pick(list, [], 'm1', msgs('x'), 'sid-t1')!
241
+ const t2 = p.pick(list, [], 'm1', msgs('x'), 'sid-t2')!
242
+ // 前两个独占 a/b;第三个号不够 → 只能复用
243
+ assert.equal(new Set([lead, t1]).size, 2, '号够时 lead/t1 应独占')
244
+ assert.ok([lead, t1].includes(t2), '第三个会话号不够时应复用已有号,而不是"消失"')
245
+ // 关键:复用后各方仍粘自己的号,不被对方挤走
246
+ assert.equal(p.pick(list, [], 'm1', msgs('x', 2), 'sid-lead'), lead)
247
+ assert.equal(p.pick(list, [], 'm1', msgs('x', 2), 'sid-t2'), t2)
248
+ })
249
+
250
+ test('【亲和】新会话交错到来也要散开(回归:block 干扰曾让它们全挤一个号)', () => {
251
+ const p = pool()
252
+ const list = accs(['a', 'b'])
253
+ // 模拟 team:Lead 先起,成员紧随其后,然后双方交错发请求(各用同一 model)
254
+ const l1 = p.pick(list, [], 'm1', msgs('lead-1'), 'sid-lead')
255
+ const b1 = p.pick(list, [], 'm1', msgs('beta-1'), 'sid-beta')
256
+ assert.notEqual(l1, b1, '先后两个新会话必须落到不同号')
257
+ // 交错回访仍各自粘住
258
+ assert.equal(p.pick(list, [], 'm1', msgs('lead-2'), 'sid-lead'), l1)
259
+ assert.equal(p.pick(list, [], 'm1', msgs('beta-2'), 'sid-beta'), b1)
194
260
  })
195
261
 
196
262
  test('【亲和】会话增长指纹稳定:前缀相同、轮数不同 → 同一号', () => {
197
263
  const p = pool()
198
264
  const list = accs(['a', 'b'])
199
- const s1 = p.pick(list, [], 'round-robin', 'm1', msgs('s1'))
265
+ const s1 = p.pick(list, [], 'm1', msgs('s1'))
200
266
  // 真实会话每轮 messages 变长,但前 3 条不变 → 指纹不变
201
- assert.equal(p.pick(list, [], 'round-robin', 'm1', msgs('s1', 30)), s1)
267
+ assert.equal(p.pick(list, [], 'm1', msgs('s1', 30)), s1)
202
268
  })
203
269
 
204
270
  test('【亲和】绑定的号冷却 → 会话让位,换健康号', () => {
205
271
  const p = pool()
206
272
  const list = accs(['a', 'b'])
207
273
  const m = msgs('s1')
208
- const first = p.pick(list, [], 'round-robin', 'm1', m)!
274
+ const first = p.pick(list, [], 'm1', m)!
209
275
  p.noteFailure(first, 'm1', 'rate_limit', '429')
210
276
  // 绑定的号不可用 → 不能返回它(挂了),应落到别的号
211
- const second = p.pick(list, [], 'round-robin', 'm1', m)
277
+ const second = p.pick(list, [], 'm1', m)
212
278
  assert.notEqual(second, first)
213
279
  // 且之后粘在新号上(绑定已更新)
214
- assert.equal(p.pick(list, [], 'round-robin', 'm1', msgs('s1', 9)), second)
280
+ assert.equal(p.pick(list, [], 'm1', msgs('s1', 9)), second)
215
281
  })
216
282
 
217
283
  test('【亲和】绑定的号降权(缓存无效)→ 跳过它', () => {
218
284
  const p = pool()
219
285
  const list = accs(['a', 'b'])
220
286
  const m = msgs('s1')
221
- const first = p.pick(list, [], 'round-robin', 'm1', m)!
287
+ const first = p.pick(list, [], 'm1', m)!
222
288
  // 该号累计 48 次全 miss → 判定缓存无效
223
289
  serveMiss(p, first, 48)
224
- const second = p.pick(list, [], 'round-robin', 'm1', m)
290
+ const second = p.pick(list, [], 'm1', m)
225
291
  assert.notEqual(second, first, '降权中的号不应被亲和继续选中')
226
292
  })
227
293
 
294
+ test('【亲和/会话隔离】多会话共用同号互踩 → 不误伤降权(team 降权串回归)', () => {
295
+ const p = pool()
296
+ const list = accs(['a', 'b'])
297
+ // 两个会话被分到同一个号 a 上(号数 < 会话数,必然共用)。
298
+ // 各自只服务了 24 次、命中率 0.5 >= 0.85? 否 —— 但关键在于:
299
+ // 按会话隔离后,每个会话的样本只有 24 次 < BLOCK_MAX(48),**不会**判降权;
300
+ // 若把两会话混在一起(旧逻辑),48 次 / 命中 24 → 50% < 85% → 立刻误降 a,
301
+ // 逼着两个会话都落到 b(降权串)。
302
+ const s1 = 'sid-team-1'
303
+ const s2 = 'sid-team-2'
304
+ for (let i = 0; i < 24; i++) {
305
+ p.noteCache('a', 'm1', i % 2 === 0 ? 1000 : 0, s1)
306
+ p.noteCache('a', 'm1', i % 2 === 0 ? 1000 : 0, s2)
307
+ }
308
+ // a 未被降权:两个会话都还能被分/粘在 a 上
309
+ const first = p.pick(list, [], 'm1', msgs('s1'), s1)!
310
+ assert.equal(first, 'a', 'a 不该因多会话混算而被误降权')
311
+ const second = p.pick(list, [], 'm1', msgs('s2'), s2)!
312
+ assert.equal(second, 'b', '新会话仍按游标独立分发(不因误降权被挤到 a)')
313
+ // 单会话真·反复不命中(自己攒满 48 次)→ 仍然会降权它,隔离不等于免疫
314
+ serveMissSession(p, s1, 'a', 48)
315
+ assert.notEqual(p.pick(list, [], 'm1', msgs('s1'), s1), 'a', '该会话自己攒满 miss 仍应降权 a')
316
+ })
317
+
318
+ test('【诊断】lastWhy 说清「为什么没粘住原号」(冷却/降权/首见)', () => {
319
+ const p = pool()
320
+ const list = accs(['a', 'b'])
321
+ const sid = 'sid-why'
322
+ // 首见:无绑定
323
+ const first = p.pick(list, [], 'm1', msgs('s1'), sid)!
324
+ assert.match(p.lastWhy, /无绑定|首次出现/, `首见应说明无绑定,实际: ${p.lastWhy}`)
325
+ // 回访:亲和命中
326
+ assert.equal(p.pick(list, [], 'm1', msgs('s1', 3), sid), first)
327
+ assert.match(p.lastWhy, /亲和命中/, `回访应报告亲和命中,实际: ${p.lastWhy}`)
328
+ // 让原绑定号冷却 → 换号,理由必须点名「冷却」
329
+ p.noteFailure(first, 'm1', 'rate_limit', '429')
330
+ assert.notEqual(p.pick(list, [], 'm1', msgs('s1', 5), sid), first)
331
+ assert.match(p.lastWhy, /冷却/, `绑定号冷却应点名,实际: ${p.lastWhy}`)
332
+ })
333
+
228
334
  test('【亲和】messages 拿不到 → 回退块轮询(旧行为不破坏)', () => {
229
335
  const p = pool()
230
336
  const list = accs(['a', 'b'])
231
337
  // 不传 messages → 走块驻留
232
- assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'a')
233
- assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'a')
338
+ assert.equal(p.pick(list, [], 'm1'), 'a')
339
+ assert.equal(p.pick(list, [], 'm1'), 'a')
234
340
  serveHits(p, 'a', 16)
235
- assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'b')
341
+ assert.equal(p.pick(list, [], 'm1'), 'b')
236
342
  // 传了但算不出指纹(空/坏结构)→ 同样回退
237
- assert.equal(p.pick(list, [], 'round-robin', 'm2', []), 'a')
238
- assert.equal(p.pick(list, [], 'round-robin', 'm2', [{ nope: 1 }]), 'a')
343
+ assert.equal(p.pick(list, [], 'm2', []), 'a')
344
+ assert.equal(p.pick(list, [], 'm2', [{ nope: 1 }]), 'a')
239
345
  })
240
346
 
241
347
  test('【亲和】绑定表按模型隔离:冷却是 (model, uid) 粒度,不串模型', () => {
242
348
  const p = pool()
243
349
  const list = accs(['a', 'b'])
244
350
  const m = msgs('s1')
245
- const m1 = p.pick(list, [], 'round-robin', 'm1', m)
351
+ const m1 = p.pick(list, [], 'm1', m)
246
352
  // rrCursor 跨模型共享(与旧块轮询一致):m1 的新会话消耗 a 后,m2 的新会话分到 b
247
- const m2 = p.pick(list, [], 'round-robin', 'm2', m)
353
+ const m2 = p.pick(list, [], 'm2', m)
248
354
  assert.equal(m1, 'a')
249
355
  assert.equal(m2, 'b')
250
356
  // 各自回访粘各自的号(绑定表按模型分开,m1 的 a 不会盖掉 m2 的 b)
251
- assert.equal(p.pick(list, [], 'round-robin', 'm1', msgs('s1', 9)), 'a')
252
- assert.equal(p.pick(list, [], 'round-robin', 'm2', msgs('s1', 9)), 'b')
357
+ assert.equal(p.pick(list, [], 'm1', msgs('s1', 9)), 'a')
358
+ assert.equal(p.pick(list, [], 'm2', msgs('s1', 9)), 'b')
253
359
  p.noteFailure('a', 'm1', 'rate_limit', '429') // 只冷 (m1, a)
254
- assert.equal(p.pick(list, [], 'round-robin', 'm1', m), 'b', 'm1 上让位')
255
- assert.equal(p.pick(list, [], 'round-robin', 'm2', m), 'b', 'm2 不受影响,仍粘 b')
360
+ assert.equal(p.pick(list, [], 'm1', m), 'b', 'm1 上让位')
361
+ assert.equal(p.pick(list, [], 'm2', m), 'b', 'm2 不受影响,仍粘 b')
256
362
  })
257
363
 
258
364
  test('【亲和】绑定表 LRU 封顶:超上限不泄漏,旧指纹被挤掉', () => {
259
365
  const p = pool()
260
366
  const list = accs(['a', 'b', 'c'])
261
- const first = p.pick(list, [], 'round-robin', 'm1', msgs('s0'))!
367
+ const first = p.pick(list, [], 'm1', msgs('s0'))!
262
368
  // 灌 600 个新会话(> AFFINITY_MAX=512)→ 最老的 s0 被挤出
263
- for (let i = 0; i < 600; i++) p.pick(list, [], 'round-robin', 'm1', msgs(`flood-${i}`))
264
- const again = p.pick(list, [], 'round-robin', 'm1', msgs('s0'))
369
+ for (let i = 0; i < 600; i++) p.pick(list, [], 'm1', msgs(`flood-${i}`))
370
+ const again = p.pick(list, [], 'm1', msgs('s0'))
265
371
  // 被挤掉后重新分发——只要不是内存无限增长即可(uid 合法性由 pool 保证)
266
372
  assert.equal(list.some((a) => a.uid === again), true)
267
373
  // 活跃会话(每次都访问)不被挤掉
268
- const hot = p.pick(list, [], 'round-robin', 'm1', msgs('hot'))!
374
+ const hot = p.pick(list, [], 'm1', msgs('hot'))!
269
375
  for (let i = 0; i < 600; i++) {
270
- p.pick(list, [], 'round-robin', 'm1', msgs(`flood2-${i}`))
271
- assert.equal(p.pick(list, [], 'round-robin', 'm1', msgs('hot')), hot, '活跃会话的绑定应被 LRU 刷新保留')
376
+ p.pick(list, [], 'm1', msgs(`flood2-${i}`))
377
+ assert.equal(p.pick(list, [], 'm1', msgs('hot')), hot, '活跃会话的绑定应被 LRU 刷新保留')
272
378
  }
273
379
  })
274
380
 
@@ -278,13 +384,13 @@ test('【亲和】多模态分片 content 也能算指纹(文本片段参与
278
384
  const multi = [
279
385
  { role: 'user', content: [{ type: 'text', text: 'look at this' }, { type: 'image_url', image_url: 'data:image/png;base64,AAAA' }] },
280
386
  ]
281
- const first = p.pick(list, [], 'round-robin', 'm1', multi)
282
- assert.equal(p.pick(list, [], 'round-robin', 'm1', multi), first)
387
+ const first = p.pick(list, [], 'm1', multi)
388
+ assert.equal(p.pick(list, [], 'm1', multi), first)
283
389
  // 文本不同的另一个会话 → 不同指纹
284
390
  const other = [
285
391
  { role: 'user', content: [{ type: 'text', text: 'different' }] },
286
392
  ]
287
- assert.notEqual(p.pick(list, [], 'round-robin', 'm1', other), first, '文本不同应散开')
393
+ assert.notEqual(p.pick(list, [], 'm1', other), first, '文本不同应散开')
288
394
  })
289
395
 
290
396
 
@@ -296,11 +402,11 @@ test('【颗粒度】连接在模型 A 失败,只冷 (A, 该连接),不影
296
402
  // a 在 m1 上限流失败 → 只冷 (m1, a)
297
403
  p.noteFailure('a', 'm1', 'rate_limit', '429')
298
404
  // m1 上 a 被冷 → 选 b
299
- assert.equal(p.pick(list, [], 'fallback', 'm1'), 'b')
405
+ assert.equal(p.pick(list, [], 'm1'), 'b')
300
406
  // m2 上 a 完全健康 → 应选 a(不被 m1 的失败殃及)
301
- assert.equal(p.pick(list, [], 'fallback', 'm2'), 'a')
407
+ assert.equal(p.pick(list, [], 'm2'), 'a')
302
408
  // m1 只剩 a 一个号 → undefined(a 在 m1 上被冷)
303
- assert.equal(p.pick(list.filter((x) => x.uid === 'a'), [], 'fallback', 'm1'), undefined)
409
+ assert.equal(p.pick(list.filter((x) => x.uid === 'a'), [], 'm1'), undefined)
304
410
  })
305
411
 
306
412
  test('【颗粒度】多号里只有一冷在 m1,其它号在 m2 仍可用', () => {
@@ -309,9 +415,9 @@ test('【颗粒度】多号里只有一冷在 m1,其它号在 m2 仍可用', (
309
415
  p.noteFailure('a', 'm1', 'transport', 'boom')
310
416
  p.noteFailure('b', 'm1', 'transport', 'boom')
311
417
  // m1:a、b 被冷 → 只剩 c
312
- assert.equal(p.pick(list, [], 'fallback', 'm1'), 'c')
418
+ assert.equal(p.pick(list, [], 'm1'), 'c')
313
419
  // m2:a、b 健康 → 应选 a(poolOrder 顺序第一个健康)
314
- assert.equal(p.pick(list, [], 'fallback', 'm2'), 'a')
420
+ assert.equal(p.pick(list, [], 'm2'), 'a')
315
421
  })
316
422
 
317
423
  test('【颗粒度】同一连接对不同模型的退避独立累加/清零', () => {
@@ -335,8 +441,8 @@ test('【颗粒度】session_dead 是连接级冷却:跨该号所有模型,
335
441
  const list = accs(['a'])
336
442
  p.noteFailure('a', 'm1', 'session_dead', '凭证失效')
337
443
  // 该号在 m1、m2 上都不可用(登录态问题,不是模型问题)
338
- assert.equal(p.pick(list, [], 'fallback', 'm1'), undefined)
339
- assert.equal(p.pick(list, [], 'fallback', 'm2'), undefined)
444
+ assert.equal(p.pick(list, [], 'm1'), undefined)
445
+ assert.equal(p.pick(list, [], 'm2'), undefined)
340
446
  const d = p.decorate(list)[0]
341
447
  assert.equal(d?.cooling, true)
342
448
  assert.ok(d?.until !== undefined, 'session_dead 冷却必须有 until(可恢复)')
@@ -346,8 +452,8 @@ test('处置:未知错误第一次就冷却(坏号不留在池里等下一
346
452
  const p = pool()
347
453
  const list = accs(['a', 'b'])
348
454
  p.noteFailure('a', 'm1', 'unknown', 'e1')
349
- assert.equal(p.pick(list, [], 'fallback', 'm1'), 'b')
350
- assert.equal(p.pick(list.filter((x) => x.uid === 'a'), [], 'fallback', 'm1'), undefined)
455
+ assert.equal(p.pick(list, [], 'm1'), 'b')
456
+ assert.equal(p.pick(list.filter((x) => x.uid === 'a'), [], 'm1'), undefined)
351
457
  })
352
458
 
353
459
  test('decorate:把冷却叠加到插件报的「现在状态」上(聚合展示)', () => {
@@ -374,7 +480,7 @@ test('no_such_model 不惩罚账号:不冷却、不计数', () => {
374
480
  const list = accs(['a'])
375
481
  // 攒够阈值次也不该冷却——模型不属于本供应商不是账号的错
376
482
  for (let i = 0; i < 10; i++) p.noteFailure('a', 'm1', 'no_such_model', 'unknown model "x"')
377
- assert.equal(p.pick(list, [], 'fallback', 'm1'), 'a')
483
+ assert.equal(p.pick(list, [], 'm1'), 'a')
378
484
  const out = p.decorate(list, 'm1')[0]
379
485
  assert.equal(out?.cooling, false)
380
486
  assert.equal(out?.err_count, 0)
@@ -390,7 +496,7 @@ test('bad_request 不惩罚账号:不冷却、不计数(一次坏请求不
390
496
  const p = pool()
391
497
  const list = accs(['a'])
392
498
  for (let i = 0; i < 10; i++) p.noteFailure('a', 'm1', 'bad_request', 'codebuddy 11148: tool calls do not match')
393
- assert.equal(p.pick(list, [], 'fallback', 'm1'), 'a', '号必须仍然可用')
499
+ assert.equal(p.pick(list, [], 'm1'), 'a', '号必须仍然可用')
394
500
  const out = p.decorate(list, 'm1')[0]
395
501
  assert.equal(out?.cooling, false, '请求非法不该把号标成冷却')
396
502
  assert.equal(out?.err_count, 0)
@@ -408,7 +514,7 @@ test('状态表:各 AccountState 的处置符合预期', () => {
408
514
  assert.equal(by.get('quota')?.cooling, true)
409
515
  assert.equal(by.get('dead')?.cooling, true, 'session_dead = 连接级冷却')
410
516
  assert.equal(by.get('unavail')?.cooling, true, '瞬时错误第一次就冷却')
411
- assert.equal(p.pick(list, [], 'fallback', 'm1'), undefined, '四个号都不可服务')
517
+ assert.equal(p.pick(list, [], 'm1'), undefined, '四个号都不可服务')
412
518
  })
413
519
 
414
520
  /**
@@ -418,7 +524,7 @@ test('处置:未知/瞬时错误第一次就短冷却(对齐 9router 瞬时
418
524
  const p = pool()
419
525
  const list = accs(['a'])
420
526
  p.noteFailure('a', 'm1', 'unknown', 'e1')
421
- assert.equal(p.pick(list, [], 'fallback', 'm1'), undefined, '第一次失败就该冻结,不该留在池里')
527
+ assert.equal(p.pick(list, [], 'm1'), undefined, '第一次失败就该冻结,不该留在池里')
422
528
  const d = p.decorate(list, 'm1')[0]
423
529
  assert.equal(d?.cooling, true)
424
530
  assert.equal(d?.err_count, 0)
@@ -476,7 +582,7 @@ test('【隔离】不同供应商的同名模型,各自独立冷却', () => {
476
582
  const list = accs(['acc']) // 同一 uid 名出现在两个供应商池里(真实是不同账号)
477
583
  // A 供应商的 acc 在 deepseek-v4-flash 上限流
478
584
  pa.noteFailure('acc', 'deepseek-v4-flash', 'rate_limit', '429')
479
- assert.equal(pa.pick(list, [], 'fallback', 'deepseek-v4-flash'), undefined, 'A 池该模型已冷')
585
+ assert.equal(pa.pick(list, [], 'deepseek-v4-flash'), undefined, 'A 池该模型已冷')
480
586
  // B 供应商同名模型不受影响
481
- assert.equal(pb.pick(list, [], 'fallback', 'deepseek-v4-flash'), 'acc', 'B 池同名模型照常可用')
587
+ assert.equal(pb.pick(list, [], 'deepseek-v4-flash'), 'acc', 'B 池同名模型照常可用')
482
588
  })