dsh-router-core 0.3.14 → 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.
package/docs/suppliers.md CHANGED
@@ -14,7 +14,7 @@
14
14
  | | 谁负责 |
15
15
  |---|---|
16
16
  | 组合回退、按策略选中模型 | 核心 |
17
- | **账号池**:选号、冷却、禁用、遍历回退 | 核心 |
17
+ | **账号池**:选号、冷却、遍历回退 | 核心 |
18
18
  | 响应写入(状态行、SSE 头、JSON body) | 核心 |
19
19
  | 模型启用/禁用、自定义模型、别名 | 核心 |
20
20
  | 凭证存储(`env.credentials`) | 核心 |
@@ -75,7 +75,7 @@ js 报告**此刻**观察到的账户状况,而不是把上游原始响应丢给
75
75
  | `ok` | 正常 |
76
76
  | `rate_limit` | 限流(429 类) |
77
77
  | `quota` | 额度/权益不足 |
78
- | `session_dead` | 凭证彻底失效,必须重新登录才能恢复 |
78
+ | `session_dead` | 凭证失效/凭证被上游拒(403 类,含风控误伤) |
79
79
  | `unavailable` | 上游不可用(404 / 服务下线) |
80
80
  | `transport` | 网络/连接层失败(没拿到 HTTP 状态) |
81
81
  | `unknown` | 说不清是什么错 |
@@ -86,11 +86,11 @@ js 报告**此刻**观察到的账户状况,而不是把上游原始响应丢给
86
86
  都报一次,于是一个都选不出来(表现为「组合越用越没号可用」)。所以「这个模型
87
87
  不属于我」一定要报 `no_such_model`,核心才知道该换供应商而不是换账号。
88
88
 
89
- **js 只说「现在怎么了」,不说「该怎么办」。** 冷却多久、是否禁用、要不要换号
89
+ **js 只说「现在怎么了」,不说「该怎么办」。** 冷却多久、要不要换号
90
90
  都是核心的策略(见下),js 不该自己记冷却表——那样每个插件都会长出一份
91
91
  互相不一致的实现。
92
92
 
93
- 核心在 `status()` 之上叠加 `cooling` / `disabled` / `err_count` / `until` / `reason`
93
+ 核心在 `status()` 之上叠加 `cooling` / `err_count` / `until` / `reason`
94
94
  再给面板,所以 js 不用(也不该)填这些字段。`accounts` 为空数组 = 无账号供应商。
95
95
  注意 `err_count` 现在是**限流退避等级**(反复被限流的号等级递增),不是
96
96
  「连续失败次数」——面板按等级展示,等级在成功后清零。
@@ -101,7 +101,7 @@ js 只要知道这一条:**报 `rate_limit`/`transport`/`unknown` 只影响「
101
101
  模型」**,不会牵连该号上的其它模型。一个连接服务多个模型时,模型 A 被限流不该
102
102
  让模型 B 也不可用。
103
103
 
104
- 例外是 `session_dead`:凭证失效是连接级问题,该号**所有模型**都停用。
104
+ 例外是 `session_dead`:凭证失效是连接级问题,该号**所有模型**都停用(连接级冷却,到期自愈)。
105
105
 
106
106
  ## `chatOnce(uid, req)`:一次只服务一个账号
107
107
 
@@ -124,7 +124,7 @@ type ChatOnceResult =
124
124
  |---|---|
125
125
  | `rate_limit` | **指数退避**:首次 2s,每次翻倍,上限 5 分钟;成功后等级清零 |
126
126
  | `quota` | 冷却 10 分钟 |
127
- | `session_dead` | **永久禁用该号**(所有模型都停用,需重新登录才能恢复) |
127
+ | `session_dead` | **连接级冷却 30 分钟**(该号所有模型都停用,到期自愈;没有永久禁用——403 分不清凭证死活与风控误伤) |
128
128
  | `unavailable` / `transport` / `unknown` | 瞬时短冷却 30s(**每次都冷**,不攒次数) |
129
129
  | `no_such_model` | **不惩罚账号**,核心直接跳过整个供应商换下一个 |
130
130
 
@@ -137,7 +137,7 @@ type ChatOnceResult =
137
137
  「请求参数非法」「上游临时不可用」)建议归 `rate_limit`——瞬时/请求类的错,
138
138
  短冷换号比长冷划算。
139
139
 
140
- 冷却/禁用/退避等级都是**内存态**,重启归零(与凭证、积分的持久化不同)。
140
+ 冷却/退避等级都是**内存态**,重启归零(与凭证、积分的持久化不同)。
141
141
  重启后所有号都会重新被选中一次,所以**上游确实挂了的号会各撞一次**,属预期。
142
142
  要跨重启保留,届时给账号池加一个落盘 state 文件即可,接口不用变。
143
143
 
@@ -274,7 +274,7 @@ export const id = 'echo'
274
274
  export const name = 'Echo'
275
275
  export const priority = 10
276
276
 
277
- // 只报「现在状态」:冷却/禁用/退避等级由核心叠加,积分由核心持久化
277
+ // 只报「现在状态」:冷却/退避等级由核心叠加,积分由核心持久化
278
278
  // credits: -1 = 不支持/没拉到(别写 0,0 会被当成「用完了」存下来)
279
279
  const accounts = [{ uid: 'echo-1', nickname: 'Echo 1', credits: -1, state: 'ok' }]
280
280
 
@@ -116,10 +116,6 @@ window.__ModuleLoader__.load({
116
116
  return `剩余 ${Math.ceil(minutes / 60)} 小时`;
117
117
  }
118
118
  function statusBadge(account) {
119
- if (account.disabled) return {
120
- text: "已禁用",
121
- cls: "dshr-badge-danger"
122
- };
123
119
  if (account.cooling) return {
124
120
  text: "冷却中",
125
121
  cls: "dshr-badge-warn"
@@ -768,7 +764,7 @@ window.__ModuleLoader__.load({
768
764
  className: "dshr-linkList",
769
765
  children: orderedAccounts.map((account, index) => {
770
766
  const badge = statusBadge(account);
771
- const coolingText = account.cooling || account.disabled ? `${account.reason ?? ""}${untilLabel(account) !== "" ? ` · ${untilLabel(account)}` : ""}` : "";
767
+ const coolingText = account.cooling ? `${account.reason ?? ""}${untilLabel(account) !== "" ? ` · ${untilLabel(account)}` : ""}` : "";
772
768
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
773
769
  className: `dshr-linkRow${dragIndex === index ? " dshr-linkRowDragging" : ""}`,
774
770
  draggable: renameUid !== account.uid,
@@ -3585,7 +3581,7 @@ window.__ModuleLoader__.load({
3585
3581
  className: "dshr-supplierGrid",
3586
3582
  children: group.map((supplier) => {
3587
3583
  const supplierAccounts = accounts.filter((a) => a.supplier === supplier.id);
3588
- const healthy = supplierAccounts.filter((a) => !a.cooling && !a.disabled).length;
3584
+ const healthy = supplierAccounts.filter((a) => !a.cooling).length;
3589
3585
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
3590
3586
  className: "dshr-supplierCard",
3591
3587
  onClick: () => setDetailSupplier(supplier),
package/lib/client.js CHANGED
@@ -116,10 +116,6 @@ window.__ModuleLoader__.load({
116
116
  return `剩余 ${Math.ceil(minutes / 60)} 小时`;
117
117
  }
118
118
  function statusBadge(account) {
119
- if (account.disabled) return {
120
- text: "已禁用",
121
- cls: "dshr-badge-danger"
122
- };
123
119
  if (account.cooling) return {
124
120
  text: "冷却中",
125
121
  cls: "dshr-badge-warn"
@@ -768,7 +764,7 @@ window.__ModuleLoader__.load({
768
764
  className: "dshr-linkList",
769
765
  children: orderedAccounts.map((account, index) => {
770
766
  const badge = statusBadge(account);
771
- const coolingText = account.cooling || account.disabled ? `${account.reason ?? ""}${untilLabel(account) !== "" ? ` · ${untilLabel(account)}` : ""}` : "";
767
+ const coolingText = account.cooling ? `${account.reason ?? ""}${untilLabel(account) !== "" ? ` · ${untilLabel(account)}` : ""}` : "";
772
768
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
773
769
  className: `dshr-linkRow${dragIndex === index ? " dshr-linkRowDragging" : ""}`,
774
770
  draggable: renameUid !== account.uid,
@@ -3585,7 +3581,7 @@ window.__ModuleLoader__.load({
3585
3581
  className: "dshr-supplierGrid",
3586
3582
  children: group.map((supplier) => {
3587
3583
  const supplierAccounts = accounts.filter((a) => a.supplier === supplier.id);
3588
- const healthy = supplierAccounts.filter((a) => !a.cooling && !a.disabled).length;
3584
+ const healthy = supplierAccounts.filter((a) => !a.cooling).length;
3589
3585
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
3590
3586
  className: "dshr-supplierCard",
3591
3587
  onClick: () => setDetailSupplier(supplier),
package/lib/index.js CHANGED
@@ -106,42 +106,34 @@ const MINUTE = 6e4;
106
106
  const RULES = {
107
107
  ok: {
108
108
  cooldown: 0,
109
- disable: false,
110
109
  counts: false
111
110
  },
112
111
  rate_limit: {
113
112
  cooldown: "backoff",
114
- disable: false,
115
113
  counts: false
116
114
  },
117
115
  quota: {
118
116
  cooldown: 10 * MINUTE,
119
- disable: false,
120
117
  counts: false
121
118
  },
122
119
  session_dead: {
123
- cooldown: 0,
124
- disable: true,
120
+ cooldown: 30 * MINUTE,
125
121
  counts: false
126
122
  },
127
123
  unavailable: {
128
124
  cooldown: "transient",
129
- disable: false,
130
125
  counts: false
131
126
  },
132
127
  transport: {
133
128
  cooldown: "transient",
134
- disable: false,
135
129
  counts: false
136
130
  },
137
131
  unknown: {
138
132
  cooldown: "transient",
139
- disable: false,
140
133
  counts: false
141
134
  },
142
135
  no_such_model: {
143
136
  cooldown: 0,
144
- disable: false,
145
137
  counts: false
146
138
  }
147
139
  };
@@ -183,7 +175,7 @@ var AccountPool = class {
183
175
  supplierId;
184
176
  /** (supplier, model, uid) → 冷却/退避。 */
185
177
  cooldowns = /* @__PURE__ */ new Map();
186
- /** uid → 连接级禁用 / 手动暂停。 */
178
+ /** uid → 连接级冷却 / 手动暂停。 */
187
179
  byUid = /* @__PURE__ */ new Map();
188
180
  rrCursor = 0;
189
181
  /** (supplier, model) → 当前驻留的号(只有**无亲和**的回退路径用它)。 */
@@ -213,46 +205,30 @@ var AccountPool = class {
213
205
  }
214
206
  return e;
215
207
  }
216
- /** 某连接整体状态(禁用/手动暂停)。 */
208
+ /** 某连接整体状态(连接级冷却/手动暂停)。 */
217
209
  uidState(uid) {
218
210
  let e = this.byUid.get(uid);
219
211
  if (!e) {
220
212
  e = {
221
213
  until: 0,
222
- disabled: false,
223
214
  reason: ""
224
215
  };
225
216
  this.byUid.set(uid, e);
226
217
  }
227
218
  return e;
228
219
  }
229
- /** 该连接是否被禁用 / 手动整连接暂停中(跨模型生效)。 */
220
+ /** 该连接是否处于连接级冷却/手动暂停中(跨模型生效,到期自愈)。 */
230
221
  uidBlocked(uid, now) {
231
222
  const e = this.byUid.get(uid);
232
- if (!e) return {
233
- blocked: false,
234
- disabled: false
235
- };
236
- if (e.disabled) return {
237
- blocked: true,
238
- disabled: true
239
- };
240
- if (e.until > now) return {
241
- blocked: true,
242
- disabled: false
243
- };
244
- return {
245
- blocked: false,
246
- disabled: false
247
- };
223
+ return e !== void 0 && e.until > now;
248
224
  }
249
225
  /**
250
226
  * 给定要试的模型,该连接是否可服务:
251
- * - 未被连接级禁用/手动暂停(跨模型);
227
+ * - 不处于连接级冷却/手动暂停(跨模型,到期自愈);
252
228
  * - 且此 (supplier, model, uid) 冷却单元不在冷却中。
253
229
  */
254
230
  healthy(uid, model, now) {
255
- if (this.uidBlocked(uid, now).blocked) return false;
231
+ if (this.uidBlocked(uid, now)) return false;
256
232
  const c = this.cooldowns.get(this.key(model, uid));
257
233
  if (!c) return true;
258
234
  return c.until <= now;
@@ -371,16 +347,17 @@ var AccountPool = class {
371
347
  blockKey(modelId) {
372
348
  return `${this.supplierId}${SEP}${modelId}`;
373
349
  }
374
- /** 记录一次失败:按状态处置(冷却/禁用/退避),落在「(模型, 连接)」这个冷却单元上。 */
350
+ /** 记录一次失败:按状态处置(冷却/退避),落在「(模型, 连接)」这个冷却单元上。 */
375
351
  noteFailure(uid, modelId, state, message) {
376
352
  const rule = RULES[state];
377
- if (rule.disable) {
353
+ if (rule.cooldown === 0) return;
354
+ if (state === "session_dead") {
378
355
  const e = this.uidState(uid);
379
- e.disabled = true;
356
+ const cd = rule.cooldown;
357
+ e.until = Math.max(e.until, Date.now() + (typeof cd === "number" ? cd : 0));
380
358
  e.reason = message;
381
359
  return;
382
360
  }
383
- if (rule.cooldown === 0) return;
384
361
  const e = this.entry(modelId, uid);
385
362
  e.reason = message;
386
363
  if (rule.cooldown === "backoff") {
@@ -394,7 +371,7 @@ var AccountPool = class {
394
371
  }
395
372
  e.until = Math.max(e.until, Date.now() + rule.cooldown);
396
373
  }
397
- /** 记录一次成功:清零「该模型」的限流退避等级(好号不背历史惩罚)。不解除禁用。 */
374
+ /** 记录一次成功:清零「该模型」的限流退避等级(好号不背历史惩罚)。 */
398
375
  noteSuccess(uid, modelId) {
399
376
  const e = this.cooldowns.get(this.key(modelId, uid));
400
377
  if (e) e.backoffLevel = 0;
@@ -406,7 +383,7 @@ var AccountPool = class {
406
383
  e.reason = reason;
407
384
  for (const [k, ce] of this.cooldowns) if (k.endsWith(`${SEP}${uid}`)) ce.backoffLevel = 0;
408
385
  }
409
- /** 把核心的冷却/禁用叠加到插件报的「现在状态」上,产出面板态。
386
+ /** 把核心的冷却叠加到插件报的「现在状态」上,产出面板态。
410
387
  *
411
388
  * 冷却现在是 (模型, 连接) 粒度的,而面板状态没有「当前模型」上下文,
412
389
  * 这里默认**聚合**:只要该连接在任一模型上有活跃冷却,就显示 cooling=true
@@ -417,11 +394,10 @@ var AccountPool = class {
417
394
  const now = Date.now();
418
395
  return accounts.map((a) => {
419
396
  const u = this.byUid.get(a.uid);
420
- const disabled = u?.disabled ?? false;
421
- const manualCooling = u !== void 0 && !u.disabled && u.until > now;
422
- let cooling = manualCooling;
423
- let maxUntil = manualCooling ? u.until : 0;
424
- let reason = manualCooling ? u.reason : void 0;
397
+ const uidCooling = u !== void 0 && u.until > now;
398
+ let cooling = uidCooling;
399
+ let maxUntil = uidCooling ? u.until : 0;
400
+ let reason = uidCooling ? u.reason : void 0;
425
401
  let err = 0;
426
402
  for (const [k, ce] of this.cooldowns) {
427
403
  if (!k.endsWith(`${SEP}${a.uid}`)) continue;
@@ -441,7 +417,6 @@ var AccountPool = class {
441
417
  return {
442
418
  ...a,
443
419
  cooling,
444
- disabled,
445
420
  err_count: err,
446
421
  until: cooling && maxUntil > 0 ? new Date(maxUntil).toISOString() : void 0,
447
422
  reason: reason !== void 0 && reason !== "" ? reason : void 0
@@ -3207,7 +3182,7 @@ function cap(set, key) {
3207
3182
  }
3208
3183
  /** 账号状态指纹(积分 + 健康),用于判断刷新是否落地。 */
3209
3184
  function fingerprint(accounts) {
3210
- return accounts.map((a) => `${a.uid}:${a.credits}:${a.cooling}:${a.disabled}`).join("|");
3185
+ return accounts.map((a) => `${a.uid}:${a.credits}:${a.cooling}`).join("|");
3211
3186
  }
3212
3187
  /**
3213
3188
  * 反复调插件已有的 status(),直到快照稳定再返回(是否有变化 = changed)。
@@ -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,7 +20,7 @@
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';
@@ -28,7 +29,7 @@ export declare class AccountPool {
28
29
  private supplierId;
29
30
  /** (supplier, model, uid) → 冷却/退避。 */
30
31
  private cooldowns;
31
- /** uid → 连接级禁用 / 手动暂停。 */
32
+ /** uid → 连接级冷却 / 手动暂停。 */
32
33
  private byUid;
33
34
  private rrCursor;
34
35
  /** (supplier, model) → 当前驻留的号(只有**无亲和**的回退路径用它)。 */
@@ -42,13 +43,13 @@ export declare class AccountPool {
42
43
  constructor(supplierId?: string);
43
44
  private key;
44
45
  private entry;
45
- /** 某连接整体状态(禁用/手动暂停)。 */
46
+ /** 某连接整体状态(连接级冷却/手动暂停)。 */
46
47
  private uidState;
47
- /** 该连接是否被禁用 / 手动整连接暂停中(跨模型生效)。 */
48
+ /** 该连接是否处于连接级冷却/手动暂停中(跨模型生效,到期自愈)。 */
48
49
  private uidBlocked;
49
50
  /**
50
51
  * 给定要试的模型,该连接是否可服务:
51
- * - 未被连接级禁用/手动暂停(跨模型);
52
+ * - 不处于连接级冷却/手动暂停(跨模型,到期自愈);
52
53
  * - 且此 (supplier, model, uid) 冷却单元不在冷却中。
53
54
  */
54
55
  private healthy;
@@ -109,13 +110,13 @@ export declare class AccountPool {
109
110
  dropBlock(modelId: string): void;
110
111
  /** 块状态键:与冷却同粒度 (supplier, model),不含 uid。 */
111
112
  private blockKey;
112
- /** 记录一次失败:按状态处置(冷却/禁用/退避),落在「(模型, 连接)」这个冷却单元上。 */
113
+ /** 记录一次失败:按状态处置(冷却/退避),落在「(模型, 连接)」这个冷却单元上。 */
113
114
  noteFailure(uid: string, modelId: string, state: AccountState, message: string): void;
114
- /** 记录一次成功:清零「该模型」的限流退避等级(好号不背历史惩罚)。不解除禁用。 */
115
+ /** 记录一次成功:清零「该模型」的限流退避等级(好号不背历史惩罚)。 */
115
116
  noteSuccess(uid: string, modelId: string): void;
116
117
  /** 手动暂停整连接(面板/管理用途,跨模型)。ms<=0 表示解除。 */
117
118
  cooldown(uid: string, ms: number, reason: string): void;
118
- /** 把核心的冷却/禁用叠加到插件报的「现在状态」上,产出面板态。
119
+ /** 把核心的冷却叠加到插件报的「现在状态」上,产出面板态。
119
120
  *
120
121
  * 冷却现在是 (模型, 连接) 粒度的,而面板状态没有「当前模型」上下文,
121
122
  * 这里默认**聚合**:只要该连接在任一模型上有活跃冷却,就显示 cooling=true
@@ -124,7 +125,6 @@ export declare class AccountPool {
124
125
  */
125
126
  decorate(accounts: SupplierAccountNow[], modelId?: string): Array<SupplierAccountNow & {
126
127
  cooling: boolean;
127
- disabled: boolean;
128
128
  err_count: number;
129
129
  until?: string;
130
130
  reason?: string;
@@ -13,7 +13,6 @@ export interface SupplierAccount {
13
13
  cooling: boolean;
14
14
  until?: string;
15
15
  reason?: string;
16
- disabled: boolean;
17
16
  err_count?: number;
18
17
  }
19
18
  /** 供应商面板状态。 */
@@ -22,7 +22,6 @@ export interface RouterAccount {
22
22
  cooling: boolean;
23
23
  until?: string;
24
24
  reason?: string;
25
- disabled: boolean;
26
25
  err_count?: number;
27
26
  /** 所属供应商 id。 */
28
27
  supplier?: string;
@@ -30,7 +30,7 @@ export interface SupplierEnv {
30
30
  * 不报上来,它就会继续留在池里被轮转选中,每次都白撞一次同一个错误。
31
31
  *
32
32
  * 核心实现 = `pool.noteFailure(uid, model, state, message)`,按该状态的规则
33
- * 冷却/禁用。真实案例:TRAE 免费通道对某账号拒绝某模型时发 HTTP 200 +
33
+ * 冷却。真实案例:TRAE 免费通道对某账号拒绝某模型时发 HTTP 200 +
34
34
  * 流内 `code=4008`,三个号里两个如此,round-robin 下 2/3 请求直接失败
35
35
  * (2026-09-08 实测)——有了这条通道,坏号被冷一次就退出轮转。
36
36
  *
@@ -43,7 +43,7 @@ export type SupplierFactory = (env: SupplierEnv) => SupplierModule;
43
43
  /**
44
44
  * 账户此刻的状态 —— 插件**解读**上游信号后的语义状态。
45
45
  *
46
- * 插件只报「现在怎么了」,不说「该怎么办」:冷却多久、是否禁用、要不要换号
46
+ * 插件只报「现在怎么了」,不说「该怎么办」:冷却多久、要不要换号
47
47
  * 都是核心的策略,见 `AccountState` 各值的处置表(docs/suppliers.md)。
48
48
  *
49
49
  * 天花板:目前是固定枚举。若将来某供应商需要更细的语义,再加值而不是放宽成
@@ -57,7 +57,7 @@ export type AccountState = 'ok' | 'rate_limit' | 'quota' | 'session_dead' | 'una
57
57
  * 把它冷却掉(历史上正是这个坑)。
58
58
  */
59
59
  | 'no_such_model';
60
- /** 面板展示的账号此刻状态(插件只报它观察到的部分,冷却/禁用由核心叠加)。 */
60
+ /** 面板展示的账号此刻状态(插件只报它观察到的部分,冷却由核心叠加)。 */
61
61
  export interface SupplierAccountNow {
62
62
  uid: string;
63
63
  nickname?: string;
@@ -92,7 +92,7 @@ export type ChatOnceResult =
92
92
  status: number;
93
93
  body: string;
94
94
  }
95
- /** 失败:核心据此做冷却/禁用/换号。 */
95
+ /** 失败:核心据此做冷却/换号。 */
96
96
  | {
97
97
  ok: false;
98
98
  state: AccountState;
@@ -114,7 +114,7 @@ export interface SupplierModule {
114
114
  * 应提供它——否则所有供应商弹窗都显示同一句通用提示(如默认会写 OpenRouter)。
115
115
  */
116
116
  readonly apiKeyHint?: string;
117
- /** 报账号「现在状态」。冷却/禁用/错误累计由核心叠加,插件不算这些。 */
117
+ /** 报账号「现在状态」。冷却/错误累计由核心叠加,插件不算这些。 */
118
118
  status(): SupplierStatusNow;
119
119
  /** 模型来源。`force=true` 时强制刷新来源(核心「获取模型」按钮调用)。 */
120
120
  listModels(force?: boolean): Promise<ModelInfo[]> | ModelInfo[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-router-core",
3
- "version": "0.3.14",
3
+ "version": "0.3.15",
4
4
  "description": "DSH plugin: a simplified 9router (AI routing gateway). Adds a 路由系统 (Routing System) sidebar entry beside 记忆系统; the panel shows suppliers, account pool, combos, and endpoint/API keys.",
5
5
  "type": "module",
6
6
  "repository": {
@@ -100,7 +100,7 @@ export function RouterView({ onBack }: RouterViewProps): JSX.Element {
100
100
  <div className="dshr-supplierGrid">
101
101
  {group.map(supplier => {
102
102
  const supplierAccounts = accounts.filter(a => a.supplier === supplier.id)
103
- const healthy = supplierAccounts.filter(a => !a.cooling && !a.disabled).length
103
+ const healthy = supplierAccounts.filter(a => !a.cooling).length
104
104
  return (
105
105
  <section key={supplier.id} className="dshr-supplierCard" onClick={() => setDetailSupplier(supplier)}>
106
106
  <div className="dshr-supplierRow">
@@ -62,7 +62,6 @@ function untilLabel(account: RouterAccount): string {
62
62
  }
63
63
 
64
64
  function statusBadge(account: RouterAccount): { text: string; cls: string } {
65
- if (account.disabled) return { text: '已禁用', cls: 'dshr-badge-danger' }
66
65
  if (account.cooling) return { text: '冷却中', cls: 'dshr-badge-warn' }
67
66
  return { text: '健康', cls: 'dshr-badge-ok' }
68
67
  }
@@ -666,7 +665,7 @@ export function SupplierDetail({ supplier, accounts, statusLoading, onBack, onRe
666
665
  <div className="dshr-linkList">
667
666
  {orderedAccounts.map((account, index) => {
668
667
  const badge = statusBadge(account)
669
- const coolingText = account.cooling || account.disabled
668
+ const coolingText = account.cooling
670
669
  ? `${account.reason ?? ''}${untilLabel(account) !== '' ? ` · ${untilLabel(account)}` : ''}`
671
670
  : ''
672
671
  return (
@@ -1,10 +1,11 @@
1
1
  /**
2
- * 账号池(核心策略)测试 —— 选号/冷却/禁用/遍历回退。
2
+ * 账号池(核心策略)测试 —— 选号/冷却/遍历回退。
3
3
  *
4
4
  * 关键语义(v2):
5
5
  * - 冷却单元 = (supplierId, modelId, uid)。某连接在模型 A 上失败,只冷
6
6
  * (A, 该连接),该连接调模型 B 不受影响。
7
- * - 禁用(session_dead) = 连接级(uid):该号所有模型都不可用。
7
+ * - session_dead = 连接级(uid)可恢复冷却:该号所有模型都不可用,到期自愈。
8
+ * **没有永久禁用**——403 分不清凭证死活与风控误伤,误伤号不该被钉死。
8
9
  * - no_such_model 不惩罚账号(模型不属于本供应商不是号的错)。
9
10
  * 用 node --test 跑(Node 原生 TS 剥离,零新依赖)。
10
11
  */
@@ -327,16 +328,18 @@ test('【颗粒度】同一连接对不同模型的退避独立累加/清零', (
327
328
  assert.equal(p.decorate(list, 'm1')[0]?.err_count, 0)
328
329
  })
329
330
 
330
- // ============ 连接级禁用(跨模型) ============
331
+ // ============ 连接级冷却(跨模型, session_dead) ============
331
332
 
332
- test('【颗粒度】session_dead 是连接级禁用:跨该号所有模型', () => {
333
+ test('【颗粒度】session_dead 是连接级冷却:跨该号所有模型,到期自愈', () => {
333
334
  const p = pool()
334
335
  const list = accs(['a'])
335
336
  p.noteFailure('a', 'm1', 'session_dead', '凭证失效')
336
337
  // 该号在 m1、m2 上都不可用(登录态问题,不是模型问题)
337
338
  assert.equal(p.pick(list, [], 'fallback', 'm1'), undefined)
338
339
  assert.equal(p.pick(list, [], 'fallback', 'm2'), undefined)
339
- assert.equal(p.decorate(list)[0]?.disabled, true)
340
+ const d = p.decorate(list)[0]
341
+ assert.equal(d?.cooling, true)
342
+ assert.ok(d?.until !== undefined, 'session_dead 冷却必须有 until(可恢复)')
340
343
  })
341
344
 
342
345
  test('处置:未知错误第一次就冷却(坏号不留在池里等下一次撞)', () => {
@@ -347,7 +350,7 @@ test('处置:未知错误第一次就冷却(坏号不留在池里等下一
347
350
  assert.equal(p.pick(list.filter((x) => x.uid === 'a'), [], 'fallback', 'm1'), undefined)
348
351
  })
349
352
 
350
- test('decorate:把冷却/禁用叠加到插件报的「现在状态」上(聚合展示)', () => {
353
+ test('decorate:把冷却叠加到插件报的「现在状态」上(聚合展示)', () => {
351
354
  const p = pool()
352
355
  const list: SupplierAccountNow[] = [
353
356
  { uid: 'ok', credits: 5, state: 'ok' },
@@ -359,10 +362,9 @@ test('decorate:把冷却/禁用叠加到插件报的「现在状态」上(
359
362
  const out = p.decorate(list)
360
363
  const by = new Map(out.map((a) => [a.uid, a]))
361
364
  assert.equal(by.get('ok')?.cooling, false)
362
- assert.equal(by.get('ok')?.disabled, false)
363
365
  assert.equal(by.get('cool')?.cooling, true)
364
366
  assert.equal(by.get('cool')?.until !== undefined, true)
365
- assert.equal(by.get('dead')?.disabled, true)
367
+ assert.equal(by.get('dead')?.cooling, true, 'session_dead 连接级冷却也展示为冷却中')
366
368
  // 插件报的字段要原样透出(不能被 decorate 吃掉)
367
369
  assert.equal(by.get('ok')?.credits, 5)
368
370
  })
@@ -375,7 +377,6 @@ test('no_such_model 不惩罚账号:不冷却、不计数', () => {
375
377
  assert.equal(p.pick(list, [], 'fallback', 'm1'), 'a')
376
378
  const out = p.decorate(list, 'm1')[0]
377
379
  assert.equal(out?.cooling, false)
378
- assert.equal(out?.disabled, false)
379
380
  assert.equal(out?.err_count, 0)
380
381
  })
381
382
 
@@ -389,7 +390,7 @@ test('状态表:各 AccountState 的处置符合预期', () => {
389
390
  const by = new Map(p.decorate(list).map((a) => [a.uid, a]))
390
391
  assert.equal(by.get('rate')?.cooling, true)
391
392
  assert.equal(by.get('quota')?.cooling, true)
392
- assert.equal(by.get('dead')?.disabled, true)
393
+ assert.equal(by.get('dead')?.cooling, true, 'session_dead = 连接级冷却')
393
394
  assert.equal(by.get('unavail')?.cooling, true, '瞬时错误第一次就冷却')
394
395
  assert.equal(p.pick(list, [], 'fallback', 'm1'), undefined, '四个号都不可服务')
395
396
  })
@@ -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
  /**