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
@@ -0,0 +1,558 @@
1
+ /**
2
+ * 用量统计测试 —— token 提取口径 + 落盘聚合 + 请求路径接线。
3
+ * 用 node --test 跑(Node 原生 TS 剥离,零新依赖)。
4
+ */
5
+ import { test } from 'node:test'
6
+ import assert from 'node:assert/strict'
7
+ import {
8
+ normalizeUsage,
9
+ mergeUsage,
10
+ extractFrameUsage,
11
+ usageFromJsonBody,
12
+ tapStreamUsage,
13
+ withEstimates,
14
+ estimateTokens,
15
+ } from './usage-tokens.ts'
16
+ import { UsageStore, localDateKey } from './usage-store.ts'
17
+ import { mkdirSync, writeFileSync } from 'node:fs'
18
+ import { Router } from './index.ts'
19
+ import { AccountPool } from './account-pool.ts'
20
+ import type { ServerResponse } from 'node:http'
21
+ import type { ModelInfo } from './types.ts'
22
+ import type { ChatOnceResult, SupplierAccountNow, SupplierStatusNow } from '../suppliers/contract.ts'
23
+ import type { AccountPool as Pool } from './account-pool.ts'
24
+
25
+ /* ---------------- token 提取 ---------------- */
26
+
27
+ test('归一化:OpenAI 形态的 cached_tokens 已含在 prompt 里,不重复加', () => {
28
+ const u = normalizeUsage({ prompt_tokens: 100, completion_tokens: 20, cached_tokens: 30 })
29
+ assert.deepEqual(u, { promptTokens: 100, completionTokens: 20, cachedTokens: 30 })
30
+ })
31
+
32
+ test('归一化:Claude 形态的 prompt 不含缓存,cache_read 要折进输入', () => {
33
+ // 照 9router canonicalizeUsage:Claude 报的 input 排除 cache,得加回来
34
+ const u = normalizeUsage({ input_tokens: 50, output_tokens: 10, cache_read_input_tokens: 80 })
35
+ assert.deepEqual(u, { promptTokens: 130, completionTokens: 10, cachedTokens: 80 })
36
+ })
37
+
38
+ test('归一化:Gemini 形态(promptTokenCount / usageMetadata)', () => {
39
+ const u = normalizeUsage({ promptTokenCount: 7, candidatesTokenCount: 3, cachedContentTokenCount: 2 })
40
+ assert.deepEqual(u, { promptTokens: 7, completionTokens: 3, cachedTokens: 2 })
41
+ })
42
+
43
+ test('归一化:垃圾输入返回 null,不产生 NaN', () => {
44
+ assert.equal(normalizeUsage(null), null)
45
+ assert.equal(normalizeUsage('x'), null)
46
+ assert.equal(normalizeUsage([]), null)
47
+ assert.equal(normalizeUsage({}), null)
48
+ // 全 0 视为「没拿到 usage」
49
+ assert.equal(normalizeUsage({ prompt_tokens: 0, completion_tokens: 0 }), null)
50
+ // 非有限数当 0,不能毒穿累加
51
+ assert.deepEqual(normalizeUsage({ prompt_tokens: Number.NaN, completion_tokens: 5 }), {
52
+ promptTokens: 0, completionTokens: 5, cachedTokens: 0,
53
+ })
54
+ })
55
+
56
+ test('合并:字段级取 max(Claude 把 usage 拆在两个事件里)', () => {
57
+ // message_start 给输入+缓存,输出是占位的 1;message_delta 给真实输出,输入缺失
58
+ const a = extractFrameUsage({ type: 'message_start', message: { usage: { input_tokens: 100, output_tokens: 1, cache_read_input_tokens: 40 } } })
59
+ const b = extractFrameUsage({ type: 'message_delta', usage: { output_tokens: 55 } })
60
+ const merged = mergeUsage(a, b)
61
+ assert.deepEqual(merged, { promptTokens: 140, completionTokens: 55, cachedTokens: 40 })
62
+ // 反过来合并(事件顺序颠倒)结果一致
63
+ assert.deepEqual(mergeUsage(b, a), merged)
64
+ })
65
+
66
+ test('合并:一侧为 null 时取另一侧', () => {
67
+ const u = { promptTokens: 1, completionTokens: 2, cachedTokens: 0 }
68
+ assert.deepEqual(mergeUsage(null, u), u)
69
+ assert.deepEqual(mergeUsage(u, null), u)
70
+ assert.equal(mergeUsage(null, null), null)
71
+ })
72
+
73
+ test('帧提取:OpenAI 最后一帧的 usage + Responses API 的 response.usage', () => {
74
+ assert.deepEqual(extractFrameUsage({ usage: { prompt_tokens: 9, completion_tokens: 1 } }), {
75
+ promptTokens: 9, completionTokens: 1, cachedTokens: 0,
76
+ })
77
+ assert.deepEqual(
78
+ extractFrameUsage({ type: 'response.completed', response: { usage: { input_tokens: 4, output_tokens: 6 } } }),
79
+ { promptTokens: 4, completionTokens: 6, cachedTokens: 0 },
80
+ )
81
+ // 坏帧(非 JSON / null / 数组)不炸
82
+ assert.equal(extractFrameUsage(null), null)
83
+ assert.equal(extractFrameUsage([]), null)
84
+ assert.equal(extractFrameUsage('x'), null)
85
+ })
86
+
87
+ test('非流式响应体:纯 JSON 取 usage;SSE 形态扫尾部帧', () => {
88
+ assert.deepEqual(usageFromJsonBody('{"usage":{"prompt_tokens":11,"completion_tokens":2}}'), {
89
+ promptTokens: 11, completionTokens: 2, cachedTokens: 0,
90
+ })
91
+ // 客户端要 JSON 但拿到 SSE(核心聚合前的形态)
92
+ const sse = [
93
+ 'data: {"choices":[{"delta":{"content":"hi"}}]}',
94
+ '',
95
+ 'data: {"choices":[{"delta":{}}],"usage":{"prompt_tokens":3,"completion_tokens":4}}',
96
+ '',
97
+ 'data: [DONE]',
98
+ '',
99
+ ].join('\n')
100
+ assert.deepEqual(usageFromJsonBody(sse), { promptTokens: 3, completionTokens: 4, cachedTokens: 0 })
101
+ // 既不是 JSON 也没有 usage → null
102
+ assert.equal(usageFromJsonBody('not json at all'), null)
103
+ })
104
+
105
+ test('估算:~4 字符/token;上游给了就不覆盖', () => {
106
+ assert.equal(estimateTokens(0), 0)
107
+ assert.equal(estimateTokens(-5), 0)
108
+ assert.equal(estimateTokens(100), 25)
109
+
110
+ // 全缺 → 输入输出都估算
111
+ const all = withEstimates(null, 400, 80)
112
+ assert.deepEqual(all, {
113
+ promptTokens: 100, completionTokens: 20, cachedTokens: 0,
114
+ inputEstimated: true, outputEstimated: true,
115
+ })
116
+ // 只缺输出 → 只估输出(输入用真值,标记 false)
117
+ const partial = withEstimates({ promptTokens: 7, completionTokens: 0, cachedTokens: 1 }, 400, 80)
118
+ assert.deepEqual(partial, {
119
+ promptTokens: 7, completionTokens: 20, cachedTokens: 1,
120
+ inputEstimated: false, outputEstimated: true,
121
+ })
122
+ })
123
+
124
+ /* ---------------- 流式:边透传边统计 ---------------- */
125
+
126
+ /** 用若干 chunk 造一条 SSE 流。 */
127
+ function sseStream(chunks: string[]): ReadableStream<Uint8Array> {
128
+ const enc = new TextEncoder()
129
+ let i = 0
130
+ return new ReadableStream<Uint8Array>({
131
+ pull(ctrl) {
132
+ if (i >= chunks.length) {
133
+ ctrl.close()
134
+ return
135
+ }
136
+ ctrl.enqueue(enc.encode(chunks[i] ?? ''))
137
+ i += 1
138
+ },
139
+ })
140
+ }
141
+
142
+ /** 读干一条流(模拟客户端消费)。 */
143
+ async function drain(stream: ReadableStream<Uint8Array>): Promise<string> {
144
+ const dec = new TextDecoder()
145
+ let out = ''
146
+ const reader = stream.getReader()
147
+ for (;;) {
148
+ const { done, value } = await reader.read()
149
+ if (done) break
150
+ out += dec.decode(value as Uint8Array, { stream: true })
151
+ }
152
+ return out
153
+ }
154
+
155
+ test('tapStreamUsage:原样透传每个字节,统计在旁路做', async () => {
156
+ const chunks = ['data: {"choices":[{"delta":{"content":"he"}}]}\n\n', 'data: {"choices":[{"delta":{"content":"llo"}}],"usage":{"prompt_tokens":10,"completion_tokens":5}}\n\n', 'data: [DONE]\n\n']
157
+ const { stream, done } = tapStreamUsage(sseStream(chunks), Date.now())
158
+
159
+ // 客户端拿到的字节必须与上游一字不差
160
+ assert.equal(await drain(stream), chunks.join(''))
161
+
162
+ const got = await done()
163
+ assert.deepEqual(got.usage, { promptTokens: 10, completionTokens: 5, cachedTokens: 0 })
164
+ assert.equal(got.outputChars, 5) // "he" + "llo"
165
+ assert.ok(got.ttfbMs >= 0)
166
+ })
167
+
168
+ test('tapStreamUsage:帧被切在 chunk 边界上也能拼回来', async () => {
169
+ // 一帧从中间劈成两半——跨边界的帧必须留在 buffer 里等下一块
170
+ const [a, b] = ['data: {"usage":{"prompt_tokens":1', '2,"completion_tokens":3}}\n\n']
171
+ const { stream, done } = tapStreamUsage(sseStream([a ?? '', b ?? '']), Date.now())
172
+ await drain(stream)
173
+ const got = await done()
174
+ assert.deepEqual(got.usage, { promptTokens: 12, completionTokens: 3, cachedTokens: 0 })
175
+ })
176
+
177
+ test('tapStreamUsage:usage 分散在多帧时取 max 合并', async () => {
178
+ const chunks = [
179
+ 'data: {"usage":{"prompt_tokens":100,"completion_tokens":1}}\n\n',
180
+ 'data: {"usage":{"completion_tokens":42}}\n\n',
181
+ ]
182
+ const { stream, done } = tapStreamUsage(sseStream(chunks), Date.now())
183
+ await drain(stream)
184
+ // 第二帧的 completion 更大 → 取它;第一帧的 prompt 保留
185
+ assert.deepEqual((await done()).usage, { promptTokens: 100, completionTokens: 42, cachedTokens: 0 })
186
+ })
187
+
188
+ test('tapStreamUsage:上游不发 usage 也能统计输出字符数(供估算)', async () => {
189
+ const { stream, done } = tapStreamUsage(
190
+ sseStream(['data: {"choices":[{"delta":{"content":"12345678"}}]}\n\n']),
191
+ Date.now(),
192
+ )
193
+ await drain(stream)
194
+ const got = await done()
195
+ assert.equal(got.usage, null)
196
+ assert.equal(got.outputChars, 8)
197
+ // 交给 withEstimates 补:输出 = ceil(8/4) = 2
198
+ assert.equal(withEstimates(got.usage, 40, got.outputChars).completionTokens, 2)
199
+ })
200
+
201
+ test('tapStreamUsage:一个字节都没有 → ttfb 为 0 而不是负数或 NaN', async () => {
202
+ const { stream, done } = tapStreamUsage(sseStream([]), Date.now())
203
+ await drain(stream)
204
+ assert.equal((await done()).ttfbMs, 0)
205
+ })
206
+
207
+ /* ---------------- 落盘聚合 ---------------- */
208
+
209
+ /** 造一条记录并记进去。 */
210
+ function rec(store: UsageStore, over: Partial<Parameters<UsageStore['record']>[0]> = {}, tokens = { promptTokens: 10, completionTokens: 5, cachedTokens: 0, inputEstimated: false, outputEstimated: false }): void {
211
+ store.record({
212
+ supplier: 'openai',
213
+ model: 'gpt-4',
214
+ requested: 'gpt-4',
215
+ ok: true,
216
+ durationMs: 100,
217
+ ttfbMs: 0,
218
+ ...over,
219
+ }, tokens)
220
+ }
221
+
222
+ test('落账:成功请求入天桶 + 明细环 + lifetime', () => {
223
+ const s = new UsageStore('')
224
+ rec(s)
225
+ const st = s.stats('today')
226
+ assert.equal(st.requests, 1)
227
+ assert.equal(st.ok, 1)
228
+ assert.equal(st.failed, 0)
229
+ assert.equal(st.promptTokens, 10)
230
+ assert.equal(st.completionTokens, 5)
231
+ assert.equal(st.lifetime, 1)
232
+ assert.equal(s.recentList(10).length, 1)
233
+ })
234
+
235
+ test('落账:成功的零 token 请求不记(照 9router 的闸门)', () => {
236
+ const s = new UsageStore('')
237
+ rec(s, {}, { promptTokens: 0, completionTokens: 0, cachedTokens: 0, inputEstimated: false, outputEstimated: false })
238
+ assert.equal(s.stats('today').requests, 0)
239
+ assert.equal(s.stats('today').lifetime, 0)
240
+ })
241
+
242
+ test('落账:失败请求必须记(否则成功率永远是假的 100%)', () => {
243
+ const s = new UsageStore('')
244
+ rec(s, { ok: false, error: 'boom' }, { promptTokens: 0, completionTokens: 0, cachedTokens: 0, inputEstimated: false, outputEstimated: false })
245
+ const st = s.stats('today')
246
+ assert.equal(st.requests, 1)
247
+ assert.equal(st.failed, 1)
248
+ assert.equal(st.ok, 0)
249
+ assert.equal(s.recentList(1)[0]?.error, 'boom')
250
+ })
251
+
252
+ test('周期:today 用自然日零点切窗,24h 用滚动 24 小时', () => {
253
+ const s = new UsageStore('')
254
+ // 固定基准:今天 12:00。绝不能用「距今 N 小时」写死偏移——真实挂钟一过
255
+ // 23:00,「23 小时前」就落回今天,断言翻车(这个坑真踩过:本地 23:18 跑
256
+ // 测试红了,代码没问题,是测试依赖了挂钟)。基准取正午,两头都留够余量。
257
+ const base = new Date()
258
+ base.setHours(12, 0, 0, 0)
259
+ const now = base.getTime()
260
+ const dayStart = new Date(now).setHours(0, 0, 0, 0)
261
+
262
+ const yesterday = dayStart - 13 * 3600000 // 昨天 11:00(距今 25 小时)
263
+ const thisMorning = dayStart + 3600000 // 今天 01:00
264
+
265
+ rec(s, { ts: yesterday })
266
+ assert.equal(s.stats('today', now).requests, 0, '昨天的请求不该进 today')
267
+ assert.equal(s.stats('24h', now).requests, 0, '距今 25 小时不该进 24h')
268
+
269
+ rec(s, { ts: thisMorning })
270
+ assert.equal(s.stats('today', now).requests, 1, '今天 01:00 该进 today')
271
+ assert.equal(s.stats('24h', now).requests, 1, '今天 01:00 该进 24h')
272
+
273
+ rec(s, { ts: now })
274
+ assert.equal(s.stats('today', now).requests, 2)
275
+ assert.equal(s.stats('24h', now).requests, 2)
276
+
277
+ // 7d/30d 走天桶:昨天的那条算得进来(today/24h 算不进来)
278
+ assert.equal(s.stats('7d', now).requests, 3)
279
+ })
280
+
281
+ test('周期:7d/30d 走天桶,明细环撑爆也不影响', () => {
282
+ const s = new UsageStore('')
283
+ const now = Date.now()
284
+ // 塞 600 条(> RING_CAP 500),全在今天
285
+ for (let i = 0; i < 600; i += 1) rec(s, { ts: now })
286
+ const week = s.stats('7d', now)
287
+ assert.equal(week.requests, 600) // 天桶是精确累加,不受环容量限制
288
+ assert.equal(s.recentList(1000).length, 500) // 明细环只留 500
289
+ })
290
+
291
+ test('Top 榜:按请求数降序,且三个维度各自聚合', () => {
292
+ const s = new UsageStore('')
293
+ rec(s, { supplier: 'a', model: 'm1', requested: 'combo-x' })
294
+ rec(s, { supplier: 'a', model: 'm1', requested: 'combo-x' })
295
+ rec(s, { supplier: 'b', model: 'm2', requested: 'm2' })
296
+ const st = s.stats('today')
297
+ assert.deepEqual(st.bySupplier.map((r) => [r.name, r.requests]), [['a', 2], ['b', 1]])
298
+ assert.deepEqual(st.byRequested.map((r) => [r.name, r.requests]), [['combo-x', 2], ['m2', 1]])
299
+ assert.equal(st.byModel.length, 2)
300
+ })
301
+
302
+ test('平均耗时 / 平均 TTFB:分母是各自有记录的条数,不是总请求数', () => {
303
+ const s = new UsageStore('')
304
+ rec(s, { durationMs: 100, ttfbMs: 0 }) // 非流式:没有 TTFB
305
+ rec(s, { durationMs: 300, ttfbMs: 200 })
306
+ const st = s.stats('today')
307
+ assert.equal(st.avgDurationMs, 200) // (100+300)/2
308
+ assert.equal(st.avgTtfbMs, 200) // 只有 1 条有 TTFB,不能除以 2
309
+ })
310
+
311
+ test('估算计数:面板据此提示「部分为估算」', () => {
312
+ const s = new UsageStore('')
313
+ rec(s, {}, { promptTokens: 10, completionTokens: 5, cachedTokens: 0, inputEstimated: true, outputEstimated: false })
314
+ rec(s, {}, { promptTokens: 10, completionTokens: 5, cachedTokens: 0, inputEstimated: false, outputEstimated: false })
315
+ const st = s.stats('7d')
316
+ assert.equal(st.estimatedInputs, 1)
317
+ assert.equal(st.estimatedOutputs, 0)
318
+ })
319
+
320
+ test('图表:today/24h = 24 个桶,7d/30d = 天桶', () => {
321
+ const s = new UsageStore('')
322
+ rec(s)
323
+ assert.equal(s.chart('today').length, 24)
324
+ assert.equal(s.chart('24h').length, 24)
325
+ assert.equal(s.chart('7d').length, 7)
326
+ assert.equal(s.chart('30d').length, 30)
327
+ // 7d 天桶里今天那条被记上了
328
+ const week = s.chart('7d')
329
+ assert.equal(week[week.length - 1]?.requests, 1)
330
+ assert.equal(week[week.length - 1]?.tokens, 15)
331
+ })
332
+
333
+ test('清空 + 落盘往返:数据持久化,重载后还在', () => {
334
+ const dir = `${process.env.TMPDIR ?? '/tmp'}/dshr-usage-${Math.random().toString(36).slice(2)}`
335
+ const fp = `${dir}/state.json`
336
+ const s1 = new UsageStore(fp)
337
+ rec(s1, { supplier: 'traework' })
338
+ s1.flush()
339
+ const s2 = new UsageStore(fp)
340
+ assert.equal(s2.stats('today').requests, 1)
341
+ assert.equal(s2.stats('today').bySupplier[0]?.name, 'traework')
342
+ assert.equal(s2.stats('today').lifetime, 1)
343
+ s2.clear()
344
+ assert.equal(s2.stats('today').requests, 0)
345
+ assert.equal(new UsageStore(fp).stats('today').requests, 0)
346
+ })
347
+
348
+ test('落盘:文件损坏不阻断启动(从空开始)', () => {
349
+ const dir = `${process.env.TMPDIR ?? '/tmp'}/dshr-usage-${Math.random().toString(36).slice(2)}`
350
+ const fp = `${dir}/state.json`
351
+ mkdirSync(dir, { recursive: true })
352
+ writeFileSync(fp, '{ this is not json')
353
+ const s = new UsageStore(fp)
354
+ assert.equal(s.stats('today').requests, 0)
355
+ })
356
+
357
+ test('localDateKey 按本地日切天,不是 UTC', () => {
358
+ // 本地时间 00:30 —— UTC 可能还是前一天,但按本地算必须是今天
359
+ const d = new Date(2026, 0, 15, 0, 30)
360
+ assert.equal(localDateKey(d.getTime()), '2026-01-15')
361
+ })
362
+
363
+ /* ---------------- 请求路径接线 ---------------- */
364
+
365
+ interface Spy {
366
+ calls: number
367
+ s: {
368
+ id: string
369
+ name: string
370
+ priority: number
371
+ pool: Pool
372
+ status: () => SupplierStatusNow
373
+ listModels: () => Promise<ModelInfo[]>
374
+ modelsWithEnabled: () => Promise<Array<ModelInfo & { enabled: boolean }>>
375
+ getAlias: () => string
376
+ accounts: () => SupplierAccountNow[]
377
+ chatOnce: (uid: string, req: { rawBody: string }) => Promise<ChatOnceResult>
378
+ dispose: () => void
379
+ }
380
+ }
381
+
382
+ /** 带探针的供应商:返回固定响应体。 */
383
+ function spy(id: string, modelId: string, reply: ChatOnceResult): Spy {
384
+ const calls = { n: 0 }
385
+ return {
386
+ get calls() { return calls.n },
387
+ s: {
388
+ id, name: id, priority: 0, pool: new AccountPool(),
389
+ status: () => ({ id, name: id, accounts: [{ uid: `${id}-u1`, credits: 0, state: 'ok' }] }),
390
+ listModels: async () => [{ id: modelId }],
391
+ modelsWithEnabled: async () => [{ id: modelId, enabled: true }],
392
+ getAlias: () => id,
393
+ accounts: () => [{ uid: `${id}-u1`, credits: 0, state: 'ok' }],
394
+ chatOnce: async (_uid, req) => {
395
+ calls.n += 1
396
+ let m = ''
397
+ try {
398
+ m = (JSON.parse(req.rawBody) as { model?: string }).model ?? ''
399
+ } catch {
400
+ m = ''
401
+ }
402
+ // 真实插件自己认得别名前缀并剥掉(核心不剥,见 chatWithTarget 注释)
403
+ if (m.startsWith(`${id}/`)) m = m.slice(id.length + 1)
404
+ if (m !== modelId) return { ok: false, state: 'no_such_model', message: `unknown model ${m}` }
405
+ return reply
406
+ },
407
+ dispose: () => {},
408
+ },
409
+ }
410
+ }
411
+
412
+ /** 走真实注册路径(router.add 内部会 sync 已知 id)。先转 unknown:
413
+ * 探针的 status() 报的是插件「现在状态」,与核心叠加后面板态类型不同。 */
414
+ function add(router: Router, s: unknown): void {
415
+ router.add(s as Parameters<Router['add']>[0])
416
+ }
417
+
418
+ /** 丢弃响应的假 res(记录写入内容)。字节按 utf-8 解码。 */
419
+ function sinkRes(): ServerResponse & { status(): number; body(): string } {
420
+ let status = 200
421
+ const parts: Uint8Array[] = []
422
+ const dec = new TextDecoder()
423
+ const take = (chunk: unknown): void => {
424
+ if (chunk instanceof Uint8Array) parts.push(chunk)
425
+ else if (typeof chunk === 'string') parts.push(new TextEncoder().encode(chunk))
426
+ }
427
+ const self = {
428
+ headersSent: false,
429
+ writableEnded: false,
430
+ writeHead: (code: number): unknown => { status = code; return self },
431
+ write: (chunk?: unknown): boolean => { take(chunk); return true },
432
+ end: (chunk?: unknown): unknown => { take(chunk); return self },
433
+ once: (): unknown => self,
434
+ removeListener: (): unknown => self,
435
+ destroy: (): void => {},
436
+ flushHeaders: (): void => {},
437
+ status: (): number => status,
438
+ body: (): string => parts.map((p) => dec.decode(p)).join(''),
439
+ }
440
+ return self as unknown as ServerResponse & { status(): number; body(): string }
441
+ }
442
+
443
+ test('请求路径:非流式成功 → 记一条 ok,token 来自响应体 usage', async () => {
444
+ const router = new Router('')
445
+ const a = spy('alpha', 'gpt-4', { ok: true, status: 200, body: '{"usage":{"prompt_tokens":100,"completion_tokens":20}}' })
446
+ add(router, a.s)
447
+ await router.chatCompletions(
448
+ { model: 'alpha/gpt-4', stream: false, rawBody: JSON.stringify({ model: 'alpha/gpt-4', messages: [] }) },
449
+ sinkRes(),
450
+ )
451
+ const st = router.usage.stats('today')
452
+ assert.equal(st.requests, 1)
453
+ assert.equal(st.ok, 1)
454
+ assert.equal(st.promptTokens, 100)
455
+ assert.equal(st.completionTokens, 20)
456
+ assert.equal(st.bySupplier[0]?.name, 'alpha')
457
+ assert.equal(st.byRequested[0]?.name, 'alpha/gpt-4')
458
+ })
459
+
460
+ test('请求路径:流式成功 → 统计透传的流,客户端字节不受影响', async () => {
461
+ const router = new Router('')
462
+ const enc = new TextEncoder()
463
+ const chunks = [
464
+ 'data: {"choices":[{"delta":{"content":"hi"}}]}\n\n',
465
+ 'data: {"choices":[{"delta":{}}],"usage":{"prompt_tokens":50,"completion_tokens":7}}\n\n',
466
+ 'data: [DONE]\n\n',
467
+ ]
468
+ const stream = new ReadableStream<Uint8Array>({
469
+ start(ctrl) {
470
+ for (const c of chunks) ctrl.enqueue(enc.encode(c))
471
+ ctrl.close()
472
+ },
473
+ })
474
+ const a = spy('alpha', 'gpt-4', { ok: true, stream })
475
+ add(router, a.s)
476
+ const res = sinkRes()
477
+ await router.chatCompletions(
478
+ { model: 'alpha/gpt-4', stream: true, rawBody: JSON.stringify({ model: 'alpha/gpt-4', messages: [], stream: true }) },
479
+ res,
480
+ )
481
+ // 客户端拿到的字节与上游一致(统计没有动过流)
482
+ assert.equal(res.body(), chunks.join(''))
483
+ const st = router.usage.stats('today')
484
+ assert.equal(st.requests, 1)
485
+ assert.equal(st.promptTokens, 50)
486
+ assert.equal(st.completionTokens, 7)
487
+ })
488
+
489
+ test('请求路径:上游不发 usage → 走估算(输入按请求体、输出按内容长度)', async () => {
490
+ const router = new Router('')
491
+ const enc = new TextEncoder()
492
+ const stream = new ReadableStream<Uint8Array>({
493
+ start(ctrl) {
494
+ ctrl.enqueue(enc.encode('data: {"choices":[{"delta":{"content":"12345678"}}]}\n\n'))
495
+ ctrl.close()
496
+ },
497
+ })
498
+ const a = spy('alpha', 'gpt-4', { ok: true, stream })
499
+ add(router, a.s)
500
+ // 请求体必须是合法 JSON(核心会改写 model 字段),长度凑到 40 字符 → 输入估 10
501
+ const rawBody = JSON.stringify({ model: 'alpha/gpt-4', messages: [{ role: 'user', content: 'x'.repeat(8) }], stream: true })
502
+ await router.chatCompletions({ model: 'alpha/gpt-4', stream: true, rawBody }, sinkRes())
503
+ const st = router.usage.stats('today')
504
+ assert.equal(st.promptTokens, Math.ceil(rawBody.length / 4))
505
+ assert.equal(st.completionTokens, 2) // 输出 8 字符 → 估 2
506
+ assert.equal(st.estimatedInputs, 1)
507
+ assert.equal(st.estimatedOutputs, 1)
508
+ })
509
+
510
+ test('请求路径:全失败 → 记一条 failed,成功率才准', async () => {
511
+ const router = new Router('')
512
+ const a = spy('alpha', 'gpt-4', { ok: false, state: 'rate_limit', message: '429' })
513
+ add(router, a.s)
514
+ await router.chatCompletions(
515
+ { model: 'alpha/gpt-4', stream: false, rawBody: JSON.stringify({ model: 'alpha/gpt-4', messages: [] }) },
516
+ sinkRes(),
517
+ )
518
+ const st = router.usage.stats('today')
519
+ assert.equal(st.requests, 1)
520
+ assert.equal(st.failed, 1)
521
+ assert.equal(st.ok, 0)
522
+ })
523
+
524
+ test('请求路径:失败请求不估算 token(没到上游就不该有输入 token)', async () => {
525
+ const router = new Router('')
526
+ add(router, spy('alpha', 'gpt-4', { ok: false, state: 'quota', message: 'no quota' }).s)
527
+ await router.chatCompletions(
528
+ { model: 'alpha/gpt-4', stream: false, rawBody: JSON.stringify({ model: 'alpha/gpt-4', messages: [{ role: 'user', content: 'x'.repeat(400) }] }) },
529
+ sinkRes(),
530
+ )
531
+ const st = router.usage.stats('today')
532
+ assert.equal(st.failed, 1)
533
+ // 请求体 400+ 字符,若按字符数估算会有 ~100 输入 token——那是编造的
534
+ assert.equal(st.promptTokens, 0)
535
+ assert.equal(st.completionTokens, 0)
536
+ assert.equal(st.estimatedInputs, 0)
537
+ assert.equal(st.estimatedOutputs, 0)
538
+ })
539
+
540
+ test('请求路径:组合回退试了 3 个模型,只记 1 条(不是 3 条)', async () => {
541
+ const router = new Router('')
542
+ add(router, spy('s1', 'm1', { ok: false, state: 'quota', message: 'no quota' }).s)
543
+ add(router, spy('s2', 'm2', { ok: false, state: 'quota', message: 'no quota' }).s)
544
+ add(router, spy('s3', 'm3', { ok: true, status: 200, body: '{"usage":{"prompt_tokens":1,"completion_tokens":1}}' }).s)
545
+ assert.equal(router.createCombo('c1', 'fallback', ['s1,m1', 's2,m2', 's3,m3']).ok, true)
546
+ await router.chatCompletions(
547
+ { model: 'c1', stream: false, rawBody: JSON.stringify({ model: 'c1', messages: [] }) },
548
+ sinkRes(),
549
+ )
550
+ const st = router.usage.stats('today')
551
+ // 一个客户端请求 = 一条记录,记的是**最终成功**的那个供应商/模型
552
+ assert.equal(st.requests, 1)
553
+ assert.equal(st.ok, 1)
554
+ assert.deepEqual(st.bySupplier.map((r) => r.name), ['s3'])
555
+ // 模型记对外全名 alias/model(与直接调用路径一致,Top 榜不会分裂成两行)
556
+ assert.deepEqual(st.byModel.map((r) => r.name), ['s3/m3'])
557
+ assert.equal(st.byRequested[0]?.name, 'c1')
558
+ })
package/src/shared.ts CHANGED
@@ -150,3 +150,80 @@ export interface RouterLoginResponse {
150
150
  error?: string
151
151
  loginUrl?: string
152
152
  }
153
+
154
+ /* ---------------- 概览看板(用量统计) ---------------- */
155
+
156
+ /** 面板周期。 */
157
+ export type RouterPeriod = 'today' | '24h' | '7d' | '30d'
158
+
159
+ /** 一条请求明细(面板「最近请求」)。 */
160
+ export interface RouterUsageRecord {
161
+ ts: number
162
+ supplier: string
163
+ model: string
164
+ /** 客户端原始请求的 model(组合场景下就是组合名)。 */
165
+ requested: string
166
+ ok: boolean
167
+ promptTokens: number
168
+ completionTokens: number
169
+ cachedTokens: number
170
+ /** 输入是估算的(上游没发 usage)。 */
171
+ inputEstimated: boolean
172
+ /** 输出是估算的。 */
173
+ outputEstimated: boolean
174
+ durationMs: number
175
+ /** 首字节延迟(ms);非流式为 0。 */
176
+ ttfbMs: number
177
+ error?: string
178
+ }
179
+
180
+ /** Top 榜一行。 */
181
+ export interface RouterRankRow {
182
+ name: string
183
+ requests: number
184
+ ok: number
185
+ failed: number
186
+ promptTokens: number
187
+ completionTokens: number
188
+ lastTs: number
189
+ }
190
+
191
+ /** `/router/api/stats` response. */
192
+ export interface RouterStatsResponse {
193
+ ok: boolean
194
+ error?: string
195
+ period?: RouterPeriod
196
+ requests?: number
197
+ okCount?: number
198
+ failed?: number
199
+ promptTokens?: number
200
+ completionTokens?: number
201
+ cachedTokens?: number
202
+ /** 平均耗时(ms)。 */
203
+ avgDurationMs?: number
204
+ /** 平均首字节延迟(ms)。 */
205
+ avgTtfbMs?: number
206
+ /** 输入/输出为估算的请求数(面板据此提示)。 */
207
+ estimatedInputs?: number
208
+ estimatedOutputs?: number
209
+ /** 累计请求数(不受周期影响)。 */
210
+ lifetime?: number
211
+ bySupplier?: RouterRankRow[]
212
+ byModel?: RouterRankRow[]
213
+ byRequested?: RouterRankRow[]
214
+ recent?: RouterUsageRecord[]
215
+ }
216
+
217
+ /** `/router/api/stats/chart` 一桶。 */
218
+ export interface RouterChartBucket {
219
+ label: string
220
+ requests: number
221
+ tokens: number
222
+ }
223
+
224
+ /** `/router/api/stats/chart` response. */
225
+ export interface RouterChartResponse {
226
+ ok: boolean
227
+ error?: string
228
+ chart?: RouterChartBucket[]
229
+ }