@rayadesu/dsh-llm-billing 0.2.2 → 0.2.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/lib/index.js CHANGED
@@ -228,11 +228,12 @@ let DeepSeekBalanceGateway = (() => {
228
228
  * Remote gateway stays transport-free and the whole spend is testable without
229
229
  * a key.
230
230
  *
231
- * The per-event pricing lives in {@link priceEvent}, the one shared fold
232
- * primitive: the events-scan paths ({@link computeSessionSpend},
233
- * {@link computeTodaySpend}) and the session-projection unit
234
- * (`billingTodaySpend` in projection.ts) all fold the same contribution, so a
235
- * pricing-table change cannot drift one path from the others.
231
+ * The per-event pricing lives in {@link priceEvent} and the fold in the
232
+ * {@link SpendAccumulator}: the events-scan paths ({@link computeSessionSpend},
233
+ * {@link computeTodaySpend}), the session-projection unit (`billingTodaySpend`
234
+ * in projection.ts), and the scanner's single-pass events path all price
235
+ * through the same primitives, so a pricing-table change cannot drift one path
236
+ * from the others.
236
237
  * @module @rayadesu/dsh-llm-billing/billing
237
238
  */
238
239
  /**
@@ -311,20 +312,28 @@ function resolveBilling(config) {
311
312
  models
312
313
  };
313
314
  }
314
- /** The Beijing (Asia/Shanghai, UTC+8, no DST) hour of a timestamp. */
315
- function beijingHour(now) {
316
- return new Date(now.getTime() + 288e5).getUTCHours();
317
- }
318
315
  /**
319
- * The Beijing (Asia/Shanghai, UTC+8, no DST) weekday of a timestamp, as
320
- * `getUTCDay()`: `0` is Sunday, `6` is Saturday.
316
+ * Derive the Beijing hour, weekday, and calendar-day key of one timestamp from
317
+ * a single shifted `Date` every timezone-sensitive read shares this one
318
+ * implementation, so the pieces cannot drift apart.
319
+ * @param time - epoch milliseconds.
321
320
  */
322
- function beijingWeekday(now) {
323
- return new Date(now.getTime() + 288e5).getUTCDay();
321
+ function beijingParts(time) {
322
+ const shifted = new Date(time + 288e5);
323
+ return {
324
+ hour: shifted.getUTCHours(),
325
+ weekday: shifted.getUTCDay(),
326
+ dayKey: shifted.toISOString().slice(0, 10)
327
+ };
324
328
  }
325
329
  /** The Beijing (Asia/Shanghai, UTC+8, no DST) calendar-day key of a timestamp. */
326
330
  function beijingDayKey(now) {
327
- return new Date(now.getTime() + 288e5).toISOString().slice(0, 10);
331
+ return beijingParts(now.getTime()).dayKey;
332
+ }
333
+ /** Whether a Beijing (hour, weekday) pair falls inside any peak-hour window. */
334
+ function isPeakParts(billing, hour, weekday) {
335
+ if (weekday === 0 || weekday === 6) return false;
336
+ return billing.peakHours.some(({ start, end }) => hour >= start && hour < end);
328
337
  }
329
338
  /**
330
339
  * Whether a timestamp falls inside any peak-hour window (Beijing time,
@@ -335,10 +344,8 @@ function beijingDayKey(now) {
335
344
  * @returns true during a weekday peak hour.
336
345
  */
337
346
  function isPeak(billing, now) {
338
- const weekday = beijingWeekday(now);
339
- if (weekday === 0 || weekday === 6) return false;
340
- const hour = beijingHour(now);
341
- return billing.peakHours.some(({ start, end }) => hour >= start && hour < end);
347
+ const { hour, weekday } = beijingParts(now.getTime());
348
+ return isPeakParts(billing, hour, weekday);
342
349
  }
343
350
  /**
344
351
  * Price one event at the official per-model rates, applying the peak/off-peak
@@ -360,8 +367,8 @@ function priceEvent(event, billing, names) {
360
367
  const model = event.data.message.source.model;
361
368
  const pricing = billing.models.get(model);
362
369
  if (pricing === void 0) return void 0;
363
- const time = new Date(event.time);
364
- const peak = isPeak(billing, time);
370
+ const { hour, weekday, dayKey } = beijingParts(event.time);
371
+ const peak = isPeakParts(billing, hour, weekday);
365
372
  const price = peak ? pricing.peak : pricing.offPeak;
366
373
  const hit = reported.cacheReadTokens ?? 0;
367
374
  const miss = reported.inputTokens + (reported.cacheWriteTokens ?? 0);
@@ -371,7 +378,7 @@ function priceEvent(event, billing, names) {
371
378
  const outputCost = output * price.output / 1e6;
372
379
  const cost = hitCost + missCost + outputCost;
373
380
  return {
374
- dayKey: beijingDayKey(time),
381
+ dayKey,
375
382
  model,
376
383
  displayName: names.get(model) ?? model,
377
384
  cost,
@@ -408,6 +415,48 @@ function contributionModel(priced) {
408
415
  outputCost: priced.outputCost
409
416
  };
410
417
  }
418
+ /** Sum two model rows of the same model (pure). */
419
+ function mergeModelRows(left, right) {
420
+ return {
421
+ model: left.model,
422
+ displayName: left.displayName,
423
+ cost: left.cost + right.cost,
424
+ peakCost: left.peakCost + right.peakCost,
425
+ offPeakCost: left.offPeakCost + right.offPeakCost,
426
+ cacheHitInputTokens: left.cacheHitInputTokens + right.cacheHitInputTokens,
427
+ cacheMissInputTokens: left.cacheMissInputTokens + right.cacheMissInputTokens,
428
+ outputTokens: left.outputTokens + right.outputTokens,
429
+ cacheHitInputCost: left.cacheHitInputCost + right.cacheHitInputCost,
430
+ cacheMissInputCost: left.cacheMissInputCost + right.cacheMissInputCost,
431
+ outputCost: left.outputCost + right.outputCost
432
+ };
433
+ }
434
+ /**
435
+ * Mutable model-row accumulator behind every spend fold. Rows keep first-seen
436
+ * model order — the same shape a pure `addEventContribution` chain produces —
437
+ * so the single-pass scan path and the pure public paths cannot diverge. One
438
+ * `Map` lookup per contribution instead of a per-event array copy: the huge
439
+ * event-log folds allocate one row object per model, not one intermediate
440
+ * array per event.
441
+ */
442
+ var SpendAccumulator = class {
443
+ rows = /* @__PURE__ */ new Map();
444
+ total = 0;
445
+ /** Add one priced contribution. */
446
+ add(priced) {
447
+ const row = contributionModel(priced);
448
+ const existing = this.rows.get(priced.model);
449
+ this.rows.set(priced.model, existing === void 0 ? row : mergeModelRows(existing, row));
450
+ this.total += priced.cost;
451
+ }
452
+ /** The folded spend; the accumulator stays usable afterwards. */
453
+ finish() {
454
+ return {
455
+ total: this.total,
456
+ models: [...this.rows.values()]
457
+ };
458
+ }
459
+ };
411
460
  /**
412
461
  * Merge one priced event's contribution into an accumulator spend (pure:
413
462
  * returns a new spend, never mutates its input).
@@ -416,19 +465,9 @@ function contributionModel(priced) {
416
465
  * @returns the merged spend.
417
466
  */
418
467
  function addEventContribution(spend, priced) {
419
- const rows = spend.models.map((row) => row.model === priced.model ? {
420
- ...row,
421
- cost: row.cost + priced.cost,
422
- peakCost: row.peakCost + priced.peakCost,
423
- offPeakCost: row.offPeakCost + priced.offPeakCost,
424
- cacheHitInputTokens: row.cacheHitInputTokens + priced.cacheHitInputTokens,
425
- cacheMissInputTokens: row.cacheMissInputTokens + priced.cacheMissInputTokens,
426
- outputTokens: row.outputTokens + priced.outputTokens,
427
- cacheHitInputCost: row.cacheHitInputCost + priced.cacheHitInputCost,
428
- cacheMissInputCost: row.cacheMissInputCost + priced.cacheMissInputCost,
429
- outputCost: row.outputCost + priced.outputCost
430
- } : row);
431
- if (!rows.some((row) => row.model === priced.model)) rows.push(contributionModel(priced));
468
+ const row = contributionModel(priced);
469
+ const rows = spend.models.map((existing) => existing.model === priced.model ? mergeModelRows(existing, row) : existing);
470
+ if (!rows.some((existing) => existing.model === priced.model)) rows.push(row);
432
471
  return {
433
472
  total: spend.total + priced.cost,
434
473
  models: rows
@@ -442,22 +481,16 @@ function addEventContribution(spend, priced) {
442
481
  * @returns the summed spend.
443
482
  */
444
483
  function mergeTodaySpend(target, source) {
445
- let merged = target;
446
- for (const row of source.models) merged = addEventContribution(merged, {
447
- dayKey: "",
448
- model: row.model,
449
- displayName: row.displayName,
450
- cost: row.cost,
451
- peakCost: row.peakCost,
452
- offPeakCost: row.offPeakCost,
453
- cacheHitInputTokens: row.cacheHitInputTokens,
454
- cacheMissInputTokens: row.cacheMissInputTokens,
455
- outputTokens: row.outputTokens,
456
- cacheHitInputCost: row.cacheHitInputCost,
457
- cacheMissInputCost: row.cacheMissInputCost,
458
- outputCost: row.outputCost
459
- });
460
- return merged;
484
+ const rows = /* @__PURE__ */ new Map();
485
+ for (const row of target.models) rows.set(row.model, row);
486
+ for (const row of source.models) {
487
+ const existing = rows.get(row.model);
488
+ rows.set(row.model, existing === void 0 ? row : mergeModelRows(existing, row));
489
+ }
490
+ return {
491
+ total: target.total + source.total,
492
+ models: [...rows.values()]
493
+ };
461
494
  }
462
495
  /**
463
496
  * Price a set of billed events at the official per-model rates, applying the
@@ -470,21 +503,18 @@ function mergeTodaySpend(target, source) {
470
503
  * published table prices only the two V4 rows).
471
504
  * @param events - the events to price.
472
505
  * @param billing - resolved pricing with peak-hour windows.
473
- * @param catalog - model display rows, in presentation order.
506
+ * @param names - model id display label.
507
+ * @param dayKey - when provided, only events on this Beijing calendar day contribute.
474
508
  * @returns the total cost plus one row per priced model.
475
509
  */
476
- function priceEvents(events, billing, catalog) {
477
- const names = new Map(catalog.map((model) => [model.id, model.name]));
478
- let spend = {
479
- total: 0,
480
- models: []
481
- };
510
+ function priceEvents(events, billing, names, dayKey) {
511
+ const accumulator = new SpendAccumulator();
482
512
  for (const event of events) {
483
513
  const priced = priceEvent(event, billing, names);
484
- if (priced === void 0) continue;
485
- spend = addEventContribution(spend, priced);
514
+ if (priced === void 0 || dayKey !== void 0 && priced.dayKey !== dayKey) continue;
515
+ accumulator.add(priced);
486
516
  }
487
- return spend;
517
+ return accumulator.finish();
488
518
  }
489
519
  /**
490
520
  * Price one session's complete event log at the official per-model rates.
@@ -494,7 +524,7 @@ function priceEvents(events, billing, catalog) {
494
524
  * @returns the session's total cost plus one row per priced model.
495
525
  */
496
526
  function computeSessionSpend(events, billing, catalog) {
497
- return priceEvents(events, billing, catalog);
527
+ return priceEvents(events, billing, new Map(catalog.map((model) => [model.id, model.name])));
498
528
  }
499
529
  /**
500
530
  * Price every event whose Beijing-time calendar day is the day of `now`,
@@ -508,14 +538,7 @@ function computeSessionSpend(events, billing, catalog) {
508
538
  */
509
539
  function computeTodaySpend(events, billing, catalog, now = /* @__PURE__ */ new Date()) {
510
540
  const day = beijingDayKey(now);
511
- const names = new Map(catalog.map((model) => [model.id, model.name]));
512
- let spend = emptyTodaySpend();
513
- for (const event of events) {
514
- const priced = priceEvent(event, billing, names);
515
- if (priced === void 0 || priced.dayKey !== day) continue;
516
- spend = addEventContribution(spend, priced);
517
- }
518
- return spend;
541
+ return priceEvents(events, billing, new Map(catalog.map((model) => [model.id, model.name])), day);
519
542
  }
520
543
  //#endregion
521
544
  //#region lib/types/projection.js
@@ -618,9 +641,10 @@ function foldBillingUnit(unit, events) {
618
641
  * with write-back) or, without the cache service, one detached local fold
619
642
  * over a full `inspect`. Persisted revisions gate every cold read, so a
620
643
  * session whose log did not change since the last resolution costs nothing.
621
- * - events path (plans A2/A3): collect only today's events (per-event
622
- * Beijing-day filter during collection) with a hard cap, skipping sessions
623
- * whose persisted revision is unchanged since the last scan.
644
+ * - events path (plans A2/A3): collect and price only today's events in one
645
+ * pass (per-event Beijing-day filter during collection) with a hard cap,
646
+ * skipping sessions whose persisted revision is unchanged since the last
647
+ * scan.
624
648
  *
625
649
  * Both strategies run behind the same {@link TodaySpendCache}, so a miss
626
650
  * happens at most once per 60 seconds per process, and a manual refresh
@@ -628,11 +652,19 @@ function foldBillingUnit(unit, events) {
628
652
  * unchanged log provably cannot change the aggregate.
629
653
  * @module @rayadesu/dsh-llm-billing/today-spend
630
654
  */
631
- /** Bounded parallel fan-out: run `run` over `items` with at most `limit` in flight. */
655
+ /**
656
+ * Bounded parallel fan-out: run `run` over `items` with at most `limit` in
657
+ * flight. A shared index counter hands each worker its next job, so the
658
+ * dispatch is O(n) overall (array `shift()` would be O(n) per pop).
659
+ */
632
660
  async function withConcurrency(items, limit, run) {
633
- const queue = [...items];
634
- await Promise.all(Array.from({ length: Math.min(limit, queue.length) }, async () => {
635
- for (let job = queue.shift(); job !== void 0; job = queue.shift()) await run(job);
661
+ const total = items.length;
662
+ let next = 0;
663
+ await Promise.all(Array.from({ length: Math.min(limit, total) }, async () => {
664
+ for (let job = next; job < total; job = next) {
665
+ next += 1;
666
+ await run(items[job]);
667
+ }
636
668
  }));
637
669
  }
638
670
  /**
@@ -713,13 +745,15 @@ var TodaySpendScanner = class {
713
745
  /** Projection path: eager cells for live sessions, revision-gated cold ladder for the rest. */
714
746
  async scanProjections(dayKey) {
715
747
  const { sessions, persistence, projections, projectionCache, unit, logger } = this.deps;
748
+ const projectionsService = projections?.();
749
+ const cache = projectionCache?.();
716
750
  let total = emptyTodaySpend();
717
751
  const liveIds = /* @__PURE__ */ new Set();
718
752
  if (sessions !== void 0) {
719
753
  const store = sessions();
720
754
  if (store !== void 0) for (const session of store.list()) {
721
755
  liveIds.add(session.id);
722
- const state = projections?.()?.stateOf(session, BILLING_UNIT_KEY);
756
+ const state = projectionsService?.stateOf(session, BILLING_UNIT_KEY);
723
757
  if (state !== void 0 && state.dayKey === dayKey) total = mergeTodaySpend(total, state.spend);
724
758
  }
725
759
  }
@@ -741,7 +775,6 @@ var TodaySpendScanner = class {
741
775
  }
742
776
  await withConcurrency(pending, 8, async ({ id, revision }) => {
743
777
  let value;
744
- const cache = projectionCache?.();
745
778
  if (cache !== void 0) try {
746
779
  value = (await cache.coldSnapshot(id)).values[BILLING_UNIT_KEY];
747
780
  } catch (error) {
@@ -764,24 +797,34 @@ var TodaySpendScanner = class {
764
797
  }
765
798
  return total;
766
799
  }
767
- /** Events path: collect only today's events (capped), gated by revisions. */
800
+ /**
801
+ * Events path: price today's events in a single pass (per-event Beijing-day
802
+ * filter during collection, hard cap), gated by revisions.
803
+ */
768
804
  async scanEvents(dayKey) {
769
805
  const { sessions, persistence, maxEvents, logger, billing, catalog } = this.deps;
770
- const events = [];
806
+ const names = new Map(catalog.map((model) => [model.id, model.name]));
807
+ const accumulator = new SpendAccumulator();
771
808
  const liveIds = /* @__PURE__ */ new Set();
809
+ let collected = 0;
772
810
  let truncated = false;
811
+ const collect = (events) => {
812
+ for (const event of events) {
813
+ if (beijingDayKey(new Date(event.time)) !== dayKey) continue;
814
+ collected += 1;
815
+ if (collected > maxEvents) {
816
+ truncated = true;
817
+ return;
818
+ }
819
+ const priced = priceEvent(event, billing, names);
820
+ if (priced !== void 0) accumulator.add(priced);
821
+ }
822
+ };
773
823
  if (sessions !== void 0) {
774
824
  const store = sessions();
775
825
  if (store !== void 0) for (const session of store.list()) {
776
826
  liveIds.add(session.id);
777
- for (const event of session.events) {
778
- if (beijingDayKey(new Date(event.time)) !== dayKey) continue;
779
- events.push(event);
780
- if (events.length >= maxEvents) {
781
- truncated = true;
782
- break;
783
- }
784
- }
827
+ collect(session.events);
785
828
  if (truncated) break;
786
829
  }
787
830
  }
@@ -792,15 +835,7 @@ var TodaySpendScanner = class {
792
835
  if (liveIds.has(header.id)) continue;
793
836
  if (this.lastEventsScan?.get(header.id) === revision) continue;
794
837
  try {
795
- const inspection = await persistenceService.inspect(header.id);
796
- for (const event of inspection.events) {
797
- if (beijingDayKey(new Date(event.time)) !== dayKey) continue;
798
- events.push(event);
799
- if (events.length >= maxEvents) {
800
- truncated = true;
801
- break;
802
- }
803
- }
838
+ collect((await persistenceService.inspect(header.id)).events);
804
839
  } catch (error) {
805
840
  logger.warn(`llm-billing: skipping unreadable session ${header.id}: ${String(error)}`);
806
841
  }
@@ -809,7 +844,7 @@ var TodaySpendScanner = class {
809
844
  if (!truncated) this.lastEventsScan = new Map(snapshots.map((snapshot) => [snapshot.header.id, snapshot.revision]));
810
845
  }
811
846
  if (truncated) logger.warn(`llm-billing: today's events exceeded ${maxEvents}; result truncated`);
812
- return computeTodaySpend(events, billing, catalog, /* @__PURE__ */ new Date(`${dayKey}T00:00:00Z`));
847
+ return accumulator.finish();
813
848
  }
814
849
  };
815
850
  //#endregion
@@ -829,6 +864,11 @@ var TodaySpendScanner = class {
829
864
  * session-projection registry is composed, the plugin additionally registers
830
865
  * the `billingTodaySpend` projection unit, which folds each session's spend
831
866
  * eagerly and lets cold reads ride the projection-cache ladder.
867
+ *
868
+ * A per-session spend cache makes the badge's turn-settled recompute
869
+ * incremental: session logs are append-only and chronological (the same
870
+ * assumption the projection unit makes), so the spend is reused while the log
871
+ * length is unchanged, and only the appended tail is priced when it grows.
832
872
  * @module @rayadesu/dsh-llm-billing
833
873
  */
834
874
  const name = "llm-billing";
@@ -928,8 +968,26 @@ function apply(ctx, config) {
928
968
  id: model.id,
929
969
  name: model.name ?? model.id
930
970
  }));
971
+ const sessionSpendCache = /* @__PURE__ */ new Map();
931
972
  const fetchSessionSpend = async (sessionId) => {
932
- return computeSessionSpend(await sessionEvents(ctx, sessionId), billing, catalog);
973
+ const events = await sessionEvents(ctx, sessionId);
974
+ const cached = sessionSpendCache.get(sessionId);
975
+ if (cached !== void 0 && cached.count === events.length) return cached.spend;
976
+ if (cached !== void 0 && cached.count < events.length) {
977
+ const spend = mergeTodaySpend(cached.spend, computeSessionSpend(events.slice(cached.count), billing, catalog));
978
+ sessionSpendCache.set(sessionId, {
979
+ count: events.length,
980
+ spend
981
+ });
982
+ return spend;
983
+ }
984
+ const spend = computeSessionSpend(events, billing, catalog);
985
+ if (sessionSpendCache.size >= 1024) sessionSpendCache.clear();
986
+ sessionSpendCache.set(sessionId, {
987
+ count: events.length,
988
+ spend
989
+ });
990
+ return spend;
933
991
  };
934
992
  const unit = billingTodaySpendDefinition(billing, catalog);
935
993
  let unitRegistered = false;
@@ -961,4 +1019,4 @@ function apply(ctx, config) {
961
1019
  });
962
1020
  }
963
1021
  //#endregion
964
- 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 };
1022
+ export { BILLING_UNIT_KEY, Config, DEFAULT_MODEL_PRICING, DEFAULT_PEAK_HOURS, DeepSeekBalanceGateway, PUBLIC_BASE_URL, SpendAccumulator, 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 };
@@ -4,11 +4,12 @@
4
4
  * Remote gateway stays transport-free and the whole spend is testable without
5
5
  * a key.
6
6
  *
7
- * The per-event pricing lives in {@link priceEvent}, the one shared fold
8
- * primitive: the events-scan paths ({@link computeSessionSpend},
9
- * {@link computeTodaySpend}) and the session-projection unit
10
- * (`billingTodaySpend` in projection.ts) all fold the same contribution, so a
11
- * pricing-table change cannot drift one path from the others.
7
+ * The per-event pricing lives in {@link priceEvent} and the fold in the
8
+ * {@link SpendAccumulator}: the events-scan paths ({@link computeSessionSpend},
9
+ * {@link computeTodaySpend}), the session-projection unit (`billingTodaySpend`
10
+ * in projection.ts), and the scanner's single-pass events path all price
11
+ * through the same primitives, so a pricing-table change cannot drift one path
12
+ * from the others.
12
13
  * @module @rayadesu/dsh-llm-billing/billing
13
14
  */
14
15
  import type { SessionEvent } from '@deepseek-ai/dsh-session';
@@ -143,6 +144,22 @@ export interface BillingEventContribution {
143
144
  export declare function priceEvent(event: SessionEvent, billing: ResolvedBilling, names: ReadonlyMap<string, string>): BillingEventContribution | undefined;
144
145
  /** A spend with no priced usage. */
145
146
  export declare function emptyTodaySpend(): DeepSeekTodaySpend;
147
+ /**
148
+ * Mutable model-row accumulator behind every spend fold. Rows keep first-seen
149
+ * model order — the same shape a pure `addEventContribution` chain produces —
150
+ * so the single-pass scan path and the pure public paths cannot diverge. One
151
+ * `Map` lookup per contribution instead of a per-event array copy: the huge
152
+ * event-log folds allocate one row object per model, not one intermediate
153
+ * array per event.
154
+ */
155
+ export declare class SpendAccumulator {
156
+ private readonly rows;
157
+ private total;
158
+ /** Add one priced contribution. */
159
+ add(priced: BillingEventContribution): void;
160
+ /** The folded spend; the accumulator stays usable afterwards. */
161
+ finish(): DeepSeekTodaySpend;
162
+ }
146
163
  /**
147
164
  * Merge one priced event's contribution into an accumulator spend (pure:
148
165
  * returns a new spend, never mutates its input).
@@ -4,11 +4,12 @@
4
4
  * Remote gateway stays transport-free and the whole spend is testable without
5
5
  * a key.
6
6
  *
7
- * The per-event pricing lives in {@link priceEvent}, the one shared fold
8
- * primitive: the events-scan paths ({@link computeSessionSpend},
9
- * {@link computeTodaySpend}) and the session-projection unit
10
- * (`billingTodaySpend` in projection.ts) all fold the same contribution, so a
11
- * pricing-table change cannot drift one path from the others.
7
+ * The per-event pricing lives in {@link priceEvent} and the fold in the
8
+ * {@link SpendAccumulator}: the events-scan paths ({@link computeSessionSpend},
9
+ * {@link computeTodaySpend}), the session-projection unit (`billingTodaySpend`
10
+ * in projection.ts), and the scanner's single-pass events path all price
11
+ * through the same primitives, so a pricing-table change cannot drift one path
12
+ * from the others.
12
13
  * @module @rayadesu/dsh-llm-billing/billing
13
14
  */
14
15
  /**
@@ -61,20 +62,29 @@ export function resolveBilling(config) {
61
62
  models.set(row.model, { peak: row.peak, offPeak: row.offPeak });
62
63
  return { peakHours, models };
63
64
  }
64
- /** The Beijing (Asia/Shanghai, UTC+8, no DST) hour of a timestamp. */
65
- function beijingHour(now) {
66
- return new Date(now.getTime() + 8 * 3_600_000).getUTCHours();
67
- }
68
65
  /**
69
- * The Beijing (Asia/Shanghai, UTC+8, no DST) weekday of a timestamp, as
70
- * `getUTCDay()`: `0` is Sunday, `6` is Saturday.
66
+ * Derive the Beijing hour, weekday, and calendar-day key of one timestamp from
67
+ * a single shifted `Date` every timezone-sensitive read shares this one
68
+ * implementation, so the pieces cannot drift apart.
69
+ * @param time - epoch milliseconds.
71
70
  */
72
- function beijingWeekday(now) {
73
- return new Date(now.getTime() + 8 * 3_600_000).getUTCDay();
71
+ function beijingParts(time) {
72
+ const shifted = new Date(time + 8 * 3_600_000);
73
+ return {
74
+ hour: shifted.getUTCHours(),
75
+ weekday: shifted.getUTCDay(),
76
+ dayKey: shifted.toISOString().slice(0, 10),
77
+ };
74
78
  }
75
79
  /** The Beijing (Asia/Shanghai, UTC+8, no DST) calendar-day key of a timestamp. */
76
80
  export function beijingDayKey(now) {
77
- return new Date(now.getTime() + 8 * 3_600_000).toISOString().slice(0, 10);
81
+ return beijingParts(now.getTime()).dayKey;
82
+ }
83
+ /** Whether a Beijing (hour, weekday) pair falls inside any peak-hour window. */
84
+ function isPeakParts(billing, hour, weekday) {
85
+ if (weekday === 0 || weekday === 6)
86
+ return false;
87
+ return billing.peakHours.some(({ start, end }) => hour >= start && hour < end);
78
88
  }
79
89
  /**
80
90
  * Whether a timestamp falls inside any peak-hour window (Beijing time,
@@ -85,11 +95,8 @@ export function beijingDayKey(now) {
85
95
  * @returns true during a weekday peak hour.
86
96
  */
87
97
  export function isPeak(billing, now) {
88
- const weekday = beijingWeekday(now);
89
- if (weekday === 0 || weekday === 6)
90
- return false;
91
- const hour = beijingHour(now);
92
- return billing.peakHours.some(({ start, end }) => hour >= start && hour < end);
98
+ const { hour, weekday } = beijingParts(now.getTime());
99
+ return isPeakParts(billing, hour, weekday);
93
100
  }
94
101
  /**
95
102
  * Price one event at the official per-model rates, applying the peak/off-peak
@@ -114,8 +121,8 @@ export function priceEvent(event, billing, names) {
114
121
  const pricing = billing.models.get(model);
115
122
  if (pricing === undefined)
116
123
  return undefined;
117
- const time = new Date(event.time);
118
- const peak = isPeak(billing, time);
124
+ const { hour, weekday, dayKey } = beijingParts(event.time);
125
+ const peak = isPeakParts(billing, hour, weekday);
119
126
  const price = peak ? pricing.peak : pricing.offPeak;
120
127
  const hit = reported.cacheReadTokens ?? 0;
121
128
  const miss = reported.inputTokens + (reported.cacheWriteTokens ?? 0);
@@ -125,7 +132,7 @@ export function priceEvent(event, billing, names) {
125
132
  const outputCost = (output * price.output) / 1_000_000;
126
133
  const cost = hitCost + missCost + outputCost;
127
134
  return {
128
- dayKey: beijingDayKey(time),
135
+ dayKey,
129
136
  model,
130
137
  displayName: names.get(model) ?? model,
131
138
  cost,
@@ -159,6 +166,45 @@ function contributionModel(priced) {
159
166
  outputCost: priced.outputCost,
160
167
  };
161
168
  }
169
+ /** Sum two model rows of the same model (pure). */
170
+ function mergeModelRows(left, right) {
171
+ return {
172
+ model: left.model,
173
+ displayName: left.displayName,
174
+ cost: left.cost + right.cost,
175
+ peakCost: left.peakCost + right.peakCost,
176
+ offPeakCost: left.offPeakCost + right.offPeakCost,
177
+ cacheHitInputTokens: left.cacheHitInputTokens + right.cacheHitInputTokens,
178
+ cacheMissInputTokens: left.cacheMissInputTokens + right.cacheMissInputTokens,
179
+ outputTokens: left.outputTokens + right.outputTokens,
180
+ cacheHitInputCost: left.cacheHitInputCost + right.cacheHitInputCost,
181
+ cacheMissInputCost: left.cacheMissInputCost + right.cacheMissInputCost,
182
+ outputCost: left.outputCost + right.outputCost,
183
+ };
184
+ }
185
+ /**
186
+ * Mutable model-row accumulator behind every spend fold. Rows keep first-seen
187
+ * model order — the same shape a pure `addEventContribution` chain produces —
188
+ * so the single-pass scan path and the pure public paths cannot diverge. One
189
+ * `Map` lookup per contribution instead of a per-event array copy: the huge
190
+ * event-log folds allocate one row object per model, not one intermediate
191
+ * array per event.
192
+ */
193
+ export class SpendAccumulator {
194
+ rows = new Map();
195
+ total = 0;
196
+ /** Add one priced contribution. */
197
+ add(priced) {
198
+ const row = contributionModel(priced);
199
+ const existing = this.rows.get(priced.model);
200
+ this.rows.set(priced.model, existing === undefined ? row : mergeModelRows(existing, row));
201
+ this.total += priced.cost;
202
+ }
203
+ /** The folded spend; the accumulator stays usable afterwards. */
204
+ finish() {
205
+ return { total: this.total, models: [...this.rows.values()] };
206
+ }
207
+ }
162
208
  /**
163
209
  * Merge one priced event's contribution into an accumulator spend (pure:
164
210
  * returns a new spend, never mutates its input).
@@ -167,22 +213,10 @@ function contributionModel(priced) {
167
213
  * @returns the merged spend.
168
214
  */
169
215
  export function addEventContribution(spend, priced) {
170
- const rows = spend.models.map(row => row.model === priced.model
171
- ? {
172
- ...row,
173
- cost: row.cost + priced.cost,
174
- peakCost: row.peakCost + priced.peakCost,
175
- offPeakCost: row.offPeakCost + priced.offPeakCost,
176
- cacheHitInputTokens: row.cacheHitInputTokens + priced.cacheHitInputTokens,
177
- cacheMissInputTokens: row.cacheMissInputTokens + priced.cacheMissInputTokens,
178
- outputTokens: row.outputTokens + priced.outputTokens,
179
- cacheHitInputCost: row.cacheHitInputCost + priced.cacheHitInputCost,
180
- cacheMissInputCost: row.cacheMissInputCost + priced.cacheMissInputCost,
181
- outputCost: row.outputCost + priced.outputCost,
182
- }
183
- : row);
184
- if (!rows.some(row => row.model === priced.model))
185
- rows.push(contributionModel(priced));
216
+ const row = contributionModel(priced);
217
+ const rows = spend.models.map(existing => existing.model === priced.model ? mergeModelRows(existing, row) : existing);
218
+ if (!rows.some(existing => existing.model === priced.model))
219
+ rows.push(row);
186
220
  return { total: spend.total + priced.cost, models: rows };
187
221
  }
188
222
  /**
@@ -193,24 +227,14 @@ export function addEventContribution(spend, priced) {
193
227
  * @returns the summed spend.
194
228
  */
195
229
  export function mergeTodaySpend(target, source) {
196
- let merged = target;
230
+ const rows = new Map();
231
+ for (const row of target.models)
232
+ rows.set(row.model, row);
197
233
  for (const row of source.models) {
198
- merged = addEventContribution(merged, {
199
- dayKey: '',
200
- model: row.model,
201
- displayName: row.displayName,
202
- cost: row.cost,
203
- peakCost: row.peakCost,
204
- offPeakCost: row.offPeakCost,
205
- cacheHitInputTokens: row.cacheHitInputTokens,
206
- cacheMissInputTokens: row.cacheMissInputTokens,
207
- outputTokens: row.outputTokens,
208
- cacheHitInputCost: row.cacheHitInputCost,
209
- cacheMissInputCost: row.cacheMissInputCost,
210
- outputCost: row.outputCost,
211
- });
234
+ const existing = rows.get(row.model);
235
+ rows.set(row.model, existing === undefined ? row : mergeModelRows(existing, row));
212
236
  }
213
- return merged;
237
+ return { total: target.total + source.total, models: [...rows.values()] };
214
238
  }
215
239
  /**
216
240
  * Price a set of billed events at the official per-model rates, applying the
@@ -223,19 +247,19 @@ export function mergeTodaySpend(target, source) {
223
247
  * published table prices only the two V4 rows).
224
248
  * @param events - the events to price.
225
249
  * @param billing - resolved pricing with peak-hour windows.
226
- * @param catalog - model display rows, in presentation order.
250
+ * @param names - model id display label.
251
+ * @param dayKey - when provided, only events on this Beijing calendar day contribute.
227
252
  * @returns the total cost plus one row per priced model.
228
253
  */
229
- function priceEvents(events, billing, catalog) {
230
- const names = new Map(catalog.map(model => [model.id, model.name]));
231
- let spend = { total: 0, models: [] };
254
+ function priceEvents(events, billing, names, dayKey) {
255
+ const accumulator = new SpendAccumulator();
232
256
  for (const event of events) {
233
257
  const priced = priceEvent(event, billing, names);
234
- if (priced === undefined)
258
+ if (priced === undefined || (dayKey !== undefined && priced.dayKey !== dayKey))
235
259
  continue;
236
- spend = addEventContribution(spend, priced);
260
+ accumulator.add(priced);
237
261
  }
238
- return spend;
262
+ return accumulator.finish();
239
263
  }
240
264
  /**
241
265
  * Price one session's complete event log at the official per-model rates.
@@ -245,7 +269,8 @@ function priceEvents(events, billing, catalog) {
245
269
  * @returns the session's total cost plus one row per priced model.
246
270
  */
247
271
  export function computeSessionSpend(events, billing, catalog) {
248
- return priceEvents(events, billing, catalog);
272
+ const names = new Map(catalog.map(model => [model.id, model.name]));
273
+ return priceEvents(events, billing, names);
249
274
  }
250
275
  /**
251
276
  * Price every event whose Beijing-time calendar day is the day of `now`,
@@ -260,13 +285,6 @@ export function computeSessionSpend(events, billing, catalog) {
260
285
  export function computeTodaySpend(events, billing, catalog, now = new Date()) {
261
286
  const day = beijingDayKey(now);
262
287
  const names = new Map(catalog.map(model => [model.id, model.name]));
263
- let spend = emptyTodaySpend();
264
- for (const event of events) {
265
- const priced = priceEvent(event, billing, names);
266
- if (priced === undefined || priced.dayKey !== day)
267
- continue;
268
- spend = addEventContribution(spend, priced);
269
- }
270
- return spend;
288
+ return priceEvents(events, billing, names, day);
271
289
  }
272
290
  //# sourceMappingURL=billing.js.map
@@ -13,13 +13,18 @@
13
13
  * session-projection registry is composed, the plugin additionally registers
14
14
  * the `billingTodaySpend` projection unit, which folds each session's spend
15
15
  * eagerly and lets cold reads ride the projection-cache ladder.
16
+ *
17
+ * A per-session spend cache makes the badge's turn-settled recompute
18
+ * incremental: session logs are append-only and chronological (the same
19
+ * assumption the projection unit makes), so the spend is reused while the log
20
+ * length is unchanged, and only the appended tail is priced when it grows.
16
21
  * @module @rayadesu/dsh-llm-billing
17
22
  */
18
23
  import type { Context } from '@deepseek-ai/cordis';
19
24
  import z from '@deepseek-ai/schemastery';
20
25
  import type { BillingConfig } from './billing.ts';
21
26
  export { DeepSeekBalanceGateway, fetchDeepSeekBalance, parseDeepSeekBalance } from './balance.ts';
22
- export { addEventContribution, beijingDayKey, computeSessionSpend, computeTodaySpend, DEFAULT_MODEL_PRICING, DEFAULT_PEAK_HOURS, emptyTodaySpend, isPeak, mergeTodaySpend, priceEvent, resolveBilling, } from './billing.ts';
27
+ export { addEventContribution, beijingDayKey, computeSessionSpend, computeTodaySpend, DEFAULT_MODEL_PRICING, DEFAULT_PEAK_HOURS, emptyTodaySpend, isPeak, mergeTodaySpend, priceEvent, resolveBilling, SpendAccumulator, } from './billing.ts';
23
28
  export type { BillingConfig, BillingConfigModel, BillingEventContribution, DeepSeekModelPricing, DeepSeekTokenPrice, PeakHourWindow, ResolvedBilling, } from './billing.ts';
24
29
  export type * from './types.ts';
25
30
  export { BILLING_UNIT_KEY, billingTodaySpendDefinition, foldBillingUnit } from './projection.ts';
@@ -13,6 +13,11 @@
13
13
  * session-projection registry is composed, the plugin additionally registers
14
14
  * the `billingTodaySpend` projection unit, which folds each session's spend
15
15
  * eagerly and lets cold reads ride the projection-cache ladder.
16
+ *
17
+ * A per-session spend cache makes the badge's turn-settled recompute
18
+ * incremental: session logs are append-only and chronological (the same
19
+ * assumption the projection unit makes), so the spend is reused while the log
20
+ * length is unchanged, and only the appended tail is priced when it grows.
16
21
  * @module @rayadesu/dsh-llm-billing
17
22
  */
18
23
  import z from '@deepseek-ai/schemastery';
@@ -20,11 +25,11 @@ import { assertUsableApiKey, LlmError } from '@deepseek-ai/dsh-llm';
20
25
  import { credentialRef } from '@deepseek-ai/dsh-credentials';
21
26
  import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment';
22
27
  import { DeepSeekBalanceGateway, fetchDeepSeekBalance } from "./balance.js";
23
- import { computeSessionSpend, DEFAULT_MODEL_PRICING, DEFAULT_PEAK_HOURS, resolveBilling, } from "./billing.js";
28
+ import { computeSessionSpend, DEFAULT_MODEL_PRICING, DEFAULT_PEAK_HOURS, mergeTodaySpend, resolveBilling, } from "./billing.js";
24
29
  import { billingTodaySpendDefinition } from "./projection.js";
25
30
  import { TodaySpendCache, TodaySpendScanner } from "./today-spend.js";
26
31
  export { DeepSeekBalanceGateway, fetchDeepSeekBalance, parseDeepSeekBalance } from "./balance.js";
27
- export { addEventContribution, beijingDayKey, computeSessionSpend, computeTodaySpend, DEFAULT_MODEL_PRICING, DEFAULT_PEAK_HOURS, emptyTodaySpend, isPeak, mergeTodaySpend, priceEvent, resolveBilling, } from "./billing.js";
32
+ export { addEventContribution, beijingDayKey, computeSessionSpend, computeTodaySpend, DEFAULT_MODEL_PRICING, DEFAULT_PEAK_HOURS, emptyTodaySpend, isPeak, mergeTodaySpend, priceEvent, resolveBilling, SpendAccumulator, } from "./billing.js";
28
33
  export { BILLING_UNIT_KEY, billingTodaySpendDefinition, foldBillingUnit } from "./projection.js";
29
34
  export { TodaySpendCache, TodaySpendScanner } from "./today-spend.js";
30
35
  export const name = 'llm-billing';
@@ -123,8 +128,29 @@ export function apply(ctx, config) {
123
128
  };
124
129
  const billing = resolveBilling(config.billing);
125
130
  const catalog = (config.models ?? DEFAULT_MODELS).map(model => ({ id: model.id, name: model.name ?? model.id }));
131
+ // Per-session incremental spend cache: a session log is append-only and
132
+ // chronological (the same assumption the projection unit makes), so a spend
133
+ // computed for `count` events stays valid while the log length is unchanged,
134
+ // and only the appended tail needs pricing when it grows. A pricing-table
135
+ // change does not retroactively reprice (same caveat as the projection
136
+ // path); the map is capped so an unbounded session-id space cannot grow it
137
+ // without bound.
138
+ const sessionSpendCache = new Map();
126
139
  const fetchSessionSpend = async (sessionId) => {
127
- return computeSessionSpend(await sessionEvents(ctx, sessionId), billing, catalog);
140
+ const events = await sessionEvents(ctx, sessionId);
141
+ const cached = sessionSpendCache.get(sessionId);
142
+ if (cached !== undefined && cached.count === events.length)
143
+ return cached.spend;
144
+ if (cached !== undefined && cached.count < events.length) {
145
+ const spend = mergeTodaySpend(cached.spend, computeSessionSpend(events.slice(cached.count), billing, catalog));
146
+ sessionSpendCache.set(sessionId, { count: events.length, spend });
147
+ return spend;
148
+ }
149
+ const spend = computeSessionSpend(events, billing, catalog);
150
+ if (sessionSpendCache.size >= 1024)
151
+ sessionSpendCache.clear();
152
+ sessionSpendCache.set(sessionId, { count: events.length, spend });
153
+ return spend;
128
154
  };
129
155
  // Plan C: register the per-session spend projection unit on the projection
130
156
  // registry. Registration is lazy — it happens on the first projection-path
@@ -9,9 +9,10 @@
9
9
  * with write-back) or, without the cache service, one detached local fold
10
10
  * over a full `inspect`. Persisted revisions gate every cold read, so a
11
11
  * session whose log did not change since the last resolution costs nothing.
12
- * - events path (plans A2/A3): collect only today's events (per-event
13
- * Beijing-day filter during collection) with a hard cap, skipping sessions
14
- * whose persisted revision is unchanged since the last scan.
12
+ * - events path (plans A2/A3): collect and price only today's events in one
13
+ * pass (per-event Beijing-day filter during collection) with a hard cap,
14
+ * skipping sessions whose persisted revision is unchanged since the last
15
+ * scan.
15
16
  *
16
17
  * Both strategies run behind the same {@link TodaySpendCache}, so a miss
17
18
  * happens at most once per 60 seconds per process, and a manual refresh
@@ -132,7 +133,10 @@ export declare class TodaySpendScanner {
132
133
  scan(dayKey: string): Promise<DeepSeekTodaySpend>;
133
134
  /** Projection path: eager cells for live sessions, revision-gated cold ladder for the rest. */
134
135
  private scanProjections;
135
- /** Events path: collect only today's events (capped), gated by revisions. */
136
+ /**
137
+ * Events path: price today's events in a single pass (per-event Beijing-day
138
+ * filter during collection, hard cap), gated by revisions.
139
+ */
136
140
  private scanEvents;
137
141
  }
138
142
  //# sourceMappingURL=today-spend.d.ts.map
@@ -9,9 +9,10 @@
9
9
  * with write-back) or, without the cache service, one detached local fold
10
10
  * over a full `inspect`. Persisted revisions gate every cold read, so a
11
11
  * session whose log did not change since the last resolution costs nothing.
12
- * - events path (plans A2/A3): collect only today's events (per-event
13
- * Beijing-day filter during collection) with a hard cap, skipping sessions
14
- * whose persisted revision is unchanged since the last scan.
12
+ * - events path (plans A2/A3): collect and price only today's events in one
13
+ * pass (per-event Beijing-day filter during collection) with a hard cap,
14
+ * skipping sessions whose persisted revision is unchanged since the last
15
+ * scan.
15
16
  *
16
17
  * Both strategies run behind the same {@link TodaySpendCache}, so a miss
17
18
  * happens at most once per 60 seconds per process, and a manual refresh
@@ -19,14 +20,21 @@
19
20
  * unchanged log provably cannot change the aggregate.
20
21
  * @module @rayadesu/dsh-llm-billing/today-spend
21
22
  */
22
- import { beijingDayKey, computeTodaySpend, emptyTodaySpend, mergeTodaySpend } from "./billing.js";
23
+ import { beijingDayKey, emptyTodaySpend, mergeTodaySpend, priceEvent, SpendAccumulator } from "./billing.js";
23
24
  import { BILLING_UNIT_KEY, foldBillingUnit } from "./projection.js";
24
- /** Bounded parallel fan-out: run `run` over `items` with at most `limit` in flight. */
25
+ /**
26
+ * Bounded parallel fan-out: run `run` over `items` with at most `limit` in
27
+ * flight. A shared index counter hands each worker its next job, so the
28
+ * dispatch is O(n) overall (array `shift()` would be O(n) per pop).
29
+ */
25
30
  async function withConcurrency(items, limit, run) {
26
- const queue = [...items];
27
- await Promise.all(Array.from({ length: Math.min(limit, queue.length) }, async () => {
28
- for (let job = queue.shift(); job !== undefined; job = queue.shift())
29
- await run(job);
31
+ const total = items.length;
32
+ let next = 0;
33
+ await Promise.all(Array.from({ length: Math.min(limit, total) }, async () => {
34
+ for (let job = next; job < total; job = next) {
35
+ next += 1;
36
+ await run(items[job]);
37
+ }
30
38
  }));
31
39
  }
32
40
  /**
@@ -114,6 +122,9 @@ export class TodaySpendScanner {
114
122
  /** Projection path: eager cells for live sessions, revision-gated cold ladder for the rest. */
115
123
  async scanProjections(dayKey) {
116
124
  const { sessions, persistence, projections, projectionCache, unit, logger } = this.deps;
125
+ // Services resolve once per scan, not per session / per cold task.
126
+ const projectionsService = projections?.();
127
+ const cache = projectionCache?.();
117
128
  let total = emptyTodaySpend();
118
129
  const liveIds = new Set();
119
130
  if (sessions !== undefined) {
@@ -121,7 +132,7 @@ export class TodaySpendScanner {
121
132
  if (store !== undefined) {
122
133
  for (const session of store.list()) {
123
134
  liveIds.add(session.id);
124
- const state = projections?.()?.stateOf(session, BILLING_UNIT_KEY);
135
+ const state = projectionsService?.stateOf(session, BILLING_UNIT_KEY);
125
136
  if (state !== undefined && state.dayKey === dayKey) {
126
137
  total = mergeTodaySpend(total, state.spend);
127
138
  }
@@ -146,7 +157,6 @@ export class TodaySpendScanner {
146
157
  }
147
158
  await withConcurrency(pending, 8, async ({ id, revision }) => {
148
159
  let value;
149
- const cache = projectionCache?.();
150
160
  if (cache !== undefined) {
151
161
  try {
152
162
  value = (await cache.coldSnapshot(id)).values[BILLING_UNIT_KEY];
@@ -176,26 +186,37 @@ export class TodaySpendScanner {
176
186
  }
177
187
  return total;
178
188
  }
179
- /** Events path: collect only today's events (capped), gated by revisions. */
189
+ /**
190
+ * Events path: price today's events in a single pass (per-event Beijing-day
191
+ * filter during collection, hard cap), gated by revisions.
192
+ */
180
193
  async scanEvents(dayKey) {
181
194
  const { sessions, persistence, maxEvents, logger, billing, catalog } = this.deps;
182
- const events = [];
195
+ const names = new Map(catalog.map(model => [model.id, model.name]));
196
+ const accumulator = new SpendAccumulator();
183
197
  const liveIds = new Set();
198
+ let collected = 0;
184
199
  let truncated = false;
200
+ const collect = (events) => {
201
+ for (const event of events) {
202
+ if (beijingDayKey(new Date(event.time)) !== dayKey)
203
+ continue;
204
+ collected += 1;
205
+ if (collected > maxEvents) {
206
+ truncated = true;
207
+ return;
208
+ }
209
+ const priced = priceEvent(event, billing, names);
210
+ if (priced !== undefined)
211
+ accumulator.add(priced);
212
+ }
213
+ };
185
214
  if (sessions !== undefined) {
186
215
  const store = sessions();
187
216
  if (store !== undefined) {
188
217
  for (const session of store.list()) {
189
218
  liveIds.add(session.id);
190
- for (const event of session.events) {
191
- if (beijingDayKey(new Date(event.time)) !== dayKey)
192
- continue;
193
- events.push(event);
194
- if (events.length >= maxEvents) {
195
- truncated = true;
196
- break;
197
- }
198
- }
219
+ collect(session.events);
199
220
  if (truncated)
200
221
  break;
201
222
  }
@@ -210,16 +231,7 @@ export class TodaySpendScanner {
210
231
  if (this.lastEventsScan?.get(header.id) === revision)
211
232
  continue;
212
233
  try {
213
- const inspection = await persistenceService.inspect(header.id);
214
- for (const event of inspection.events) {
215
- if (beijingDayKey(new Date(event.time)) !== dayKey)
216
- continue;
217
- events.push(event);
218
- if (events.length >= maxEvents) {
219
- truncated = true;
220
- break;
221
- }
222
- }
234
+ collect((await persistenceService.inspect(header.id)).events);
223
235
  }
224
236
  catch (error) {
225
237
  // One unreadable session must not blank the whole-day aggregate.
@@ -237,10 +249,7 @@ export class TodaySpendScanner {
237
249
  }
238
250
  if (truncated)
239
251
  logger.warn(`llm-billing: today's events exceeded ${maxEvents}; result truncated`);
240
- // The reference moment is derived from the day key (UTC midnight on that
241
- // date is 08:00 Beijing the same day), so the pricing re-check cannot
242
- // drift from the collection filter across a Beijing-day boundary.
243
- return computeTodaySpend(events, billing, catalog, new Date(`${dayKey}T00:00:00Z`));
252
+ return accumulator.finish();
244
253
  }
245
254
  }
246
255
  //# sourceMappingURL=today-spend.js.map
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rayadesu/dsh-llm-billing",
3
3
  "description": "Standalone DeepSeek account-balance and session-spend provider exposed through the billing Remote",
4
- "version": "0.2.2",
4
+ "version": "0.2.3",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },