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.
- package/README.md +77 -34
- package/docs/pool-sticky-block.md +158 -0
- package/lib/client-registry.js +351 -407
- package/lib/client.js +351 -407
- package/lib/index.js +383 -170
- package/lib/types/ext/contract.d.ts +0 -7
- package/lib/types/host-version.d.ts +15 -0
- package/lib/types/index.d.ts +6 -0
- package/lib/types/router/account-pool.d.ts +53 -18
- package/lib/types/router/index.d.ts +4 -7
- package/lib/types/router/prefix-affinity.d.ts +18 -1
- package/lib/types/router/types.d.ts +7 -4
- package/lib/types/router/usage-store.d.ts +12 -0
- package/lib/types/shared.d.ts +13 -21
- package/lib/types/supplier-config.d.ts +0 -3
- package/lib/types/suppliers/contract.d.ts +2 -2
- package/lib/types/suppliers/registry.d.ts +0 -1
- package/package.json +12 -13
- package/src/client/CombosTab.tsx +8 -53
- package/src/client/ExtDetail.tsx +15 -178
- package/src/client/LastHitDock.tsx +141 -0
- package/src/client/StatsTab.tsx +8 -6
- package/src/client/SupplierDetail.tsx +2 -34
- package/src/client/index.tsx +45 -0
- package/src/client/last-hit-store.ts +89 -0
- package/src/client/model-hint-copy.test.ts +7 -2
- package/src/client/model-hint-copy.ts +2 -2
- package/src/client/router.css +2106 -1630
- package/src/ext/contract.ts +0 -7
- package/src/host-version.test.ts +61 -0
- package/src/host-version.ts +101 -0
- package/src/index.ts +133 -59
- package/src/llm/adapter.test.ts +74 -0
- package/src/llm/adapter.ts +33 -0
- package/src/router/account-pool.test.ts +189 -83
- package/src/router/account-pool.ts +120 -31
- package/src/router/index.ts +22 -28
- package/src/router/models-cache.test.ts +9 -9
- package/src/router/precise-dispatch.test.ts +6 -6
- package/src/router/prefix-affinity.ts +22 -1
- package/src/router/types.ts +7 -5
- package/src/router/usage-store.ts +16 -0
- package/src/router/usage.test.ts +1 -1
- package/src/shared.ts +13 -24
- package/src/supplier-config.ts +2 -13
- package/src/suppliers/contract.ts +2 -2
- package/src/suppliers/loader.test.ts +2 -2
- package/src/suppliers/registry.ts +1 -20
- package/src/types.d.ts +2 -1
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* 账号池(核心)测试 —— 会话亲和选号/冷却/降级。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
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('
|
|
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'], '
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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, [], '
|
|
40
|
+
assert.equal(p.pick(list, [], 'm1'), 'b')
|
|
36
41
|
p.noteFailure('b', 'm1', 'rate_limit', '429')
|
|
37
|
-
assert.equal(p.pick(list, [], '
|
|
42
|
+
assert.equal(p.pick(list, [], 'm1'), undefined)
|
|
38
43
|
})
|
|
39
44
|
|
|
40
|
-
test('
|
|
45
|
+
test('选号:无指纹时块内粘住不换号(带 model)', () => {
|
|
41
46
|
const p = pool()
|
|
42
47
|
const list = accs(['a', 'b'])
|
|
43
48
|
// 块没满 → 一直粘在 a(这就是缓存命中的来源)
|
|
44
|
-
assert.equal(p.pick(list, [], '
|
|
45
|
-
assert.equal(p.pick(list, [], '
|
|
46
|
-
assert.equal(p.pick(list, [], '
|
|
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, [], '
|
|
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, [], '
|
|
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, [], '
|
|
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, [], '
|
|
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, [], '
|
|
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, [], '
|
|
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, [], '
|
|
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, [], '
|
|
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, [], '
|
|
109
|
+
assert.equal(p.pick(list, [], 'm1'), 'a')
|
|
101
110
|
serveMiss(p, 'a', 48) // a 判定缓存无效(降权)
|
|
102
|
-
assert.equal(p.pick(list, [], '
|
|
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, [], '
|
|
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, [], '
|
|
120
|
+
assert.equal(p.pick(list, [], 'm1'), 'a')
|
|
112
121
|
serveMiss(p, 'a', 48)
|
|
113
|
-
assert.equal(p.pick(list, [], '
|
|
122
|
+
assert.equal(p.pick(list, [], 'm1'), 'b')
|
|
114
123
|
serveMiss(p, 'b', 48)
|
|
115
124
|
// 两个都降权 → 不能返回 undefined(那等于服务挂了),退回健康池
|
|
116
|
-
const uid = p.pick(list, [], '
|
|
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, [], '
|
|
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, [], '
|
|
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, [], '
|
|
142
|
+
assert.equal(p.pick(list, [], 'm1'), 'a')
|
|
134
143
|
// 16 次全命中 → 达标切走(失败不算,否则比例被稀释永不达标)
|
|
135
144
|
serveHits(p, 'a', 16)
|
|
136
|
-
assert.equal(p.pick(list, [], '
|
|
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, [], '
|
|
151
|
+
assert.equal(p.pick(list, [], 'm1'), 'a')
|
|
143
152
|
serveHits(p, 'a', 16) // m1 上 a 已达标
|
|
144
|
-
assert.equal(p.pick(list, [], '
|
|
153
|
+
assert.equal(p.pick(list, [], 'm1'), 'b')
|
|
145
154
|
// m2 是独立的块 → 从头开始,选 a
|
|
146
|
-
assert.equal(p.pick(list, [], '
|
|
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, [], '
|
|
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, [], '
|
|
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, [], '
|
|
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, [], '
|
|
189
|
-
const s2 = p.pick(list, [], '
|
|
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, [], '
|
|
193
|
-
assert.equal(p.pick(list, [], '
|
|
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, [], '
|
|
265
|
+
const s1 = p.pick(list, [], 'm1', msgs('s1'))
|
|
200
266
|
// 真实会话每轮 messages 变长,但前 3 条不变 → 指纹不变
|
|
201
|
-
assert.equal(p.pick(list, [], '
|
|
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, [], '
|
|
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, [], '
|
|
277
|
+
const second = p.pick(list, [], 'm1', m)
|
|
212
278
|
assert.notEqual(second, first)
|
|
213
279
|
// 且之后粘在新号上(绑定已更新)
|
|
214
|
-
assert.equal(p.pick(list, [], '
|
|
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, [], '
|
|
287
|
+
const first = p.pick(list, [], 'm1', m)!
|
|
222
288
|
// 该号累计 48 次全 miss → 判定缓存无效
|
|
223
289
|
serveMiss(p, first, 48)
|
|
224
|
-
const second = p.pick(list, [], '
|
|
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, [], '
|
|
233
|
-
assert.equal(p.pick(list, [], '
|
|
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, [], '
|
|
341
|
+
assert.equal(p.pick(list, [], 'm1'), 'b')
|
|
236
342
|
// 传了但算不出指纹(空/坏结构)→ 同样回退
|
|
237
|
-
assert.equal(p.pick(list, [], '
|
|
238
|
-
assert.equal(p.pick(list, [], '
|
|
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, [], '
|
|
351
|
+
const m1 = p.pick(list, [], 'm1', m)
|
|
246
352
|
// rrCursor 跨模型共享(与旧块轮询一致):m1 的新会话消耗 a 后,m2 的新会话分到 b
|
|
247
|
-
const m2 = p.pick(list, [], '
|
|
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, [], '
|
|
252
|
-
assert.equal(p.pick(list, [], '
|
|
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, [], '
|
|
255
|
-
assert.equal(p.pick(list, [], '
|
|
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, [], '
|
|
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, [], '
|
|
264
|
-
const again = p.pick(list, [], '
|
|
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, [], '
|
|
374
|
+
const hot = p.pick(list, [], 'm1', msgs('hot'))!
|
|
269
375
|
for (let i = 0; i < 600; i++) {
|
|
270
|
-
p.pick(list, [], '
|
|
271
|
-
assert.equal(p.pick(list, [], '
|
|
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, [], '
|
|
282
|
-
assert.equal(p.pick(list, [], '
|
|
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, [], '
|
|
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, [], '
|
|
405
|
+
assert.equal(p.pick(list, [], 'm1'), 'b')
|
|
300
406
|
// m2 上 a 完全健康 → 应选 a(不被 m1 的失败殃及)
|
|
301
|
-
assert.equal(p.pick(list, [], '
|
|
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'), [], '
|
|
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, [], '
|
|
418
|
+
assert.equal(p.pick(list, [], 'm1'), 'c')
|
|
313
419
|
// m2:a、b 健康 → 应选 a(poolOrder 顺序第一个健康)
|
|
314
|
-
assert.equal(p.pick(list, [], '
|
|
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, [], '
|
|
339
|
-
assert.equal(p.pick(list, [], '
|
|
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, [], '
|
|
350
|
-
assert.equal(p.pick(list.filter((x) => x.uid === 'a'), [], '
|
|
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, [], '
|
|
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, [], '
|
|
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, [], '
|
|
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, [], '
|
|
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, [], '
|
|
585
|
+
assert.equal(pa.pick(list, [], 'deepseek-v4-flash'), undefined, 'A 池该模型已冷')
|
|
480
586
|
// B 供应商同名模型不受影响
|
|
481
|
-
assert.equal(pb.pick(list, [], '
|
|
587
|
+
assert.equal(pb.pick(list, [], 'deepseek-v4-flash'), 'acc', 'B 池同名模型照常可用')
|
|
482
588
|
})
|