dsh-router-core 0.3.13 → 0.3.15

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,5 @@
1
1
  /**
2
- * 账号池 —— dsh-router 核心策略:选号、冷却、禁用、遍历回退。
2
+ * 账号池 —— dsh-router 核心策略:选号、冷却、遍历回退。
3
3
  *
4
4
  * 供应商插件只管「对单个账号调通上游」,账号层面的策略全在这里。
5
5
  *
@@ -11,7 +11,8 @@
11
11
  * 失败而对其它模型也不可用,这是错的。
12
12
  *
13
13
  * - 冷却/限流退避/瞬时 → 键 = (supplierId, modelId, uid),只冷「这个号 × 这个模型」
14
- * - 禁用(session_dead) → 键 = uid(登录态问题,该号所有模型都不可用)
14
+ * - 凭证失效(session_dead) → 键 = uid 连接级冷却(登录态问题,该号所有模型都不可用);
15
+ * **没有永久禁用**——403 分不清凭证死活与风控误伤,冷却到期自愈
15
16
  * - 手动冷却(面板) → 键 = uid(管理员整连接暂停)
16
17
  *
17
18
  * 为什么键里要带 supplierId:不同供应商**会有同名的模型 id**(如 traework 与
@@ -19,13 +20,13 @@
19
20
  * 会把跨供应商的同名模型串到一起。尽管本池实例当前是单供应商(loader 每供应商
20
21
  * 一个 AccountPool),键仍显式带 supplierId,语义自明且将来若池合并也安全。
21
22
  *
22
- * 天花板:冷却/禁用都是**内存态**,重启归零(与旧插件行为一致)。要跨重启保留
23
+ * 天花板:冷却都是**内存态**,重启归零(与旧插件行为一致)。要跨重启保留
23
24
  * 得落盘,届时加 stateFile 即可,接口不用变。
24
25
  */
25
26
  import type { AccountState, SupplierAccountNow } from '../suppliers/contract.ts'
26
27
  import { prefixFingerprint } from './prefix-affinity.ts'
27
28
 
28
- /** 各 AccountState 的处置:冷却策略 / 是否禁用 / 是否计入连续错误。 */
29
+ /** 各 AccountState 的处置:冷却策略 / 是否计入连续错误。 */
29
30
  interface Rule {
30
31
  /**
31
32
  * 冷却策略:
@@ -35,14 +36,19 @@ interface Rule {
35
36
  * - `0` 不冷却
36
37
  */
37
38
  cooldown: number | 'transient' | 'backoff'
38
- /** 是否永久禁用(需重新登录才能恢复)。 */
39
- disable: boolean
40
39
  /** 是否计入连续错误(攒够阈值自动冷却)。 */
41
40
  counts: boolean
42
41
  }
43
42
 
44
43
  const MINUTE = 60_000
45
44
 
45
+ /**
46
+ * 凭证失效(session_dead)的连接级冷却时长。
47
+ * 403 = 凭证死 or 风控误伤,上游语义分不清——取「足够长、但不永久」的窗口:
48
+ * 误伤号到点自愈(用户无需重启/重登),真死号每窗口只重试一次,不刷上游。
49
+ */
50
+ const SESSION_DEAD_COOLDOWN_MS = 30 * MINUTE
51
+
46
52
  /**
47
53
  * 状态 → 处置表。**核心策略就在这一张表里**,插件只报状态不做决策。
48
54
  *
@@ -57,16 +63,19 @@ const MINUTE = 60_000
57
63
  * 核心不必再猜文本,更准。
58
64
  */
59
65
  const RULES: Record<AccountState, Rule> = {
60
- ok: { cooldown: 0, disable: false, counts: false },
61
- rate_limit: { cooldown: 'backoff', disable: false, counts: false },
62
- quota: { cooldown: 10 * MINUTE, disable: false, counts: false },
63
- session_dead: { cooldown: 0, disable: true, counts: false },
66
+ ok: { cooldown: 0, counts: false },
67
+ rate_limit: { cooldown: 'backoff', counts: false },
68
+ quota: { cooldown: 10 * MINUTE, counts: false },
69
+ // 凭证失效也只做**连接级冷却**(跨该号所有模型),到期自愈:上游 403 常是
70
+ // 风控/WAF 拦截,与凭证死活分不清(codebuddy 三号被 403 误禁的教训)。
71
+ // 真死号损失 = 每窗口重试一次,可接受;误伤号到点自动回来,无需重启。
72
+ session_dead: { cooldown: SESSION_DEAD_COOLDOWN_MS, counts: false },
64
73
  // 404/服务下线、传输中断、未知:每次都瞬时短冷却(见上方 9router 对齐说明)
65
- unavailable: { cooldown: 'transient', disable: false, counts: false },
66
- transport: { cooldown: 'transient', disable: false, counts: false },
67
- unknown: { cooldown: 'transient', disable: false, counts: false },
74
+ unavailable: { cooldown: 'transient', counts: false },
75
+ transport: { cooldown: 'transient', counts: false },
76
+ unknown: { cooldown: 'transient', counts: false },
68
77
  // 模型不属于本供应商:不是账号的错,不冷却也不计数(核心据此换下一个供应商)
69
- no_such_model: { cooldown: 0, disable: false, counts: false },
78
+ no_such_model: { cooldown: 0, counts: false },
70
79
  }
71
80
 
72
81
  /**
@@ -112,10 +121,9 @@ interface CooldownEntry {
112
121
  reason: string
113
122
  }
114
123
 
115
- /** 连接级禁用/手动暂停记录(键 = uid)。 */
124
+ /** 连接级冷却/手动暂停记录(键 = uid,跨该号所有模型)。 */
116
125
  interface UidEntry {
117
126
  until: number
118
- disabled: boolean
119
127
  reason: string
120
128
  }
121
129
 
@@ -166,7 +174,7 @@ export class AccountPool {
166
174
  private supplierId: string
167
175
  /** (supplier, model, uid) → 冷却/退避。 */
168
176
  private cooldowns = new Map<string, CooldownEntry>()
169
- /** uid → 连接级禁用 / 手动暂停。 */
177
+ /** uid → 连接级冷却 / 手动暂停。 */
170
178
  private byUid = new Map<string, UidEntry>()
171
179
  private rrCursor = 0
172
180
  /** (supplier, model) → 当前驻留的号(只有**无亲和**的回退路径用它)。 */
@@ -196,33 +204,29 @@ export class AccountPool {
196
204
  return e
197
205
  }
198
206
 
199
- /** 某连接整体状态(禁用/手动暂停)。 */
207
+ /** 某连接整体状态(连接级冷却/手动暂停)。 */
200
208
  private uidState(uid: string): UidEntry {
201
209
  let e = this.byUid.get(uid)
202
210
  if (!e) {
203
- e = { until: 0, disabled: false, reason: '' }
211
+ e = { until: 0, reason: '' }
204
212
  this.byUid.set(uid, e)
205
213
  }
206
214
  return e
207
215
  }
208
216
 
209
- /** 该连接是否被禁用 / 手动整连接暂停中(跨模型生效)。 */
210
- private uidBlocked(uid: string, now: number): { blocked: boolean; disabled: boolean } {
217
+ /** 该连接是否处于连接级冷却/手动暂停中(跨模型生效,到期自愈)。 */
218
+ private uidBlocked(uid: string, now: number): boolean {
211
219
  const e = this.byUid.get(uid)
212
- if (!e) return { blocked: false, disabled: false }
213
- if (e.disabled) return { blocked: true, disabled: true }
214
- if (e.until > now) return { blocked: true, disabled: false }
215
- return { blocked: false, disabled: false }
220
+ return e !== undefined && e.until > now
216
221
  }
217
222
 
218
223
  /**
219
224
  * 给定要试的模型,该连接是否可服务:
220
- * - 未被连接级禁用/手动暂停(跨模型);
225
+ * - 不处于连接级冷却/手动暂停(跨模型,到期自愈);
221
226
  * - 且此 (supplier, model, uid) 冷却单元不在冷却中。
222
227
  */
223
228
  private healthy(uid: string, model: string, now: number): boolean {
224
- const b = this.uidBlocked(uid, now)
225
- if (b.blocked) return false
229
+ if (this.uidBlocked(uid, now)) return false
226
230
  const c = this.cooldowns.get(this.key(model, uid))
227
231
  if (!c) return true
228
232
  return c.until <= now
@@ -367,18 +371,19 @@ export class AccountPool {
367
371
  return `${this.supplierId}${SEP}${modelId}`
368
372
  }
369
373
 
370
- /** 记录一次失败:按状态处置(冷却/禁用/退避),落在「(模型, 连接)」这个冷却单元上。 */
374
+ /** 记录一次失败:按状态处置(冷却/退避),落在「(模型, 连接)」这个冷却单元上。 */
371
375
  noteFailure(uid: string, modelId: string, state: AccountState, message: string): void {
372
376
  const rule = RULES[state]
373
- // 禁用 / 手动暂停是连接级(登录态问题,跨模型)——但这里只处理规则本身
374
- if (rule.disable) {
375
- // session_dead:整个连接禁用(跨该号所有模型)
377
+ if (rule.cooldown === 0) return // no_such_model 等:不冷不记
378
+ // session_dead 是连接级冷却(登录态问题,跨该号所有模型),到期自愈——
379
+ // 不再有「永久禁用」:403 分不清凭证死活,误伤号不该被钉死
380
+ if (state === 'session_dead') {
376
381
  const e = this.uidState(uid)
377
- e.disabled = true
382
+ const cd = rule.cooldown
383
+ e.until = Math.max(e.until, Date.now() + (typeof cd === 'number' ? cd : 0))
378
384
  e.reason = message
379
385
  return
380
386
  }
381
- if (rule.cooldown === 0) return // no_such_model 等:不冷不记
382
387
  // 冷却/退避:记在 (model, uid) 单元上,不动该号其它模型
383
388
  const e = this.entry(modelId, uid)
384
389
  e.reason = message
@@ -396,7 +401,7 @@ export class AccountPool {
396
401
  e.until = Math.max(e.until, Date.now() + rule.cooldown)
397
402
  }
398
403
 
399
- /** 记录一次成功:清零「该模型」的限流退避等级(好号不背历史惩罚)。不解除禁用。 */
404
+ /** 记录一次成功:清零「该模型」的限流退避等级(好号不背历史惩罚)。 */
400
405
  noteSuccess(uid: string, modelId: string): void {
401
406
  const e = this.cooldowns.get(this.key(modelId, uid))
402
407
  if (e) e.backoffLevel = 0
@@ -408,13 +413,12 @@ export class AccountPool {
408
413
  e.until = Date.now() + ms
409
414
  e.reason = reason
410
415
  // 手动暂停通常是想清掉这个号上的累积惩罚:把它的退避等级清零
411
- // (disabled 不可由此解除——需重新登录)
412
416
  for (const [k, ce] of this.cooldowns) {
413
417
  if (k.endsWith(`${SEP}${uid}`)) ce.backoffLevel = 0
414
418
  }
415
419
  }
416
420
 
417
- /** 把核心的冷却/禁用叠加到插件报的「现在状态」上,产出面板态。
421
+ /** 把核心的冷却叠加到插件报的「现在状态」上,产出面板态。
418
422
  *
419
423
  * 冷却现在是 (模型, 连接) 粒度的,而面板状态没有「当前模型」上下文,
420
424
  * 这里默认**聚合**:只要该连接在任一模型上有活跃冷却,就显示 cooling=true
@@ -423,7 +427,6 @@ export class AccountPool {
423
427
  */
424
428
  decorate(accounts: SupplierAccountNow[], modelId?: string): Array<SupplierAccountNow & {
425
429
  cooling: boolean
426
- disabled: boolean
427
430
  err_count: number
428
431
  until?: string
429
432
  reason?: string
@@ -431,13 +434,12 @@ export class AccountPool {
431
434
  const now = Date.now()
432
435
  return accounts.map((a) => {
433
436
  const u = this.byUid.get(a.uid)
434
- const disabled = u?.disabled ?? false
435
- // 连接级手动暂停或禁用也算不可服务;但 disabled 单列,冷却不算它
436
- const manualCooling = u !== undefined && !u.disabled && u.until > now
437
+ // 连接级冷却(session_dead 处置 / 手动暂停)也算 cooling
438
+ const uidCooling = u !== undefined && u.until > now
437
439
 
438
- let cooling = manualCooling
439
- let maxUntil = manualCooling ? u!.until : 0
440
- let reason = manualCooling ? u!.reason : undefined
440
+ let cooling = uidCooling
441
+ let maxUntil = uidCooling ? u!.until : 0
442
+ let reason = uidCooling ? u!.reason : undefined
441
443
  let err = 0
442
444
  // 遍历本连接在所有模型上的冷却单元(或仅指定模型)
443
445
  for (const [k, ce] of this.cooldowns) {
@@ -459,7 +461,6 @@ export class AccountPool {
459
461
  return {
460
462
  ...a,
461
463
  cooling,
462
- disabled,
463
464
  err_count: err,
464
465
  until: cooling && maxUntil > 0 ? new Date(maxUntil).toISOString() : undefined,
465
466
  reason: reason !== undefined && reason !== '' ? reason : undefined,
@@ -613,7 +613,6 @@ test('组合请求不污染无关供应商的账号(no_such_model 不记账)
613
613
  const acc = other.s.pool.decorate([{ uid: 'u1', credits: 0, state: 'ok' }])[0]
614
614
  assert.equal(acc?.err_count, 0, '不该累计连续错误')
615
615
  assert.equal(acc?.cooling, false, '不该被冷却')
616
- assert.equal(acc?.disabled, false, '不该被禁用')
617
616
  })
618
617
 
619
618
  /**
@@ -14,7 +14,6 @@ export interface SupplierAccount {
14
14
  cooling: boolean
15
15
  until?: string
16
16
  reason?: string
17
- disabled: boolean
18
17
  err_count?: number
19
18
  }
20
19
 
package/src/shared.ts CHANGED
@@ -28,7 +28,6 @@ export interface RouterAccount {
28
28
  cooling: boolean
29
29
  until?: string
30
30
  reason?: string
31
- disabled: boolean
32
31
  err_count?: number
33
32
  /** 所属供应商 id。 */
34
33
  supplier?: string
@@ -31,7 +31,7 @@ export interface SupplierEnv {
31
31
  * 不报上来,它就会继续留在池里被轮转选中,每次都白撞一次同一个错误。
32
32
  *
33
33
  * 核心实现 = `pool.noteFailure(uid, model, state, message)`,按该状态的规则
34
- * 冷却/禁用。真实案例:TRAE 免费通道对某账号拒绝某模型时发 HTTP 200 +
34
+ * 冷却。真实案例:TRAE 免费通道对某账号拒绝某模型时发 HTTP 200 +
35
35
  * 流内 `code=4008`,三个号里两个如此,round-robin 下 2/3 请求直接失败
36
36
  * (2026-09-08 实测)——有了这条通道,坏号被冷一次就退出轮转。
37
37
  *
@@ -46,7 +46,7 @@ export type SupplierFactory = (env: SupplierEnv) => SupplierModule
46
46
  /**
47
47
  * 账户此刻的状态 —— 插件**解读**上游信号后的语义状态。
48
48
  *
49
- * 插件只报「现在怎么了」,不说「该怎么办」:冷却多久、是否禁用、要不要换号
49
+ * 插件只报「现在怎么了」,不说「该怎么办」:冷却多久、要不要换号
50
50
  * 都是核心的策略,见 `AccountState` 各值的处置表(docs/suppliers.md)。
51
51
  *
52
52
  * 天花板:目前是固定枚举。若将来某供应商需要更细的语义,再加值而不是放宽成
@@ -68,7 +68,7 @@ export type AccountState =
68
68
  */
69
69
  | 'no_such_model'
70
70
 
71
- /** 面板展示的账号此刻状态(插件只报它观察到的部分,冷却/禁用由核心叠加)。 */
71
+ /** 面板展示的账号此刻状态(插件只报它观察到的部分,冷却由核心叠加)。 */
72
72
  export interface SupplierAccountNow {
73
73
  uid: string
74
74
  nickname?: string
@@ -98,7 +98,7 @@ export type ChatOnceResult =
98
98
  | { ok: true; stream: ReadableStream<Uint8Array> }
99
99
  /** 非流式:核心写 JSON。 */
100
100
  | { ok: true; status: number; body: string }
101
- /** 失败:核心据此做冷却/禁用/换号。 */
101
+ /** 失败:核心据此做冷却/换号。 */
102
102
  | { ok: false; state: AccountState; message: string }
103
103
 
104
104
  /** 供应商模块 —— 契约(核心必须,差异化可选)。 */
@@ -120,7 +120,7 @@ export interface SupplierModule {
120
120
  readonly apiKeyHint?: string
121
121
 
122
122
  // ---- 核心(必须) ----
123
- /** 报账号「现在状态」。冷却/禁用/错误累计由核心叠加,插件不算这些。 */
123
+ /** 报账号「现在状态」。冷却/错误累计由核心叠加,插件不算这些。 */
124
124
  status(): SupplierStatusNow
125
125
  /** 模型来源。`force=true` 时强制刷新来源(核心「获取模型」按钮调用)。 */
126
126
  listModels(force?: boolean): Promise<ModelInfo[]> | ModelInfo[]
@@ -124,7 +124,7 @@ export function wrapModule(instance: SupplierModule, env: SupplierEnv, source: s
124
124
  if (isFn((instance as unknown as Record<string, unknown>)[key])) capabilities.add(key)
125
125
  }
126
126
 
127
- // 账号池由核心持有(选号/冷却/禁用/错误累计),插件不参与。
127
+ // 账号池由核心持有(选号/冷却/错误累计),插件不参与。
128
128
  // 挂在 supplier 上:status() 叠加状态与 Router 的 chat 循环共用同一实例。
129
129
  // 冷却键带 supplierId(跨供应商同名模型不串),故构造时注入本供应商 id。
130
130
  const pool = new AccountPool(instance.id)
@@ -30,7 +30,7 @@ function fakeRes(): { status: number; body: unknown; res: never } {
30
30
  return { get status() { return out.status }, get body() { return out.body }, res: res as never }
31
31
  }
32
32
 
33
- type FakeAccount = { uid: string; disabled?: boolean; credits?: number }
33
+ type FakeAccount = { uid: string; credits?: number }
34
34
 
35
35
  /** 造一个只带 checkinNow 的假供应商:status() 返回账号列表,checkinNow 按 uid 应答。
36
36
  * accounts 可传函数(每次 status() 重新求值,用来模拟插件后台异步刷积分)。 */
@@ -50,7 +50,6 @@ function fixture(
50
50
  nickname: a.uid,
51
51
  credits: a.credits ?? 0,
52
52
  cooling: false,
53
- disabled: a.disabled ?? false,
54
53
  err_count: 0,
55
54
  })),
56
55
  }),
@@ -109,10 +108,10 @@ test('签到:遍历所有链接,逐个调 checkinNow(uid)', async () => {
109
108
  assert.deepEqual(body.results?.map((r) => r.uid), ['a', 'b'])
110
109
  })
111
110
 
112
- test('签到:禁用链接照样签(禁用由插件自己判定,核心不筛)', async () => {
111
+ test('签到:插件自标 disabled 的链接照样签(筛不筛由插件自己判定,核心不筛)', async () => {
113
112
  const seen: string[] = []
114
113
  const { body } = await postCheckin(
115
- fixture([{ uid: 'a', disabled: true }, { uid: 'b' }], async (uid) => {
114
+ fixture([{ uid: 'a' }, { uid: 'b' }], async (uid) => {
116
115
  seen.push(uid)
117
116
  // 插件(如 traework)自己会对禁用链接返回 status:'disabled'
118
117
  return uid === 'a' ? { ok: false, status: 'disabled', message: '链接已禁用' } : { ok: true, status: 'ok' }
@@ -66,7 +66,7 @@ function cap(set: Set<string>, key: string): boolean {
66
66
 
67
67
  /** 账号状态指纹(积分 + 健康),用于判断刷新是否落地。 */
68
68
  function fingerprint(accounts: SupplierStatus['accounts']): string {
69
- return accounts.map((a) => `${a.uid}:${a.credits}:${a.cooling}:${a.disabled}`).join('|')
69
+ return accounts.map((a) => `${a.uid}:${a.credits}:${a.cooling}`).join('|')
70
70
  }
71
71
 
72
72
  /**