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/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; `assistant/message` with a usage record is
279
- * counted once. @returns true when the event was counted.
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
- if (event.type !== 'assistant/message') return false
293
- const usage = event.data?.usage
294
- if (usage === null || typeof usage !== 'object') return false
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
- addUsage(this.totals, usage)
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
- addUsage(this.bucket(this.byModel, model), usage)
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, typeof event.time === 'number' ? localDay(event.time) : undefined, model)
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 (typeof event.time === 'number') {
311
- const day = localDay(event.time)
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), { workerData: { dshHome: this.dshHome } })
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, -29)
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, -29)
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: Date.now(),
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 `scanAtBoot`.
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 tracker = new UsageTracker(resolveDshHome(), { allowRemote: config.allowRemote === true })
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 })