@rayadesu/dsh-llm-billing 0.3.7 → 0.3.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -28,6 +28,14 @@ export const DEFAULT_MODEL_PRICING = [
28
28
  peak: { cacheHitInput: 0.10, cacheMissInput: 3.0, output: 9.0 },
29
29
  offPeak: { cacheHitInput: 0.05, cacheMissInput: 1.5, output: 4.5 },
30
30
  },
31
+ // deepseek-v4.1-flash-expires-on-0910 bills at the same rates as
32
+ // deepseek-v4-flash; image inputs are converted to tokens at the same
33
+ // per-token price.
34
+ {
35
+ model: 'deepseek-v4.1-flash-expires-on-0910',
36
+ peak: { cacheHitInput: 0.10, cacheMissInput: 3.0, output: 9.0 },
37
+ offPeak: { cacheHitInput: 0.05, cacheMissInput: 1.5, output: 4.5 },
38
+ },
31
39
  {
32
40
  model: 'deepseek-v4-pro',
33
41
  peak: { cacheHitInput: 0.30, cacheMissInput: 9.0, output: 27.0 },
@@ -40,6 +48,17 @@ export const DEFAULT_MODEL_PRICING = [
40
48
  peak: { cacheHitInput: 0.10, cacheMissInput: 3.0, output: 9.0 },
41
49
  offPeak: { cacheHitInput: 0.05, cacheMissInput: 1.5, output: 4.5 },
42
50
  },
51
+ // MiMo-V2.5 series (Xiaomi): flat rate, no peak/off-peak distinction.
52
+ {
53
+ model: 'mimo-v2.5-pro',
54
+ peak: { cacheHitInput: 0.025, cacheMissInput: 3.0, output: 6.0 },
55
+ offPeak: { cacheHitInput: 0.025, cacheMissInput: 3.0, output: 6.0 },
56
+ },
57
+ {
58
+ model: 'mimo-v2.5',
59
+ peak: { cacheHitInput: 0.02, cacheMissInput: 1.0, output: 2.0 },
60
+ offPeak: { cacheHitInput: 0.02, cacheMissInput: 1.0, output: 2.0 },
61
+ },
43
62
  ];
44
63
  /**
45
64
  * Resolve optional configuration to a pricing table, defaulting omitted or
@@ -62,23 +81,64 @@ export function resolveBilling(config) {
62
81
  models.set(row.model, { peak: row.peak, offPeak: row.offPeak });
63
82
  return { peakHours, models };
64
83
  }
84
+ /** Beijing is a fixed UTC+8 offset with no DST. */
85
+ const BEIJING_OFFSET_MS = 8 * 3_600_000;
86
+ /** Milliseconds in one day. */
87
+ const DAY_MS = 86_400_000;
88
+ /** Epoch day of 1970-01-01 in the civil-date algorithm below. */
89
+ const CIVIL_EPOCH_DAY = 719_468;
90
+ /** Two-digit zero pad for a calendar field. */
91
+ function pad2(value) {
92
+ return value < 10 ? `0${value}` : String(value);
93
+ }
65
94
  /**
66
- * Derive the Beijing hour, weekday, and calendar-day key of one timestamp from
67
- * a single shifted `Date` every timezone-sensitive read shares this one
68
- * implementation, so the pieces cannot drift apart.
95
+ * Civil date of an epoch day (Howard Hinnant's days-from-civil inverse):
96
+ * pure integer arithmetic, no `Date` allocation and no ISO-string slicing.
97
+ */
98
+ function civilDateOf(epochDay) {
99
+ const shifted = epochDay + CIVIL_EPOCH_DAY;
100
+ const era = Math.floor(shifted / 146_097);
101
+ const dayOfEra = shifted - era * 146_097;
102
+ const yearOfEra = Math.floor((dayOfEra - Math.floor(dayOfEra / 1_460) + Math.floor(dayOfEra / 36_524) - Math.floor(dayOfEra / 146_096)) / 365);
103
+ const year = yearOfEra + era * 400;
104
+ const dayOfYear = dayOfEra - (365 * yearOfEra + Math.floor(yearOfEra / 4) - Math.floor(yearOfEra / 100));
105
+ const monthPrime = Math.floor((5 * dayOfYear + 2) / 153);
106
+ const month = monthPrime + (monthPrime < 10 ? 3 : -9);
107
+ return {
108
+ // January/February belong to the civil year AFTER the era year.
109
+ year: month <= 2 ? year + 1 : year,
110
+ month,
111
+ day: dayOfYear - Math.floor((153 * monthPrime + 2) / 5) + 1,
112
+ };
113
+ }
114
+ /**
115
+ * Derive the Beijing hour, weekday, and calendar-day key of one timestamp with
116
+ * pure integer arithmetic — every timezone-sensitive read shares this one
117
+ * implementation, so the pieces cannot drift apart. Callers that filter by
118
+ * day and then price the same event reuse the returned view, so each event is
119
+ * parsed exactly once. (The hot fold path runs this per committed event; the
120
+ * previous `Date` + `toISOString().slice()` version allocated a `Date` and a
121
+ * 24-character string per call.)
69
122
  * @param time - epoch milliseconds.
123
+ * @throws {RangeError} when `time` is not a finite number.
70
124
  */
71
- function beijingParts(time) {
72
- const shifted = new Date(time + 8 * 3_600_000);
125
+ export function beijingPartsOf(time) {
126
+ if (!Number.isFinite(time))
127
+ throw new RangeError(`billing: event time is not finite (${String(time)})`);
128
+ const shifted = time + BEIJING_OFFSET_MS;
129
+ const epochDay = Math.floor(shifted / DAY_MS);
130
+ const msOfDay = shifted - epochDay * DAY_MS;
131
+ const civil = civilDateOf(epochDay);
73
132
  return {
74
- hour: shifted.getUTCHours(),
75
- weekday: shifted.getUTCDay(),
76
- dayKey: shifted.toISOString().slice(0, 10),
133
+ hour: Math.floor(msOfDay / 3_600_000),
134
+ // 1970-01-01 was a Thursday (4).
135
+ weekday: ((epochDay + 4) % 7 + 7) % 7,
136
+ dayKey: `${civil.year}-${pad2(civil.month)}-${pad2(civil.day)}`,
77
137
  };
78
138
  }
79
139
  /** The Beijing (Asia/Shanghai, UTC+8, no DST) calendar-day key of a timestamp. */
80
140
  export function beijingDayKey(now) {
81
- return beijingParts(now.getTime()).dayKey;
141
+ return beijingPartsOf(now.getTime()).dayKey;
82
142
  }
83
143
  /**
84
144
  * The durable inherited-prefix boundary of one session: the number of leading
@@ -123,7 +183,7 @@ function isPeakParts(billing, hour, weekday) {
123
183
  * @returns true during a weekday peak hour.
124
184
  */
125
185
  export function isPeak(billing, now) {
126
- const { hour, weekday } = beijingParts(now.getTime());
186
+ const { hour, weekday } = beijingPartsOf(now.getTime());
127
187
  return isPeakParts(billing, hour, weekday);
128
188
  }
129
189
  /**
@@ -132,35 +192,60 @@ export function isPeak(billing, now) {
132
192
  * only; weekends are off-peak). Each `assistant/message` event with usage
133
193
  * contributes cache-hit input, cache-miss input (uncached input plus cache
134
194
  * writes), and output (reasoning included) tokens at the rate of its own
135
- * timestamp; a model with usage but no pricing row contributes nothing (the
136
- * published table prices only the two V4 rows).
195
+ * timestamp; a model with usage but no pricing row contributes nothing.
137
196
  * @param event - the event to price.
138
197
  * @param billing - resolved pricing with peak-hour windows.
139
198
  * @param names - model id → display label.
140
199
  * @returns the priced contribution, or `undefined` when the event has no priced usage.
141
200
  */
142
201
  export function priceEvent(event, billing, names) {
202
+ return priceEventAt(beijingPartsOf(event.time), event, billing, names);
203
+ }
204
+ /**
205
+ * Price one event at the official per-model rates using a precomputed
206
+ * Beijing-time view — the day-filtering and pricing of one event share a
207
+ * single timezone parse (see {@link beijingPartsOf}). Semantics are identical
208
+ * to {@link priceEvent}.
209
+ * @param parts - the event's Beijing-time view.
210
+ * @param event - the event to price.
211
+ * @param billing - resolved pricing with peak-hour windows.
212
+ * @param names - model id → display label.
213
+ * @returns the priced contribution, or `undefined` when the event has no priced usage.
214
+ */
215
+ export function priceEventAt(parts, event, billing, names) {
143
216
  if (event.type !== 'assistant/message')
144
217
  return undefined;
145
218
  const reported = event.data.usage;
146
219
  if (reported === undefined)
147
220
  return undefined;
148
- const model = event.data.message.source.model;
221
+ return priceUsage(parts, reported, event.data.message.source.model, billing, names);
222
+ }
223
+ /**
224
+ * Price one provider-reported usage sample for one model at the rates of the
225
+ * sample's own Beijing-time hour and weekday. `undefined` when the model has
226
+ * no pricing row.
227
+ * @param parts - the sample's Beijing-time view.
228
+ * @param usage - the reported token buckets.
229
+ * @param model - the wire model id the sample belongs to.
230
+ * @param billing - resolved pricing with peak-hour windows.
231
+ * @param names - model id → display label.
232
+ * @returns the priced contribution, or `undefined` when the model has no rate row.
233
+ */
234
+ export function priceUsage(parts, usage, model, billing, names) {
149
235
  const pricing = billing.models.get(model);
150
236
  if (pricing === undefined)
151
237
  return undefined;
152
- const { hour, weekday, dayKey } = beijingParts(event.time);
153
- const peak = isPeakParts(billing, hour, weekday);
238
+ const peak = isPeakParts(billing, parts.hour, parts.weekday);
154
239
  const price = peak ? pricing.peak : pricing.offPeak;
155
- const hit = reported.cacheReadTokens ?? 0;
156
- const miss = reported.inputTokens + (reported.cacheWriteTokens ?? 0);
157
- const output = reported.outputTokens;
240
+ const hit = usage.cacheReadTokens ?? 0;
241
+ const miss = usage.inputTokens + (usage.cacheWriteTokens ?? 0);
242
+ const output = usage.outputTokens;
158
243
  const hitCost = (hit * price.cacheHitInput) / 1_000_000;
159
244
  const missCost = (miss * price.cacheMissInput) / 1_000_000;
160
245
  const outputCost = (output * price.output) / 1_000_000;
161
246
  const cost = hitCost + missCost + outputCost;
162
247
  return {
163
- dayKey,
248
+ dayKey: parts.dayKey,
164
249
  model,
165
250
  displayName: names.get(model) ?? model,
166
251
  cost,
@@ -233,6 +318,202 @@ export class SpendAccumulator {
233
318
  return { total: this.total, models: [...this.rows.values()] };
234
319
  }
235
320
  }
321
+ /** The additive inverse of one spend (pure): used to replace a priced sample. */
322
+ export function negateSpend(spend) {
323
+ const negate = (value) => -value;
324
+ return {
325
+ total: negate(spend.total),
326
+ models: spend.models.map(row => ({
327
+ ...row,
328
+ cost: negate(row.cost),
329
+ peakCost: negate(row.peakCost),
330
+ offPeakCost: negate(row.offPeakCost),
331
+ cacheHitInputTokens: negate(row.cacheHitInputTokens),
332
+ cacheMissInputTokens: negate(row.cacheMissInputTokens),
333
+ outputTokens: negate(row.outputTokens),
334
+ cacheHitInputCost: negate(row.cacheHitInputCost),
335
+ cacheMissInputCost: negate(row.cacheMissInputCost),
336
+ outputCost: negate(row.outputCost),
337
+ })),
338
+ };
339
+ }
340
+ /**
341
+ * Subtract one spend from another (pure). Rows that cancel out completely are
342
+ * dropped so a replaced sample leaves no zero row behind.
343
+ * @param target - the spend to subtract from.
344
+ * @param source - the spend to remove.
345
+ * @returns the difference.
346
+ */
347
+ export function subtractSpend(target, source) {
348
+ const rows = new Map();
349
+ for (const row of target.models)
350
+ rows.set(row.model, row);
351
+ for (const row of source.models) {
352
+ const existing = rows.get(row.model);
353
+ if (existing === undefined)
354
+ continue;
355
+ const next = mergeModelRows(existing, negateSpend({ total: 0, models: [row] }).models[0]);
356
+ if (next.cost === 0 && next.cacheHitInputTokens === 0 && next.cacheMissInputTokens === 0 && next.outputTokens === 0) {
357
+ rows.delete(row.model);
358
+ }
359
+ else {
360
+ rows.set(row.model, next);
361
+ }
362
+ }
363
+ return { total: target.total - source.total, models: [...rows.values()] };
364
+ }
365
+ /** The empty fold state for one fork boundary. */
366
+ export function emptyBillingFoldState(inheritedEventCount = 0) {
367
+ return {
368
+ dayKey: '',
369
+ spend: emptyTodaySpend(),
370
+ session: emptyTodaySpend(),
371
+ inheritedEventCount,
372
+ model: '',
373
+ last: null,
374
+ };
375
+ }
376
+ /** Whether an unknown value looks like a provider usage report. */
377
+ function isTokenUsage(value) {
378
+ if (typeof value !== 'object' || value === null)
379
+ return false;
380
+ const candidate = value;
381
+ return typeof candidate.inputTokens === 'number' && typeof candidate.outputTokens === 'number';
382
+ }
383
+ /**
384
+ * The last `usage` sample embedded in an event's stream, if any. `assistant/
385
+ * attempt` and the embedded streams are newer than the plugin's npm baseline,
386
+ * so the stream is read structurally (a failed/retried attempt reports its
387
+ * usage only there).
388
+ */
389
+ function streamUsageOf(event) {
390
+ const stream = event.data === undefined
391
+ ? undefined
392
+ : event.data.stream;
393
+ if (!Array.isArray(stream))
394
+ return undefined;
395
+ for (let index = stream.length - 1; index >= 0; index -= 1) {
396
+ const chunk = stream[index]?.chunk;
397
+ if (chunk === undefined || chunk.type !== 'usage')
398
+ continue;
399
+ return isTokenUsage(chunk.usage) ? chunk.usage : undefined;
400
+ }
401
+ return undefined;
402
+ }
403
+ /** The contribution as a one-row spend (the shape a sample keeps for replacement). */
404
+ function contributionSpend(priced) {
405
+ return { total: priced.cost, models: [contributionModel(priced)] };
406
+ }
407
+ /**
408
+ * Fold one committed event into a session's billed-spend state.
409
+ *
410
+ * Priced samples come from `assistant/message` (its own reported usage, or the
411
+ * stream's last usage chunk) and `assistant/attempt` (the stream's last usage
412
+ * chunk, priced with the model of the latest `request/header`, since an
413
+ * attempt carries no route). A sample for the same `(turn, step)` replaces the
414
+ * previous one; `llm/retry-started` closes the replacement slot so a retried
415
+ * attempt adds. Every other event is inert and returns the same state
416
+ * reference.
417
+ * @param state - the previous fold state.
418
+ * @param event - the committed event.
419
+ * @param billing - resolved pricing with peak-hour windows.
420
+ * @param names - model id → display label.
421
+ * @returns the next state (the same reference when nothing was priced).
422
+ */
423
+ export function applyBillingEvent(state, event, billing, names) {
424
+ if (event.seq < state.inheritedEventCount)
425
+ return state;
426
+ // `assistant/attempt`, `llm/retry-started`, and the embedded stream are all
427
+ // newer than the npm baseline this package builds against, so their fields
428
+ // are read structurally.
429
+ const type = event.type;
430
+ if (type === 'request/header') {
431
+ const model = event.data
432
+ ?.header?.config?.model;
433
+ return typeof model === 'string' && model.length > 0 && model !== state.model ? { ...state, model } : state;
434
+ }
435
+ const data = event.data;
436
+ if (type === 'llm/retry-started') {
437
+ if (typeof data?.turn !== 'number' || typeof data.step !== 'number')
438
+ return state;
439
+ const last = state.last;
440
+ if (last === null || last.turn !== data.turn || last.step !== data.step)
441
+ return state;
442
+ return { ...state, last: null };
443
+ }
444
+ if (type !== 'assistant/message' && type !== 'assistant/attempt')
445
+ return state;
446
+ const usage = (type === 'assistant/message' ? data?.usage : undefined) ?? streamUsageOf(event);
447
+ if (!isTokenUsage(usage))
448
+ return state;
449
+ const model = type === 'assistant/message' ? data?.message?.source?.model : state.model;
450
+ if (typeof model !== 'string' || model.length === 0)
451
+ return state;
452
+ const priced = priceUsage(beijingPartsOf(event.time), usage, model, billing, names);
453
+ if (priced === undefined)
454
+ return state;
455
+ let session = state.session;
456
+ let spend = state.spend;
457
+ let dayKey = state.dayKey;
458
+ const last = state.last;
459
+ const turn = typeof data?.turn === 'number' ? data.turn : 0;
460
+ const step = typeof data?.step === 'number' ? data.step : 0;
461
+ if (last !== null && last.turn === turn && last.step === step) {
462
+ session = subtractSpend(session, last.spend);
463
+ if (last.dayKey === dayKey)
464
+ spend = subtractSpend(spend, last.spend);
465
+ }
466
+ session = addEventContribution(session, priced);
467
+ if (dayKey === priced.dayKey) {
468
+ spend = addEventContribution(spend, priced);
469
+ }
470
+ else if (dayKey === '' || priced.dayKey > dayKey) {
471
+ // The session log is append-only and chronological, so a strictly older
472
+ // day cannot legally follow; ignore it for the latest-day state (the
473
+ // whole-session total still accrues).
474
+ dayKey = priced.dayKey;
475
+ spend = addEventContribution(emptyTodaySpend(), priced);
476
+ }
477
+ return {
478
+ ...state,
479
+ dayKey,
480
+ spend,
481
+ session,
482
+ last: { turn, step, dayKey: priced.dayKey, spend: contributionSpend(priced) },
483
+ };
484
+ }
485
+ /**
486
+ * Mutable wrapper over {@link applyBillingEvent} for the pure pricing paths:
487
+ * feed events in order, read the folded spend.
488
+ */
489
+ export class BillingFolder {
490
+ billing;
491
+ state;
492
+ /**
493
+ * @param billing - resolved pricing with peak-hour windows.
494
+ * @param catalog - model display rows, in presentation order.
495
+ * @param inheritedEventCount - fork boundary to skip (default 0).
496
+ */
497
+ constructor(billing, catalog, inheritedEventCount = 0) {
498
+ this.billing = billing;
499
+ this.names = new Map(catalog.map(model => [model.id, model.name]));
500
+ this.state = emptyBillingFoldState(inheritedEventCount);
501
+ }
502
+ names;
503
+ /** Fold one event. */
504
+ add(event) {
505
+ this.state = applyBillingEvent(this.state, event, this.billing, this.names);
506
+ }
507
+ /** Fold every event, in order. */
508
+ addAll(events) {
509
+ for (const event of events)
510
+ this.add(event);
511
+ }
512
+ /** The folded state (live reference; do not mutate). */
513
+ get fold() {
514
+ return this.state;
515
+ }
516
+ }
236
517
  /**
237
518
  * Merge one priced event's contribution into an accumulator spend (pure:
238
519
  * returns a new spend, never mutates its input).
@@ -265,36 +546,11 @@ export function mergeTodaySpend(target, source) {
265
546
  return { total: target.total + source.total, models: [...rows.values()] };
266
547
  }
267
548
  /**
268
- * Price a set of billed events at the official per-model rates, applying the
269
- * peak/off-peak table per event by its Beijing-time hour and weekday (peak
270
- * windows apply Monday–Friday only; weekends are off-peak). Each
271
- * `assistant/message` event with usage contributes cache-hit input, cache-miss
272
- * input (uncached input plus cache writes), and output (reasoning included)
273
- * tokens at the rate of its own timestamp, with the three component costs
274
- * carried separately; a model with usage but no pricing row is omitted (the
275
- * published table prices only the two V4 rows).
276
- * @param events - the events to price.
277
- * @param billing - resolved pricing with peak-hour windows.
278
- * @param names - model id → display label.
279
- * @param dayKey - when provided, only events on this Beijing calendar day contribute.
280
- * @param startSeq - when provided, only events with `seq >= startSeq` contribute
281
- * (a forked session's inherited prefix, `seq < startSeq`, is skipped).
282
- * @returns the total cost plus one row per priced model.
283
- */
284
- function priceEvents(events, billing, names, dayKey, startSeq = 0) {
285
- const accumulator = new SpendAccumulator();
286
- for (const event of events) {
287
- if (event.seq < startSeq)
288
- continue;
289
- const priced = priceEvent(event, billing, names);
290
- if (priced === undefined || (dayKey !== undefined && priced.dayKey !== dayKey))
291
- continue;
292
- accumulator.add(priced);
293
- }
294
- return accumulator.finish();
295
- }
296
- /**
297
- * Price one session's complete event log at the official per-model rates.
549
+ * Price one session's complete event log at the official per-model rates,
550
+ * with DSH's attempt semantics: every provider-reported sample (an
551
+ * `assistant/message`'s usage, or an `assistant/attempt`'s stream usage)
552
+ * contributes, a later sample for the same `(turn, step)` replaces the earlier
553
+ * one, and `llm/retry-started` makes the retried attempt add.
298
554
  * @param events - one session's complete event log.
299
555
  * @param billing - resolved pricing with peak-hour windows.
300
556
  * @param catalog - model display rows, in presentation order.
@@ -305,17 +561,18 @@ function priceEvents(events, billing, names, dayKey, startSeq = 0) {
305
561
  * @returns the session's total cost plus one row per priced model.
306
562
  */
307
563
  export function computeSessionSpend(events, billing, catalog, startSeq = 0) {
308
- const names = new Map(catalog.map(model => [model.id, model.name]));
309
- return priceEvents(events, billing, names, undefined, startSeq);
564
+ const folder = new BillingFolder(billing, catalog, startSeq);
565
+ folder.addAll(events);
566
+ return folder.fold.session;
310
567
  }
311
568
  /**
312
- * Price one completed Turn's billed usage at the official per-model rates,
313
- * identified by its closing assistant message id. The turn's events are those
314
- * between its `turn/start` and `turn/end` (both matched by the message's own
315
- * turn coordinate); each priced event applies the peak/off-peak table by its
316
- * Beijing-time hour and weekday. A message that cannot be located, a turn
317
- * without bracketing `turn/start` / `turn/end` events (for example after
318
- * compaction), or a session with no priced usage prices to zero.
569
+ * Price one completed Turn's billed usage, identified by its closing
570
+ * assistant message id. The turn's events are those between its `turn/start`
571
+ * and `turn/end` (both matched by the message's own turn coordinate), priced
572
+ * with the same attempt semantics as {@link computeSessionSpend}. A message
573
+ * that cannot be located, a turn without bracketing `turn/start` / `turn/end`
574
+ * events (for example after compaction), or a session with no priced usage
575
+ * prices to zero.
319
576
  * @param events - one session's complete event log.
320
577
  * @param billing - resolved pricing with peak-hour windows.
321
578
  * @param catalog - model display rows, in presentation order.
@@ -323,7 +580,18 @@ export function computeSessionSpend(events, billing, catalog, startSeq = 0) {
323
580
  * @returns the turn's total cost in CNY.
324
581
  */
325
582
  export function computeTurnSpend(events, billing, catalog, messageId) {
326
- const names = new Map(catalog.map(model => [model.id, model.name]));
583
+ return { total: turnCostOf(events, billing, catalog, messageId) };
584
+ }
585
+ /**
586
+ * The total cost of the Turn containing `messageId`, folded with the shared
587
+ * attempt semantics (see {@link applyBillingEvent}).
588
+ * @param events - one session's complete event log.
589
+ * @param billing - resolved pricing with peak-hour windows.
590
+ * @param catalog - model display rows, in presentation order.
591
+ * @param messageId - one assistant message inside the Turn.
592
+ * @returns the Turn's total cost in CNY, or 0 when the Turn cannot be located.
593
+ */
594
+ function turnCostOf(events, billing, catalog, messageId) {
327
595
  let turn;
328
596
  for (const event of events) {
329
597
  if (event.type !== 'assistant/message')
@@ -334,8 +602,8 @@ export function computeTurnSpend(events, billing, catalog, messageId) {
334
602
  break;
335
603
  }
336
604
  if (turn === undefined)
337
- return { total: 0 };
338
- const accumulator = new SpendAccumulator();
605
+ return 0;
606
+ const folder = new BillingFolder(billing, catalog);
339
607
  let active = false;
340
608
  for (const event of events) {
341
609
  if (event.type === 'turn/start' && event.data.turn === turn) {
@@ -346,17 +614,117 @@ export function computeTurnSpend(events, billing, catalog, messageId) {
346
614
  break;
347
615
  if (!active)
348
616
  continue;
349
- const priced = priceEvent(event, billing, names);
350
- if (priced !== undefined)
351
- accumulator.add(priced);
617
+ folder.add(event);
618
+ }
619
+ return folder.fold.session.total;
620
+ }
621
+ /**
622
+ * Incremental single-pass fold of one session's completed-Turn costs, keyed by
623
+ * the id of every assistant message inside each Turn. Feeding the fold only
624
+ * the appended tail keeps a growing session's map current in O(new events)
625
+ * instead of re-scanning the whole log per message.
626
+ *
627
+ * Semantics are exactly {@link computeTurnSpend}'s: a Turn is the
628
+ * `turn/start`..`turn/end` range (matched by the event's own turn coordinate),
629
+ * every priced event inside it contributes at its own timestamp's rate, and a
630
+ * message outside any bracket contributes nothing.
631
+ */
632
+ export class SessionTurnSpendFolder {
633
+ billing;
634
+ catalog;
635
+ rows = [];
636
+ ids = [];
637
+ /** Events of the open Turn, folded with the shared attempt semantics on close. */
638
+ events = [];
639
+ open = false;
640
+ /** Events already fed; a shorter log resets the fold. */
641
+ cursor = 0;
642
+ /**
643
+ * @param billing - resolved pricing with peak-hour windows.
644
+ * @param catalog - model display rows, in presentation order.
645
+ */
646
+ constructor(billing, catalog) {
647
+ this.billing = billing;
648
+ this.catalog = catalog;
649
+ }
650
+ /** How many events have been folded so far (the host's incremental cursor). */
651
+ get processed() {
652
+ return this.cursor;
653
+ }
654
+ /**
655
+ * Fold every event from the cursor to the end of the log. A log shorter than
656
+ * the cursor (rewritten session) restarts the fold from an empty state.
657
+ * @param events - the session's complete event log, in seq order.
658
+ */
659
+ feed(events) {
660
+ if (events.length < this.cursor)
661
+ this.reset();
662
+ for (let index = this.cursor; index < events.length; index += 1) {
663
+ const event = events[index];
664
+ if (event.type === 'turn/start') {
665
+ this.open = true;
666
+ this.ids = [];
667
+ this.events = [];
668
+ continue;
669
+ }
670
+ if (event.type === 'turn/end') {
671
+ if (this.open) {
672
+ const folder = new BillingFolder(this.billing, this.catalog);
673
+ folder.addAll(this.events);
674
+ const total = folder.fold.session.total;
675
+ for (const messageId of this.ids)
676
+ this.rows.push({ messageId, total });
677
+ }
678
+ this.open = false;
679
+ this.ids = [];
680
+ this.events = [];
681
+ continue;
682
+ }
683
+ if (!this.open)
684
+ continue;
685
+ if (event.type === 'assistant/message')
686
+ this.ids.push(event.data.message.id);
687
+ this.events.push(event);
688
+ }
689
+ this.cursor = events.length;
352
690
  }
353
- return { total: accumulator.finish().total };
691
+ /** The folded map; the fold stays usable afterwards. */
692
+ finish() {
693
+ return { turns: [...this.rows] };
694
+ }
695
+ /** Drop the fold state so the next feed starts from the log's beginning. */
696
+ reset() {
697
+ this.rows.length = 0;
698
+ this.ids = [];
699
+ this.events = [];
700
+ this.open = false;
701
+ this.cursor = 0;
702
+ }
703
+ }
704
+ /**
705
+ * Price every completed Turn of one session in a single pass (the pure
706
+ * equivalent of {@link SessionTurnSpendFolder}).
707
+ * @param events - one session's complete event log.
708
+ * @param billing - resolved pricing with peak-hour windows.
709
+ * @param catalog - model display rows, in presentation order.
710
+ * @returns one row per assistant message inside a completed Turn, in log order.
711
+ */
712
+ export function computeSessionTurnSpends(events, billing, catalog) {
713
+ const folder = new SessionTurnSpendFolder(billing, catalog);
714
+ folder.feed(events);
715
+ return folder.finish();
354
716
  }
355
717
  /**
356
- * Price every event whose Beijing-time calendar day is the day of `now`,
357
- * aggregating across every session's event log. Events from other Beijing
358
- * days are ignored, so a caller passes the concatenated logs of all sessions.
359
- * @param events - every session's complete event log, concatenated.
718
+ * Price one session's log for the Beijing-time calendar day of `now`. Events
719
+ * after the reference day are ignored; the fold's latest-day state then
720
+ * answers the query exactly (empty when the session's latest priced day is not
721
+ * the reference day). Pricing follows {@link applyBillingEvent} (attempt
722
+ * samples with same-step replacement).
723
+ *
724
+ * The fold's `(turn, step)` replacement slot is per session, so callers must
725
+ * pass ONE session's log; aggregate across sessions with
726
+ * {@link mergeTodaySpend}.
727
+ * @param events - one session's complete event log.
360
728
  * @param billing - resolved pricing with peak-hour windows.
361
729
  * @param catalog - model display rows, in presentation order.
362
730
  * @param now - the reference moment whose Beijing-time calendar day is "today".
@@ -364,7 +732,13 @@ export function computeTurnSpend(events, billing, catalog, messageId) {
364
732
  */
365
733
  export function computeTodaySpend(events, billing, catalog, now = new Date()) {
366
734
  const day = beijingDayKey(now);
367
- const names = new Map(catalog.map(model => [model.id, model.name]));
368
- return priceEvents(events, billing, names, day);
735
+ const folder = new BillingFolder(billing, catalog);
736
+ for (const event of events) {
737
+ // Only events up to the reference day can contribute.
738
+ if (beijingPartsOf(event.time).dayKey > day)
739
+ continue;
740
+ folder.add(event);
741
+ }
742
+ return folder.fold.dayKey === day ? folder.fold.spend : emptyTodaySpend();
369
743
  }
370
744
  //# sourceMappingURL=billing.js.map
@@ -24,8 +24,8 @@ import type { Context } from '@deepseek-ai/cordis';
24
24
  import z from '@deepseek-ai/schemastery';
25
25
  import type { BillingConfig } from './billing.ts';
26
26
  export { DeepSeekBalanceGateway, fetchDeepSeekBalance, parseDeepSeekBalance } from './balance.ts';
27
- export { addEventContribution, beijingDayKey, computeSessionSpend, computeTodaySpend, computeTurnSpend, DEFAULT_MODEL_PRICING, DEFAULT_PEAK_HOURS, emptyTodaySpend, forkBoundaryOf, isPeak, isSeededSession, mergeTodaySpend, priceEvent, resolveBilling, SpendAccumulator, } from './billing.ts';
28
- export type { BillingConfig, BillingConfigModel, BillingEventContribution, DeepSeekModelPricing, DeepSeekTokenPrice, PeakHourWindow, ResolvedBilling, } from './billing.ts';
27
+ export { addEventContribution, applyBillingEvent, beijingDayKey, BillingFolder, computeSessionSpend, computeSessionTurnSpends, computeTodaySpend, computeTurnSpend, DEFAULT_MODEL_PRICING, DEFAULT_PEAK_HOURS, emptyBillingFoldState, emptyTodaySpend, forkBoundaryOf, isPeak, isSeededSession, mergeTodaySpend, negateSpend, priceEvent, priceUsage, resolveBilling, SessionTurnSpendFolder, SpendAccumulator, subtractSpend, } from './billing.ts';
28
+ export type { BillingConfig, BillingConfigModel, BillingEventContribution, BillingFoldSample, BillingFoldState, DeepSeekModelPricing, DeepSeekTokenPrice, PeakHourWindow, ResolvedBilling, } from './billing.ts';
29
29
  export type * from './types.ts';
30
30
  export { BILLING_UNIT_KEY, billingTodaySpendDefinition, foldBillingUnit, foldOwnBilling } from './projection.ts';
31
31
  export type { BillingUnitFold, BillingUnitState } from './projection.ts';
@@ -52,7 +52,7 @@ export interface Config {
52
52
  apiKeyEnv?: string;
53
53
  /** Endpoint base; defaults to `$DEEPSEEK_BASE_URL`, then `https://api.deepseek.com`. */
54
54
  baseURL?: string;
55
- /** Advisory display rows, in presentation order; defaults to V4 Flash, V4 Pro, and V4 Flash Vision Exp. */
55
+ /** Advisory display rows, in presentation order; defaults to V4 Flash, V4.1 Flash, V4 Pro, and V4 Flash Vision Exp. */
56
56
  models?: BillingModel[];
57
57
  /** Pricing table and peak-hour windows; omission uses the published defaults. Peak windows apply weekdays (Monday–Friday) only; weekends are always off-peak. */
58
58
  billing?: BillingConfig;
@@ -62,8 +62,18 @@ export declare const Config: z<Config>;
62
62
  export declare const TODAY_SPEND_CACHE_MS = 60000;
63
63
  /** Hard cap on today's events collected by the events scan path. */
64
64
  export declare const TODAY_SPEND_MAX_EVENTS = 200000;
65
+ /** Max session-spend rows kept for incremental recompute before eviction. */
66
+ export declare const SESSION_SPEND_CACHE_LIMIT = 1024;
67
+ /** Max session-id entries kept in the per-turn-cost fold cache before eviction. */
68
+ export declare const SESSION_TURN_SPEND_CACHE_LIMIT = 64;
69
+ /** How long one balance snapshot is reused before the host refetches it (15s). */
70
+ export declare const BALANCE_CACHE_MS = 15000;
71
+ /** Hard cap on one `/user/balance` request (5s); a hung endpoint never blocks the badge. */
72
+ export declare const BALANCE_TIMEOUT_MS = 5000;
65
73
  /**
66
- * Register the `billing` Remote under the `billing` namespace.
74
+ * Register the `billing` Remote under the `billing` namespace. Assembly only:
75
+ * facts resolve once, each loader owns its caches, and the gateway receives
76
+ * the bound thunks.
67
77
  * @param ctx - owning plugin context.
68
78
  * @param config - validated plugin config.
69
79
  */