dsh-router-core 0.1.0 → 0.3.0
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/README.md +58 -13
- package/lib/client-registry.js +824 -153
- package/lib/client.js +824 -153
- package/lib/index.js +1606 -227
- package/lib/types/llm/adapter.d.ts +2 -0
- package/lib/types/router/account-pool.d.ts +41 -0
- package/lib/types/router/index.d.ts +72 -4
- package/lib/types/router/types.d.ts +12 -10
- package/lib/types/router/usage-store.d.ts +93 -0
- package/lib/types/router/usage-tokens.d.ts +118 -0
- package/lib/types/shared.d.ts +69 -0
- package/lib/types/supplier-config.d.ts +15 -4
- package/lib/types/suppliers/contract.d.ts +70 -21
- package/lib/types/suppliers/registry.d.ts +4 -3
- package/package.json +2 -1
- package/src/client/CombosTab.tsx +28 -16
- package/src/client/RouterView.tsx +15 -2
- package/src/client/StatsTab.tsx +521 -0
- package/src/client/SupplierDetail.tsx +58 -34
- package/src/client/router.css +406 -0
- package/src/client/workspace-mount.tsx +2 -9
- package/src/index.ts +52 -4
- package/src/llm/adapter.test.ts +108 -0
- package/src/llm/adapter.ts +18 -6
- package/src/router/account-pool.test.ts +120 -0
- package/src/router/account-pool.ts +161 -0
- package/src/router/index.ts +652 -26
- package/src/router/models-cache.test.ts +455 -0
- package/src/router/precise-dispatch.test.ts +198 -0
- package/src/router/types.ts +12 -7
- package/src/router/usage-store.ts +499 -0
- package/src/router/usage-tokens.ts +327 -0
- package/src/router/usage.test.ts +558 -0
- package/src/shared.ts +77 -0
- package/src/supplier-config.ts +31 -14
- package/src/suppliers/contract.ts +71 -8
- package/src/suppliers/loader.ts +21 -13
- package/src/suppliers/nvidia/plugin.ts +161 -119
- package/src/suppliers/opencode/plugin.ts +47 -95
- package/src/suppliers/openrouter/plugin.ts +67 -122
- package/src/suppliers/registry.test.ts +194 -0
- package/src/suppliers/registry.ts +79 -59
package/src/index.ts
CHANGED
|
@@ -18,6 +18,9 @@
|
|
|
18
18
|
* PATCH /router/api/keys/:id toggle key {isActive}
|
|
19
19
|
* GET /router/api/settings { requireApiKey }
|
|
20
20
|
* PATCH /router/api/settings { requireApiKey }
|
|
21
|
+
* GET /router/api/stats usage overview (period=today|24h|7d|30d)
|
|
22
|
+
* GET /router/api/stats/chart usage bar chart buckets
|
|
23
|
+
* POST /router/api/stats/clear reset all usage stats
|
|
21
24
|
* POST /router/api/suppliers/:id/login generate login URL
|
|
22
25
|
* POST /router/api/suppliers/:id/login/callback {callbackUrl} → add account
|
|
23
26
|
* GET /router/api/suppliers/:id/models models with enabled state
|
|
@@ -28,7 +31,7 @@
|
|
|
28
31
|
*/
|
|
29
32
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
30
33
|
import { join, dirname } from 'node:path'
|
|
31
|
-
import { ROUTER_API_BASE } from './shared.ts'
|
|
34
|
+
import { ROUTER_API_BASE, type RouterPeriod } from './shared.ts'
|
|
32
35
|
import { Router } from './router/index.ts'
|
|
33
36
|
import { RouterAdapter } from './llm/adapter.ts'
|
|
34
37
|
import { KeysStore } from './keys.ts'
|
|
@@ -128,7 +131,7 @@ export function apply(rawContext: unknown): void {
|
|
|
128
131
|
const stateFile = process.env.TW2A_STATE_FILE ?? 'data/state.json'
|
|
129
132
|
const store = new SupplierConfigStore(stateFile)
|
|
130
133
|
const credentials = new CredentialStore(process.env.TW2A_AUTH_DIR ?? join(dirname(stateFile), 'auths'))
|
|
131
|
-
const router = new Router(stateFile)
|
|
134
|
+
const router = new Router(stateFile, store, log)
|
|
132
135
|
|
|
133
136
|
const keys = new KeysStore(stateFile)
|
|
134
137
|
|
|
@@ -146,7 +149,7 @@ export function apply(rawContext: unknown): void {
|
|
|
146
149
|
const supplierDisposers = new Map<string, Array<() => void>>()
|
|
147
150
|
const registerSupplierRoutes = (loaded: LoadedSupplier): void => {
|
|
148
151
|
const dis: Array<() => void> = []
|
|
149
|
-
for (const r of supplierRoutes(ROUTER_API_BASE, loaded, store)) {
|
|
152
|
+
for (const r of supplierRoutes(ROUTER_API_BASE, loaded, store, router)) {
|
|
150
153
|
dis.push(ctx.webServer.register(r))
|
|
151
154
|
}
|
|
152
155
|
supplierDisposers.set(loaded.supplier.id, dis)
|
|
@@ -405,6 +408,51 @@ export function apply(rawContext: unknown): void {
|
|
|
405
408
|
writeJson(res, 200, { ok: true, requireApiKey: keys.requireApiKey })
|
|
406
409
|
})
|
|
407
410
|
|
|
411
|
+
// ---- 概览看板:用量统计 ----
|
|
412
|
+
|
|
413
|
+
/** 解析并校验周期参数(不接受任意字符串)。 */
|
|
414
|
+
const periodOf = (raw: string | null): RouterPeriod => {
|
|
415
|
+
return raw === '24h' || raw === '7d' || raw === '30d' ? raw : 'today'
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
route(`${ROUTER_API_BASE}/stats`, (req, res) => {
|
|
419
|
+
const period = periodOf(new URL(req.url ?? '/', 'http://localhost').searchParams.get('period'))
|
|
420
|
+
const s = router.usage.stats(period)
|
|
421
|
+
writeJson(res, 200, {
|
|
422
|
+
ok: true,
|
|
423
|
+
period,
|
|
424
|
+
requests: s.requests,
|
|
425
|
+
okCount: s.ok,
|
|
426
|
+
failed: s.failed,
|
|
427
|
+
promptTokens: s.promptTokens,
|
|
428
|
+
completionTokens: s.completionTokens,
|
|
429
|
+
cachedTokens: s.cachedTokens,
|
|
430
|
+
avgDurationMs: s.avgDurationMs,
|
|
431
|
+
avgTtfbMs: s.avgTtfbMs,
|
|
432
|
+
estimatedInputs: s.estimatedInputs,
|
|
433
|
+
estimatedOutputs: s.estimatedOutputs,
|
|
434
|
+
lifetime: s.lifetime,
|
|
435
|
+
bySupplier: s.bySupplier,
|
|
436
|
+
byModel: s.byModel,
|
|
437
|
+
byRequested: s.byRequested,
|
|
438
|
+
recent: router.usage.recentList(20),
|
|
439
|
+
})
|
|
440
|
+
})
|
|
441
|
+
|
|
442
|
+
route(`${ROUTER_API_BASE}/stats/chart`, (req, res) => {
|
|
443
|
+
const period = periodOf(new URL(req.url ?? '/', 'http://localhost').searchParams.get('period'))
|
|
444
|
+
writeJson(res, 200, { ok: true, chart: router.usage.chart(period) })
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
route(`${ROUTER_API_BASE}/stats/clear`, async (req, res) => {
|
|
448
|
+
if (req.method !== 'POST') {
|
|
449
|
+
writeJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
450
|
+
return
|
|
451
|
+
}
|
|
452
|
+
router.usage.clear()
|
|
453
|
+
writeJson(res, 200, { ok: true })
|
|
454
|
+
})
|
|
455
|
+
|
|
408
456
|
// ---- 设置-模型:Router 提供方(固定卡片,插件注册,不可删)+ 模型目录(= 组合) ----
|
|
409
457
|
|
|
410
458
|
// llm 缺失时(如测试环境)跳过,不影响 /v1 核心。
|
|
@@ -461,7 +509,7 @@ export function apply(rawContext: unknown): void {
|
|
|
461
509
|
ctx.effect(
|
|
462
510
|
() => () => {
|
|
463
511
|
for (const dispose of disposers.splice(0)) dispose()
|
|
464
|
-
router.dispose()
|
|
512
|
+
router.dispose() // 内部会 flush 用量统计(防抖中的计数不能丢)
|
|
465
513
|
},
|
|
466
514
|
'dsh-router: teardown',
|
|
467
515
|
)
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RouterAdapter 的 usage 契约测试。
|
|
3
|
+
*
|
|
4
|
+
* 这些用例存在的原因:adapter 曾把上游 OpenAI 形态的 usage 原样透传给
|
|
5
|
+
* dsh-llm,导致会话日志里的 usage 全是 `prompt_tokens`(没有 `inputTokens`)。
|
|
6
|
+
* 下游 token-meter 读 `usage.inputTokens` 得 `undefined`,累加出 `NaN`,
|
|
7
|
+
* 投影 schema(`z.number().int().nonnegative()`)校验一抛,整条
|
|
8
|
+
* `session.history` RPC 失败 —— 用户看到的就是「历史加载失败」。
|
|
9
|
+
*
|
|
10
|
+
* 所以这里锁死两条:字段名对、口径对(DISJOINT)。
|
|
11
|
+
*/
|
|
12
|
+
import { test } from 'node:test'
|
|
13
|
+
import assert from 'node:assert/strict'
|
|
14
|
+
import { toTokenUsage } from '../router/usage-tokens.ts'
|
|
15
|
+
import { translateSse } from './adapter.ts'
|
|
16
|
+
|
|
17
|
+
/** 跑一遍 SSE 载荷序列,返回产出的 usage chunk(没有则 undefined)。 */
|
|
18
|
+
async function usageOf(...payloads: string[]): Promise<Record<string, unknown> | undefined> {
|
|
19
|
+
for await (const chunk of translateSse(payloads)) {
|
|
20
|
+
if (chunk.type === 'usage') return chunk.usage as unknown as Record<string, unknown>
|
|
21
|
+
}
|
|
22
|
+
return undefined
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** 一帧 SSE 的 data 载荷(translateSse 吃的是已剥掉 `data:` 前缀的载荷)。 */
|
|
26
|
+
const frame = (obj: unknown): string => JSON.stringify(obj)
|
|
27
|
+
|
|
28
|
+
/* ---------------- 契约转换 ---------------- */
|
|
29
|
+
|
|
30
|
+
test('转换:OpenAI 形态的 prompt_tokens → inputTokens,且扣掉缓存(DISJOINT)', () => {
|
|
31
|
+
// prompt_tokens 是含缓存的总量;DISJOINT 要求 inputTokens 只算未缓存部分
|
|
32
|
+
const u = toTokenUsage({ promptTokens: 1000, completionTokens: 50, cachedTokens: 300 })
|
|
33
|
+
assert.deepEqual(u, { inputTokens: 700, outputTokens: 50, cacheReadTokens: 300 })
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
test('转换:无缓存时不带 cacheReadTokens 字段(不写无意义的 0)', () => {
|
|
37
|
+
const u = toTokenUsage({ promptTokens: 100, completionTokens: 20, cachedTokens: 0 })
|
|
38
|
+
assert.deepEqual(u, { inputTokens: 100, outputTokens: 20 })
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('转换:全 0 返回 null(这一帧没真数据,整个省略 usage)', () => {
|
|
42
|
+
assert.equal(toTokenUsage({ promptTokens: 0, completionTokens: 0, cachedTokens: 0 }), null)
|
|
43
|
+
assert.equal(toTokenUsage(null), null)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('转换:NaN / Infinity / 负数一律归零 —— 绝不把 NaN 写进会话日志', () => {
|
|
47
|
+
const u = toTokenUsage({
|
|
48
|
+
promptTokens: Number.NaN,
|
|
49
|
+
completionTokens: Number.POSITIVE_INFINITY,
|
|
50
|
+
cachedTokens: -5,
|
|
51
|
+
})
|
|
52
|
+
assert.equal(u, null) // 全归零 → 视为无数据
|
|
53
|
+
const partial = toTokenUsage({ promptTokens: Number.NaN, completionTokens: 42, cachedTokens: 0 })
|
|
54
|
+
assert.deepEqual(partial, { inputTokens: 0, outputTokens: 42 })
|
|
55
|
+
for (const v of Object.values(partial ?? {})) assert.ok(Number.isFinite(v), `出现非有限数: ${v}`)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('转换:缓存比总输入还大时 inputTokens 不为负(上游口径打架也要自洽)', () => {
|
|
59
|
+
const u = toTokenUsage({ promptTokens: 100, completionTokens: 1, cachedTokens: 500 })
|
|
60
|
+
assert.equal(u?.inputTokens, 0)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
/* ---------------- adapter 出口 ---------------- */
|
|
64
|
+
|
|
65
|
+
test('出口:流式 SSE 的 usage 必须是 DSH 契约,不是上游原样', async () => {
|
|
66
|
+
const usage = await usageOf(
|
|
67
|
+
frame({ choices: [{ delta: { content: 'hi' } }] }),
|
|
68
|
+
frame({
|
|
69
|
+
choices: [{ delta: {}, finish_reason: 'stop' }],
|
|
70
|
+
usage: { prompt_tokens: 236655, completion_tokens: 548, prompt_tokens_details: { cached_tokens: 1024 } },
|
|
71
|
+
}),
|
|
72
|
+
'[DONE]',
|
|
73
|
+
)
|
|
74
|
+
assert.ok(usage !== undefined, '应当产出 usage chunk')
|
|
75
|
+
// 字段名对
|
|
76
|
+
assert.equal(usage.prompt_tokens, undefined, '不能把上游字段原样透传')
|
|
77
|
+
assert.equal(usage.inputTokens, 235631) // 236655 - 1024
|
|
78
|
+
assert.equal(usage.outputTokens, 548)
|
|
79
|
+
assert.equal(usage.cacheReadTokens, 1024)
|
|
80
|
+
// 每个字段都是有限非负整数(投影 schema 的硬要求)
|
|
81
|
+
for (const [k, v] of Object.entries(usage)) {
|
|
82
|
+
assert.ok(Number.isInteger(v) && (v as number) >= 0, `${k} 非法: ${v}`)
|
|
83
|
+
}
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
test('出口:上游把 usage 拆在多帧时字段级合并(不是被后一帧覆盖)', async () => {
|
|
87
|
+
// Claude 系:message_start 给输入,message_delta 只给输出
|
|
88
|
+
const usage = await usageOf(
|
|
89
|
+
frame({ choices: [{ delta: { content: 'x' } }], usage: { input_tokens: 100, cache_read_input_tokens: 40 } }),
|
|
90
|
+
frame({ choices: [{ delta: {}, finish_reason: 'stop' }], usage: { output_tokens: 55 } }),
|
|
91
|
+
'[DONE]',
|
|
92
|
+
)
|
|
93
|
+
assert.equal(usage?.inputTokens, 100, '后一帧没有输入,不能把前一帧的输入冲掉')
|
|
94
|
+
assert.equal(usage?.outputTokens, 55)
|
|
95
|
+
assert.equal(usage?.cacheReadTokens, 40)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
test('出口:上游发全 0 或垃圾 usage 时省略整个 usage chunk', async () => {
|
|
99
|
+
const zero = frame({ choices: [{ delta: { content: 'x' } }], usage: { prompt_tokens: 0, completion_tokens: 0 } })
|
|
100
|
+
assert.equal(await usageOf(zero, '[DONE]'), undefined)
|
|
101
|
+
const junk = frame({ choices: [{ delta: { content: 'x' } }], usage: { prompt_tokens: 'abc', completion_tokens: null } })
|
|
102
|
+
assert.equal(await usageOf(junk, '[DONE]'), undefined)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
test('出口:上游压根不发 usage 时不硬凑 usage chunk', async () => {
|
|
106
|
+
const sse = frame({ choices: [{ delta: { content: 'x' }, finish_reason: 'stop' }] })
|
|
107
|
+
assert.equal(await usageOf(sse, '[DONE]'), undefined)
|
|
108
|
+
})
|
package/src/llm/adapter.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
type LlmModelInfo,
|
|
16
16
|
type StreamChunk,
|
|
17
17
|
} from '@deepseek-ai/dsh-llm'
|
|
18
|
+
import { mergeUsage, normalizeUsage, toTokenUsage, type UsageTokens } from '../router/usage-tokens.ts'
|
|
18
19
|
|
|
19
20
|
/** 模型目录来源:组合。 */
|
|
20
21
|
export interface RouterAdapterSource {
|
|
@@ -107,15 +108,16 @@ interface OpenBlock {
|
|
|
107
108
|
name?: string
|
|
108
109
|
}
|
|
109
110
|
|
|
110
|
-
/** openai SSE payload → DSH StreamChunk
|
|
111
|
-
async function*
|
|
111
|
+
/** openai SSE payload → DSH StreamChunk 流。导出以便单测锁死 usage 契约。 */
|
|
112
|
+
export async function* translateSse(payloads: Iterable<string>): AsyncGenerator<StreamChunk> {
|
|
112
113
|
let nextIndex = 0
|
|
113
114
|
let textBlock: OpenBlock | undefined
|
|
114
115
|
let reasoningBlock: OpenBlock | undefined
|
|
115
116
|
const toolBlocks = new Map<number, OpenBlock>()
|
|
116
117
|
const order: OpenBlock[] = []
|
|
117
118
|
let pendingFinish: { kind: string; failure?: unknown } | undefined
|
|
118
|
-
|
|
119
|
+
/** 攒上游各帧的 usage(归一形态),[DONE] 时统一转成 DSH 契约。 */
|
|
120
|
+
let accumulated: UsageTokens | null = null
|
|
119
121
|
|
|
120
122
|
const open = (kind: OpenBlock['kind']): OpenBlock => {
|
|
121
123
|
const block: OpenBlock = { index: nextIndex++, kind, text: '' }
|
|
@@ -128,7 +130,15 @@ async function* translate(payloads: Iterable<string>): AsyncGenerator<StreamChun
|
|
|
128
130
|
for (const block of order) {
|
|
129
131
|
yield { type: 'block-end', index: block.index, block: closeBlock(block) }
|
|
130
132
|
}
|
|
131
|
-
if (
|
|
133
|
+
if (accumulated !== null) {
|
|
134
|
+
// 转成 DSH 的 TokenUsage:**必须**转,不能把上游的 OpenAI 形态
|
|
135
|
+
// (prompt_tokens,且含缓存)原样透传。字段名和 DISJOINT 口径都
|
|
136
|
+
// 对不上,下游 token-meter 读到 undefined 会累加出 NaN,投影
|
|
137
|
+
// schema 校验一抛就是整条 session.history 失败(「历史加载失败」)。
|
|
138
|
+
// 转换结果为 null(三个数全 0)则整个省略 usage。见 toTokenUsage。
|
|
139
|
+
const usage = toTokenUsage(accumulated)
|
|
140
|
+
if (usage !== null) yield { type: 'usage', usage }
|
|
141
|
+
}
|
|
132
142
|
const reason = pendingFinish ?? { kind: 'stop' }
|
|
133
143
|
yield {
|
|
134
144
|
type: 'finish',
|
|
@@ -188,7 +198,9 @@ async function* translate(payloads: Iterable<string>): AsyncGenerator<StreamChun
|
|
|
188
198
|
}
|
|
189
199
|
if (typeof choice.finish_reason === 'string') pendingFinish = mapFinishReason(choice.finish_reason)
|
|
190
200
|
}
|
|
191
|
-
|
|
201
|
+
// 上游可能把 usage 拆在多个帧里(Claude 系:先给输入、后给输出),
|
|
202
|
+
// 所以是字段级 max 合并,不是覆盖。攒着,[DONE] 时统一转契约。
|
|
203
|
+
if (chunk.usage !== undefined) accumulated = mergeUsage(accumulated, normalizeUsage(chunk.usage))
|
|
192
204
|
}
|
|
193
205
|
throw new LlmError('SSE payload stream ended without [DONE]', 'STREAM_CLOSED')
|
|
194
206
|
}
|
|
@@ -268,6 +280,6 @@ export class RouterAdapter extends LlmAdapter {
|
|
|
268
280
|
throw new LlmError(`dsh-router /v1 returned ${resp.status}: ${detail.slice(0, 200)}`, 'TRANSPORT')
|
|
269
281
|
}
|
|
270
282
|
const text = resp.body === null ? '' : await resp.text()
|
|
271
|
-
yield*
|
|
283
|
+
yield* translateSse(parseSseText(text))
|
|
272
284
|
}
|
|
273
285
|
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 账号池(核心策略)测试 —— 选号/冷却/禁用/错误累计/遍历回退。
|
|
3
|
+
* 用 node --test 跑(Node 原生 TS 剥离,零新依赖)。
|
|
4
|
+
*/
|
|
5
|
+
import { test } from 'node:test'
|
|
6
|
+
import assert from 'node:assert/strict'
|
|
7
|
+
import { AccountPool } from './account-pool.ts'
|
|
8
|
+
import type { SupplierAccountNow } from '../suppliers/contract.ts'
|
|
9
|
+
|
|
10
|
+
const accs = (uids: string[]): SupplierAccountNow[] => uids.map((uid) => ({ uid, credits: 0, state: 'ok' }))
|
|
11
|
+
|
|
12
|
+
test('选号:无冷却时按 poolOrder 优先,未配置的追加在后', () => {
|
|
13
|
+
const p = new AccountPool()
|
|
14
|
+
const list = accs(['a', 'b', 'c'])
|
|
15
|
+
assert.equal(p.pick(list, ['c', 'a'], 'fallback'), 'c')
|
|
16
|
+
assert.equal(p.pick(list, ['b'], 'fallback'), 'b')
|
|
17
|
+
// 未配置顺序的按自然顺序补上
|
|
18
|
+
assert.equal(p.pick(list, [], 'fallback'), 'a')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test('选号:冷却中的号跳过,全冷却返回 undefined', () => {
|
|
22
|
+
const p = new AccountPool()
|
|
23
|
+
const list = accs(['a', 'b'])
|
|
24
|
+
p.noteFailure('a', 'rate_limit', '429')
|
|
25
|
+
assert.equal(p.pick(list, [], 'fallback'), 'b')
|
|
26
|
+
p.noteFailure('b', 'rate_limit', '429')
|
|
27
|
+
assert.equal(p.pick(list, [], 'fallback'), undefined)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
test('选号:round-robin 在健康号间轮转', () => {
|
|
31
|
+
const p = new AccountPool()
|
|
32
|
+
const list = accs(['a', 'b'])
|
|
33
|
+
assert.equal(p.pick(list, [], 'round-robin'), 'a')
|
|
34
|
+
assert.equal(p.pick(list, [], 'round-robin'), 'b')
|
|
35
|
+
assert.equal(p.pick(list, [], 'round-robin'), 'a')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('处置:session_dead 永久禁用(不再被选中)', () => {
|
|
39
|
+
const p = new AccountPool()
|
|
40
|
+
const list = accs(['a'])
|
|
41
|
+
p.noteFailure('a', 'session_dead', '凭证失效')
|
|
42
|
+
assert.equal(p.pick(list, [], 'fallback'), undefined)
|
|
43
|
+
assert.equal(p.decorate(list)[0]?.disabled, true)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('处置:连续错误攒够阈值才冷却(前两次仍可服务)', () => {
|
|
47
|
+
const p = new AccountPool()
|
|
48
|
+
const list = accs(['a'])
|
|
49
|
+
p.noteFailure('a', 'unknown', 'e1')
|
|
50
|
+
assert.equal(p.pick(list, [], 'fallback'), 'a')
|
|
51
|
+
p.noteFailure('a', 'unknown', 'e2')
|
|
52
|
+
assert.equal(p.pick(list, [], 'fallback'), 'a')
|
|
53
|
+
// 第三次触发冷却
|
|
54
|
+
p.noteFailure('a', 'unknown', 'e3')
|
|
55
|
+
assert.equal(p.pick(list, [], 'fallback'), undefined)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('处置:成功清零连续错误,避免之前累积的错误把好号冷却掉', () => {
|
|
59
|
+
const p = new AccountPool()
|
|
60
|
+
const list = accs(['a'])
|
|
61
|
+
p.noteFailure('a', 'unknown', 'e1')
|
|
62
|
+
p.noteFailure('a', 'unknown', 'e2')
|
|
63
|
+
p.noteSuccess('a')
|
|
64
|
+
p.noteFailure('a', 'unknown', 'e3')
|
|
65
|
+
assert.equal(p.pick(list, [], 'fallback'), 'a')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('decorate:把冷却/禁用/错误累计叠加到插件报的「现在状态」上', () => {
|
|
69
|
+
const p = new AccountPool()
|
|
70
|
+
const list: SupplierAccountNow[] = [
|
|
71
|
+
{ uid: 'ok', credits: 5, state: 'ok' },
|
|
72
|
+
{ uid: 'cool', credits: 5, state: 'ok' },
|
|
73
|
+
{ uid: 'dead', credits: 5, state: 'ok' },
|
|
74
|
+
]
|
|
75
|
+
p.noteFailure('cool', 'rate_limit', '429')
|
|
76
|
+
p.noteFailure('dead', 'session_dead', '凭证失效')
|
|
77
|
+
const out = p.decorate(list)
|
|
78
|
+
const by = new Map(out.map((a) => [a.uid, a]))
|
|
79
|
+
assert.equal(by.get('ok')?.cooling, false)
|
|
80
|
+
assert.equal(by.get('ok')?.disabled, false)
|
|
81
|
+
assert.equal(by.get('cool')?.cooling, true)
|
|
82
|
+
assert.equal(by.get('cool')?.until !== undefined, true)
|
|
83
|
+
assert.equal(by.get('cool')?.reason, '429')
|
|
84
|
+
assert.equal(by.get('dead')?.disabled, true)
|
|
85
|
+
// 插件报的字段要原样透出(不能被 decorate 吃掉)
|
|
86
|
+
assert.equal(by.get('ok')?.credits, 5)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test('no_such_model 不惩罚账号:不冷却、不计数', () => {
|
|
90
|
+
const p = new AccountPool()
|
|
91
|
+
const list = accs(['a'])
|
|
92
|
+
// 攒够阈值次也不该冷却——模型不属于本供应商不是账号的错
|
|
93
|
+
for (let i = 0; i < 10; i++) p.noteFailure('a', 'no_such_model', 'unknown model "x"')
|
|
94
|
+
assert.equal(p.pick(list, [], 'fallback'), 'a')
|
|
95
|
+
const out = p.decorate(list)[0]
|
|
96
|
+
assert.equal(out?.cooling, false)
|
|
97
|
+
assert.equal(out?.disabled, false)
|
|
98
|
+
assert.equal(out?.err_count, 0)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
test('状态表:各 AccountState 的处置符合预期', () => {
|
|
102
|
+
const p = new AccountPool()
|
|
103
|
+
const list = accs(['rate', 'quota', 'dead', 'unavail'])
|
|
104
|
+
p.noteFailure('rate', 'rate_limit', 'x')
|
|
105
|
+
p.noteFailure('quota', 'quota', 'x')
|
|
106
|
+
p.noteFailure('dead', 'session_dead', 'x')
|
|
107
|
+
p.noteFailure('unavail', 'unavailable', 'x')
|
|
108
|
+
// rate_limit/quota 立刻冷却;session_dead 禁用;unavailable 只计错误不冷却
|
|
109
|
+
assert.equal(p.pick(list, [], 'fallback'), 'unavail')
|
|
110
|
+
const by = new Map(p.decorate(list).map((a) => [a.uid, a]))
|
|
111
|
+
assert.equal(by.get('rate')?.cooling, true)
|
|
112
|
+
assert.equal(by.get('quota')?.cooling, true)
|
|
113
|
+
assert.equal(by.get('dead')?.disabled, true)
|
|
114
|
+
assert.equal(by.get('unavail')?.cooling, false)
|
|
115
|
+
assert.equal(by.get('unavail')?.err_count, 1)
|
|
116
|
+
// 攒够阈值后才冷却(与 unknown/transport 同一条路径)
|
|
117
|
+
p.noteFailure('unavail', 'unavailable', 'x')
|
|
118
|
+
p.noteFailure('unavail', 'unavailable', 'x')
|
|
119
|
+
assert.equal(p.pick(list, [], 'fallback'), undefined)
|
|
120
|
+
})
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 账号池 —— dsh-router 核心策略:选号、冷却、禁用、错误累计、遍历回退。
|
|
3
|
+
*
|
|
4
|
+
* 供应商插件只管「对单个账号调通上游」,账号层面的策略全在这里:
|
|
5
|
+
* - 按 poolOrder 排序(未配置的按插件报告顺序追加)
|
|
6
|
+
* - 按 poolStrategy 选号:fallback = 取第一个健康;round-robin = 轮转健康号
|
|
7
|
+
* - 失败按插件报的 AccountState 处置(冷却/禁用/错误累计),攒够阈值自动冷却
|
|
8
|
+
*
|
|
9
|
+
* 天花板:冷却/禁用/错误计数都是**内存态**,重启归零(与旧插件行为一致,
|
|
10
|
+
* 插件的持久化只存凭证与积分,不存健康)。要跨重启保留得落盘,届时加一个
|
|
11
|
+
* stateFile 即可,接口不用变。
|
|
12
|
+
*/
|
|
13
|
+
import type { AccountState, SupplierAccountNow } from '../suppliers/contract.ts'
|
|
14
|
+
|
|
15
|
+
/** 各 AccountState 的处置:冷却时长 / 是否禁用 / 是否计入连续错误。 */
|
|
16
|
+
interface Rule {
|
|
17
|
+
/** 冷却时长(ms);0 = 不冷却。 */
|
|
18
|
+
cooldownMs: number
|
|
19
|
+
/** 是否永久禁用(需重新登录才能恢复)。 */
|
|
20
|
+
disable: boolean
|
|
21
|
+
/** 是否计入连续错误(攒够阈值自动冷却)。 */
|
|
22
|
+
counts: boolean
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const MINUTE = 60_000
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 状态 → 处置表。**核心策略就在这一张表里**,插件只报状态不做决策。
|
|
29
|
+
* 时长参考 9router 的 errorConfig(限流类短冷却、额度类长冷却),
|
|
30
|
+
* 但 9router 按文本/状态匹配,这里按插件解读后的语义状态匹配,更准。
|
|
31
|
+
*/
|
|
32
|
+
const RULES: Record<AccountState, Rule> = {
|
|
33
|
+
ok: { cooldownMs: 0, disable: false, counts: false },
|
|
34
|
+
rate_limit: { cooldownMs: 1 * MINUTE, disable: false, counts: false },
|
|
35
|
+
quota: { cooldownMs: 10 * MINUTE, disable: false, counts: false },
|
|
36
|
+
session_dead: { cooldownMs: 0, disable: true, counts: false },
|
|
37
|
+
// 404/服务下线:只计错误不立刻冷却——偶发的自己恢复,真挂了攒够阈值再冷却
|
|
38
|
+
unavailable: { cooldownMs: 0, disable: false, counts: true },
|
|
39
|
+
transport: { cooldownMs: 0, disable: false, counts: true },
|
|
40
|
+
unknown: { cooldownMs: 0, disable: false, counts: true },
|
|
41
|
+
// 模型不属于本供应商:不是账号的错,不冷却也不计数(核心据此换下一个供应商)
|
|
42
|
+
no_such_model: { cooldownMs: 0, disable: false, counts: false },
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 连续错误攒够这么多次就自动冷却(防止坏号被反复重试)。 */
|
|
46
|
+
const ERR_THRESHOLD = 3
|
|
47
|
+
/** 攒够后的冷却时长。 */
|
|
48
|
+
const ERR_COOLDOWN_MS = 10 * MINUTE
|
|
49
|
+
|
|
50
|
+
interface Entry {
|
|
51
|
+
until: number
|
|
52
|
+
disabled: boolean
|
|
53
|
+
errCount: number
|
|
54
|
+
reason: string
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export class AccountPool {
|
|
58
|
+
private entries = new Map<string, Entry>()
|
|
59
|
+
private rrCursor = 0
|
|
60
|
+
|
|
61
|
+
private entry(uid: string): Entry {
|
|
62
|
+
let e = this.entries.get(uid)
|
|
63
|
+
if (!e) {
|
|
64
|
+
e = { until: 0, disabled: false, errCount: 0, reason: '' }
|
|
65
|
+
this.entries.set(uid, e)
|
|
66
|
+
}
|
|
67
|
+
return e
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 是否可服务:未禁用且不在冷却中。 */
|
|
71
|
+
private healthy(uid: string, now: number): boolean {
|
|
72
|
+
const e = this.entries.get(uid)
|
|
73
|
+
if (!e) return true
|
|
74
|
+
if (e.disabled) return false
|
|
75
|
+
return e.until <= now
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* 按策略选一个健康账号。
|
|
80
|
+
* @param accounts 插件报告的「现在状态」(顺序即插件的自然顺序)
|
|
81
|
+
* @param poolOrder 用户在面板拖出来的顺序(核心管)
|
|
82
|
+
* @returns 选中的 uid;无健康账号返回 undefined
|
|
83
|
+
*/
|
|
84
|
+
pick(accounts: SupplierAccountNow[], poolOrder: string[], strategy: string): string | undefined {
|
|
85
|
+
const now = Date.now()
|
|
86
|
+
const byUid = new Map(accounts.map((a) => [a.uid, a]))
|
|
87
|
+
// 池顺序优先,未配置的按插件自然顺序追加
|
|
88
|
+
const ordered = [
|
|
89
|
+
...poolOrder.filter((uid) => byUid.has(uid)),
|
|
90
|
+
...accounts.map((a) => a.uid).filter((uid) => !poolOrder.includes(uid)),
|
|
91
|
+
]
|
|
92
|
+
const healthy = ordered.filter((uid) => this.healthy(uid, now))
|
|
93
|
+
if (healthy.length === 0) return undefined
|
|
94
|
+
if (strategy !== 'round-robin') return healthy[0]
|
|
95
|
+
const uid = healthy[this.rrCursor % healthy.length]
|
|
96
|
+
this.rrCursor = (this.rrCursor + 1) % healthy.length
|
|
97
|
+
return uid
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** 记录一次失败:按状态处置(冷却/禁用/错误累计)。 */
|
|
101
|
+
noteFailure(uid: string, state: AccountState, message: string): void {
|
|
102
|
+
const rule = RULES[state]
|
|
103
|
+
const e = this.entry(uid)
|
|
104
|
+
if (rule.disable) {
|
|
105
|
+
e.disabled = true
|
|
106
|
+
e.reason = message
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
if (rule.cooldownMs > 0) {
|
|
110
|
+
e.until = Math.max(e.until, Date.now() + rule.cooldownMs)
|
|
111
|
+
e.reason = message
|
|
112
|
+
e.errCount = 0
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
if (rule.counts) {
|
|
116
|
+
e.errCount += 1
|
|
117
|
+
e.reason = message
|
|
118
|
+
if (e.errCount >= ERR_THRESHOLD) {
|
|
119
|
+
e.until = Date.now() + ERR_COOLDOWN_MS
|
|
120
|
+
e.errCount = 0
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** 记录一次成功:清零连续错误。 */
|
|
126
|
+
noteSuccess(uid: string): void {
|
|
127
|
+
const e = this.entries.get(uid)
|
|
128
|
+
if (e) e.errCount = 0
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** 手动冷却(面板/管理用途)。 */
|
|
132
|
+
cooldown(uid: string, ms: number, reason: string): void {
|
|
133
|
+
const e = this.entry(uid)
|
|
134
|
+
e.until = Date.now() + ms
|
|
135
|
+
e.reason = reason
|
|
136
|
+
e.errCount = 0
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** 把核心的冷却/禁用/错误累计叠加到插件报告的「现在状态」上,产出面板态。 */
|
|
140
|
+
decorate(accounts: SupplierAccountNow[]): Array<SupplierAccountNow & {
|
|
141
|
+
cooling: boolean
|
|
142
|
+
disabled: boolean
|
|
143
|
+
err_count: number
|
|
144
|
+
until?: string
|
|
145
|
+
reason?: string
|
|
146
|
+
}> {
|
|
147
|
+
const now = Date.now()
|
|
148
|
+
return accounts.map((a) => {
|
|
149
|
+
const e = this.entries.get(a.uid)
|
|
150
|
+
const cooling = e !== undefined && !e.disabled && e.until > now
|
|
151
|
+
return {
|
|
152
|
+
...a,
|
|
153
|
+
cooling,
|
|
154
|
+
disabled: e?.disabled ?? false,
|
|
155
|
+
err_count: e?.errCount ?? 0,
|
|
156
|
+
until: cooling && e ? new Date(e.until).toISOString() : undefined,
|
|
157
|
+
reason: e?.reason !== '' ? e?.reason : undefined,
|
|
158
|
+
}
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
}
|