@rayadesu/dsh-llm-billing 0.3.7 → 0.3.9

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
@@ -312,6 +337,19 @@ const DEFAULT_MODEL_PRICING = [
312
337
  output: 4.5
313
338
  }
314
339
  },
340
+ {
341
+ model: "deepseek-v4.1-flash-expires-on-0910",
342
+ peak: {
343
+ cacheHitInput: .1,
344
+ cacheMissInput: 3,
345
+ output: 9
346
+ },
347
+ offPeak: {
348
+ cacheHitInput: .05,
349
+ cacheMissInput: 1.5,
350
+ output: 4.5
351
+ }
352
+ },
315
353
  {
316
354
  model: "deepseek-v4-pro",
317
355
  peak: {
@@ -337,6 +375,32 @@ const DEFAULT_MODEL_PRICING = [
337
375
  cacheMissInput: 1.5,
338
376
  output: 4.5
339
377
  }
378
+ },
379
+ {
380
+ model: "mimo-v2.5-pro",
381
+ peak: {
382
+ cacheHitInput: .025,
383
+ cacheMissInput: 3,
384
+ output: 6
385
+ },
386
+ offPeak: {
387
+ cacheHitInput: .025,
388
+ cacheMissInput: 3,
389
+ output: 6
390
+ }
391
+ },
392
+ {
393
+ model: "mimo-v2.5",
394
+ peak: {
395
+ cacheHitInput: .02,
396
+ cacheMissInput: 1,
397
+ output: 2
398
+ },
399
+ offPeak: {
400
+ cacheHitInput: .02,
401
+ cacheMissInput: 1,
402
+ output: 2
403
+ }
340
404
  }
341
405
  ];
342
406
  /**
@@ -361,23 +425,61 @@ function resolveBilling(config) {
361
425
  models
362
426
  };
363
427
  }
428
+ /** Beijing is a fixed UTC+8 offset with no DST. */
429
+ const BEIJING_OFFSET_MS = 288e5;
430
+ /** Milliseconds in one day. */
431
+ const DAY_MS = 864e5;
432
+ /** Epoch day of 1970-01-01 in the civil-date algorithm below. */
433
+ const CIVIL_EPOCH_DAY = 719468;
434
+ /** Two-digit zero pad for a calendar field. */
435
+ function pad2(value) {
436
+ return value < 10 ? `0${value}` : String(value);
437
+ }
438
+ /**
439
+ * Civil date of an epoch day (Howard Hinnant's days-from-civil inverse):
440
+ * pure integer arithmetic, no `Date` allocation and no ISO-string slicing.
441
+ */
442
+ function civilDateOf(epochDay) {
443
+ const shifted = epochDay + CIVIL_EPOCH_DAY;
444
+ const era = Math.floor(shifted / 146097);
445
+ const dayOfEra = shifted - era * 146097;
446
+ const yearOfEra = Math.floor((dayOfEra - Math.floor(dayOfEra / 1460) + Math.floor(dayOfEra / 36524) - Math.floor(dayOfEra / 146096)) / 365);
447
+ const year = yearOfEra + era * 400;
448
+ const dayOfYear = dayOfEra - (365 * yearOfEra + Math.floor(yearOfEra / 4) - Math.floor(yearOfEra / 100));
449
+ const monthPrime = Math.floor((5 * dayOfYear + 2) / 153);
450
+ const month = monthPrime + (monthPrime < 10 ? 3 : -9);
451
+ return {
452
+ year: month <= 2 ? year + 1 : year,
453
+ month,
454
+ day: dayOfYear - Math.floor((153 * monthPrime + 2) / 5) + 1
455
+ };
456
+ }
364
457
  /**
365
- * Derive the Beijing hour, weekday, and calendar-day key of one timestamp from
366
- * a single shifted `Date` — every timezone-sensitive read shares this one
367
- * implementation, so the pieces cannot drift apart.
458
+ * Derive the Beijing hour, weekday, and calendar-day key of one timestamp with
459
+ * pure integer arithmetic — every timezone-sensitive read shares this one
460
+ * implementation, so the pieces cannot drift apart. Callers that filter by
461
+ * day and then price the same event reuse the returned view, so each event is
462
+ * parsed exactly once. (The hot fold path runs this per committed event; the
463
+ * previous `Date` + `toISOString().slice()` version allocated a `Date` and a
464
+ * 24-character string per call.)
368
465
  * @param time - epoch milliseconds.
466
+ * @throws {RangeError} when `time` is not a finite number.
369
467
  */
370
- function beijingParts(time) {
371
- const shifted = new Date(time + 288e5);
468
+ function beijingPartsOf(time) {
469
+ if (!Number.isFinite(time)) throw new RangeError(`billing: event time is not finite (${String(time)})`);
470
+ const shifted = time + BEIJING_OFFSET_MS;
471
+ const epochDay = Math.floor(shifted / DAY_MS);
472
+ const msOfDay = shifted - epochDay * DAY_MS;
473
+ const civil = civilDateOf(epochDay);
372
474
  return {
373
- hour: shifted.getUTCHours(),
374
- weekday: shifted.getUTCDay(),
375
- dayKey: shifted.toISOString().slice(0, 10)
475
+ hour: Math.floor(msOfDay / 36e5),
476
+ weekday: ((epochDay + 4) % 7 + 7) % 7,
477
+ dayKey: `${civil.year}-${pad2(civil.month)}-${pad2(civil.day)}`
376
478
  };
377
479
  }
378
480
  /** The Beijing (Asia/Shanghai, UTC+8, no DST) calendar-day key of a timestamp. */
379
481
  function beijingDayKey(now) {
380
- return beijingParts(now.getTime()).dayKey;
482
+ return beijingPartsOf(now.getTime()).dayKey;
381
483
  }
382
484
  /**
383
485
  * The durable inherited-prefix boundary of one session: the number of leading
@@ -417,7 +519,7 @@ function isPeakParts(billing, hour, weekday) {
417
519
  * @returns true during a weekday peak hour.
418
520
  */
419
521
  function isPeak(billing, now) {
420
- const { hour, weekday } = beijingParts(now.getTime());
522
+ const { hour, weekday } = beijingPartsOf(now.getTime());
421
523
  return isPeakParts(billing, hour, weekday);
422
524
  }
423
525
  /**
@@ -426,32 +528,57 @@ function isPeak(billing, now) {
426
528
  * only; weekends are off-peak). Each `assistant/message` event with usage
427
529
  * contributes cache-hit input, cache-miss input (uncached input plus cache
428
530
  * writes), and output (reasoning included) tokens at the rate of its own
429
- * timestamp; a model with usage but no pricing row contributes nothing (the
430
- * published table prices only the two V4 rows).
531
+ * timestamp; a model with usage but no pricing row contributes nothing.
431
532
  * @param event - the event to price.
432
533
  * @param billing - resolved pricing with peak-hour windows.
433
534
  * @param names - model id → display label.
434
535
  * @returns the priced contribution, or `undefined` when the event has no priced usage.
435
536
  */
436
537
  function priceEvent(event, billing, names) {
538
+ return priceEventAt(beijingPartsOf(event.time), event, billing, names);
539
+ }
540
+ /**
541
+ * Price one event at the official per-model rates using a precomputed
542
+ * Beijing-time view — the day-filtering and pricing of one event share a
543
+ * single timezone parse (see {@link beijingPartsOf}). Semantics are identical
544
+ * to {@link priceEvent}.
545
+ * @param parts - the event's Beijing-time view.
546
+ * @param event - the event to price.
547
+ * @param billing - resolved pricing with peak-hour windows.
548
+ * @param names - model id → display label.
549
+ * @returns the priced contribution, or `undefined` when the event has no priced usage.
550
+ */
551
+ function priceEventAt(parts, event, billing, names) {
437
552
  if (event.type !== "assistant/message") return void 0;
438
553
  const reported = event.data.usage;
439
554
  if (reported === void 0) return void 0;
440
- const model = event.data.message.source.model;
555
+ return priceUsage(parts, reported, event.data.message.source.model, billing, names);
556
+ }
557
+ /**
558
+ * Price one provider-reported usage sample for one model at the rates of the
559
+ * sample's own Beijing-time hour and weekday. `undefined` when the model has
560
+ * no pricing row.
561
+ * @param parts - the sample's Beijing-time view.
562
+ * @param usage - the reported token buckets.
563
+ * @param model - the wire model id the sample belongs to.
564
+ * @param billing - resolved pricing with peak-hour windows.
565
+ * @param names - model id → display label.
566
+ * @returns the priced contribution, or `undefined` when the model has no rate row.
567
+ */
568
+ function priceUsage(parts, usage, model, billing, names) {
441
569
  const pricing = billing.models.get(model);
442
570
  if (pricing === void 0) return void 0;
443
- const { hour, weekday, dayKey } = beijingParts(event.time);
444
- const peak = isPeakParts(billing, hour, weekday);
571
+ const peak = isPeakParts(billing, parts.hour, parts.weekday);
445
572
  const price = peak ? pricing.peak : pricing.offPeak;
446
- const hit = reported.cacheReadTokens ?? 0;
447
- const miss = reported.inputTokens + (reported.cacheWriteTokens ?? 0);
448
- const output = reported.outputTokens;
573
+ const hit = usage.cacheReadTokens ?? 0;
574
+ const miss = usage.inputTokens + (usage.cacheWriteTokens ?? 0);
575
+ const output = usage.outputTokens;
449
576
  const hitCost = hit * price.cacheHitInput / 1e6;
450
577
  const missCost = miss * price.cacheMissInput / 1e6;
451
578
  const outputCost = output * price.output / 1e6;
452
579
  const cost = hitCost + missCost + outputCost;
453
580
  return {
454
- dayKey,
581
+ dayKey: parts.dayKey,
455
582
  model,
456
583
  displayName: names.get(model) ?? model,
457
584
  cost,
@@ -530,6 +657,192 @@ var SpendAccumulator = class {
530
657
  };
531
658
  }
532
659
  };
660
+ /** The additive inverse of one spend (pure): used to replace a priced sample. */
661
+ function negateSpend(spend) {
662
+ const negate = (value) => -value;
663
+ return {
664
+ total: negate(spend.total),
665
+ models: spend.models.map((row) => ({
666
+ ...row,
667
+ cost: negate(row.cost),
668
+ peakCost: negate(row.peakCost),
669
+ offPeakCost: negate(row.offPeakCost),
670
+ cacheHitInputTokens: negate(row.cacheHitInputTokens),
671
+ cacheMissInputTokens: negate(row.cacheMissInputTokens),
672
+ outputTokens: negate(row.outputTokens),
673
+ cacheHitInputCost: negate(row.cacheHitInputCost),
674
+ cacheMissInputCost: negate(row.cacheMissInputCost),
675
+ outputCost: negate(row.outputCost)
676
+ }))
677
+ };
678
+ }
679
+ /**
680
+ * Subtract one spend from another (pure). Rows that cancel out completely are
681
+ * dropped so a replaced sample leaves no zero row behind.
682
+ * @param target - the spend to subtract from.
683
+ * @param source - the spend to remove.
684
+ * @returns the difference.
685
+ */
686
+ function subtractSpend(target, source) {
687
+ const rows = /* @__PURE__ */ new Map();
688
+ for (const row of target.models) rows.set(row.model, row);
689
+ for (const row of source.models) {
690
+ const existing = rows.get(row.model);
691
+ if (existing === void 0) continue;
692
+ const next = mergeModelRows(existing, negateSpend({
693
+ total: 0,
694
+ models: [row]
695
+ }).models[0]);
696
+ if (next.cost === 0 && next.cacheHitInputTokens === 0 && next.cacheMissInputTokens === 0 && next.outputTokens === 0) rows.delete(row.model);
697
+ else rows.set(row.model, next);
698
+ }
699
+ return {
700
+ total: target.total - source.total,
701
+ models: [...rows.values()]
702
+ };
703
+ }
704
+ /** The empty fold state for one fork boundary. */
705
+ function emptyBillingFoldState(inheritedEventCount = 0) {
706
+ return {
707
+ dayKey: "",
708
+ spend: emptyTodaySpend(),
709
+ session: emptyTodaySpend(),
710
+ inheritedEventCount,
711
+ model: "",
712
+ last: null
713
+ };
714
+ }
715
+ /** Whether an unknown value looks like a provider usage report. */
716
+ function isTokenUsage(value) {
717
+ if (typeof value !== "object" || value === null) return false;
718
+ const candidate = value;
719
+ return typeof candidate.inputTokens === "number" && typeof candidate.outputTokens === "number";
720
+ }
721
+ /**
722
+ * The last `usage` sample embedded in an event's stream, if any. `assistant/
723
+ * attempt` and the embedded streams are newer than the plugin's npm baseline,
724
+ * so the stream is read structurally (a failed/retried attempt reports its
725
+ * usage only there).
726
+ */
727
+ function streamUsageOf(event) {
728
+ const stream = event.data === void 0 ? void 0 : event.data.stream;
729
+ if (!Array.isArray(stream)) return void 0;
730
+ for (let index = stream.length - 1; index >= 0; index -= 1) {
731
+ const chunk = stream[index]?.chunk;
732
+ if (chunk === void 0 || chunk.type !== "usage") continue;
733
+ return isTokenUsage(chunk.usage) ? chunk.usage : void 0;
734
+ }
735
+ }
736
+ /** The contribution as a one-row spend (the shape a sample keeps for replacement). */
737
+ function contributionSpend(priced) {
738
+ return {
739
+ total: priced.cost,
740
+ models: [contributionModel(priced)]
741
+ };
742
+ }
743
+ /**
744
+ * Fold one committed event into a session's billed-spend state.
745
+ *
746
+ * Priced samples come from `assistant/message` (its own reported usage, or the
747
+ * stream's last usage chunk) and `assistant/attempt` (the stream's last usage
748
+ * chunk, priced with the model of the latest `request/header`, since an
749
+ * attempt carries no route). A sample for the same `(turn, step)` replaces the
750
+ * previous one; `llm/retry-started` closes the replacement slot so a retried
751
+ * attempt adds. Every other event is inert and returns the same state
752
+ * reference.
753
+ * @param state - the previous fold state.
754
+ * @param event - the committed event.
755
+ * @param billing - resolved pricing with peak-hour windows.
756
+ * @param names - model id → display label.
757
+ * @returns the next state (the same reference when nothing was priced).
758
+ */
759
+ function applyBillingEvent(state, event, billing, names) {
760
+ if (event.seq < state.inheritedEventCount) return state;
761
+ const type = event.type;
762
+ if (type === "request/header") {
763
+ const model = event.data?.header?.config?.model;
764
+ return typeof model === "string" && model.length > 0 && model !== state.model ? {
765
+ ...state,
766
+ model
767
+ } : state;
768
+ }
769
+ const data = event.data;
770
+ if (type === "llm/retry-started") {
771
+ if (typeof data?.turn !== "number" || typeof data.step !== "number") return state;
772
+ const last = state.last;
773
+ if (last === null || last.turn !== data.turn || last.step !== data.step) return state;
774
+ return {
775
+ ...state,
776
+ last: null
777
+ };
778
+ }
779
+ if (type !== "assistant/message" && type !== "assistant/attempt") return state;
780
+ const usage = (type === "assistant/message" ? data?.usage : void 0) ?? streamUsageOf(event);
781
+ if (!isTokenUsage(usage)) return state;
782
+ const model = type === "assistant/message" ? data?.message?.source?.model : state.model;
783
+ if (typeof model !== "string" || model.length === 0) return state;
784
+ const priced = priceUsage(beijingPartsOf(event.time), usage, model, billing, names);
785
+ if (priced === void 0) return state;
786
+ let session = state.session;
787
+ let spend = state.spend;
788
+ let dayKey = state.dayKey;
789
+ const last = state.last;
790
+ const turn = typeof data?.turn === "number" ? data.turn : 0;
791
+ const step = typeof data?.step === "number" ? data.step : 0;
792
+ if (last !== null && last.turn === turn && last.step === step) {
793
+ session = subtractSpend(session, last.spend);
794
+ if (last.dayKey === dayKey) spend = subtractSpend(spend, last.spend);
795
+ }
796
+ session = addEventContribution(session, priced);
797
+ if (dayKey === priced.dayKey) spend = addEventContribution(spend, priced);
798
+ else if (dayKey === "" || priced.dayKey > dayKey) {
799
+ dayKey = priced.dayKey;
800
+ spend = addEventContribution(emptyTodaySpend(), priced);
801
+ }
802
+ return {
803
+ ...state,
804
+ dayKey,
805
+ spend,
806
+ session,
807
+ last: {
808
+ turn,
809
+ step,
810
+ dayKey: priced.dayKey,
811
+ spend: contributionSpend(priced)
812
+ }
813
+ };
814
+ }
815
+ /**
816
+ * Mutable wrapper over {@link applyBillingEvent} for the pure pricing paths:
817
+ * feed events in order, read the folded spend.
818
+ */
819
+ var BillingFolder = class {
820
+ billing;
821
+ state;
822
+ /**
823
+ * @param billing - resolved pricing with peak-hour windows.
824
+ * @param catalog - model display rows, in presentation order.
825
+ * @param inheritedEventCount - fork boundary to skip (default 0).
826
+ */
827
+ constructor(billing, catalog, inheritedEventCount = 0) {
828
+ this.billing = billing;
829
+ this.names = new Map(catalog.map((model) => [model.id, model.name]));
830
+ this.state = emptyBillingFoldState(inheritedEventCount);
831
+ }
832
+ names;
833
+ /** Fold one event. */
834
+ add(event) {
835
+ this.state = applyBillingEvent(this.state, event, this.billing, this.names);
836
+ }
837
+ /** Fold every event, in order. */
838
+ addAll(events) {
839
+ for (const event of events) this.add(event);
840
+ }
841
+ /** The folded state (live reference; do not mutate). */
842
+ get fold() {
843
+ return this.state;
844
+ }
845
+ };
533
846
  /**
534
847
  * Merge one priced event's contribution into an accumulator spend (pure:
535
848
  * returns a new spend, never mutates its input).
@@ -566,34 +879,11 @@ function mergeTodaySpend(target, source) {
566
879
  };
567
880
  }
568
881
  /**
569
- * Price a set of billed events at the official per-model rates, applying the
570
- * peak/off-peak table per event by its Beijing-time hour and weekday (peak
571
- * windows apply Monday–Friday only; weekends are off-peak). Each
572
- * `assistant/message` event with usage contributes cache-hit input, cache-miss
573
- * input (uncached input plus cache writes), and output (reasoning included)
574
- * tokens at the rate of its own timestamp, with the three component costs
575
- * carried separately; a model with usage but no pricing row is omitted (the
576
- * published table prices only the two V4 rows).
577
- * @param events - the events to price.
578
- * @param billing - resolved pricing with peak-hour windows.
579
- * @param names - model id → display label.
580
- * @param dayKey - when provided, only events on this Beijing calendar day contribute.
581
- * @param startSeq - when provided, only events with `seq >= startSeq` contribute
582
- * (a forked session's inherited prefix, `seq < startSeq`, is skipped).
583
- * @returns the total cost plus one row per priced model.
584
- */
585
- function priceEvents(events, billing, names, dayKey, startSeq = 0) {
586
- const accumulator = new SpendAccumulator();
587
- for (const event of events) {
588
- if (event.seq < startSeq) continue;
589
- const priced = priceEvent(event, billing, names);
590
- if (priced === void 0 || dayKey !== void 0 && priced.dayKey !== dayKey) continue;
591
- accumulator.add(priced);
592
- }
593
- return accumulator.finish();
594
- }
595
- /**
596
- * Price one session's complete event log at the official per-model rates.
882
+ * Price one session's complete event log at the official per-model rates,
883
+ * with DSH's attempt semantics: every provider-reported sample (an
884
+ * `assistant/message`'s usage, or an `assistant/attempt`'s stream usage)
885
+ * contributes, a later sample for the same `(turn, step)` replaces the earlier
886
+ * one, and `llm/retry-started` makes the retried attempt add.
597
887
  * @param events - one session's complete event log.
598
888
  * @param billing - resolved pricing with peak-hour windows.
599
889
  * @param catalog - model display rows, in presentation order.
@@ -604,16 +894,18 @@ function priceEvents(events, billing, names, dayKey, startSeq = 0) {
604
894
  * @returns the session's total cost plus one row per priced model.
605
895
  */
606
896
  function computeSessionSpend(events, billing, catalog, startSeq = 0) {
607
- return priceEvents(events, billing, new Map(catalog.map((model) => [model.id, model.name])), void 0, startSeq);
897
+ const folder = new BillingFolder(billing, catalog, startSeq);
898
+ folder.addAll(events);
899
+ return folder.fold.session;
608
900
  }
609
901
  /**
610
- * Price one completed Turn's billed usage at the official per-model rates,
611
- * identified by its closing assistant message id. The turn's events are those
612
- * between its `turn/start` and `turn/end` (both matched by the message's own
613
- * turn coordinate); each priced event applies the peak/off-peak table by its
614
- * Beijing-time hour and weekday. A message that cannot be located, a turn
615
- * without bracketing `turn/start` / `turn/end` events (for example after
616
- * compaction), or a session with no priced usage prices to zero.
902
+ * Price one completed Turn's billed usage, identified by its closing
903
+ * assistant message id. The turn's events are those between its `turn/start`
904
+ * and `turn/end` (both matched by the message's own turn coordinate), priced
905
+ * with the same attempt semantics as {@link computeSessionSpend}. A message
906
+ * that cannot be located, a turn without bracketing `turn/start` / `turn/end`
907
+ * events (for example after compaction), or a session with no priced usage
908
+ * prices to zero.
617
909
  * @param events - one session's complete event log.
618
910
  * @param billing - resolved pricing with peak-hour windows.
619
911
  * @param catalog - model display rows, in presentation order.
@@ -621,7 +913,18 @@ function computeSessionSpend(events, billing, catalog, startSeq = 0) {
621
913
  * @returns the turn's total cost in CNY.
622
914
  */
623
915
  function computeTurnSpend(events, billing, catalog, messageId) {
624
- const names = new Map(catalog.map((model) => [model.id, model.name]));
916
+ return { total: turnCostOf(events, billing, catalog, messageId) };
917
+ }
918
+ /**
919
+ * The total cost of the Turn containing `messageId`, folded with the shared
920
+ * attempt semantics (see {@link applyBillingEvent}).
921
+ * @param events - one session's complete event log.
922
+ * @param billing - resolved pricing with peak-hour windows.
923
+ * @param catalog - model display rows, in presentation order.
924
+ * @param messageId - one assistant message inside the Turn.
925
+ * @returns the Turn's total cost in CNY, or 0 when the Turn cannot be located.
926
+ */
927
+ function turnCostOf(events, billing, catalog, messageId) {
625
928
  let turn;
626
929
  for (const event of events) {
627
930
  if (event.type !== "assistant/message") continue;
@@ -629,8 +932,8 @@ function computeTurnSpend(events, billing, catalog, messageId) {
629
932
  turn = event.data.turn;
630
933
  break;
631
934
  }
632
- if (turn === void 0) return { total: 0 };
633
- const accumulator = new SpendAccumulator();
935
+ if (turn === void 0) return 0;
936
+ const folder = new BillingFolder(billing, catalog);
634
937
  let active = false;
635
938
  for (const event of events) {
636
939
  if (event.type === "turn/start" && event.data.turn === turn) {
@@ -639,16 +942,116 @@ function computeTurnSpend(events, billing, catalog, messageId) {
639
942
  }
640
943
  if (event.type === "turn/end" && event.data.turn === turn) break;
641
944
  if (!active) continue;
642
- const priced = priceEvent(event, billing, names);
643
- if (priced !== void 0) accumulator.add(priced);
945
+ folder.add(event);
946
+ }
947
+ return folder.fold.session.total;
948
+ }
949
+ /**
950
+ * Incremental single-pass fold of one session's completed-Turn costs, keyed by
951
+ * the id of every assistant message inside each Turn. Feeding the fold only
952
+ * the appended tail keeps a growing session's map current in O(new events)
953
+ * instead of re-scanning the whole log per message.
954
+ *
955
+ * Semantics are exactly {@link computeTurnSpend}'s: a Turn is the
956
+ * `turn/start`..`turn/end` range (matched by the event's own turn coordinate),
957
+ * every priced event inside it contributes at its own timestamp's rate, and a
958
+ * message outside any bracket contributes nothing.
959
+ */
960
+ var SessionTurnSpendFolder = class {
961
+ billing;
962
+ catalog;
963
+ rows = [];
964
+ ids = [];
965
+ /** Events of the open Turn, folded with the shared attempt semantics on close. */
966
+ events = [];
967
+ open = false;
968
+ /** Events already fed; a shorter log resets the fold. */
969
+ cursor = 0;
970
+ /**
971
+ * @param billing - resolved pricing with peak-hour windows.
972
+ * @param catalog - model display rows, in presentation order.
973
+ */
974
+ constructor(billing, catalog) {
975
+ this.billing = billing;
976
+ this.catalog = catalog;
977
+ }
978
+ /** How many events have been folded so far (the host's incremental cursor). */
979
+ get processed() {
980
+ return this.cursor;
981
+ }
982
+ /**
983
+ * Fold every event from the cursor to the end of the log. A log shorter than
984
+ * the cursor (rewritten session) restarts the fold from an empty state.
985
+ * @param events - the session's complete event log, in seq order.
986
+ */
987
+ feed(events) {
988
+ if (events.length < this.cursor) this.reset();
989
+ for (let index = this.cursor; index < events.length; index += 1) {
990
+ const event = events[index];
991
+ if (event.type === "turn/start") {
992
+ this.open = true;
993
+ this.ids = [];
994
+ this.events = [];
995
+ continue;
996
+ }
997
+ if (event.type === "turn/end") {
998
+ if (this.open) {
999
+ const folder = new BillingFolder(this.billing, this.catalog);
1000
+ folder.addAll(this.events);
1001
+ const total = folder.fold.session.total;
1002
+ for (const messageId of this.ids) this.rows.push({
1003
+ messageId,
1004
+ total
1005
+ });
1006
+ }
1007
+ this.open = false;
1008
+ this.ids = [];
1009
+ this.events = [];
1010
+ continue;
1011
+ }
1012
+ if (!this.open) continue;
1013
+ if (event.type === "assistant/message") this.ids.push(event.data.message.id);
1014
+ this.events.push(event);
1015
+ }
1016
+ this.cursor = events.length;
1017
+ }
1018
+ /** The folded map; the fold stays usable afterwards. */
1019
+ finish() {
1020
+ return { turns: [...this.rows] };
644
1021
  }
645
- return { total: accumulator.finish().total };
1022
+ /** Drop the fold state so the next feed starts from the log's beginning. */
1023
+ reset() {
1024
+ this.rows.length = 0;
1025
+ this.ids = [];
1026
+ this.events = [];
1027
+ this.open = false;
1028
+ this.cursor = 0;
1029
+ }
1030
+ };
1031
+ /**
1032
+ * Price every completed Turn of one session in a single pass (the pure
1033
+ * equivalent of {@link SessionTurnSpendFolder}).
1034
+ * @param events - one session's complete event log.
1035
+ * @param billing - resolved pricing with peak-hour windows.
1036
+ * @param catalog - model display rows, in presentation order.
1037
+ * @returns one row per assistant message inside a completed Turn, in log order.
1038
+ */
1039
+ function computeSessionTurnSpends(events, billing, catalog) {
1040
+ const folder = new SessionTurnSpendFolder(billing, catalog);
1041
+ folder.feed(events);
1042
+ return folder.finish();
646
1043
  }
647
1044
  /**
648
- * Price every event whose Beijing-time calendar day is the day of `now`,
649
- * aggregating across every session's event log. Events from other Beijing
650
- * days are ignored, so a caller passes the concatenated logs of all sessions.
651
- * @param events - every session's complete event log, concatenated.
1045
+ * Price one session's log for the Beijing-time calendar day of `now`. Events
1046
+ * after the reference day are ignored; the fold's latest-day state then
1047
+ * answers the query exactly (empty when the session's latest priced day is not
1048
+ * the reference day). Pricing follows {@link applyBillingEvent} (attempt
1049
+ * samples with same-step replacement).
1050
+ *
1051
+ * The fold's `(turn, step)` replacement slot is per session, so callers must
1052
+ * pass ONE session's log; aggregate across sessions with
1053
+ * {@link mergeTodaySpend}.
1054
+ * @param events - one session's complete event log.
652
1055
  * @param billing - resolved pricing with peak-hour windows.
653
1056
  * @param catalog - model display rows, in presentation order.
654
1057
  * @param now - the reference moment whose Beijing-time calendar day is "today".
@@ -656,24 +1059,31 @@ function computeTurnSpend(events, billing, catalog, messageId) {
656
1059
  */
657
1060
  function computeTodaySpend(events, billing, catalog, now = /* @__PURE__ */ new Date()) {
658
1061
  const day = beijingDayKey(now);
659
- return priceEvents(events, billing, new Map(catalog.map((model) => [model.id, model.name])), day);
1062
+ const folder = new BillingFolder(billing, catalog);
1063
+ for (const event of events) {
1064
+ if (beijingPartsOf(event.time).dayKey > day) continue;
1065
+ folder.add(event);
1066
+ }
1067
+ return folder.fold.dayKey === day ? folder.fold.spend : emptyTodaySpend();
660
1068
  }
661
1069
  //#endregion
662
1070
  //#region lib/types/projection.js
663
1071
  /**
664
- * `billingTodaySpend` session-projection unit: per-session, per-Beijing-day
665
- * billed spend, folded eagerly by the DSH projection drive over committed
666
- * session events and checkpointed by the projection cache. The unit keeps only
667
- * the spend of the session's LATEST priced day (events are append-only and
668
- * chronological, so a day strictly older than the state's day never returns);
669
- * the aggregate "today" read sums the units whose `dayKey` matches the current
670
- * Beijing day — zero full-log scans once the fold is warm.
1072
+ * `billingTodaySpend` session-projection unit: per-session billed spend,
1073
+ * folded eagerly by the DSH projection drive over committed session events and
1074
+ * checkpointed by the projection cache. The state keeps the session's LATEST
1075
+ * priced Beijing day, its whole-session total, the fork boundary, the latest
1076
+ * request model, and the last priced attempt sample (DSH's same-step
1077
+ * replacement rule); the aggregate "today" read sums the units whose `dayKey`
1078
+ * matches the current Beijing day — zero full-log scans once the fold is warm.
671
1079
  *
672
- * The unit's fold shares {@link priceEvent} with the events-scan paths
673
- * (`computeTodaySpend`), so both price with the same table. The unit is
1080
+ * The unit's fold IS the shared pricing fold ({@link applyBillingEvent}), so
1081
+ * the projection path and the events-scan paths cannot drift. The unit is
674
1082
  * client-visible (`wire` = identity) because the persisted-cache read ladder
675
- * (`sessionProjectionCache.coldSnapshot` / registry `restore`) serves only
676
- * wired units; the wire value is the state itself.
1083
+ * (`sessionProjectionCache.cachedSnapshot` / registry `restore`) serves only
1084
+ * wired units, and because the browser half reads this value through
1085
+ * `useProjection` instead of polling a Remote; the wire value is the state
1086
+ * itself.
677
1087
  * @module @rayadesu/dsh-llm-billing/projection
678
1088
  */
679
1089
  /** The projection key this unit owns. */
@@ -697,7 +1107,16 @@ const todaySpendSchema = z$1.object({
697
1107
  }).strict();
698
1108
  const billingUnitSchema = z$1.object({
699
1109
  dayKey: z$1.string(),
700
- spend: todaySpendSchema
1110
+ spend: todaySpendSchema,
1111
+ session: todaySpendSchema,
1112
+ inheritedEventCount: z$1.number().int().nonnegative(),
1113
+ model: z$1.string(),
1114
+ last: z$1.object({
1115
+ turn: z$1.number().int().nonnegative(),
1116
+ step: z$1.number().int().nonnegative(),
1117
+ dayKey: z$1.string(),
1118
+ spend: todaySpendSchema
1119
+ }).strict().nullable()
701
1120
  }).strict();
702
1121
  /**
703
1122
  * Build the `billingTodaySpend` unit for one resolved pricing table. The
@@ -715,25 +1134,10 @@ function billingTodaySpendDefinition(billing, catalog) {
715
1134
  const names = new Map(catalog.map((model) => [model.id, model.name]));
716
1135
  return {
717
1136
  key: BILLING_UNIT_KEY,
718
- stateVersion: 1,
1137
+ stateVersion: 3,
719
1138
  stateSchema: billingUnitSchema,
720
- init: () => ({
721
- dayKey: "",
722
- spend: emptyTodaySpend()
723
- }),
724
- apply: (state, event) => {
725
- const priced = priceEvent(event, billing, names);
726
- if (priced === void 0) return state;
727
- if (state.dayKey === priced.dayKey) return {
728
- dayKey: state.dayKey,
729
- spend: addEventContribution(state.spend, priced)
730
- };
731
- if (state.dayKey !== "" && priced.dayKey < state.dayKey) return state;
732
- return {
733
- dayKey: priced.dayKey,
734
- spend: addEventContribution(emptyTodaySpend(), priced)
735
- };
736
- },
1139
+ init: (_header, inheritedEventCount) => emptyBillingFoldState(Number(inheritedEventCount ?? 0)),
1140
+ apply: (state, event) => applyBillingEvent(state, event, billing, names),
737
1141
  wire: {
738
1142
  viewSchema: billingUnitSchema,
739
1143
  view: (state) => state
@@ -774,11 +1178,13 @@ function foldOwnBilling(unit, events, seedLength = 0) {
774
1178
  * compute the aggregate behind a cache miss:
775
1179
  *
776
1180
  * - projection path (plan C): live sessions read their eagerly folded
777
- * `billingTodaySpend` projection cell; cold sessions resolve through the
778
- * projection-cache ladder (cached row + tail replay + registry restore,
779
- * with write-back) or, without the cache service, one detached local fold
780
- * over a full `inspect`. Persisted revisions gate every cold read, so a
781
- * session whose log did not change since the last resolution costs nothing.
1181
+ * `billingTodaySpend` projection cell; cold sessions are answered from the
1182
+ * zero-I/O projection-cache row whenever that row's own day is not the
1183
+ * queried one, and otherwise resolved through one detached local fold over
1184
+ * a full `inspect`. Persisted revisions gate every cold read, so a session
1185
+ * whose log did not change since the last resolution costs nothing — and a
1186
+ * failed resolution is remembered by revision instead of being retried on
1187
+ * every scan.
782
1188
  * - events path (plans A2/A3): collect and price only today's events in one
783
1189
  * pass (per-event Beijing-day filter during collection) with a hard cap,
784
1190
  * skipping sessions whose persisted revision is unchanged since the last
@@ -790,14 +1196,15 @@ function foldOwnBilling(unit, events, seedLength = 0) {
790
1196
  * unchanged log provably cannot change the aggregate.
791
1197
  *
792
1198
  * Forked sessions never double-count: a fork child's log opens with a
793
- * verbatim copy of its source session's events (its inherited boundary),
794
- * so the scanner prices only the child's OWN events on every path — the
795
- * projection path bypasses the eager cell for a seeded session and folds its
796
- * own events instead (the cell covers the inherited prefix too), and the cold
797
- * ladder skips the projection cache for a seeded session (its cached row
798
- * predates the boundary and covers inherited events). The boundary is the
799
- * durable session state, read across both DSH runtime families a resumed
800
- * fork child keeps its original boundary and an unseeded session stays at 0.
1199
+ * verbatim copy of its source session's events (its inherited boundary), so
1200
+ * the scanner prices only the child's OWN events on every path. The
1201
+ * `billingTodaySpend` unit is boundary-aware (its state carries the inherited
1202
+ * cut, and `apply` skips events below it), so the eager cell is correct for a
1203
+ * fork child; the cold path skips the projection cache for a seeded session
1204
+ * (its cached row may predate the boundary) and folds its own events with the
1205
+ * durable cut instead. The boundary is the durable session state, read across
1206
+ * both DSH runtime families a resumed fork child keeps its original
1207
+ * boundary and an unseeded session stays at 0.
801
1208
  *
802
1209
  * The live `Session` log surface changed in 0.1.2-alpha.4: `Session.events`
803
1210
  * was removed and replaced by `Session.snapshotEvents()` / `ownEvents()`, and
@@ -836,6 +1243,15 @@ function liveSessionEvents(session) {
836
1243
  if (session.snapshotEvents !== void 0) return session.snapshotEvents();
837
1244
  throw new Error("llm-billing: session log surface is neither Session.events nor Session.snapshotEvents");
838
1245
  }
1246
+ /**
1247
+ * Unwrap a handle read across both return shapes.
1248
+ * @param read - the handle's read result.
1249
+ * @returns the event array.
1250
+ */
1251
+ function handleReadEvents(read) {
1252
+ if (Array.isArray(read)) return read;
1253
+ return read.events;
1254
+ }
839
1255
  function isHandlePersistence(persistence) {
840
1256
  return typeof persistence.open === "function";
841
1257
  }
@@ -862,7 +1278,7 @@ async function persistenceInspect(persistence, id) {
862
1278
  const handle = await persistence.open(id, "read");
863
1279
  try {
864
1280
  return {
865
- events: await handle.read(),
1281
+ events: handleReadEvents(await handle.read()),
866
1282
  seedLength: forkBoundaryOf(handle)
867
1283
  };
868
1284
  } finally {
@@ -926,7 +1342,7 @@ var TodaySpendCache = class {
926
1342
  const now = this.now();
927
1343
  const dayKey = beijingDayKey(now);
928
1344
  if (!force && this.cachedDayKey === dayKey && this.cachedValue !== void 0 && now.getTime() - this.cachedAt < this.ttlMs) return Promise.resolve(this.cachedValue);
929
- if (!force && this.inFlight !== void 0) return this.inFlight;
1345
+ if (this.inFlight !== void 0) return this.inFlight;
930
1346
  const run = (async () => {
931
1347
  try {
932
1348
  const value = await this.scan(dayKey);
@@ -938,10 +1354,24 @@ var TodaySpendCache = class {
938
1354
  this.inFlight = void 0;
939
1355
  }
940
1356
  })();
941
- if (!force) this.inFlight = run;
1357
+ this.inFlight = run;
942
1358
  return run;
943
1359
  }
944
1360
  };
1361
+ /** Max session-ids kept in the scanner's cold-resolution cache before eviction. */
1362
+ const COLD_RESOLVE_CACHE_LIMIT = 1024;
1363
+ /** Max session-ids kept in the scanner's cold-failure cache before eviction. */
1364
+ const COLD_FAILED_CACHE_LIMIT = 1024;
1365
+ /**
1366
+ * Bounded-map eviction: drop the oldest inserted entry once `size` reached
1367
+ * `limit`. Evicting one entry (instead of clearing) keeps the other sessions'
1368
+ * resolved state warm across scans.
1369
+ */
1370
+ function evictOldest$1(map, limit) {
1371
+ if (map.size < limit) return;
1372
+ const oldest = map.keys().next().value;
1373
+ if (oldest !== void 0) map.delete(oldest);
1374
+ }
945
1375
  /**
946
1376
  * The aggregate computation behind a cache miss. Chooses the projection path
947
1377
  * when the projection registry is composed, the events path otherwise; both
@@ -952,10 +1382,10 @@ var TodaySpendScanner = class {
952
1382
  deps;
953
1383
  /** Cold sessions resolved on the projection path: id → revision + unit state + title. */
954
1384
  coldResolved = /* @__PURE__ */ new Map();
1385
+ /** Cold sessions whose resolution failed: id → revision (retried only when the log changes). */
1386
+ coldFailed = /* @__PURE__ */ new Map();
955
1387
  /** Cold sessions resolved on the events path: id → revision (events were collected). */
956
1388
  lastEventsScan;
957
- /** Live fork children priced on the projection path: id → own-events count + folded state. */
958
- ownStates = /* @__PURE__ */ new Map();
959
1389
  constructor(deps) {
960
1390
  this.deps = deps;
961
1391
  }
@@ -965,9 +1395,7 @@ var TodaySpendScanner = class {
965
1395
  * @returns today's spend across every session.
966
1396
  */
967
1397
  async scan(dayKey) {
968
- if (this.deps.projections?.() === void 0) return this.scanEvents(dayKey);
969
- this.deps.ensureUnit?.();
970
- return this.scanProjections(dayKey);
1398
+ return (await this.scanDetail(dayKey)).aggregate;
971
1399
  }
972
1400
  /**
973
1401
  * Compute today's per-session spend for one Beijing day, sorted by cost
@@ -977,157 +1405,169 @@ var TodaySpendScanner = class {
977
1405
  * @returns today's per-session rows, highest first.
978
1406
  */
979
1407
  async scanSessions(dayKey) {
980
- const rows = this.deps.projections?.() === void 0 ? await this.scanSessionsEvents(dayKey) : await this.scanSessionsProjections(dayKey);
981
- rows.sort((left, right) => right.total - left.total);
982
- return { sessions: rows };
1408
+ return { sessions: (await this.scanDetail(dayKey)).sessions };
1409
+ }
1410
+ /**
1411
+ * Compute the day's aggregate AND its per-session ranking in ONE pass: the
1412
+ * aggregate is the sum of the rows, so the two reads share every session
1413
+ * read, unit fold, and title fold instead of scanning twice. Chooses the
1414
+ * projection path when the projection registry is composed, the events path
1415
+ * otherwise.
1416
+ * @param dayKey - the Beijing-time calendar-day key to aggregate.
1417
+ * @returns the aggregate plus per-session rows sorted by cost descending.
1418
+ */
1419
+ async scanDetail(dayKey) {
1420
+ if (this.deps.projections?.() === void 0) return this.scanDetailEvents(dayKey);
1421
+ this.deps.ensureUnit?.();
1422
+ return this.scanDetailProjections(dayKey);
983
1423
  }
984
1424
  /**
985
- * Resolve one cold session's billing unit state and display title through
986
- * the projection-cache ladder (cached row first, then a detached local
987
- * fold over a full inspect). A cache-served value carries no title (the
988
- * ladder only stores projection values), so such rows report `title: null`
989
- * until the session is inspected again. A SEEDED session (fork child)
990
- * skips the ladder entirely: its cached row was folded over the inherited
1425
+ * Resolve one cold session's billing unit state and display title.
1426
+ *
1427
+ * The zero-I/O projection-cache row answers the query directly whenever its
1428
+ * own latest priced day is NOT the queried day: the row then proves the
1429
+ * session contributed nothing to the queried day, so the log is never read.
1430
+ * When the row IS the queried day (or no usable row exists) the session is
1431
+ * inspected and folded locally, because the row may trail the log (a crash
1432
+ * between the last checkpoint and the session's last event).
1433
+ *
1434
+ * A cache-served value carries no title (the ladder only stores projection
1435
+ * values), so such rows report `title: null`. A SEEDED session (fork child)
1436
+ * skips the cache entirely: its cached row was folded over the inherited
991
1437
  * prefix too, so it always detaches through inspect with the durable
992
1438
  * boundary (the inspect result's inherited count or `meta.seedLength`,
993
1439
  * depending on the runtime family) applied to the local fold.
994
- * @param id - the cold session's id.
995
- * @param seeded - whether the session carries a fork-inherited prefix
996
- * (from the snapshot header: `isSeeded` on 0.1.2-alpha.4+, `seedLength`
997
- * at and before the 0.1.1-rc.2 baseline).
1440
+ * @param header - the listed session header (the cache identity witness).
1441
+ * @param seeded - whether the session carries a fork-inherited prefix.
1442
+ * @param dayKey - the Beijing-time day being aggregated.
998
1443
  * @returns the resolved state and title, or `undefined` when unreadable.
999
1444
  */
1000
- async resolveCold(id, seeded) {
1445
+ async resolveCold(header, seeded, dayKey) {
1001
1446
  const { persistence, projectionCache, logger } = this.deps;
1002
- const persistenceService = persistence?.();
1003
- if (persistenceService === void 0) return void 0;
1004
1447
  if (!seeded) {
1005
1448
  const cache = projectionCache?.();
1006
1449
  if (cache !== void 0) try {
1007
- const value = (await cache.coldSnapshot(id)).values[BILLING_UNIT_KEY];
1008
- if (value !== void 0) return {
1450
+ const value = cache.cachedSnapshot(header, 0, [BILLING_UNIT_KEY])?.values[BILLING_UNIT_KEY];
1451
+ if (value !== void 0 && value.dayKey !== dayKey) return {
1009
1452
  value,
1010
1453
  title: null
1011
1454
  };
1012
1455
  } catch (error) {
1013
- logger.warn(`llm-billing: projection cold read for session ${id} failed: ${String(error)}`);
1456
+ logger.warn(`llm-billing: projection cache read for session ${header.id} failed: ${String(error)}`);
1014
1457
  }
1015
1458
  }
1459
+ const persistenceService = persistence?.();
1460
+ if (persistenceService === void 0) return void 0;
1016
1461
  try {
1017
- const read = await persistenceInspect(persistenceService, id);
1462
+ const read = await persistenceInspect(persistenceService, header.id);
1018
1463
  return {
1019
1464
  value: foldOwnBilling(this.deps.unit, read.events, read.seedLength),
1020
1465
  title: foldSessionTitle(read.events)
1021
1466
  };
1022
1467
  } catch (error) {
1023
- logger.warn(`llm-billing: skipping unreadable session ${id}: ${String(error)}`);
1468
+ logger.warn(`llm-billing: skipping unreadable session ${header.id}: ${String(error)}`);
1024
1469
  return;
1025
1470
  }
1026
1471
  }
1027
1472
  /**
1028
- * Fold one fork child's OWN events (its log minus the inherited prefix)
1029
- * with the billing unit, incrementally: the fold is reused while the log
1030
- * length is unchanged and only the new tail is applied when it grows.
1031
- * @param id - the session id (the own-state cache key).
1032
- * @param events - the session's complete log.
1033
- * @param seedLength - the inherited-prefix boundary.
1034
- * @returns the unit state over the session's own events.
1473
+ * Live-session entries of one projection-path scan: each session with its
1474
+ * eager `billingTodaySpend` cell. The cell is boundary-aware (the unit skips
1475
+ * a fork child's inherited prefix), so a fork child reads the same own-event
1476
+ * spend a non-fork session does.
1035
1477
  */
1036
- ownBillingState(id, events, seedLength) {
1037
- const cached = this.ownStates.get(id);
1038
- const ownCount = events.length - seedLength;
1039
- if (cached !== void 0 && cached.count === ownCount) return cached.state;
1040
- let state;
1041
- if (cached !== void 0 && cached.count < ownCount) {
1042
- state = cached.state;
1043
- for (const event of events) {
1044
- if (event.seq < seedLength + cached.count) continue;
1045
- state = this.deps.unit.apply(state, event);
1046
- }
1047
- } else state = foldOwnBilling(this.deps.unit, events, seedLength);
1048
- this.ownStates.set(id, {
1049
- count: ownCount,
1050
- state
1051
- });
1052
- return state;
1478
+ *liveBillingEntries(store, projections) {
1479
+ for (const session of store.list()) yield {
1480
+ session,
1481
+ state: projections?.stateOf(session, BILLING_UNIT_KEY)
1482
+ };
1053
1483
  }
1054
- /** Projection path: eager cells for live sessions, revision-gated cold ladder for the rest. */
1055
- async scanProjections(dayKey) {
1056
- const { sessions, persistence, projections } = this.deps;
1057
- const projectionsService = projections?.();
1058
- let total = emptyTodaySpend();
1059
- const liveIds = /* @__PURE__ */ new Set();
1060
- if (sessions !== void 0) {
1061
- const store = sessions();
1062
- if (store !== void 0) for (const session of store.list()) {
1063
- liveIds.add(session.id);
1064
- const seedLength = forkBoundaryOf(session);
1065
- const state = seedLength > 0 ? this.ownBillingState(session.id, liveSessionEvents(session), seedLength) : projectionsService?.stateOf(session, BILLING_UNIT_KEY);
1066
- if (state !== void 0 && state.dayKey === dayKey) total = mergeTodaySpend(total, state.spend);
1067
- }
1068
- }
1069
- const persistenceService = persistence?.();
1070
- if (persistenceService === void 0) return total;
1071
- const snapshots = await persistenceListSnapshots(persistenceService);
1484
+ /**
1485
+ * Cold-ladder adopt: for every stored session not live, either the
1486
+ * revision-gated resolution already in {@link coldResolved} is adopted
1487
+ * (unchanged log costs nothing) or the session is queued behind a bounded
1488
+ * parallel fan-out, resolved, remembered, and then adopted. A session whose
1489
+ * resolution failed is remembered too (by revision), so an unreadable log
1490
+ * is not re-read on every scan; a changed revision retries it. One
1491
+ * unreadable session never blanks the whole-day aggregate.
1492
+ * @param liveIds - ids of sessions already folded from the live store.
1493
+ * @param snapshots - stored snapshot list (either runtime family).
1494
+ * @param dayKey - the Beijing-time day being aggregated.
1495
+ * @param adopt - fold one resolved cold session into the scan's result.
1496
+ */
1497
+ async coldAdopt(liveIds, snapshots, dayKey, adopt) {
1498
+ const persistenceAvailable = this.deps.persistence?.() !== void 0;
1072
1499
  const pending = [];
1073
1500
  for (const { header, revision } of snapshots) {
1074
1501
  if (liveIds.has(header.id)) continue;
1075
1502
  const seeded = isSeededSession(header);
1076
1503
  const resolved = this.coldResolved.get(header.id);
1077
1504
  if (resolved !== void 0 && resolved.revision === revision) {
1078
- if (resolved.value.dayKey === dayKey) total = mergeTodaySpend(total, resolved.value.spend);
1505
+ adopt(header.id, resolved);
1079
1506
  continue;
1080
1507
  }
1508
+ if (this.coldFailed.get(header.id) === revision) continue;
1081
1509
  pending.push({
1082
- id: header.id,
1510
+ header,
1083
1511
  revision,
1084
1512
  seeded
1085
1513
  });
1086
1514
  }
1087
- await withConcurrency(pending, 8, async ({ id, revision, seeded }) => {
1088
- const resolved = await this.resolveCold(id, seeded);
1089
- if (resolved !== void 0) this.coldResolved.set(id, {
1090
- revision,
1091
- ...resolved
1092
- });
1515
+ await withConcurrency(pending, 8, async ({ header, revision, seeded }) => {
1516
+ const resolved = await this.resolveCold(header, seeded, dayKey);
1517
+ if (resolved !== void 0) {
1518
+ this.coldFailed.delete(header.id);
1519
+ evictOldest$1(this.coldResolved, COLD_RESOLVE_CACHE_LIMIT);
1520
+ this.coldResolved.set(header.id, {
1521
+ revision,
1522
+ ...resolved
1523
+ });
1524
+ } else if (persistenceAvailable) {
1525
+ evictOldest$1(this.coldFailed, COLD_FAILED_CACHE_LIMIT);
1526
+ this.coldFailed.set(header.id, revision);
1527
+ }
1093
1528
  });
1094
- for (const { id } of pending) {
1095
- const resolved = this.coldResolved.get(id);
1096
- if (resolved !== void 0 && resolved.value.dayKey === dayKey) total = mergeTodaySpend(total, resolved.value.spend);
1529
+ for (const { header } of pending) {
1530
+ const resolved = this.coldResolved.get(header.id);
1531
+ if (resolved !== void 0) adopt(header.id, resolved);
1097
1532
  }
1098
- return total;
1099
1533
  }
1100
1534
  /**
1101
- * Events path: price today's events in a single pass (per-event Beijing-day
1102
- * filter during collection, hard cap), gated by revisions. A fork child's
1103
- * inherited prefix (`seq < seedLength`) is skipped, so each model output is
1104
- * priced only in its source session.
1535
+ * Events-path collection shared by both aggregate and per-session scans:
1536
+ * fold each session's log with the shared pricing fold (attempt samples with
1537
+ * same-step replacement) and announce the session's latest-day spend, gated
1538
+ * by revisions a persisted session whose log did not change since the last
1539
+ * scan is skipped. A fork child's inherited prefix (`seq < seedLength`) is
1540
+ * skipped, so each model output is priced only in its source session. The
1541
+ * hard cap counts the queried day's events; the revision watermark only
1542
+ * advances on a complete pass.
1543
+ * @param dayKey - the Beijing-time calendar-day key to aggregate.
1544
+ * @param onSession - fold one session's state plus its complete log.
1545
+ * @returns whether the hard cap truncated the scan.
1105
1546
  */
1106
- async scanEvents(dayKey) {
1547
+ async collectTodayEvents(dayKey, onSession) {
1107
1548
  const { sessions, persistence, maxEvents, logger, billing, catalog } = this.deps;
1108
- const names = new Map(catalog.map((model) => [model.id, model.name]));
1109
- const accumulator = new SpendAccumulator();
1110
1549
  const liveIds = /* @__PURE__ */ new Set();
1111
1550
  let collected = 0;
1112
1551
  let truncated = false;
1113
- const collect = (events, seedLength) => {
1552
+ const collect = (id, events, seedLength) => {
1553
+ const folder = new BillingFolder(billing, catalog, seedLength);
1114
1554
  for (const event of events) {
1115
- if (event.seq < seedLength) continue;
1116
- if (beijingDayKey(new Date(event.time)) !== dayKey) continue;
1117
- collected += 1;
1118
- if (collected > maxEvents) {
1119
- truncated = true;
1120
- return;
1555
+ if (beijingPartsOf(event.time).dayKey === dayKey) {
1556
+ collected += 1;
1557
+ if (collected > maxEvents) {
1558
+ truncated = true;
1559
+ break;
1560
+ }
1121
1561
  }
1122
- const priced = priceEvent(event, billing, names);
1123
- if (priced !== void 0) accumulator.add(priced);
1562
+ folder.add(event);
1124
1563
  }
1564
+ onSession(id, folder.fold, events);
1125
1565
  };
1126
1566
  if (sessions !== void 0) {
1127
1567
  const store = sessions();
1128
1568
  if (store !== void 0) for (const session of store.list()) {
1129
1569
  liveIds.add(session.id);
1130
- collect(liveSessionEvents(session), forkBoundaryOf(session));
1570
+ collect(session.id, liveSessionEvents(session), forkBoundaryOf(session));
1131
1571
  if (truncated) break;
1132
1572
  }
1133
1573
  }
@@ -1139,7 +1579,7 @@ var TodaySpendScanner = class {
1139
1579
  if (this.lastEventsScan?.get(header.id) === revision) continue;
1140
1580
  try {
1141
1581
  const read = await persistenceInspect(persistenceService, header.id);
1142
- collect(read.events, read.seedLength);
1582
+ collect(header.id, read.events, read.seedLength);
1143
1583
  } catch (error) {
1144
1584
  logger.warn(`llm-billing: skipping unreadable session ${header.id}: ${String(error)}`);
1145
1585
  }
@@ -1148,146 +1588,87 @@ var TodaySpendScanner = class {
1148
1588
  if (!truncated) this.lastEventsScan = new Map(snapshots.map((snapshot) => [snapshot.header.id, snapshot.revision]));
1149
1589
  }
1150
1590
  if (truncated) logger.warn(`llm-billing: today's events exceeded ${maxEvents}; result truncated`);
1151
- return accumulator.finish();
1591
+ return truncated;
1152
1592
  }
1153
1593
  /**
1154
- * Projection-path per-session scan: eager cells for live sessions (title
1155
- * folded from the live log, so a rename is reflected immediately),
1156
- * revision-gated cold ladder for the rest (title resolved on inspect,
1157
- * `null` when served from the projection cache). A fork child's row prices
1158
- * its OWN events only (the cell covers the inherited prefix too).
1594
+ * Projection path, one pass for both outputs: eager cells for live sessions
1595
+ * (title folded from the live log, so a rename is reflected immediately),
1596
+ * revision-gated cold ladder for the rest (title resolved on inspect, `null`
1597
+ * when answered from the projection cache). A fork child's cell covers its
1598
+ * inherited prefix, so its own-events fold supplies both outputs.
1159
1599
  * @param dayKey - the Beijing-time calendar-day key to aggregate.
1160
- * @returns unsorted per-session rows for the day.
1600
+ * @returns the aggregate plus per-session rows, sorted by cost descending.
1161
1601
  */
1162
- async scanSessionsProjections(dayKey) {
1602
+ async scanDetailProjections(dayKey) {
1163
1603
  const { sessions, persistence, projections } = this.deps;
1164
1604
  const projectionsService = projections?.();
1605
+ let aggregate = emptyTodaySpend();
1165
1606
  const rows = /* @__PURE__ */ new Map();
1166
1607
  const liveIds = /* @__PURE__ */ new Set();
1167
1608
  if (sessions !== void 0) {
1168
1609
  const store = sessions();
1169
- if (store !== void 0) for (const session of store.list()) {
1610
+ if (store !== void 0) for (const { session, state } of this.liveBillingEntries(store, projectionsService)) {
1170
1611
  liveIds.add(session.id);
1171
- const seedLength = forkBoundaryOf(session);
1172
- const events = liveSessionEvents(session);
1173
- const state = seedLength > 0 ? this.ownBillingState(session.id, events, seedLength) : projectionsService?.stateOf(session, BILLING_UNIT_KEY);
1174
- if (state !== void 0 && state.dayKey === dayKey) rows.set(session.id, {
1612
+ if (state === void 0 || state.dayKey !== dayKey) continue;
1613
+ aggregate = mergeTodaySpend(aggregate, state.spend);
1614
+ rows.set(session.id, {
1175
1615
  sessionId: session.id,
1176
- title: foldSessionTitle(events),
1616
+ title: foldSessionTitle(liveSessionEvents(session)),
1177
1617
  total: state.spend.total
1178
1618
  });
1179
1619
  }
1180
1620
  }
1181
1621
  const persistenceService = persistence?.();
1182
- if (persistenceService === void 0) return [...rows.values()];
1183
- const snapshots = await persistenceListSnapshots(persistenceService);
1184
- const pending = [];
1185
- for (const { header, revision } of snapshots) {
1186
- if (liveIds.has(header.id)) continue;
1187
- const seeded = isSeededSession(header);
1188
- const resolved = this.coldResolved.get(header.id);
1189
- if (resolved !== void 0 && resolved.revision === revision) {
1190
- if (resolved.value.dayKey === dayKey) rows.set(header.id, {
1191
- sessionId: header.id,
1622
+ if (persistenceService !== void 0) {
1623
+ const snapshots = await persistenceListSnapshots(persistenceService);
1624
+ await this.coldAdopt(liveIds, snapshots, dayKey, (id, resolved) => {
1625
+ if (resolved.value.dayKey !== dayKey) return;
1626
+ aggregate = mergeTodaySpend(aggregate, resolved.value.spend);
1627
+ rows.set(id, {
1628
+ sessionId: id,
1192
1629
  title: resolved.title,
1193
1630
  total: resolved.value.spend.total
1194
1631
  });
1195
- continue;
1196
- }
1197
- pending.push({
1198
- id: header.id,
1199
- revision,
1200
- seeded
1201
- });
1202
- }
1203
- await withConcurrency(pending, 8, async ({ id, revision, seeded }) => {
1204
- const resolved = await this.resolveCold(id, seeded);
1205
- if (resolved !== void 0) this.coldResolved.set(id, {
1206
- revision,
1207
- ...resolved
1208
- });
1209
- });
1210
- for (const { id } of pending) {
1211
- const resolved = this.coldResolved.get(id);
1212
- if (resolved !== void 0 && resolved.value.dayKey === dayKey) rows.set(id, {
1213
- sessionId: id,
1214
- title: resolved.title,
1215
- total: resolved.value.spend.total
1216
1632
  });
1217
1633
  }
1218
- return [...rows.values()];
1634
+ return {
1635
+ aggregate,
1636
+ sessions: sortRows(rows)
1637
+ };
1219
1638
  }
1220
1639
  /**
1221
- * Events-path per-session scan: price today's events in a single pass,
1222
- * accumulating per session (per-event Beijing-day filter during collection,
1223
- * hard cap), gated by revisions. A fork child's inherited prefix
1224
- * (`seq < seedLength`) is skipped, so each row is the session's OWN spend.
1225
- * Titles fold from each session's complete log — a `session/title` event
1226
- * can predate today — so a rename is reflected as soon as the session's log
1227
- * is re-read.
1640
+ * Events path, one pass for both outputs: price today's events (per-event
1641
+ * Beijing-day filter during collection, hard cap), gated by revisions. A
1642
+ * fork child's inherited prefix (`seq < seedLength`) is skipped, so each
1643
+ * model output is priced only in its source session. Titles fold from each
1644
+ * session's complete log — a `session/title` event can predate today — so a
1645
+ * rename is reflected as soon as the session's log is re-read.
1228
1646
  * @param dayKey - the Beijing-time calendar-day key to aggregate.
1229
- * @returns unsorted per-session rows for the day.
1647
+ * @returns the aggregate plus per-session rows, sorted by cost descending.
1230
1648
  */
1231
- async scanSessionsEvents(dayKey) {
1232
- const { sessions, persistence, maxEvents, logger, billing, catalog } = this.deps;
1233
- const names = new Map(catalog.map((model) => [model.id, model.name]));
1234
- const rows = /* @__PURE__ */ new Map();
1235
- const liveIds = /* @__PURE__ */ new Set();
1236
- let collected = 0;
1237
- let truncated = false;
1238
- const collect = (id, events, seedLength) => {
1239
- let row = rows.get(id);
1240
- if (row === void 0) {
1241
- row = {
1242
- title: foldSessionTitle(events),
1243
- total: 0
1244
- };
1245
- rows.set(id, row);
1246
- }
1247
- for (const event of events) {
1248
- if (event.seq < seedLength) continue;
1249
- if (beijingDayKey(new Date(event.time)) !== dayKey) continue;
1250
- collected += 1;
1251
- if (collected > maxEvents) {
1252
- truncated = true;
1253
- return;
1254
- }
1255
- const priced = priceEvent(event, billing, names);
1256
- if (priced !== void 0) row.total += priced.cost;
1257
- }
1649
+ async scanDetailEvents(dayKey) {
1650
+ let aggregate = emptyTodaySpend();
1651
+ const sessions = [];
1652
+ await this.collectTodayEvents(dayKey, (id, fold, events) => {
1653
+ if (fold.dayKey !== dayKey) return;
1654
+ aggregate = mergeTodaySpend(aggregate, fold.spend);
1655
+ sessions.push({
1656
+ sessionId: id,
1657
+ title: foldSessionTitle(events),
1658
+ total: fold.spend.total
1659
+ });
1660
+ });
1661
+ sessions.sort((left, right) => right.total - left.total);
1662
+ return {
1663
+ aggregate,
1664
+ sessions
1258
1665
  };
1259
- if (sessions !== void 0) {
1260
- const store = sessions();
1261
- if (store !== void 0) for (const session of store.list()) {
1262
- liveIds.add(session.id);
1263
- collect(session.id, liveSessionEvents(session), forkBoundaryOf(session));
1264
- if (truncated) break;
1265
- }
1266
- }
1267
- const persistenceService = persistence?.();
1268
- if (!truncated && persistenceService !== void 0) {
1269
- const snapshots = await persistenceListSnapshots(persistenceService);
1270
- for (const { header, revision } of snapshots) {
1271
- if (liveIds.has(header.id)) continue;
1272
- if (this.lastEventsScan?.get(header.id) === revision) continue;
1273
- try {
1274
- const read = await persistenceInspect(persistenceService, header.id);
1275
- collect(header.id, read.events, read.seedLength);
1276
- } catch (error) {
1277
- logger.warn(`llm-billing: skipping unreadable session ${header.id}: ${String(error)}`);
1278
- }
1279
- if (truncated) break;
1280
- }
1281
- if (!truncated) this.lastEventsScan = new Map(snapshots.map((snapshot) => [snapshot.header.id, snapshot.revision]));
1282
- }
1283
- if (truncated) logger.warn(`llm-billing: today's events exceeded ${maxEvents}; result truncated`);
1284
- return [...rows.entries()].filter(([, row]) => row.total > 0).map(([sessionId, row]) => ({
1285
- sessionId,
1286
- title: row.title,
1287
- total: row.total
1288
- }));
1289
1666
  }
1290
1667
  };
1668
+ /** Per-session rows from the map, highest total first. */
1669
+ function sortRows(rows) {
1670
+ return [...rows.values()].sort((left, right) => right.total - left.total);
1671
+ }
1291
1672
  //#endregion
1292
1673
  //#region lib/types/index.js
1293
1674
  /**
@@ -1322,6 +1703,10 @@ const DEFAULT_MODELS = [
1322
1703
  id: "deepseek-v4-flash",
1323
1704
  name: "DeepSeek-V4-Flash"
1324
1705
  },
1706
+ {
1707
+ id: "deepseek-v4.1-flash-expires-on-0910",
1708
+ name: "DeepSeek-V4.1-Flash"
1709
+ },
1325
1710
  {
1326
1711
  id: "deepseek-v4-pro",
1327
1712
  name: "DeepSeek-V4-Pro"
@@ -1329,6 +1714,14 @@ const DEFAULT_MODELS = [
1329
1714
  {
1330
1715
  id: "deepseek-v4-flash-vision-exp",
1331
1716
  name: "DeepSeek-V4-Flash-Vision-Exp"
1717
+ },
1718
+ {
1719
+ id: "mimo-v2.5-pro",
1720
+ name: "MiMo-V2.5-Pro"
1721
+ },
1722
+ {
1723
+ id: "mimo-v2.5",
1724
+ name: "MiMo-V2.5"
1332
1725
  }
1333
1726
  ];
1334
1727
  const billingModel = z.object({
@@ -1344,12 +1737,12 @@ const billingConfig = z.object({
1344
1737
  peakHours: z.array(z.object({
1345
1738
  start: z.number().step(1).min(0).max(23),
1346
1739
  end: z.number().step(1).min(0).max(24)
1347
- })).default(DEFAULT_PEAK_HOURS),
1740
+ })).default([...DEFAULT_PEAK_HOURS]),
1348
1741
  models: z.array(z.object({
1349
1742
  model: z.string().required(),
1350
1743
  peak: tokenPrice,
1351
1744
  offPeak: tokenPrice
1352
- })).default(DEFAULT_MODEL_PRICING)
1745
+ })).default([...DEFAULT_MODEL_PRICING])
1353
1746
  });
1354
1747
  const Config = z.object({
1355
1748
  apiKeyEnv: z.string().role("credential-ref").default(DEFAULT_API_KEY_ENV),
@@ -1361,6 +1754,25 @@ const Config = z.object({
1361
1754
  const TODAY_SPEND_CACHE_MS = 6e4;
1362
1755
  /** Hard cap on today's events collected by the events scan path. */
1363
1756
  const TODAY_SPEND_MAX_EVENTS = 2e5;
1757
+ /** Max session-spend rows kept for incremental recompute before eviction. */
1758
+ const SESSION_SPEND_CACHE_LIMIT = 1024;
1759
+ /** Max session-id entries kept in the per-turn-cost fold cache before eviction. */
1760
+ const SESSION_TURN_SPEND_CACHE_LIMIT = 64;
1761
+ /** How long one balance snapshot is reused before the host refetches it (15s). */
1762
+ const BALANCE_CACHE_MS = 15e3;
1763
+ /** Hard cap on one `/user/balance` request (5s); a hung endpoint never blocks the badge. */
1764
+ const BALANCE_TIMEOUT_MS = 5e3;
1765
+ /**
1766
+ * Bounded-map eviction: drop the oldest inserted entry once `size` reached
1767
+ * `limit`, so an unbounded session-id space grows the map no further. Evicting
1768
+ * one entry (instead of clearing) keeps the other sessions' incremental
1769
+ * spend warm.
1770
+ */
1771
+ function evictOldest(map, limit) {
1772
+ if (map.size < limit) return;
1773
+ const oldest = map.keys().next().value;
1774
+ if (oldest !== void 0) map.delete(oldest);
1775
+ }
1364
1776
  /**
1365
1777
  * Read one session's event log and durable seed boundary: the live
1366
1778
  * SessionStore first, then the persistence backend for a flushed session
@@ -1387,65 +1799,111 @@ async function sessionEvents(ctx, sessionId) {
1387
1799
  }
1388
1800
  throw new LlmError(`llm-billing: session ${sessionId} not found`, "NOT_FOUND");
1389
1801
  }
1802
+ /** Resolve the plugin's static facts once: endpoint, credential ref, pricing table. */
1803
+ function resolveFacts(ctx, config) {
1804
+ return {
1805
+ baseURL: () => config.baseURL ?? launchEnvironmentOf(ctx).get(BASE_URL_ENV)?.value ?? "https://api.deepseek.com",
1806
+ apiKeyRef: credentialRef(config.apiKeyEnv ?? DEFAULT_API_KEY_ENV),
1807
+ billing: resolveBilling(config.billing),
1808
+ catalog: (config.models ?? DEFAULT_MODELS).map((model) => ({
1809
+ id: model.id,
1810
+ name: model.name ?? model.id
1811
+ }))
1812
+ };
1813
+ }
1390
1814
  /**
1391
- * Register the `billing` Remote under the `billing` namespace.
1392
- * @param ctx - owning plugin context.
1393
- * @param config - validated plugin config.
1815
+ * Resolve the API key per call: the credentials service first, then the
1816
+ * launch environment fallback.
1817
+ * @throws {@link LlmError} with code `MISSING_CREDENTIAL` when neither yields a usable key.
1394
1818
  */
1395
- function apply(ctx, config) {
1396
- const baseURL = () => config.baseURL ?? launchEnvironmentOf(ctx).get(BASE_URL_ENV)?.value ?? "https://api.deepseek.com";
1397
- const apiKeyRef = credentialRef(config.apiKeyEnv ?? DEFAULT_API_KEY_ENV);
1398
- const resolveApiKey = async () => {
1399
- const credentials = ctx.get("credentials");
1400
- if (credentials !== void 0) {
1401
- const hit = await credentials.resolve(apiKeyRef);
1402
- if (hit !== void 0) return assertUsableApiKey(hit.value, "llm-billing", apiKeyRef);
1403
- } else {
1404
- const ambient = launchEnvironmentOf(ctx).get(apiKeyRef);
1405
- if (ambient !== void 0 && ambient.value.length > 0) return assertUsableApiKey(ambient.value, "llm-billing", apiKeyRef);
1406
- }
1407
- throw new LlmError(`llm-billing: no API key; store ${apiKeyRef} through the credentials service or export it`, "MISSING_CREDENTIAL");
1408
- };
1409
- const fetchBalance = async () => {
1410
- const apiKey = await resolveApiKey();
1411
- return fetchDeepSeekBalance(baseURL(), apiKey);
1412
- };
1413
- const billing = resolveBilling(config.billing);
1414
- const catalog = (config.models ?? DEFAULT_MODELS).map((model) => ({
1415
- id: model.id,
1416
- name: model.name ?? model.id
1417
- }));
1819
+ async function resolveApiKey(ctx, apiKeyRef) {
1820
+ const credentials = ctx.get("credentials");
1821
+ if (credentials !== void 0) {
1822
+ const hit = await credentials.resolve(apiKeyRef);
1823
+ if (hit !== void 0) return assertUsableApiKey(hit.value, "llm-billing", apiKeyRef);
1824
+ } else {
1825
+ const ambient = launchEnvironmentOf(ctx).get(apiKeyRef);
1826
+ if (ambient !== void 0 && ambient.value.length > 0) return assertUsableApiKey(ambient.value, "llm-billing", apiKeyRef);
1827
+ }
1828
+ throw new LlmError(`llm-billing: no API key; store ${apiKeyRef} through the credentials service or export it`, "MISSING_CREDENTIAL");
1829
+ }
1830
+ /**
1831
+ * Per-session incremental spend loader: a session log is append-only and
1832
+ * chronological (the same assumption the projection unit makes), so a spend
1833
+ * computed for `count` EVENTS OF THE SESSION'S OWN WORK (the log minus its
1834
+ * inherited fork prefix) stays valid while the log length is unchanged, and
1835
+ * only the appended tail needs pricing when it grows. A forked child's
1836
+ * inherited prefix (`seq < seedLength`) is priced only in its source
1837
+ * session; the cache is bounded (see {@link evictOldest}), so an unbounded
1838
+ * session-id space cannot grow it without bound.
1839
+ */
1840
+ function createSessionSpendFetcher(ctx, facts) {
1418
1841
  const sessionSpendCache = /* @__PURE__ */ new Map();
1419
- const fetchSessionSpend = async (sessionId) => {
1842
+ return async (sessionId) => {
1420
1843
  const { events, seedLength } = await sessionEvents(ctx, sessionId);
1421
1844
  const ownCount = events.length - seedLength;
1422
1845
  const cached = sessionSpendCache.get(sessionId);
1423
- if (cached !== void 0 && cached.count === ownCount) return cached.spend;
1846
+ if (cached !== void 0 && cached.count === ownCount) {
1847
+ sessionSpendCache.delete(sessionId);
1848
+ sessionSpendCache.set(sessionId, cached);
1849
+ return cached.spend;
1850
+ }
1424
1851
  if (cached !== void 0 && cached.count < ownCount) {
1425
- const spend = mergeTodaySpend(cached.spend, computeSessionSpend(events.slice(seedLength + cached.count), billing, catalog));
1852
+ const spend = mergeTodaySpend(cached.spend, computeSessionSpend(events.slice(seedLength + cached.count), facts.billing, facts.catalog));
1853
+ evictOldest(sessionSpendCache, SESSION_SPEND_CACHE_LIMIT);
1426
1854
  sessionSpendCache.set(sessionId, {
1427
1855
  count: ownCount,
1428
1856
  spend
1429
1857
  });
1430
1858
  return spend;
1431
1859
  }
1432
- const spend = computeSessionSpend(events, billing, catalog, seedLength);
1433
- if (sessionSpendCache.size >= 1024) sessionSpendCache.clear();
1860
+ const spend = computeSessionSpend(events, facts.billing, facts.catalog, seedLength);
1861
+ evictOldest(sessionSpendCache, SESSION_SPEND_CACHE_LIMIT);
1434
1862
  sessionSpendCache.set(sessionId, {
1435
1863
  count: ownCount,
1436
1864
  spend
1437
1865
  });
1438
1866
  return spend;
1439
1867
  };
1440
- const unit = billingTodaySpendDefinition(billing, catalog);
1441
- let unitRegistered = false;
1442
- const ensureUnit = () => {
1443
- if (unitRegistered) return;
1868
+ }
1869
+ /**
1870
+ * Once-registrar for the billing projection unit: the first call that finds
1871
+ * the registry composed registers the shared unit and every later call is a
1872
+ * no-op. Registering as early as the registry exists lets DSH's projection
1873
+ * write-behind (mandatory at `turn/end`) checkpoint a billing row for every
1874
+ * session that runs in this process, which is what makes the zero-I/O cold
1875
+ * path in {@link TodaySpendScanner} hit after the next restart.
1876
+ * @param ctx - plugin context.
1877
+ * @param unit - the unit definition built once per plugin config.
1878
+ * @returns an idempotent registrar.
1879
+ */
1880
+ function createUnitRegistrar(ctx, unit) {
1881
+ let registered = false;
1882
+ return () => {
1883
+ if (registered) return;
1444
1884
  const registry = ctx.get("sessionProjections");
1445
1885
  if (registry === void 0) return;
1446
1886
  registry.register(unit);
1447
- unitRegistered = true;
1887
+ registered = true;
1448
1888
  };
1889
+ }
1890
+ /**
1891
+ * Today-spend loaders over one revision-gated scanner with two 60s
1892
+ * Beijing-day caches (in-flight coalescing and a `force` bypass):
1893
+ * - plan C uses the per-session spend projection unit registered by the
1894
+ * caller's {@link createUnitRegistrar} as early as the registry exists (the
1895
+ * registry builds cells lazily over the in-memory log, so events committed
1896
+ * before registration are folded on first touch); without the registry the
1897
+ * events path serves today's spend.
1898
+ * - plans A1–A3: the scanner chooses the projection path when the registry
1899
+ * is composed, the events path otherwise.
1900
+ * @param ctx - plugin context.
1901
+ * @param facts - resolved endpoint, credential, pricing, and catalog facts.
1902
+ * @param unit - the shared projection unit definition.
1903
+ * @param ensureUnit - idempotent unit registrar (last-resort registration).
1904
+ * @returns the two today-spend loaders.
1905
+ */
1906
+ function createTodaySpendLoaders(ctx, facts, unit, ensureUnit) {
1449
1907
  const scanner = new TodaySpendScanner({
1450
1908
  sessions: () => ctx.get("sessions"),
1451
1909
  persistence: () => ctx.get("sessionPersistence"),
@@ -1455,24 +1913,108 @@ function apply(ctx, config) {
1455
1913
  unit,
1456
1914
  maxEvents: TODAY_SPEND_MAX_EVENTS,
1457
1915
  logger: ctx.logger,
1458
- billing,
1459
- catalog
1916
+ billing: facts.billing,
1917
+ catalog: facts.catalog
1460
1918
  });
1461
- const todayCache = new TodaySpendCache((dayKey) => scanner.scan(dayKey), TODAY_SPEND_CACHE_MS);
1462
- const todaySessionsCache = new TodaySpendCache((dayKey) => scanner.scanSessions(dayKey), TODAY_SPEND_CACHE_MS);
1463
- const fetchTodaySpend = async (force = false) => todayCache.get(force);
1464
- const fetchTodaySessionsSpend = async (force = false) => todaySessionsCache.get(force);
1465
- const fetchTurnSpend = async (sessionId, messageId) => {
1919
+ const todayCache = new TodaySpendCache((dayKey) => scanner.scanDetail(dayKey), TODAY_SPEND_CACHE_MS);
1920
+ return {
1921
+ fetchTodaySpend: async (force = false) => (await todayCache.get(force)).aggregate,
1922
+ fetchTodaySessionsSpend: async (force = false) => ({ sessions: (await todayCache.get(force)).sessions })
1923
+ };
1924
+ }
1925
+ /** One completed Turn's spend loader, located by its closing message id. */
1926
+ function createTurnSpendFetcher(ctx, facts) {
1927
+ return async (sessionId, messageId) => {
1466
1928
  const { events } = await sessionEvents(ctx, sessionId);
1467
- return computeTurnSpend(events, billing, catalog, messageId);
1929
+ return computeTurnSpend(events, facts.billing, facts.catalog, messageId);
1468
1930
  };
1931
+ }
1932
+ /**
1933
+ * Every completed Turn's cost in one session, folded incrementally per session
1934
+ * (session logs are append-only, so only the appended tail is priced on a
1935
+ * growing log). One call serves a whole transcript's per-message cost rows,
1936
+ * replacing the per-message `getTurnSpend` fan-out.
1937
+ */
1938
+ function createTurnSpendsFetcher(ctx, facts) {
1939
+ const folders = /* @__PURE__ */ new Map();
1940
+ return async (sessionId) => {
1941
+ const { events } = await sessionEvents(ctx, sessionId);
1942
+ let entry = folders.get(sessionId);
1943
+ if (entry === void 0 || entry.count > events.length) {
1944
+ entry = {
1945
+ folder: new SessionTurnSpendFolder(facts.billing, facts.catalog),
1946
+ count: 0
1947
+ };
1948
+ evictOldest(folders, 64);
1949
+ folders.set(sessionId, entry);
1950
+ }
1951
+ if (entry.count !== events.length) {
1952
+ entry.folder.feed(events);
1953
+ entry.count = events.length;
1954
+ }
1955
+ return entry.folder.finish();
1956
+ };
1957
+ }
1958
+ /**
1959
+ * Balance loader with a short host-side TTL and a hard request timeout: the
1960
+ * credential resolves per call, a fresh snapshot is reused for
1961
+ * {@link BALANCE_CACHE_MS} (so several badge mounts and several browsers share
1962
+ * one `/user/balance` call), concurrent misses coalesce, and `force` bypasses
1963
+ * the TTL for the manual refresh. A hung endpoint aborts after
1964
+ * {@link BALANCE_TIMEOUT_MS} instead of holding the badge's fetch forever.
1965
+ * @param ctx - plugin context carrying the credential seam.
1966
+ * @param facts - resolved endpoint and credential facts.
1967
+ * @returns the balance loader.
1968
+ */
1969
+ function createBalanceFetcher(ctx, facts) {
1970
+ let cached;
1971
+ let inflight;
1972
+ return async (force = false) => {
1973
+ if (!force && cached !== void 0 && Date.now() - cached.at < 15e3) return cached.value;
1974
+ if (inflight !== void 0) return inflight;
1975
+ const run = (async () => {
1976
+ try {
1977
+ const apiKey = await resolveApiKey(ctx, facts.apiKeyRef);
1978
+ const value = await fetchDeepSeekBalance(facts.baseURL(), apiKey, AbortSignal.timeout(BALANCE_TIMEOUT_MS));
1979
+ cached = {
1980
+ at: Date.now(),
1981
+ value
1982
+ };
1983
+ return value;
1984
+ } finally {
1985
+ inflight = void 0;
1986
+ }
1987
+ })();
1988
+ inflight = run;
1989
+ return run;
1990
+ };
1991
+ }
1992
+ /**
1993
+ * Register the `billing` Remote under the `billing` namespace. Assembly only:
1994
+ * facts resolve once, each loader owns its caches, and the gateway receives
1995
+ * the bound thunks.
1996
+ * @param ctx - owning plugin context.
1997
+ * @param config - validated plugin config.
1998
+ */
1999
+ function apply(ctx, config) {
2000
+ const facts = resolveFacts(ctx, config);
2001
+ const unit = billingTodaySpendDefinition(facts.billing, facts.catalog);
2002
+ const ensureUnit = createUnitRegistrar(ctx, unit);
2003
+ ensureUnit();
2004
+ ctx.on("session/created", ensureUnit);
2005
+ const fetchBalance = createBalanceFetcher(ctx, facts);
2006
+ const fetchSessionSpend = createSessionSpendFetcher(ctx, facts);
2007
+ const { fetchTodaySpend, fetchTodaySessionsSpend } = createTodaySpendLoaders(ctx, facts, unit, ensureUnit);
2008
+ const fetchTurnSpend = createTurnSpendFetcher(ctx, facts);
2009
+ const fetchTurnSpends = createTurnSpendsFetcher(ctx, facts);
1469
2010
  new DeepSeekBalanceGateway(ctx, {
1470
2011
  fetchBalance,
1471
2012
  fetchSessionSpend,
1472
2013
  fetchTodaySpend,
1473
2014
  fetchTodaySessionsSpend,
1474
- fetchTurnSpend
2015
+ fetchTurnSpend,
2016
+ fetchTurnSpends
1475
2017
  });
1476
2018
  }
1477
2019
  //#endregion
1478
- 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 };
2020
+ export { BALANCE_CACHE_MS, BALANCE_TIMEOUT_MS, BILLING_UNIT_KEY, BillingFolder, Config, DEFAULT_MODEL_PRICING, DEFAULT_PEAK_HOURS, DeepSeekBalanceGateway, PUBLIC_BASE_URL, SESSION_SPEND_CACHE_LIMIT, SESSION_TURN_SPEND_CACHE_LIMIT, SessionTurnSpendFolder, SpendAccumulator, TODAY_SPEND_CACHE_MS, TODAY_SPEND_MAX_EVENTS, TodaySpendCache, TodaySpendScanner, 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 };