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.
Files changed (42) hide show
  1. package/README.md +58 -13
  2. package/lib/client-registry.js +824 -153
  3. package/lib/client.js +824 -153
  4. package/lib/index.js +1606 -227
  5. package/lib/types/llm/adapter.d.ts +2 -0
  6. package/lib/types/router/account-pool.d.ts +41 -0
  7. package/lib/types/router/index.d.ts +72 -4
  8. package/lib/types/router/types.d.ts +12 -10
  9. package/lib/types/router/usage-store.d.ts +93 -0
  10. package/lib/types/router/usage-tokens.d.ts +118 -0
  11. package/lib/types/shared.d.ts +69 -0
  12. package/lib/types/supplier-config.d.ts +15 -4
  13. package/lib/types/suppliers/contract.d.ts +70 -21
  14. package/lib/types/suppliers/registry.d.ts +4 -3
  15. package/package.json +2 -1
  16. package/src/client/CombosTab.tsx +28 -16
  17. package/src/client/RouterView.tsx +15 -2
  18. package/src/client/StatsTab.tsx +521 -0
  19. package/src/client/SupplierDetail.tsx +58 -34
  20. package/src/client/router.css +406 -0
  21. package/src/client/workspace-mount.tsx +2 -9
  22. package/src/index.ts +52 -4
  23. package/src/llm/adapter.test.ts +108 -0
  24. package/src/llm/adapter.ts +18 -6
  25. package/src/router/account-pool.test.ts +120 -0
  26. package/src/router/account-pool.ts +161 -0
  27. package/src/router/index.ts +652 -26
  28. package/src/router/models-cache.test.ts +455 -0
  29. package/src/router/precise-dispatch.test.ts +198 -0
  30. package/src/router/types.ts +12 -7
  31. package/src/router/usage-store.ts +499 -0
  32. package/src/router/usage-tokens.ts +327 -0
  33. package/src/router/usage.test.ts +558 -0
  34. package/src/shared.ts +77 -0
  35. package/src/supplier-config.ts +31 -14
  36. package/src/suppliers/contract.ts +71 -8
  37. package/src/suppliers/loader.ts +21 -13
  38. package/src/suppliers/nvidia/plugin.ts +161 -119
  39. package/src/suppliers/opencode/plugin.ts +47 -95
  40. package/src/suppliers/openrouter/plugin.ts +67 -122
  41. package/src/suppliers/registry.test.ts +194 -0
  42. 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
+ })
@@ -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* translate(payloads: Iterable<string>): AsyncGenerator<StreamChunk> {
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
- let pendingUsage: unknown
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 (pendingUsage !== undefined) yield { type: 'usage', usage: pendingUsage as never }
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
- if (chunk.usage !== undefined) pendingUsage = chunk.usage
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* translate(parseSseText(text))
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
+ }