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 +8 -8
- package/lib/client-registry.js +2 -6
- package/lib/client.js +2 -6
- package/lib/index.js +19 -44
- package/lib/types/router/account-pool.d.ts +11 -11
- package/lib/types/router/types.d.ts +0 -1
- package/lib/types/shared.d.ts +0 -1
- package/lib/types/suppliers/contract.d.ts +5 -5
- package/package.json +1 -1
- package/src/client/RouterView.tsx +1 -1
- package/src/client/SupplierDetail.tsx +1 -2
- package/src/router/account-pool.test.ts +11 -10
- package/src/router/account-pool.ts +46 -45
- package/src/router/models-cache.test.ts +0 -1
- package/src/router/types.ts +0 -1
- package/src/shared.ts +0 -1
- package/src/suppliers/contract.ts +5 -5
- package/src/suppliers/loader.ts +1 -1
- package/src/suppliers/registry.test.ts +3 -4
- package/src/suppliers/registry.ts +1 -1
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` / `
|
|
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
|
|
package/lib/client-registry.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
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
|
|
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)
|
|
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.
|
|
353
|
+
if (rule.cooldown === 0) return;
|
|
354
|
+
if (state === "session_dead") {
|
|
378
355
|
const e = this.uidState(uid);
|
|
379
|
-
|
|
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
|
|
421
|
-
|
|
422
|
-
let
|
|
423
|
-
let
|
|
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}
|
|
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
|
-
* -
|
|
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;
|
package/lib/types/shared.d.ts
CHANGED
|
@@ -30,7 +30,7 @@ export interface SupplierEnv {
|
|
|
30
30
|
* 不报上来,它就会继续留在池里被轮转选中,每次都白撞一次同一个错误。
|
|
31
31
|
*
|
|
32
32
|
* 核心实现 = `pool.noteFailure(uid, model, state, message)`,按该状态的规则
|
|
33
|
-
*
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
* -
|
|
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
|
-
|
|
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')?.
|
|
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')?.
|
|
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
|
-
* -
|
|
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,
|
|
61
|
-
rate_limit: { cooldown: 'backoff',
|
|
62
|
-
quota: { cooldown: 10 * MINUTE,
|
|
63
|
-
|
|
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',
|
|
66
|
-
transport: { cooldown: 'transient',
|
|
67
|
-
unknown: { cooldown: 'transient',
|
|
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,
|
|
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
|
-
/**
|
|
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,
|
|
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):
|
|
217
|
+
/** 该连接是否处于连接级冷却/手动暂停中(跨模型生效,到期自愈)。 */
|
|
218
|
+
private uidBlocked(uid: string, now: number): boolean {
|
|
211
219
|
const e = this.byUid.get(uid)
|
|
212
|
-
|
|
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
|
-
|
|
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
|
-
|
|
375
|
-
|
|
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
|
-
|
|
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
|
-
|
|
435
|
-
|
|
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 =
|
|
439
|
-
let maxUntil =
|
|
440
|
-
let reason =
|
|
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
|
/**
|
package/src/router/types.ts
CHANGED
package/src/shared.ts
CHANGED
|
@@ -31,7 +31,7 @@ export interface SupplierEnv {
|
|
|
31
31
|
* 不报上来,它就会继续留在池里被轮转选中,每次都白撞一次同一个错误。
|
|
32
32
|
*
|
|
33
33
|
* 核心实现 = `pool.noteFailure(uid, model, state, message)`,按该状态的规则
|
|
34
|
-
*
|
|
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[]
|
package/src/suppliers/loader.ts
CHANGED
|
@@ -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;
|
|
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('
|
|
111
|
+
test('签到:插件自标 disabled 的链接照样签(筛不筛由插件自己判定,核心不筛)', async () => {
|
|
113
112
|
const seen: string[] = []
|
|
114
113
|
const { body } = await postCheckin(
|
|
115
|
-
fixture([{ uid: 'a'
|
|
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}
|
|
69
|
+
return accounts.map((a) => `${a.uid}:${a.credits}:${a.cooling}`).join('|')
|
|
70
70
|
}
|
|
71
71
|
|
|
72
72
|
/**
|