@rayadesu/dsh-llm-billing 0.3.8 → 0.3.10

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
@@ -137,6 +137,7 @@ let DeepSeekBalanceGateway = (() => {
137
137
  let _getTodaySpend_decorators;
138
138
  let _getTodaySessionsSpend_decorators;
139
139
  let _getTurnSpend_decorators;
140
+ let _getSessionTurnSpends_decorators;
140
141
  return class DeepSeekBalanceGateway extends _classSuper {
141
142
  static {
142
143
  const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
@@ -145,6 +146,7 @@ let DeepSeekBalanceGateway = (() => {
145
146
  _getTodaySpend_decorators = [Remote("getTodaySpend")];
146
147
  _getTodaySessionsSpend_decorators = [Remote("getTodaySessionsSpend")];
147
148
  _getTurnSpend_decorators = [Remote("getTurnSpend")];
149
+ _getSessionTurnSpends_decorators = [Remote("getSessionTurnSpends")];
148
150
  __esDecorate(this, null, _getBalance_decorators, {
149
151
  kind: "method",
150
152
  name: "getBalance",
@@ -200,6 +202,17 @@ let DeepSeekBalanceGateway = (() => {
200
202
  },
201
203
  metadata: _metadata
202
204
  }, null, _instanceExtraInitializers);
205
+ __esDecorate(this, null, _getSessionTurnSpends_decorators, {
206
+ kind: "method",
207
+ name: "getSessionTurnSpends",
208
+ static: false,
209
+ private: false,
210
+ access: {
211
+ has: (obj) => "getSessionTurnSpends" in obj,
212
+ get: (obj) => obj.getSessionTurnSpends
213
+ },
214
+ metadata: _metadata
215
+ }, null, _instanceExtraInitializers);
203
216
  if (_metadata) Object.defineProperty(this, Symbol.metadata, {
204
217
  enumerable: true,
205
218
  configurable: true,
@@ -218,15 +231,18 @@ let DeepSeekBalanceGateway = (() => {
218
231
  this.options = options;
219
232
  }
220
233
  /**
221
- * Read the current DeepSeek account balance.
234
+ * Read the current DeepSeek account balance. A snapshot younger than the
235
+ * host-side TTL is reused, so several badge mounts share one provider call;
236
+ * `force` bypasses the TTL for the manual refresh.
237
+ * @param force - bypass the host-side TTL; omitted means a cached read.
222
238
  * @returns the validated balance snapshot.
223
239
  */
224
- getBalance() {
225
- return this.options.fetchBalance();
240
+ getBalance(force) {
241
+ return this.options.fetchBalance(force ?? false);
226
242
  }
227
243
  /**
228
- * Read one session's billed spend, priced per event by its Beijing-time
229
- * peak/off-peak hour and weekday (weekends are always off-peak).
244
+ * Read one session's billed spend (same per-event pricing as
245
+ * {@link priceEvent}).
230
246
  * @param sessionId - the session whose spend to compute.
231
247
  * @returns the session's total cost plus one row per priced model.
232
248
  */
@@ -234,8 +250,8 @@ let DeepSeekBalanceGateway = (() => {
234
250
  return this.options.fetchSessionSpend(sessionId);
235
251
  }
236
252
  /**
237
- * Read today's billed spend across every session, priced per event by its
238
- * Beijing-time calendar day, hour, and weekday (weekends are always off-peak).
253
+ * Read today's billed spend across every session: the same per-event
254
+ * pricing as {@link priceEvent}, restricted to the queried Beijing day.
239
255
  * @param force - bypass the host-side 60s cache (manual refresh); omitted
240
256
  * means a cached read. Remote parameters cannot carry default values, so
241
257
  * the thunk receives `undefined` for an omitted argument.
@@ -245,10 +261,9 @@ let DeepSeekBalanceGateway = (() => {
245
261
  return this.options.fetchTodaySpend(force ?? false);
246
262
  }
247
263
  /**
248
- * Read today's billed spend per session, priced per event by its
249
- * Beijing-time calendar day, hour, and weekday (weekends are always
250
- * off-peak). Rows carry the session's durable title and sort by cost
251
- * descending; sessions with no priced usage on the day are omitted.
264
+ * Read today's billed spend per session, restricted to the queried Beijing
265
+ * day. Rows carry the session's durable title and sort by cost descending;
266
+ * sessions with no priced usage on the day are omitted.
252
267
  * @param force - bypass the host-side 60s cache (manual refresh); omitted
253
268
  * means a cached read.
254
269
  * @returns today's per-session rows, highest first.
@@ -257,8 +272,8 @@ let DeepSeekBalanceGateway = (() => {
257
272
  return this.options.fetchTodaySessionsSpend(force ?? false);
258
273
  }
259
274
  /**
260
- * Read one completed Turn's billed spend, priced per event by its
261
- * Beijing-time hour and weekday (weekends are always off-peak).
275
+ * Read one completed Turn's billed spend (same per-event pricing as
276
+ * {@link priceEvent}).
262
277
  * @param sessionId - the session owning the Turn.
263
278
  * @param messageId - the closing assistant message's durable id, which
264
279
  * locates the Turn in the session log.
@@ -267,6 +282,16 @@ let DeepSeekBalanceGateway = (() => {
267
282
  getTurnSpend(sessionId, messageId) {
268
283
  return this.options.fetchTurnSpend(sessionId, messageId);
269
284
  }
285
+ /**
286
+ * Read every completed Turn's billed spend in one session (same per-event
287
+ * pricing as {@link priceEvent}). One call replaces the per-message
288
+ * `getTurnSpend` fan-out for a rendered transcript.
289
+ * @param sessionId - the session whose Turn costs to compute.
290
+ * @returns one row per assistant message inside a completed Turn, in log order.
291
+ */
292
+ getSessionTurnSpends(sessionId) {
293
+ return this.options.fetchTurnSpends(sessionId);
294
+ }
270
295
  };
271
296
  })();
272
297
  //#endregion
@@ -297,20 +322,93 @@ const DEFAULT_PEAK_HOURS = [{
297
322
  start: 14,
298
323
  end: 18
299
324
  }];
300
- /** Official peak/off-peak rates (CNY per 1M tokens), effective 2026-08-17. */
325
+ /**
326
+ * Inclusive epoch ms of the published V4 Flash series re-pricing:
327
+ * 2026-09-10 12:00 Beijing time (UTC+8, no DST) = 04:00 UTC. Samples before
328
+ * this instant keep the base rates; samples at or after it bill at the second
329
+ * revision.
330
+ */
331
+ const FLASH_SERIES_RATE_CHANGE_AT = Date.UTC(2026, 8, 10, 4, 0, 0);
332
+ /**
333
+ * Inclusive epoch ms of the announced V4 Pro route switch: 2026-09-14 12:00
334
+ * Beijing time (UTC+8, no DST) = 04:00 UTC. From that instant the V4 Pro route
335
+ * is served by V4.1 Flash and billed at the V4.1 Flash rates.
336
+ */
337
+ const V4_PRO_ROUTE_SWITCH_AT = Date.UTC(2026, 8, 14, 4, 0, 0);
338
+ /** The V4 Flash series' base rates (effective 2026-08-17), CNY per 1M tokens. */
339
+ const FLASH_BASE_RATES = {
340
+ peak: {
341
+ cacheHitInput: .1,
342
+ cacheMissInput: 3,
343
+ output: 9
344
+ },
345
+ offPeak: {
346
+ cacheHitInput: .05,
347
+ cacheMissInput: 1.5,
348
+ output: 4.5
349
+ }
350
+ };
351
+ /**
352
+ * The V4 Flash series' second revision (effective
353
+ * {@link FLASH_SERIES_RATE_CHANGE_AT}): off-peak 0.02 / 1.0 / 4.0, peak at
354
+ * twice those prices.
355
+ */
356
+ const FLASH_REPRICED_RATES = {
357
+ effectiveFrom: FLASH_SERIES_RATE_CHANGE_AT,
358
+ peak: {
359
+ cacheHitInput: .04,
360
+ cacheMissInput: 2,
361
+ output: 8
362
+ },
363
+ offPeak: {
364
+ cacheHitInput: .02,
365
+ cacheMissInput: 1,
366
+ output: 4
367
+ }
368
+ };
369
+ /**
370
+ * The V4.1 Flash rates as they reach the retired V4 Pro route from
371
+ * {@link V4_PRO_ROUTE_SWITCH_AT}: the same price pair as the flash series'
372
+ * second revision, carried at its own effective instant.
373
+ */
374
+ const V4_PRO_SWITCHED_RATES = {
375
+ effectiveFrom: V4_PRO_ROUTE_SWITCH_AT,
376
+ peak: FLASH_REPRICED_RATES.peak,
377
+ offPeak: FLASH_REPRICED_RATES.offPeak
378
+ };
379
+ /**
380
+ * Official peak/off-peak rates (CNY per 1M tokens) per model, as dated
381
+ * revisions. Base rows are the schedule effective 2026-08-17; the V4 Flash
382
+ * series (V4.1 Flash, V4 Flash, V4 Flash Vision Exp) carries the second
383
+ * revision effective 2026-09-10 12:00 Beijing, and the V4 Pro row the V4.1
384
+ * Flash rates from its announced route switch (2026-09-14 12:00 Beijing) —
385
+ * the MiMo-V2.5 series is untouched by either adjustment. Rows sharing a model
386
+ * are that model's rate history.
387
+ */
301
388
  const DEFAULT_MODEL_PRICING = [
389
+ {
390
+ model: "deepseek-flash",
391
+ ...FLASH_BASE_RATES
392
+ },
393
+ {
394
+ model: "deepseek-flash",
395
+ ...FLASH_REPRICED_RATES
396
+ },
302
397
  {
303
398
  model: "deepseek-v4-flash",
304
- peak: {
305
- cacheHitInput: .1,
306
- cacheMissInput: 3,
307
- output: 9
308
- },
309
- offPeak: {
310
- cacheHitInput: .05,
311
- cacheMissInput: 1.5,
312
- output: 4.5
313
- }
399
+ ...FLASH_BASE_RATES
400
+ },
401
+ {
402
+ model: "deepseek-v4-flash",
403
+ ...FLASH_REPRICED_RATES
404
+ },
405
+ {
406
+ model: "deepseek-v4.1-flash-expires-on-0910",
407
+ ...FLASH_BASE_RATES
408
+ },
409
+ {
410
+ model: "deepseek-v4.1-flash-expires-on-0910",
411
+ ...FLASH_REPRICED_RATES
314
412
  },
315
413
  {
316
414
  model: "deepseek-v4-pro",
@@ -325,18 +423,17 @@ const DEFAULT_MODEL_PRICING = [
325
423
  output: 13.5
326
424
  }
327
425
  },
426
+ {
427
+ model: "deepseek-v4-pro",
428
+ ...V4_PRO_SWITCHED_RATES
429
+ },
328
430
  {
329
431
  model: "deepseek-v4-flash-vision-exp",
330
- peak: {
331
- cacheHitInput: .1,
332
- cacheMissInput: 3,
333
- output: 9
334
- },
335
- offPeak: {
336
- cacheHitInput: .05,
337
- cacheMissInput: 1.5,
338
- output: 4.5
339
- }
432
+ ...FLASH_BASE_RATES
433
+ },
434
+ {
435
+ model: "deepseek-v4-flash-vision-exp",
436
+ ...FLASH_REPRICED_RATES
340
437
  },
341
438
  {
342
439
  model: "mimo-v2.5-pro",
@@ -371,39 +468,123 @@ const DEFAULT_MODEL_PRICING = [
371
468
  * `z.array` as `[]` rather than `undefined`, so emptiness — not just absence —
372
469
  * selects the defaults. Explicit non-empty rows override the same model; a
373
470
  * supplied non-empty `models` list is authoritative.
471
+ *
472
+ * Rows sharing a model are that model's rate revisions, kept in ascending
473
+ * `effectiveFrom` order (an undated base revision first). Two rows declaring
474
+ * the same effective instant are one revision and the later row wins — the
475
+ * historical override rule — so re-declaring a model can neither duplicate a
476
+ * revision nor install a second undated base.
374
477
  * @param config - optional raw billing configuration.
375
- * @returns the resolved table and peak-hour windows.
478
+ * @returns the resolved table (per model: its revisions plus the newest rates) and peak-hour windows.
376
479
  */
377
480
  function resolveBilling(config) {
378
481
  const peakHours = config?.peakHours !== void 0 && config.peakHours.length > 0 ? config.peakHours : DEFAULT_PEAK_HOURS;
379
482
  const rows = config?.models !== void 0 && config.models.length > 0 ? config.models : DEFAULT_MODEL_PRICING;
483
+ const schedules = /* @__PURE__ */ new Map();
484
+ for (const row of rows) {
485
+ const revision = row.effectiveFrom === void 0 ? {
486
+ peak: row.peak,
487
+ offPeak: row.offPeak
488
+ } : {
489
+ effectiveFrom: row.effectiveFrom,
490
+ peak: row.peak,
491
+ offPeak: row.offPeak
492
+ };
493
+ const revisions = schedules.get(row.model);
494
+ if (revisions === void 0) {
495
+ schedules.set(row.model, [revision]);
496
+ continue;
497
+ }
498
+ const duplicate = revisions.findIndex((candidate) => candidate.effectiveFrom === revision.effectiveFrom);
499
+ if (duplicate >= 0) revisions[duplicate] = revision;
500
+ else revisions.push(revision);
501
+ }
380
502
  const models = /* @__PURE__ */ new Map();
381
- for (const row of rows) models.set(row.model, {
382
- peak: row.peak,
383
- offPeak: row.offPeak
384
- });
503
+ for (const [model, revisions] of schedules) {
504
+ revisions.sort((left, right) => (left.effectiveFrom ?? Number.NEGATIVE_INFINITY) - (right.effectiveFrom ?? Number.NEGATIVE_INFINITY));
505
+ const newest = revisions[revisions.length - 1];
506
+ models.set(model, {
507
+ peak: newest.peak,
508
+ offPeak: newest.offPeak,
509
+ revisions
510
+ });
511
+ }
385
512
  return {
386
513
  peakHours,
387
514
  models
388
515
  };
389
516
  }
390
517
  /**
391
- * Derive the Beijing hour, weekday, and calendar-day key of one timestamp from
392
- * a single shifted `Date` every timezone-sensitive read shares this one
393
- * implementation, so the pieces cannot drift apart.
518
+ * The rate revision in effect at one instant: the newest revision that took
519
+ * effect at or before it. Revisions are ascending, so the scan stops at the
520
+ * first future one. An instant before the earliest dated revision bills at that
521
+ * earliest revision — a model with only dated rows is never left unpriced.
522
+ */
523
+ function ratesAt(revisions, time) {
524
+ let chosen = revisions[0];
525
+ for (let index = 1; index < revisions.length; index += 1) {
526
+ const revision = revisions[index];
527
+ if (revision.effectiveFrom === void 0 || revision.effectiveFrom > time) break;
528
+ chosen = revision;
529
+ }
530
+ return chosen;
531
+ }
532
+ /** Beijing is a fixed UTC+8 offset with no DST. */
533
+ const BEIJING_OFFSET_MS = 288e5;
534
+ /** Milliseconds in one day. */
535
+ const DAY_MS = 864e5;
536
+ /** Epoch day of 1970-01-01 in the civil-date algorithm below. */
537
+ const CIVIL_EPOCH_DAY = 719468;
538
+ /** Two-digit zero pad for a calendar field. */
539
+ function pad2(value) {
540
+ return value < 10 ? `0${value}` : String(value);
541
+ }
542
+ /**
543
+ * Civil date of an epoch day (Howard Hinnant's days-from-civil inverse):
544
+ * pure integer arithmetic, no `Date` allocation and no ISO-string slicing.
545
+ */
546
+ function civilDateOf(epochDay) {
547
+ const shifted = epochDay + CIVIL_EPOCH_DAY;
548
+ const era = Math.floor(shifted / 146097);
549
+ const dayOfEra = shifted - era * 146097;
550
+ const yearOfEra = Math.floor((dayOfEra - Math.floor(dayOfEra / 1460) + Math.floor(dayOfEra / 36524) - Math.floor(dayOfEra / 146096)) / 365);
551
+ const year = yearOfEra + era * 400;
552
+ const dayOfYear = dayOfEra - (365 * yearOfEra + Math.floor(yearOfEra / 4) - Math.floor(yearOfEra / 100));
553
+ const monthPrime = Math.floor((5 * dayOfYear + 2) / 153);
554
+ const month = monthPrime + (monthPrime < 10 ? 3 : -9);
555
+ return {
556
+ year: month <= 2 ? year + 1 : year,
557
+ month,
558
+ day: dayOfYear - Math.floor((153 * monthPrime + 2) / 5) + 1
559
+ };
560
+ }
561
+ /**
562
+ * Derive the Beijing hour, weekday, and calendar-day key of one timestamp with
563
+ * pure integer arithmetic — every timezone-sensitive read shares this one
564
+ * implementation, so the pieces cannot drift apart. Callers that filter by
565
+ * day and then price the same event reuse the returned view, so each event is
566
+ * parsed exactly once. (The hot fold path runs this per committed event; the
567
+ * previous `Date` + `toISOString().slice()` version allocated a `Date` and a
568
+ * 24-character string per call.)
394
569
  * @param time - epoch milliseconds.
570
+ * @throws {RangeError} when `time` is not a finite number.
395
571
  */
396
- function beijingParts(time) {
397
- const shifted = new Date(time + 288e5);
572
+ function beijingPartsOf(time) {
573
+ if (!Number.isFinite(time)) throw new RangeError(`billing: event time is not finite (${String(time)})`);
574
+ const shifted = time + BEIJING_OFFSET_MS;
575
+ const epochDay = Math.floor(shifted / DAY_MS);
576
+ const msOfDay = shifted - epochDay * DAY_MS;
577
+ const civil = civilDateOf(epochDay);
398
578
  return {
399
- hour: shifted.getUTCHours(),
400
- weekday: shifted.getUTCDay(),
401
- dayKey: shifted.toISOString().slice(0, 10)
579
+ time,
580
+ hour: Math.floor(msOfDay / 36e5),
581
+ weekday: ((epochDay + 4) % 7 + 7) % 7,
582
+ dayKey: `${civil.year}-${pad2(civil.month)}-${pad2(civil.day)}`
402
583
  };
403
584
  }
404
585
  /** The Beijing (Asia/Shanghai, UTC+8, no DST) calendar-day key of a timestamp. */
405
586
  function beijingDayKey(now) {
406
- return beijingParts(now.getTime()).dayKey;
587
+ return beijingPartsOf(now.getTime()).dayKey;
407
588
  }
408
589
  /**
409
590
  * The durable inherited-prefix boundary of one session: the number of leading
@@ -443,41 +624,70 @@ function isPeakParts(billing, hour, weekday) {
443
624
  * @returns true during a weekday peak hour.
444
625
  */
445
626
  function isPeak(billing, now) {
446
- const { hour, weekday } = beijingParts(now.getTime());
627
+ const { hour, weekday } = beijingPartsOf(now.getTime());
447
628
  return isPeakParts(billing, hour, weekday);
448
629
  }
449
630
  /**
450
631
  * Price one event at the official per-model rates, applying the peak/off-peak
451
632
  * table by its Beijing-time hour and weekday (peak windows apply Monday–Friday
452
- * only; weekends are off-peak). Each `assistant/message` event with usage
633
+ * only; weekends are off-peak) and the rate revision in effect at its own
634
+ * timestamp. Each `assistant/message` event with usage
453
635
  * contributes cache-hit input, cache-miss input (uncached input plus cache
454
636
  * writes), and output (reasoning included) tokens at the rate of its own
455
- * timestamp; a model with usage but no pricing row contributes nothing (the
456
- * published table prices only the two V4 rows).
637
+ * timestamp; a model with usage but no pricing row contributes nothing.
457
638
  * @param event - the event to price.
458
639
  * @param billing - resolved pricing with peak-hour windows.
459
640
  * @param names - model id → display label.
460
641
  * @returns the priced contribution, or `undefined` when the event has no priced usage.
461
642
  */
462
643
  function priceEvent(event, billing, names) {
644
+ return priceEventAt(beijingPartsOf(event.time), event, billing, names);
645
+ }
646
+ /**
647
+ * Price one event at the official per-model rates using a precomputed
648
+ * Beijing-time view — the day-filtering and pricing of one event share a
649
+ * single timezone parse (see {@link beijingPartsOf}). Semantics are identical
650
+ * to {@link priceEvent}.
651
+ * @param parts - the event's Beijing-time view.
652
+ * @param event - the event to price.
653
+ * @param billing - resolved pricing with peak-hour windows.
654
+ * @param names - model id → display label.
655
+ * @returns the priced contribution, or `undefined` when the event has no priced usage.
656
+ */
657
+ function priceEventAt(parts, event, billing, names) {
463
658
  if (event.type !== "assistant/message") return void 0;
464
659
  const reported = event.data.usage;
465
660
  if (reported === void 0) return void 0;
466
- const model = event.data.message.source.model;
661
+ return priceUsage(parts, reported, event.data.message.source.model, billing, names);
662
+ }
663
+ /**
664
+ * Price one provider-reported usage sample for one model at the rates of the
665
+ * sample's own Beijing-time hour and weekday — the peak or off-peak price of
666
+ * the rate revision in effect at the sample's own timestamp (a re-priced series
667
+ * bills its history at the rates that applied then). `undefined` when the model
668
+ * has no pricing row.
669
+ * @param parts - the sample's Beijing-time view.
670
+ * @param usage - the reported token buckets.
671
+ * @param model - the wire model id the sample belongs to.
672
+ * @param billing - resolved pricing with peak-hour windows.
673
+ * @param names - model id → display label.
674
+ * @returns the priced contribution, or `undefined` when the model has no rate row.
675
+ */
676
+ function priceUsage(parts, usage, model, billing, names) {
467
677
  const pricing = billing.models.get(model);
468
678
  if (pricing === void 0) return void 0;
469
- const { hour, weekday, dayKey } = beijingParts(event.time);
470
- const peak = isPeakParts(billing, hour, weekday);
471
- const price = peak ? pricing.peak : pricing.offPeak;
472
- const hit = reported.cacheReadTokens ?? 0;
473
- const miss = reported.inputTokens + (reported.cacheWriteTokens ?? 0);
474
- const output = reported.outputTokens;
679
+ const peak = isPeakParts(billing, parts.hour, parts.weekday);
680
+ const revision = ratesAt(pricing.revisions, parts.time);
681
+ const price = peak ? revision.peak : revision.offPeak;
682
+ const hit = usage.cacheReadTokens ?? 0;
683
+ const miss = usage.inputTokens + (usage.cacheWriteTokens ?? 0);
684
+ const output = usage.outputTokens;
475
685
  const hitCost = hit * price.cacheHitInput / 1e6;
476
686
  const missCost = miss * price.cacheMissInput / 1e6;
477
687
  const outputCost = output * price.output / 1e6;
478
688
  const cost = hitCost + missCost + outputCost;
479
689
  return {
480
- dayKey,
690
+ dayKey: parts.dayKey,
481
691
  model,
482
692
  displayName: names.get(model) ?? model,
483
693
  cost,
@@ -556,6 +766,192 @@ var SpendAccumulator = class {
556
766
  };
557
767
  }
558
768
  };
769
+ /** The additive inverse of one spend (pure): used to replace a priced sample. */
770
+ function negateSpend(spend) {
771
+ const negate = (value) => -value;
772
+ return {
773
+ total: negate(spend.total),
774
+ models: spend.models.map((row) => ({
775
+ ...row,
776
+ cost: negate(row.cost),
777
+ peakCost: negate(row.peakCost),
778
+ offPeakCost: negate(row.offPeakCost),
779
+ cacheHitInputTokens: negate(row.cacheHitInputTokens),
780
+ cacheMissInputTokens: negate(row.cacheMissInputTokens),
781
+ outputTokens: negate(row.outputTokens),
782
+ cacheHitInputCost: negate(row.cacheHitInputCost),
783
+ cacheMissInputCost: negate(row.cacheMissInputCost),
784
+ outputCost: negate(row.outputCost)
785
+ }))
786
+ };
787
+ }
788
+ /**
789
+ * Subtract one spend from another (pure). Rows that cancel out completely are
790
+ * dropped so a replaced sample leaves no zero row behind.
791
+ * @param target - the spend to subtract from.
792
+ * @param source - the spend to remove.
793
+ * @returns the difference.
794
+ */
795
+ function subtractSpend(target, source) {
796
+ const rows = /* @__PURE__ */ new Map();
797
+ for (const row of target.models) rows.set(row.model, row);
798
+ for (const row of source.models) {
799
+ const existing = rows.get(row.model);
800
+ if (existing === void 0) continue;
801
+ const next = mergeModelRows(existing, negateSpend({
802
+ total: 0,
803
+ models: [row]
804
+ }).models[0]);
805
+ if (next.cost === 0 && next.cacheHitInputTokens === 0 && next.cacheMissInputTokens === 0 && next.outputTokens === 0) rows.delete(row.model);
806
+ else rows.set(row.model, next);
807
+ }
808
+ return {
809
+ total: target.total - source.total,
810
+ models: [...rows.values()]
811
+ };
812
+ }
813
+ /** The empty fold state for one fork boundary. */
814
+ function emptyBillingFoldState(inheritedEventCount = 0) {
815
+ return {
816
+ dayKey: "",
817
+ spend: emptyTodaySpend(),
818
+ session: emptyTodaySpend(),
819
+ inheritedEventCount,
820
+ model: "",
821
+ last: null
822
+ };
823
+ }
824
+ /** Whether an unknown value looks like a provider usage report. */
825
+ function isTokenUsage(value) {
826
+ if (typeof value !== "object" || value === null) return false;
827
+ const candidate = value;
828
+ return typeof candidate.inputTokens === "number" && typeof candidate.outputTokens === "number";
829
+ }
830
+ /**
831
+ * The last `usage` sample embedded in an event's stream, if any. `assistant/
832
+ * attempt` and the embedded streams are newer than the plugin's npm baseline,
833
+ * so the stream is read structurally (a failed/retried attempt reports its
834
+ * usage only there).
835
+ */
836
+ function streamUsageOf(event) {
837
+ const stream = event.data === void 0 ? void 0 : event.data.stream;
838
+ if (!Array.isArray(stream)) return void 0;
839
+ for (let index = stream.length - 1; index >= 0; index -= 1) {
840
+ const chunk = stream[index]?.chunk;
841
+ if (chunk === void 0 || chunk.type !== "usage") continue;
842
+ return isTokenUsage(chunk.usage) ? chunk.usage : void 0;
843
+ }
844
+ }
845
+ /** The contribution as a one-row spend (the shape a sample keeps for replacement). */
846
+ function contributionSpend(priced) {
847
+ return {
848
+ total: priced.cost,
849
+ models: [contributionModel(priced)]
850
+ };
851
+ }
852
+ /**
853
+ * Fold one committed event into a session's billed-spend state.
854
+ *
855
+ * Priced samples come from `assistant/message` (its own reported usage, or the
856
+ * stream's last usage chunk) and `assistant/attempt` (the stream's last usage
857
+ * chunk, priced with the model of the latest `request/header`, since an
858
+ * attempt carries no route). A sample for the same `(turn, step)` replaces the
859
+ * previous one; `llm/retry-started` closes the replacement slot so a retried
860
+ * attempt adds. Every other event is inert and returns the same state
861
+ * reference.
862
+ * @param state - the previous fold state.
863
+ * @param event - the committed event.
864
+ * @param billing - resolved pricing with peak-hour windows.
865
+ * @param names - model id → display label.
866
+ * @returns the next state (the same reference when nothing was priced).
867
+ */
868
+ function applyBillingEvent(state, event, billing, names) {
869
+ if (event.seq < state.inheritedEventCount) return state;
870
+ const type = event.type;
871
+ if (type === "request/header") {
872
+ const model = event.data?.header?.config?.model;
873
+ return typeof model === "string" && model.length > 0 && model !== state.model ? {
874
+ ...state,
875
+ model
876
+ } : state;
877
+ }
878
+ const data = event.data;
879
+ if (type === "llm/retry-started") {
880
+ if (typeof data?.turn !== "number" || typeof data.step !== "number") return state;
881
+ const last = state.last;
882
+ if (last === null || last.turn !== data.turn || last.step !== data.step) return state;
883
+ return {
884
+ ...state,
885
+ last: null
886
+ };
887
+ }
888
+ if (type !== "assistant/message" && type !== "assistant/attempt") return state;
889
+ const usage = (type === "assistant/message" ? data?.usage : void 0) ?? streamUsageOf(event);
890
+ if (!isTokenUsage(usage)) return state;
891
+ const model = type === "assistant/message" ? data?.message?.source?.model : state.model;
892
+ if (typeof model !== "string" || model.length === 0) return state;
893
+ const priced = priceUsage(beijingPartsOf(event.time), usage, model, billing, names);
894
+ if (priced === void 0) return state;
895
+ let session = state.session;
896
+ let spend = state.spend;
897
+ let dayKey = state.dayKey;
898
+ const last = state.last;
899
+ const turn = typeof data?.turn === "number" ? data.turn : 0;
900
+ const step = typeof data?.step === "number" ? data.step : 0;
901
+ if (last !== null && last.turn === turn && last.step === step) {
902
+ session = subtractSpend(session, last.spend);
903
+ if (last.dayKey === dayKey) spend = subtractSpend(spend, last.spend);
904
+ }
905
+ session = addEventContribution(session, priced);
906
+ if (dayKey === priced.dayKey) spend = addEventContribution(spend, priced);
907
+ else if (dayKey === "" || priced.dayKey > dayKey) {
908
+ dayKey = priced.dayKey;
909
+ spend = addEventContribution(emptyTodaySpend(), priced);
910
+ }
911
+ return {
912
+ ...state,
913
+ dayKey,
914
+ spend,
915
+ session,
916
+ last: {
917
+ turn,
918
+ step,
919
+ dayKey: priced.dayKey,
920
+ spend: contributionSpend(priced)
921
+ }
922
+ };
923
+ }
924
+ /**
925
+ * Mutable wrapper over {@link applyBillingEvent} for the pure pricing paths:
926
+ * feed events in order, read the folded spend.
927
+ */
928
+ var BillingFolder = class {
929
+ billing;
930
+ state;
931
+ /**
932
+ * @param billing - resolved pricing with peak-hour windows.
933
+ * @param catalog - model display rows, in presentation order.
934
+ * @param inheritedEventCount - fork boundary to skip (default 0).
935
+ */
936
+ constructor(billing, catalog, inheritedEventCount = 0) {
937
+ this.billing = billing;
938
+ this.names = new Map(catalog.map((model) => [model.id, model.name]));
939
+ this.state = emptyBillingFoldState(inheritedEventCount);
940
+ }
941
+ names;
942
+ /** Fold one event. */
943
+ add(event) {
944
+ this.state = applyBillingEvent(this.state, event, this.billing, this.names);
945
+ }
946
+ /** Fold every event, in order. */
947
+ addAll(events) {
948
+ for (const event of events) this.add(event);
949
+ }
950
+ /** The folded state (live reference; do not mutate). */
951
+ get fold() {
952
+ return this.state;
953
+ }
954
+ };
559
955
  /**
560
956
  * Merge one priced event's contribution into an accumulator spend (pure:
561
957
  * returns a new spend, never mutates its input).
@@ -592,34 +988,11 @@ function mergeTodaySpend(target, source) {
592
988
  };
593
989
  }
594
990
  /**
595
- * Price a set of billed events at the official per-model rates, applying the
596
- * peak/off-peak table per event by its Beijing-time hour and weekday (peak
597
- * windows apply Monday–Friday only; weekends are off-peak). Each
598
- * `assistant/message` event with usage contributes cache-hit input, cache-miss
599
- * input (uncached input plus cache writes), and output (reasoning included)
600
- * tokens at the rate of its own timestamp, with the three component costs
601
- * carried separately; a model with usage but no pricing row is omitted (the
602
- * published table prices only the two V4 rows).
603
- * @param events - the events to price.
604
- * @param billing - resolved pricing with peak-hour windows.
605
- * @param names - model id → display label.
606
- * @param dayKey - when provided, only events on this Beijing calendar day contribute.
607
- * @param startSeq - when provided, only events with `seq >= startSeq` contribute
608
- * (a forked session's inherited prefix, `seq < startSeq`, is skipped).
609
- * @returns the total cost plus one row per priced model.
610
- */
611
- function priceEvents(events, billing, names, dayKey, startSeq = 0) {
612
- const accumulator = new SpendAccumulator();
613
- for (const event of events) {
614
- if (event.seq < startSeq) continue;
615
- const priced = priceEvent(event, billing, names);
616
- if (priced === void 0 || dayKey !== void 0 && priced.dayKey !== dayKey) continue;
617
- accumulator.add(priced);
618
- }
619
- return accumulator.finish();
620
- }
621
- /**
622
- * Price one session's complete event log at the official per-model rates.
991
+ * Price one session's complete event log at the official per-model rates,
992
+ * with DSH's attempt semantics: every provider-reported sample (an
993
+ * `assistant/message`'s usage, or an `assistant/attempt`'s stream usage)
994
+ * contributes, a later sample for the same `(turn, step)` replaces the earlier
995
+ * one, and `llm/retry-started` makes the retried attempt add.
623
996
  * @param events - one session's complete event log.
624
997
  * @param billing - resolved pricing with peak-hour windows.
625
998
  * @param catalog - model display rows, in presentation order.
@@ -630,16 +1003,18 @@ function priceEvents(events, billing, names, dayKey, startSeq = 0) {
630
1003
  * @returns the session's total cost plus one row per priced model.
631
1004
  */
632
1005
  function computeSessionSpend(events, billing, catalog, startSeq = 0) {
633
- return priceEvents(events, billing, new Map(catalog.map((model) => [model.id, model.name])), void 0, startSeq);
1006
+ const folder = new BillingFolder(billing, catalog, startSeq);
1007
+ folder.addAll(events);
1008
+ return folder.fold.session;
634
1009
  }
635
1010
  /**
636
- * Price one completed Turn's billed usage at the official per-model rates,
637
- * identified by its closing assistant message id. The turn's events are those
638
- * between its `turn/start` and `turn/end` (both matched by the message's own
639
- * turn coordinate); each priced event applies the peak/off-peak table by its
640
- * Beijing-time hour and weekday. A message that cannot be located, a turn
641
- * without bracketing `turn/start` / `turn/end` events (for example after
642
- * compaction), or a session with no priced usage prices to zero.
1011
+ * Price one completed Turn's billed usage, identified by its closing
1012
+ * assistant message id. The turn's events are those between its `turn/start`
1013
+ * and `turn/end` (both matched by the message's own turn coordinate), priced
1014
+ * with the same attempt semantics as {@link computeSessionSpend}. A message
1015
+ * that cannot be located, a turn without bracketing `turn/start` / `turn/end`
1016
+ * events (for example after compaction), or a session with no priced usage
1017
+ * prices to zero.
643
1018
  * @param events - one session's complete event log.
644
1019
  * @param billing - resolved pricing with peak-hour windows.
645
1020
  * @param catalog - model display rows, in presentation order.
@@ -647,7 +1022,18 @@ function computeSessionSpend(events, billing, catalog, startSeq = 0) {
647
1022
  * @returns the turn's total cost in CNY.
648
1023
  */
649
1024
  function computeTurnSpend(events, billing, catalog, messageId) {
650
- const names = new Map(catalog.map((model) => [model.id, model.name]));
1025
+ return { total: turnCostOf(events, billing, catalog, messageId) };
1026
+ }
1027
+ /**
1028
+ * The total cost of the Turn containing `messageId`, folded with the shared
1029
+ * attempt semantics (see {@link applyBillingEvent}).
1030
+ * @param events - one session's complete event log.
1031
+ * @param billing - resolved pricing with peak-hour windows.
1032
+ * @param catalog - model display rows, in presentation order.
1033
+ * @param messageId - one assistant message inside the Turn.
1034
+ * @returns the Turn's total cost in CNY, or 0 when the Turn cannot be located.
1035
+ */
1036
+ function turnCostOf(events, billing, catalog, messageId) {
651
1037
  let turn;
652
1038
  for (const event of events) {
653
1039
  if (event.type !== "assistant/message") continue;
@@ -655,8 +1041,8 @@ function computeTurnSpend(events, billing, catalog, messageId) {
655
1041
  turn = event.data.turn;
656
1042
  break;
657
1043
  }
658
- if (turn === void 0) return { total: 0 };
659
- const accumulator = new SpendAccumulator();
1044
+ if (turn === void 0) return 0;
1045
+ const folder = new BillingFolder(billing, catalog);
660
1046
  let active = false;
661
1047
  for (const event of events) {
662
1048
  if (event.type === "turn/start" && event.data.turn === turn) {
@@ -665,16 +1051,116 @@ function computeTurnSpend(events, billing, catalog, messageId) {
665
1051
  }
666
1052
  if (event.type === "turn/end" && event.data.turn === turn) break;
667
1053
  if (!active) continue;
668
- const priced = priceEvent(event, billing, names);
669
- if (priced !== void 0) accumulator.add(priced);
1054
+ folder.add(event);
670
1055
  }
671
- return { total: accumulator.finish().total };
1056
+ return folder.fold.session.total;
1057
+ }
1058
+ /**
1059
+ * Incremental single-pass fold of one session's completed-Turn costs, keyed by
1060
+ * the id of every assistant message inside each Turn. Feeding the fold only
1061
+ * the appended tail keeps a growing session's map current in O(new events)
1062
+ * instead of re-scanning the whole log per message.
1063
+ *
1064
+ * Semantics are exactly {@link computeTurnSpend}'s: a Turn is the
1065
+ * `turn/start`..`turn/end` range (matched by the event's own turn coordinate),
1066
+ * every priced event inside it contributes at its own timestamp's rate, and a
1067
+ * message outside any bracket contributes nothing.
1068
+ */
1069
+ var SessionTurnSpendFolder = class {
1070
+ billing;
1071
+ catalog;
1072
+ rows = [];
1073
+ ids = [];
1074
+ /** Events of the open Turn, folded with the shared attempt semantics on close. */
1075
+ events = [];
1076
+ open = false;
1077
+ /** Events already fed; a shorter log resets the fold. */
1078
+ cursor = 0;
1079
+ /**
1080
+ * @param billing - resolved pricing with peak-hour windows.
1081
+ * @param catalog - model display rows, in presentation order.
1082
+ */
1083
+ constructor(billing, catalog) {
1084
+ this.billing = billing;
1085
+ this.catalog = catalog;
1086
+ }
1087
+ /** How many events have been folded so far (the host's incremental cursor). */
1088
+ get processed() {
1089
+ return this.cursor;
1090
+ }
1091
+ /**
1092
+ * Fold every event from the cursor to the end of the log. A log shorter than
1093
+ * the cursor (rewritten session) restarts the fold from an empty state.
1094
+ * @param events - the session's complete event log, in seq order.
1095
+ */
1096
+ feed(events) {
1097
+ if (events.length < this.cursor) this.reset();
1098
+ for (let index = this.cursor; index < events.length; index += 1) {
1099
+ const event = events[index];
1100
+ if (event.type === "turn/start") {
1101
+ this.open = true;
1102
+ this.ids = [];
1103
+ this.events = [];
1104
+ continue;
1105
+ }
1106
+ if (event.type === "turn/end") {
1107
+ if (this.open) {
1108
+ const folder = new BillingFolder(this.billing, this.catalog);
1109
+ folder.addAll(this.events);
1110
+ const total = folder.fold.session.total;
1111
+ for (const messageId of this.ids) this.rows.push({
1112
+ messageId,
1113
+ total
1114
+ });
1115
+ }
1116
+ this.open = false;
1117
+ this.ids = [];
1118
+ this.events = [];
1119
+ continue;
1120
+ }
1121
+ if (!this.open) continue;
1122
+ if (event.type === "assistant/message") this.ids.push(event.data.message.id);
1123
+ this.events.push(event);
1124
+ }
1125
+ this.cursor = events.length;
1126
+ }
1127
+ /** The folded map; the fold stays usable afterwards. */
1128
+ finish() {
1129
+ return { turns: [...this.rows] };
1130
+ }
1131
+ /** Drop the fold state so the next feed starts from the log's beginning. */
1132
+ reset() {
1133
+ this.rows.length = 0;
1134
+ this.ids = [];
1135
+ this.events = [];
1136
+ this.open = false;
1137
+ this.cursor = 0;
1138
+ }
1139
+ };
1140
+ /**
1141
+ * Price every completed Turn of one session in a single pass (the pure
1142
+ * equivalent of {@link SessionTurnSpendFolder}).
1143
+ * @param events - one session's complete event log.
1144
+ * @param billing - resolved pricing with peak-hour windows.
1145
+ * @param catalog - model display rows, in presentation order.
1146
+ * @returns one row per assistant message inside a completed Turn, in log order.
1147
+ */
1148
+ function computeSessionTurnSpends(events, billing, catalog) {
1149
+ const folder = new SessionTurnSpendFolder(billing, catalog);
1150
+ folder.feed(events);
1151
+ return folder.finish();
672
1152
  }
673
1153
  /**
674
- * Price every event whose Beijing-time calendar day is the day of `now`,
675
- * aggregating across every session's event log. Events from other Beijing
676
- * days are ignored, so a caller passes the concatenated logs of all sessions.
677
- * @param events - every session's complete event log, concatenated.
1154
+ * Price one session's log for the Beijing-time calendar day of `now`. Events
1155
+ * after the reference day are ignored; the fold's latest-day state then
1156
+ * answers the query exactly (empty when the session's latest priced day is not
1157
+ * the reference day). Pricing follows {@link applyBillingEvent} (attempt
1158
+ * samples with same-step replacement).
1159
+ *
1160
+ * The fold's `(turn, step)` replacement slot is per session, so callers must
1161
+ * pass ONE session's log; aggregate across sessions with
1162
+ * {@link mergeTodaySpend}.
1163
+ * @param events - one session's complete event log.
678
1164
  * @param billing - resolved pricing with peak-hour windows.
679
1165
  * @param catalog - model display rows, in presentation order.
680
1166
  * @param now - the reference moment whose Beijing-time calendar day is "today".
@@ -682,24 +1168,31 @@ function computeTurnSpend(events, billing, catalog, messageId) {
682
1168
  */
683
1169
  function computeTodaySpend(events, billing, catalog, now = /* @__PURE__ */ new Date()) {
684
1170
  const day = beijingDayKey(now);
685
- return priceEvents(events, billing, new Map(catalog.map((model) => [model.id, model.name])), day);
1171
+ const folder = new BillingFolder(billing, catalog);
1172
+ for (const event of events) {
1173
+ if (beijingPartsOf(event.time).dayKey > day) continue;
1174
+ folder.add(event);
1175
+ }
1176
+ return folder.fold.dayKey === day ? folder.fold.spend : emptyTodaySpend();
686
1177
  }
687
1178
  //#endregion
688
1179
  //#region lib/types/projection.js
689
1180
  /**
690
- * `billingTodaySpend` session-projection unit: per-session, per-Beijing-day
691
- * billed spend, folded eagerly by the DSH projection drive over committed
692
- * session events and checkpointed by the projection cache. The unit keeps only
693
- * the spend of the session's LATEST priced day (events are append-only and
694
- * chronological, so a day strictly older than the state's day never returns);
695
- * the aggregate "today" read sums the units whose `dayKey` matches the current
696
- * Beijing day — zero full-log scans once the fold is warm.
1181
+ * `billingTodaySpend` session-projection unit: per-session billed spend,
1182
+ * folded eagerly by the DSH projection drive over committed session events and
1183
+ * checkpointed by the projection cache. The state keeps the session's LATEST
1184
+ * priced Beijing day, its whole-session total, the fork boundary, the latest
1185
+ * request model, and the last priced attempt sample (DSH's same-step
1186
+ * replacement rule); the aggregate "today" read sums the units whose `dayKey`
1187
+ * matches the current Beijing day — zero full-log scans once the fold is warm.
697
1188
  *
698
- * The unit's fold shares {@link priceEvent} with the events-scan paths
699
- * (`computeTodaySpend`), so both price with the same table. The unit is
1189
+ * The unit's fold IS the shared pricing fold ({@link applyBillingEvent}), so
1190
+ * the projection path and the events-scan paths cannot drift. The unit is
700
1191
  * client-visible (`wire` = identity) because the persisted-cache read ladder
701
- * (`sessionProjectionCache.coldSnapshot` / registry `restore`) serves only
702
- * wired units; the wire value is the state itself.
1192
+ * (`sessionProjectionCache.cachedSnapshot` / registry `restore`) serves only
1193
+ * wired units, and because the browser half reads this value through
1194
+ * `useProjection` instead of polling a Remote; the wire value is the state
1195
+ * itself.
703
1196
  * @module @rayadesu/dsh-llm-billing/projection
704
1197
  */
705
1198
  /** The projection key this unit owns. */
@@ -723,16 +1216,27 @@ const todaySpendSchema = z$1.object({
723
1216
  }).strict();
724
1217
  const billingUnitSchema = z$1.object({
725
1218
  dayKey: z$1.string(),
726
- spend: todaySpendSchema
1219
+ spend: todaySpendSchema,
1220
+ session: todaySpendSchema,
1221
+ inheritedEventCount: z$1.number().int().nonnegative(),
1222
+ model: z$1.string(),
1223
+ last: z$1.object({
1224
+ turn: z$1.number().int().nonnegative(),
1225
+ step: z$1.number().int().nonnegative(),
1226
+ dayKey: z$1.string(),
1227
+ spend: todaySpendSchema
1228
+ }).strict().nullable()
727
1229
  }).strict();
728
1230
  /**
729
- * Build the `billingTodaySpend` unit for one resolved pricing table. The
730
- * pricing closure is fixed at registration; a pricing-table change therefore
731
- * prices only events folded after the change (historical spend keeps its
732
- * historical rates), unlike the events-scan paths which re-price the whole
733
- * log. Bump {@link ProjectionDefinition.stateVersion} whenever the state
734
- * shape or fold semantics change, so persisted checkpoint rows are discarded
735
- * instead of folded forward.
1231
+ * Build the `billingTodaySpend` unit for one resolved pricing table. Published
1232
+ * rate revisions travel inside the closure and are resolved per sample
1233
+ * timestamp, so a re-priced series bills its own history correctly however late
1234
+ * a log is folded; only a configuration change (editing `billing.models`) is
1235
+ * fixed at registration, and it re-prices just the events folded afterwards
1236
+ * (the events-scan paths re-price the whole log). Bump
1237
+ * {@link ProjectionDefinition.stateVersion} whenever the state shape or fold
1238
+ * semantics change, so persisted checkpoint rows are discarded instead of
1239
+ * folded forward.
736
1240
  * @param billing - resolved pricing with peak-hour windows.
737
1241
  * @param catalog - model display rows, in presentation order.
738
1242
  * @returns the unit definition to register on `ctx.sessionProjections`.
@@ -741,25 +1245,10 @@ function billingTodaySpendDefinition(billing, catalog) {
741
1245
  const names = new Map(catalog.map((model) => [model.id, model.name]));
742
1246
  return {
743
1247
  key: BILLING_UNIT_KEY,
744
- stateVersion: 1,
1248
+ stateVersion: 4,
745
1249
  stateSchema: billingUnitSchema,
746
- init: () => ({
747
- dayKey: "",
748
- spend: emptyTodaySpend()
749
- }),
750
- apply: (state, event) => {
751
- const priced = priceEvent(event, billing, names);
752
- if (priced === void 0) return state;
753
- if (state.dayKey === priced.dayKey) return {
754
- dayKey: state.dayKey,
755
- spend: addEventContribution(state.spend, priced)
756
- };
757
- if (state.dayKey !== "" && priced.dayKey < state.dayKey) return state;
758
- return {
759
- dayKey: priced.dayKey,
760
- spend: addEventContribution(emptyTodaySpend(), priced)
761
- };
762
- },
1250
+ init: (_header, inheritedEventCount) => emptyBillingFoldState(Number(inheritedEventCount ?? 0)),
1251
+ apply: (state, event) => applyBillingEvent(state, event, billing, names),
763
1252
  wire: {
764
1253
  viewSchema: billingUnitSchema,
765
1254
  view: (state) => state
@@ -800,11 +1289,13 @@ function foldOwnBilling(unit, events, seedLength = 0) {
800
1289
  * compute the aggregate behind a cache miss:
801
1290
  *
802
1291
  * - projection path (plan C): live sessions read their eagerly folded
803
- * `billingTodaySpend` projection cell; cold sessions resolve through the
804
- * projection-cache ladder (cached row + tail replay + registry restore,
805
- * with write-back) or, without the cache service, one detached local fold
806
- * over a full `inspect`. Persisted revisions gate every cold read, so a
807
- * session whose log did not change since the last resolution costs nothing.
1292
+ * `billingTodaySpend` projection cell; cold sessions are answered from the
1293
+ * zero-I/O projection-cache row whenever that row's own day is not the
1294
+ * queried one, and otherwise resolved through one detached local fold over
1295
+ * a full `inspect`. Persisted revisions gate every cold read, so a session
1296
+ * whose log did not change since the last resolution costs nothing — and a
1297
+ * failed resolution is remembered by revision instead of being retried on
1298
+ * every scan.
808
1299
  * - events path (plans A2/A3): collect and price only today's events in one
809
1300
  * pass (per-event Beijing-day filter during collection) with a hard cap,
810
1301
  * skipping sessions whose persisted revision is unchanged since the last
@@ -816,14 +1307,15 @@ function foldOwnBilling(unit, events, seedLength = 0) {
816
1307
  * unchanged log provably cannot change the aggregate.
817
1308
  *
818
1309
  * Forked sessions never double-count: a fork child's log opens with a
819
- * verbatim copy of its source session's events (its inherited boundary),
820
- * so the scanner prices only the child's OWN events on every path — the
821
- * projection path bypasses the eager cell for a seeded session and folds its
822
- * own events instead (the cell covers the inherited prefix too), and the cold
823
- * ladder skips the projection cache for a seeded session (its cached row
824
- * predates the boundary and covers inherited events). The boundary is the
825
- * durable session state, read across both DSH runtime families a resumed
826
- * fork child keeps its original boundary and an unseeded session stays at 0.
1310
+ * verbatim copy of its source session's events (its inherited boundary), so
1311
+ * the scanner prices only the child's OWN events on every path. The
1312
+ * `billingTodaySpend` unit is boundary-aware (its state carries the inherited
1313
+ * cut, and `apply` skips events below it), so the eager cell is correct for a
1314
+ * fork child; the cold path skips the projection cache for a seeded session
1315
+ * (its cached row may predate the boundary) and folds its own events with the
1316
+ * durable cut instead. The boundary is the durable session state, read across
1317
+ * both DSH runtime families a resumed fork child keeps its original
1318
+ * boundary and an unseeded session stays at 0.
827
1319
  *
828
1320
  * The live `Session` log surface changed in 0.1.2-alpha.4: `Session.events`
829
1321
  * was removed and replaced by `Session.snapshotEvents()` / `ownEvents()`, and
@@ -862,6 +1354,15 @@ function liveSessionEvents(session) {
862
1354
  if (session.snapshotEvents !== void 0) return session.snapshotEvents();
863
1355
  throw new Error("llm-billing: session log surface is neither Session.events nor Session.snapshotEvents");
864
1356
  }
1357
+ /**
1358
+ * Unwrap a handle read across both return shapes.
1359
+ * @param read - the handle's read result.
1360
+ * @returns the event array.
1361
+ */
1362
+ function handleReadEvents(read) {
1363
+ if (Array.isArray(read)) return read;
1364
+ return read.events;
1365
+ }
865
1366
  function isHandlePersistence(persistence) {
866
1367
  return typeof persistence.open === "function";
867
1368
  }
@@ -888,7 +1389,7 @@ async function persistenceInspect(persistence, id) {
888
1389
  const handle = await persistence.open(id, "read");
889
1390
  try {
890
1391
  return {
891
- events: await handle.read(),
1392
+ events: handleReadEvents(await handle.read()),
892
1393
  seedLength: forkBoundaryOf(handle)
893
1394
  };
894
1395
  } finally {
@@ -952,7 +1453,7 @@ var TodaySpendCache = class {
952
1453
  const now = this.now();
953
1454
  const dayKey = beijingDayKey(now);
954
1455
  if (!force && this.cachedDayKey === dayKey && this.cachedValue !== void 0 && now.getTime() - this.cachedAt < this.ttlMs) return Promise.resolve(this.cachedValue);
955
- if (!force && this.inFlight !== void 0) return this.inFlight;
1456
+ if (this.inFlight !== void 0) return this.inFlight;
956
1457
  const run = (async () => {
957
1458
  try {
958
1459
  const value = await this.scan(dayKey);
@@ -964,10 +1465,24 @@ var TodaySpendCache = class {
964
1465
  this.inFlight = void 0;
965
1466
  }
966
1467
  })();
967
- if (!force) this.inFlight = run;
1468
+ this.inFlight = run;
968
1469
  return run;
969
1470
  }
970
1471
  };
1472
+ /** Max session-ids kept in the scanner's cold-resolution cache before eviction. */
1473
+ const COLD_RESOLVE_CACHE_LIMIT = 1024;
1474
+ /** Max session-ids kept in the scanner's cold-failure cache before eviction. */
1475
+ const COLD_FAILED_CACHE_LIMIT = 1024;
1476
+ /**
1477
+ * Bounded-map eviction: drop the oldest inserted entry once `size` reached
1478
+ * `limit`. Evicting one entry (instead of clearing) keeps the other sessions'
1479
+ * resolved state warm across scans.
1480
+ */
1481
+ function evictOldest$1(map, limit) {
1482
+ if (map.size < limit) return;
1483
+ const oldest = map.keys().next().value;
1484
+ if (oldest !== void 0) map.delete(oldest);
1485
+ }
971
1486
  /**
972
1487
  * The aggregate computation behind a cache miss. Chooses the projection path
973
1488
  * when the projection registry is composed, the events path otherwise; both
@@ -978,10 +1493,10 @@ var TodaySpendScanner = class {
978
1493
  deps;
979
1494
  /** Cold sessions resolved on the projection path: id → revision + unit state + title. */
980
1495
  coldResolved = /* @__PURE__ */ new Map();
1496
+ /** Cold sessions whose resolution failed: id → revision (retried only when the log changes). */
1497
+ coldFailed = /* @__PURE__ */ new Map();
981
1498
  /** Cold sessions resolved on the events path: id → revision (events were collected). */
982
1499
  lastEventsScan;
983
- /** Live fork children priced on the projection path: id → own-events count + folded state. */
984
- ownStates = /* @__PURE__ */ new Map();
985
1500
  constructor(deps) {
986
1501
  this.deps = deps;
987
1502
  }
@@ -991,9 +1506,7 @@ var TodaySpendScanner = class {
991
1506
  * @returns today's spend across every session.
992
1507
  */
993
1508
  async scan(dayKey) {
994
- if (this.deps.projections?.() === void 0) return this.scanEvents(dayKey);
995
- this.deps.ensureUnit?.();
996
- return this.scanProjections(dayKey);
1509
+ return (await this.scanDetail(dayKey)).aggregate;
997
1510
  }
998
1511
  /**
999
1512
  * Compute today's per-session spend for one Beijing day, sorted by cost
@@ -1003,157 +1516,169 @@ var TodaySpendScanner = class {
1003
1516
  * @returns today's per-session rows, highest first.
1004
1517
  */
1005
1518
  async scanSessions(dayKey) {
1006
- const rows = this.deps.projections?.() === void 0 ? await this.scanSessionsEvents(dayKey) : await this.scanSessionsProjections(dayKey);
1007
- rows.sort((left, right) => right.total - left.total);
1008
- return { sessions: rows };
1519
+ return { sessions: (await this.scanDetail(dayKey)).sessions };
1009
1520
  }
1010
1521
  /**
1011
- * Resolve one cold session's billing unit state and display title through
1012
- * the projection-cache ladder (cached row first, then a detached local
1013
- * fold over a full inspect). A cache-served value carries no title (the
1014
- * ladder only stores projection values), so such rows report `title: null`
1015
- * until the session is inspected again. A SEEDED session (fork child)
1016
- * skips the ladder entirely: its cached row was folded over the inherited
1522
+ * Compute the day's aggregate AND its per-session ranking in ONE pass: the
1523
+ * aggregate is the sum of the rows, so the two reads share every session
1524
+ * read, unit fold, and title fold instead of scanning twice. Chooses the
1525
+ * projection path when the projection registry is composed, the events path
1526
+ * otherwise.
1527
+ * @param dayKey - the Beijing-time calendar-day key to aggregate.
1528
+ * @returns the aggregate plus per-session rows sorted by cost descending.
1529
+ */
1530
+ async scanDetail(dayKey) {
1531
+ if (this.deps.projections?.() === void 0) return this.scanDetailEvents(dayKey);
1532
+ this.deps.ensureUnit?.();
1533
+ return this.scanDetailProjections(dayKey);
1534
+ }
1535
+ /**
1536
+ * Resolve one cold session's billing unit state and display title.
1537
+ *
1538
+ * The zero-I/O projection-cache row answers the query directly whenever its
1539
+ * own latest priced day is NOT the queried day: the row then proves the
1540
+ * session contributed nothing to the queried day, so the log is never read.
1541
+ * When the row IS the queried day (or no usable row exists) the session is
1542
+ * inspected and folded locally, because the row may trail the log (a crash
1543
+ * between the last checkpoint and the session's last event).
1544
+ *
1545
+ * A cache-served value carries no title (the ladder only stores projection
1546
+ * values), so such rows report `title: null`. A SEEDED session (fork child)
1547
+ * skips the cache entirely: its cached row was folded over the inherited
1017
1548
  * prefix too, so it always detaches through inspect with the durable
1018
1549
  * boundary (the inspect result's inherited count or `meta.seedLength`,
1019
1550
  * depending on the runtime family) applied to the local fold.
1020
- * @param id - the cold session's id.
1021
- * @param seeded - whether the session carries a fork-inherited prefix
1022
- * (from the snapshot header: `isSeeded` on 0.1.2-alpha.4+, `seedLength`
1023
- * at and before the 0.1.1-rc.2 baseline).
1551
+ * @param header - the listed session header (the cache identity witness).
1552
+ * @param seeded - whether the session carries a fork-inherited prefix.
1553
+ * @param dayKey - the Beijing-time day being aggregated.
1024
1554
  * @returns the resolved state and title, or `undefined` when unreadable.
1025
1555
  */
1026
- async resolveCold(id, seeded) {
1556
+ async resolveCold(header, seeded, dayKey) {
1027
1557
  const { persistence, projectionCache, logger } = this.deps;
1028
- const persistenceService = persistence?.();
1029
- if (persistenceService === void 0) return void 0;
1030
1558
  if (!seeded) {
1031
1559
  const cache = projectionCache?.();
1032
1560
  if (cache !== void 0) try {
1033
- const value = (await cache.coldSnapshot(id)).values[BILLING_UNIT_KEY];
1034
- if (value !== void 0) return {
1561
+ const value = cache.cachedSnapshot(header, 0, [BILLING_UNIT_KEY])?.values[BILLING_UNIT_KEY];
1562
+ if (value !== void 0 && value.dayKey !== dayKey) return {
1035
1563
  value,
1036
1564
  title: null
1037
1565
  };
1038
1566
  } catch (error) {
1039
- logger.warn(`llm-billing: projection cold read for session ${id} failed: ${String(error)}`);
1567
+ logger.warn(`llm-billing: projection cache read for session ${header.id} failed: ${String(error)}`);
1040
1568
  }
1041
1569
  }
1570
+ const persistenceService = persistence?.();
1571
+ if (persistenceService === void 0) return void 0;
1042
1572
  try {
1043
- const read = await persistenceInspect(persistenceService, id);
1573
+ const read = await persistenceInspect(persistenceService, header.id);
1044
1574
  return {
1045
1575
  value: foldOwnBilling(this.deps.unit, read.events, read.seedLength),
1046
1576
  title: foldSessionTitle(read.events)
1047
1577
  };
1048
1578
  } catch (error) {
1049
- logger.warn(`llm-billing: skipping unreadable session ${id}: ${String(error)}`);
1579
+ logger.warn(`llm-billing: skipping unreadable session ${header.id}: ${String(error)}`);
1050
1580
  return;
1051
1581
  }
1052
1582
  }
1053
1583
  /**
1054
- * Fold one fork child's OWN events (its log minus the inherited prefix)
1055
- * with the billing unit, incrementally: the fold is reused while the log
1056
- * length is unchanged and only the new tail is applied when it grows.
1057
- * @param id - the session id (the own-state cache key).
1058
- * @param events - the session's complete log.
1059
- * @param seedLength - the inherited-prefix boundary.
1060
- * @returns the unit state over the session's own events.
1584
+ * Live-session entries of one projection-path scan: each session with its
1585
+ * eager `billingTodaySpend` cell. The cell is boundary-aware (the unit skips
1586
+ * a fork child's inherited prefix), so a fork child reads the same own-event
1587
+ * spend a non-fork session does.
1061
1588
  */
1062
- ownBillingState(id, events, seedLength) {
1063
- const cached = this.ownStates.get(id);
1064
- const ownCount = events.length - seedLength;
1065
- if (cached !== void 0 && cached.count === ownCount) return cached.state;
1066
- let state;
1067
- if (cached !== void 0 && cached.count < ownCount) {
1068
- state = cached.state;
1069
- for (const event of events) {
1070
- if (event.seq < seedLength + cached.count) continue;
1071
- state = this.deps.unit.apply(state, event);
1072
- }
1073
- } else state = foldOwnBilling(this.deps.unit, events, seedLength);
1074
- this.ownStates.set(id, {
1075
- count: ownCount,
1076
- state
1077
- });
1078
- return state;
1589
+ *liveBillingEntries(store, projections) {
1590
+ for (const session of store.list()) yield {
1591
+ session,
1592
+ state: projections?.stateOf(session, BILLING_UNIT_KEY)
1593
+ };
1079
1594
  }
1080
- /** Projection path: eager cells for live sessions, revision-gated cold ladder for the rest. */
1081
- async scanProjections(dayKey) {
1082
- const { sessions, persistence, projections } = this.deps;
1083
- const projectionsService = projections?.();
1084
- let total = emptyTodaySpend();
1085
- const liveIds = /* @__PURE__ */ new Set();
1086
- if (sessions !== void 0) {
1087
- const store = sessions();
1088
- if (store !== void 0) for (const session of store.list()) {
1089
- liveIds.add(session.id);
1090
- const seedLength = forkBoundaryOf(session);
1091
- const state = seedLength > 0 ? this.ownBillingState(session.id, liveSessionEvents(session), seedLength) : projectionsService?.stateOf(session, BILLING_UNIT_KEY);
1092
- if (state !== void 0 && state.dayKey === dayKey) total = mergeTodaySpend(total, state.spend);
1093
- }
1094
- }
1095
- const persistenceService = persistence?.();
1096
- if (persistenceService === void 0) return total;
1097
- const snapshots = await persistenceListSnapshots(persistenceService);
1595
+ /**
1596
+ * Cold-ladder adopt: for every stored session not live, either the
1597
+ * revision-gated resolution already in {@link coldResolved} is adopted
1598
+ * (unchanged log costs nothing) or the session is queued behind a bounded
1599
+ * parallel fan-out, resolved, remembered, and then adopted. A session whose
1600
+ * resolution failed is remembered too (by revision), so an unreadable log
1601
+ * is not re-read on every scan; a changed revision retries it. One
1602
+ * unreadable session never blanks the whole-day aggregate.
1603
+ * @param liveIds - ids of sessions already folded from the live store.
1604
+ * @param snapshots - stored snapshot list (either runtime family).
1605
+ * @param dayKey - the Beijing-time day being aggregated.
1606
+ * @param adopt - fold one resolved cold session into the scan's result.
1607
+ */
1608
+ async coldAdopt(liveIds, snapshots, dayKey, adopt) {
1609
+ const persistenceAvailable = this.deps.persistence?.() !== void 0;
1098
1610
  const pending = [];
1099
1611
  for (const { header, revision } of snapshots) {
1100
1612
  if (liveIds.has(header.id)) continue;
1101
1613
  const seeded = isSeededSession(header);
1102
1614
  const resolved = this.coldResolved.get(header.id);
1103
1615
  if (resolved !== void 0 && resolved.revision === revision) {
1104
- if (resolved.value.dayKey === dayKey) total = mergeTodaySpend(total, resolved.value.spend);
1616
+ adopt(header.id, resolved);
1105
1617
  continue;
1106
1618
  }
1619
+ if (this.coldFailed.get(header.id) === revision) continue;
1107
1620
  pending.push({
1108
- id: header.id,
1621
+ header,
1109
1622
  revision,
1110
1623
  seeded
1111
1624
  });
1112
1625
  }
1113
- await withConcurrency(pending, 8, async ({ id, revision, seeded }) => {
1114
- const resolved = await this.resolveCold(id, seeded);
1115
- if (resolved !== void 0) this.coldResolved.set(id, {
1116
- revision,
1117
- ...resolved
1118
- });
1626
+ await withConcurrency(pending, 8, async ({ header, revision, seeded }) => {
1627
+ const resolved = await this.resolveCold(header, seeded, dayKey);
1628
+ if (resolved !== void 0) {
1629
+ this.coldFailed.delete(header.id);
1630
+ evictOldest$1(this.coldResolved, COLD_RESOLVE_CACHE_LIMIT);
1631
+ this.coldResolved.set(header.id, {
1632
+ revision,
1633
+ ...resolved
1634
+ });
1635
+ } else if (persistenceAvailable) {
1636
+ evictOldest$1(this.coldFailed, COLD_FAILED_CACHE_LIMIT);
1637
+ this.coldFailed.set(header.id, revision);
1638
+ }
1119
1639
  });
1120
- for (const { id } of pending) {
1121
- const resolved = this.coldResolved.get(id);
1122
- if (resolved !== void 0 && resolved.value.dayKey === dayKey) total = mergeTodaySpend(total, resolved.value.spend);
1640
+ for (const { header } of pending) {
1641
+ const resolved = this.coldResolved.get(header.id);
1642
+ if (resolved !== void 0) adopt(header.id, resolved);
1123
1643
  }
1124
- return total;
1125
1644
  }
1126
1645
  /**
1127
- * Events path: price today's events in a single pass (per-event Beijing-day
1128
- * filter during collection, hard cap), gated by revisions. A fork child's
1129
- * inherited prefix (`seq < seedLength`) is skipped, so each model output is
1130
- * priced only in its source session.
1646
+ * Events-path collection shared by both aggregate and per-session scans:
1647
+ * fold each session's log with the shared pricing fold (attempt samples with
1648
+ * same-step replacement) and announce the session's latest-day spend, gated
1649
+ * by revisions a persisted session whose log did not change since the last
1650
+ * scan is skipped. A fork child's inherited prefix (`seq < seedLength`) is
1651
+ * skipped, so each model output is priced only in its source session. The
1652
+ * hard cap counts the queried day's events; the revision watermark only
1653
+ * advances on a complete pass.
1654
+ * @param dayKey - the Beijing-time calendar-day key to aggregate.
1655
+ * @param onSession - fold one session's state plus its complete log.
1656
+ * @returns whether the hard cap truncated the scan.
1131
1657
  */
1132
- async scanEvents(dayKey) {
1658
+ async collectTodayEvents(dayKey, onSession) {
1133
1659
  const { sessions, persistence, maxEvents, logger, billing, catalog } = this.deps;
1134
- const names = new Map(catalog.map((model) => [model.id, model.name]));
1135
- const accumulator = new SpendAccumulator();
1136
1660
  const liveIds = /* @__PURE__ */ new Set();
1137
1661
  let collected = 0;
1138
1662
  let truncated = false;
1139
- const collect = (events, seedLength) => {
1663
+ const collect = (id, events, seedLength) => {
1664
+ const folder = new BillingFolder(billing, catalog, seedLength);
1140
1665
  for (const event of events) {
1141
- if (event.seq < seedLength) continue;
1142
- if (beijingDayKey(new Date(event.time)) !== dayKey) continue;
1143
- collected += 1;
1144
- if (collected > maxEvents) {
1145
- truncated = true;
1146
- return;
1666
+ if (beijingPartsOf(event.time).dayKey === dayKey) {
1667
+ collected += 1;
1668
+ if (collected > maxEvents) {
1669
+ truncated = true;
1670
+ break;
1671
+ }
1147
1672
  }
1148
- const priced = priceEvent(event, billing, names);
1149
- if (priced !== void 0) accumulator.add(priced);
1673
+ folder.add(event);
1150
1674
  }
1675
+ onSession(id, folder.fold, events);
1151
1676
  };
1152
1677
  if (sessions !== void 0) {
1153
1678
  const store = sessions();
1154
1679
  if (store !== void 0) for (const session of store.list()) {
1155
1680
  liveIds.add(session.id);
1156
- collect(liveSessionEvents(session), forkBoundaryOf(session));
1681
+ collect(session.id, liveSessionEvents(session), forkBoundaryOf(session));
1157
1682
  if (truncated) break;
1158
1683
  }
1159
1684
  }
@@ -1165,7 +1690,7 @@ var TodaySpendScanner = class {
1165
1690
  if (this.lastEventsScan?.get(header.id) === revision) continue;
1166
1691
  try {
1167
1692
  const read = await persistenceInspect(persistenceService, header.id);
1168
- collect(read.events, read.seedLength);
1693
+ collect(header.id, read.events, read.seedLength);
1169
1694
  } catch (error) {
1170
1695
  logger.warn(`llm-billing: skipping unreadable session ${header.id}: ${String(error)}`);
1171
1696
  }
@@ -1174,146 +1699,87 @@ var TodaySpendScanner = class {
1174
1699
  if (!truncated) this.lastEventsScan = new Map(snapshots.map((snapshot) => [snapshot.header.id, snapshot.revision]));
1175
1700
  }
1176
1701
  if (truncated) logger.warn(`llm-billing: today's events exceeded ${maxEvents}; result truncated`);
1177
- return accumulator.finish();
1702
+ return truncated;
1178
1703
  }
1179
1704
  /**
1180
- * Projection-path per-session scan: eager cells for live sessions (title
1181
- * folded from the live log, so a rename is reflected immediately),
1182
- * revision-gated cold ladder for the rest (title resolved on inspect,
1183
- * `null` when served from the projection cache). A fork child's row prices
1184
- * its OWN events only (the cell covers the inherited prefix too).
1705
+ * Projection path, one pass for both outputs: eager cells for live sessions
1706
+ * (title folded from the live log, so a rename is reflected immediately),
1707
+ * revision-gated cold ladder for the rest (title resolved on inspect, `null`
1708
+ * when answered from the projection cache). A fork child's cell covers its
1709
+ * inherited prefix, so its own-events fold supplies both outputs.
1185
1710
  * @param dayKey - the Beijing-time calendar-day key to aggregate.
1186
- * @returns unsorted per-session rows for the day.
1711
+ * @returns the aggregate plus per-session rows, sorted by cost descending.
1187
1712
  */
1188
- async scanSessionsProjections(dayKey) {
1713
+ async scanDetailProjections(dayKey) {
1189
1714
  const { sessions, persistence, projections } = this.deps;
1190
1715
  const projectionsService = projections?.();
1716
+ let aggregate = emptyTodaySpend();
1191
1717
  const rows = /* @__PURE__ */ new Map();
1192
1718
  const liveIds = /* @__PURE__ */ new Set();
1193
1719
  if (sessions !== void 0) {
1194
1720
  const store = sessions();
1195
- if (store !== void 0) for (const session of store.list()) {
1721
+ if (store !== void 0) for (const { session, state } of this.liveBillingEntries(store, projectionsService)) {
1196
1722
  liveIds.add(session.id);
1197
- const seedLength = forkBoundaryOf(session);
1198
- const events = liveSessionEvents(session);
1199
- const state = seedLength > 0 ? this.ownBillingState(session.id, events, seedLength) : projectionsService?.stateOf(session, BILLING_UNIT_KEY);
1200
- if (state !== void 0 && state.dayKey === dayKey) rows.set(session.id, {
1723
+ if (state === void 0 || state.dayKey !== dayKey) continue;
1724
+ aggregate = mergeTodaySpend(aggregate, state.spend);
1725
+ rows.set(session.id, {
1201
1726
  sessionId: session.id,
1202
- title: foldSessionTitle(events),
1727
+ title: foldSessionTitle(liveSessionEvents(session)),
1203
1728
  total: state.spend.total
1204
1729
  });
1205
1730
  }
1206
1731
  }
1207
1732
  const persistenceService = persistence?.();
1208
- if (persistenceService === void 0) return [...rows.values()];
1209
- const snapshots = await persistenceListSnapshots(persistenceService);
1210
- const pending = [];
1211
- for (const { header, revision } of snapshots) {
1212
- if (liveIds.has(header.id)) continue;
1213
- const seeded = isSeededSession(header);
1214
- const resolved = this.coldResolved.get(header.id);
1215
- if (resolved !== void 0 && resolved.revision === revision) {
1216
- if (resolved.value.dayKey === dayKey) rows.set(header.id, {
1217
- sessionId: header.id,
1733
+ if (persistenceService !== void 0) {
1734
+ const snapshots = await persistenceListSnapshots(persistenceService);
1735
+ await this.coldAdopt(liveIds, snapshots, dayKey, (id, resolved) => {
1736
+ if (resolved.value.dayKey !== dayKey) return;
1737
+ aggregate = mergeTodaySpend(aggregate, resolved.value.spend);
1738
+ rows.set(id, {
1739
+ sessionId: id,
1218
1740
  title: resolved.title,
1219
1741
  total: resolved.value.spend.total
1220
1742
  });
1221
- continue;
1222
- }
1223
- pending.push({
1224
- id: header.id,
1225
- revision,
1226
- seeded
1227
1743
  });
1228
1744
  }
1229
- await withConcurrency(pending, 8, async ({ id, revision, seeded }) => {
1230
- const resolved = await this.resolveCold(id, seeded);
1231
- if (resolved !== void 0) this.coldResolved.set(id, {
1232
- revision,
1233
- ...resolved
1234
- });
1235
- });
1236
- for (const { id } of pending) {
1237
- const resolved = this.coldResolved.get(id);
1238
- if (resolved !== void 0 && resolved.value.dayKey === dayKey) rows.set(id, {
1239
- sessionId: id,
1240
- title: resolved.title,
1241
- total: resolved.value.spend.total
1242
- });
1243
- }
1244
- return [...rows.values()];
1745
+ return {
1746
+ aggregate,
1747
+ sessions: sortRows(rows)
1748
+ };
1245
1749
  }
1246
1750
  /**
1247
- * Events-path per-session scan: price today's events in a single pass,
1248
- * accumulating per session (per-event Beijing-day filter during collection,
1249
- * hard cap), gated by revisions. A fork child's inherited prefix
1250
- * (`seq < seedLength`) is skipped, so each row is the session's OWN spend.
1251
- * Titles fold from each session's complete log — a `session/title` event
1252
- * can predate today — so a rename is reflected as soon as the session's log
1253
- * is re-read.
1751
+ * Events path, one pass for both outputs: price today's events (per-event
1752
+ * Beijing-day filter during collection, hard cap), gated by revisions. A
1753
+ * fork child's inherited prefix (`seq < seedLength`) is skipped, so each
1754
+ * model output is priced only in its source session. Titles fold from each
1755
+ * session's complete log — a `session/title` event can predate today — so a
1756
+ * rename is reflected as soon as the session's log is re-read.
1254
1757
  * @param dayKey - the Beijing-time calendar-day key to aggregate.
1255
- * @returns unsorted per-session rows for the day.
1758
+ * @returns the aggregate plus per-session rows, sorted by cost descending.
1256
1759
  */
1257
- async scanSessionsEvents(dayKey) {
1258
- const { sessions, persistence, maxEvents, logger, billing, catalog } = this.deps;
1259
- const names = new Map(catalog.map((model) => [model.id, model.name]));
1260
- const rows = /* @__PURE__ */ new Map();
1261
- const liveIds = /* @__PURE__ */ new Set();
1262
- let collected = 0;
1263
- let truncated = false;
1264
- const collect = (id, events, seedLength) => {
1265
- let row = rows.get(id);
1266
- if (row === void 0) {
1267
- row = {
1268
- title: foldSessionTitle(events),
1269
- total: 0
1270
- };
1271
- rows.set(id, row);
1272
- }
1273
- for (const event of events) {
1274
- if (event.seq < seedLength) continue;
1275
- if (beijingDayKey(new Date(event.time)) !== dayKey) continue;
1276
- collected += 1;
1277
- if (collected > maxEvents) {
1278
- truncated = true;
1279
- return;
1280
- }
1281
- const priced = priceEvent(event, billing, names);
1282
- if (priced !== void 0) row.total += priced.cost;
1283
- }
1760
+ async scanDetailEvents(dayKey) {
1761
+ let aggregate = emptyTodaySpend();
1762
+ const sessions = [];
1763
+ await this.collectTodayEvents(dayKey, (id, fold, events) => {
1764
+ if (fold.dayKey !== dayKey) return;
1765
+ aggregate = mergeTodaySpend(aggregate, fold.spend);
1766
+ sessions.push({
1767
+ sessionId: id,
1768
+ title: foldSessionTitle(events),
1769
+ total: fold.spend.total
1770
+ });
1771
+ });
1772
+ sessions.sort((left, right) => right.total - left.total);
1773
+ return {
1774
+ aggregate,
1775
+ sessions
1284
1776
  };
1285
- if (sessions !== void 0) {
1286
- const store = sessions();
1287
- if (store !== void 0) for (const session of store.list()) {
1288
- liveIds.add(session.id);
1289
- collect(session.id, liveSessionEvents(session), forkBoundaryOf(session));
1290
- if (truncated) break;
1291
- }
1292
- }
1293
- const persistenceService = persistence?.();
1294
- if (!truncated && persistenceService !== void 0) {
1295
- const snapshots = await persistenceListSnapshots(persistenceService);
1296
- for (const { header, revision } of snapshots) {
1297
- if (liveIds.has(header.id)) continue;
1298
- if (this.lastEventsScan?.get(header.id) === revision) continue;
1299
- try {
1300
- const read = await persistenceInspect(persistenceService, header.id);
1301
- collect(header.id, read.events, read.seedLength);
1302
- } catch (error) {
1303
- logger.warn(`llm-billing: skipping unreadable session ${header.id}: ${String(error)}`);
1304
- }
1305
- if (truncated) break;
1306
- }
1307
- if (!truncated) this.lastEventsScan = new Map(snapshots.map((snapshot) => [snapshot.header.id, snapshot.revision]));
1308
- }
1309
- if (truncated) logger.warn(`llm-billing: today's events exceeded ${maxEvents}; result truncated`);
1310
- return [...rows.entries()].filter(([, row]) => row.total > 0).map(([sessionId, row]) => ({
1311
- sessionId,
1312
- title: row.title,
1313
- total: row.total
1314
- }));
1315
1777
  }
1316
1778
  };
1779
+ /** Per-session rows from the map, highest total first. */
1780
+ function sortRows(rows) {
1781
+ return [...rows.values()].sort((left, right) => right.total - left.total);
1782
+ }
1317
1783
  //#endregion
1318
1784
  //#region lib/types/index.js
1319
1785
  /**
@@ -1343,11 +1809,26 @@ const DEFAULT_API_KEY_ENV = "DEEPSEEK_API_KEY";
1343
1809
  const BASE_URL_ENV = "DEEPSEEK_BASE_URL";
1344
1810
  /** Public API default; deployments may point elsewhere via $DEEPSEEK_BASE_URL. */
1345
1811
  const PUBLIC_BASE_URL = "https://api.deepseek.com";
1812
+ /**
1813
+ * Advisory display rows mirroring the DSH `llm-deepseek` catalog (V4.1 Flash
1814
+ * first, its current default route), plus the MiMo-V2.5 series. The retired
1815
+ * preview id `deepseek-v4.1-flash-expires-on-0910` stays so the logs that used
1816
+ * it keep a readable label; rows never restrict which models are priced — the
1817
+ * pricing table does.
1818
+ */
1346
1819
  const DEFAULT_MODELS = [
1820
+ {
1821
+ id: "deepseek-flash",
1822
+ name: "DeepSeek-V41-Flash"
1823
+ },
1347
1824
  {
1348
1825
  id: "deepseek-v4-flash",
1349
1826
  name: "DeepSeek-V4-Flash"
1350
1827
  },
1828
+ {
1829
+ id: "deepseek-v4.1-flash-expires-on-0910",
1830
+ name: "DeepSeek-V4.1-Flash"
1831
+ },
1351
1832
  {
1352
1833
  id: "deepseek-v4-pro",
1353
1834
  name: "DeepSeek-V4-Pro"
@@ -1374,16 +1855,18 @@ const tokenPrice = z.object({
1374
1855
  cacheMissInput: z.number().min(0),
1375
1856
  output: z.number().min(0)
1376
1857
  });
1858
+ const billingRateRow = z.object({
1859
+ model: z.string().required(),
1860
+ peak: tokenPrice,
1861
+ offPeak: tokenPrice,
1862
+ effectiveFrom: z.number().min(0)
1863
+ });
1377
1864
  const billingConfig = z.object({
1378
1865
  peakHours: z.array(z.object({
1379
1866
  start: z.number().step(1).min(0).max(23),
1380
1867
  end: z.number().step(1).min(0).max(24)
1381
- })).default(DEFAULT_PEAK_HOURS),
1382
- models: z.array(z.object({
1383
- model: z.string().required(),
1384
- peak: tokenPrice,
1385
- offPeak: tokenPrice
1386
- })).default(DEFAULT_MODEL_PRICING)
1868
+ })).default([...DEFAULT_PEAK_HOURS]),
1869
+ models: z.array(billingRateRow).default([...DEFAULT_MODEL_PRICING])
1387
1870
  });
1388
1871
  const Config = z.object({
1389
1872
  apiKeyEnv: z.string().role("credential-ref").default(DEFAULT_API_KEY_ENV),
@@ -1395,6 +1878,25 @@ const Config = z.object({
1395
1878
  const TODAY_SPEND_CACHE_MS = 6e4;
1396
1879
  /** Hard cap on today's events collected by the events scan path. */
1397
1880
  const TODAY_SPEND_MAX_EVENTS = 2e5;
1881
+ /** Max session-spend rows kept for incremental recompute before eviction. */
1882
+ const SESSION_SPEND_CACHE_LIMIT = 1024;
1883
+ /** Max session-id entries kept in the per-turn-cost fold cache before eviction. */
1884
+ const SESSION_TURN_SPEND_CACHE_LIMIT = 64;
1885
+ /** How long one balance snapshot is reused before the host refetches it (15s). */
1886
+ const BALANCE_CACHE_MS = 15e3;
1887
+ /** Hard cap on one `/user/balance` request (5s); a hung endpoint never blocks the badge. */
1888
+ const BALANCE_TIMEOUT_MS = 5e3;
1889
+ /**
1890
+ * Bounded-map eviction: drop the oldest inserted entry once `size` reached
1891
+ * `limit`, so an unbounded session-id space grows the map no further. Evicting
1892
+ * one entry (instead of clearing) keeps the other sessions' incremental
1893
+ * spend warm.
1894
+ */
1895
+ function evictOldest(map, limit) {
1896
+ if (map.size < limit) return;
1897
+ const oldest = map.keys().next().value;
1898
+ if (oldest !== void 0) map.delete(oldest);
1899
+ }
1398
1900
  /**
1399
1901
  * Read one session's event log and durable seed boundary: the live
1400
1902
  * SessionStore first, then the persistence backend for a flushed session
@@ -1421,65 +1923,111 @@ async function sessionEvents(ctx, sessionId) {
1421
1923
  }
1422
1924
  throw new LlmError(`llm-billing: session ${sessionId} not found`, "NOT_FOUND");
1423
1925
  }
1926
+ /** Resolve the plugin's static facts once: endpoint, credential ref, pricing table. */
1927
+ function resolveFacts(ctx, config) {
1928
+ return {
1929
+ baseURL: () => config.baseURL ?? launchEnvironmentOf(ctx).get(BASE_URL_ENV)?.value ?? "https://api.deepseek.com",
1930
+ apiKeyRef: credentialRef(config.apiKeyEnv ?? DEFAULT_API_KEY_ENV),
1931
+ billing: resolveBilling(config.billing),
1932
+ catalog: (config.models ?? DEFAULT_MODELS).map((model) => ({
1933
+ id: model.id,
1934
+ name: model.name ?? model.id
1935
+ }))
1936
+ };
1937
+ }
1424
1938
  /**
1425
- * Register the `billing` Remote under the `billing` namespace.
1426
- * @param ctx - owning plugin context.
1427
- * @param config - validated plugin config.
1939
+ * Resolve the API key per call: the credentials service first, then the
1940
+ * launch environment fallback.
1941
+ * @throws {@link LlmError} with code `MISSING_CREDENTIAL` when neither yields a usable key.
1428
1942
  */
1429
- function apply(ctx, config) {
1430
- const baseURL = () => config.baseURL ?? launchEnvironmentOf(ctx).get(BASE_URL_ENV)?.value ?? "https://api.deepseek.com";
1431
- const apiKeyRef = credentialRef(config.apiKeyEnv ?? DEFAULT_API_KEY_ENV);
1432
- const resolveApiKey = async () => {
1433
- const credentials = ctx.get("credentials");
1434
- if (credentials !== void 0) {
1435
- const hit = await credentials.resolve(apiKeyRef);
1436
- if (hit !== void 0) return assertUsableApiKey(hit.value, "llm-billing", apiKeyRef);
1437
- } else {
1438
- const ambient = launchEnvironmentOf(ctx).get(apiKeyRef);
1439
- if (ambient !== void 0 && ambient.value.length > 0) return assertUsableApiKey(ambient.value, "llm-billing", apiKeyRef);
1440
- }
1441
- throw new LlmError(`llm-billing: no API key; store ${apiKeyRef} through the credentials service or export it`, "MISSING_CREDENTIAL");
1442
- };
1443
- const fetchBalance = async () => {
1444
- const apiKey = await resolveApiKey();
1445
- return fetchDeepSeekBalance(baseURL(), apiKey);
1446
- };
1447
- const billing = resolveBilling(config.billing);
1448
- const catalog = (config.models ?? DEFAULT_MODELS).map((model) => ({
1449
- id: model.id,
1450
- name: model.name ?? model.id
1451
- }));
1943
+ async function resolveApiKey(ctx, apiKeyRef) {
1944
+ const credentials = ctx.get("credentials");
1945
+ if (credentials !== void 0) {
1946
+ const hit = await credentials.resolve(apiKeyRef);
1947
+ if (hit !== void 0) return assertUsableApiKey(hit.value, "llm-billing", apiKeyRef);
1948
+ } else {
1949
+ const ambient = launchEnvironmentOf(ctx).get(apiKeyRef);
1950
+ if (ambient !== void 0 && ambient.value.length > 0) return assertUsableApiKey(ambient.value, "llm-billing", apiKeyRef);
1951
+ }
1952
+ throw new LlmError(`llm-billing: no API key; store ${apiKeyRef} through the credentials service or export it`, "MISSING_CREDENTIAL");
1953
+ }
1954
+ /**
1955
+ * Per-session incremental spend loader: a session log is append-only and
1956
+ * chronological (the same assumption the projection unit makes), so a spend
1957
+ * computed for `count` EVENTS OF THE SESSION'S OWN WORK (the log minus its
1958
+ * inherited fork prefix) stays valid while the log length is unchanged, and
1959
+ * only the appended tail needs pricing when it grows. A forked child's
1960
+ * inherited prefix (`seq < seedLength`) is priced only in its source
1961
+ * session; the cache is bounded (see {@link evictOldest}), so an unbounded
1962
+ * session-id space cannot grow it without bound.
1963
+ */
1964
+ function createSessionSpendFetcher(ctx, facts) {
1452
1965
  const sessionSpendCache = /* @__PURE__ */ new Map();
1453
- const fetchSessionSpend = async (sessionId) => {
1966
+ return async (sessionId) => {
1454
1967
  const { events, seedLength } = await sessionEvents(ctx, sessionId);
1455
1968
  const ownCount = events.length - seedLength;
1456
1969
  const cached = sessionSpendCache.get(sessionId);
1457
- if (cached !== void 0 && cached.count === ownCount) return cached.spend;
1970
+ if (cached !== void 0 && cached.count === ownCount) {
1971
+ sessionSpendCache.delete(sessionId);
1972
+ sessionSpendCache.set(sessionId, cached);
1973
+ return cached.spend;
1974
+ }
1458
1975
  if (cached !== void 0 && cached.count < ownCount) {
1459
- const spend = mergeTodaySpend(cached.spend, computeSessionSpend(events.slice(seedLength + cached.count), billing, catalog));
1976
+ const spend = mergeTodaySpend(cached.spend, computeSessionSpend(events.slice(seedLength + cached.count), facts.billing, facts.catalog));
1977
+ evictOldest(sessionSpendCache, SESSION_SPEND_CACHE_LIMIT);
1460
1978
  sessionSpendCache.set(sessionId, {
1461
1979
  count: ownCount,
1462
1980
  spend
1463
1981
  });
1464
1982
  return spend;
1465
1983
  }
1466
- const spend = computeSessionSpend(events, billing, catalog, seedLength);
1467
- if (sessionSpendCache.size >= 1024) sessionSpendCache.clear();
1984
+ const spend = computeSessionSpend(events, facts.billing, facts.catalog, seedLength);
1985
+ evictOldest(sessionSpendCache, SESSION_SPEND_CACHE_LIMIT);
1468
1986
  sessionSpendCache.set(sessionId, {
1469
1987
  count: ownCount,
1470
1988
  spend
1471
1989
  });
1472
1990
  return spend;
1473
1991
  };
1474
- const unit = billingTodaySpendDefinition(billing, catalog);
1475
- let unitRegistered = false;
1476
- const ensureUnit = () => {
1477
- if (unitRegistered) return;
1992
+ }
1993
+ /**
1994
+ * Once-registrar for the billing projection unit: the first call that finds
1995
+ * the registry composed registers the shared unit and every later call is a
1996
+ * no-op. Registering as early as the registry exists lets DSH's projection
1997
+ * write-behind (mandatory at `turn/end`) checkpoint a billing row for every
1998
+ * session that runs in this process, which is what makes the zero-I/O cold
1999
+ * path in {@link TodaySpendScanner} hit after the next restart.
2000
+ * @param ctx - plugin context.
2001
+ * @param unit - the unit definition built once per plugin config.
2002
+ * @returns an idempotent registrar.
2003
+ */
2004
+ function createUnitRegistrar(ctx, unit) {
2005
+ let registered = false;
2006
+ return () => {
2007
+ if (registered) return;
1478
2008
  const registry = ctx.get("sessionProjections");
1479
2009
  if (registry === void 0) return;
1480
2010
  registry.register(unit);
1481
- unitRegistered = true;
2011
+ registered = true;
1482
2012
  };
2013
+ }
2014
+ /**
2015
+ * Today-spend loaders over one revision-gated scanner with two 60s
2016
+ * Beijing-day caches (in-flight coalescing and a `force` bypass):
2017
+ * - plan C uses the per-session spend projection unit registered by the
2018
+ * caller's {@link createUnitRegistrar} as early as the registry exists (the
2019
+ * registry builds cells lazily over the in-memory log, so events committed
2020
+ * before registration are folded on first touch); without the registry the
2021
+ * events path serves today's spend.
2022
+ * - plans A1–A3: the scanner chooses the projection path when the registry
2023
+ * is composed, the events path otherwise.
2024
+ * @param ctx - plugin context.
2025
+ * @param facts - resolved endpoint, credential, pricing, and catalog facts.
2026
+ * @param unit - the shared projection unit definition.
2027
+ * @param ensureUnit - idempotent unit registrar (last-resort registration).
2028
+ * @returns the two today-spend loaders.
2029
+ */
2030
+ function createTodaySpendLoaders(ctx, facts, unit, ensureUnit) {
1483
2031
  const scanner = new TodaySpendScanner({
1484
2032
  sessions: () => ctx.get("sessions"),
1485
2033
  persistence: () => ctx.get("sessionPersistence"),
@@ -1489,24 +2037,108 @@ function apply(ctx, config) {
1489
2037
  unit,
1490
2038
  maxEvents: TODAY_SPEND_MAX_EVENTS,
1491
2039
  logger: ctx.logger,
1492
- billing,
1493
- catalog
2040
+ billing: facts.billing,
2041
+ catalog: facts.catalog
1494
2042
  });
1495
- const todayCache = new TodaySpendCache((dayKey) => scanner.scan(dayKey), TODAY_SPEND_CACHE_MS);
1496
- const todaySessionsCache = new TodaySpendCache((dayKey) => scanner.scanSessions(dayKey), TODAY_SPEND_CACHE_MS);
1497
- const fetchTodaySpend = async (force = false) => todayCache.get(force);
1498
- const fetchTodaySessionsSpend = async (force = false) => todaySessionsCache.get(force);
1499
- const fetchTurnSpend = async (sessionId, messageId) => {
2043
+ const todayCache = new TodaySpendCache((dayKey) => scanner.scanDetail(dayKey), TODAY_SPEND_CACHE_MS);
2044
+ return {
2045
+ fetchTodaySpend: async (force = false) => (await todayCache.get(force)).aggregate,
2046
+ fetchTodaySessionsSpend: async (force = false) => ({ sessions: (await todayCache.get(force)).sessions })
2047
+ };
2048
+ }
2049
+ /** One completed Turn's spend loader, located by its closing message id. */
2050
+ function createTurnSpendFetcher(ctx, facts) {
2051
+ return async (sessionId, messageId) => {
1500
2052
  const { events } = await sessionEvents(ctx, sessionId);
1501
- return computeTurnSpend(events, billing, catalog, messageId);
2053
+ return computeTurnSpend(events, facts.billing, facts.catalog, messageId);
1502
2054
  };
2055
+ }
2056
+ /**
2057
+ * Every completed Turn's cost in one session, folded incrementally per session
2058
+ * (session logs are append-only, so only the appended tail is priced on a
2059
+ * growing log). One call serves a whole transcript's per-message cost rows,
2060
+ * replacing the per-message `getTurnSpend` fan-out.
2061
+ */
2062
+ function createTurnSpendsFetcher(ctx, facts) {
2063
+ const folders = /* @__PURE__ */ new Map();
2064
+ return async (sessionId) => {
2065
+ const { events } = await sessionEvents(ctx, sessionId);
2066
+ let entry = folders.get(sessionId);
2067
+ if (entry === void 0 || entry.count > events.length) {
2068
+ entry = {
2069
+ folder: new SessionTurnSpendFolder(facts.billing, facts.catalog),
2070
+ count: 0
2071
+ };
2072
+ evictOldest(folders, 64);
2073
+ folders.set(sessionId, entry);
2074
+ }
2075
+ if (entry.count !== events.length) {
2076
+ entry.folder.feed(events);
2077
+ entry.count = events.length;
2078
+ }
2079
+ return entry.folder.finish();
2080
+ };
2081
+ }
2082
+ /**
2083
+ * Balance loader with a short host-side TTL and a hard request timeout: the
2084
+ * credential resolves per call, a fresh snapshot is reused for
2085
+ * {@link BALANCE_CACHE_MS} (so several badge mounts and several browsers share
2086
+ * one `/user/balance` call), concurrent misses coalesce, and `force` bypasses
2087
+ * the TTL for the manual refresh. A hung endpoint aborts after
2088
+ * {@link BALANCE_TIMEOUT_MS} instead of holding the badge's fetch forever.
2089
+ * @param ctx - plugin context carrying the credential seam.
2090
+ * @param facts - resolved endpoint and credential facts.
2091
+ * @returns the balance loader.
2092
+ */
2093
+ function createBalanceFetcher(ctx, facts) {
2094
+ let cached;
2095
+ let inflight;
2096
+ return async (force = false) => {
2097
+ if (!force && cached !== void 0 && Date.now() - cached.at < 15e3) return cached.value;
2098
+ if (inflight !== void 0) return inflight;
2099
+ const run = (async () => {
2100
+ try {
2101
+ const apiKey = await resolveApiKey(ctx, facts.apiKeyRef);
2102
+ const value = await fetchDeepSeekBalance(facts.baseURL(), apiKey, AbortSignal.timeout(BALANCE_TIMEOUT_MS));
2103
+ cached = {
2104
+ at: Date.now(),
2105
+ value
2106
+ };
2107
+ return value;
2108
+ } finally {
2109
+ inflight = void 0;
2110
+ }
2111
+ })();
2112
+ inflight = run;
2113
+ return run;
2114
+ };
2115
+ }
2116
+ /**
2117
+ * Register the `billing` Remote under the `billing` namespace. Assembly only:
2118
+ * facts resolve once, each loader owns its caches, and the gateway receives
2119
+ * the bound thunks.
2120
+ * @param ctx - owning plugin context.
2121
+ * @param config - validated plugin config.
2122
+ */
2123
+ function apply(ctx, config) {
2124
+ const facts = resolveFacts(ctx, config);
2125
+ const unit = billingTodaySpendDefinition(facts.billing, facts.catalog);
2126
+ const ensureUnit = createUnitRegistrar(ctx, unit);
2127
+ ensureUnit();
2128
+ ctx.on("session/created", ensureUnit);
2129
+ const fetchBalance = createBalanceFetcher(ctx, facts);
2130
+ const fetchSessionSpend = createSessionSpendFetcher(ctx, facts);
2131
+ const { fetchTodaySpend, fetchTodaySessionsSpend } = createTodaySpendLoaders(ctx, facts, unit, ensureUnit);
2132
+ const fetchTurnSpend = createTurnSpendFetcher(ctx, facts);
2133
+ const fetchTurnSpends = createTurnSpendsFetcher(ctx, facts);
1503
2134
  new DeepSeekBalanceGateway(ctx, {
1504
2135
  fetchBalance,
1505
2136
  fetchSessionSpend,
1506
2137
  fetchTodaySpend,
1507
2138
  fetchTodaySessionsSpend,
1508
- fetchTurnSpend
2139
+ fetchTurnSpend,
2140
+ fetchTurnSpends
1509
2141
  });
1510
2142
  }
1511
2143
  //#endregion
1512
- 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, computeTurnSpend, emptyTodaySpend, fetchDeepSeekBalance, foldBillingUnit, foldOwnBilling, foldSessionTitle, forkBoundaryOf, isPeak, isSeededSession, liveSessionEvents, mergeTodaySpend, name, parseDeepSeekBalance, persistenceInspect, persistenceListSnapshots, priceEvent, resolveBilling };
2144
+ export { BALANCE_CACHE_MS, BALANCE_TIMEOUT_MS, BILLING_UNIT_KEY, BillingFolder, Config, DEFAULT_MODEL_PRICING, DEFAULT_PEAK_HOURS, DeepSeekBalanceGateway, FLASH_SERIES_RATE_CHANGE_AT, PUBLIC_BASE_URL, SESSION_SPEND_CACHE_LIMIT, SESSION_TURN_SPEND_CACHE_LIMIT, SessionTurnSpendFolder, SpendAccumulator, TODAY_SPEND_CACHE_MS, TODAY_SPEND_MAX_EVENTS, TodaySpendCache, TodaySpendScanner, V4_PRO_ROUTE_SWITCH_AT, addEventContribution, apply, applyBillingEvent, beijingDayKey, billingTodaySpendDefinition, computeSessionSpend, computeSessionTurnSpends, computeTodaySpend, computeTurnSpend, emptyBillingFoldState, emptyTodaySpend, fetchDeepSeekBalance, foldBillingUnit, foldOwnBilling, foldSessionTitle, forkBoundaryOf, isPeak, isSeededSession, liveSessionEvents, mergeTodaySpend, name, negateSpend, parseDeepSeekBalance, persistenceInspect, persistenceListSnapshots, priceEvent, priceUsage, resolveBilling, subtractSpend };