dsh-router-core 0.2.0 → 0.3.1

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 (38) hide show
  1. package/README.md +40 -11
  2. package/docs/screenshot.png +0 -0
  3. package/docs/suppliers.md +302 -0
  4. package/lib/client-registry.js +1119 -213
  5. package/lib/client.js +1119 -213
  6. package/lib/index.js +1066 -50
  7. package/lib/types/llm/adapter.d.ts +2 -0
  8. package/lib/types/router/index.d.ts +34 -5
  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 +86 -1
  12. package/lib/types/supplier-config.d.ts +15 -1
  13. package/package.json +21 -4
  14. package/src/client/CheckinCard.tsx +180 -0
  15. package/src/client/CombosTab.tsx +28 -18
  16. package/src/client/RouterView.tsx +44 -27
  17. package/src/client/StatsTab.tsx +531 -0
  18. package/src/client/index.tsx +61 -11
  19. package/src/client/router.css +688 -35
  20. package/src/client/settings-nav-icon.ts +55 -0
  21. package/src/client/settings-section.tsx +21 -0
  22. package/src/client/workspace-mount.tsx +2 -9
  23. package/src/index.ts +51 -3
  24. package/src/llm/adapter.test.ts +108 -0
  25. package/src/llm/adapter.ts +18 -6
  26. package/src/router/index.ts +461 -49
  27. package/src/router/models-cache.test.ts +269 -0
  28. package/src/router/precise-dispatch.test.ts +198 -0
  29. package/src/router/usage-store.ts +499 -0
  30. package/src/router/usage-tokens.ts +327 -0
  31. package/src/router/usage.test.ts +558 -0
  32. package/src/shared.ts +90 -1
  33. package/src/supplier-config.ts +30 -2
  34. package/src/suppliers/loader.ts +2 -1
  35. package/src/suppliers/nvidia/plugin.ts +2 -3
  36. package/src/suppliers/opencode/plugin.ts +2 -3
  37. package/src/suppliers/openrouter/plugin.ts +2 -3
  38. package/src/suppliers/registry.ts +7 -2
@@ -0,0 +1,499 @@
1
+ /**
2
+ * 用量落盘与聚合 —— 概览看板的数据源。
3
+ *
4
+ * 结构照 9router `usageRepo` 的双表思路(按天聚合 + 最近明细),但用 **JSON
5
+ * 落盘**而不是 SQLite:dsh-router 现有持久化全是 JSON(supplier-config.json /
6
+ * keys.json),不为一个看板引入数据库依赖。
7
+ *
8
+ * 两条查询路径(照 9router `getUsageStats` 的 `useDailySummary` 分叉):
9
+ * - `today` / `24h` → 走**明细环**(滚动窗口,天桶是自然日,对不上)
10
+ * - `7d` / `30d` → 走**天桶**(长期精确,不受明细环容量限制)
11
+ *
12
+ * 天花板(JSON 的代价,刻意接受):
13
+ * - 明细环只留最近 `RING_CAP` 条。超量时 today/24h 会**少算**(天桶不会,
14
+ * 所以 7d/30d 永远准)。要彻底准就把明细也落盘成按小时的桶,届时
15
+ * `chart()` 的小时桶直接读它即可,接口不用变。
16
+ * - 天桶保留 30 天(9router 有 SQLite 不 prune,JSON 撑不住,得自己限)
17
+ * - 落盘防抖 2s:进程被 kill 最多丢 2s 的计数。看板不是账本,可接受;
18
+ * 要严格就改成立即写,代价是每个请求一次磁盘写。
19
+ */
20
+ import { mkdirSync, readFileSync, writeFileSync, renameSync } from 'node:fs'
21
+ import { dirname, join } from 'node:path'
22
+ import type { Usage } from './usage-tokens.ts'
23
+
24
+ /** 一条明细(最近请求的环)。 */
25
+ export interface UsageRecord {
26
+ ts: number
27
+ supplier: string
28
+ model: string
29
+ /** 客户端原始请求的 model(组合场景下就是组合名)。 */
30
+ requested: string
31
+ ok: boolean
32
+ promptTokens: number
33
+ completionTokens: number
34
+ cachedTokens: number
35
+ inputEstimated: boolean
36
+ outputEstimated: boolean
37
+ durationMs: number
38
+ /** 首字节延迟(ms);非流式为 0。 */
39
+ ttfbMs: number
40
+ /** 失败原因(ok=false 时)。 */
41
+ error?: string
42
+ }
43
+
44
+ /** 按某个维度(供应商/模型/请求名)聚合的小计。 */
45
+ interface Entry {
46
+ requests: number
47
+ ok: number
48
+ failed: number
49
+ promptTokens: number
50
+ completionTokens: number
51
+ cachedTokens: number
52
+ lastTs: number
53
+ }
54
+
55
+ /** 一天一桶。 */
56
+ interface DayBucket {
57
+ requests: number
58
+ ok: number
59
+ failed: number
60
+ promptTokens: number
61
+ completionTokens: number
62
+ cachedTokens: number
63
+ durationMs: number
64
+ ttfbMs: number
65
+ /** 有 TTFB 记录的请求数(非流式为 0,不能拿 requests 当分母)。 */
66
+ ttfbCount: number
67
+ /** 输入是估算的请求数(面板据此提示「部分数据为估算」)。 */
68
+ estimatedInputs: number
69
+ estimatedOutputs: number
70
+ bySupplier: Record<string, Entry>
71
+ byModel: Record<string, Entry>
72
+ byRequested: Record<string, Entry>
73
+ }
74
+
75
+ interface UsageFile {
76
+ days: Record<string, DayBucket>
77
+ recent: UsageRecord[]
78
+ lifetime: number
79
+ }
80
+
81
+ export type Period = 'today' | '24h' | '7d' | '30d'
82
+
83
+ /** 明细环容量:够 today/24h 看,又不至于让 JSON 无限长。 */
84
+ const RING_CAP = 500
85
+ /** 天桶保留天数。 */
86
+ const KEEP_DAYS = 30
87
+ /** 落盘防抖。 */
88
+ const SAVE_DEBOUNCE_MS = 2000
89
+
90
+ const emptyDay = (): DayBucket => ({
91
+ requests: 0,
92
+ ok: 0,
93
+ failed: 0,
94
+ promptTokens: 0,
95
+ completionTokens: 0,
96
+ cachedTokens: 0,
97
+ durationMs: 0,
98
+ ttfbMs: 0,
99
+ ttfbCount: 0,
100
+ estimatedInputs: 0,
101
+ estimatedOutputs: 0,
102
+ bySupplier: {},
103
+ byModel: {},
104
+ byRequested: {},
105
+ })
106
+
107
+ const emptyEntry = (): Entry => ({
108
+ requests: 0, ok: 0, failed: 0,
109
+ promptTokens: 0, completionTokens: 0, cachedTokens: 0,
110
+ lastTs: 0,
111
+ })
112
+
113
+ /** 本地日期键 `YYYY-MM-DD`(照 9router getLocalDateKey,不按 UTC 切天)。 */
114
+ export function localDateKey(ts: number): string {
115
+ const d = new Date(ts)
116
+ const p = (n: number): string => String(n).padStart(2, '0')
117
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`
118
+ }
119
+
120
+ /** 用量存储。stateFile 为空时纯内存(测试用),不落盘。 */
121
+ export class UsageStore {
122
+ private fp = ''
123
+ private days = new Map<string, DayBucket>()
124
+ private recent: UsageRecord[] = []
125
+ private lifetime = 0
126
+ private timer: NodeJS.Timeout | null = null
127
+
128
+ constructor(stateFile: string) {
129
+ this.fp = stateFile ? join(dirname(stateFile), 'usage.json') : ''
130
+ this.load()
131
+ }
132
+
133
+ /**
134
+ * 记一次请求。
135
+ *
136
+ * **成功的零 token 请求不记**(照 9router `saveUsageStats` 的闸门:
137
+ * `in === 0 && out === 0` 直接 return,空请求混进来只会稀释统计)。
138
+ * **失败请求一定记** —— 不管有没有 token。成功率全靠它,挡掉的话
139
+ * 失败永远是 0 条,面板会显示 100% 成功,这个数字是骗人的。
140
+ */
141
+ record(r: Omit<UsageRecord, 'ts' | 'promptTokens' | 'completionTokens' | 'cachedTokens' | 'inputEstimated' | 'outputEstimated'> & { ts?: number }, usage: Usage): void {
142
+ const promptTokens = usage.promptTokens
143
+ const completionTokens = usage.completionTokens
144
+ if (r.ok && promptTokens === 0 && completionTokens === 0) return
145
+
146
+ const ts = r.ts ?? Date.now()
147
+ const rec: UsageRecord = {
148
+ ...r,
149
+ ts,
150
+ promptTokens,
151
+ completionTokens,
152
+ cachedTokens: usage.cachedTokens,
153
+ inputEstimated: usage.inputEstimated,
154
+ outputEstimated: usage.outputEstimated,
155
+ }
156
+ const key = localDateKey(ts)
157
+ let day = this.days.get(key)
158
+ if (day === undefined) {
159
+ day = emptyDay()
160
+ this.days.set(key, day)
161
+ }
162
+
163
+ day.requests += 1
164
+ if (rec.ok) day.ok += 1
165
+ else day.failed += 1
166
+ day.promptTokens += promptTokens
167
+ day.completionTokens += completionTokens
168
+ day.cachedTokens += rec.cachedTokens
169
+ day.durationMs += rec.durationMs
170
+ if (rec.ttfbMs > 0) {
171
+ day.ttfbMs += rec.ttfbMs
172
+ day.ttfbCount += 1
173
+ }
174
+ if (rec.inputEstimated) day.estimatedInputs += 1
175
+ if (rec.outputEstimated) day.estimatedOutputs += 1
176
+
177
+ this.bump(day.bySupplier, rec.supplier, rec)
178
+ this.bump(day.byModel, rec.model, rec)
179
+ this.bump(day.byRequested, rec.requested, rec)
180
+
181
+ this.recent.unshift(rec)
182
+ if (this.recent.length > RING_CAP) this.recent.length = RING_CAP
183
+ this.lifetime += 1
184
+
185
+ this.scheduleSave()
186
+ }
187
+
188
+ private bump(map: Record<string, Entry>, key: string, rec: UsageRecord): void {
189
+ if (key === '') return
190
+ const e = map[key] ?? emptyEntry()
191
+ e.requests += 1
192
+ if (rec.ok) e.ok += 1
193
+ else e.failed += 1
194
+ e.promptTokens += rec.promptTokens
195
+ e.completionTokens += rec.completionTokens
196
+ e.cachedTokens += rec.cachedTokens
197
+ e.lastTs = Math.max(e.lastTs, rec.ts)
198
+ map[key] = e
199
+ }
200
+
201
+ /** 窗口内的明细(today/24h 路径)。 */
202
+ private inWindow(period: Period, now: number): UsageRecord[] {
203
+ const cutoff = period === 'today' ? new Date(now).setHours(0, 0, 0, 0) : now - 86400000
204
+ return this.recent.filter((r) => r.ts >= cutoff)
205
+ }
206
+
207
+ /** 汇总 + Top 榜。 */
208
+ stats(period: Period, now = Date.now()): StatsResult {
209
+ // today/24h 走明细环(滚动窗口);7d/30d 走天桶
210
+ if (period === 'today' || period === '24h') {
211
+ const rows = this.inWindow(period, now)
212
+ const acc = emptyAcc()
213
+ const bySupplier: Record<string, Entry> = {}
214
+ const byModel: Record<string, Entry> = {}
215
+ const byRequested: Record<string, Entry> = {}
216
+ for (const rec of rows) {
217
+ addRec(acc, rec)
218
+ bumpInto(bySupplier, rec.supplier, rec)
219
+ bumpInto(byModel, rec.model, rec)
220
+ bumpInto(byRequested, rec.requested, rec)
221
+ }
222
+ return finish(acc, bySupplier, byModel, byRequested, this.lifetime)
223
+ }
224
+
225
+ const days = period === '7d' ? 7 : 30
226
+ const acc = emptyAcc()
227
+ const bySupplier: Record<string, Entry> = {}
228
+ const byModel: Record<string, Entry> = {}
229
+ const byRequested: Record<string, Entry> = {}
230
+ for (let i = days - 1; i >= 0; i -= 1) {
231
+ const d = this.days.get(localDateKey(now - i * 86400000))
232
+ if (d === undefined) continue
233
+ acc.requests += d.requests
234
+ acc.ok += d.ok
235
+ acc.failed += d.failed
236
+ acc.promptTokens += d.promptTokens
237
+ acc.completionTokens += d.completionTokens
238
+ acc.cachedTokens += d.cachedTokens
239
+ acc.durationMs += d.durationMs
240
+ acc.ttfbMs += d.ttfbMs
241
+ acc.ttfbCount += d.ttfbCount
242
+ acc.estimatedInputs += d.estimatedInputs
243
+ acc.estimatedOutputs += d.estimatedOutputs
244
+ mergeInto(bySupplier, d.bySupplier)
245
+ mergeInto(byModel, d.byModel)
246
+ mergeInto(byRequested, d.byRequested)
247
+ }
248
+ return finish(acc, bySupplier, byModel, byRequested, this.lifetime)
249
+ }
250
+
251
+ /** 柱状图数据:today/24h = 24 个小时桶(走明细环),7d/30d = 天桶。 */
252
+ chart(period: Period, now = Date.now()): ChartBucket[] {
253
+ if (period === 'today' || period === '24h') {
254
+ const rows = this.inWindow(period, now)
255
+ const start = period === 'today'
256
+ ? Math.floor(new Date(now).setHours(0, 0, 0, 0) / 3600000) * 3600000
257
+ : Math.floor((now - 23 * 3600000) / 3600000) * 3600000
258
+ const buckets: ChartBucket[] = Array.from({ length: 24 }, (_, i) => ({
259
+ label: `${String(new Date(start + i * 3600000).getHours()).padStart(2, '0')}:00`,
260
+ requests: 0,
261
+ tokens: 0,
262
+ }))
263
+ for (const rec of rows) {
264
+ const idx = Math.floor((rec.ts - start) / 3600000)
265
+ const b = buckets[idx]
266
+ if (b === undefined) continue
267
+ b.requests += 1
268
+ b.tokens += rec.promptTokens + rec.completionTokens
269
+ }
270
+ return buckets
271
+ }
272
+ const days = period === '7d' ? 7 : 30
273
+ const out: ChartBucket[] = []
274
+ for (let i = days - 1; i >= 0; i -= 1) {
275
+ const ts = now - i * 86400000
276
+ const d = this.days.get(localDateKey(ts))
277
+ out.push({
278
+ label: localDateKey(ts).slice(5),
279
+ requests: d?.requests ?? 0,
280
+ tokens: (d?.promptTokens ?? 0) + (d?.completionTokens ?? 0),
281
+ })
282
+ }
283
+ return out
284
+ }
285
+
286
+ /** 最近请求明细。 */
287
+ recentList(limit = 20): UsageRecord[] {
288
+ return this.recent.slice(0, limit)
289
+ }
290
+
291
+ clear(): void {
292
+ this.days.clear()
293
+ this.recent = []
294
+ this.lifetime = 0
295
+ this.save()
296
+ }
297
+
298
+ /** 进程退出前把防抖中的写入落盘。 */
299
+ flush(): void {
300
+ if (this.timer !== null) {
301
+ clearTimeout(this.timer)
302
+ this.timer = null
303
+ }
304
+ this.save()
305
+ }
306
+
307
+ private scheduleSave(): void {
308
+ if (this.fp === '' || this.timer !== null) return
309
+ this.timer = setTimeout(() => {
310
+ this.timer = null
311
+ this.save()
312
+ }, SAVE_DEBOUNCE_MS)
313
+ this.timer.unref?.()
314
+ }
315
+
316
+ private load(): void {
317
+ if (this.fp === '') return
318
+ try {
319
+ const f = JSON.parse(readFileSync(this.fp, 'utf8')) as Partial<UsageFile>
320
+ this.days = new Map(Object.entries(f.days ?? {}))
321
+ this.recent = Array.isArray(f.recent) ? f.recent.slice(0, RING_CAP) : []
322
+ this.lifetime = typeof f.lifetime === 'number' ? f.lifetime : 0
323
+ // 逐字段兜底:旧文件缺后加的字段(ttfb*/estimated*)不能让整个桶变 NaN
324
+ for (const [k, d] of this.days) this.days.set(k, { ...emptyDay(), ...d })
325
+ } catch {
326
+ // 首次运行/文件损坏:从空开始,不阻断启动
327
+ }
328
+ }
329
+
330
+ private save(): void {
331
+ if (this.fp === '') return
332
+ // prune 超过保留期的天桶(JSON 不能无限长)
333
+ const cutoff = localDateKey(Date.now() - KEEP_DAYS * 86400000)
334
+ for (const k of [...this.days.keys()]) {
335
+ if (k < cutoff) this.days.delete(k)
336
+ }
337
+ const f: UsageFile = {
338
+ days: Object.fromEntries(this.days),
339
+ recent: this.recent,
340
+ lifetime: this.lifetime,
341
+ }
342
+ try {
343
+ const dir = dirname(this.fp)
344
+ if (dir !== '' && dir !== '.') mkdirSync(dir, { recursive: true })
345
+ const tmp = `${this.fp}.tmp`
346
+ writeFileSync(tmp, JSON.stringify(f), { mode: 0o600 })
347
+ renameSync(tmp, this.fp)
348
+ } catch {
349
+ // 持久化失败不阻断运行
350
+ }
351
+ }
352
+ }
353
+
354
+ /** 柱状图一桶。 */
355
+ export interface ChartBucket {
356
+ label: string
357
+ requests: number
358
+ tokens: number
359
+ }
360
+
361
+ export interface StatsResult {
362
+ requests: number
363
+ ok: number
364
+ failed: number
365
+ promptTokens: number
366
+ completionTokens: number
367
+ cachedTokens: number
368
+ /** 平均耗时(ms)。 */
369
+ avgDurationMs: number
370
+ /** 平均首字节延迟(ms);没有 TTFB 记录时为 0。 */
371
+ avgTtfbMs: number
372
+ /** 输入/输出为估算的请求数(面板提示用)。 */
373
+ estimatedInputs: number
374
+ estimatedOutputs: number
375
+ /** 累计请求数(不受周期影响)。 */
376
+ lifetime: number
377
+ bySupplier: RankRow[]
378
+ byModel: RankRow[]
379
+ byRequested: RankRow[]
380
+ }
381
+
382
+ export interface RankRow {
383
+ name: string
384
+ requests: number
385
+ ok: number
386
+ failed: number
387
+ promptTokens: number
388
+ completionTokens: number
389
+ lastTs: number
390
+ }
391
+
392
+ /** 累加器(stats 两条路径共用)。 */
393
+ interface Acc {
394
+ requests: number
395
+ ok: number
396
+ failed: number
397
+ promptTokens: number
398
+ completionTokens: number
399
+ cachedTokens: number
400
+ durationMs: number
401
+ ttfbMs: number
402
+ ttfbCount: number
403
+ estimatedInputs: number
404
+ estimatedOutputs: number
405
+ }
406
+
407
+ const emptyAcc = (): Acc => ({
408
+ requests: 0, ok: 0, failed: 0,
409
+ promptTokens: 0, completionTokens: 0, cachedTokens: 0,
410
+ durationMs: 0, ttfbMs: 0, ttfbCount: 0,
411
+ estimatedInputs: 0, estimatedOutputs: 0,
412
+ })
413
+
414
+ function addRec(acc: Acc, rec: UsageRecord): void {
415
+ acc.requests += 1
416
+ if (rec.ok) acc.ok += 1
417
+ else acc.failed += 1
418
+ acc.promptTokens += rec.promptTokens
419
+ acc.completionTokens += rec.completionTokens
420
+ acc.cachedTokens += rec.cachedTokens
421
+ acc.durationMs += rec.durationMs
422
+ if (rec.ttfbMs > 0) {
423
+ acc.ttfbMs += rec.ttfbMs
424
+ acc.ttfbCount += 1
425
+ }
426
+ if (rec.inputEstimated) acc.estimatedInputs += 1
427
+ if (rec.outputEstimated) acc.estimatedOutputs += 1
428
+ }
429
+
430
+ function bumpInto(map: Record<string, Entry>, key: string, rec: UsageRecord): void {
431
+ if (key === '') return
432
+ const e = map[key] ?? emptyEntry()
433
+ e.requests += 1
434
+ if (rec.ok) e.ok += 1
435
+ else e.failed += 1
436
+ e.promptTokens += rec.promptTokens
437
+ e.completionTokens += rec.completionTokens
438
+ e.cachedTokens += rec.cachedTokens
439
+ e.lastTs = Math.max(e.lastTs, rec.ts)
440
+ map[key] = e
441
+ }
442
+
443
+ function finish(
444
+ acc: Acc,
445
+ bySupplier: Record<string, Entry>,
446
+ byModel: Record<string, Entry>,
447
+ byRequested: Record<string, Entry>,
448
+ lifetime: number,
449
+ ): StatsResult {
450
+ return {
451
+ requests: acc.requests,
452
+ ok: acc.ok,
453
+ failed: acc.failed,
454
+ promptTokens: acc.promptTokens,
455
+ completionTokens: acc.completionTokens,
456
+ cachedTokens: acc.cachedTokens,
457
+ avgDurationMs: acc.requests > 0 ? Math.round(acc.durationMs / acc.requests) : 0,
458
+ avgTtfbMs: acc.ttfbCount > 0 ? Math.round(acc.ttfbMs / acc.ttfbCount) : 0,
459
+ estimatedInputs: acc.estimatedInputs,
460
+ estimatedOutputs: acc.estimatedOutputs,
461
+ lifetime,
462
+ bySupplier: top(bySupplier),
463
+ byModel: top(byModel),
464
+ byRequested: top(byRequested),
465
+ }
466
+ }
467
+
468
+ function mergeInto(target: Record<string, Entry>, src: Record<string, Entry>): void {
469
+ for (const [k, v] of Object.entries(src)) {
470
+ const t = target[k]
471
+ if (t === undefined) {
472
+ target[k] = { ...emptyEntry(), ...v }
473
+ continue
474
+ }
475
+ t.requests += v.requests
476
+ t.ok += v.ok
477
+ t.failed += v.failed
478
+ t.promptTokens += v.promptTokens
479
+ t.completionTokens += v.completionTokens
480
+ t.cachedTokens += v.cachedTokens
481
+ t.lastTs = Math.max(t.lastTs, v.lastTs)
482
+ }
483
+ }
484
+
485
+ /** Top 榜:按请求数降序,取前 10。 */
486
+ function top(map: Record<string, Entry>): RankRow[] {
487
+ return Object.entries(map)
488
+ .map(([name, e]) => ({
489
+ name,
490
+ requests: e.requests,
491
+ ok: e.ok,
492
+ failed: e.failed,
493
+ promptTokens: e.promptTokens,
494
+ completionTokens: e.completionTokens,
495
+ lastTs: e.lastTs,
496
+ }))
497
+ .sort((a, b) => b.requests - a.requests || b.lastTs - a.lastTs)
498
+ .slice(0, 10)
499
+ }