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.
- package/docs/suppliers.md +8 -8
- package/lib/client-registry.js +57 -6
- package/lib/client.js +57 -6
- package/lib/index.js +158 -66
- package/lib/types/llm/adapter.d.ts +34 -8
- 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 +5 -1
- package/src/client/RouterView.tsx +1 -1
- package/src/client/SupplierDetail.tsx +1 -2
- package/src/client/index.tsx +3 -0
- package/src/client/model-hint-copy.test.ts +31 -0
- package/src/client/model-hint-copy.ts +55 -0
- package/src/index.ts +61 -2
- package/src/llm/adapter.test.ts +82 -0
- package/src/llm/adapter.ts +130 -17
- 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
|
@@ -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
|
/**
|