@rayadesu/dsh-llm-billing 0.3.15 → 0.3.17

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
@@ -92,14 +92,35 @@ function parseDeepSeekBalance(body) {
92
92
  };
93
93
  }
94
94
  /**
95
- * Fetch one account-balance snapshot from `{baseURL}/user/balance`.
96
- * @param baseURL - resolved endpoint base; `/user/balance` is appended.
97
- * @param apiKey - resolved bearer token for this endpoint.
98
- * @param signal - optional cancellation.
99
- * @returns the validated balance snapshot.
100
- * @throws {@link LlmError} for transport, HTTP, or malformed-response failures.
95
+ * Whether another attempt can plausibly succeed. Only faults transient BY
96
+ * NATURE qualify: a dropped socket (no status), a rate limit, or a server-side
97
+ * error. A settled 4xx is final — retrying a bad key three times only reports
98
+ * the truth three times later.
101
99
  */
102
- async function fetchDeepSeekBalance(baseURL, apiKey, signal) {
100
+ function isRetryableStatus(status) {
101
+ if (status === void 0) return true;
102
+ return status === 429 || status >= 500;
103
+ }
104
+ /** Wait `ms` before the next attempt, abandoning the wait if the caller aborts. */
105
+ function waitBalanceRetry(ms, signal) {
106
+ return new Promise((resolve, reject) => {
107
+ if (signal?.aborted === true) {
108
+ reject(signal.reason);
109
+ return;
110
+ }
111
+ const onAbort = () => {
112
+ clearTimeout(timer);
113
+ reject(signal?.reason);
114
+ };
115
+ const timer = setTimeout(() => {
116
+ signal?.removeEventListener("abort", onAbort);
117
+ resolve();
118
+ }, ms);
119
+ signal?.addEventListener("abort", onAbort, { once: true });
120
+ });
121
+ }
122
+ /** One balance request attempt, REPORTED rather than thrown so the caller decides. */
123
+ async function requestBalanceOnce(baseURL, apiKey, signal) {
103
124
  let response;
104
125
  try {
105
126
  response = await fetch(`${baseURL}/user/balance`, {
@@ -111,17 +132,59 @@ async function fetchDeepSeekBalance(baseURL, apiKey, signal) {
111
132
  ...signal === void 0 ? {} : { signal }
112
133
  });
113
134
  } catch (error) {
114
- if (signal?.aborted) throw new LlmError("DeepSeek balance request aborted by caller", "ABORTED", { cause: error });
115
- throw new LlmError(`DeepSeek balance request to ${baseURL} failed`, "TRANSPORT", { cause: error });
135
+ return {
136
+ ok: false,
137
+ status: void 0,
138
+ error: signal?.aborted === true ? new LlmError("DeepSeek balance request aborted by caller", "ABORTED", { cause: error }) : new LlmError(`DeepSeek balance request to ${baseURL} failed`, "TRANSPORT", { cause: error })
139
+ };
116
140
  }
117
- if (!response.ok) throw new LlmError(`DeepSeek balance request failed (HTTP ${response.status})`, httpErrorCode(response.status), { status: response.status });
141
+ if (!response.ok) return {
142
+ ok: false,
143
+ status: response.status,
144
+ error: new LlmError(`DeepSeek balance request failed (HTTP ${response.status})`, httpErrorCode(response.status), { status: response.status })
145
+ };
118
146
  let body;
119
147
  try {
120
148
  body = await response.json();
121
149
  } catch (error) {
122
- throw new LlmError("DeepSeek balance response was not valid JSON", "TRANSPORT", { cause: error });
150
+ return {
151
+ ok: false,
152
+ status: response.status,
153
+ error: new LlmError("DeepSeek balance response was not valid JSON", "TRANSPORT", { cause: error })
154
+ };
155
+ }
156
+ return {
157
+ ok: true,
158
+ value: parseDeepSeekBalance(body)
159
+ };
160
+ }
161
+ /**
162
+ * Fetch one account-balance snapshot from `{baseURL}/user/balance`.
163
+ *
164
+ * Retries the faults that can plausibly clear — a dropped socket, a 429, a 5xx
165
+ * — up to {@link BALANCE_FETCH_ATTEMPTS} attempts, because the badge polls
166
+ * every few minutes and one lost packet would otherwise blank the balance
167
+ * until the next interval. Permanent answers (including a rejected key) fail
168
+ * on the first attempt.
169
+ * @param baseURL - resolved endpoint base; `/user/balance` is appended.
170
+ * @param apiKey - resolved bearer token for this endpoint.
171
+ * @param signal - optional cancellation.
172
+ * @returns the validated balance snapshot.
173
+ * @throws {@link LlmError} for transport, HTTP, or malformed-response failures.
174
+ */
175
+ async function fetchDeepSeekBalance(baseURL, apiKey, signal) {
176
+ for (let attempt = 1;; attempt += 1) {
177
+ const outcome = await requestBalanceOnce(baseURL, apiKey, signal);
178
+ if (outcome.ok) return outcome.value;
179
+ const exhausted = attempt >= 3;
180
+ const aborted = signal?.aborted === true;
181
+ if (!exhausted && !aborted && isRetryableStatus(outcome.status)) {
182
+ await waitBalanceRetry(150 * 2 ** (attempt - 1), signal);
183
+ continue;
184
+ }
185
+ if (outcome.status === 401 || outcome.status === 403) throw new LlmError(`DeepSeek balance request was rejected (HTTP ${outcome.status}) — the API key in effect for ${baseURL} is missing or unauthorized (check DEEPSEEK_API_KEY)`, httpErrorCode(outcome.status), { status: outcome.status });
186
+ throw outcome.error;
123
187
  }
124
- return parseDeepSeekBalance(body);
125
188
  }
126
189
  /**
127
190
  * Remote-only service exposing the DeepSeek account balance and session spend.
@@ -323,20 +386,307 @@ let DeepSeekBalanceGateway = (() => {
323
386
  };
324
387
  })();
325
388
  //#endregion
326
- //#region lib/types/billing.js
389
+ //#region lib/types/beijing-time.js
390
+ /**
391
+ * Beijing wall-clock arithmetic (Asia/Shanghai: a fixed UTC+8, no DST) — the
392
+ * calendar fields a sample's rate depends on, and the day boundaries a scan is
393
+ * clipped to. Split out of `billing.ts`: pricing asks this module questions, it
394
+ * never asks about pricing.
395
+ * @module @rayadesu/dsh-llm-billing/beijing-time
396
+ */
397
+ /** Beijing is a fixed UTC+8 offset with no DST. */
398
+ const BEIJING_OFFSET_MS = 288e5;
399
+ /** Milliseconds in one day. */
400
+ const DAY_MS = 864e5;
401
+ /** Epoch day of 1970-01-01 in the civil-date algorithm below. */
402
+ const CIVIL_EPOCH_DAY = 719468;
403
+ /** Two-digit zero pad for a calendar field. */
404
+ function pad2(value) {
405
+ return value < 10 ? `0${value}` : String(value);
406
+ }
327
407
  /**
328
- * DeepSeek billing: the peak/off-peak pricing table and the per-session spend
329
- * pricing. Pure functions over session events and the pricing table, so the
330
- * Remote gateway stays transport-free and the whole spend is testable without
331
- * a key.
408
+ * Civil date of an epoch day (Howard Hinnant's days-from-civil inverse):
409
+ * pure integer arithmetic, no `Date` allocation and no ISO-string slicing.
410
+ */
411
+ function civilDateOf(epochDay) {
412
+ const shifted = epochDay + CIVIL_EPOCH_DAY;
413
+ const era = Math.floor(shifted / 146097);
414
+ const dayOfEra = shifted - era * 146097;
415
+ const yearOfEra = Math.floor((dayOfEra - Math.floor(dayOfEra / 1460) + Math.floor(dayOfEra / 36524) - Math.floor(dayOfEra / 146096)) / 365);
416
+ const year = yearOfEra + era * 400;
417
+ const dayOfYear = dayOfEra - (365 * yearOfEra + Math.floor(yearOfEra / 4) - Math.floor(yearOfEra / 100));
418
+ const monthPrime = Math.floor((5 * dayOfYear + 2) / 153);
419
+ const month = monthPrime + (monthPrime < 10 ? 3 : -9);
420
+ return {
421
+ year: month <= 2 ? year + 1 : year,
422
+ month,
423
+ day: dayOfYear - Math.floor((153 * monthPrime + 2) / 5) + 1
424
+ };
425
+ }
426
+ /**
427
+ * Derive the Beijing hour, weekday, and calendar-day key of one timestamp with
428
+ * pure integer arithmetic — every timezone-sensitive read shares this one
429
+ * implementation, so the pieces cannot drift apart. Callers that filter by
430
+ * day and then price the same event reuse the returned view, so each event is
431
+ * parsed exactly once. (The hot fold path runs this per committed event; the
432
+ * previous `Date` + `toISOString().slice()` version allocated a `Date` and a
433
+ * 24-character string per call.)
434
+ * @param time - epoch milliseconds.
435
+ * @throws {RangeError} when `time` is not a finite number.
436
+ */
437
+ function beijingPartsOf(time) {
438
+ if (!Number.isFinite(time)) throw new RangeError(`billing: event time is not finite (${String(time)})`);
439
+ const shifted = time + BEIJING_OFFSET_MS;
440
+ const epochDay = Math.floor(shifted / DAY_MS);
441
+ const msOfDay = shifted - epochDay * DAY_MS;
442
+ const civil = civilDateOf(epochDay);
443
+ return {
444
+ time,
445
+ hour: Math.floor(msOfDay / 36e5),
446
+ weekday: ((epochDay + 4) % 7 + 7) % 7,
447
+ dayKey: `${civil.year}-${pad2(civil.month)}-${pad2(civil.day)}`
448
+ };
449
+ }
450
+ /** The Beijing (Asia/Shanghai, UTC+8, no DST) calendar-day key of a timestamp. */
451
+ function beijingDayKey(now) {
452
+ return beijingPartsOf(now.getTime()).dayKey;
453
+ }
454
+ /**
455
+ * The Beijing calendar day NAMED by `dayKey` as an epoch-millisecond range:
456
+ * `start` inclusive, `end` exclusive. Derived from the key itself rather than
457
+ * from a clock, because a scan prices whatever day it was asked for — today,
458
+ * or a day a caller is replaying.
332
459
  *
333
- * The per-event pricing lives in {@link priceEvent} and the fold in the
334
- * {@link SpendAccumulator}: the events-scan paths ({@link computeSessionSpend},
335
- * {@link computeTodaySpend}), the session-projection unit (`billingTodaySpend`
336
- * in projection.ts), and the scanner's single-pass events path all price
337
- * through the same primitives, so a pricing-table change cannot drift one path
338
- * from the others.
339
- * @module @rayadesu/dsh-llm-billing/billing
460
+ * The conversion needs no calendar arithmetic: `Date.UTC` builds midnight UTC
461
+ * of that civil date, and Beijing midnight is a fixed offset earlier (no DST).
462
+ * @param dayKey - a `YYYY-MM-DD` Beijing day key.
463
+ * @returns the day's bounds, or `undefined` when the key is not that shape.
464
+ */
465
+ function beijingDayRangeOfKey(dayKey) {
466
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dayKey);
467
+ if (match === null) return void 0;
468
+ const utcMidnight = Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
469
+ if (Number.isNaN(utcMidnight)) return void 0;
470
+ return {
471
+ start: utcMidnight - BEIJING_OFFSET_MS,
472
+ end: utcMidnight + DAY_MS - BEIJING_OFFSET_MS
473
+ };
474
+ }
475
+ //#endregion
476
+ //#region lib/types/spend.js
477
+ /**
478
+ * Spend arithmetic: how two spends combine, how one is inverted, and how the
479
+ * samples that could NOT be priced are tallied beside them. Pure, and knowing
480
+ * nothing about rates, timezones or event shapes — the pricing engine
481
+ * (`billing.ts`) owns all of that.
482
+ * @module @rayadesu/dsh-llm-billing/spend
483
+ */
484
+ /** A spend with no priced usage. */
485
+ function emptyTodaySpend() {
486
+ return {
487
+ total: 0,
488
+ models: []
489
+ };
490
+ }
491
+ /** Sum two usage tallies (pure); an absent side stays absent. */
492
+ function mergeUsageTally(left, right) {
493
+ if (left === void 0) return right;
494
+ if (right === void 0) return left;
495
+ return {
496
+ events: left.events + right.events,
497
+ tokens: left.tokens + right.tokens,
498
+ models: [.../* @__PURE__ */ new Set([...left.models, ...right.models])]
499
+ };
500
+ }
501
+ /** Add one affected sample to a tally (pure). */
502
+ function addUsageTally(previous, model, tokens) {
503
+ const models = previous?.models ?? [];
504
+ return {
505
+ events: (previous?.events ?? 0) + 1,
506
+ tokens: (previous?.tokens ?? 0) + tokens,
507
+ models: models.includes(model) ? models : [...models, model]
508
+ };
509
+ }
510
+ /**
511
+ * The advisory tallies carried BESIDE the priced rows, summed over `spends` as
512
+ * a partial you can spread into any spend literal. Keys stay absent rather
513
+ * than becoming `undefined` (`exactOptionalPropertyTypes`), so a fully priced
514
+ * spend stays structurally identical to what every existing test asserts.
515
+ */
516
+ function usageTalliesOf(spends) {
517
+ let unpriced;
518
+ for (const spend of spends) unpriced = mergeUsageTally(unpriced, spend.unpriced);
519
+ return { ...unpriced === void 0 ? {} : { unpriced } };
520
+ }
521
+ /** Note one sample that matched no pricing row and was therefore not billed. */
522
+ function noteUnpriced(spend, model, tokens) {
523
+ return {
524
+ total: spend.total,
525
+ models: spend.models,
526
+ unpriced: addUsageTally(spend.unpriced, model, tokens)
527
+ };
528
+ }
529
+ /** The today-spend shape of a single priced contribution. */
530
+ function contributionModel(priced) {
531
+ return {
532
+ model: priced.model,
533
+ displayName: priced.displayName,
534
+ cost: priced.cost,
535
+ peakCost: priced.peakCost,
536
+ offPeakCost: priced.offPeakCost,
537
+ cacheHitInputTokens: priced.cacheHitInputTokens,
538
+ cacheMissInputTokens: priced.cacheMissInputTokens,
539
+ outputTokens: priced.outputTokens,
540
+ cacheHitInputCost: priced.cacheHitInputCost,
541
+ cacheMissInputCost: priced.cacheMissInputCost,
542
+ outputCost: priced.outputCost
543
+ };
544
+ }
545
+ /** Sum two model rows of the same model (pure). */
546
+ function mergeModelRows(left, right) {
547
+ return {
548
+ model: left.model,
549
+ displayName: left.displayName,
550
+ cost: left.cost + right.cost,
551
+ peakCost: left.peakCost + right.peakCost,
552
+ offPeakCost: left.offPeakCost + right.offPeakCost,
553
+ cacheHitInputTokens: left.cacheHitInputTokens + right.cacheHitInputTokens,
554
+ cacheMissInputTokens: left.cacheMissInputTokens + right.cacheMissInputTokens,
555
+ outputTokens: left.outputTokens + right.outputTokens,
556
+ cacheHitInputCost: left.cacheHitInputCost + right.cacheHitInputCost,
557
+ cacheMissInputCost: left.cacheMissInputCost + right.cacheMissInputCost,
558
+ outputCost: left.outputCost + right.outputCost
559
+ };
560
+ }
561
+ /**
562
+ * Mutable model-row accumulator behind every spend fold. Rows keep first-seen
563
+ * model order — the same shape a pure `addEventContribution` chain produces —
564
+ * so the single-pass scan path and the pure public paths cannot diverge. One
565
+ * `Map` lookup per contribution instead of a per-event array copy: the huge
566
+ * event-log folds allocate one row object per model, not one intermediate
567
+ * array per event.
568
+ */
569
+ var SpendAccumulator = class {
570
+ rows = /* @__PURE__ */ new Map();
571
+ total = 0;
572
+ /** Add one priced contribution. */
573
+ add(priced) {
574
+ const row = contributionModel(priced);
575
+ const existing = this.rows.get(priced.model);
576
+ this.rows.set(priced.model, existing === void 0 ? row : mergeModelRows(existing, row));
577
+ this.total += priced.cost;
578
+ }
579
+ /** The folded spend; the accumulator stays usable afterwards. */
580
+ finish() {
581
+ return {
582
+ total: this.total,
583
+ models: [...this.rows.values()]
584
+ };
585
+ }
586
+ };
587
+ /** The additive inverse of one spend (pure): used to replace a priced sample. */
588
+ function negateSpend(spend) {
589
+ const negate = (value) => -value;
590
+ return {
591
+ total: negate(spend.total),
592
+ ...usageTalliesOf([spend]),
593
+ models: spend.models.map((row) => ({
594
+ ...row,
595
+ cost: negate(row.cost),
596
+ peakCost: negate(row.peakCost),
597
+ offPeakCost: negate(row.offPeakCost),
598
+ cacheHitInputTokens: negate(row.cacheHitInputTokens),
599
+ cacheMissInputTokens: negate(row.cacheMissInputTokens),
600
+ outputTokens: negate(row.outputTokens),
601
+ cacheHitInputCost: negate(row.cacheHitInputCost),
602
+ cacheMissInputCost: negate(row.cacheMissInputCost),
603
+ outputCost: negate(row.outputCost)
604
+ }))
605
+ };
606
+ }
607
+ /**
608
+ * Subtract one spend from another (pure). Rows that cancel out completely are
609
+ * dropped so a replaced sample leaves no zero row behind.
610
+ * @param target - the spend to subtract from.
611
+ * @param source - the spend to remove.
612
+ * @returns the difference.
613
+ */
614
+ function subtractSpend(target, source) {
615
+ const rows = /* @__PURE__ */ new Map();
616
+ for (const row of target.models) rows.set(row.model, row);
617
+ for (const row of source.models) {
618
+ const existing = rows.get(row.model);
619
+ if (existing === void 0) continue;
620
+ const next = mergeModelRows(existing, negateSpend({
621
+ total: 0,
622
+ models: [row]
623
+ }).models[0]);
624
+ if (next.cost === 0 && next.cacheHitInputTokens === 0 && next.cacheMissInputTokens === 0 && next.outputTokens === 0) rows.delete(row.model);
625
+ else rows.set(row.model, next);
626
+ }
627
+ return {
628
+ total: target.total - source.total,
629
+ models: [...rows.values()],
630
+ ...usageTalliesOf([target])
631
+ };
632
+ }
633
+ /** The contribution as a one-row spend (the shape a sample keeps for replacement). */
634
+ function contributionSpend(priced) {
635
+ return {
636
+ total: priced.cost,
637
+ models: [contributionModel(priced)]
638
+ };
639
+ }
640
+ /**
641
+ * Merge one priced event's contribution into an accumulator spend (pure:
642
+ * returns a new spend, never mutates its input).
643
+ * @param spend - the accumulator (per session and day, or across sessions).
644
+ * @param priced - the priced contribution to add.
645
+ * @returns the merged spend.
646
+ */
647
+ function addEventContribution(spend, priced) {
648
+ const row = contributionModel(priced);
649
+ const rows = spend.models.map((existing) => existing.model === priced.model ? mergeModelRows(existing, row) : existing);
650
+ if (!rows.some((existing) => existing.model === priced.model)) rows.push(row);
651
+ return {
652
+ total: spend.total + priced.cost,
653
+ models: rows,
654
+ ...usageTalliesOf([spend])
655
+ };
656
+ }
657
+ /**
658
+ * Sum two spends (per session and day, or across sessions) into one (pure:
659
+ * returns a new spend, never mutates its inputs).
660
+ * @param target - the accumulator spend.
661
+ * @param source - the spend to add.
662
+ * @returns the summed spend.
663
+ */
664
+ function mergeTodaySpend(target, source) {
665
+ const rows = /* @__PURE__ */ new Map();
666
+ for (const row of target.models) rows.set(row.model, row);
667
+ for (const row of source.models) {
668
+ const existing = rows.get(row.model);
669
+ rows.set(row.model, existing === void 0 ? row : mergeModelRows(existing, row));
670
+ }
671
+ return {
672
+ total: target.total + source.total,
673
+ models: [...rows.values()],
674
+ ...usageTalliesOf([target, source])
675
+ };
676
+ }
677
+ //#endregion
678
+ //#region lib/types/pricing-table.js
679
+ /**
680
+ * The published rate schedule: which models cost what, and since when.
681
+ *
682
+ * Data only — every policy decision about how a rate is chosen (revision
683
+ * lookup, peak windows, day boundaries) lives in `billing.ts`. Keeping them
684
+ * apart means adding a model never touches the pricing engine: the change is
685
+ * one row here, plus a test.
686
+ *
687
+ * Rates are CNY per 1M tokens. Rows sharing a model are that model's rate
688
+ * history (see {@link resolveBilling}).
689
+ * @module @rayadesu/dsh-llm-billing/pricing-table
340
690
  */
341
691
  /**
342
692
  * Published peak-hour windows (Beijing time): 09:00–12:00 and 14:00–18:00,
@@ -410,8 +760,8 @@ const V4_PRO_SWITCHED_RATES = {
410
760
  * series (V4.1 Flash, V4 Flash, V4 Flash Vision Exp) carries the second
411
761
  * revision effective 2026-09-10 12:00 Beijing, and the V4 Pro row the V4.1
412
762
  * Flash rates from its announced route switch (2026-09-14 12:00 Beijing) —
413
- * the MiMo-V2.5 series is untouched by either adjustment. Rows sharing a model
414
- * are that model's rate history.
763
+ * the MiMo series (V2.5, and V2.6 which kept V2.5's pricing) is untouched by
764
+ * either adjustment. Rows sharing a model are that model's rate history.
415
765
  */
416
766
  const DEFAULT_MODEL_PRICING = [
417
767
  {
@@ -488,8 +838,48 @@ const DEFAULT_MODEL_PRICING = [
488
838
  cacheMissInput: 1,
489
839
  output: 2
490
840
  }
841
+ },
842
+ {
843
+ model: "mimo-v2.6-pro",
844
+ peak: {
845
+ cacheHitInput: .025,
846
+ cacheMissInput: 3,
847
+ output: 6
848
+ },
849
+ offPeak: {
850
+ cacheHitInput: .025,
851
+ cacheMissInput: 3,
852
+ output: 6
853
+ }
854
+ },
855
+ {
856
+ model: "mimo-v2.6-flash",
857
+ peak: {
858
+ cacheHitInput: .02,
859
+ cacheMissInput: 1,
860
+ output: 2
861
+ },
862
+ offPeak: {
863
+ cacheHitInput: .02,
864
+ cacheMissInput: 1,
865
+ output: 2
866
+ }
491
867
  }
492
868
  ];
869
+ //#endregion
870
+ //#region lib/types/pricing.js
871
+ /**
872
+ * DeepSeek pricing: the peak/off-peak pricing table, the per-event pricing, and
873
+ * the resolved-billing shape. Pure functions over token usage and the pricing
874
+ * table, so the Remote gateway stays transport-free and the whole spend is
875
+ * testable without a key.
876
+ *
877
+ * The per-event pricing lives in {@link priceEvent} / {@link priceUsage}; the
878
+ * fold state machine that consumes it (`BillingFolder` / {@link applyBillingEvent})
879
+ * lives in `fold.ts`, and every scan path prices through these primitives so a
880
+ * pricing-table change cannot drift one path from the others.
881
+ * @module @rayadesu/dsh-llm-billing/pricing
882
+ */
493
883
  /**
494
884
  * Resolve optional configuration to a pricing table, defaulting omitted or
495
885
  * empty rows to the published rates. Schemastery materializes an absent
@@ -557,87 +947,6 @@ function ratesAt(revisions, time) {
557
947
  }
558
948
  return chosen;
559
949
  }
560
- /** Beijing is a fixed UTC+8 offset with no DST. */
561
- const BEIJING_OFFSET_MS = 288e5;
562
- /** Milliseconds in one day. */
563
- const DAY_MS = 864e5;
564
- /** Epoch day of 1970-01-01 in the civil-date algorithm below. */
565
- const CIVIL_EPOCH_DAY = 719468;
566
- /** Two-digit zero pad for a calendar field. */
567
- function pad2(value) {
568
- return value < 10 ? `0${value}` : String(value);
569
- }
570
- /**
571
- * Civil date of an epoch day (Howard Hinnant's days-from-civil inverse):
572
- * pure integer arithmetic, no `Date` allocation and no ISO-string slicing.
573
- */
574
- function civilDateOf(epochDay) {
575
- const shifted = epochDay + CIVIL_EPOCH_DAY;
576
- const era = Math.floor(shifted / 146097);
577
- const dayOfEra = shifted - era * 146097;
578
- const yearOfEra = Math.floor((dayOfEra - Math.floor(dayOfEra / 1460) + Math.floor(dayOfEra / 36524) - Math.floor(dayOfEra / 146096)) / 365);
579
- const year = yearOfEra + era * 400;
580
- const dayOfYear = dayOfEra - (365 * yearOfEra + Math.floor(yearOfEra / 4) - Math.floor(yearOfEra / 100));
581
- const monthPrime = Math.floor((5 * dayOfYear + 2) / 153);
582
- const month = monthPrime + (monthPrime < 10 ? 3 : -9);
583
- return {
584
- year: month <= 2 ? year + 1 : year,
585
- month,
586
- day: dayOfYear - Math.floor((153 * monthPrime + 2) / 5) + 1
587
- };
588
- }
589
- /**
590
- * Derive the Beijing hour, weekday, and calendar-day key of one timestamp with
591
- * pure integer arithmetic — every timezone-sensitive read shares this one
592
- * implementation, so the pieces cannot drift apart. Callers that filter by
593
- * day and then price the same event reuse the returned view, so each event is
594
- * parsed exactly once. (The hot fold path runs this per committed event; the
595
- * previous `Date` + `toISOString().slice()` version allocated a `Date` and a
596
- * 24-character string per call.)
597
- * @param time - epoch milliseconds.
598
- * @throws {RangeError} when `time` is not a finite number.
599
- */
600
- function beijingPartsOf(time) {
601
- if (!Number.isFinite(time)) throw new RangeError(`billing: event time is not finite (${String(time)})`);
602
- const shifted = time + BEIJING_OFFSET_MS;
603
- const epochDay = Math.floor(shifted / DAY_MS);
604
- const msOfDay = shifted - epochDay * DAY_MS;
605
- const civil = civilDateOf(epochDay);
606
- return {
607
- time,
608
- hour: Math.floor(msOfDay / 36e5),
609
- weekday: ((epochDay + 4) % 7 + 7) % 7,
610
- dayKey: `${civil.year}-${pad2(civil.month)}-${pad2(civil.day)}`
611
- };
612
- }
613
- /** The Beijing (Asia/Shanghai, UTC+8, no DST) calendar-day key of a timestamp. */
614
- function beijingDayKey(now) {
615
- return beijingPartsOf(now.getTime()).dayKey;
616
- }
617
- /**
618
- * The durable inherited-prefix boundary of one session: the number of leading
619
- * events it inherited verbatim from its fork source, 0 for a session created
620
- * without a seed. A forked session (or any seeded replay) carries that count
621
- * in its session state; every event with `seq < seedLength` is a copy of an
622
- * event already billed in that source session, so pricing must skip them or
623
- * the same model output is counted once per copy. Accepts the durable field
624
- * of both DSH runtime families (see {@link ForkBoundarySource}).
625
- * @param source - the session, inspection result, header slice, or durable
626
- * header carrying the boundary; `undefined` reads as 0.
627
- * @returns the inherited-prefix length; 0 for an unseeded session.
628
- */
629
- function forkBoundaryOf(source) {
630
- if (source === void 0) return 0;
631
- const inherited = source.inheritedEventCount;
632
- if (inherited !== void 0 && Number.isSafeInteger(inherited)) return inherited;
633
- return source.header?.seedLength ?? source.meta?.seedLength ?? source.seedLength ?? 0;
634
- }
635
- /** Whether a durable header marks a fork-inherited (seeded) session across both runtime families. */
636
- function isSeededSession(header) {
637
- if (header === void 0) return false;
638
- if (header.isSeeded === true) return true;
639
- return (header.seedLength ?? 0) > 0;
640
- }
641
950
  /** Whether a Beijing (hour, weekday) pair falls inside any peak-hour window. */
642
951
  function isPeakParts(billing, hour, weekday) {
643
952
  if (weekday === 0 || weekday === 6) return false;
@@ -688,6 +997,31 @@ function priceEventAt(parts, event, billing, names) {
688
997
  if (reported === void 0) return void 0;
689
998
  return priceUsage(parts, reported, event.data.message.source.model, billing, names);
690
999
  }
1000
+ /** Every token one usage sample reports, whether or not it gets priced. */
1001
+ function usageTokens(usage) {
1002
+ return usage.inputTokens + usage.outputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0);
1003
+ }
1004
+ /** Max distinct model ids remembered for the once-only warning. */
1005
+ const UNPRICED_WARN_LIMIT = 64;
1006
+ /** Model ids already reported, so a long-running host logs each one once. */
1007
+ const reportedUnpricedModels = /* @__PURE__ */ new Set();
1008
+ /**
1009
+ * Report a model carrying no rate row, at most once per id (and at most
1010
+ * {@link UNPRICED_WARN_LIMIT} ids per process). Every figure reads as zero when
1011
+ * the upstream model is unknown, which looks exactly like "no usage" rather
1012
+ * than a missing row — the one thing this warning exists to separate.
1013
+ *
1014
+ * Deliberately NOT guessed: model ids are chosen by whoever ships the model, so
1015
+ * a similar-looking name says nothing about a similar price — and a wrong number
1016
+ * that LOOKS authoritative is worse than a visible gap. The fix is one row under
1017
+ * `billing.models`, which is exactly what the warning points at.
1018
+ * @param model - the unlisted wire model id.
1019
+ */
1020
+ function reportUnpricedModel(model) {
1021
+ if (reportedUnpricedModels.has(model) || reportedUnpricedModels.size >= UNPRICED_WARN_LIMIT) return;
1022
+ reportedUnpricedModels.add(model);
1023
+ console.warn(`[llm-billing] no pricing row for model "${model}" — its usage is NOT billed; add its row under billing.models to price it`);
1024
+ }
691
1025
  /**
692
1026
  * Price one provider-reported usage sample for one model at the rates of the
693
1027
  * sample's own Beijing-time hour and weekday — the peak or off-peak price of
@@ -729,115 +1063,17 @@ function priceUsage(parts, usage, model, billing, names) {
729
1063
  outputCost
730
1064
  };
731
1065
  }
732
- /** A spend with no priced usage. */
733
- function emptyTodaySpend() {
734
- return {
735
- total: 0,
736
- models: []
737
- };
738
- }
739
- /** The today-spend shape of a single priced contribution. */
740
- function contributionModel(priced) {
741
- return {
742
- model: priced.model,
743
- displayName: priced.displayName,
744
- cost: priced.cost,
745
- peakCost: priced.peakCost,
746
- offPeakCost: priced.offPeakCost,
747
- cacheHitInputTokens: priced.cacheHitInputTokens,
748
- cacheMissInputTokens: priced.cacheMissInputTokens,
749
- outputTokens: priced.outputTokens,
750
- cacheHitInputCost: priced.cacheHitInputCost,
751
- cacheMissInputCost: priced.cacheMissInputCost,
752
- outputCost: priced.outputCost
753
- };
754
- }
755
- /** Sum two model rows of the same model (pure). */
756
- function mergeModelRows(left, right) {
757
- return {
758
- model: left.model,
759
- displayName: left.displayName,
760
- cost: left.cost + right.cost,
761
- peakCost: left.peakCost + right.peakCost,
762
- offPeakCost: left.offPeakCost + right.offPeakCost,
763
- cacheHitInputTokens: left.cacheHitInputTokens + right.cacheHitInputTokens,
764
- cacheMissInputTokens: left.cacheMissInputTokens + right.cacheMissInputTokens,
765
- outputTokens: left.outputTokens + right.outputTokens,
766
- cacheHitInputCost: left.cacheHitInputCost + right.cacheHitInputCost,
767
- cacheMissInputCost: left.cacheMissInputCost + right.cacheMissInputCost,
768
- outputCost: left.outputCost + right.outputCost
769
- };
770
- }
771
- /**
772
- * Mutable model-row accumulator behind every spend fold. Rows keep first-seen
773
- * model order — the same shape a pure `addEventContribution` chain produces —
774
- * so the single-pass scan path and the pure public paths cannot diverge. One
775
- * `Map` lookup per contribution instead of a per-event array copy: the huge
776
- * event-log folds allocate one row object per model, not one intermediate
777
- * array per event.
778
- */
779
- var SpendAccumulator = class {
780
- rows = /* @__PURE__ */ new Map();
781
- total = 0;
782
- /** Add one priced contribution. */
783
- add(priced) {
784
- const row = contributionModel(priced);
785
- const existing = this.rows.get(priced.model);
786
- this.rows.set(priced.model, existing === void 0 ? row : mergeModelRows(existing, row));
787
- this.total += priced.cost;
788
- }
789
- /** The folded spend; the accumulator stays usable afterwards. */
790
- finish() {
791
- return {
792
- total: this.total,
793
- models: [...this.rows.values()]
794
- };
795
- }
796
- };
797
- /** The additive inverse of one spend (pure): used to replace a priced sample. */
798
- function negateSpend(spend) {
799
- const negate = (value) => -value;
800
- return {
801
- total: negate(spend.total),
802
- models: spend.models.map((row) => ({
803
- ...row,
804
- cost: negate(row.cost),
805
- peakCost: negate(row.peakCost),
806
- offPeakCost: negate(row.offPeakCost),
807
- cacheHitInputTokens: negate(row.cacheHitInputTokens),
808
- cacheMissInputTokens: negate(row.cacheMissInputTokens),
809
- outputTokens: negate(row.outputTokens),
810
- cacheHitInputCost: negate(row.cacheHitInputCost),
811
- cacheMissInputCost: negate(row.cacheMissInputCost),
812
- outputCost: negate(row.outputCost)
813
- }))
814
- };
815
- }
1066
+ //#endregion
1067
+ //#region lib/types/fold.js
816
1068
  /**
817
- * Subtract one spend from another (pure). Rows that cancel out completely are
818
- * dropped so a replaced sample leaves no zero row behind.
819
- * @param target - the spend to subtract from.
820
- * @param source - the spend to remove.
821
- * @returns the difference.
1069
+ * The single-pass fold state machine that turns a committed event log into a
1070
+ * session's billed spend. Consumes the pricing primitives in `pricing.ts`
1071
+ * (`priceUsage` / `reportUnpricedModel`) and the spend algebra in `spend.ts`
1072
+ * (`addEventContribution` / `subtractSpend` / `noteUnpriced` / `emptyTodaySpend`);
1073
+ * every scan path prices through {@link applyBillingEvent} so a pricing-table
1074
+ * change cannot drift one path from the others.
1075
+ * @module @rayadesu/dsh-llm-billing/fold
822
1076
  */
823
- function subtractSpend(target, source) {
824
- const rows = /* @__PURE__ */ new Map();
825
- for (const row of target.models) rows.set(row.model, row);
826
- for (const row of source.models) {
827
- const existing = rows.get(row.model);
828
- if (existing === void 0) continue;
829
- const next = mergeModelRows(existing, negateSpend({
830
- total: 0,
831
- models: [row]
832
- }).models[0]);
833
- if (next.cost === 0 && next.cacheHitInputTokens === 0 && next.cacheMissInputTokens === 0 && next.outputTokens === 0) rows.delete(row.model);
834
- else rows.set(row.model, next);
835
- }
836
- return {
837
- total: target.total - source.total,
838
- models: [...rows.values()]
839
- };
840
- }
841
1077
  /** The empty fold state for one fork boundary. */
842
1078
  function emptyBillingFoldState(inheritedEventCount = 0) {
843
1079
  return {
@@ -870,13 +1106,6 @@ function streamUsageOf(event) {
870
1106
  return isTokenUsage(chunk.usage) ? chunk.usage : void 0;
871
1107
  }
872
1108
  }
873
- /** The contribution as a one-row spend (the shape a sample keeps for replacement). */
874
- function contributionSpend(priced) {
875
- return {
876
- total: priced.cost,
877
- models: [contributionModel(priced)]
878
- };
879
- }
880
1109
  /**
881
1110
  * Fold one committed event into a session's billed-spend state.
882
1111
  *
@@ -918,7 +1147,20 @@ function applyBillingEvent(state, event, billing, names) {
918
1147
  if (!isTokenUsage(usage)) return state;
919
1148
  const model = type === "assistant/message" ? data?.message?.source?.model : state.model;
920
1149
  if (typeof model !== "string" || model.length === 0) return state;
921
- const priced = priceUsage(beijingPartsOf(event.time), usage, model, billing, names);
1150
+ const parts = beijingPartsOf(event.time);
1151
+ if (billing.models.get(model) === void 0) {
1152
+ reportUnpricedModel(model);
1153
+ const tokens = usageTokens(usage);
1154
+ const opens = state.dayKey === "" || parts.dayKey > state.dayKey;
1155
+ const sameDay = parts.dayKey === state.dayKey;
1156
+ return {
1157
+ ...state,
1158
+ dayKey: opens ? parts.dayKey : state.dayKey,
1159
+ spend: opens ? noteUnpriced(emptyTodaySpend(), model, tokens) : sameDay ? noteUnpriced(state.spend, model, tokens) : state.spend,
1160
+ session: noteUnpriced(state.session, model, tokens)
1161
+ };
1162
+ }
1163
+ const priced = priceUsage(parts, usage, model, billing, names);
922
1164
  if (priced === void 0) return state;
923
1165
  let session = state.session;
924
1166
  let spend = state.spend;
@@ -980,40 +1222,43 @@ var BillingFolder = class {
980
1222
  return this.state;
981
1223
  }
982
1224
  };
983
- /**
984
- * Merge one priced event's contribution into an accumulator spend (pure:
985
- * returns a new spend, never mutates its input).
986
- * @param spend - the accumulator (per session and day, or across sessions).
987
- * @param priced - the priced contribution to add.
988
- * @returns the merged spend.
1225
+ //#endregion
1226
+ //#region lib/types/billing.js
1227
+ /**
1228
+ * DeepSeek billing aggregation: the session/turn/today spend computations
1229
+ * over the pricing engine (`pricing.ts`) and the per-event fold state machine
1230
+ * (`fold.ts`). Pure functions over session events, so the whole spend is
1231
+ * testable without a key and the Remote gateway stays transport-free.
1232
+ *
1233
+ * The fold in {@link BillingFolder} / {@link applyBillingEvent} (see `fold.ts`)
1234
+ * and the per-event pricing in `pricing.ts` are the single source of truth the
1235
+ * events-scan paths, the session-projection unit, and the scanner all price
1236
+ * through, so a pricing-table change cannot drift one path from the others.
1237
+ * @module @rayadesu/dsh-llm-billing/billing
989
1238
  */
990
- function addEventContribution(spend, priced) {
991
- const row = contributionModel(priced);
992
- const rows = spend.models.map((existing) => existing.model === priced.model ? mergeModelRows(existing, row) : existing);
993
- if (!rows.some((existing) => existing.model === priced.model)) rows.push(row);
994
- return {
995
- total: spend.total + priced.cost,
996
- models: rows
997
- };
998
- }
999
1239
  /**
1000
- * Sum two spends (per session and day, or across sessions) into one (pure:
1001
- * returns a new spend, never mutates its inputs).
1002
- * @param target - the accumulator spend.
1003
- * @param source - the spend to add.
1004
- * @returns the summed spend.
1240
+ * The durable inherited-prefix boundary of one session: the number of leading
1241
+ * events it inherited verbatim from its fork source, 0 for a session created
1242
+ * without a seed. A forked session (or any seeded replay) carries that count
1243
+ * in its session state; every event with `seq < seedLength` is a copy of an
1244
+ * event already billed in that source session, so pricing must skip them or
1245
+ * the same model output is counted once per copy. Accepts the durable field
1246
+ * of the live-session and handle surfaces (see {@link ForkBoundarySource}).
1247
+ * @param source - the session, header slice, or opened handle carrying the
1248
+ * boundary; `undefined` reads as 0.
1249
+ * @returns the inherited-prefix length; 0 for an unseeded session.
1005
1250
  */
1006
- function mergeTodaySpend(target, source) {
1007
- const rows = /* @__PURE__ */ new Map();
1008
- for (const row of target.models) rows.set(row.model, row);
1009
- for (const row of source.models) {
1010
- const existing = rows.get(row.model);
1011
- rows.set(row.model, existing === void 0 ? row : mergeModelRows(existing, row));
1012
- }
1013
- return {
1014
- total: target.total + source.total,
1015
- models: [...rows.values()]
1016
- };
1251
+ function forkBoundaryOf(source) {
1252
+ if (source === void 0) return 0;
1253
+ const inherited = source.inheritedEventCount;
1254
+ if (inherited !== void 0 && Number.isSafeInteger(inherited)) return inherited;
1255
+ return source.header?.seedLength ?? source.seedLength ?? 0;
1256
+ }
1257
+ /** Whether a durable header marks a fork-inherited (seeded) session across the live-session and handle surfaces. */
1258
+ function isSeededSession(header) {
1259
+ if (header === void 0) return false;
1260
+ if (header.isSeeded === true) return true;
1261
+ return (header.seedLength ?? 0) > 0;
1017
1262
  }
1018
1263
  /**
1019
1264
  * Price one session's complete event log at the official per-model rates,
@@ -1196,9 +1441,10 @@ function computeSessionTurnSpends(events, billing, catalog) {
1196
1441
  */
1197
1442
  function computeTodaySpend(events, billing, catalog, now = /* @__PURE__ */ new Date()) {
1198
1443
  const day = beijingDayKey(now);
1444
+ const dayEnd = beijingDayRangeOfKey(day)?.end ?? Number.POSITIVE_INFINITY;
1199
1445
  const folder = new BillingFolder(billing, catalog);
1200
1446
  for (const event of events) {
1201
- if (beijingPartsOf(event.time).dayKey > day) continue;
1447
+ if (event.time >= dayEnd) continue;
1202
1448
  folder.add(event);
1203
1449
  }
1204
1450
  return folder.fold.dayKey === day ? folder.fold.spend : emptyTodaySpend();
@@ -1238,9 +1484,23 @@ const modelRowSchema = z$1.object({
1238
1484
  cacheMissInputCost: z$1.number().nonnegative(),
1239
1485
  outputCost: z$1.number().nonnegative()
1240
1486
  }).strict();
1487
+ /**
1488
+ * The advisory tally of usage that could not be billed at its own rate (see
1489
+ * `DeepSeekTodaySpend.unpriced`). Declared here because the
1490
+ * unit's schemas are `.strict()`: an undeclared key REJECTS the whole unit
1491
+ * rather than being stripped, so adding these fields to the fold without
1492
+ * declaring them here would fail every projection fold the moment a sample
1493
+ * went unpriced.
1494
+ */
1495
+ const usageTallySchema = z$1.object({
1496
+ events: z$1.number().int().nonnegative(),
1497
+ tokens: z$1.number().int().nonnegative(),
1498
+ models: z$1.array(z$1.string())
1499
+ }).strict();
1241
1500
  const todaySpendSchema = z$1.object({
1242
1501
  total: z$1.number().nonnegative(),
1243
- models: z$1.array(modelRowSchema)
1502
+ models: z$1.array(modelRowSchema),
1503
+ unpriced: usageTallySchema.optional()
1244
1504
  }).strict();
1245
1505
  const billingUnitSchema = z$1.object({
1246
1506
  dayKey: z$1.string(),
@@ -1310,67 +1570,13 @@ function foldOwnBilling(unit, events, seedLength = 0) {
1310
1570
  return state;
1311
1571
  }
1312
1572
  //#endregion
1313
- //#region lib/types/today-spend.js
1314
- /**
1315
- * Today-spend read path: the 60-second Beijing-day cache with in-flight
1316
- * coalescing and a force bypass (plan A1), plus the two scan strategies that
1317
- * compute the aggregate behind a cache miss:
1318
- *
1319
- * - projection path (plan C): live sessions read their eagerly folded
1320
- * `billingTodaySpend` projection cell; cold sessions are answered from the
1321
- * zero-I/O projection-cache row whenever that row's own day is not the
1322
- * queried one, and otherwise resolved through one detached local fold over
1323
- * a full `inspect`. Persisted revisions gate every cold read, so a session
1324
- * whose log did not change since the last resolution costs nothing — and a
1325
- * failed resolution is remembered by revision instead of being retried on
1326
- * every scan.
1327
- * - events path (plans A2/A3): collect and price only today's events in one
1328
- * pass (per-event Beijing-day filter during collection) with a hard cap,
1329
- * adopting the fold it already priced for a session whose persisted revision
1330
- * is unchanged since the last pass.
1331
- *
1332
- * Both strategies run behind the same {@link TodaySpendCache}, so a miss
1333
- * happens at most once per 60 seconds per process, and a manual refresh
1334
- * (`force`) bypasses the time window but keeps the revision caches — an
1335
- * unchanged log provably cannot change the aggregate. That proof is what makes
1336
- * a revision gate a CACHE: both strategies therefore adopt the resolution they
1337
- * remember for an unchanged revision instead of skipping the session, so an
1338
- * unchanged log costs no I/O and still contributes its full spend (and title)
1339
- * to the aggregate and the ranking on every scan.
1340
- *
1341
- * The per-session ranking is a per-CONVERSATION ranking: a subagent child is
1342
- * work the delegating conversation paid for, not a session the user opened, so
1343
- * every subagent row is folded into the row of the top-level session at the
1344
- * root of its `parentSession` chain (see {@link rollUpSubagentSpend}). The
1345
- * aggregate is unaffected — it sums the same sessions either way.
1346
- *
1347
- * Forked sessions never double-count: a fork child's log opens with a
1348
- * verbatim copy of its source session's events (its inherited boundary), so
1349
- * the scanner prices only the child's OWN events on every path. The
1350
- * `billingTodaySpend` unit is boundary-aware (its state carries the inherited
1351
- * cut, and `apply` skips events below it), so the eager cell is correct for a
1352
- * fork child; the cold path skips the projection cache for a seeded session
1353
- * (its cached row may predate the boundary) and folds its own events with the
1354
- * durable cut instead. The boundary is the durable session state, read across
1355
- * both DSH runtime families — a resumed fork child keeps its original
1356
- * boundary and an unseeded session stays at 0.
1357
- *
1358
- * The live `Session` log surface changed in 0.1.2-alpha.4: `Session.events`
1359
- * was removed and replaced by `Session.snapshotEvents()` / `ownEvents()`, and
1360
- * `SessionHeader.seedLength` moved to `Session.inheritedEventCount` (the
1361
- * persistence `inspect` result carries it alongside `meta`). Reads go through
1362
- * {@link liveSessionEvents} / {@link forkBoundaryOf}, which accept both
1363
- * families structurally, so the scanner runs on the ≤ 0.1.1-rc.2 npm baseline
1364
- * and on the newer runtime.
1365
- * @module @rayadesu/dsh-llm-billing/today-spend
1366
- */
1573
+ //#region lib/types/session-lineage.js
1367
1574
  /**
1368
- * Fold one session's durable display title: the latest `session/title`
1369
- * event's text (last-wins, matching the `title` projection), or `null` before
1370
- * the first title lands. The fold runs over the complete log, so an explicit
1371
- * user rename is picked up as soon as its event commits.
1372
- * @param events - one session's complete event log.
1373
- * @returns the session's current title, or `null` when untitled.
1575
+ * Session lineage and titles: which session delegated which, where a subtree's
1576
+ * spend belongs, and the title a ranking row is labelled with. Split out of
1577
+ * `today-spend.ts` because neither the cache nor the scan strategies care
1578
+ * about lineage for their own sake — they only ask for it.
1579
+ * @module @rayadesu/dsh-llm-billing/session-lineage
1374
1580
  */
1375
1581
  function foldSessionTitle(events) {
1376
1582
  for (let index = events.length - 1; index >= 0; index--) {
@@ -1455,68 +1661,23 @@ function rollUpSubagentSpend(rows, lineage, titles = /* @__PURE__ */ new Map())
1455
1661
  return [...merged.values()].sort((left, right) => right.total - left.total);
1456
1662
  }
1457
1663
  /**
1458
- * Read one live session's complete event log across both runtime families.
1459
- * @throws when the session exposes neither the legacy `events` snapshot nor
1460
- * the newer `snapshotEvents()` reader — an unknown runtime surface must
1461
- * fail loudly rather than silently price an empty log.
1462
- */
1463
- function liveSessionEvents(session) {
1464
- if (session.events !== void 0) return session.events;
1465
- if (session.snapshotEvents !== void 0) return session.snapshotEvents();
1466
- throw new Error("llm-billing: session log surface is neither Session.events nor Session.snapshotEvents");
1467
- }
1468
- /**
1469
- * Unwrap a handle read across both return shapes.
1470
- * @param read - the handle's read result.
1471
- * @returns the event array.
1472
- */
1473
- function handleReadEvents(read) {
1474
- if (Array.isArray(read)) return read;
1475
- return read.events;
1476
- }
1477
- function isHandlePersistence(persistence) {
1478
- return typeof persistence.open === "function";
1479
- }
1480
- /**
1481
- * List every stored session snapshot across both persistence runtime families:
1482
- * `listSnapshots` (≤ 0.1.1-rc.2) or `list` (0.1.2-alpha.5+).
1483
- * @param persistence - the persistence service slice.
1484
- * @returns one snapshot per stored session.
1664
+ * Structural slice of a live session's header: the fork boundary of both DSH
1665
+ * runtime families plus the delegation lineage the ranking roll-up reads.
1485
1666
  */
1486
- function persistenceListSnapshots(persistence) {
1487
- return isHandlePersistence(persistence) ? persistence.list() : persistence.listSnapshots();
1488
- }
1667
+ //#endregion
1668
+ //#region lib/types/cache.js
1489
1669
  /**
1490
- * Read one stored session's complete event log and durable inherited boundary
1491
- * across both persistence runtime families: legacy `inspect` (≤ 0.1.1-rc.2)
1492
- * or `open` + handle `read` (0.1.2-alpha.5+; the handle is closed after the
1493
- * read). Both throw when the session does not exist.
1494
- * @param persistence - the persistence service slice.
1495
- * @param id - the stored session to read.
1496
- * @returns the session's complete event log plus its inherited-prefix boundary.
1670
+ * The read-path cache: one 60-second Beijing-day window per process with
1671
+ * in-flight coalescing and stale-while-revalidate, plus the bounded fan-out
1672
+ * helper the cold scan parallelises with. Split out of `today-spend.ts` — it is
1673
+ * the one piece that knows nothing about sessions at all.
1674
+ * @module @rayadesu/dsh-llm-billing/cache
1497
1675
  */
1498
- async function persistenceInspect(persistence, id) {
1499
- if (isHandlePersistence(persistence)) {
1500
- const handle = await persistence.open(id, "read");
1501
- try {
1502
- return {
1503
- events: handleReadEvents(await handle.read()),
1504
- seedLength: forkBoundaryOf(handle)
1505
- };
1506
- } finally {
1507
- await handle.close();
1508
- }
1509
- }
1510
- const inspection = await persistence.inspect(id);
1511
- return {
1512
- events: inspection.events,
1513
- seedLength: forkBoundaryOf(inspection)
1514
- };
1515
- }
1516
1676
  /**
1517
1677
  * Bounded parallel fan-out: run `run` over `items` with at most `limit` in
1518
1678
  * flight. A shared index counter hands each worker its next job, so the
1519
- * dispatch is O(n) overall (array `shift()` would be O(n) per pop).
1679
+ * dispatch is O(n) overall (array `shift()` would be O(n) per pop). The cold
1680
+ * scan parallelises its session reads through this.
1520
1681
  */
1521
1682
  async function withConcurrency(items, limit, run) {
1522
1683
  const total = items.length;
@@ -1529,10 +1690,29 @@ async function withConcurrency(items, limit, run) {
1529
1690
  }));
1530
1691
  }
1531
1692
  /**
1693
+ * Hand the host's event loop back between session folds.
1694
+ *
1695
+ * The folds are synchronous CPU work — the pricing fold and `foldSessionTitle`
1696
+ * walk a whole log without an `await` — and they run on the same main loop that
1697
+ * serves the GUI's own round trips (switching model, creating a session). A
1698
+ * scan that never yields blocks those requests for its entire duration, which
1699
+ * is exactly what a cold scan did. `setImmediate` schedules the continuation
1700
+ * for the check phase, so the poll phase is served first: pending I/O — the
1701
+ * GUI's requests among it — completes before the next slice of folding.
1702
+ */
1703
+ function yieldToEventLoop() {
1704
+ return new Promise((resolve) => {
1705
+ setImmediate(resolve);
1706
+ });
1707
+ }
1708
+ /**
1532
1709
  * The A1 cache: one Beijing-day key + a 60s window, an in-flight promise that
1533
- * coalesces concurrent misses, and a `force` bypass for the manual refresh
1534
- * path. Cross-day invalidation is automatic (the day key changes); a failed
1535
- * scan leaves the previous value in place and retries on the next call.
1710
+ * coalesces concurrent misses, a stale-while-revalidate path so a lapsed window
1711
+ * never makes a plain reader wait on the day's scan, and a `force` bypass that
1712
+ * makes a reader wait for a fresh scan instead. Cross-day invalidation is
1713
+ * automatic (the day key changes, and the previous day's value is never
1714
+ * served); a failed scan leaves the previous value in place and retries on the
1715
+ * next call.
1536
1716
  * @typeParam T - the cached aggregate's value shape (the spend or its
1537
1717
  * per-session breakdown).
1538
1718
  */
@@ -1556,26 +1736,60 @@ var TodaySpendCache = class {
1556
1736
  }
1557
1737
  /**
1558
1738
  * Read today's spend, cached per Beijing day within the TTL window.
1559
- * @param force - bypass the time window (manual refresh); the day-key gate
1560
- * and the in-flight coalescing still apply to non-force callers.
1739
+ *
1740
+ * Two reads share this entry point, and `force` is the switch between them —
1741
+ * its two values are exactly the two policies a caller can want, so there is
1742
+ * no third mode:
1743
+ *
1744
+ * - **plain (`force: false`)** — stale-while-revalidate. A value for the
1745
+ * queried day is returned AT ONCE; once its window has lapsed the scan that
1746
+ * refreshes it runs behind the answer. This is the read for everything the
1747
+ * user did not just cause: a mount, a panel open, a poll, a session switch.
1748
+ * - **forced (`force: true`)** — recompute now and WAIT: both the window and
1749
+ * the stale shortcut are bypassed. This is the read for a manual refresh and
1750
+ * for a settled turn, which have just changed the answer and must not be
1751
+ * handed the value from before they ran. A plain read there would report the
1752
+ * day's pre-turn figure one turn late.
1753
+ *
1754
+ * A day with no value yet (the first read of the day, or a Beijing-day
1755
+ * rollover — yesterday's total is never served as today's) waits either way,
1756
+ * because there is nothing honest to show. A failed revalidation leaves the
1757
+ * previous value in place, and a pass already in flight is joined rather than
1758
+ * duplicated.
1759
+ * @param force - `true` recomputes now and waits; `false` serves the value on
1760
+ * hand and refreshes behind it.
1561
1761
  * @returns today's spend.
1562
1762
  */
1563
1763
  get(force = false) {
1564
1764
  const now = this.now();
1565
1765
  const dayKey = beijingDayKey(now);
1566
- if (!force && this.cachedDayKey === dayKey && this.cachedValue !== void 0 && now.getTime() - this.cachedAt < this.ttlMs) return Promise.resolve(this.cachedValue);
1766
+ const cached = this.cachedDayKey === dayKey ? this.cachedValue : void 0;
1767
+ if (!force && cached !== void 0) {
1768
+ if (now.getTime() - this.cachedAt < this.ttlMs) return Promise.resolve(cached);
1769
+ if (this.inFlight === void 0) this.run(dayKey).catch(() => void 0);
1770
+ return Promise.resolve(cached);
1771
+ }
1567
1772
  if (this.inFlight !== void 0) return this.inFlight;
1568
- const run = (async () => {
1569
- try {
1570
- const value = await this.scan(dayKey);
1571
- this.cachedDayKey = dayKey;
1572
- this.cachedValue = value;
1573
- this.cachedAt = now.getTime();
1574
- return value;
1575
- } finally {
1576
- this.inFlight = void 0;
1577
- }
1578
- })();
1773
+ return this.run(dayKey);
1774
+ }
1775
+ /**
1776
+ * Run one scan and publish its result.
1777
+ *
1778
+ * `cachedAt` is stamped when the scan COMPLETES, not when it starts: a pass
1779
+ * slower than the TTL would otherwise be born expired, and every read after
1780
+ * it would start another pass back-to-back.
1781
+ * @param dayKey - the Beijing day the pass aggregates.
1782
+ * @returns the freshly scanned value.
1783
+ */
1784
+ run(dayKey) {
1785
+ const run = this.scan(dayKey).then((value) => {
1786
+ this.cachedDayKey = dayKey;
1787
+ this.cachedValue = value;
1788
+ this.cachedAt = this.now().getTime();
1789
+ return value;
1790
+ }).finally(() => {
1791
+ this.inFlight = void 0;
1792
+ });
1579
1793
  this.inFlight = run;
1580
1794
  return run;
1581
1795
  }
@@ -1584,6 +1798,121 @@ var TodaySpendCache = class {
1584
1798
  const COLD_RESOLVE_CACHE_LIMIT = 1024;
1585
1799
  /** Max session-ids kept in the scanner's cold-failure cache before eviction. */
1586
1800
  const COLD_FAILED_CACHE_LIMIT = 1024;
1801
+ /** The live SessionStore slice a scan reads (resolved once per scan). */
1802
+ //#endregion
1803
+ //#region lib/types/persistence.js
1804
+ /**
1805
+ * The persistence seams a cold scan reads through. DSH ships a single
1806
+ * persistence runtime family — the handle-based `list` / `open` +
1807
+ * `SessionHandle` surface (0.1.2-alpha.5+, the only one the plugin's
1808
+ * `^0.1.7-alpha.2` peer targets). This module is the only place that knows
1809
+ * the handle shape. Split out of `today-spend.ts` so the scan strategies read
1810
+ * one shape.
1811
+ * @module @rayadesu/dsh-llm-billing/persistence
1812
+ */
1813
+ /**
1814
+ * Read one live session's complete event log across both runtime families.
1815
+ * @throws when the session exposes neither the legacy `events` snapshot nor
1816
+ * the newer `snapshotEvents()` reader — an unknown runtime surface must
1817
+ * fail loudly rather than silently price an empty log.
1818
+ */
1819
+ function liveSessionEvents(session) {
1820
+ if (session.events !== void 0) return session.events;
1821
+ if (session.snapshotEvents !== void 0) return session.snapshotEvents();
1822
+ throw new Error("llm-billing: session log surface is neither Session.events nor Session.snapshotEvents");
1823
+ }
1824
+ /**
1825
+ * Unwrap a handle read across both return shapes.
1826
+ * @param read - the handle's read result.
1827
+ * @returns the event array.
1828
+ */
1829
+ function handleReadEvents(read) {
1830
+ if (Array.isArray(read)) return read;
1831
+ return read.events;
1832
+ }
1833
+ /**
1834
+ * List every stored session snapshot through the handle-based persistence
1835
+ * service (`list`).
1836
+ * @param persistence - the persistence service slice.
1837
+ * @returns one snapshot per stored session.
1838
+ */
1839
+ function persistenceListSnapshots(persistence) {
1840
+ return persistence.list();
1841
+ }
1842
+ /**
1843
+ * Read one stored session's complete event log and durable inherited boundary
1844
+ * through the handle-based persistence service (`open` + handle `read`; the
1845
+ * handle is closed after the read). Throws when the session does not exist.
1846
+ * @param persistence - the persistence service slice.
1847
+ * @param id - the stored session to read.
1848
+ * @returns the session's complete event log plus its inherited-prefix boundary.
1849
+ */
1850
+ async function persistenceInspect(persistence, id) {
1851
+ const handle = await persistence.open(id, "read");
1852
+ try {
1853
+ return {
1854
+ events: handleReadEvents(await handle.read()),
1855
+ seedLength: forkBoundaryOf(handle)
1856
+ };
1857
+ } finally {
1858
+ await handle.close();
1859
+ }
1860
+ }
1861
+ //#endregion
1862
+ //#region lib/types/today-spend.js
1863
+ /**
1864
+ * Today-spend read path: the 60-second Beijing-day cache with in-flight
1865
+ * coalescing and a force bypass (plan A1), plus the two scan strategies that
1866
+ * compute the aggregate behind a cache miss:
1867
+ *
1868
+ * - projection path (plan C): live sessions read their eagerly folded
1869
+ * `billingTodaySpend` projection cell; cold sessions are answered from the
1870
+ * zero-I/O projection-cache row whenever that row's own day is not the
1871
+ * queried one, and otherwise resolved through one detached local fold over
1872
+ * a full `open` + handle read. Persisted revisions gate every cold read, so a session
1873
+ * whose log did not change since the last resolution costs nothing — and a
1874
+ * failed resolution is remembered by revision instead of being retried on
1875
+ * every scan.
1876
+ * - events path (plans A2/A3): collect and price only today's events in one
1877
+ * pass (per-event Beijing-day filter during collection) with a hard cap,
1878
+ * adopting the fold it already priced for a session whose persisted revision
1879
+ * is unchanged since the last pass.
1880
+ *
1881
+ * Both strategies run behind the same {@link TodaySpendCache}, so a miss
1882
+ * happens at most once per 60 seconds per process, and a manual refresh
1883
+ * (`force`) bypasses the time window but keeps the revision caches — an
1884
+ * unchanged log provably cannot change the aggregate. That proof is what makes
1885
+ * a revision gate a CACHE: both strategies therefore adopt the resolution they
1886
+ * remember for an unchanged revision instead of skipping the session, so an
1887
+ * unchanged log costs no I/O and still contributes its full spend (and title)
1888
+ * to the aggregate and the ranking on every scan.
1889
+ *
1890
+ * The per-session ranking is a per-CONVERSATION ranking: a subagent child is
1891
+ * work the delegating conversation paid for, not a session the user opened, so
1892
+ * every subagent row is folded into the row of the top-level session at the
1893
+ * root of its `parentSession` chain (see {@link rollUpSubagentSpend}). The
1894
+ * aggregate is unaffected — it sums the same sessions either way.
1895
+ *
1896
+ * Forked sessions never double-count: a fork child's log opens with a
1897
+ * verbatim copy of its source session's events (its inherited boundary), so
1898
+ * the scanner prices only the child's OWN events on every path. The
1899
+ * `billingTodaySpend` unit is boundary-aware (its state carries the inherited
1900
+ * cut, and `apply` skips events below it), so the eager cell is correct for a
1901
+ * fork child; the cold path skips the projection cache for a seeded session
1902
+ * (its cached row may predate the boundary) and folds its own events with the
1903
+ * durable cut instead. The boundary is the durable session state, read from the
1904
+ * live-session and handle surfaces — a resumed fork child keeps its original
1905
+ * boundary and an unseeded session stays at 0.
1906
+ *
1907
+ * The live `Session` log surface changed in 0.1.2-alpha.4: `Session.events`
1908
+ * was removed and replaced by `Session.snapshotEvents()` / `ownEvents()`, and
1909
+ * `SessionHeader.seedLength` moved to `Session.inheritedEventCount` (the
1910
+ * opened `SessionHandle` carries it alongside `header`). Reads go through
1911
+ * {@link liveSessionEvents} / {@link forkBoundaryOf}, which accept the
1912
+ * live-session and handle shapes structurally, so the scanner runs on the
1913
+ * older `events` runtime and on the newer `snapshotEvents` runtime.
1914
+ * @module @rayadesu/dsh-llm-billing/today-spend
1915
+ */
1587
1916
  /**
1588
1917
  * Bounded-map eviction: drop the oldest inserted entry once `size` reached
1589
1918
  * `limit`. Evicting one entry (instead of clearing) keeps the other sessions'
@@ -1652,7 +1981,12 @@ var TodaySpendScanner = class {
1652
1981
  * from the aggregate and the ranking on every scan after the first.
1653
1982
  */
1654
1983
  coldResolved = /* @__PURE__ */ new Map();
1655
- /** Cold sessions whose resolution failed: id → revision (retried only when the log changes). */
1984
+ /**
1985
+ * Cold sessions whose resolution failed, id → the revision it failed at plus
1986
+ * when. Keyed by revision too, so an unchanged log is not re-read every scan,
1987
+ * but the retry is TIME-boxed: a purely revision-keyed failure would hide a
1988
+ * session permanently when its log never changes again.
1989
+ */
1656
1990
  coldFailed = /* @__PURE__ */ new Map();
1657
1991
  constructor(deps) {
1658
1992
  this.deps = deps;
@@ -1695,7 +2029,10 @@ var TodaySpendScanner = class {
1695
2029
  * otherwise.
1696
2030
  *
1697
2031
  * The aggregate sums every priced session, subagents included — the ranking's
1698
- * subagent roll-up only regroups rows, so neither total moves.
2032
+ * subagent roll-up only regroups rows, so neither total moves. Both paths
2033
+ * fold in slices and hand the host's event loop back every
2034
+ * {@link SCAN_YIELD_SESSIONS} sessions, so a long scan never starves the
2035
+ * GUI's own round trips.
1699
2036
  * @param dayKey - the Beijing-time calendar-day key to aggregate.
1700
2037
  * @returns the aggregate plus per-session rows sorted by cost descending.
1701
2038
  */
@@ -1711,14 +2048,14 @@ var TodaySpendScanner = class {
1711
2048
  * own latest priced day is NOT the queried day: the row then proves the
1712
2049
  * session contributed nothing to the queried day, so the log is never read.
1713
2050
  * When the row IS the queried day (or no usable row exists) the session is
1714
- * inspected and folded locally, because the row may trail the log (a crash
2051
+ * opened and folded locally, because the row may trail the log (a crash
1715
2052
  * between the last checkpoint and the session's last event).
1716
2053
  *
1717
2054
  * A cache-served value carries no title (the ladder only stores projection
1718
2055
  * values), so such rows report `title: null`. A SEEDED session (fork child)
1719
2056
  * skips the cache entirely: its cached row was folded over the inherited
1720
- * prefix too, so it always detaches through inspect with the durable
1721
- * boundary (the inspect result's inherited count or `meta.seedLength`,
2057
+ * prefix too, so it always detaches through a handle read with the durable
2058
+ * boundary (the handle's `inheritedEventCount` or `header.seedLength`,
1722
2059
  * depending on the runtime family) applied to the local fold.
1723
2060
  * @param header - the listed session header (the cache identity witness).
1724
2061
  * @param seeded - whether the session carries a fork-inherited prefix.
@@ -1788,14 +2125,18 @@ var TodaySpendScanner = class {
1788
2125
  adopt(header.id, resolved);
1789
2126
  continue;
1790
2127
  }
1791
- if (this.coldFailed.get(header.id) === revision) continue;
2128
+ const failed = this.coldFailed.get(header.id);
2129
+ if (failed !== void 0 && failed.revision === revision && Date.now() - failed.at < 3e5) continue;
1792
2130
  pending.push({
1793
2131
  header,
1794
2132
  revision,
1795
2133
  seeded
1796
2134
  });
1797
2135
  }
2136
+ let resolvedCount = 0;
1798
2137
  await withConcurrency(pending, 8, async ({ header, revision, seeded }) => {
2138
+ resolvedCount += 1;
2139
+ if (resolvedCount % 8 === 0) await yieldToEventLoop();
1799
2140
  const resolved = await this.resolveCold(header, seeded, dayKey);
1800
2141
  if (resolved !== void 0) {
1801
2142
  this.coldFailed.delete(header.id);
@@ -1805,10 +2146,16 @@ var TodaySpendScanner = class {
1805
2146
  });
1806
2147
  } else if (persistenceAvailable) {
1807
2148
  evictOldest$1(this.coldFailed, COLD_FAILED_CACHE_LIMIT);
1808
- this.coldFailed.set(header.id, revision);
2149
+ this.coldFailed.set(header.id, {
2150
+ revision,
2151
+ at: Date.now()
2152
+ });
1809
2153
  }
1810
2154
  });
2155
+ let adopted = 0;
1811
2156
  for (const { header } of pending) {
2157
+ adopted += 1;
2158
+ if (adopted % 8 === 0) await yieldToEventLoop();
1812
2159
  const resolved = this.coldResolved.get(header.id);
1813
2160
  if (resolved !== void 0) adopt(header.id, resolved);
1814
2161
  }
@@ -1834,11 +2181,13 @@ var TodaySpendScanner = class {
1834
2181
  const liveIds = /* @__PURE__ */ new Set();
1835
2182
  let collected = 0;
1836
2183
  let truncated = false;
2184
+ const dayRange = beijingDayRangeOfKey(dayKey);
2185
+ const onDay = dayRange === void 0 ? (event) => beijingPartsOf(event.time).dayKey === dayKey : (event) => event.time >= dayRange.start && event.time < dayRange.end;
1837
2186
  /** Price one complete log, announce it, and return what to remember. */
1838
2187
  const collect = (id, events, seedLength, lineage) => {
1839
2188
  const folder = new BillingFolder(billing, catalog, seedLength);
1840
2189
  for (const event of events) {
1841
- if (beijingPartsOf(event.time).dayKey === dayKey) {
2190
+ if (onDay(event)) {
1842
2191
  collected += 1;
1843
2192
  if (collected > maxEvents) {
1844
2193
  truncated = true;
@@ -1857,10 +2206,15 @@ var TodaySpendScanner = class {
1857
2206
  };
1858
2207
  if (sessions !== void 0) {
1859
2208
  const store = sessions();
1860
- if (store !== void 0) for (const session of store.list()) {
1861
- liveIds.add(session.id);
1862
- collect(session.id, liveSessionEvents(session), forkBoundaryOf(session), session.header ?? {});
1863
- if (truncated) break;
2209
+ if (store !== void 0) {
2210
+ let folded = 0;
2211
+ for (const session of store.list()) {
2212
+ folded += 1;
2213
+ if (folded % 8 === 0) await yieldToEventLoop();
2214
+ liveIds.add(session.id);
2215
+ collect(session.id, liveSessionEvents(session), forkBoundaryOf(session), session.header ?? {});
2216
+ if (truncated) break;
2217
+ }
1864
2218
  }
1865
2219
  }
1866
2220
  const persistenceService = persistence?.();
@@ -1892,7 +2246,7 @@ var TodaySpendScanner = class {
1892
2246
  /**
1893
2247
  * Projection path, one pass for both outputs: eager cells for live sessions
1894
2248
  * (title folded from the live log, so a rename is reflected immediately),
1895
- * revision-gated cold ladder for the rest (title resolved on inspect, `null`
2249
+ * revision-gated cold ladder for the rest (title resolved on the handle read, `null`
1896
2250
  * when answered from the projection cache). A fork child's cell covers its
1897
2251
  * inherited prefix, so its own-events fold supplies both outputs. Lineage
1898
2252
  * (which session delegated which) comes from the same headers the boundary
@@ -1912,23 +2266,28 @@ var TodaySpendScanner = class {
1912
2266
  const liveIds = /* @__PURE__ */ new Set();
1913
2267
  if (sessions !== void 0) {
1914
2268
  const store = sessions();
1915
- if (store !== void 0) for (const { session, state } of this.liveBillingEntries(store, projectionsService)) {
1916
- liveIds.add(session.id);
1917
- lineage.set(session.id, session.header ?? {});
1918
- const born = session.header?.createdAt;
1919
- if (born !== void 0) createdAt.set(session.id, born);
1920
- const title = foldSessionTitle(liveSessionEvents(session));
1921
- titles.set(session.id, title);
1922
- if (state === void 0) continue;
1923
- ownSpend.set(session.id, state.session);
1924
- if (state.dayKey !== dayKey) continue;
1925
- aggregate = mergeTodaySpend(aggregate, state.spend);
1926
- rows.set(session.id, {
1927
- sessionId: session.id,
1928
- title,
1929
- total: state.spend.total,
1930
- ownTotal: state.spend.total
1931
- });
2269
+ if (store !== void 0) {
2270
+ let folded = 0;
2271
+ for (const { session, state } of this.liveBillingEntries(store, projectionsService)) {
2272
+ folded += 1;
2273
+ if (folded % 8 === 0) await yieldToEventLoop();
2274
+ liveIds.add(session.id);
2275
+ lineage.set(session.id, session.header ?? {});
2276
+ const born = session.header?.createdAt;
2277
+ if (born !== void 0) createdAt.set(session.id, born);
2278
+ const title = foldSessionTitle(liveSessionEvents(session));
2279
+ titles.set(session.id, title);
2280
+ if (state === void 0) continue;
2281
+ ownSpend.set(session.id, state.session);
2282
+ if (state.dayKey !== dayKey) continue;
2283
+ aggregate = mergeTodaySpend(aggregate, state.spend);
2284
+ rows.set(session.id, {
2285
+ sessionId: session.id,
2286
+ title,
2287
+ total: state.spend.total,
2288
+ ownTotal: state.spend.total
2289
+ });
2290
+ }
1932
2291
  }
1933
2292
  }
1934
2293
  const persistenceService = persistence?.();
@@ -2037,10 +2396,11 @@ const BASE_URL_ENV = "DEEPSEEK_BASE_URL";
2037
2396
  const PUBLIC_BASE_URL = "https://api.deepseek.com";
2038
2397
  /**
2039
2398
  * Advisory display rows mirroring the DSH `llm-deepseek` catalog (V4.1 Flash
2040
- * first, its current default route), plus the MiMo-V2.5 series. The retired
2041
- * preview id `deepseek-v4.1-flash-expires-on-0910` stays so the logs that used
2042
- * it keep a readable label; rows never restrict which models are priced — the
2043
- * pricing table does.
2399
+ * first, its current default route), plus the MiMo series (V2.5, and V2.6
2400
+ * which kept V2.5's pricing). The retired preview id
2401
+ * `deepseek-v4.1-flash-expires-on-0910` stays so the logs that used it keep a
2402
+ * readable label; rows never restrict which models are priced — the pricing
2403
+ * table does.
2044
2404
  */
2045
2405
  const DEFAULT_MODELS = [
2046
2406
  {
@@ -2070,6 +2430,14 @@ const DEFAULT_MODELS = [
2070
2430
  {
2071
2431
  id: "mimo-v2.5",
2072
2432
  name: "MiMo-V2.5"
2433
+ },
2434
+ {
2435
+ id: "mimo-v2.6-pro",
2436
+ name: "MiMo-V2.6-Pro"
2437
+ },
2438
+ {
2439
+ id: "mimo-v2.6-flash",
2440
+ name: "MiMo-V2.6-Flash"
2073
2441
  }
2074
2442
  ];
2075
2443
  const billingModel = z.object({
@@ -2126,7 +2494,7 @@ function evictOldest(map, limit) {
2126
2494
  /**
2127
2495
  * Read one session's event log and durable seed boundary: the live
2128
2496
  * SessionStore first, then the persistence backend for a flushed session
2129
- * (inspected directly by id — no header listing). The live-session surface
2497
+ * (opened directly by id — no header listing). The live-session surface
2130
2498
  * is read structurally across both DSH runtime families — `Session.events`
2131
2499
  * (≤ 0.1.1-rc.2) or `Session.snapshotEvents()` + `Session.inheritedEventCount`
2132
2500
  * (0.1.2-alpha.4+) — via {@link liveSessionEvents} / {@link forkBoundaryOf}.