dsh-token-use 0.1.1 → 0.2.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.en.md +38 -9
- package/README.md +38 -9
- package/client/client.js +349 -44
- package/cordis.patch.yml +7 -0
- package/lib/index.js +121 -30
- package/lib/pricing.js +380 -0
- package/lib/scan-worker.js +2 -1
- package/package.json +2 -2
package/cordis.patch.yml
CHANGED
|
@@ -5,3 +5,10 @@
|
|
|
5
5
|
config:
|
|
6
6
|
endpoint: /dsh-token-use
|
|
7
7
|
scanAtBoot: true
|
|
8
|
+
# Official price book: one fetch per day at 12:00 local time, cached
|
|
9
|
+
# under $DSH_HOME/dsh-token-use/pricing.json. `enabled: false` keeps the
|
|
10
|
+
# plugin offline; `currency: USD` reads the English pricing page.
|
|
11
|
+
pricing:
|
|
12
|
+
enabled: true
|
|
13
|
+
currency: CNY
|
|
14
|
+
refreshHour: 12
|
package/lib/index.js
CHANGED
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
* watches, no periodic writes. History is rebuilt once at boot by streaming
|
|
7
7
|
* the zstd session logs with cooperative yields; from then on the fold only
|
|
8
8
|
* moves forward through the live event bus. A per-session seq watermark makes
|
|
9
|
-
* the boot scan and the live fold overlap-safe in either order.
|
|
9
|
+
* the boot scan and the live fold overlap-safe in either order. Every record
|
|
10
|
+
* is also priced at the DeepSeek rate of the moment it happened (see
|
|
11
|
+
* `pricing.js`), so each bucket carries an estimated amount alongside tokens.
|
|
10
12
|
*
|
|
11
13
|
* Queries: `GET /dsh-token-use` returns all-time buckets;
|
|
12
14
|
* `?day=YYYY-MM-DD` and `?month=YYYY-MM` return the matching window,
|
|
@@ -20,6 +22,7 @@ import { readdir, readFile, stat } from 'node:fs/promises'
|
|
|
20
22
|
import { join } from 'node:path'
|
|
21
23
|
import { zstdDecompressSync } from 'node:zlib'
|
|
22
24
|
import { scheduler } from 'node:timers/promises'
|
|
25
|
+
import { PricingStore } from './pricing.js'
|
|
23
26
|
|
|
24
27
|
export const name = 'dsh-token-use'
|
|
25
28
|
|
|
@@ -36,6 +39,25 @@ const USAGE_FIELDS = [
|
|
|
36
39
|
|
|
37
40
|
const LOOPBACK = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1'])
|
|
38
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Days of per-day buckets the snapshot carries for the trend chart. The client
|
|
44
|
+
* slices this down to its 7/30/90-day range switcher, so the widest range is
|
|
45
|
+
* served without another round trip.
|
|
46
|
+
*/
|
|
47
|
+
const TREND_DAYS = 90
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Auxiliary LLM calls that DeepSeek bills but that persist no `usage` record:
|
|
51
|
+
* session-title generation and the web-search tool's own model call. They are
|
|
52
|
+
* counted — so the dashboard can say how many calls the amount leaves out —
|
|
53
|
+
* but never priced, because their token counts exist only in the platform's
|
|
54
|
+
* ledger, not in the session log.
|
|
55
|
+
*/
|
|
56
|
+
const AUXILIARY_EVENTS = {
|
|
57
|
+
'session/title-llm-request': 'title',
|
|
58
|
+
'web/deepseek-search-llm-request': 'search',
|
|
59
|
+
}
|
|
60
|
+
|
|
39
61
|
/**
|
|
40
62
|
* Session artifact names: `session.jsonl[.zstd]` for the original generation
|
|
41
63
|
* and `session.v<N>.jsonl[.zstd]` for a versioned re-encoding of the same log.
|
|
@@ -103,11 +125,12 @@ function sessionParent(session) {
|
|
|
103
125
|
}
|
|
104
126
|
|
|
105
127
|
function emptyBucket() {
|
|
106
|
-
return { calls: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 }
|
|
128
|
+
return { calls: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, cost: 0 }
|
|
107
129
|
}
|
|
108
130
|
|
|
109
|
-
function addUsage(bucket, usage) {
|
|
131
|
+
function addUsage(bucket, usage, cost = 0) {
|
|
110
132
|
bucket.calls += 1
|
|
133
|
+
bucket.cost += cost
|
|
111
134
|
for (const [field, key] of USAGE_FIELDS) {
|
|
112
135
|
const value = usage[field]
|
|
113
136
|
if (typeof value === 'number' && Number.isFinite(value)) bucket[key] += value
|
|
@@ -116,7 +139,7 @@ function addUsage(bucket, usage) {
|
|
|
116
139
|
|
|
117
140
|
function mergeInto(target, source) {
|
|
118
141
|
target.calls += source.calls
|
|
119
|
-
for (const key of ['input', 'output', 'cacheRead', 'cacheWrite', 'reasoning']) target[key] += source[key]
|
|
142
|
+
for (const key of ['input', 'output', 'cacheRead', 'cacheWrite', 'reasoning', 'cost']) target[key] += source[key]
|
|
120
143
|
}
|
|
121
144
|
|
|
122
145
|
/**
|
|
@@ -228,6 +251,7 @@ export class UsageTracker {
|
|
|
228
251
|
constructor(dshHome = resolveDshHome(), options = {}) {
|
|
229
252
|
this.dshHome = dshHome
|
|
230
253
|
this.allowRemote = options.allowRemote === true
|
|
254
|
+
this.pricing = options.pricing ?? new PricingStore(dshHome, options.pricingOptions ?? {})
|
|
231
255
|
this.startedAt = Date.now()
|
|
232
256
|
this.totals = emptyBucket()
|
|
233
257
|
this.byModel = new Map()
|
|
@@ -236,6 +260,7 @@ export class UsageTracker {
|
|
|
236
260
|
this.dayModel = new Map()
|
|
237
261
|
this.dayProject = new Map()
|
|
238
262
|
this.dayModelProject = new Map()
|
|
263
|
+
this.dayAuxiliary = new Map()
|
|
239
264
|
this.lastModel = new Map()
|
|
240
265
|
this.seen = new Map()
|
|
241
266
|
this.scanning = false
|
|
@@ -273,10 +298,35 @@ export class UsageTracker {
|
|
|
273
298
|
return this.nestedBucket(second, secondKey, thirdKey)
|
|
274
299
|
}
|
|
275
300
|
|
|
301
|
+
/** Counters for auxiliary calls (title/search) on one day. */
|
|
302
|
+
auxiliaryBucket(day) {
|
|
303
|
+
let counts = this.dayAuxiliary.get(day)
|
|
304
|
+
if (counts === undefined) {
|
|
305
|
+
counts = { title: 0, search: 0, total: 0 }
|
|
306
|
+
this.dayAuxiliary.set(day, counts)
|
|
307
|
+
}
|
|
308
|
+
return counts
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Auxiliary-call counters summed over the given days. */
|
|
312
|
+
auxiliaryOver(days) {
|
|
313
|
+
const counts = { title: 0, search: 0, total: 0 }
|
|
314
|
+
for (const day of days) {
|
|
315
|
+
const found = this.dayAuxiliary.get(day)
|
|
316
|
+
if (found === undefined) continue
|
|
317
|
+
counts.title += found.title
|
|
318
|
+
counts.search += found.search
|
|
319
|
+
counts.total += found.total
|
|
320
|
+
}
|
|
321
|
+
return counts
|
|
322
|
+
}
|
|
323
|
+
|
|
276
324
|
/**
|
|
277
325
|
* Fold one decoded session event. `request/header` only updates the
|
|
278
|
-
* per-session model attribution;
|
|
279
|
-
*
|
|
326
|
+
* per-session model attribution; an auxiliary request is counted (its usage
|
|
327
|
+
* is never persisted, so it cannot be priced); `assistant/message` with a
|
|
328
|
+
* usage record is counted once, priced with the rate in effect right then.
|
|
329
|
+
* @returns true when the event was counted.
|
|
280
330
|
*/
|
|
281
331
|
fold(session, event) {
|
|
282
332
|
if (event === null || typeof event !== 'object' || session === null || typeof session !== 'object') return false
|
|
@@ -289,31 +339,42 @@ export class UsageTracker {
|
|
|
289
339
|
if (typeof model === 'string' && model.length > 0) this.lastModel.set(session.id, model)
|
|
290
340
|
return false
|
|
291
341
|
}
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
342
|
+
const auxiliary = AUXILIARY_EVENTS[event.type]
|
|
343
|
+
if (auxiliary === undefined && event.type !== 'assistant/message') return false
|
|
344
|
+
const usage = auxiliary === undefined ? event.data?.usage : undefined
|
|
345
|
+
if (auxiliary === undefined && (usage === null || typeof usage !== 'object')) return false
|
|
295
346
|
const seq = event.seq
|
|
296
347
|
const seen = this.seen.get(session.id)
|
|
297
348
|
if (typeof seen === 'number' && typeof seq === 'number' && seq <= seen) return false
|
|
298
349
|
if (typeof seq === 'number') this.seen.set(session.id, Math.max(seen ?? 0, seq))
|
|
299
|
-
|
|
350
|
+
const time = typeof event.time === 'number' ? event.time : undefined
|
|
351
|
+
if (auxiliary !== undefined) {
|
|
352
|
+
if (time !== undefined) {
|
|
353
|
+
const counts = this.auxiliaryBucket(localDay(time))
|
|
354
|
+
counts[auxiliary] += 1
|
|
355
|
+
counts.total += 1
|
|
356
|
+
}
|
|
357
|
+
return false
|
|
358
|
+
}
|
|
300
359
|
const model = this.lastModel.get(session.id) ?? 'unknown'
|
|
301
|
-
|
|
360
|
+
const cost = this.pricing.costOf(usage, model, time)
|
|
361
|
+
addUsage(this.totals, usage, cost)
|
|
362
|
+
addUsage(this.bucket(this.byModel, model), usage, cost)
|
|
302
363
|
const project = this.projectFor(session)
|
|
303
364
|
if (project === undefined) {
|
|
304
365
|
// The session's project is not known yet: hold the project-side usage
|
|
305
366
|
// until it resolves (a later event or the parent's resolution).
|
|
306
|
-
this.holdOrphan(session.id, usage,
|
|
367
|
+
this.holdOrphan(session.id, usage, cost, time === undefined ? undefined : localDay(time), model)
|
|
307
368
|
} else {
|
|
308
|
-
addUsage(this.bucket(this.byProject, project), usage)
|
|
369
|
+
addUsage(this.bucket(this.byProject, project), usage, cost)
|
|
309
370
|
}
|
|
310
|
-
if (
|
|
311
|
-
const day = localDay(
|
|
312
|
-
addUsage(this.bucket(this.byDay, day), usage)
|
|
313
|
-
addUsage(this.nestedBucket(this.dayModel, day, model), usage)
|
|
371
|
+
if (time !== undefined) {
|
|
372
|
+
const day = localDay(time)
|
|
373
|
+
addUsage(this.bucket(this.byDay, day), usage, cost)
|
|
374
|
+
addUsage(this.nestedBucket(this.dayModel, day, model), usage, cost)
|
|
314
375
|
if (project !== undefined) {
|
|
315
|
-
addUsage(this.nestedBucket(this.dayProject, day, project), usage)
|
|
316
|
-
addUsage(this.nestedBucket3(this.dayModelProject, day, model, project), usage)
|
|
376
|
+
addUsage(this.nestedBucket(this.dayProject, day, project), usage, cost)
|
|
377
|
+
addUsage(this.nestedBucket3(this.dayModelProject, day, model, project), usage, cost)
|
|
317
378
|
}
|
|
318
379
|
}
|
|
319
380
|
return true
|
|
@@ -347,7 +408,9 @@ export class UsageTracker {
|
|
|
347
408
|
this.scanning = true
|
|
348
409
|
try {
|
|
349
410
|
const result = await new Promise((resolve, reject) => {
|
|
350
|
-
const worker = new WorkerCtor(new URL('./scan-worker.js', import.meta.url), {
|
|
411
|
+
const worker = new WorkerCtor(new URL('./scan-worker.js', import.meta.url), {
|
|
412
|
+
workerData: { dshHome: this.dshHome, pricing: { snapshots: this.pricing.snapshots ?? [], currency: this.pricing.currency } },
|
|
413
|
+
})
|
|
351
414
|
worker.once('message', (message) => {
|
|
352
415
|
worker.terminate().catch(() => {})
|
|
353
416
|
resolve(message)
|
|
@@ -442,22 +505,22 @@ export class UsageTracker {
|
|
|
442
505
|
}
|
|
443
506
|
|
|
444
507
|
/** Attribute usage that arrived before its session's project was known. */
|
|
445
|
-
holdOrphan(sessionId, usage, day, model) {
|
|
508
|
+
holdOrphan(sessionId, usage, cost, day, model) {
|
|
446
509
|
let held = this.orphans.get(sessionId)
|
|
447
510
|
if (held === undefined) {
|
|
448
511
|
held = { totals: emptyBucket(), days: new Map(), dayModels: new Map(), since: Date.now() }
|
|
449
512
|
this.orphans.set(sessionId, held)
|
|
450
513
|
}
|
|
451
|
-
addUsage(held.totals, usage)
|
|
514
|
+
addUsage(held.totals, usage, cost)
|
|
452
515
|
if (day !== undefined) {
|
|
453
|
-
addUsage(this.bucket(held.days, day), usage)
|
|
516
|
+
addUsage(this.bucket(held.days, day), usage, cost)
|
|
454
517
|
if (model !== undefined) {
|
|
455
518
|
let byModel = held.dayModels.get(day)
|
|
456
519
|
if (byModel === undefined) {
|
|
457
520
|
byModel = new Map()
|
|
458
521
|
held.dayModels.set(day, byModel)
|
|
459
522
|
}
|
|
460
|
-
addUsage(this.bucket(byModel, model), usage)
|
|
523
|
+
addUsage(this.bucket(byModel, model), usage, cost)
|
|
461
524
|
}
|
|
462
525
|
}
|
|
463
526
|
}
|
|
@@ -516,6 +579,7 @@ export class UsageTracker {
|
|
|
516
579
|
dayModel: pairs(this.dayModel).map(([day, inner]) => [day, pairs(inner)]),
|
|
517
580
|
dayProject: pairs(this.dayProject).map(([day, inner]) => [day, pairs(inner)]),
|
|
518
581
|
dayModelProject: pairs(this.dayModelProject).map(([day, inner]) => [day, pairs(inner).map(([model, byProject]) => [model, pairs(byProject)])]),
|
|
582
|
+
dayAuxiliary: pairs(this.dayAuxiliary).map(([day, counts]) => [day, { ...counts }]),
|
|
519
583
|
lastModel: pairs(this.lastModel),
|
|
520
584
|
projectOf: pairs(this.projectOf),
|
|
521
585
|
parentOf: pairs(this.parentOf),
|
|
@@ -541,6 +605,12 @@ export class UsageTracker {
|
|
|
541
605
|
for (const [key, project] of part.projectOf) this.rememberProject(key, project)
|
|
542
606
|
for (const [key, parent] of part.parentOf) this.parentOf.set(key, parent)
|
|
543
607
|
for (const [key, seq] of part.seen) this.seen.set(key, Math.max(this.seen.get(key) ?? 0, seq))
|
|
608
|
+
for (const [day, counts] of part.dayAuxiliary ?? []) {
|
|
609
|
+
const bucket = this.auxiliaryBucket(day)
|
|
610
|
+
bucket.title += counts.title
|
|
611
|
+
bucket.search += counts.search
|
|
612
|
+
bucket.total += counts.total
|
|
613
|
+
}
|
|
544
614
|
this.scan = { ...this.scan, ...part.scan }
|
|
545
615
|
}
|
|
546
616
|
|
|
@@ -557,7 +627,7 @@ export class UsageTracker {
|
|
|
557
627
|
if (filter === null) {
|
|
558
628
|
const sortedDays = [...this.byDay.keys()].sort()
|
|
559
629
|
const trendEnd = sortedDays.length > 0 ? sortedDays[sortedDays.length - 1] : localDay(Date.now())
|
|
560
|
-
const trendStart = shiftDay(trendEnd, -
|
|
630
|
+
const trendStart = shiftDay(trendEnd, -(TREND_DAYS - 1))
|
|
561
631
|
const trendDays = []
|
|
562
632
|
for (let day = trendStart; day <= trendEnd; day = shiftDay(day, 1)) {
|
|
563
633
|
const bucket = this.byDay.get(day)
|
|
@@ -569,10 +639,11 @@ export class UsageTracker {
|
|
|
569
639
|
byProject: this.sorted(this.byProject),
|
|
570
640
|
byDay: Object.fromEntries([...this.byDay.entries()].sort()),
|
|
571
641
|
trend: { from: trendStart, to: trendEnd, days: Object.fromEntries(trendDays) },
|
|
642
|
+
auxiliary: this.auxiliaryOver([...this.dayAuxiliary.keys()].sort()),
|
|
572
643
|
}
|
|
573
644
|
}
|
|
574
645
|
const model = filter.model
|
|
575
|
-
const allDays = [...this.byDay.keys()].sort()
|
|
646
|
+
const allDays = [...new Set([...this.byDay.keys(), ...this.dayAuxiliary.keys()])].sort()
|
|
576
647
|
const days = filter.kind === 'all'
|
|
577
648
|
? allDays
|
|
578
649
|
: allDays.filter((day) => (filter.kind === 'day' ? day === filter.value : day.startsWith(filter.value)))
|
|
@@ -598,7 +669,7 @@ export class UsageTracker {
|
|
|
598
669
|
const trendEnd = filter.kind === 'day'
|
|
599
670
|
? filter.value
|
|
600
671
|
: (lastDataDay ?? localDay(Date.now()))
|
|
601
|
-
const trendStart = shiftDay(trendEnd, -
|
|
672
|
+
const trendStart = shiftDay(trendEnd, -(TREND_DAYS - 1))
|
|
602
673
|
for (let day = trendStart; day <= trendEnd; day = shiftDay(day, 1)) {
|
|
603
674
|
const bucket = model === undefined ? this.byDay.get(day) : this.dayModel.get(day)?.get(model)
|
|
604
675
|
trendDays.push([day, bucket === undefined ? emptyBucket() : { ...bucket }])
|
|
@@ -609,6 +680,7 @@ export class UsageTracker {
|
|
|
609
680
|
byProject: this.sorted(projects),
|
|
610
681
|
byDay,
|
|
611
682
|
trend: { from: trendStart, to: trendEnd, days: Object.fromEntries(trendDays) },
|
|
683
|
+
auxiliary: this.auxiliaryOver(days),
|
|
612
684
|
}
|
|
613
685
|
}
|
|
614
686
|
|
|
@@ -616,15 +688,27 @@ export class UsageTracker {
|
|
|
616
688
|
this.flushStaleOrphans()
|
|
617
689
|
const view = this.view(filter)
|
|
618
690
|
const withTotal = (bucket) => ({ ...bucket, total: bucket.input + bucket.output + bucket.cacheRead + bucket.cacheWrite })
|
|
691
|
+
const now = Date.now()
|
|
692
|
+
const names = new Set([...Object.keys(view.byModel), ...this.byModel.keys()])
|
|
693
|
+
const modelPricing = {}
|
|
694
|
+
for (const model of names) {
|
|
695
|
+
const resolved = this.pricing.priceFor(model, now)
|
|
696
|
+
modelPricing[model] = resolved.status === 'priced'
|
|
697
|
+
? { status: resolved.status, id: resolved.id, label: resolved.label, hit: resolved.hit, miss: resolved.miss, out: resolved.out }
|
|
698
|
+
: { status: resolved.status, reason: resolved.reason }
|
|
699
|
+
}
|
|
619
700
|
return {
|
|
620
701
|
ok: true,
|
|
621
702
|
name,
|
|
622
703
|
version: VERSION,
|
|
623
704
|
startedAt: this.startedAt,
|
|
624
|
-
updatedAt:
|
|
705
|
+
updatedAt: now,
|
|
625
706
|
dshHome: this.dshHome,
|
|
626
707
|
filter: filter === null ? null : { kind: filter.kind, value: filter.value ?? null, model: filter.model ?? null },
|
|
627
708
|
models: this.models(),
|
|
709
|
+
modelPricing,
|
|
710
|
+
pricing: this.pricing.describe(now),
|
|
711
|
+
auxiliary: view.auxiliary,
|
|
628
712
|
scan: { ...this.scan },
|
|
629
713
|
totals: withTotal(view.totals),
|
|
630
714
|
byModel: Object.fromEntries(Object.entries(view.byModel).map(([k, v]) => [k, withTotal(v)])),
|
|
@@ -700,13 +784,20 @@ export class UsageTracker {
|
|
|
700
784
|
/**
|
|
701
785
|
* Register the tracker once the profile's webServer service exists.
|
|
702
786
|
* @param ctx - host context from the cordis loader.
|
|
703
|
-
* @param config - loader config: `endpoint` and
|
|
787
|
+
* @param config - loader config: `endpoint`, `scanAtBoot`, `allowRemote` and
|
|
788
|
+
* `pricing` (`enabled`, `currency`, `refreshHour`, `url`, `timeoutMs`).
|
|
704
789
|
*/
|
|
705
790
|
export function apply(ctx, config = {}) {
|
|
706
791
|
const endpoint = typeof config.endpoint === 'string' && config.endpoint.length > 0 ? config.endpoint : '/dsh-token-use'
|
|
707
792
|
const scanAtBoot = config.scanAtBoot !== false
|
|
708
793
|
ctx.inject(['webServer'], (host) => {
|
|
709
|
-
const
|
|
794
|
+
const dshHome = resolveDshHome()
|
|
795
|
+
const pricing = new PricingStore(dshHome, config.pricing ?? {})
|
|
796
|
+
const tracker = new UsageTracker(dshHome, { allowRemote: config.allowRemote === true, pricing })
|
|
797
|
+
host.effect(() => {
|
|
798
|
+
pricing.start()
|
|
799
|
+
return () => pricing.stop()
|
|
800
|
+
}, 'dsh-token-use: daily price refresh')
|
|
710
801
|
host.on('session/event', (session, event) => {
|
|
711
802
|
tracker.fold(session, event)
|
|
712
803
|
}, { global: true })
|