@rayadesu/dsh-llm-billing 0.1.0 → 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/lib/index.js CHANGED
@@ -3,7 +3,8 @@ import { LlmError, assertUsableApiKey } from "@deepseek-ai/dsh-llm";
3
3
  import { credentialRef } from "@deepseek-ai/dsh-credentials";
4
4
  import { launchEnvironmentOf } from "@deepseek-ai/dsh-launch-environment";
5
5
  import { Remote, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
6
- //#region ../../../../DeepSeek-Harness-chat-billing/packages/llm-billing/lib/types/balance.js
6
+ import { z as z$1 } from "zod";
7
+ //#region lib/types/balance.js
7
8
  /**
8
9
  * DeepSeek account-balance capability: the `GET /user/balance` transport and
9
10
  * the Remote gateway that exposes one snapshot to trusted clients. The fetch
@@ -207,20 +208,29 @@ let DeepSeekBalanceGateway = (() => {
207
208
  /**
208
209
  * Read today's billed spend across every session, priced per event by its
209
210
  * Beijing-time calendar day, hour, and weekday (weekends are always off-peak).
211
+ * @param force - bypass the host-side 60s cache (manual refresh); omitted
212
+ * means a cached read. Remote parameters cannot carry default values, so
213
+ * the thunk receives `undefined` for an omitted argument.
210
214
  * @returns today's total cost plus one row per priced model.
211
215
  */
212
- getTodaySpend() {
213
- return this.options.fetchTodaySpend();
216
+ getTodaySpend(force) {
217
+ return this.options.fetchTodaySpend(force ?? false);
214
218
  }
215
219
  };
216
220
  })();
217
221
  //#endregion
218
- //#region ../../../../DeepSeek-Harness-chat-billing/packages/llm-billing/lib/types/billing.js
222
+ //#region lib/types/billing.js
219
223
  /**
220
224
  * DeepSeek billing: the peak/off-peak pricing table and the per-session spend
221
225
  * pricing. Pure functions over session events and the pricing table, so the
222
226
  * Remote gateway stays transport-free and the whole spend is testable without
223
227
  * a key.
228
+ *
229
+ * The per-event pricing lives in {@link priceEvent}, the one shared fold
230
+ * primitive: the events-scan paths ({@link computeSessionSpend},
231
+ * {@link computeTodaySpend}) and the session-projection unit
232
+ * (`billingTodaySpend` in projection.ts) all fold the same contribution, so a
233
+ * pricing-table change cannot drift one path from the others.
224
234
  * @module @rayadesu/dsh-llm-billing/billing
225
235
  */
226
236
  /**
@@ -329,6 +339,125 @@ function isPeak(billing, now) {
329
339
  return billing.peakHours.some(({ start, end }) => hour >= start && hour < end);
330
340
  }
331
341
  /**
342
+ * Price one event at the official per-model rates, applying the peak/off-peak
343
+ * table by its Beijing-time hour and weekday (peak windows apply Monday–Friday
344
+ * only; weekends are off-peak). Each `assistant/message` event with usage
345
+ * contributes cache-hit input, cache-miss input (uncached input plus cache
346
+ * writes), and output (reasoning included) tokens at the rate of its own
347
+ * timestamp; a model with usage but no pricing row contributes nothing (the
348
+ * published table prices only the two V4 rows).
349
+ * @param event - the event to price.
350
+ * @param billing - resolved pricing with peak-hour windows.
351
+ * @param names - model id → display label.
352
+ * @returns the priced contribution, or `undefined` when the event has no priced usage.
353
+ */
354
+ function priceEvent(event, billing, names) {
355
+ if (event.type !== "assistant/message") return void 0;
356
+ const reported = event.data.usage;
357
+ if (reported === void 0) return void 0;
358
+ const model = event.data.message.source.model;
359
+ const pricing = billing.models.get(model);
360
+ if (pricing === void 0) return void 0;
361
+ const time = new Date(event.time);
362
+ const peak = isPeak(billing, time);
363
+ const price = peak ? pricing.peak : pricing.offPeak;
364
+ const hit = reported.cacheReadTokens ?? 0;
365
+ const miss = reported.inputTokens + (reported.cacheWriteTokens ?? 0);
366
+ const output = reported.outputTokens;
367
+ const hitCost = hit * price.cacheHitInput / 1e6;
368
+ const missCost = miss * price.cacheMissInput / 1e6;
369
+ const outputCost = output * price.output / 1e6;
370
+ const cost = hitCost + missCost + outputCost;
371
+ return {
372
+ dayKey: beijingDayKey(time),
373
+ model,
374
+ displayName: names.get(model) ?? model,
375
+ cost,
376
+ peakCost: peak ? cost : 0,
377
+ offPeakCost: peak ? 0 : cost,
378
+ cacheHitInputTokens: hit,
379
+ cacheMissInputTokens: miss,
380
+ outputTokens: output,
381
+ cacheHitInputCost: hitCost,
382
+ cacheMissInputCost: missCost,
383
+ outputCost
384
+ };
385
+ }
386
+ /** A spend with no priced usage. */
387
+ function emptyTodaySpend() {
388
+ return {
389
+ total: 0,
390
+ models: []
391
+ };
392
+ }
393
+ /** The today-spend shape of a single priced contribution. */
394
+ function contributionModel(priced) {
395
+ return {
396
+ model: priced.model,
397
+ displayName: priced.displayName,
398
+ cost: priced.cost,
399
+ peakCost: priced.peakCost,
400
+ offPeakCost: priced.offPeakCost,
401
+ cacheHitInputTokens: priced.cacheHitInputTokens,
402
+ cacheMissInputTokens: priced.cacheMissInputTokens,
403
+ outputTokens: priced.outputTokens,
404
+ cacheHitInputCost: priced.cacheHitInputCost,
405
+ cacheMissInputCost: priced.cacheMissInputCost,
406
+ outputCost: priced.outputCost
407
+ };
408
+ }
409
+ /**
410
+ * Merge one priced event's contribution into an accumulator spend (pure:
411
+ * returns a new spend, never mutates its input).
412
+ * @param spend - the accumulator (per session and day, or across sessions).
413
+ * @param priced - the priced contribution to add.
414
+ * @returns the merged spend.
415
+ */
416
+ function addEventContribution(spend, priced) {
417
+ const rows = spend.models.map((row) => row.model === priced.model ? {
418
+ ...row,
419
+ cost: row.cost + priced.cost,
420
+ peakCost: row.peakCost + priced.peakCost,
421
+ offPeakCost: row.offPeakCost + priced.offPeakCost,
422
+ cacheHitInputTokens: row.cacheHitInputTokens + priced.cacheHitInputTokens,
423
+ cacheMissInputTokens: row.cacheMissInputTokens + priced.cacheMissInputTokens,
424
+ outputTokens: row.outputTokens + priced.outputTokens,
425
+ cacheHitInputCost: row.cacheHitInputCost + priced.cacheHitInputCost,
426
+ cacheMissInputCost: row.cacheMissInputCost + priced.cacheMissInputCost,
427
+ outputCost: row.outputCost + priced.outputCost
428
+ } : row);
429
+ if (!rows.some((row) => row.model === priced.model)) rows.push(contributionModel(priced));
430
+ return {
431
+ total: spend.total + priced.cost,
432
+ models: rows
433
+ };
434
+ }
435
+ /**
436
+ * Sum two spends (per session and day, or across sessions) into one (pure:
437
+ * returns a new spend, never mutates its inputs).
438
+ * @param target - the accumulator spend.
439
+ * @param source - the spend to add.
440
+ * @returns the summed spend.
441
+ */
442
+ function mergeTodaySpend(target, source) {
443
+ let merged = target;
444
+ for (const row of source.models) merged = addEventContribution(merged, {
445
+ dayKey: "",
446
+ model: row.model,
447
+ displayName: row.displayName,
448
+ cost: row.cost,
449
+ peakCost: row.peakCost,
450
+ offPeakCost: row.offPeakCost,
451
+ cacheHitInputTokens: row.cacheHitInputTokens,
452
+ cacheMissInputTokens: row.cacheMissInputTokens,
453
+ outputTokens: row.outputTokens,
454
+ cacheHitInputCost: row.cacheHitInputCost,
455
+ cacheMissInputCost: row.cacheMissInputCost,
456
+ outputCost: row.outputCost
457
+ });
458
+ return merged;
459
+ }
460
+ /**
332
461
  * Price a set of billed events at the official per-model rates, applying the
333
462
  * peak/off-peak table per event by its Beijing-time hour and weekday (peak
334
463
  * windows apply Monday–Friday only; weekends are off-peak). Each
@@ -344,65 +473,16 @@ function isPeak(billing, now) {
344
473
  */
345
474
  function priceEvents(events, billing, catalog) {
346
475
  const names = new Map(catalog.map((model) => [model.id, model.name]));
347
- const rows = /* @__PURE__ */ new Map();
476
+ let spend = {
477
+ total: 0,
478
+ models: []
479
+ };
348
480
  for (const event of events) {
349
- if (event.type !== "assistant/message") continue;
350
- const reported = event.data.usage;
351
- if (reported === void 0) continue;
352
- const model = event.data.message.source.model;
353
- const pricing = billing.models.get(model);
354
- if (pricing === void 0) continue;
355
- const peak = isPeak(billing, new Date(event.time));
356
- const price = peak ? pricing.peak : pricing.offPeak;
357
- const hit = reported.cacheReadTokens ?? 0;
358
- const miss = reported.inputTokens + (reported.cacheWriteTokens ?? 0);
359
- const output = reported.outputTokens;
360
- const hitCost = hit * price.cacheHitInput / 1e6;
361
- const missCost = miss * price.cacheMissInput / 1e6;
362
- const outputCost = output * price.output / 1e6;
363
- const cost = hitCost + missCost + outputCost;
364
- let row = rows.get(model);
365
- if (row === void 0) {
366
- row = {
367
- cacheHitInputTokens: 0,
368
- cacheMissInputTokens: 0,
369
- outputTokens: 0,
370
- cost: 0,
371
- peakCost: 0,
372
- offPeakCost: 0,
373
- cacheHitInputCost: 0,
374
- cacheMissInputCost: 0,
375
- outputCost: 0
376
- };
377
- rows.set(model, row);
378
- }
379
- row.cacheHitInputTokens += hit;
380
- row.cacheMissInputTokens += miss;
381
- row.outputTokens += output;
382
- row.cost += cost;
383
- row.cacheHitInputCost += hitCost;
384
- row.cacheMissInputCost += missCost;
385
- row.outputCost += outputCost;
386
- if (peak) row.peakCost += cost;
387
- else row.offPeakCost += cost;
481
+ const priced = priceEvent(event, billing, names);
482
+ if (priced === void 0) continue;
483
+ spend = addEventContribution(spend, priced);
388
484
  }
389
- const models = [...rows.entries()].map(([model, row]) => ({
390
- model,
391
- displayName: names.get(model) ?? model,
392
- cost: row.cost,
393
- peakCost: row.peakCost,
394
- offPeakCost: row.offPeakCost,
395
- cacheHitInputTokens: row.cacheHitInputTokens,
396
- cacheMissInputTokens: row.cacheMissInputTokens,
397
- outputTokens: row.outputTokens,
398
- cacheHitInputCost: row.cacheHitInputCost,
399
- cacheMissInputCost: row.cacheMissInputCost,
400
- outputCost: row.outputCost
401
- }));
402
- return {
403
- total: models.reduce((sum, model) => sum + model.cost, 0),
404
- models
405
- };
485
+ return spend;
406
486
  }
407
487
  /**
408
488
  * Price one session's complete event log at the official per-model rates.
@@ -426,16 +506,326 @@ function computeSessionSpend(events, billing, catalog) {
426
506
  */
427
507
  function computeTodaySpend(events, billing, catalog, now = /* @__PURE__ */ new Date()) {
428
508
  const day = beijingDayKey(now);
429
- return priceEvents(events.filter((event) => beijingDayKey(new Date(event.time)) === day), billing, catalog);
509
+ const names = new Map(catalog.map((model) => [model.id, model.name]));
510
+ let spend = emptyTodaySpend();
511
+ for (const event of events) {
512
+ const priced = priceEvent(event, billing, names);
513
+ if (priced === void 0 || priced.dayKey !== day) continue;
514
+ spend = addEventContribution(spend, priced);
515
+ }
516
+ return spend;
430
517
  }
431
518
  //#endregion
432
- //#region ../../../../DeepSeek-Harness-chat-billing/packages/llm-billing/lib/types/index.js
519
+ //#region lib/types/projection.js
520
+ /**
521
+ * `billingTodaySpend` session-projection unit: per-session, per-Beijing-day
522
+ * billed spend, folded eagerly by the DSH projection drive over committed
523
+ * session events and checkpointed by the projection cache. The unit keeps only
524
+ * the spend of the session's LATEST priced day (events are append-only and
525
+ * chronological, so a day strictly older than the state's day never returns);
526
+ * the aggregate "today" read sums the units whose `dayKey` matches the current
527
+ * Beijing day — zero full-log scans once the fold is warm.
528
+ *
529
+ * The unit's fold shares {@link priceEvent} with the events-scan paths
530
+ * (`computeTodaySpend`), so both price with the same table. The unit is
531
+ * client-visible (`wire` = identity) because the persisted-cache read ladder
532
+ * (`sessionProjectionCache.coldSnapshot` / registry `restore`) serves only
533
+ * wired units; the wire value is the state itself.
534
+ * @module @rayadesu/dsh-llm-billing/projection
535
+ */
536
+ /** The projection key this unit owns. */
537
+ const BILLING_UNIT_KEY = "billingTodaySpend";
538
+ const modelRowSchema = z$1.object({
539
+ model: z$1.string(),
540
+ displayName: z$1.string(),
541
+ cost: z$1.number().nonnegative(),
542
+ peakCost: z$1.number().nonnegative(),
543
+ offPeakCost: z$1.number().nonnegative(),
544
+ cacheHitInputTokens: z$1.number().int().nonnegative(),
545
+ cacheMissInputTokens: z$1.number().int().nonnegative(),
546
+ outputTokens: z$1.number().int().nonnegative(),
547
+ cacheHitInputCost: z$1.number().nonnegative(),
548
+ cacheMissInputCost: z$1.number().nonnegative(),
549
+ outputCost: z$1.number().nonnegative()
550
+ }).strict();
551
+ const todaySpendSchema = z$1.object({
552
+ total: z$1.number().nonnegative(),
553
+ models: z$1.array(modelRowSchema)
554
+ }).strict();
555
+ const billingUnitSchema = z$1.object({
556
+ dayKey: z$1.string(),
557
+ spend: todaySpendSchema
558
+ }).strict();
559
+ /**
560
+ * Build the `billingTodaySpend` unit for one resolved pricing table. The
561
+ * pricing closure is fixed at registration; a pricing-table change therefore
562
+ * prices only events folded after the change (historical spend keeps its
563
+ * historical rates), unlike the events-scan paths which re-price the whole
564
+ * log. Bump {@link ProjectionDefinition.stateVersion} whenever the state
565
+ * shape or fold semantics change, so persisted checkpoint rows are discarded
566
+ * instead of folded forward.
567
+ * @param billing - resolved pricing with peak-hour windows.
568
+ * @param catalog - model display rows, in presentation order.
569
+ * @returns the unit definition to register on `ctx.sessionProjections`.
570
+ */
571
+ function billingTodaySpendDefinition(billing, catalog) {
572
+ const names = new Map(catalog.map((model) => [model.id, model.name]));
573
+ return {
574
+ key: BILLING_UNIT_KEY,
575
+ stateVersion: 1,
576
+ stateSchema: billingUnitSchema,
577
+ init: () => ({
578
+ dayKey: "",
579
+ spend: emptyTodaySpend()
580
+ }),
581
+ apply: (state, event) => {
582
+ const priced = priceEvent(event, billing, names);
583
+ if (priced === void 0) return state;
584
+ if (state.dayKey === priced.dayKey) return {
585
+ dayKey: state.dayKey,
586
+ spend: addEventContribution(state.spend, priced)
587
+ };
588
+ if (state.dayKey !== "" && priced.dayKey < state.dayKey) return state;
589
+ return {
590
+ dayKey: priced.dayKey,
591
+ spend: addEventContribution(emptyTodaySpend(), priced)
592
+ };
593
+ },
594
+ wire: {
595
+ viewSchema: billingUnitSchema,
596
+ view: (state) => state
597
+ }
598
+ };
599
+ }
600
+ /** Fold a unit from init over one session's event log (the detached cold recipe). */
601
+ function foldBillingUnit(unit, events) {
602
+ let state = unit.init();
603
+ for (const event of events) state = unit.apply(state, event);
604
+ return state;
605
+ }
606
+ //#endregion
607
+ //#region lib/types/today-spend.js
608
+ /**
609
+ * Today-spend read path: the 60-second Beijing-day cache with in-flight
610
+ * coalescing and a force bypass (plan A1), plus the two scan strategies that
611
+ * compute the aggregate behind a cache miss:
612
+ *
613
+ * - projection path (plan C): live sessions read their eagerly folded
614
+ * `billingTodaySpend` projection cell; cold sessions resolve through the
615
+ * projection-cache ladder (cached row + tail replay + registry restore,
616
+ * with write-back) or, without the cache service, one detached local fold
617
+ * over a full `inspect`. Persisted revisions gate every cold read, so a
618
+ * session whose log did not change since the last resolution costs nothing.
619
+ * - events path (plans A2/A3): collect only today's events (per-event
620
+ * Beijing-day filter during collection) with a hard cap, skipping sessions
621
+ * whose persisted revision is unchanged since the last scan.
622
+ *
623
+ * Both strategies run behind the same {@link TodaySpendCache}, so a miss
624
+ * happens at most once per 60 seconds per process, and a manual refresh
625
+ * (`force`) bypasses the time window but keeps the revision caches — an
626
+ * unchanged log provably cannot change the aggregate.
627
+ * @module @rayadesu/dsh-llm-billing/today-spend
628
+ */
629
+ /** Bounded parallel fan-out: run `run` over `items` with at most `limit` in flight. */
630
+ async function withConcurrency(items, limit, run) {
631
+ const queue = [...items];
632
+ await Promise.all(Array.from({ length: Math.min(limit, queue.length) }, async () => {
633
+ for (let job = queue.shift(); job !== void 0; job = queue.shift()) await run(job);
634
+ }));
635
+ }
636
+ /**
637
+ * The A1 cache: one Beijing-day key + a 60s window, an in-flight promise that
638
+ * coalesces concurrent misses, and a `force` bypass for the manual refresh
639
+ * path. Cross-day invalidation is automatic (the day key changes); a failed
640
+ * scan leaves the previous value in place and retries on the next call.
641
+ */
642
+ var TodaySpendCache = class {
643
+ scan;
644
+ ttlMs;
645
+ now;
646
+ cachedDayKey;
647
+ cachedValue;
648
+ cachedAt = 0;
649
+ inFlight;
650
+ /**
651
+ * @param ttlMs - time window in milliseconds (default 60 000).
652
+ * @param now - clock source (injectable for tests).
653
+ * @param scan - the aggregate computation behind a miss.
654
+ */
655
+ constructor(scan, ttlMs = 6e4, now = () => /* @__PURE__ */ new Date()) {
656
+ this.scan = scan;
657
+ this.ttlMs = ttlMs;
658
+ this.now = now;
659
+ }
660
+ /**
661
+ * Read today's spend, cached per Beijing day within the TTL window.
662
+ * @param force - bypass the time window (manual refresh); the day-key gate
663
+ * and the in-flight coalescing still apply to non-force callers.
664
+ * @returns today's spend.
665
+ */
666
+ get(force = false) {
667
+ const now = this.now();
668
+ const dayKey = beijingDayKey(now);
669
+ if (!force && this.cachedDayKey === dayKey && this.cachedValue !== void 0 && now.getTime() - this.cachedAt < this.ttlMs) return Promise.resolve(this.cachedValue);
670
+ if (!force && this.inFlight !== void 0) return this.inFlight;
671
+ const run = (async () => {
672
+ try {
673
+ const value = await this.scan(dayKey);
674
+ this.cachedDayKey = dayKey;
675
+ this.cachedValue = value;
676
+ this.cachedAt = now.getTime();
677
+ return value;
678
+ } finally {
679
+ this.inFlight = void 0;
680
+ }
681
+ })();
682
+ if (!force) this.inFlight = run;
683
+ return run;
684
+ }
685
+ };
686
+ /**
687
+ * The aggregate computation behind a cache miss. Chooses the projection path
688
+ * when the projection registry is composed, the events path otherwise; both
689
+ * gate cold reads on persisted revisions so steady-state scans touch only
690
+ * sessions whose logs actually changed.
691
+ */
692
+ var TodaySpendScanner = class {
693
+ deps;
694
+ /** Cold sessions resolved on the projection path: id → revision + unit state. */
695
+ coldResolved = /* @__PURE__ */ new Map();
696
+ /** Cold sessions resolved on the events path: id → revision (events were collected). */
697
+ lastEventsScan;
698
+ constructor(deps) {
699
+ this.deps = deps;
700
+ }
701
+ /**
702
+ * Compute today's aggregate for one Beijing day.
703
+ * @param dayKey - the Beijing-time calendar-day key to aggregate.
704
+ * @returns today's spend across every session.
705
+ */
706
+ async scan(dayKey) {
707
+ if (this.deps.projections?.() === void 0) return this.scanEvents(dayKey);
708
+ this.deps.ensureUnit?.();
709
+ return this.scanProjections(dayKey);
710
+ }
711
+ /** Projection path: eager cells for live sessions, revision-gated cold ladder for the rest. */
712
+ async scanProjections(dayKey) {
713
+ const { sessions, persistence, projections, projectionCache, unit, logger } = this.deps;
714
+ let total = emptyTodaySpend();
715
+ const liveIds = /* @__PURE__ */ new Set();
716
+ if (sessions !== void 0) {
717
+ const store = sessions();
718
+ if (store !== void 0) for (const session of store.list()) {
719
+ liveIds.add(session.id);
720
+ const state = projections?.()?.stateOf(session, BILLING_UNIT_KEY);
721
+ if (state !== void 0 && state.dayKey === dayKey) total = mergeTodaySpend(total, state.spend);
722
+ }
723
+ }
724
+ const persistenceService = persistence?.();
725
+ if (persistenceService === void 0) return total;
726
+ const snapshots = await persistenceService.listSnapshots();
727
+ const pending = [];
728
+ for (const { header, revision } of snapshots) {
729
+ if (liveIds.has(header.id)) continue;
730
+ const resolved = this.coldResolved.get(header.id);
731
+ if (resolved !== void 0 && resolved.revision === revision) {
732
+ if (resolved.value.dayKey === dayKey) total = mergeTodaySpend(total, resolved.value.spend);
733
+ continue;
734
+ }
735
+ pending.push({
736
+ id: header.id,
737
+ revision
738
+ });
739
+ }
740
+ await withConcurrency(pending, 8, async ({ id, revision }) => {
741
+ let value;
742
+ const cache = projectionCache?.();
743
+ if (cache !== void 0) try {
744
+ value = (await cache.coldSnapshot(id)).values[BILLING_UNIT_KEY];
745
+ } catch (error) {
746
+ logger.warn(`llm-billing: projection cold read for session ${id} failed: ${String(error)}`);
747
+ }
748
+ if (value === void 0) try {
749
+ value = foldBillingUnit(unit, (await persistenceService.inspect(id)).events);
750
+ } catch (error) {
751
+ logger.warn(`llm-billing: skipping unreadable session ${id}: ${String(error)}`);
752
+ }
753
+ if (value !== void 0) this.coldResolved.set(id, {
754
+ revision,
755
+ value
756
+ });
757
+ });
758
+ for (const { id } of pending) {
759
+ const resolved = this.coldResolved.get(id);
760
+ if (resolved !== void 0 && resolved.value.dayKey === dayKey) total = mergeTodaySpend(total, resolved.value.spend);
761
+ }
762
+ return total;
763
+ }
764
+ /** Events path: collect only today's events (capped), gated by revisions. */
765
+ async scanEvents(dayKey) {
766
+ const { sessions, persistence, maxEvents, logger, billing, catalog } = this.deps;
767
+ const events = [];
768
+ const liveIds = /* @__PURE__ */ new Set();
769
+ let truncated = false;
770
+ if (sessions !== void 0) {
771
+ const store = sessions();
772
+ if (store !== void 0) for (const session of store.list()) {
773
+ liveIds.add(session.id);
774
+ for (const event of session.events) {
775
+ if (beijingDayKey(new Date(event.time)) !== dayKey) continue;
776
+ events.push(event);
777
+ if (events.length >= maxEvents) {
778
+ truncated = true;
779
+ break;
780
+ }
781
+ }
782
+ if (truncated) break;
783
+ }
784
+ }
785
+ const persistenceService = persistence?.();
786
+ if (!truncated && persistenceService !== void 0) {
787
+ const snapshots = await persistenceService.listSnapshots();
788
+ for (const { header, revision } of snapshots) {
789
+ if (liveIds.has(header.id)) continue;
790
+ if (this.lastEventsScan?.get(header.id) === revision) continue;
791
+ try {
792
+ const inspection = await persistenceService.inspect(header.id);
793
+ for (const event of inspection.events) {
794
+ if (beijingDayKey(new Date(event.time)) !== dayKey) continue;
795
+ events.push(event);
796
+ if (events.length >= maxEvents) {
797
+ truncated = true;
798
+ break;
799
+ }
800
+ }
801
+ } catch (error) {
802
+ logger.warn(`llm-billing: skipping unreadable session ${header.id}: ${String(error)}`);
803
+ }
804
+ if (truncated) break;
805
+ }
806
+ if (!truncated) this.lastEventsScan = new Map(snapshots.map((snapshot) => [snapshot.header.id, snapshot.revision]));
807
+ }
808
+ if (truncated) logger.warn(`llm-billing: today's events exceeded ${maxEvents}; result truncated`);
809
+ return computeTodaySpend(events, billing, catalog, /* @__PURE__ */ new Date(`${dayKey}T00:00:00Z`));
810
+ }
811
+ };
812
+ //#endregion
813
+ //#region lib/types/index.js
433
814
  /**
434
815
  * DeepSeek account balance and session-spend provider, as a standalone host
435
816
  * plugin. It resolves the DeepSeek endpoint and API key from its own config and
436
817
  * the credential/environment seams, prices each session's billed usage with the
437
818
  * peak/off-peak table, and exposes the `billing` Remote (`getBalance`, the
438
819
  * per-session `getSessionSpend`, and the all-sessions `getTodaySpend`).
820
+ *
821
+ * Today's spend never scans every session log per request: a 60-second
822
+ * Beijing-day cache with in-flight coalescing serves the message-triggered
823
+ * reads, the manual refresh may bypass the time window (`force`), and the
824
+ * computation behind a miss reads only sessions whose persisted revision
825
+ * changed since the last resolution (see today-spend.ts). When the
826
+ * session-projection registry is composed, the plugin additionally registers
827
+ * the `billingTodaySpend` projection unit, which folds each session's spend
828
+ * eagerly and lets cold reads ride the projection-cache ladder.
439
829
  * @module @rayadesu/dsh-llm-billing
440
830
  */
441
831
  const name = "llm-billing";
@@ -483,9 +873,14 @@ const Config = z.object({
483
873
  models: z.array(billingModel).default(DEFAULT_MODELS),
484
874
  billing: billingConfig
485
875
  });
876
+ /** How often a Beijing-day "today spend" value may be recomputed (60s). */
877
+ const TODAY_SPEND_CACHE_MS = 6e4;
878
+ /** Hard cap on today's events collected by the events scan path. */
879
+ const TODAY_SPEND_MAX_EVENTS = 2e5;
486
880
  /**
487
881
  * Read one session's event log: the live SessionStore first, then the
488
- * persistence backend for a flushed session.
882
+ * persistence backend for a flushed session (inspected directly by id — no
883
+ * header listing).
489
884
  * @param ctx - plugin context carrying the SessionStore and optional persistence.
490
885
  * @param sessionId - the session to read.
491
886
  * @returns the session's complete event log.
@@ -495,42 +890,14 @@ async function sessionEvents(ctx, sessionId) {
495
890
  const live = ctx.get("sessions")?.get(sessionId);
496
891
  if (live !== void 0) return live.events;
497
892
  const persistence = ctx.get("sessionPersistence");
498
- if (persistence !== void 0) for (const header of await persistence.list()) {
499
- if (header.id !== sessionId) continue;
893
+ if (persistence !== void 0) try {
500
894
  return (await persistence.inspect(sessionId)).events;
895
+ } catch (error) {
896
+ throw new LlmError(`llm-billing: session ${sessionId} not found`, "NOT_FOUND", { cause: error });
501
897
  }
502
898
  throw new LlmError(`llm-billing: session ${sessionId} not found`, "NOT_FOUND");
503
899
  }
504
900
  /**
505
- * Read every session's event log, concatenated: each live SessionStore
506
- * session first (its log may hold events not yet flushed), then each persisted
507
- * session that is not live, so no event is counted twice. Events are appended
508
- * one at a time: spreading a very large log into `push(...)` exceeds the
509
- * engine's argument limit and throws a stack RangeError.
510
- * @param ctx - plugin context carrying the SessionStore and optional persistence.
511
- * @returns every session's complete event log, concatenated.
512
- */
513
- async function allSessionEvents(ctx) {
514
- const events = [];
515
- const sessions = ctx.get("sessions");
516
- const liveIds = /* @__PURE__ */ new Set();
517
- if (sessions !== void 0) for (const session of sessions.list()) {
518
- liveIds.add(session.id);
519
- for (const event of session.events) events.push(event);
520
- }
521
- const persistence = ctx.get("sessionPersistence");
522
- if (persistence !== void 0) for (const header of await persistence.list()) {
523
- if (liveIds.has(header.id)) continue;
524
- try {
525
- const inspection = await persistence.inspect(header.id);
526
- for (const event of inspection.events) events.push(event);
527
- } catch (error) {
528
- ctx.logger.warn(`llm-billing: skipping unreadable session ${header.id}: ${String(error)}`);
529
- }
530
- }
531
- return events;
532
- }
533
- /**
534
901
  * Register the `billing` Remote under the `billing` namespace.
535
902
  * @param ctx - owning plugin context.
536
903
  * @param config - validated plugin config.
@@ -553,22 +920,37 @@ function apply(ctx, config) {
553
920
  const apiKey = await resolveApiKey();
554
921
  return fetchDeepSeekBalance(baseURL(), apiKey);
555
922
  };
923
+ const billing = resolveBilling(config.billing);
924
+ const catalog = (config.models ?? DEFAULT_MODELS).map((model) => ({
925
+ id: model.id,
926
+ name: model.name ?? model.id
927
+ }));
556
928
  const fetchSessionSpend = async (sessionId) => {
557
- const billing = resolveBilling(config.billing);
558
- const catalog = (config.models ?? DEFAULT_MODELS).map((model) => ({
559
- id: model.id,
560
- name: model.name ?? model.id
561
- }));
562
929
  return computeSessionSpend(await sessionEvents(ctx, sessionId), billing, catalog);
563
930
  };
564
- const fetchTodaySpend = async () => {
565
- const billing = resolveBilling(config.billing);
566
- const catalog = (config.models ?? DEFAULT_MODELS).map((model) => ({
567
- id: model.id,
568
- name: model.name ?? model.id
569
- }));
570
- return computeTodaySpend(await allSessionEvents(ctx), billing, catalog);
931
+ const unit = billingTodaySpendDefinition(billing, catalog);
932
+ let unitRegistered = false;
933
+ const ensureUnit = () => {
934
+ if (unitRegistered) return;
935
+ const registry = ctx.get("sessionProjections");
936
+ if (registry === void 0) return;
937
+ registry.register(unit);
938
+ unitRegistered = true;
571
939
  };
940
+ const scanner = new TodaySpendScanner({
941
+ sessions: () => ctx.get("sessions"),
942
+ persistence: () => ctx.get("sessionPersistence"),
943
+ projections: () => ctx.get("sessionProjections"),
944
+ projectionCache: () => ctx.get("sessionProjectionCache"),
945
+ ensureUnit,
946
+ unit,
947
+ maxEvents: TODAY_SPEND_MAX_EVENTS,
948
+ logger: ctx.logger,
949
+ billing,
950
+ catalog
951
+ });
952
+ const todayCache = new TodaySpendCache((dayKey) => scanner.scan(dayKey), TODAY_SPEND_CACHE_MS);
953
+ const fetchTodaySpend = async (force = false) => todayCache.get(force);
572
954
  new DeepSeekBalanceGateway(ctx, {
573
955
  fetchBalance,
574
956
  fetchSessionSpend,
@@ -576,4 +958,4 @@ function apply(ctx, config) {
576
958
  });
577
959
  }
578
960
  //#endregion
579
- export { Config, DEFAULT_MODEL_PRICING, DEFAULT_PEAK_HOURS, DeepSeekBalanceGateway, PUBLIC_BASE_URL, apply, computeSessionSpend, computeTodaySpend, fetchDeepSeekBalance, isPeak, name, parseDeepSeekBalance, resolveBilling };
961
+ export { BILLING_UNIT_KEY, Config, DEFAULT_MODEL_PRICING, DEFAULT_PEAK_HOURS, DeepSeekBalanceGateway, PUBLIC_BASE_URL, TODAY_SPEND_CACHE_MS, TODAY_SPEND_MAX_EVENTS, TodaySpendCache, TodaySpendScanner, addEventContribution, apply, beijingDayKey, billingTodaySpendDefinition, computeSessionSpend, computeTodaySpend, emptyTodaySpend, fetchDeepSeekBalance, foldBillingUnit, isPeak, mergeTodaySpend, name, parseDeepSeekBalance, priceEvent, resolveBilling };
package/lib/invariant.js CHANGED
@@ -1,4 +1,4 @@
1
- //#region ../../../../DeepSeek-Harness-chat-billing/packages/llm-billing/lib/types/invariant.js
1
+ //#region lib/types/invariant.js
2
2
  /**
3
3
  * Package-owned invariant companion for `@rayadesu/dsh-llm-billing`.
4
4
  * @module @rayadesu/dsh-llm-billing/invariant