@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.
@@ -1,233 +1,25 @@
1
1
  /**
2
- * DeepSeek billing: the peak/off-peak pricing table and the per-session spend
3
- * pricing. Pure functions over session events and the pricing table, so the
4
- * Remote gateway stays transport-free and the whole spend is testable without
5
- * a key.
2
+ * DeepSeek billing aggregation: the session/turn/today spend computations
3
+ * over the pricing engine (`pricing.ts`) and the per-event fold state machine
4
+ * (`fold.ts`). Pure functions over session events, so the whole spend is
5
+ * testable without a key and the Remote gateway stays transport-free.
6
6
  *
7
- * The per-event pricing lives in {@link priceEvent} and the fold in the
8
- * {@link SpendAccumulator}: the events-scan paths ({@link computeSessionSpend},
9
- * {@link computeTodaySpend}), the session-projection unit (`billingTodaySpend`
10
- * in projection.ts), and the scanner's single-pass events path all price
11
- * through the same primitives, so a pricing-table change cannot drift one path
12
- * from the others.
7
+ * The fold in {@link BillingFolder} / {@link applyBillingEvent} (see `fold.ts`)
8
+ * and the per-event pricing in `pricing.ts` are the single source of truth the
9
+ * events-scan paths, the session-projection unit, and the scanner all price
10
+ * through, so a pricing-table change cannot drift one path from the others.
13
11
  * @module @rayadesu/dsh-llm-billing/billing
14
12
  */
15
- /**
16
- * Published peak-hour windows (Beijing time): 09:00–12:00 and 14:00–18:00,
17
- * applied on weekdays (Monday–Friday) only — weekends are always off-peak
18
- * (effective 2026-08-23).
19
- */
20
- export const DEFAULT_PEAK_HOURS = [
21
- { start: 9, end: 12 },
22
- { start: 14, end: 18 },
23
- ];
24
- /**
25
- * Inclusive epoch ms of the published V4 Flash series re-pricing:
26
- * 2026-09-10 12:00 Beijing time (UTC+8, no DST) = 04:00 UTC. Samples before
27
- * this instant keep the base rates; samples at or after it bill at the second
28
- * revision.
29
- */
30
- export const FLASH_SERIES_RATE_CHANGE_AT = Date.UTC(2026, 8, 10, 4, 0, 0);
31
- /**
32
- * Inclusive epoch ms of the announced V4 Pro route switch: 2026-09-14 12:00
33
- * Beijing time (UTC+8, no DST) = 04:00 UTC. From that instant the V4 Pro route
34
- * is served by V4.1 Flash and billed at the V4.1 Flash rates.
35
- */
36
- export const V4_PRO_ROUTE_SWITCH_AT = Date.UTC(2026, 8, 14, 4, 0, 0);
37
- /** The V4 Flash series' base rates (effective 2026-08-17), CNY per 1M tokens. */
38
- const FLASH_BASE_RATES = {
39
- peak: { cacheHitInput: 0.10, cacheMissInput: 3.0, output: 9.0 },
40
- offPeak: { cacheHitInput: 0.05, cacheMissInput: 1.5, output: 4.5 },
41
- };
42
- /**
43
- * The V4 Flash series' second revision (effective
44
- * {@link FLASH_SERIES_RATE_CHANGE_AT}): off-peak 0.02 / 1.0 / 4.0, peak at
45
- * twice those prices.
46
- */
47
- const FLASH_REPRICED_RATES = {
48
- effectiveFrom: FLASH_SERIES_RATE_CHANGE_AT,
49
- peak: { cacheHitInput: 0.04, cacheMissInput: 2.0, output: 8.0 },
50
- offPeak: { cacheHitInput: 0.02, cacheMissInput: 1.0, output: 4.0 },
51
- };
52
- /**
53
- * The V4.1 Flash rates as they reach the retired V4 Pro route from
54
- * {@link V4_PRO_ROUTE_SWITCH_AT}: the same price pair as the flash series'
55
- * second revision, carried at its own effective instant.
56
- */
57
- const V4_PRO_SWITCHED_RATES = {
58
- effectiveFrom: V4_PRO_ROUTE_SWITCH_AT,
59
- peak: FLASH_REPRICED_RATES.peak,
60
- offPeak: FLASH_REPRICED_RATES.offPeak,
61
- };
62
- /**
63
- * Official peak/off-peak rates (CNY per 1M tokens) per model, as dated
64
- * revisions. Base rows are the schedule effective 2026-08-17; the V4 Flash
65
- * series (V4.1 Flash, V4 Flash, V4 Flash Vision Exp) carries the second
66
- * revision effective 2026-09-10 12:00 Beijing, and the V4 Pro row the V4.1
67
- * Flash rates from its announced route switch (2026-09-14 12:00 Beijing) —
68
- * the MiMo-V2.5 series is untouched by either adjustment. Rows sharing a model
69
- * are that model's rate history.
70
- */
71
- export const DEFAULT_MODEL_PRICING = [
72
- // deepseek-flash is the V4.1 Flash route, DSH's default catalog entry; image
73
- // inputs are converted to tokens at the same per-token price.
74
- { model: 'deepseek-flash', ...FLASH_BASE_RATES },
75
- { model: 'deepseek-flash', ...FLASH_REPRICED_RATES },
76
- { model: 'deepseek-v4-flash', ...FLASH_BASE_RATES },
77
- { model: 'deepseek-v4-flash', ...FLASH_REPRICED_RATES },
78
- // deepseek-v4.1-flash-expires-on-0910 was the V4.1 Flash preview route,
79
- // retired when the model was released on 2026-09-10; its rows stay so the
80
- // logs that used it keep pricing.
81
- { model: 'deepseek-v4.1-flash-expires-on-0910', ...FLASH_BASE_RATES },
82
- { model: 'deepseek-v4.1-flash-expires-on-0910', ...FLASH_REPRICED_RATES },
83
- {
84
- model: 'deepseek-v4-pro',
85
- peak: { cacheHitInput: 0.30, cacheMissInput: 9.0, output: 27.0 },
86
- offPeak: { cacheHitInput: 0.15, cacheMissInput: 4.5, output: 13.5 },
87
- },
88
- // From the announced route switch V4 Pro is served by V4.1 Flash and billed
89
- // at the V4.1 Flash rates.
90
- { model: 'deepseek-v4-pro', ...V4_PRO_SWITCHED_RATES },
91
- // deepseek-v4-flash-vision-exp bills at the same rates as deepseek-v4-flash;
92
- // images are converted to tokens at the same per-token price.
93
- { model: 'deepseek-v4-flash-vision-exp', ...FLASH_BASE_RATES },
94
- { model: 'deepseek-v4-flash-vision-exp', ...FLASH_REPRICED_RATES },
95
- // MiMo-V2.5 series (Xiaomi): flat rate, no peak/off-peak distinction.
96
- {
97
- model: 'mimo-v2.5-pro',
98
- peak: { cacheHitInput: 0.025, cacheMissInput: 3.0, output: 6.0 },
99
- offPeak: { cacheHitInput: 0.025, cacheMissInput: 3.0, output: 6.0 },
100
- },
101
- {
102
- model: 'mimo-v2.5',
103
- peak: { cacheHitInput: 0.02, cacheMissInput: 1.0, output: 2.0 },
104
- offPeak: { cacheHitInput: 0.02, cacheMissInput: 1.0, output: 2.0 },
105
- },
106
- ];
107
- /**
108
- * Resolve optional configuration to a pricing table, defaulting omitted or
109
- * empty rows to the published rates. Schemastery materializes an absent
110
- * `z.array` as `[]` rather than `undefined`, so emptiness — not just absence —
111
- * selects the defaults. Explicit non-empty rows override the same model; a
112
- * supplied non-empty `models` list is authoritative.
113
- *
114
- * Rows sharing a model are that model's rate revisions, kept in ascending
115
- * `effectiveFrom` order (an undated base revision first). Two rows declaring
116
- * the same effective instant are one revision and the later row wins — the
117
- * historical override rule — so re-declaring a model can neither duplicate a
118
- * revision nor install a second undated base.
119
- * @param config - optional raw billing configuration.
120
- * @returns the resolved table (per model: its revisions plus the newest rates) and peak-hour windows.
121
- */
122
- export function resolveBilling(config) {
123
- const peakHours = config?.peakHours !== undefined && config.peakHours.length > 0
124
- ? config.peakHours
125
- : DEFAULT_PEAK_HOURS;
126
- const rows = config?.models !== undefined && config.models.length > 0
127
- ? config.models
128
- : DEFAULT_MODEL_PRICING;
129
- const schedules = new Map();
130
- for (const row of rows) {
131
- // Spelled out per branch: `exactOptionalPropertyTypes` forbids handing an
132
- // explicit `undefined` to an optional field.
133
- const revision = row.effectiveFrom === undefined
134
- ? { peak: row.peak, offPeak: row.offPeak }
135
- : { effectiveFrom: row.effectiveFrom, peak: row.peak, offPeak: row.offPeak };
136
- const revisions = schedules.get(row.model);
137
- if (revisions === undefined) {
138
- schedules.set(row.model, [revision]);
139
- continue;
140
- }
141
- const duplicate = revisions.findIndex(candidate => candidate.effectiveFrom === revision.effectiveFrom);
142
- if (duplicate >= 0)
143
- revisions[duplicate] = revision;
144
- else
145
- revisions.push(revision);
146
- }
147
- const models = new Map();
148
- for (const [model, revisions] of schedules) {
149
- revisions.sort((left, right) => (left.effectiveFrom ?? Number.NEGATIVE_INFINITY) - (right.effectiveFrom ?? Number.NEGATIVE_INFINITY));
150
- const newest = revisions[revisions.length - 1];
151
- models.set(model, { peak: newest.peak, offPeak: newest.offPeak, revisions });
152
- }
153
- return { peakHours, models };
154
- }
155
- /**
156
- * The rate revision in effect at one instant: the newest revision that took
157
- * effect at or before it. Revisions are ascending, so the scan stops at the
158
- * first future one. An instant before the earliest dated revision bills at that
159
- * earliest revision — a model with only dated rows is never left unpriced.
160
- */
161
- function ratesAt(revisions, time) {
162
- let chosen = revisions[0];
163
- for (let index = 1; index < revisions.length; index += 1) {
164
- const revision = revisions[index];
165
- if (revision.effectiveFrom === undefined || revision.effectiveFrom > time)
166
- break;
167
- chosen = revision;
168
- }
169
- return chosen;
170
- }
171
- /** Beijing is a fixed UTC+8 offset with no DST. */
172
- const BEIJING_OFFSET_MS = 8 * 3_600_000;
173
- /** Milliseconds in one day. */
174
- const DAY_MS = 86_400_000;
175
- /** Epoch day of 1970-01-01 in the civil-date algorithm below. */
176
- const CIVIL_EPOCH_DAY = 719_468;
177
- /** Two-digit zero pad for a calendar field. */
178
- function pad2(value) {
179
- return value < 10 ? `0${value}` : String(value);
180
- }
181
- /**
182
- * Civil date of an epoch day (Howard Hinnant's days-from-civil inverse):
183
- * pure integer arithmetic, no `Date` allocation and no ISO-string slicing.
184
- */
185
- function civilDateOf(epochDay) {
186
- const shifted = epochDay + CIVIL_EPOCH_DAY;
187
- const era = Math.floor(shifted / 146_097);
188
- const dayOfEra = shifted - era * 146_097;
189
- const yearOfEra = Math.floor((dayOfEra - Math.floor(dayOfEra / 1_460) + Math.floor(dayOfEra / 36_524) - Math.floor(dayOfEra / 146_096)) / 365);
190
- const year = yearOfEra + era * 400;
191
- const dayOfYear = dayOfEra - (365 * yearOfEra + Math.floor(yearOfEra / 4) - Math.floor(yearOfEra / 100));
192
- const monthPrime = Math.floor((5 * dayOfYear + 2) / 153);
193
- const month = monthPrime + (monthPrime < 10 ? 3 : -9);
194
- return {
195
- // January/February belong to the civil year AFTER the era year.
196
- year: month <= 2 ? year + 1 : year,
197
- month,
198
- day: dayOfYear - Math.floor((153 * monthPrime + 2) / 5) + 1,
199
- };
200
- }
201
- /**
202
- * Derive the Beijing hour, weekday, and calendar-day key of one timestamp with
203
- * pure integer arithmetic — every timezone-sensitive read shares this one
204
- * implementation, so the pieces cannot drift apart. Callers that filter by
205
- * day and then price the same event reuse the returned view, so each event is
206
- * parsed exactly once. (The hot fold path runs this per committed event; the
207
- * previous `Date` + `toISOString().slice()` version allocated a `Date` and a
208
- * 24-character string per call.)
209
- * @param time - epoch milliseconds.
210
- * @throws {RangeError} when `time` is not a finite number.
211
- */
212
- export function beijingPartsOf(time) {
213
- if (!Number.isFinite(time))
214
- throw new RangeError(`billing: event time is not finite (${String(time)})`);
215
- const shifted = time + BEIJING_OFFSET_MS;
216
- const epochDay = Math.floor(shifted / DAY_MS);
217
- const msOfDay = shifted - epochDay * DAY_MS;
218
- const civil = civilDateOf(epochDay);
219
- return {
220
- time,
221
- hour: Math.floor(msOfDay / 3_600_000),
222
- // 1970-01-01 was a Thursday (4).
223
- weekday: ((epochDay + 4) % 7 + 7) % 7,
224
- dayKey: `${civil.year}-${pad2(civil.month)}-${pad2(civil.day)}`,
225
- };
226
- }
227
- /** The Beijing (Asia/Shanghai, UTC+8, no DST) calendar-day key of a timestamp. */
228
- export function beijingDayKey(now) {
229
- return beijingPartsOf(now.getTime()).dayKey;
230
- }
13
+ import { beijingDayKey, beijingDayRangeOfKey } from "./beijing-time.js";
14
+ import { emptyTodaySpend } from "./spend.js";
15
+ import { BillingFolder } from "./fold.js";
16
+ // Re-exported so the package's public surface and its tests keep importing
17
+ // them from this module: the pricing engine, the fold state machine, the
18
+ // Beijing-time helpers, and the spend algebra.
19
+ export * from "./beijing-time.js";
20
+ export * from "./spend.js";
21
+ export * from "./pricing.js";
22
+ export * from "./fold.js";
231
23
  /**
232
24
  * The durable inherited-prefix boundary of one session: the number of leading
233
25
  * events it inherited verbatim from its fork source, 0 for a session created
@@ -235,9 +27,9 @@ export function beijingDayKey(now) {
235
27
  * in its session state; every event with `seq < seedLength` is a copy of an
236
28
  * event already billed in that source session, so pricing must skip them or
237
29
  * the same model output is counted once per copy. Accepts the durable field
238
- * of both DSH runtime families (see {@link ForkBoundarySource}).
239
- * @param source - the session, inspection result, header slice, or durable
240
- * header carrying the boundary; `undefined` reads as 0.
30
+ * of the live-session and handle surfaces (see {@link ForkBoundarySource}).
31
+ * @param source - the session, header slice, or opened handle carrying the
32
+ * boundary; `undefined` reads as 0.
241
33
  * @returns the inherited-prefix length; 0 for an unseeded session.
242
34
  */
243
35
  export function forkBoundaryOf(source) {
@@ -246,9 +38,9 @@ export function forkBoundaryOf(source) {
246
38
  const inherited = source.inheritedEventCount;
247
39
  if (inherited !== undefined && Number.isSafeInteger(inherited))
248
40
  return inherited;
249
- return source.header?.seedLength ?? source.meta?.seedLength ?? source.seedLength ?? 0;
41
+ return source.header?.seedLength ?? source.seedLength ?? 0;
250
42
  }
251
- /** Whether a durable header marks a fork-inherited (seeded) session across both runtime families. */
43
+ /** Whether a durable header marks a fork-inherited (seeded) session across the live-session and handle surfaces. */
252
44
  export function isSeededSession(header) {
253
45
  if (header === undefined)
254
46
  return false;
@@ -256,387 +48,6 @@ export function isSeededSession(header) {
256
48
  return true;
257
49
  return (header.seedLength ?? 0) > 0;
258
50
  }
259
- /** Whether a Beijing (hour, weekday) pair falls inside any peak-hour window. */
260
- function isPeakParts(billing, hour, weekday) {
261
- if (weekday === 0 || weekday === 6)
262
- return false;
263
- return billing.peakHours.some(({ start, end }) => hour >= start && hour < end);
264
- }
265
- /**
266
- * Whether a timestamp falls inside any peak-hour window (Beijing time,
267
- * weekdays Monday–Friday only). Weekends (Saturday and Sunday) are always
268
- * off-peak, matching the published peak-hours rule.
269
- * @param billing - resolved pricing with peak-hour windows.
270
- * @param now - the moment to classify.
271
- * @returns true during a weekday peak hour.
272
- */
273
- export function isPeak(billing, now) {
274
- const { hour, weekday } = beijingPartsOf(now.getTime());
275
- return isPeakParts(billing, hour, weekday);
276
- }
277
- /**
278
- * Price one event at the official per-model rates, applying the peak/off-peak
279
- * table by its Beijing-time hour and weekday (peak windows apply Monday–Friday
280
- * only; weekends are off-peak) and the rate revision in effect at its own
281
- * timestamp. Each `assistant/message` event with usage
282
- * contributes cache-hit input, cache-miss input (uncached input plus cache
283
- * writes), and output (reasoning included) tokens at the rate of its own
284
- * timestamp; a model with usage but no pricing row contributes nothing.
285
- * @param event - the event to price.
286
- * @param billing - resolved pricing with peak-hour windows.
287
- * @param names - model id → display label.
288
- * @returns the priced contribution, or `undefined` when the event has no priced usage.
289
- */
290
- export function priceEvent(event, billing, names) {
291
- return priceEventAt(beijingPartsOf(event.time), event, billing, names);
292
- }
293
- /**
294
- * Price one event at the official per-model rates using a precomputed
295
- * Beijing-time view — the day-filtering and pricing of one event share a
296
- * single timezone parse (see {@link beijingPartsOf}). Semantics are identical
297
- * to {@link priceEvent}.
298
- * @param parts - the event's Beijing-time view.
299
- * @param event - the event to price.
300
- * @param billing - resolved pricing with peak-hour windows.
301
- * @param names - model id → display label.
302
- * @returns the priced contribution, or `undefined` when the event has no priced usage.
303
- */
304
- export function priceEventAt(parts, event, billing, names) {
305
- if (event.type !== 'assistant/message')
306
- return undefined;
307
- const reported = event.data.usage;
308
- if (reported === undefined)
309
- return undefined;
310
- return priceUsage(parts, reported, event.data.message.source.model, billing, names);
311
- }
312
- /**
313
- * Price one provider-reported usage sample for one model at the rates of the
314
- * sample's own Beijing-time hour and weekday — the peak or off-peak price of
315
- * the rate revision in effect at the sample's own timestamp (a re-priced series
316
- * bills its history at the rates that applied then). `undefined` when the model
317
- * has no pricing row.
318
- * @param parts - the sample's Beijing-time view.
319
- * @param usage - the reported token buckets.
320
- * @param model - the wire model id the sample belongs to.
321
- * @param billing - resolved pricing with peak-hour windows.
322
- * @param names - model id → display label.
323
- * @returns the priced contribution, or `undefined` when the model has no rate row.
324
- */
325
- export function priceUsage(parts, usage, model, billing, names) {
326
- const pricing = billing.models.get(model);
327
- if (pricing === undefined)
328
- return undefined;
329
- const peak = isPeakParts(billing, parts.hour, parts.weekday);
330
- const revision = ratesAt(pricing.revisions, parts.time);
331
- const price = peak ? revision.peak : revision.offPeak;
332
- const hit = usage.cacheReadTokens ?? 0;
333
- const miss = usage.inputTokens + (usage.cacheWriteTokens ?? 0);
334
- const output = usage.outputTokens;
335
- const hitCost = (hit * price.cacheHitInput) / 1_000_000;
336
- const missCost = (miss * price.cacheMissInput) / 1_000_000;
337
- const outputCost = (output * price.output) / 1_000_000;
338
- const cost = hitCost + missCost + outputCost;
339
- return {
340
- dayKey: parts.dayKey,
341
- model,
342
- displayName: names.get(model) ?? model,
343
- cost,
344
- peakCost: peak ? cost : 0,
345
- offPeakCost: peak ? 0 : cost,
346
- cacheHitInputTokens: hit,
347
- cacheMissInputTokens: miss,
348
- outputTokens: output,
349
- cacheHitInputCost: hitCost,
350
- cacheMissInputCost: missCost,
351
- outputCost,
352
- };
353
- }
354
- /** A spend with no priced usage. */
355
- export function emptyTodaySpend() {
356
- return { total: 0, models: [] };
357
- }
358
- /** The today-spend shape of a single priced contribution. */
359
- function contributionModel(priced) {
360
- return {
361
- model: priced.model,
362
- displayName: priced.displayName,
363
- cost: priced.cost,
364
- peakCost: priced.peakCost,
365
- offPeakCost: priced.offPeakCost,
366
- cacheHitInputTokens: priced.cacheHitInputTokens,
367
- cacheMissInputTokens: priced.cacheMissInputTokens,
368
- outputTokens: priced.outputTokens,
369
- cacheHitInputCost: priced.cacheHitInputCost,
370
- cacheMissInputCost: priced.cacheMissInputCost,
371
- outputCost: priced.outputCost,
372
- };
373
- }
374
- /** Sum two model rows of the same model (pure). */
375
- function mergeModelRows(left, right) {
376
- return {
377
- model: left.model,
378
- displayName: left.displayName,
379
- cost: left.cost + right.cost,
380
- peakCost: left.peakCost + right.peakCost,
381
- offPeakCost: left.offPeakCost + right.offPeakCost,
382
- cacheHitInputTokens: left.cacheHitInputTokens + right.cacheHitInputTokens,
383
- cacheMissInputTokens: left.cacheMissInputTokens + right.cacheMissInputTokens,
384
- outputTokens: left.outputTokens + right.outputTokens,
385
- cacheHitInputCost: left.cacheHitInputCost + right.cacheHitInputCost,
386
- cacheMissInputCost: left.cacheMissInputCost + right.cacheMissInputCost,
387
- outputCost: left.outputCost + right.outputCost,
388
- };
389
- }
390
- /**
391
- * Mutable model-row accumulator behind every spend fold. Rows keep first-seen
392
- * model order — the same shape a pure `addEventContribution` chain produces —
393
- * so the single-pass scan path and the pure public paths cannot diverge. One
394
- * `Map` lookup per contribution instead of a per-event array copy: the huge
395
- * event-log folds allocate one row object per model, not one intermediate
396
- * array per event.
397
- */
398
- export class SpendAccumulator {
399
- rows = new Map();
400
- total = 0;
401
- /** Add one priced contribution. */
402
- add(priced) {
403
- const row = contributionModel(priced);
404
- const existing = this.rows.get(priced.model);
405
- this.rows.set(priced.model, existing === undefined ? row : mergeModelRows(existing, row));
406
- this.total += priced.cost;
407
- }
408
- /** The folded spend; the accumulator stays usable afterwards. */
409
- finish() {
410
- return { total: this.total, models: [...this.rows.values()] };
411
- }
412
- }
413
- /** The additive inverse of one spend (pure): used to replace a priced sample. */
414
- export function negateSpend(spend) {
415
- const negate = (value) => -value;
416
- return {
417
- total: negate(spend.total),
418
- models: spend.models.map(row => ({
419
- ...row,
420
- cost: negate(row.cost),
421
- peakCost: negate(row.peakCost),
422
- offPeakCost: negate(row.offPeakCost),
423
- cacheHitInputTokens: negate(row.cacheHitInputTokens),
424
- cacheMissInputTokens: negate(row.cacheMissInputTokens),
425
- outputTokens: negate(row.outputTokens),
426
- cacheHitInputCost: negate(row.cacheHitInputCost),
427
- cacheMissInputCost: negate(row.cacheMissInputCost),
428
- outputCost: negate(row.outputCost),
429
- })),
430
- };
431
- }
432
- /**
433
- * Subtract one spend from another (pure). Rows that cancel out completely are
434
- * dropped so a replaced sample leaves no zero row behind.
435
- * @param target - the spend to subtract from.
436
- * @param source - the spend to remove.
437
- * @returns the difference.
438
- */
439
- export function subtractSpend(target, source) {
440
- const rows = new Map();
441
- for (const row of target.models)
442
- rows.set(row.model, row);
443
- for (const row of source.models) {
444
- const existing = rows.get(row.model);
445
- if (existing === undefined)
446
- continue;
447
- const next = mergeModelRows(existing, negateSpend({ total: 0, models: [row] }).models[0]);
448
- if (next.cost === 0 && next.cacheHitInputTokens === 0 && next.cacheMissInputTokens === 0 && next.outputTokens === 0) {
449
- rows.delete(row.model);
450
- }
451
- else {
452
- rows.set(row.model, next);
453
- }
454
- }
455
- return { total: target.total - source.total, models: [...rows.values()] };
456
- }
457
- /** The empty fold state for one fork boundary. */
458
- export function emptyBillingFoldState(inheritedEventCount = 0) {
459
- return {
460
- dayKey: '',
461
- spend: emptyTodaySpend(),
462
- session: emptyTodaySpend(),
463
- inheritedEventCount,
464
- model: '',
465
- last: null,
466
- };
467
- }
468
- /** Whether an unknown value looks like a provider usage report. */
469
- function isTokenUsage(value) {
470
- if (typeof value !== 'object' || value === null)
471
- return false;
472
- const candidate = value;
473
- return typeof candidate.inputTokens === 'number' && typeof candidate.outputTokens === 'number';
474
- }
475
- /**
476
- * The last `usage` sample embedded in an event's stream, if any. `assistant/
477
- * attempt` and the embedded streams are newer than the plugin's npm baseline,
478
- * so the stream is read structurally (a failed/retried attempt reports its
479
- * usage only there).
480
- */
481
- function streamUsageOf(event) {
482
- const stream = event.data === undefined
483
- ? undefined
484
- : event.data.stream;
485
- if (!Array.isArray(stream))
486
- return undefined;
487
- for (let index = stream.length - 1; index >= 0; index -= 1) {
488
- const chunk = stream[index]?.chunk;
489
- if (chunk === undefined || chunk.type !== 'usage')
490
- continue;
491
- return isTokenUsage(chunk.usage) ? chunk.usage : undefined;
492
- }
493
- return undefined;
494
- }
495
- /** The contribution as a one-row spend (the shape a sample keeps for replacement). */
496
- function contributionSpend(priced) {
497
- return { total: priced.cost, models: [contributionModel(priced)] };
498
- }
499
- /**
500
- * Fold one committed event into a session's billed-spend state.
501
- *
502
- * Priced samples come from `assistant/message` (its own reported usage, or the
503
- * stream's last usage chunk) and `assistant/attempt` (the stream's last usage
504
- * chunk, priced with the model of the latest `request/header`, since an
505
- * attempt carries no route). A sample for the same `(turn, step)` replaces the
506
- * previous one; `llm/retry-started` closes the replacement slot so a retried
507
- * attempt adds. Every other event is inert and returns the same state
508
- * reference.
509
- * @param state - the previous fold state.
510
- * @param event - the committed event.
511
- * @param billing - resolved pricing with peak-hour windows.
512
- * @param names - model id → display label.
513
- * @returns the next state (the same reference when nothing was priced).
514
- */
515
- export function applyBillingEvent(state, event, billing, names) {
516
- if (event.seq < state.inheritedEventCount)
517
- return state;
518
- // `assistant/attempt`, `llm/retry-started`, and the embedded stream are all
519
- // newer than the npm baseline this package builds against, so their fields
520
- // are read structurally.
521
- const type = event.type;
522
- if (type === 'request/header') {
523
- const model = event.data
524
- ?.header?.config?.model;
525
- return typeof model === 'string' && model.length > 0 && model !== state.model ? { ...state, model } : state;
526
- }
527
- const data = event.data;
528
- if (type === 'llm/retry-started') {
529
- if (typeof data?.turn !== 'number' || typeof data.step !== 'number')
530
- return state;
531
- const last = state.last;
532
- if (last === null || last.turn !== data.turn || last.step !== data.step)
533
- return state;
534
- return { ...state, last: null };
535
- }
536
- if (type !== 'assistant/message' && type !== 'assistant/attempt')
537
- return state;
538
- const usage = (type === 'assistant/message' ? data?.usage : undefined) ?? streamUsageOf(event);
539
- if (!isTokenUsage(usage))
540
- return state;
541
- const model = type === 'assistant/message' ? data?.message?.source?.model : state.model;
542
- if (typeof model !== 'string' || model.length === 0)
543
- return state;
544
- const priced = priceUsage(beijingPartsOf(event.time), usage, model, billing, names);
545
- if (priced === undefined)
546
- return state;
547
- let session = state.session;
548
- let spend = state.spend;
549
- let dayKey = state.dayKey;
550
- const last = state.last;
551
- const turn = typeof data?.turn === 'number' ? data.turn : 0;
552
- const step = typeof data?.step === 'number' ? data.step : 0;
553
- if (last !== null && last.turn === turn && last.step === step) {
554
- session = subtractSpend(session, last.spend);
555
- if (last.dayKey === dayKey)
556
- spend = subtractSpend(spend, last.spend);
557
- }
558
- session = addEventContribution(session, priced);
559
- if (dayKey === priced.dayKey) {
560
- spend = addEventContribution(spend, priced);
561
- }
562
- else if (dayKey === '' || priced.dayKey > dayKey) {
563
- // The session log is append-only and chronological, so a strictly older
564
- // day cannot legally follow; ignore it for the latest-day state (the
565
- // whole-session total still accrues).
566
- dayKey = priced.dayKey;
567
- spend = addEventContribution(emptyTodaySpend(), priced);
568
- }
569
- return {
570
- ...state,
571
- dayKey,
572
- spend,
573
- session,
574
- last: { turn, step, dayKey: priced.dayKey, spend: contributionSpend(priced) },
575
- };
576
- }
577
- /**
578
- * Mutable wrapper over {@link applyBillingEvent} for the pure pricing paths:
579
- * feed events in order, read the folded spend.
580
- */
581
- export class BillingFolder {
582
- billing;
583
- state;
584
- /**
585
- * @param billing - resolved pricing with peak-hour windows.
586
- * @param catalog - model display rows, in presentation order.
587
- * @param inheritedEventCount - fork boundary to skip (default 0).
588
- */
589
- constructor(billing, catalog, inheritedEventCount = 0) {
590
- this.billing = billing;
591
- this.names = new Map(catalog.map(model => [model.id, model.name]));
592
- this.state = emptyBillingFoldState(inheritedEventCount);
593
- }
594
- names;
595
- /** Fold one event. */
596
- add(event) {
597
- this.state = applyBillingEvent(this.state, event, this.billing, this.names);
598
- }
599
- /** Fold every event, in order. */
600
- addAll(events) {
601
- for (const event of events)
602
- this.add(event);
603
- }
604
- /** The folded state (live reference; do not mutate). */
605
- get fold() {
606
- return this.state;
607
- }
608
- }
609
- /**
610
- * Merge one priced event's contribution into an accumulator spend (pure:
611
- * returns a new spend, never mutates its input).
612
- * @param spend - the accumulator (per session and day, or across sessions).
613
- * @param priced - the priced contribution to add.
614
- * @returns the merged spend.
615
- */
616
- export function addEventContribution(spend, priced) {
617
- const row = contributionModel(priced);
618
- const rows = spend.models.map(existing => existing.model === priced.model ? mergeModelRows(existing, row) : existing);
619
- if (!rows.some(existing => existing.model === priced.model))
620
- rows.push(row);
621
- return { total: spend.total + priced.cost, models: rows };
622
- }
623
- /**
624
- * Sum two spends (per session and day, or across sessions) into one (pure:
625
- * returns a new spend, never mutates its inputs).
626
- * @param target - the accumulator spend.
627
- * @param source - the spend to add.
628
- * @returns the summed spend.
629
- */
630
- export function mergeTodaySpend(target, source) {
631
- const rows = new Map();
632
- for (const row of target.models)
633
- rows.set(row.model, row);
634
- for (const row of source.models) {
635
- const existing = rows.get(row.model);
636
- rows.set(row.model, existing === undefined ? row : mergeModelRows(existing, row));
637
- }
638
- return { total: target.total + source.total, models: [...rows.values()] };
639
- }
640
51
  /**
641
52
  * Price one session's complete event log at the official per-model rates,
642
53
  * with DSH's attempt semantics: every provider-reported sample (an
@@ -824,10 +235,13 @@ export function computeSessionTurnSpends(events, billing, catalog) {
824
235
  */
825
236
  export function computeTodaySpend(events, billing, catalog, now = new Date()) {
826
237
  const day = beijingDayKey(now);
238
+ // Events past the reference day cannot contribute: one numeric bound for the
239
+ // scan instead of a date string per event.
240
+ const dayRange = beijingDayRangeOfKey(day);
241
+ const dayEnd = dayRange?.end ?? Number.POSITIVE_INFINITY;
827
242
  const folder = new BillingFolder(billing, catalog);
828
243
  for (const event of events) {
829
- // Only events up to the reference day can contribute.
830
- if (beijingPartsOf(event.time).dayKey > day)
244
+ if (event.time >= dayEnd)
831
245
  continue;
832
246
  folder.add(event);
833
247
  }