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.
- package/README.md +40 -11
- package/docs/screenshot.png +0 -0
- package/docs/suppliers.md +302 -0
- package/lib/client-registry.js +1119 -213
- package/lib/client.js +1119 -213
- package/lib/index.js +1066 -50
- package/lib/types/llm/adapter.d.ts +2 -0
- package/lib/types/router/index.d.ts +34 -5
- 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 +86 -1
- package/lib/types/supplier-config.d.ts +15 -1
- package/package.json +21 -4
- package/src/client/CheckinCard.tsx +180 -0
- package/src/client/CombosTab.tsx +28 -18
- package/src/client/RouterView.tsx +44 -27
- package/src/client/StatsTab.tsx +531 -0
- package/src/client/index.tsx +61 -11
- package/src/client/router.css +688 -35
- package/src/client/settings-nav-icon.ts +55 -0
- package/src/client/settings-section.tsx +21 -0
- package/src/client/workspace-mount.tsx +2 -9
- package/src/index.ts +51 -3
- package/src/llm/adapter.test.ts +108 -0
- package/src/llm/adapter.ts +18 -6
- package/src/router/index.ts +461 -49
- package/src/router/models-cache.test.ts +269 -0
- package/src/router/precise-dispatch.test.ts +198 -0
- 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 +90 -1
- package/src/supplier-config.ts +30 -2
- package/src/suppliers/loader.ts +2 -1
- package/src/suppliers/nvidia/plugin.ts +2 -3
- package/src/suppliers/opencode/plugin.ts +2 -3
- package/src/suppliers/openrouter/plugin.ts +2 -3
- 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
|
+
}
|