dsh-router-core 0.3.2 → 0.3.3

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.
@@ -1,5 +1,11 @@
1
1
  /**
2
- * 账号池(核心策略)测试 —— 选号/冷却/禁用/错误累计/遍历回退。
2
+ * 账号池(核心策略)测试 —— 选号/冷却/禁用/遍历回退。
3
+ *
4
+ * 关键语义(v2):
5
+ * - 冷却单元 = (supplierId, modelId, uid)。某连接在模型 A 上失败,只冷
6
+ * (A, 该连接),该连接调模型 B 不受影响。
7
+ * - 禁用(session_dead) = 连接级(uid):该号所有模型都不可用。
8
+ * - no_such_model 不惩罚账号(模型不属于本供应商不是号的错)。
3
9
  * 用 node --test 跑(Node 原生 TS 剥离,零新依赖)。
4
10
  */
5
11
  import { test } from 'node:test'
@@ -9,112 +15,208 @@ import type { SupplierAccountNow } from '../suppliers/contract.ts'
9
15
 
10
16
  const accs = (uids: string[]): SupplierAccountNow[] => uids.map((uid) => ({ uid, credits: 0, state: 'ok' }))
11
17
 
12
- test('选号:无冷却时按 poolOrder 优先,未配置的追加在后', () => {
13
- const p = new AccountPool()
18
+ /** 构造带供应商的池,与 loader 一致(真实使用会传 supplierId)。 */
19
+ const pool = (sid = 'supA'): AccountPool => new AccountPool(sid)
20
+
21
+ test('选号:无冷却时按 poolOrder 优先,未配置的追加在后(带 model)', () => {
22
+ const p = pool()
14
23
  const list = accs(['a', 'b', 'c'])
15
- assert.equal(p.pick(list, ['c', 'a'], 'fallback'), 'c')
16
- assert.equal(p.pick(list, ['b'], 'fallback'), 'b')
24
+ assert.equal(p.pick(list, ['c', 'a'], 'fallback', 'm1'), 'c')
25
+ assert.equal(p.pick(list, ['b'], 'fallback', 'm1'), 'b')
17
26
  // 未配置顺序的按自然顺序补上
18
- assert.equal(p.pick(list, [], 'fallback'), 'a')
27
+ assert.equal(p.pick(list, [], 'fallback', 'm1'), 'a')
19
28
  })
20
29
 
21
- test('选号:冷却中的号跳过,全冷却返回 undefined', () => {
22
- const p = new AccountPool()
30
+ test('选号:某模型冷却中的号对该模型跳过,全冷却返回 undefined', () => {
31
+ const p = pool()
23
32
  const list = accs(['a', 'b'])
24
- p.noteFailure('a', 'rate_limit', '429')
25
- assert.equal(p.pick(list, [], 'fallback'), 'b')
26
- p.noteFailure('b', 'rate_limit', '429')
27
- assert.equal(p.pick(list, [], 'fallback'), undefined)
33
+ p.noteFailure('a', 'm1', 'rate_limit', '429')
34
+ assert.equal(p.pick(list, [], 'fallback', 'm1'), 'b')
35
+ p.noteFailure('b', 'm1', 'rate_limit', '429')
36
+ assert.equal(p.pick(list, [], 'fallback', 'm1'), undefined)
28
37
  })
29
38
 
30
- test('选号:round-robin 在健康号间轮转', () => {
31
- const p = new AccountPool()
39
+ test('round-robin 在健康号间轮转(带 model)', () => {
40
+ const p = pool()
32
41
  const list = accs(['a', 'b'])
33
- assert.equal(p.pick(list, [], 'round-robin'), 'a')
34
- assert.equal(p.pick(list, [], 'round-robin'), 'b')
35
- assert.equal(p.pick(list, [], 'round-robin'), 'a')
42
+ assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'a')
43
+ assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'b')
44
+ assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'a')
36
45
  })
37
46
 
38
- test('处置:session_dead 永久禁用(不再被选中)', () => {
39
- const p = new AccountPool()
40
- const list = accs(['a'])
41
- p.noteFailure('a', 'session_dead', '凭证失效')
42
- assert.equal(p.pick(list, [], 'fallback'), undefined)
43
- assert.equal(p.decorate(list)[0]?.disabled, true)
47
+ // ============ 核心新语义:冷却按 (模型, 连接) 分号 ============
48
+
49
+ test('【颗粒度】连接在模型 A 失败,只冷 (A, 该连接),不影响它调模型 B', () => {
50
+ const p = pool()
51
+ const list = accs(['a', 'b'])
52
+ // a 在 m1 上限流失败 → 只冷 (m1, a)
53
+ p.noteFailure('a', 'm1', 'rate_limit', '429')
54
+ // m1 上 a 被冷 → 选 b
55
+ assert.equal(p.pick(list, [], 'fallback', 'm1'), 'b')
56
+ // m2 上 a 完全健康 → 应选 a(不被 m1 的失败殃及)
57
+ assert.equal(p.pick(list, [], 'fallback', 'm2'), 'a')
58
+ // m1 只剩 a 一个号 → undefined(a 在 m1 上被冷)
59
+ assert.equal(p.pick(list.filter((x) => x.uid === 'a'), [], 'fallback', 'm1'), undefined)
60
+ })
61
+
62
+ test('【颗粒度】多号里只有一冷在 m1,其它号在 m2 仍可用', () => {
63
+ const p = pool()
64
+ const list = accs(['a', 'b', 'c'])
65
+ p.noteFailure('a', 'm1', 'transport', 'boom')
66
+ p.noteFailure('b', 'm1', 'transport', 'boom')
67
+ // m1:a、b 被冷 → 只剩 c
68
+ assert.equal(p.pick(list, [], 'fallback', 'm1'), 'c')
69
+ // m2:a、b 健康 → 应选 a(poolOrder 顺序第一个健康)
70
+ assert.equal(p.pick(list, [], 'fallback', 'm2'), 'a')
44
71
  })
45
72
 
46
- test('处置:连续错误攒够阈值才冷却(前两次仍可服务)', () => {
47
- const p = new AccountPool()
73
+ test('【颗粒度】同一连接对不同模型的退避独立累加/清零', () => {
74
+ const p = pool()
48
75
  const list = accs(['a'])
49
- p.noteFailure('a', 'unknown', 'e1')
50
- assert.equal(p.pick(list, [], 'fallback'), 'a')
51
- p.noteFailure('a', 'unknown', 'e2')
52
- assert.equal(p.pick(list, [], 'fallback'), 'a')
53
- // 第三次触发冷却
54
- p.noteFailure('a', 'unknown', 'e3')
55
- assert.equal(p.pick(list, [], 'fallback'), undefined)
76
+ p.noteFailure('a', 'm1', 'rate_limit', '429')
77
+ p.noteFailure('a', 'm1', 'rate_limit', '429')
78
+ // m1 退避等级 2
79
+ assert.equal(p.decorate(list, 'm1')[0]?.err_count, 2)
80
+ // m2 没被 m1 影响 → 等级 0
81
+ assert.equal(p.decorate(list, 'm2')[0]?.err_count, 0)
82
+ // m1 成功 → 清零 m1 的退避
83
+ p.noteSuccess('a', 'm1')
84
+ assert.equal(p.decorate(list, 'm1')[0]?.err_count, 0)
56
85
  })
57
86
 
58
- test('处置:成功清零连续错误,避免之前累积的错误把好号冷却掉', () => {
59
- const p = new AccountPool()
87
+ // ============ 连接级禁用(跨模型) ============
88
+
89
+ test('【颗粒度】session_dead 是连接级禁用:跨该号所有模型', () => {
90
+ const p = pool()
60
91
  const list = accs(['a'])
61
- p.noteFailure('a', 'unknown', 'e1')
62
- p.noteFailure('a', 'unknown', 'e2')
63
- p.noteSuccess('a')
64
- p.noteFailure('a', 'unknown', 'e3')
65
- assert.equal(p.pick(list, [], 'fallback'), 'a')
92
+ p.noteFailure('a', 'm1', 'session_dead', '凭证失效')
93
+ // 该号在 m1、m2 上都不可用(登录态问题,不是模型问题)
94
+ assert.equal(p.pick(list, [], 'fallback', 'm1'), undefined)
95
+ assert.equal(p.pick(list, [], 'fallback', 'm2'), undefined)
96
+ assert.equal(p.decorate(list)[0]?.disabled, true)
97
+ })
98
+
99
+ test('处置:未知错误第一次就冷却(坏号不留在池里等下一次撞)', () => {
100
+ const p = pool()
101
+ const list = accs(['a', 'b'])
102
+ p.noteFailure('a', 'm1', 'unknown', 'e1')
103
+ assert.equal(p.pick(list, [], 'fallback', 'm1'), 'b')
104
+ assert.equal(p.pick(list.filter((x) => x.uid === 'a'), [], 'fallback', 'm1'), undefined)
66
105
  })
67
106
 
68
- test('decorate:把冷却/禁用/错误累计叠加到插件报的「现在状态」上', () => {
69
- const p = new AccountPool()
107
+ test('decorate:把冷却/禁用叠加到插件报的「现在状态」上(聚合展示)', () => {
108
+ const p = pool()
70
109
  const list: SupplierAccountNow[] = [
71
110
  { uid: 'ok', credits: 5, state: 'ok' },
72
111
  { uid: 'cool', credits: 5, state: 'ok' },
73
112
  { uid: 'dead', credits: 5, state: 'ok' },
74
113
  ]
75
- p.noteFailure('cool', 'rate_limit', '429')
76
- p.noteFailure('dead', 'session_dead', '凭证失效')
114
+ p.noteFailure('cool', 'm1', 'rate_limit', '429')
115
+ p.noteFailure('dead', 'm1', 'session_dead', '凭证失效')
77
116
  const out = p.decorate(list)
78
117
  const by = new Map(out.map((a) => [a.uid, a]))
79
118
  assert.equal(by.get('ok')?.cooling, false)
80
119
  assert.equal(by.get('ok')?.disabled, false)
81
120
  assert.equal(by.get('cool')?.cooling, true)
82
121
  assert.equal(by.get('cool')?.until !== undefined, true)
83
- assert.equal(by.get('cool')?.reason, '429')
84
122
  assert.equal(by.get('dead')?.disabled, true)
85
123
  // 插件报的字段要原样透出(不能被 decorate 吃掉)
86
124
  assert.equal(by.get('ok')?.credits, 5)
87
125
  })
88
126
 
89
127
  test('no_such_model 不惩罚账号:不冷却、不计数', () => {
90
- const p = new AccountPool()
128
+ const p = pool()
91
129
  const list = accs(['a'])
92
130
  // 攒够阈值次也不该冷却——模型不属于本供应商不是账号的错
93
- for (let i = 0; i < 10; i++) p.noteFailure('a', 'no_such_model', 'unknown model "x"')
94
- assert.equal(p.pick(list, [], 'fallback'), 'a')
95
- const out = p.decorate(list)[0]
131
+ for (let i = 0; i < 10; i++) p.noteFailure('a', 'm1', 'no_such_model', 'unknown model "x"')
132
+ assert.equal(p.pick(list, [], 'fallback', 'm1'), 'a')
133
+ const out = p.decorate(list, 'm1')[0]
96
134
  assert.equal(out?.cooling, false)
97
135
  assert.equal(out?.disabled, false)
98
136
  assert.equal(out?.err_count, 0)
99
137
  })
100
138
 
101
139
  test('状态表:各 AccountState 的处置符合预期', () => {
102
- const p = new AccountPool()
140
+ const p = pool()
103
141
  const list = accs(['rate', 'quota', 'dead', 'unavail'])
104
- p.noteFailure('rate', 'rate_limit', 'x')
105
- p.noteFailure('quota', 'quota', 'x')
106
- p.noteFailure('dead', 'session_dead', 'x')
107
- p.noteFailure('unavail', 'unavailable', 'x')
108
- // rate_limit/quota 立刻冷却;session_dead 禁用;unavailable 只计错误不冷却
109
- assert.equal(p.pick(list, [], 'fallback'), 'unavail')
142
+ p.noteFailure('rate', 'm1', 'rate_limit', 'x')
143
+ p.noteFailure('quota', 'm1', 'quota', 'x')
144
+ p.noteFailure('dead', 'm1', 'session_dead', 'x')
145
+ p.noteFailure('unavail', 'm1', 'unavailable', 'x')
110
146
  const by = new Map(p.decorate(list).map((a) => [a.uid, a]))
111
147
  assert.equal(by.get('rate')?.cooling, true)
112
148
  assert.equal(by.get('quota')?.cooling, true)
113
149
  assert.equal(by.get('dead')?.disabled, true)
114
- assert.equal(by.get('unavail')?.cooling, false)
115
- assert.equal(by.get('unavail')?.err_count, 1)
116
- // 攒够阈值后才冷却(与 unknown/transport 同一条路径)
117
- p.noteFailure('unavail', 'unavailable', 'x')
118
- p.noteFailure('unavail', 'unavailable', 'x')
119
- assert.equal(p.pick(list, [], 'fallback'), undefined)
150
+ assert.equal(by.get('unavail')?.cooling, true, '瞬时错误第一次就冷却')
151
+ assert.equal(p.pick(list, [], 'fallback', 'm1'), undefined, '四个号都不可服务')
152
+ })
153
+
154
+ /**
155
+ * 处置对齐 9router:未知/瞬时错误**每次**短冷却(不再攒够 3 次)。
156
+ */
157
+ test('处置:未知/瞬时错误第一次就短冷却(对齐 9router 瞬时冷却)', () => {
158
+ const p = pool()
159
+ const list = accs(['a'])
160
+ p.noteFailure('a', 'm1', 'unknown', 'e1')
161
+ assert.equal(p.pick(list, [], 'fallback', 'm1'), undefined, '第一次失败就该冻结,不该留在池里')
162
+ const d = p.decorate(list, 'm1')[0]
163
+ assert.equal(d?.cooling, true)
164
+ assert.equal(d?.err_count, 0)
165
+ })
166
+
167
+ test('处置:瞬时冷却是短冷却(30s 量级),不是攒够后的 5 分钟', () => {
168
+ const p = pool()
169
+ const list = accs(['a'])
170
+ p.noteFailure('a', 'm1', 'transport', 'boom')
171
+ const d = p.decorate(list, 'm1')[0]
172
+ const until = d?.until === undefined ? 0 : new Date(d.until).getTime()
173
+ const span = until - Date.now()
174
+ assert.ok(span > 0 && span <= 60_000, `瞬时冷却应在 60s 内,实际 ${span}ms`)
175
+ })
176
+
177
+ test('处置:限流越频繁冷却越久(指数退避,对齐 9router backoff)', () => {
178
+ const p = pool()
179
+ const list = accs(['a'])
180
+ const spanOf = (m: string): number => {
181
+ const d = p.decorate(list, m)[0]
182
+ const until = d?.until === undefined ? 0 : new Date(d.until).getTime()
183
+ return until - Date.now()
184
+ }
185
+ p.noteFailure('a', 'm1', 'rate_limit', '429-1')
186
+ const first = spanOf('m1')
187
+ assert.ok(first > 0 && first <= 60_000, `首次限流应短冷(≤60s),实际 ${first}ms`)
188
+ assert.equal(p.decorate(list, 'm1')[0]?.err_count, 1, '退避等级 1')
189
+
190
+ // 连着再限流:等级递进 → 冷却变长。
191
+ p.noteFailure('a', 'm1', 'rate_limit', '429-2')
192
+ const second = spanOf('m1')
193
+ assert.equal(p.decorate(list, 'm1')[0]?.err_count, 2, '退避等级 2')
194
+ assert.ok(second > first, `第二次限流冷却应更久:${second}ms 应 > ${first}ms`)
195
+ p.noteFailure('a', 'm1', 'rate_limit', '429-3')
196
+ assert.ok(spanOf('m1') > second, '第三次更久')
197
+ })
198
+
199
+ test('处置:成功后限流退避等级归零(好号不再背历史惩罚)', () => {
200
+ const p = pool()
201
+ const list = accs(['a'])
202
+ p.noteFailure('a', 'm1', 'rate_limit', '429')
203
+ p.noteSuccess('a', 'm1')
204
+ p.cooldown('a', -1, 'reset')
205
+ p.noteFailure('a', 'm1', 'rate_limit', '429')
206
+ const after = p.decorate(list, 'm1')[0]
207
+ const until = after?.until === undefined ? 0 : new Date(after.until).getTime()
208
+ assert.ok(until - Date.now() <= 60_000, '成功清零后应从最短退避重新开始')
209
+ })
210
+
211
+ // ============ 跨供应商同名模型不串(键带 supplierId) ============
212
+
213
+ test('【隔离】不同供应商的同名模型,各自独立冷却', () => {
214
+ const pa = pool('supA') // 供应商 A
215
+ const pb = pool('supB') // 供应商 B
216
+ const list = accs(['acc']) // 同一 uid 名出现在两个供应商池里(真实是不同账号)
217
+ // A 供应商的 acc 在 deepseek-v4-flash 上限流
218
+ pa.noteFailure('acc', 'deepseek-v4-flash', 'rate_limit', '429')
219
+ assert.equal(pa.pick(list, [], 'fallback', 'deepseek-v4-flash'), undefined, 'A 池该模型已冷')
220
+ // B 供应商同名模型不受影响
221
+ assert.equal(pb.pick(list, [], 'fallback', 'deepseek-v4-flash'), 'acc', 'B 池同名模型照常可用')
120
222
  })
@@ -1,21 +1,39 @@
1
1
  /**
2
- * 账号池 —— dsh-router 核心策略:选号、冷却、禁用、错误累计、遍历回退。
2
+ * 账号池 —— dsh-router 核心策略:选号、冷却、禁用、遍历回退。
3
3
  *
4
- * 供应商插件只管「对单个账号调通上游」,账号层面的策略全在这里:
5
- * - 按 poolOrder 排序(未配置的按插件报告顺序追加)
6
- * - 按 poolStrategy 选号:fallback = 取第一个健康;round-robin = 轮转健康号
7
- * - 失败按插件报的 AccountState 处置(冷却/禁用/错误累计),攒够阈值自动冷却
4
+ * 供应商插件只管「对单个账号调通上游」,账号层面的策略全在这里。
8
5
  *
9
- * 天花板:冷却/禁用/错误计数都是**内存态**,重启归零(与旧插件行为一致,
10
- * 插件的持久化只存凭证与积分,不存健康)。要跨重启保留得落盘,届时加一个
11
- * stateFile 即可,接口不用变。
6
+ * ## 冷却颗粒度 = (供应商, 模型, 连接) 三元组
7
+ *
8
+ * 冷却**不按连接整体记**,而是按「哪家供应商 × 哪个模型 × 哪个连接」单独记。
9
+ * 理由:一个连接可以服务多个模型。若某连接调模型 A 时被限流/瞬时抖动而把
10
+ * **整个连接**冷掉,那它调模型 B 也被殃及——一个付费连接会因一个模型的瞬时
11
+ * 失败而对其它模型也不可用,这是错的。
12
+ *
13
+ * - 冷却/限流退避/瞬时 → 键 = (supplierId, modelId, uid),只冷「这个号 × 这个模型」
14
+ * - 禁用(session_dead) → 键 = uid(登录态问题,该号所有模型都不可用)
15
+ * - 手动冷却(面板) → 键 = uid(管理员整连接暂停)
16
+ *
17
+ * 为什么键里要带 supplierId:不同供应商**会有同名的模型 id**(如 traework 与
18
+ * codebuddy 都有 `deepseek-v4-flash`),它们是不同的实体,单靠 (modelId, uid)
19
+ * 会把跨供应商的同名模型串到一起。尽管本池实例当前是单供应商(loader 每供应商
20
+ * 一个 AccountPool),键仍显式带 supplierId,语义自明且将来若池合并也安全。
21
+ *
22
+ * 天花板:冷却/禁用都是**内存态**,重启归零(与旧插件行为一致)。要跨重启保留
23
+ * 得落盘,届时加 stateFile 即可,接口不用变。
12
24
  */
13
25
  import type { AccountState, SupplierAccountNow } from '../suppliers/contract.ts'
14
26
 
15
- /** 各 AccountState 的处置:冷却时长 / 是否禁用 / 是否计入连续错误。 */
27
+ /** 各 AccountState 的处置:冷却策略 / 是否禁用 / 是否计入连续错误。 */
16
28
  interface Rule {
17
- /** 冷却时长(ms);0 = 不冷却。 */
18
- cooldownMs: number
29
+ /**
30
+ * 冷却策略:
31
+ * - `number` 固定时长(ms)
32
+ * - `'transient'` 瞬时短冷却(每次都冷,30s 量级)
33
+ * - `'backoff'` 指数退避(反复限流越退越久)
34
+ * - `0` 不冷却
35
+ */
36
+ cooldown: number | 'transient' | 'backoff'
19
37
  /** 是否永久禁用(需重新登录才能恢复)。 */
20
38
  disable: boolean
21
39
  /** 是否计入连续错误(攒够阈值自动冷却)。 */
@@ -26,62 +44,138 @@ const MINUTE = 60_000
26
44
 
27
45
  /**
28
46
  * 状态 → 处置表。**核心策略就在这一张表里**,插件只报状态不做决策。
29
- * 时长参考 9router 的 errorConfig(限流类短冷却、额度类长冷却),
30
- * 但 9router 按文本/状态匹配,这里按插件解读后的语义状态匹配,更准。
47
+ *
48
+ * 冷却策略对齐 9router 的 accountFallback / errorConfig:
49
+ * - `rate_limit` 用**指数退避**(9router 的 `backoff: true`):偶发限流只
50
+ * 短冷,持续限流越退越久,比固定 1 分钟两头不讨好要准。
51
+ * - 未知/瞬时错误用**瞬时短冷却**(9router 默认分支 TRANSIENT_COOLDOWN_MS):
52
+ * 每次失败都冻结,坏号不会留在池里被下一个请求再选中。
53
+ *
54
+ * 与 9router 的差别:9router 按错误文本/HTTP 状态匹配,这里按插件解读后的
55
+ * 语义状态匹配 —— 插件已经把 11133/11134 这类网关噪声归成 `rate_limit`,
56
+ * 核心不必再猜文本,更准。
31
57
  */
32
58
  const RULES: Record<AccountState, Rule> = {
33
- ok: { cooldownMs: 0, disable: false, counts: false },
34
- rate_limit: { cooldownMs: 1 * MINUTE, disable: false, counts: false },
35
- quota: { cooldownMs: 10 * MINUTE, disable: false, counts: false },
36
- session_dead: { cooldownMs: 0, disable: true, counts: false },
37
- // 404/服务下线:只计错误不立刻冷却——偶发的自己恢复,真挂了攒够阈值再冷却
38
- unavailable: { cooldownMs: 0, disable: false, counts: true },
39
- transport: { cooldownMs: 0, disable: false, counts: true },
40
- unknown: { cooldownMs: 0, disable: false, counts: true },
59
+ ok: { cooldown: 0, disable: false, counts: false },
60
+ rate_limit: { cooldown: 'backoff', disable: false, counts: false },
61
+ quota: { cooldown: 10 * MINUTE, disable: false, counts: false },
62
+ session_dead: { cooldown: 0, disable: true, counts: false },
63
+ // 404/服务下线、传输中断、未知:每次都瞬时短冷却(见上方 9router 对齐说明)
64
+ unavailable: { cooldown: 'transient', disable: false, counts: false },
65
+ transport: { cooldown: 'transient', disable: false, counts: false },
66
+ unknown: { cooldown: 'transient', disable: false, counts: false },
41
67
  // 模型不属于本供应商:不是账号的错,不冷却也不计数(核心据此换下一个供应商)
42
- no_such_model: { cooldownMs: 0, disable: false, counts: false },
68
+ no_such_model: { cooldown: 0, disable: false, counts: false },
43
69
  }
44
70
 
71
+ /**
72
+ * 瞬时/未知错误的冷却时长 —— 对齐 9router 的 TRANSIENT_COOLDOWN_MS。
73
+ * 取短值:这类错误大多是偶发的上游抖动,冷太久会把好号白白关掉;
74
+ * 但**必须冷**,否则坏号留在池里,每个请求都要先撞一次才知道它坏了。
75
+ * (现在按 (模型, 号) 记,只关这一个号在这个模型上的可用性。)
76
+ */
77
+ const TRANSIENT_COOLDOWN_MS = 30_000
78
+
79
+ /** 限流退避:首次 2s,每次翻倍,上限 5 分钟(对齐 9router BACKOFF_CONFIG)。 */
80
+ const BACKOFF_BASE_MS = 2_000
81
+ const BACKOFF_MAX_MS = 5 * MINUTE
82
+ const BACKOFF_MAX_LEVEL = 15
83
+
45
84
  /** 连续错误攒够这么多次就自动冷却(防止坏号被反复重试)。 */
46
85
  const ERR_THRESHOLD = 3
47
86
  /** 攒够后的冷却时长。 */
48
- const ERR_COOLDOWN_MS = 10 * MINUTE
87
+ const ERR_COOLDOWN_MS = 5 * MINUTE
88
+
89
+ /** 冷却记录(键 = (supplier, model, uid))。 */
90
+ interface CooldownEntry {
91
+ until: number
92
+ backoffLevel: number
93
+ reason: string
94
+ }
49
95
 
50
- interface Entry {
96
+ /** 连接级禁用/手动暂停记录(键 = uid)。 */
97
+ interface UidEntry {
51
98
  until: number
52
99
  disabled: boolean
53
- errCount: number
54
100
  reason: string
55
101
  }
56
102
 
103
+ /** 指数退避:等级 1 = BACKOFF_BASE_MS,每级翻倍,封顶 BACKOFF_MAX_MS。 */
104
+ function backoffMs(level: number): number {
105
+ return Math.min(BACKOFF_BASE_MS * 2 ** Math.max(0, level - 1), BACKOFF_MAX_MS)
106
+ }
107
+
108
+ /** 冷却键分隔符:模型 id 本身可含 `/` `,`,用不可见控制符隔离最稳。 */
109
+ const SEP = '\u0000'
110
+
57
111
  export class AccountPool {
58
- private entries = new Map<string, Entry>()
112
+ /** 本池所属供应商(loader 每供应商一个实例;键里带上,跨供应商同名模型不串)。 */
113
+ private supplierId: string
114
+ /** (supplier, model, uid) → 冷却/退避。 */
115
+ private cooldowns = new Map<string, CooldownEntry>()
116
+ /** uid → 连接级禁用 / 手动暂停。 */
117
+ private byUid = new Map<string, UidEntry>()
59
118
  private rrCursor = 0
60
119
 
61
- private entry(uid: string): Entry {
62
- let e = this.entries.get(uid)
120
+ constructor(supplierId = '') {
121
+ this.supplierId = supplierId
122
+ }
123
+
124
+ private key(model: string, uid: string): string {
125
+ return `${this.supplierId}${SEP}${model}${SEP}${uid}`
126
+ }
127
+
128
+ private entry(model: string, uid: string): CooldownEntry {
129
+ const k = this.key(model, uid)
130
+ let e = this.cooldowns.get(k)
131
+ if (!e) {
132
+ e = { until: 0, backoffLevel: 0, reason: '' }
133
+ this.cooldowns.set(k, e)
134
+ }
135
+ return e
136
+ }
137
+
138
+ /** 某连接整体状态(禁用/手动暂停)。 */
139
+ private uidState(uid: string): UidEntry {
140
+ let e = this.byUid.get(uid)
63
141
  if (!e) {
64
- e = { until: 0, disabled: false, errCount: 0, reason: '' }
65
- this.entries.set(uid, e)
142
+ e = { until: 0, disabled: false, reason: '' }
143
+ this.byUid.set(uid, e)
66
144
  }
67
145
  return e
68
146
  }
69
147
 
70
- /** 是否可服务:未禁用且不在冷却中。 */
71
- private healthy(uid: string, now: number): boolean {
72
- const e = this.entries.get(uid)
73
- if (!e) return true
74
- if (e.disabled) return false
75
- return e.until <= now
148
+ /** 该连接是否被禁用 / 手动整连接暂停中(跨模型生效)。 */
149
+ private uidBlocked(uid: string, now: number): { blocked: boolean; disabled: boolean } {
150
+ const e = this.byUid.get(uid)
151
+ if (!e) return { blocked: false, disabled: false }
152
+ if (e.disabled) return { blocked: true, disabled: true }
153
+ if (e.until > now) return { blocked: true, disabled: false }
154
+ return { blocked: false, disabled: false }
155
+ }
156
+
157
+ /**
158
+ * 给定要试的模型,该连接是否可服务:
159
+ * - 未被连接级禁用/手动暂停(跨模型);
160
+ * - 且此 (supplier, model, uid) 冷却单元不在冷却中。
161
+ */
162
+ private healthy(uid: string, model: string, now: number): boolean {
163
+ const b = this.uidBlocked(uid, now)
164
+ if (b.blocked) return false
165
+ const c = this.cooldowns.get(this.key(model, uid))
166
+ if (!c) return true
167
+ return c.until <= now
76
168
  }
77
169
 
78
170
  /**
79
- * 按策略选一个健康账号。
171
+ * 按策略为「某个模型」选一个健康账号。
80
172
  * @param accounts 插件报告的「现在状态」(顺序即插件的自然顺序)
81
173
  * @param poolOrder 用户在面板拖出来的顺序(核心管)
174
+ * @param strategy fallback 取第一个健康 / round-robin 轮转健康号
175
+ * @param modelId 当前要路由的模型(决定查哪个 (model, uid) 冷却单元)
82
176
  * @returns 选中的 uid;无健康账号返回 undefined
83
177
  */
84
- pick(accounts: SupplierAccountNow[], poolOrder: string[], strategy: string): string | undefined {
178
+ pick(accounts: SupplierAccountNow[], poolOrder: string[], strategy: string, modelId: string): string | undefined {
85
179
  const now = Date.now()
86
180
  const byUid = new Map(accounts.map((a) => [a.uid, a]))
87
181
  // 池顺序优先,未配置的按插件自然顺序追加
@@ -89,7 +183,7 @@ export class AccountPool {
89
183
  ...poolOrder.filter((uid) => byUid.has(uid)),
90
184
  ...accounts.map((a) => a.uid).filter((uid) => !poolOrder.includes(uid)),
91
185
  ]
92
- const healthy = ordered.filter((uid) => this.healthy(uid, now))
186
+ const healthy = ordered.filter((uid) => this.healthy(uid, modelId, now))
93
187
  if (healthy.length === 0) return undefined
94
188
  if (strategy !== 'round-robin') return healthy[0]
95
189
  const uid = healthy[this.rrCursor % healthy.length]
@@ -97,47 +191,61 @@ export class AccountPool {
97
191
  return uid
98
192
  }
99
193
 
100
- /** 记录一次失败:按状态处置(冷却/禁用/错误累计)。 */
101
- noteFailure(uid: string, state: AccountState, message: string): void {
194
+ /** 记录一次失败:按状态处置(冷却/禁用/退避),落在「(模型, 连接)」这个冷却单元上。 */
195
+ noteFailure(uid: string, modelId: string, state: AccountState, message: string): void {
102
196
  const rule = RULES[state]
103
- const e = this.entry(uid)
197
+ // 禁用 / 手动暂停是连接级(登录态问题,跨模型)——但这里只处理规则本身
104
198
  if (rule.disable) {
199
+ // session_dead:整个连接禁用(跨该号所有模型)
200
+ const e = this.uidState(uid)
105
201
  e.disabled = true
106
202
  e.reason = message
107
203
  return
108
204
  }
109
- if (rule.cooldownMs > 0) {
110
- e.until = Math.max(e.until, Date.now() + rule.cooldownMs)
111
- e.reason = message
112
- e.errCount = 0
205
+ if (rule.cooldown === 0) return // no_such_model 等:不冷不记
206
+ // 冷却/退避:记在 (model, uid) 单元上,不动该号其它模型
207
+ const e = this.entry(modelId, uid)
208
+ e.reason = message
209
+ if (rule.cooldown === 'backoff') {
210
+ // 连续被限流 → 等级递进冷却;成功(noteSuccess)会清零等级
211
+ e.backoffLevel = Math.min(e.backoffLevel + 1, BACKOFF_MAX_LEVEL)
212
+ e.until = Math.max(e.until, Date.now() + backoffMs(e.backoffLevel))
113
213
  return
114
214
  }
115
- if (rule.counts) {
116
- e.errCount += 1
117
- e.reason = message
118
- if (e.errCount >= ERR_THRESHOLD) {
119
- e.until = Date.now() + ERR_COOLDOWN_MS
120
- e.errCount = 0
121
- }
215
+ if (rule.cooldown === 'transient') {
216
+ // 每次都冷(不再攒次数):坏号不该留在池里等下一次撞
217
+ e.until = Math.max(e.until, Date.now() + TRANSIENT_COOLDOWN_MS)
218
+ return
122
219
  }
220
+ e.until = Math.max(e.until, Date.now() + rule.cooldown)
123
221
  }
124
222
 
125
- /** 记录一次成功:清零连续错误。 */
126
- noteSuccess(uid: string): void {
127
- const e = this.entries.get(uid)
128
- if (e) e.errCount = 0
223
+ /** 记录一次成功:清零「该模型」的限流退避等级(好号不背历史惩罚)。不解除禁用。 */
224
+ noteSuccess(uid: string, modelId: string): void {
225
+ const e = this.cooldowns.get(this.key(modelId, uid))
226
+ if (e) e.backoffLevel = 0
129
227
  }
130
228
 
131
- /** 手动冷却(面板/管理用途)。 */
229
+ /** 手动暂停整连接(面板/管理用途,跨模型)。ms<=0 表示解除。 */
132
230
  cooldown(uid: string, ms: number, reason: string): void {
133
- const e = this.entry(uid)
231
+ const e = this.uidState(uid)
134
232
  e.until = Date.now() + ms
135
233
  e.reason = reason
136
- e.errCount = 0
234
+ // 手动暂停通常是想清掉这个号上的累积惩罚:把它的退避等级清零
235
+ // (disabled 不可由此解除——需重新登录)
236
+ for (const [k, ce] of this.cooldowns) {
237
+ if (k.endsWith(`${SEP}${uid}`)) ce.backoffLevel = 0
238
+ }
137
239
  }
138
240
 
139
- /** 把核心的冷却/禁用/错误累计叠加到插件报告的「现在状态」上,产出面板态。 */
140
- decorate(accounts: SupplierAccountNow[]): Array<SupplierAccountNow & {
241
+ /** 把核心的冷却/禁用叠加到插件报的「现在状态」上,产出面板态。
242
+ *
243
+ * 冷却现在是 (模型, 连接) 粒度的,而面板状态没有「当前模型」上下文,
244
+ * 这里默认**聚合**:只要该连接在任一模型上有活跃冷却,就显示 cooling=true
245
+ * (reason 带上具体模型),并在 `until` 给最长者。真实路由用 `pick` 的
246
+ * 模型级判定,不受此展示聚合影响。传 modelId 可只按该模型判定。
247
+ */
248
+ decorate(accounts: SupplierAccountNow[], modelId?: string): Array<SupplierAccountNow & {
141
249
  cooling: boolean
142
250
  disabled: boolean
143
251
  err_count: number
@@ -146,15 +254,39 @@ export class AccountPool {
146
254
  }> {
147
255
  const now = Date.now()
148
256
  return accounts.map((a) => {
149
- const e = this.entries.get(a.uid)
150
- const cooling = e !== undefined && !e.disabled && e.until > now
257
+ const u = this.byUid.get(a.uid)
258
+ const disabled = u?.disabled ?? false
259
+ // 连接级手动暂停或禁用也算不可服务;但 disabled 单列,冷却不算它
260
+ const manualCooling = u !== undefined && !u.disabled && u.until > now
261
+
262
+ let cooling = manualCooling
263
+ let maxUntil = manualCooling ? u!.until : 0
264
+ let reason = manualCooling ? u!.reason : undefined
265
+ let err = 0
266
+ // 遍历本连接在所有模型上的冷却单元(或仅指定模型)
267
+ for (const [k, ce] of this.cooldowns) {
268
+ if (!k.endsWith(`${SEP}${a.uid}`)) continue
269
+ if (modelId !== undefined) {
270
+ // 键形如 supplier\u0000model\u0000uid —— 只在键开头含该 model 时命中
271
+ const prefix = `${this.supplierId}${SEP}${modelId}${SEP}`
272
+ if (!k.startsWith(prefix)) continue
273
+ }
274
+ if (ce.until > now) {
275
+ cooling = true
276
+ if (ce.until > maxUntil) {
277
+ maxUntil = ce.until
278
+ reason = ce.reason
279
+ }
280
+ }
281
+ if (ce.backoffLevel > err) err = ce.backoffLevel
282
+ }
151
283
  return {
152
284
  ...a,
153
285
  cooling,
154
- disabled: e?.disabled ?? false,
155
- err_count: e?.errCount ?? 0,
156
- until: cooling && e ? new Date(e.until).toISOString() : undefined,
157
- reason: e?.reason !== '' ? e?.reason : undefined,
286
+ disabled,
287
+ err_count: err,
288
+ until: cooling && maxUntil > 0 ? new Date(maxUntil).toISOString() : undefined,
289
+ reason: reason !== undefined && reason !== '' ? reason : undefined,
158
290
  }
159
291
  })
160
292
  }