dsh-usage-badge 0.1.1 → 0.1.3
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 +33 -12
- package/docs/design-notes.md +172 -39
- package/lib/buckets.js +115 -0
- package/lib/cache-store.js +633 -0
- package/lib/client.js +1503 -195
- package/lib/fold.js +82 -178
- package/lib/holidays.js +5 -4
- package/lib/index.js +246 -30
- package/lib/official-pricing.js +14 -3
- package/lib/pricing.js +188 -20
- package/package.json +2 -2
package/lib/fold.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* dsh-usage-badge — session-log folding
|
|
2
|
+
* dsh-usage-badge — session-log folding.
|
|
3
3
|
*
|
|
4
4
|
* DSH persists one append-only log per session under `$DSH_HOME/sessions`. This
|
|
5
5
|
* module folds every log it finds into per-day / per-(provider, model) token
|
|
@@ -10,31 +10,28 @@
|
|
|
10
10
|
* Ported from the desktop shell's `usage-sidecar.mjs` fold, with the same
|
|
11
11
|
* discovery rules (generation selection per session directory) and the same
|
|
12
12
|
* size/mtime cursor that keeps a refresh proportional to what actually changed.
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* Where the folded history lives between runs is `cache-store.js`'s business, not
|
|
14
|
+
* this module's: a fold hands it one session at a time and asks it for the window
|
|
15
|
+
* back.
|
|
16
16
|
*
|
|
17
17
|
* @module dsh-usage-badge/fold
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import {
|
|
20
|
+
import { readFileSync, readdirSync, statSync } from 'node:fs'
|
|
21
21
|
import { homedir } from 'node:os'
|
|
22
22
|
import { join, resolve } from 'node:path'
|
|
23
23
|
import { zstdDecompressSync } from 'node:zlib'
|
|
24
24
|
|
|
25
|
-
import {
|
|
25
|
+
import { addBucket, emptyBucket, UNKNOWN } from './buckets.js'
|
|
26
|
+
import { dayKey, localHour } from './pricing.js'
|
|
26
27
|
|
|
27
|
-
|
|
28
|
-
export const UNKNOWN = 'unknown'
|
|
28
|
+
export { UNKNOWN }
|
|
29
29
|
|
|
30
30
|
/** Matches `session.jsonl`, `session.jsonl.zstd`, `session.v2.jsonl(.zstd)`, … */
|
|
31
31
|
const SESSION_LOG_RE = /^session(?:\.v(\d+))?\.jsonl(?:\.zstd)?$/
|
|
32
32
|
|
|
33
33
|
const ZSTD_MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd])
|
|
34
34
|
|
|
35
|
-
/** This plugin's cache format version. Unknown versions are ignored, not migrated. */
|
|
36
|
-
const CACHE_VERSION = 1
|
|
37
|
-
|
|
38
35
|
/** Files folded between event-loop yields, so a cold scan never stalls the host. */
|
|
39
36
|
const YIELD_EVERY = 25
|
|
40
37
|
|
|
@@ -141,58 +138,6 @@ export function sessionIdFromLogPath(file) {
|
|
|
141
138
|
return parts.length >= 2 ? parts[parts.length - 2] : file
|
|
142
139
|
}
|
|
143
140
|
|
|
144
|
-
function emptyBucket(provider, model) {
|
|
145
|
-
return {
|
|
146
|
-
provider,
|
|
147
|
-
model,
|
|
148
|
-
input: 0,
|
|
149
|
-
cacheRead: 0,
|
|
150
|
-
cacheWrite: 0,
|
|
151
|
-
output: 0,
|
|
152
|
-
requests: 0,
|
|
153
|
-
hourly: initHourly(),
|
|
154
|
-
usageRecords: [],
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/**
|
|
159
|
-
* Add `source` into `target`.
|
|
160
|
-
*
|
|
161
|
-
* A missing per-request list marks a legacy aggregate row: mixing one into a
|
|
162
|
-
* bucket that has a list would silently drop the aggregate's share from the
|
|
163
|
-
* context-tier price, so the list is dropped to `null` for the whole bucket
|
|
164
|
-
* instead, and that bucket falls back to flat pricing. Appending is looped rather
|
|
165
|
-
* than spread because one day bucket can hold hundreds of thousands of requests,
|
|
166
|
-
* which would overflow the argument list long before it exhausted memory.
|
|
167
|
-
*/
|
|
168
|
-
function addBucket(target, source) {
|
|
169
|
-
target.input += Number(source?.input) || 0
|
|
170
|
-
target.cacheRead += Number(source?.cacheRead) || 0
|
|
171
|
-
target.cacheWrite += Number(source?.cacheWrite) || 0
|
|
172
|
-
target.output += Number(source?.output) || 0
|
|
173
|
-
target.requests += Number(source?.requests) || 0
|
|
174
|
-
if (!Array.isArray(target.hourly)) target.hourly = initHourly()
|
|
175
|
-
if (Array.isArray(source?.hourly)) {
|
|
176
|
-
for (let hour = 0; hour < 24; hour++) {
|
|
177
|
-
const from = source.hourly[hour]
|
|
178
|
-
const to = target.hourly[hour]
|
|
179
|
-
if (!from || !to) continue
|
|
180
|
-
to.input += Number(from.input) || 0
|
|
181
|
-
to.cacheRead += Number(from.cacheRead) || 0
|
|
182
|
-
to.cacheWrite += Number(from.cacheWrite) || 0
|
|
183
|
-
to.output += Number(from.output) || 0
|
|
184
|
-
to.requests += Number(from.requests) || 0
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
if (!Array.isArray(target.usageRecords) || !Array.isArray(source?.usageRecords)) {
|
|
188
|
-
target.usageRecords = null
|
|
189
|
-
} else {
|
|
190
|
-
for (const usage of source.usageRecords) {
|
|
191
|
-
if (usage && typeof usage === 'object') target.usageRecords.push(usage)
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
|
|
196
141
|
/**
|
|
197
142
|
* Fold one session log into per-day, per-route buckets.
|
|
198
143
|
*
|
|
@@ -299,47 +244,31 @@ export function foldSession(file, previous) {
|
|
|
299
244
|
}
|
|
300
245
|
}
|
|
301
246
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
const dayObj = new Map()
|
|
325
|
-
for (const [key, source] of Object.entries(sourceDay)) {
|
|
326
|
-
if (!source || typeof source !== 'object') continue
|
|
327
|
-
const bucket = emptyBucket(String(source.provider ?? UNKNOWN), String(source.model ?? UNKNOWN))
|
|
328
|
-
addBucket(bucket, source)
|
|
329
|
-
dayObj.set(key, bucket)
|
|
247
|
+
/**
|
|
248
|
+
* Merge session records into the per-day / per-route view the snapshot reads.
|
|
249
|
+
*
|
|
250
|
+
* Exported because a caller that reads a slice of history straight from the store (the year
|
|
251
|
+
* view does) needs exactly this merge rather than a second implementation of it.
|
|
252
|
+
*
|
|
253
|
+
* @param {Iterable<object>} records - session records, each carrying a `days` map.
|
|
254
|
+
*/
|
|
255
|
+
export function mergeRecords(records) {
|
|
256
|
+
const merged = new Map()
|
|
257
|
+
for (const record of records) {
|
|
258
|
+
for (const [date, sourceDay] of record.days ?? []) {
|
|
259
|
+
let dayObj = merged.get(date)
|
|
260
|
+
if (!dayObj) {
|
|
261
|
+
dayObj = new Map()
|
|
262
|
+
merged.set(date, dayObj)
|
|
263
|
+
}
|
|
264
|
+
for (const [key, source] of sourceDay) {
|
|
265
|
+
const target = dayObj.get(key) ?? emptyBucket(source.provider, source.model)
|
|
266
|
+
addBucket(target, source)
|
|
267
|
+
dayObj.set(key, target)
|
|
268
|
+
}
|
|
330
269
|
}
|
|
331
|
-
days.set(date, dayObj)
|
|
332
|
-
}
|
|
333
|
-
return {
|
|
334
|
-
sessionId,
|
|
335
|
-
path: String(record.path ?? ''),
|
|
336
|
-
fileSize: Number(record.fileSize) || 0,
|
|
337
|
-
fileMtimeMs: Number(record.fileMtimeMs) || 0,
|
|
338
|
-
seq: Number(record.seq) || -1,
|
|
339
|
-
provider: record.provider ?? null,
|
|
340
|
-
model: record.model ?? null,
|
|
341
|
-
days,
|
|
342
270
|
}
|
|
271
|
+
return merged
|
|
343
272
|
}
|
|
344
273
|
|
|
345
274
|
/**
|
|
@@ -350,46 +279,37 @@ function deserializeSession(sessionId, record) {
|
|
|
350
279
|
* per-session records on every refresh; that is deliberate, because rebuilding is
|
|
351
280
|
* what makes a deleted log keep its cached contribution instead of erasing
|
|
352
281
|
* history that the log no longer exists to prove.
|
|
282
|
+
*
|
|
283
|
+
* @param {{sessionsRoot:string, store:object, log?:Function}} options `store` is the cache
|
|
284
|
+
* from `cache-store.js`: a fold writes the sessions it changed and reads a window back.
|
|
353
285
|
*/
|
|
354
|
-
export function createAggregator({ sessionsRoot,
|
|
286
|
+
export function createAggregator({ sessionsRoot, store, log = () => {} }) {
|
|
355
287
|
const records = new Map()
|
|
356
288
|
const cursors = new Map()
|
|
357
289
|
let days = new Map()
|
|
358
|
-
let saveTimer = null
|
|
359
290
|
let lastRefresh = null
|
|
360
291
|
|
|
361
292
|
function mergeDays() {
|
|
362
|
-
|
|
363
|
-
for (const record of records.values()) {
|
|
364
|
-
for (const [date, sourceDay] of record.days ?? []) {
|
|
365
|
-
let dayObj = merged.get(date)
|
|
366
|
-
if (!dayObj) {
|
|
367
|
-
dayObj = new Map()
|
|
368
|
-
merged.set(date, dayObj)
|
|
369
|
-
}
|
|
370
|
-
for (const [key, source] of sourceDay) {
|
|
371
|
-
const target = dayObj.get(key) ?? emptyBucket(source.provider, source.model)
|
|
372
|
-
addBucket(target, source)
|
|
373
|
-
dayObj.set(key, target)
|
|
374
|
-
}
|
|
375
|
-
}
|
|
376
|
-
}
|
|
377
|
-
days = merged
|
|
293
|
+
days = mergeRecords(records.values())
|
|
378
294
|
}
|
|
379
295
|
|
|
296
|
+
/**
|
|
297
|
+
* Take back the stored history, oldest row first dropped into the window the panel can
|
|
298
|
+
* show. Sessions whose days all fall outside the window still come back — with their
|
|
299
|
+
* cursor, so their logs are never folded again — just without the buckets that no view
|
|
300
|
+
* asks for. Their rows stay in the store.
|
|
301
|
+
*/
|
|
380
302
|
function loadCache() {
|
|
381
|
-
let
|
|
303
|
+
let loaded
|
|
382
304
|
try {
|
|
383
|
-
|
|
384
|
-
} catch {
|
|
305
|
+
loaded = store.load()
|
|
306
|
+
} catch (error) {
|
|
307
|
+
log(`cache load failed: ${error?.message ?? error}`)
|
|
385
308
|
return false
|
|
386
309
|
}
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
if (!record) continue
|
|
391
|
-
records.set(sessionId, record)
|
|
392
|
-
cursors.set(sessionId, {
|
|
310
|
+
for (const record of loaded ?? []) {
|
|
311
|
+
records.set(record.sessionId, record)
|
|
312
|
+
cursors.set(record.sessionId, {
|
|
393
313
|
seq: record.seq,
|
|
394
314
|
provider: record.provider,
|
|
395
315
|
model: record.model,
|
|
@@ -399,64 +319,36 @@ export function createAggregator({ sessionsRoot, cachePath, log = () => {} }) {
|
|
|
399
319
|
})
|
|
400
320
|
}
|
|
401
321
|
mergeDays()
|
|
402
|
-
log(`cache loaded: ${records.size} session(s)`)
|
|
403
|
-
return
|
|
322
|
+
log(`cache loaded: ${records.size} session(s) from ${store.kind}`)
|
|
323
|
+
return records.size > 0
|
|
404
324
|
}
|
|
405
325
|
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
// The temporary name carries pid, time and entropy so a restarted host can
|
|
410
|
-
// never collide with a stale temp file another process still holds open.
|
|
411
|
-
const temp = `${cachePath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
|
412
|
-
try {
|
|
413
|
-
mkdirSync(join(cachePath, '..'), { recursive: true })
|
|
414
|
-
writeFileSync(temp, JSON.stringify({ version: CACHE_VERSION, sessions }))
|
|
415
|
-
try {
|
|
416
|
-
renameSync(temp, cachePath)
|
|
417
|
-
} catch {
|
|
418
|
-
// Windows cannot rename over an existing file; the complete temp file is
|
|
419
|
-
// already on disk, so removing the old one first is safe here.
|
|
420
|
-
try {
|
|
421
|
-
unlinkSync(cachePath)
|
|
422
|
-
} catch {
|
|
423
|
-
// absent is fine
|
|
424
|
-
}
|
|
425
|
-
renameSync(temp, cachePath)
|
|
426
|
-
}
|
|
427
|
-
} catch (error) {
|
|
428
|
-
try {
|
|
429
|
-
unlinkSync(temp)
|
|
430
|
-
} catch {
|
|
431
|
-
// best-effort cleanup only
|
|
432
|
-
}
|
|
433
|
-
log(`cache save failed: ${error?.message ?? error}`)
|
|
434
|
-
}
|
|
326
|
+
/** Write through anything the store is holding back; called on warm-up and on unload. */
|
|
327
|
+
function flush() {
|
|
328
|
+
store.flush()
|
|
435
329
|
}
|
|
436
330
|
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
saveTimer = null
|
|
441
|
-
saveCache()
|
|
442
|
-
}, 2000)
|
|
443
|
-
saveTimer.unref?.()
|
|
331
|
+
/** Flush and release the store's handle. */
|
|
332
|
+
function close() {
|
|
333
|
+
store.close()
|
|
444
334
|
}
|
|
445
335
|
|
|
446
336
|
/**
|
|
447
337
|
* Fold every changed log.
|
|
448
338
|
*
|
|
339
|
+
* A changed session is written back immediately, one session at a time: that is the whole
|
|
340
|
+
* reason the store is not a single document, and it means there is no save timer to lose
|
|
341
|
+
* work in and no moment where a burst of edits has to be batched.
|
|
342
|
+
*
|
|
449
343
|
* @returns {Promise<{files:number, folded:number, ms:number}>} what this pass did.
|
|
450
344
|
*/
|
|
451
345
|
async function refresh() {
|
|
452
346
|
const started = Date.now()
|
|
453
347
|
const files = listLogFiles(sessionsRoot)
|
|
454
|
-
const seen = new Set()
|
|
455
348
|
let folded = 0
|
|
456
349
|
let sinceYield = 0
|
|
457
350
|
for (const file of files) {
|
|
458
351
|
const sessionId = sessionIdFromLogPath(file)
|
|
459
|
-
seen.add(sessionId)
|
|
460
352
|
const cursor = cursors.get(sessionId)
|
|
461
353
|
let stat
|
|
462
354
|
try {
|
|
@@ -467,8 +359,14 @@ export function createAggregator({ sessionsRoot, cachePath, log = () => {} }) {
|
|
|
467
359
|
if (cursor && cursor.fileSize === stat.size && cursor.fileMtimeMs === (stat.mtimeMs || 0) && cursor.path === file) {
|
|
468
360
|
continue
|
|
469
361
|
}
|
|
470
|
-
const
|
|
471
|
-
|
|
362
|
+
const previous = records.get(sessionId)
|
|
363
|
+
const record = foldSession(file, previous)
|
|
364
|
+
// `foldSession` hands the previous record straight back when it could not read the log, and
|
|
365
|
+
// that record is already in the store — writing it again would replace the session's rows
|
|
366
|
+
// with only the days this process happens to hold (the loading window), deleting the older
|
|
367
|
+
// ones on the way past. Leaving the cursor untouched also means the next refresh retries the
|
|
368
|
+
// fold, which is what a transient read failure wants.
|
|
369
|
+
if (record && record !== previous) {
|
|
472
370
|
records.set(sessionId, record)
|
|
473
371
|
cursors.set(sessionId, {
|
|
474
372
|
seq: record.seq,
|
|
@@ -479,18 +377,23 @@ export function createAggregator({ sessionsRoot, cachePath, log = () => {} }) {
|
|
|
479
377
|
path: record.path,
|
|
480
378
|
})
|
|
481
379
|
folded++
|
|
380
|
+
// Written back one session at a time, right here: a save costs the changed session's
|
|
381
|
+
// days, not the whole history, so there is nothing to batch and no timer to lose it in.
|
|
382
|
+
try {
|
|
383
|
+
store.put(record)
|
|
384
|
+
} catch (error) {
|
|
385
|
+
log(`cache write failed for ${sessionId}: ${error?.message ?? error}`)
|
|
386
|
+
}
|
|
482
387
|
}
|
|
483
388
|
if (++sinceYield >= YIELD_EVERY) {
|
|
484
389
|
sinceYield = 0
|
|
485
390
|
await new Promise((r) => setImmediate(r))
|
|
486
391
|
}
|
|
487
392
|
}
|
|
488
|
-
// A session whose log disappeared keeps its record (and therefore its
|
|
489
|
-
// history
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
scheduleCacheSave()
|
|
493
|
-
}
|
|
393
|
+
// A session whose log disappeared keeps its record (and therefore its history) but stops
|
|
394
|
+
// being refreshed — and, because nothing writes it again, that history stays in the store
|
|
395
|
+
// for good rather than being erased by the log's absence.
|
|
396
|
+
if (folded > 0) mergeDays()
|
|
494
397
|
lastRefresh = { files: files.length, folded, ms: Date.now() - started, at: Date.now() }
|
|
495
398
|
return lastRefresh
|
|
496
399
|
}
|
|
@@ -498,7 +401,8 @@ export function createAggregator({ sessionsRoot, cachePath, log = () => {} }) {
|
|
|
498
401
|
return {
|
|
499
402
|
loadCache,
|
|
500
403
|
refresh,
|
|
501
|
-
|
|
404
|
+
flush,
|
|
405
|
+
close,
|
|
502
406
|
get days() {
|
|
503
407
|
return days
|
|
504
408
|
},
|
package/lib/holidays.js
CHANGED
|
@@ -6,10 +6,11 @@
|
|
|
6
6
|
* > 空闲时段价格为高峰时段价格的一半。北京时间周一至周五(**不含中国法定节假日**)
|
|
7
7
|
* > 9:00 - 12:00、14:00 - 18:00 为高峰时段;其余时段,包括周末及**中国法定节假日全天均为空闲时段**。
|
|
8
8
|
*
|
|
9
|
-
* So a weekday holiday must be billed entirely off-peak, and
|
|
10
|
-
* the State Council turns into a working day
|
|
11
|
-
*
|
|
12
|
-
*
|
|
9
|
+
* So a weekday holiday must be billed entirely off-peak, and — the part that is easy to
|
|
10
|
+
* get backwards — a 调休 weekend the State Council turns into a working day stays a
|
|
11
|
+
* *weekend*, because the policy names 周末 as off-peak in full. This module therefore
|
|
12
|
+
* only ever *adds* off-peak days; it never turns one into a working day. (The 调休
|
|
13
|
+
* workdays are still recorded in `holidays-cn.json`, for audit.)
|
|
13
14
|
*
|
|
14
15
|
* The bundled table is per-year data transcribed from the State Council notices
|
|
15
16
|
* (see `holidays-cn.json`, which records document numbers and URLs). A year the
|