@rayadesu/dsh-llm-billing 0.3.8 → 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: {
@@ -387,23 +425,61 @@ function resolveBilling(config) {
387
425
  models
388
426
  };
389
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
+ }
390
457
  /**
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.
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.)
394
465
  * @param time - epoch milliseconds.
466
+ * @throws {RangeError} when `time` is not a finite number.
395
467
  */
396
- function beijingParts(time) {
397
- 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);
398
474
  return {
399
- hour: shifted.getUTCHours(),
400
- weekday: shifted.getUTCDay(),
401
- 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)}`
402
478
  };
403
479
  }
404
480
  /** The Beijing (Asia/Shanghai, UTC+8, no DST) calendar-day key of a timestamp. */
405
481
  function beijingDayKey(now) {
406
- return beijingParts(now.getTime()).dayKey;
482
+ return beijingPartsOf(now.getTime()).dayKey;
407
483
  }
408
484
  /**
409
485
  * The durable inherited-prefix boundary of one session: the number of leading
@@ -443,7 +519,7 @@ function isPeakParts(billing, hour, weekday) {
443
519
  * @returns true during a weekday peak hour.
444
520
  */
445
521
  function isPeak(billing, now) {
446
- const { hour, weekday } = beijingParts(now.getTime());
522
+ const { hour, weekday } = beijingPartsOf(now.getTime());
447
523
  return isPeakParts(billing, hour, weekday);
448
524
  }
449
525
  /**
@@ -452,32 +528,57 @@ function isPeak(billing, now) {
452
528
  * only; weekends are off-peak). Each `assistant/message` event with usage
453
529
  * contributes cache-hit input, cache-miss input (uncached input plus cache
454
530
  * 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).
531
+ * timestamp; a model with usage but no pricing row contributes nothing.
457
532
  * @param event - the event to price.
458
533
  * @param billing - resolved pricing with peak-hour windows.
459
534
  * @param names - model id → display label.
460
535
  * @returns the priced contribution, or `undefined` when the event has no priced usage.
461
536
  */
462
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) {
463
552
  if (event.type !== "assistant/message") return void 0;
464
553
  const reported = event.data.usage;
465
554
  if (reported === void 0) return void 0;
466
- 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) {
467
569
  const pricing = billing.models.get(model);
468
570
  if (pricing === void 0) return void 0;
469
- const { hour, weekday, dayKey } = beijingParts(event.time);
470
- const peak = isPeakParts(billing, hour, weekday);
571
+ const peak = isPeakParts(billing, parts.hour, parts.weekday);
471
572
  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;
573
+ const hit = usage.cacheReadTokens ?? 0;
574
+ const miss = usage.inputTokens + (usage.cacheWriteTokens ?? 0);
575
+ const output = usage.outputTokens;
475
576
  const hitCost = hit * price.cacheHitInput / 1e6;
476
577
  const missCost = miss * price.cacheMissInput / 1e6;
477
578
  const outputCost = output * price.output / 1e6;
478
579
  const cost = hitCost + missCost + outputCost;
479
580
  return {
480
- dayKey,
581
+ dayKey: parts.dayKey,
481
582
  model,
482
583
  displayName: names.get(model) ?? model,
483
584
  cost,
@@ -556,6 +657,192 @@ var SpendAccumulator = class {
556
657
  };
557
658
  }
558
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
+ };
559
846
  /**
560
847
  * Merge one priced event's contribution into an accumulator spend (pure:
561
848
  * returns a new spend, never mutates its input).
@@ -592,34 +879,11 @@ function mergeTodaySpend(target, source) {
592
879
  };
593
880
  }
594
881
  /**
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.
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.
623
887
  * @param events - one session's complete event log.
624
888
  * @param billing - resolved pricing with peak-hour windows.
625
889
  * @param catalog - model display rows, in presentation order.
@@ -630,16 +894,18 @@ function priceEvents(events, billing, names, dayKey, startSeq = 0) {
630
894
  * @returns the session's total cost plus one row per priced model.
631
895
  */
632
896
  function computeSessionSpend(events, billing, catalog, startSeq = 0) {
633
- 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;
634
900
  }
635
901
  /**
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.
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.
643
909
  * @param events - one session's complete event log.
644
910
  * @param billing - resolved pricing with peak-hour windows.
645
911
  * @param catalog - model display rows, in presentation order.
@@ -647,7 +913,18 @@ function computeSessionSpend(events, billing, catalog, startSeq = 0) {
647
913
  * @returns the turn's total cost in CNY.
648
914
  */
649
915
  function computeTurnSpend(events, billing, catalog, messageId) {
650
- 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) {
651
928
  let turn;
652
929
  for (const event of events) {
653
930
  if (event.type !== "assistant/message") continue;
@@ -655,8 +932,8 @@ function computeTurnSpend(events, billing, catalog, messageId) {
655
932
  turn = event.data.turn;
656
933
  break;
657
934
  }
658
- if (turn === void 0) return { total: 0 };
659
- const accumulator = new SpendAccumulator();
935
+ if (turn === void 0) return 0;
936
+ const folder = new BillingFolder(billing, catalog);
660
937
  let active = false;
661
938
  for (const event of events) {
662
939
  if (event.type === "turn/start" && event.data.turn === turn) {
@@ -665,16 +942,116 @@ function computeTurnSpend(events, billing, catalog, messageId) {
665
942
  }
666
943
  if (event.type === "turn/end" && event.data.turn === turn) break;
667
944
  if (!active) continue;
668
- const priced = priceEvent(event, billing, names);
669
- if (priced !== void 0) accumulator.add(priced);
945
+ folder.add(event);
670
946
  }
671
- return { total: accumulator.finish().total };
947
+ return folder.fold.session.total;
672
948
  }
673
949
  /**
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.
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] };
1021
+ }
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();
1043
+ }
1044
+ /**
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.
678
1055
  * @param billing - resolved pricing with peak-hour windows.
679
1056
  * @param catalog - model display rows, in presentation order.
680
1057
  * @param now - the reference moment whose Beijing-time calendar day is "today".
@@ -682,24 +1059,31 @@ function computeTurnSpend(events, billing, catalog, messageId) {
682
1059
  */
683
1060
  function computeTodaySpend(events, billing, catalog, now = /* @__PURE__ */ new Date()) {
684
1061
  const day = beijingDayKey(now);
685
- 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();
686
1068
  }
687
1069
  //#endregion
688
1070
  //#region lib/types/projection.js
689
1071
  /**
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.
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.
697
1079
  *
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
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
700
1082
  * 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.
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.
703
1087
  * @module @rayadesu/dsh-llm-billing/projection
704
1088
  */
705
1089
  /** The projection key this unit owns. */
@@ -723,7 +1107,16 @@ const todaySpendSchema = z$1.object({
723
1107
  }).strict();
724
1108
  const billingUnitSchema = z$1.object({
725
1109
  dayKey: z$1.string(),
726
- 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()
727
1120
  }).strict();
728
1121
  /**
729
1122
  * Build the `billingTodaySpend` unit for one resolved pricing table. The
@@ -741,25 +1134,10 @@ function billingTodaySpendDefinition(billing, catalog) {
741
1134
  const names = new Map(catalog.map((model) => [model.id, model.name]));
742
1135
  return {
743
1136
  key: BILLING_UNIT_KEY,
744
- stateVersion: 1,
1137
+ stateVersion: 3,
745
1138
  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
- },
1139
+ init: (_header, inheritedEventCount) => emptyBillingFoldState(Number(inheritedEventCount ?? 0)),
1140
+ apply: (state, event) => applyBillingEvent(state, event, billing, names),
763
1141
  wire: {
764
1142
  viewSchema: billingUnitSchema,
765
1143
  view: (state) => state
@@ -800,11 +1178,13 @@ function foldOwnBilling(unit, events, seedLength = 0) {
800
1178
  * compute the aggregate behind a cache miss:
801
1179
  *
802
1180
  * - 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.
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.
808
1188
  * - events path (plans A2/A3): collect and price only today's events in one
809
1189
  * pass (per-event Beijing-day filter during collection) with a hard cap,
810
1190
  * skipping sessions whose persisted revision is unchanged since the last
@@ -816,14 +1196,15 @@ function foldOwnBilling(unit, events, seedLength = 0) {
816
1196
  * unchanged log provably cannot change the aggregate.
817
1197
  *
818
1198
  * 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.
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.
827
1208
  *
828
1209
  * The live `Session` log surface changed in 0.1.2-alpha.4: `Session.events`
829
1210
  * was removed and replaced by `Session.snapshotEvents()` / `ownEvents()`, and
@@ -862,6 +1243,15 @@ function liveSessionEvents(session) {
862
1243
  if (session.snapshotEvents !== void 0) return session.snapshotEvents();
863
1244
  throw new Error("llm-billing: session log surface is neither Session.events nor Session.snapshotEvents");
864
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
+ }
865
1255
  function isHandlePersistence(persistence) {
866
1256
  return typeof persistence.open === "function";
867
1257
  }
@@ -888,7 +1278,7 @@ async function persistenceInspect(persistence, id) {
888
1278
  const handle = await persistence.open(id, "read");
889
1279
  try {
890
1280
  return {
891
- events: await handle.read(),
1281
+ events: handleReadEvents(await handle.read()),
892
1282
  seedLength: forkBoundaryOf(handle)
893
1283
  };
894
1284
  } finally {
@@ -952,7 +1342,7 @@ var TodaySpendCache = class {
952
1342
  const now = this.now();
953
1343
  const dayKey = beijingDayKey(now);
954
1344
  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;
1345
+ if (this.inFlight !== void 0) return this.inFlight;
956
1346
  const run = (async () => {
957
1347
  try {
958
1348
  const value = await this.scan(dayKey);
@@ -964,10 +1354,24 @@ var TodaySpendCache = class {
964
1354
  this.inFlight = void 0;
965
1355
  }
966
1356
  })();
967
- if (!force) this.inFlight = run;
1357
+ this.inFlight = run;
968
1358
  return run;
969
1359
  }
970
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
+ }
971
1375
  /**
972
1376
  * The aggregate computation behind a cache miss. Chooses the projection path
973
1377
  * when the projection registry is composed, the events path otherwise; both
@@ -978,10 +1382,10 @@ var TodaySpendScanner = class {
978
1382
  deps;
979
1383
  /** Cold sessions resolved on the projection path: id → revision + unit state + title. */
980
1384
  coldResolved = /* @__PURE__ */ new Map();
1385
+ /** Cold sessions whose resolution failed: id → revision (retried only when the log changes). */
1386
+ coldFailed = /* @__PURE__ */ new Map();
981
1387
  /** Cold sessions resolved on the events path: id → revision (events were collected). */
982
1388
  lastEventsScan;
983
- /** Live fork children priced on the projection path: id → own-events count + folded state. */
984
- ownStates = /* @__PURE__ */ new Map();
985
1389
  constructor(deps) {
986
1390
  this.deps = deps;
987
1391
  }
@@ -991,9 +1395,7 @@ var TodaySpendScanner = class {
991
1395
  * @returns today's spend across every session.
992
1396
  */
993
1397
  async scan(dayKey) {
994
- if (this.deps.projections?.() === void 0) return this.scanEvents(dayKey);
995
- this.deps.ensureUnit?.();
996
- return this.scanProjections(dayKey);
1398
+ return (await this.scanDetail(dayKey)).aggregate;
997
1399
  }
998
1400
  /**
999
1401
  * Compute today's per-session spend for one Beijing day, sorted by cost
@@ -1003,157 +1405,169 @@ var TodaySpendScanner = class {
1003
1405
  * @returns today's per-session rows, highest first.
1004
1406
  */
1005
1407
  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 };
1408
+ return { sessions: (await this.scanDetail(dayKey)).sessions };
1009
1409
  }
1010
1410
  /**
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
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);
1423
+ }
1424
+ /**
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
1017
1437
  * prefix too, so it always detaches through inspect with the durable
1018
1438
  * boundary (the inspect result's inherited count or `meta.seedLength`,
1019
1439
  * 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).
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.
1024
1443
  * @returns the resolved state and title, or `undefined` when unreadable.
1025
1444
  */
1026
- async resolveCold(id, seeded) {
1445
+ async resolveCold(header, seeded, dayKey) {
1027
1446
  const { persistence, projectionCache, logger } = this.deps;
1028
- const persistenceService = persistence?.();
1029
- if (persistenceService === void 0) return void 0;
1030
1447
  if (!seeded) {
1031
1448
  const cache = projectionCache?.();
1032
1449
  if (cache !== void 0) try {
1033
- const value = (await cache.coldSnapshot(id)).values[BILLING_UNIT_KEY];
1034
- 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 {
1035
1452
  value,
1036
1453
  title: null
1037
1454
  };
1038
1455
  } catch (error) {
1039
- 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)}`);
1040
1457
  }
1041
1458
  }
1459
+ const persistenceService = persistence?.();
1460
+ if (persistenceService === void 0) return void 0;
1042
1461
  try {
1043
- const read = await persistenceInspect(persistenceService, id);
1462
+ const read = await persistenceInspect(persistenceService, header.id);
1044
1463
  return {
1045
1464
  value: foldOwnBilling(this.deps.unit, read.events, read.seedLength),
1046
1465
  title: foldSessionTitle(read.events)
1047
1466
  };
1048
1467
  } catch (error) {
1049
- logger.warn(`llm-billing: skipping unreadable session ${id}: ${String(error)}`);
1468
+ logger.warn(`llm-billing: skipping unreadable session ${header.id}: ${String(error)}`);
1050
1469
  return;
1051
1470
  }
1052
1471
  }
1053
1472
  /**
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.
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.
1061
1477
  */
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;
1478
+ *liveBillingEntries(store, projections) {
1479
+ for (const session of store.list()) yield {
1480
+ session,
1481
+ state: projections?.stateOf(session, BILLING_UNIT_KEY)
1482
+ };
1079
1483
  }
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);
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;
1098
1499
  const pending = [];
1099
1500
  for (const { header, revision } of snapshots) {
1100
1501
  if (liveIds.has(header.id)) continue;
1101
1502
  const seeded = isSeededSession(header);
1102
1503
  const resolved = this.coldResolved.get(header.id);
1103
1504
  if (resolved !== void 0 && resolved.revision === revision) {
1104
- if (resolved.value.dayKey === dayKey) total = mergeTodaySpend(total, resolved.value.spend);
1505
+ adopt(header.id, resolved);
1105
1506
  continue;
1106
1507
  }
1508
+ if (this.coldFailed.get(header.id) === revision) continue;
1107
1509
  pending.push({
1108
- id: header.id,
1510
+ header,
1109
1511
  revision,
1110
1512
  seeded
1111
1513
  });
1112
1514
  }
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
- });
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
+ }
1119
1528
  });
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);
1529
+ for (const { header } of pending) {
1530
+ const resolved = this.coldResolved.get(header.id);
1531
+ if (resolved !== void 0) adopt(header.id, resolved);
1123
1532
  }
1124
- return total;
1125
1533
  }
1126
1534
  /**
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.
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.
1131
1546
  */
1132
- async scanEvents(dayKey) {
1547
+ async collectTodayEvents(dayKey, onSession) {
1133
1548
  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
1549
  const liveIds = /* @__PURE__ */ new Set();
1137
1550
  let collected = 0;
1138
1551
  let truncated = false;
1139
- const collect = (events, seedLength) => {
1552
+ const collect = (id, events, seedLength) => {
1553
+ const folder = new BillingFolder(billing, catalog, seedLength);
1140
1554
  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;
1555
+ if (beijingPartsOf(event.time).dayKey === dayKey) {
1556
+ collected += 1;
1557
+ if (collected > maxEvents) {
1558
+ truncated = true;
1559
+ break;
1560
+ }
1147
1561
  }
1148
- const priced = priceEvent(event, billing, names);
1149
- if (priced !== void 0) accumulator.add(priced);
1562
+ folder.add(event);
1150
1563
  }
1564
+ onSession(id, folder.fold, events);
1151
1565
  };
1152
1566
  if (sessions !== void 0) {
1153
1567
  const store = sessions();
1154
1568
  if (store !== void 0) for (const session of store.list()) {
1155
1569
  liveIds.add(session.id);
1156
- collect(liveSessionEvents(session), forkBoundaryOf(session));
1570
+ collect(session.id, liveSessionEvents(session), forkBoundaryOf(session));
1157
1571
  if (truncated) break;
1158
1572
  }
1159
1573
  }
@@ -1165,7 +1579,7 @@ var TodaySpendScanner = class {
1165
1579
  if (this.lastEventsScan?.get(header.id) === revision) continue;
1166
1580
  try {
1167
1581
  const read = await persistenceInspect(persistenceService, header.id);
1168
- collect(read.events, read.seedLength);
1582
+ collect(header.id, read.events, read.seedLength);
1169
1583
  } catch (error) {
1170
1584
  logger.warn(`llm-billing: skipping unreadable session ${header.id}: ${String(error)}`);
1171
1585
  }
@@ -1174,146 +1588,87 @@ var TodaySpendScanner = class {
1174
1588
  if (!truncated) this.lastEventsScan = new Map(snapshots.map((snapshot) => [snapshot.header.id, snapshot.revision]));
1175
1589
  }
1176
1590
  if (truncated) logger.warn(`llm-billing: today's events exceeded ${maxEvents}; result truncated`);
1177
- return accumulator.finish();
1591
+ return truncated;
1178
1592
  }
1179
1593
  /**
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).
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.
1185
1599
  * @param dayKey - the Beijing-time calendar-day key to aggregate.
1186
- * @returns unsorted per-session rows for the day.
1600
+ * @returns the aggregate plus per-session rows, sorted by cost descending.
1187
1601
  */
1188
- async scanSessionsProjections(dayKey) {
1602
+ async scanDetailProjections(dayKey) {
1189
1603
  const { sessions, persistence, projections } = this.deps;
1190
1604
  const projectionsService = projections?.();
1605
+ let aggregate = emptyTodaySpend();
1191
1606
  const rows = /* @__PURE__ */ new Map();
1192
1607
  const liveIds = /* @__PURE__ */ new Set();
1193
1608
  if (sessions !== void 0) {
1194
1609
  const store = sessions();
1195
- if (store !== void 0) for (const session of store.list()) {
1610
+ if (store !== void 0) for (const { session, state } of this.liveBillingEntries(store, projectionsService)) {
1196
1611
  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, {
1612
+ if (state === void 0 || state.dayKey !== dayKey) continue;
1613
+ aggregate = mergeTodaySpend(aggregate, state.spend);
1614
+ rows.set(session.id, {
1201
1615
  sessionId: session.id,
1202
- title: foldSessionTitle(events),
1616
+ title: foldSessionTitle(liveSessionEvents(session)),
1203
1617
  total: state.spend.total
1204
1618
  });
1205
1619
  }
1206
1620
  }
1207
1621
  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,
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,
1218
1629
  title: resolved.title,
1219
1630
  total: resolved.value.spend.total
1220
1631
  });
1221
- continue;
1222
- }
1223
- pending.push({
1224
- id: header.id,
1225
- revision,
1226
- seeded
1227
1632
  });
1228
1633
  }
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()];
1634
+ return {
1635
+ aggregate,
1636
+ sessions: sortRows(rows)
1637
+ };
1245
1638
  }
1246
1639
  /**
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.
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.
1254
1646
  * @param dayKey - the Beijing-time calendar-day key to aggregate.
1255
- * @returns unsorted per-session rows for the day.
1647
+ * @returns the aggregate plus per-session rows, sorted by cost descending.
1256
1648
  */
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
- }
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
1284
1665
  };
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
1666
  }
1316
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
+ }
1317
1672
  //#endregion
1318
1673
  //#region lib/types/index.js
1319
1674
  /**
@@ -1348,6 +1703,10 @@ const DEFAULT_MODELS = [
1348
1703
  id: "deepseek-v4-flash",
1349
1704
  name: "DeepSeek-V4-Flash"
1350
1705
  },
1706
+ {
1707
+ id: "deepseek-v4.1-flash-expires-on-0910",
1708
+ name: "DeepSeek-V4.1-Flash"
1709
+ },
1351
1710
  {
1352
1711
  id: "deepseek-v4-pro",
1353
1712
  name: "DeepSeek-V4-Pro"
@@ -1378,12 +1737,12 @@ const billingConfig = z.object({
1378
1737
  peakHours: z.array(z.object({
1379
1738
  start: z.number().step(1).min(0).max(23),
1380
1739
  end: z.number().step(1).min(0).max(24)
1381
- })).default(DEFAULT_PEAK_HOURS),
1740
+ })).default([...DEFAULT_PEAK_HOURS]),
1382
1741
  models: z.array(z.object({
1383
1742
  model: z.string().required(),
1384
1743
  peak: tokenPrice,
1385
1744
  offPeak: tokenPrice
1386
- })).default(DEFAULT_MODEL_PRICING)
1745
+ })).default([...DEFAULT_MODEL_PRICING])
1387
1746
  });
1388
1747
  const Config = z.object({
1389
1748
  apiKeyEnv: z.string().role("credential-ref").default(DEFAULT_API_KEY_ENV),
@@ -1395,6 +1754,25 @@ const Config = z.object({
1395
1754
  const TODAY_SPEND_CACHE_MS = 6e4;
1396
1755
  /** Hard cap on today's events collected by the events scan path. */
1397
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
+ }
1398
1776
  /**
1399
1777
  * Read one session's event log and durable seed boundary: the live
1400
1778
  * SessionStore first, then the persistence backend for a flushed session
@@ -1421,65 +1799,111 @@ async function sessionEvents(ctx, sessionId) {
1421
1799
  }
1422
1800
  throw new LlmError(`llm-billing: session ${sessionId} not found`, "NOT_FOUND");
1423
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
+ }
1424
1814
  /**
1425
- * Register the `billing` Remote under the `billing` namespace.
1426
- * @param ctx - owning plugin context.
1427
- * @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.
1428
1818
  */
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
- }));
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) {
1452
1841
  const sessionSpendCache = /* @__PURE__ */ new Map();
1453
- const fetchSessionSpend = async (sessionId) => {
1842
+ return async (sessionId) => {
1454
1843
  const { events, seedLength } = await sessionEvents(ctx, sessionId);
1455
1844
  const ownCount = events.length - seedLength;
1456
1845
  const cached = sessionSpendCache.get(sessionId);
1457
- 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
+ }
1458
1851
  if (cached !== void 0 && cached.count < ownCount) {
1459
- 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);
1460
1854
  sessionSpendCache.set(sessionId, {
1461
1855
  count: ownCount,
1462
1856
  spend
1463
1857
  });
1464
1858
  return spend;
1465
1859
  }
1466
- const spend = computeSessionSpend(events, billing, catalog, seedLength);
1467
- if (sessionSpendCache.size >= 1024) sessionSpendCache.clear();
1860
+ const spend = computeSessionSpend(events, facts.billing, facts.catalog, seedLength);
1861
+ evictOldest(sessionSpendCache, SESSION_SPEND_CACHE_LIMIT);
1468
1862
  sessionSpendCache.set(sessionId, {
1469
1863
  count: ownCount,
1470
1864
  spend
1471
1865
  });
1472
1866
  return spend;
1473
1867
  };
1474
- const unit = billingTodaySpendDefinition(billing, catalog);
1475
- let unitRegistered = false;
1476
- const ensureUnit = () => {
1477
- 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;
1478
1884
  const registry = ctx.get("sessionProjections");
1479
1885
  if (registry === void 0) return;
1480
1886
  registry.register(unit);
1481
- unitRegistered = true;
1887
+ registered = true;
1482
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) {
1483
1907
  const scanner = new TodaySpendScanner({
1484
1908
  sessions: () => ctx.get("sessions"),
1485
1909
  persistence: () => ctx.get("sessionPersistence"),
@@ -1489,24 +1913,108 @@ function apply(ctx, config) {
1489
1913
  unit,
1490
1914
  maxEvents: TODAY_SPEND_MAX_EVENTS,
1491
1915
  logger: ctx.logger,
1492
- billing,
1493
- catalog
1916
+ billing: facts.billing,
1917
+ catalog: facts.catalog
1494
1918
  });
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) => {
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) => {
1500
1928
  const { events } = await sessionEvents(ctx, sessionId);
1501
- return computeTurnSpend(events, billing, catalog, messageId);
1929
+ return computeTurnSpend(events, facts.billing, facts.catalog, messageId);
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();
1502
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);
1503
2010
  new DeepSeekBalanceGateway(ctx, {
1504
2011
  fetchBalance,
1505
2012
  fetchSessionSpend,
1506
2013
  fetchTodaySpend,
1507
2014
  fetchTodaySessionsSpend,
1508
- fetchTurnSpend
2015
+ fetchTurnSpend,
2016
+ fetchTurnSpends
1509
2017
  });
1510
2018
  }
1511
2019
  //#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 };
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 };