@mzzsfy/dsh-usage-dash 0.1.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 +49 -0
- package/cordis.patch.yml +9 -0
- package/package.json +46 -0
- package/src/client.js +2416 -0
- package/src/collector.js +327 -0
- package/src/index.js +104 -0
- package/src/pricing.js +119 -0
- package/src/query.js +193 -0
- package/src/routes.js +323 -0
- package/src/store.js +333 -0
- package/test/client.test.mjs +614 -0
- package/test/collector.test.mjs +603 -0
- package/test/pricing-parity.test.mjs +231 -0
- package/test/pricing.test.mjs +285 -0
- package/test/query.test.mjs +427 -0
- package/test/routes.test.mjs +566 -0
- package/test/stats-line.test.mjs +407 -0
- package/test/store.test.mjs +397 -0
- package/test/switch-guard.test.mjs +32 -0
- package/test/turn-tail.test.mjs +120 -0
package/src/store.js
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
// 用量统计存储:usage_stats 域,单表 buckets,行键 <粒度>|<桶串>|<provider>|<model>。
|
|
2
|
+
// 写入走 update 的原子读改写,缺键时 put 种子后重试一次;同一样本三粒度三行
|
|
3
|
+
// 全部尝试后聚合上抛,单粒度失败不造成其余粒度缺失。游标读写全部串行在
|
|
4
|
+
// 同一条 promise 链上,防 global 整值覆写的 lost update。进程级单例挂
|
|
5
|
+
// globalThis,防 HMR 热重载后重复开域。
|
|
6
|
+
|
|
7
|
+
import { defineDomain, domainTable } from '@deepseek-ai/dsh-storage-domain'
|
|
8
|
+
import { z } from 'zod'
|
|
9
|
+
|
|
10
|
+
export const GRANULARITY_DAILY = 'D'
|
|
11
|
+
export const GRANULARITY_HOURLY = 'H'
|
|
12
|
+
export const GRANULARITY_MINUTE = 'M'
|
|
13
|
+
|
|
14
|
+
export const PROVIDER_DEFAULT = 'default'
|
|
15
|
+
export const MODEL_TURNS = '(turns)'
|
|
16
|
+
export const MODEL_UNKNOWN = '(unknown)'
|
|
17
|
+
|
|
18
|
+
export const DEFAULT_MINUTE_RETENTION_DAYS = 2
|
|
19
|
+
|
|
20
|
+
// 分钟桶对齐粒度(分钟);小时/天桶不受影响
|
|
21
|
+
export const MINUTE_BUCKET_SPAN_MINUTES = 10
|
|
22
|
+
|
|
23
|
+
// 保留上限:小时桶固定 15 天,分钟桶可配置但最大 2 天(48h)
|
|
24
|
+
export const HOUR_RETENTION_DAYS = 15
|
|
25
|
+
export const MINUTE_RETENTION_MAX_DAYS = 2
|
|
26
|
+
|
|
27
|
+
const DAY_MS = 24 * 60 * 60 * 1000
|
|
28
|
+
const PAD_WIDTH = 2
|
|
29
|
+
const DOMAIN_NAME = 'usage_stats'
|
|
30
|
+
const TABLE_BUCKETS = 'buckets'
|
|
31
|
+
const MISSING_RECORD_PATTERN = /no record .* to update/
|
|
32
|
+
const MINUTE_KEY_PREFIX = `${GRANULARITY_MINUTE}|`
|
|
33
|
+
const HOUR_KEY_PREFIX = `${GRANULARITY_HOURLY}|`
|
|
34
|
+
|
|
35
|
+
// 保留值归一:非法回落默认,超出上限截到上限;0(禁用)合法保留
|
|
36
|
+
export function clampMinuteRetentionDays(days) {
|
|
37
|
+
const raw = Number.isFinite(days) && days >= 0 ? days : DEFAULT_MINUTE_RETENTION_DAYS
|
|
38
|
+
return Math.min(raw, MINUTE_RETENTION_MAX_DAYS)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// 桶串统一本地时区推导,同粒度内字典序即时间序
|
|
42
|
+
const pad = (value) => String(value).padStart(PAD_WIDTH, '0')
|
|
43
|
+
|
|
44
|
+
export function dayKey(ts) {
|
|
45
|
+
const d = new Date(ts)
|
|
46
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function hourKey(ts) {
|
|
50
|
+
return `${dayKey(ts)}T${pad(new Date(ts).getHours())}`
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// 分钟桶起点对齐 10 分钟:向下取整到桶边界,不跨时
|
|
54
|
+
export function minuteKey(ts) {
|
|
55
|
+
const d = new Date(ts)
|
|
56
|
+
const aligned = d.getMinutes() - (d.getMinutes() % MINUTE_BUCKET_SPAN_MINUTES)
|
|
57
|
+
return `${hourKey(ts)}:${pad(aligned)}`
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// 同一样本依次落三粒度
|
|
61
|
+
export const GRANULARITIES = [
|
|
62
|
+
[GRANULARITY_DAILY, dayKey],
|
|
63
|
+
[GRANULARITY_HOURLY, hourKey],
|
|
64
|
+
[GRANULARITY_MINUTE, minuteKey],
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
export function providerOf(modelRef) {
|
|
68
|
+
const slash = modelRef.indexOf('/')
|
|
69
|
+
return slash > 0 ? modelRef.slice(0, slash) : PROVIDER_DEFAULT
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export const usageRowSchema = z.object({
|
|
73
|
+
bucket: z.string(),
|
|
74
|
+
provider: z.string(),
|
|
75
|
+
model: z.string(),
|
|
76
|
+
inputTokens: z.number(),
|
|
77
|
+
outputTokens: z.number(),
|
|
78
|
+
cacheReadTokens: z.number(),
|
|
79
|
+
cacheWriteTokens: z.number(),
|
|
80
|
+
requests: z.number(),
|
|
81
|
+
turns: z.number(),
|
|
82
|
+
lastSeen: z.number(),
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
export const usageStatsDomain = defineDomain({
|
|
86
|
+
name: DOMAIN_NAME,
|
|
87
|
+
version: 1,
|
|
88
|
+
tables: {
|
|
89
|
+
[TABLE_BUCKETS]: domainTable(usageRowSchema),
|
|
90
|
+
},
|
|
91
|
+
global: {
|
|
92
|
+
schema: z.object({
|
|
93
|
+
backfilledSessions: z.array(z.string()),
|
|
94
|
+
liveFirstSeq: z.record(z.string(), z.number()).optional(),
|
|
95
|
+
}),
|
|
96
|
+
initial: { backfilledSessions: [], liveFirstSeq: {} },
|
|
97
|
+
},
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
export function rowKey(g, bucket, provider, model) {
|
|
101
|
+
return `${g}|${bucket}|${provider}|${model}`
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function emptyRow(bucket, provider, model, nowMs) {
|
|
105
|
+
return {
|
|
106
|
+
bucket,
|
|
107
|
+
provider,
|
|
108
|
+
model,
|
|
109
|
+
inputTokens: 0,
|
|
110
|
+
outputTokens: 0,
|
|
111
|
+
cacheReadTokens: 0,
|
|
112
|
+
cacheWriteTokens: 0,
|
|
113
|
+
requests: 0,
|
|
114
|
+
turns: 0,
|
|
115
|
+
lastSeen: nowMs,
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function isMissingRecord(err) {
|
|
120
|
+
return err instanceof Error && MISSING_RECORD_PATTERN.test(err.message)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export class UsageStore {
|
|
124
|
+
constructor(facility, options = {}) {
|
|
125
|
+
this.now = options.now ?? Date.now
|
|
126
|
+
this.retentionDays = options.retentionDays ?? (() => DEFAULT_MINUTE_RETENTION_DAYS)
|
|
127
|
+
this.table = null
|
|
128
|
+
this.domain = null
|
|
129
|
+
this.openError = undefined
|
|
130
|
+
this.lastPruneDay = ''
|
|
131
|
+
this.markChain = Promise.resolve()
|
|
132
|
+
// ready 永远 resolve:打开失败转降级,操作按调用失败,逃逸拒绝会拖垮宿主
|
|
133
|
+
this.ready = this.initialize(facility).catch((err) => {
|
|
134
|
+
this.openError = err
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async initialize(facility) {
|
|
139
|
+
const domain = await facility.open(usageStatsDomain)
|
|
140
|
+
this.domain = domain
|
|
141
|
+
this.table = domain.table(TABLE_BUCKETS)
|
|
142
|
+
// 游标空而已有行:无法区分半写坏态,丢弃行交由回扫重建
|
|
143
|
+
const cursor = this.readCursor()
|
|
144
|
+
if ((cursor?.backfilledSessions?.length ?? 0) === 0 && !this.table.keys().next().done) {
|
|
145
|
+
for (const key of [...this.table.keys()]) await this.table.delete(key)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
get degradation() {
|
|
150
|
+
return this.openError
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async readyPromise() {
|
|
154
|
+
await this.ready
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
readCursor() {
|
|
158
|
+
return this.domain?.global?.get()
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
degradedError() {
|
|
162
|
+
const detail = this.openError instanceof Error ? this.openError.message : String(this.openError)
|
|
163
|
+
return new Error(`usage store degraded (domain unavailable: ${detail})`)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
requireTable() {
|
|
167
|
+
if (this.openError !== undefined) throw this.degradedError()
|
|
168
|
+
return this.table
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
requireCursor() {
|
|
172
|
+
if (this.openError !== undefined) throw this.degradedError()
|
|
173
|
+
return this.readCursor() ?? {}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async record(sample) {
|
|
177
|
+
await this.ready
|
|
178
|
+
const table = this.requireTable()
|
|
179
|
+
await this.pruneOncePerDay()
|
|
180
|
+
const nowMs = this.now()
|
|
181
|
+
const outcomes = await Promise.allSettled(
|
|
182
|
+
GRANULARITIES.map(([g, bucketOf]) => this.recordRow(table, g, bucketOf(sample.time), sample, nowMs)),
|
|
183
|
+
)
|
|
184
|
+
const failures = outcomes.flatMap((outcome) => (outcome.status === 'rejected' ? [outcome.reason] : []))
|
|
185
|
+
if (failures.length > 0) throw new AggregateError(failures, 'usage store record failed')
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async pruneOncePerDay() {
|
|
189
|
+
const today = dayKey(this.now())
|
|
190
|
+
if (today === this.lastPruneDay) return
|
|
191
|
+
this.lastPruneDay = today
|
|
192
|
+
try {
|
|
193
|
+
await this.pruneMinutes()
|
|
194
|
+
await this.pruneHours()
|
|
195
|
+
} catch {
|
|
196
|
+
// 清理失败不阻断写入,次日首写再试
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// days = 0 表示禁用分钟桶:全量清理而非按窗口保留
|
|
201
|
+
async pruneMinutes(days = this.retentionDays()) {
|
|
202
|
+
await this.ready
|
|
203
|
+
const table = this.requireTable()
|
|
204
|
+
const retention = clampMinuteRetentionDays(days)
|
|
205
|
+
const cutoff = retention === 0 ? null : minuteKey(this.now() - retention * DAY_MS)
|
|
206
|
+
for (const [key, existing] of table.entries()) {
|
|
207
|
+
if (!key.startsWith(MINUTE_KEY_PREFIX)) continue
|
|
208
|
+
if (cutoff === null || existing.bucket < cutoff) await table.delete(key)
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// 小时桶保留固定 15 天,无配置项
|
|
213
|
+
async pruneHours() {
|
|
214
|
+
await this.ready
|
|
215
|
+
const table = this.requireTable()
|
|
216
|
+
const cutoff = hourKey(this.now() - HOUR_RETENTION_DAYS * DAY_MS)
|
|
217
|
+
for (const [key, existing] of table.entries()) {
|
|
218
|
+
if (!key.startsWith(HOUR_KEY_PREFIX)) continue
|
|
219
|
+
if (existing.bucket < cutoff) await table.delete(key)
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async recordRow(table, g, bucket, sample, nowMs) {
|
|
224
|
+
const model = sample.turn ? MODEL_TURNS : sample.model ? sample.model : MODEL_UNKNOWN
|
|
225
|
+
const provider = sample.turn ? PROVIDER_DEFAULT : providerOf(model)
|
|
226
|
+
const key = rowKey(g, bucket, provider, model)
|
|
227
|
+
const apply = (current) => {
|
|
228
|
+
const base = current ?? emptyRow(bucket, provider, model, nowMs)
|
|
229
|
+
if (sample.turn) return { ...base, turns: base.turns + 1, lastSeen: nowMs }
|
|
230
|
+
if (sample.request) return { ...base, requests: base.requests + 1, lastSeen: nowMs }
|
|
231
|
+
return {
|
|
232
|
+
...base,
|
|
233
|
+
inputTokens: base.inputTokens + sample.inputTokens,
|
|
234
|
+
outputTokens: base.outputTokens + sample.outputTokens,
|
|
235
|
+
cacheReadTokens: base.cacheReadTokens + sample.cacheReadTokens,
|
|
236
|
+
cacheWriteTokens: base.cacheWriteTokens + sample.cacheWriteTokens,
|
|
237
|
+
lastSeen: nowMs,
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
try {
|
|
241
|
+
await table.update(key, apply)
|
|
242
|
+
} catch (err) {
|
|
243
|
+
if (!isMissingRecord(err)) throw err
|
|
244
|
+
await table.put(key, emptyRow(bucket, provider, model, nowMs))
|
|
245
|
+
await table.update(key, apply)
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async rangeRows(g, from, to) {
|
|
250
|
+
await this.ready
|
|
251
|
+
const table = this.requireTable()
|
|
252
|
+
const prefix = `${g}|`
|
|
253
|
+
const matched = []
|
|
254
|
+
for (const [key, existing] of table.entries()) {
|
|
255
|
+
if (!key.startsWith(prefix)) continue
|
|
256
|
+
if (existing.bucket >= from && existing.bucket <= to) matched.push(existing)
|
|
257
|
+
}
|
|
258
|
+
return matched
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async seenSessions() {
|
|
262
|
+
await this.ready
|
|
263
|
+
return new Set(this.readCursor()?.backfilledSessions ?? [])
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async liveSequences() {
|
|
267
|
+
await this.ready
|
|
268
|
+
return new Map(Object.entries(this.readCursor()?.liveFirstSeq ?? {}))
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
markSeenSessions(ids) {
|
|
272
|
+
return this.enqueueGlobalWrite(async () => {
|
|
273
|
+
const cursor = this.requireCursor()
|
|
274
|
+
const seen = new Set(cursor.backfilledSessions ?? [])
|
|
275
|
+
for (const id of ids) seen.add(id)
|
|
276
|
+
await this.writeGlobal([...seen], cursor.liveFirstSeq ?? {})
|
|
277
|
+
})
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
markLiveSequences(entries) {
|
|
281
|
+
return this.enqueueGlobalWrite(async () => {
|
|
282
|
+
const cursor = this.requireCursor()
|
|
283
|
+
const merged = { ...(cursor.liveFirstSeq ?? {}) }
|
|
284
|
+
let changed = false
|
|
285
|
+
for (const [id, seq] of entries) {
|
|
286
|
+
if (merged[id] === undefined || seq < merged[id]) {
|
|
287
|
+
merged[id] = seq
|
|
288
|
+
changed = true
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
if (!changed) return
|
|
292
|
+
await this.writeGlobal(cursor.backfilledSessions ?? [], merged)
|
|
293
|
+
})
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
reset(boundaries) {
|
|
297
|
+
return this.enqueueGlobalWrite(async () => {
|
|
298
|
+
const table = this.requireTable()
|
|
299
|
+
for (const key of [...table.keys()]) await table.delete(key)
|
|
300
|
+
const liveFirstSeq = {}
|
|
301
|
+
if (boundaries) for (const [id, seq] of boundaries) liveFirstSeq[id] = seq
|
|
302
|
+
await this.writeGlobal([], liveFirstSeq)
|
|
303
|
+
})
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// global 只有整值覆写:全部游标写挂同一链,读改写不再交错
|
|
307
|
+
enqueueGlobalWrite(write) {
|
|
308
|
+
const pending = this.markChain.then(async () => {
|
|
309
|
+
await this.ready
|
|
310
|
+
await write()
|
|
311
|
+
})
|
|
312
|
+
this.markChain = pending.then(() => {}, () => {})
|
|
313
|
+
return pending
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async writeGlobal(backfilledSessions, liveFirstSeq) {
|
|
317
|
+
await this.domain.global.set({ backfilledSessions, liveFirstSeq })
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const SHARED_STORE_KEY = '__dshUsageDashStore'
|
|
322
|
+
|
|
323
|
+
export function sharedStore(facility, options = {}) {
|
|
324
|
+
const existing = globalThis[SHARED_STORE_KEY]
|
|
325
|
+
if (existing) return existing
|
|
326
|
+
const store = new UsageStore(facility, options)
|
|
327
|
+
globalThis[SHARED_STORE_KEY] = store
|
|
328
|
+
return store
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export function __resetSharedStoreForTests() {
|
|
332
|
+
delete globalThis[SHARED_STORE_KEY]
|
|
333
|
+
}
|