@rayadesu/dsh-llm-billing 0.3.8 → 0.3.10
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/README.i18n.yaml +2 -2
- package/README.md +24 -14
- package/README.zh.md +24 -14
- package/lib/index.js +1053 -421
- package/lib/typert.host.js +49 -5
- package/lib/typert.remote-client.d.ts +5 -3
- package/lib/typert.remote-client.js +49 -5
- package/lib/types/balance.d.ts +33 -15
- package/lib/types/balance.js +28 -13
- package/lib/types/billing.d.ts +287 -31
- package/lib/types/billing.js +542 -87
- package/lib/types/index.d.ts +20 -5
- package/lib/types/index.js +228 -74
- package/lib/types/projection.d.ts +33 -32
- package/lib/types/projection.js +41 -38
- package/lib/types/today-spend.d.ts +138 -62
- package/lib/types/today-spend.js +209 -261
- package/lib/types/types.d.ts +34 -5
- package/lib/types/types.js +12 -0
- package/package.json +5 -1
package/lib/index.js
CHANGED
|
@@ -137,6 +137,7 @@ let DeepSeekBalanceGateway = (() => {
|
|
|
137
137
|
let _getTodaySpend_decorators;
|
|
138
138
|
let _getTodaySessionsSpend_decorators;
|
|
139
139
|
let _getTurnSpend_decorators;
|
|
140
|
+
let _getSessionTurnSpends_decorators;
|
|
140
141
|
return class DeepSeekBalanceGateway extends _classSuper {
|
|
141
142
|
static {
|
|
142
143
|
const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
|
|
@@ -145,6 +146,7 @@ let DeepSeekBalanceGateway = (() => {
|
|
|
145
146
|
_getTodaySpend_decorators = [Remote("getTodaySpend")];
|
|
146
147
|
_getTodaySessionsSpend_decorators = [Remote("getTodaySessionsSpend")];
|
|
147
148
|
_getTurnSpend_decorators = [Remote("getTurnSpend")];
|
|
149
|
+
_getSessionTurnSpends_decorators = [Remote("getSessionTurnSpends")];
|
|
148
150
|
__esDecorate(this, null, _getBalance_decorators, {
|
|
149
151
|
kind: "method",
|
|
150
152
|
name: "getBalance",
|
|
@@ -200,6 +202,17 @@ let DeepSeekBalanceGateway = (() => {
|
|
|
200
202
|
},
|
|
201
203
|
metadata: _metadata
|
|
202
204
|
}, null, _instanceExtraInitializers);
|
|
205
|
+
__esDecorate(this, null, _getSessionTurnSpends_decorators, {
|
|
206
|
+
kind: "method",
|
|
207
|
+
name: "getSessionTurnSpends",
|
|
208
|
+
static: false,
|
|
209
|
+
private: false,
|
|
210
|
+
access: {
|
|
211
|
+
has: (obj) => "getSessionTurnSpends" in obj,
|
|
212
|
+
get: (obj) => obj.getSessionTurnSpends
|
|
213
|
+
},
|
|
214
|
+
metadata: _metadata
|
|
215
|
+
}, null, _instanceExtraInitializers);
|
|
203
216
|
if (_metadata) Object.defineProperty(this, Symbol.metadata, {
|
|
204
217
|
enumerable: true,
|
|
205
218
|
configurable: true,
|
|
@@ -218,15 +231,18 @@ let DeepSeekBalanceGateway = (() => {
|
|
|
218
231
|
this.options = options;
|
|
219
232
|
}
|
|
220
233
|
/**
|
|
221
|
-
* Read the current DeepSeek account balance.
|
|
234
|
+
* Read the current DeepSeek account balance. A snapshot younger than the
|
|
235
|
+
* host-side TTL is reused, so several badge mounts share one provider call;
|
|
236
|
+
* `force` bypasses the TTL for the manual refresh.
|
|
237
|
+
* @param force - bypass the host-side TTL; omitted means a cached read.
|
|
222
238
|
* @returns the validated balance snapshot.
|
|
223
239
|
*/
|
|
224
|
-
getBalance() {
|
|
225
|
-
return this.options.fetchBalance();
|
|
240
|
+
getBalance(force) {
|
|
241
|
+
return this.options.fetchBalance(force ?? false);
|
|
226
242
|
}
|
|
227
243
|
/**
|
|
228
|
-
* Read one session's billed spend
|
|
229
|
-
*
|
|
244
|
+
* Read one session's billed spend (same per-event pricing as
|
|
245
|
+
* {@link priceEvent}).
|
|
230
246
|
* @param sessionId - the session whose spend to compute.
|
|
231
247
|
* @returns the session's total cost plus one row per priced model.
|
|
232
248
|
*/
|
|
@@ -234,8 +250,8 @@ let DeepSeekBalanceGateway = (() => {
|
|
|
234
250
|
return this.options.fetchSessionSpend(sessionId);
|
|
235
251
|
}
|
|
236
252
|
/**
|
|
237
|
-
* Read today's billed spend across every session
|
|
238
|
-
*
|
|
253
|
+
* Read today's billed spend across every session: the same per-event
|
|
254
|
+
* pricing as {@link priceEvent}, restricted to the queried Beijing day.
|
|
239
255
|
* @param force - bypass the host-side 60s cache (manual refresh); omitted
|
|
240
256
|
* means a cached read. Remote parameters cannot carry default values, so
|
|
241
257
|
* the thunk receives `undefined` for an omitted argument.
|
|
@@ -245,10 +261,9 @@ let DeepSeekBalanceGateway = (() => {
|
|
|
245
261
|
return this.options.fetchTodaySpend(force ?? false);
|
|
246
262
|
}
|
|
247
263
|
/**
|
|
248
|
-
* Read today's billed spend per session,
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
* descending; sessions with no priced usage on the day are omitted.
|
|
264
|
+
* Read today's billed spend per session, restricted to the queried Beijing
|
|
265
|
+
* day. Rows carry the session's durable title and sort by cost descending;
|
|
266
|
+
* sessions with no priced usage on the day are omitted.
|
|
252
267
|
* @param force - bypass the host-side 60s cache (manual refresh); omitted
|
|
253
268
|
* means a cached read.
|
|
254
269
|
* @returns today's per-session rows, highest first.
|
|
@@ -257,8 +272,8 @@ let DeepSeekBalanceGateway = (() => {
|
|
|
257
272
|
return this.options.fetchTodaySessionsSpend(force ?? false);
|
|
258
273
|
}
|
|
259
274
|
/**
|
|
260
|
-
* Read one completed Turn's billed spend
|
|
261
|
-
*
|
|
275
|
+
* Read one completed Turn's billed spend (same per-event pricing as
|
|
276
|
+
* {@link priceEvent}).
|
|
262
277
|
* @param sessionId - the session owning the Turn.
|
|
263
278
|
* @param messageId - the closing assistant message's durable id, which
|
|
264
279
|
* locates the Turn in the session log.
|
|
@@ -267,6 +282,16 @@ let DeepSeekBalanceGateway = (() => {
|
|
|
267
282
|
getTurnSpend(sessionId, messageId) {
|
|
268
283
|
return this.options.fetchTurnSpend(sessionId, messageId);
|
|
269
284
|
}
|
|
285
|
+
/**
|
|
286
|
+
* Read every completed Turn's billed spend in one session (same per-event
|
|
287
|
+
* pricing as {@link priceEvent}). One call replaces the per-message
|
|
288
|
+
* `getTurnSpend` fan-out for a rendered transcript.
|
|
289
|
+
* @param sessionId - the session whose Turn costs to compute.
|
|
290
|
+
* @returns one row per assistant message inside a completed Turn, in log order.
|
|
291
|
+
*/
|
|
292
|
+
getSessionTurnSpends(sessionId) {
|
|
293
|
+
return this.options.fetchTurnSpends(sessionId);
|
|
294
|
+
}
|
|
270
295
|
};
|
|
271
296
|
})();
|
|
272
297
|
//#endregion
|
|
@@ -297,20 +322,93 @@ const DEFAULT_PEAK_HOURS = [{
|
|
|
297
322
|
start: 14,
|
|
298
323
|
end: 18
|
|
299
324
|
}];
|
|
300
|
-
/**
|
|
325
|
+
/**
|
|
326
|
+
* Inclusive epoch ms of the published V4 Flash series re-pricing:
|
|
327
|
+
* 2026-09-10 12:00 Beijing time (UTC+8, no DST) = 04:00 UTC. Samples before
|
|
328
|
+
* this instant keep the base rates; samples at or after it bill at the second
|
|
329
|
+
* revision.
|
|
330
|
+
*/
|
|
331
|
+
const FLASH_SERIES_RATE_CHANGE_AT = Date.UTC(2026, 8, 10, 4, 0, 0);
|
|
332
|
+
/**
|
|
333
|
+
* Inclusive epoch ms of the announced V4 Pro route switch: 2026-09-14 12:00
|
|
334
|
+
* Beijing time (UTC+8, no DST) = 04:00 UTC. From that instant the V4 Pro route
|
|
335
|
+
* is served by V4.1 Flash and billed at the V4.1 Flash rates.
|
|
336
|
+
*/
|
|
337
|
+
const V4_PRO_ROUTE_SWITCH_AT = Date.UTC(2026, 8, 14, 4, 0, 0);
|
|
338
|
+
/** The V4 Flash series' base rates (effective 2026-08-17), CNY per 1M tokens. */
|
|
339
|
+
const FLASH_BASE_RATES = {
|
|
340
|
+
peak: {
|
|
341
|
+
cacheHitInput: .1,
|
|
342
|
+
cacheMissInput: 3,
|
|
343
|
+
output: 9
|
|
344
|
+
},
|
|
345
|
+
offPeak: {
|
|
346
|
+
cacheHitInput: .05,
|
|
347
|
+
cacheMissInput: 1.5,
|
|
348
|
+
output: 4.5
|
|
349
|
+
}
|
|
350
|
+
};
|
|
351
|
+
/**
|
|
352
|
+
* The V4 Flash series' second revision (effective
|
|
353
|
+
* {@link FLASH_SERIES_RATE_CHANGE_AT}): off-peak 0.02 / 1.0 / 4.0, peak at
|
|
354
|
+
* twice those prices.
|
|
355
|
+
*/
|
|
356
|
+
const FLASH_REPRICED_RATES = {
|
|
357
|
+
effectiveFrom: FLASH_SERIES_RATE_CHANGE_AT,
|
|
358
|
+
peak: {
|
|
359
|
+
cacheHitInput: .04,
|
|
360
|
+
cacheMissInput: 2,
|
|
361
|
+
output: 8
|
|
362
|
+
},
|
|
363
|
+
offPeak: {
|
|
364
|
+
cacheHitInput: .02,
|
|
365
|
+
cacheMissInput: 1,
|
|
366
|
+
output: 4
|
|
367
|
+
}
|
|
368
|
+
};
|
|
369
|
+
/**
|
|
370
|
+
* The V4.1 Flash rates as they reach the retired V4 Pro route from
|
|
371
|
+
* {@link V4_PRO_ROUTE_SWITCH_AT}: the same price pair as the flash series'
|
|
372
|
+
* second revision, carried at its own effective instant.
|
|
373
|
+
*/
|
|
374
|
+
const V4_PRO_SWITCHED_RATES = {
|
|
375
|
+
effectiveFrom: V4_PRO_ROUTE_SWITCH_AT,
|
|
376
|
+
peak: FLASH_REPRICED_RATES.peak,
|
|
377
|
+
offPeak: FLASH_REPRICED_RATES.offPeak
|
|
378
|
+
};
|
|
379
|
+
/**
|
|
380
|
+
* Official peak/off-peak rates (CNY per 1M tokens) per model, as dated
|
|
381
|
+
* revisions. Base rows are the schedule effective 2026-08-17; the V4 Flash
|
|
382
|
+
* series (V4.1 Flash, V4 Flash, V4 Flash Vision Exp) carries the second
|
|
383
|
+
* revision effective 2026-09-10 12:00 Beijing, and the V4 Pro row the V4.1
|
|
384
|
+
* Flash rates from its announced route switch (2026-09-14 12:00 Beijing) —
|
|
385
|
+
* the MiMo-V2.5 series is untouched by either adjustment. Rows sharing a model
|
|
386
|
+
* are that model's rate history.
|
|
387
|
+
*/
|
|
301
388
|
const DEFAULT_MODEL_PRICING = [
|
|
389
|
+
{
|
|
390
|
+
model: "deepseek-flash",
|
|
391
|
+
...FLASH_BASE_RATES
|
|
392
|
+
},
|
|
393
|
+
{
|
|
394
|
+
model: "deepseek-flash",
|
|
395
|
+
...FLASH_REPRICED_RATES
|
|
396
|
+
},
|
|
302
397
|
{
|
|
303
398
|
model: "deepseek-v4-flash",
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
399
|
+
...FLASH_BASE_RATES
|
|
400
|
+
},
|
|
401
|
+
{
|
|
402
|
+
model: "deepseek-v4-flash",
|
|
403
|
+
...FLASH_REPRICED_RATES
|
|
404
|
+
},
|
|
405
|
+
{
|
|
406
|
+
model: "deepseek-v4.1-flash-expires-on-0910",
|
|
407
|
+
...FLASH_BASE_RATES
|
|
408
|
+
},
|
|
409
|
+
{
|
|
410
|
+
model: "deepseek-v4.1-flash-expires-on-0910",
|
|
411
|
+
...FLASH_REPRICED_RATES
|
|
314
412
|
},
|
|
315
413
|
{
|
|
316
414
|
model: "deepseek-v4-pro",
|
|
@@ -325,18 +423,17 @@ const DEFAULT_MODEL_PRICING = [
|
|
|
325
423
|
output: 13.5
|
|
326
424
|
}
|
|
327
425
|
},
|
|
426
|
+
{
|
|
427
|
+
model: "deepseek-v4-pro",
|
|
428
|
+
...V4_PRO_SWITCHED_RATES
|
|
429
|
+
},
|
|
328
430
|
{
|
|
329
431
|
model: "deepseek-v4-flash-vision-exp",
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
offPeak: {
|
|
336
|
-
cacheHitInput: .05,
|
|
337
|
-
cacheMissInput: 1.5,
|
|
338
|
-
output: 4.5
|
|
339
|
-
}
|
|
432
|
+
...FLASH_BASE_RATES
|
|
433
|
+
},
|
|
434
|
+
{
|
|
435
|
+
model: "deepseek-v4-flash-vision-exp",
|
|
436
|
+
...FLASH_REPRICED_RATES
|
|
340
437
|
},
|
|
341
438
|
{
|
|
342
439
|
model: "mimo-v2.5-pro",
|
|
@@ -371,39 +468,123 @@ const DEFAULT_MODEL_PRICING = [
|
|
|
371
468
|
* `z.array` as `[]` rather than `undefined`, so emptiness — not just absence —
|
|
372
469
|
* selects the defaults. Explicit non-empty rows override the same model; a
|
|
373
470
|
* supplied non-empty `models` list is authoritative.
|
|
471
|
+
*
|
|
472
|
+
* Rows sharing a model are that model's rate revisions, kept in ascending
|
|
473
|
+
* `effectiveFrom` order (an undated base revision first). Two rows declaring
|
|
474
|
+
* the same effective instant are one revision and the later row wins — the
|
|
475
|
+
* historical override rule — so re-declaring a model can neither duplicate a
|
|
476
|
+
* revision nor install a second undated base.
|
|
374
477
|
* @param config - optional raw billing configuration.
|
|
375
|
-
* @returns the resolved table and peak-hour windows.
|
|
478
|
+
* @returns the resolved table (per model: its revisions plus the newest rates) and peak-hour windows.
|
|
376
479
|
*/
|
|
377
480
|
function resolveBilling(config) {
|
|
378
481
|
const peakHours = config?.peakHours !== void 0 && config.peakHours.length > 0 ? config.peakHours : DEFAULT_PEAK_HOURS;
|
|
379
482
|
const rows = config?.models !== void 0 && config.models.length > 0 ? config.models : DEFAULT_MODEL_PRICING;
|
|
483
|
+
const schedules = /* @__PURE__ */ new Map();
|
|
484
|
+
for (const row of rows) {
|
|
485
|
+
const revision = row.effectiveFrom === void 0 ? {
|
|
486
|
+
peak: row.peak,
|
|
487
|
+
offPeak: row.offPeak
|
|
488
|
+
} : {
|
|
489
|
+
effectiveFrom: row.effectiveFrom,
|
|
490
|
+
peak: row.peak,
|
|
491
|
+
offPeak: row.offPeak
|
|
492
|
+
};
|
|
493
|
+
const revisions = schedules.get(row.model);
|
|
494
|
+
if (revisions === void 0) {
|
|
495
|
+
schedules.set(row.model, [revision]);
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
const duplicate = revisions.findIndex((candidate) => candidate.effectiveFrom === revision.effectiveFrom);
|
|
499
|
+
if (duplicate >= 0) revisions[duplicate] = revision;
|
|
500
|
+
else revisions.push(revision);
|
|
501
|
+
}
|
|
380
502
|
const models = /* @__PURE__ */ new Map();
|
|
381
|
-
for (const
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
503
|
+
for (const [model, revisions] of schedules) {
|
|
504
|
+
revisions.sort((left, right) => (left.effectiveFrom ?? Number.NEGATIVE_INFINITY) - (right.effectiveFrom ?? Number.NEGATIVE_INFINITY));
|
|
505
|
+
const newest = revisions[revisions.length - 1];
|
|
506
|
+
models.set(model, {
|
|
507
|
+
peak: newest.peak,
|
|
508
|
+
offPeak: newest.offPeak,
|
|
509
|
+
revisions
|
|
510
|
+
});
|
|
511
|
+
}
|
|
385
512
|
return {
|
|
386
513
|
peakHours,
|
|
387
514
|
models
|
|
388
515
|
};
|
|
389
516
|
}
|
|
390
517
|
/**
|
|
391
|
-
*
|
|
392
|
-
*
|
|
393
|
-
*
|
|
518
|
+
* The rate revision in effect at one instant: the newest revision that took
|
|
519
|
+
* effect at or before it. Revisions are ascending, so the scan stops at the
|
|
520
|
+
* first future one. An instant before the earliest dated revision bills at that
|
|
521
|
+
* earliest revision — a model with only dated rows is never left unpriced.
|
|
522
|
+
*/
|
|
523
|
+
function ratesAt(revisions, time) {
|
|
524
|
+
let chosen = revisions[0];
|
|
525
|
+
for (let index = 1; index < revisions.length; index += 1) {
|
|
526
|
+
const revision = revisions[index];
|
|
527
|
+
if (revision.effectiveFrom === void 0 || revision.effectiveFrom > time) break;
|
|
528
|
+
chosen = revision;
|
|
529
|
+
}
|
|
530
|
+
return chosen;
|
|
531
|
+
}
|
|
532
|
+
/** Beijing is a fixed UTC+8 offset with no DST. */
|
|
533
|
+
const BEIJING_OFFSET_MS = 288e5;
|
|
534
|
+
/** Milliseconds in one day. */
|
|
535
|
+
const DAY_MS = 864e5;
|
|
536
|
+
/** Epoch day of 1970-01-01 in the civil-date algorithm below. */
|
|
537
|
+
const CIVIL_EPOCH_DAY = 719468;
|
|
538
|
+
/** Two-digit zero pad for a calendar field. */
|
|
539
|
+
function pad2(value) {
|
|
540
|
+
return value < 10 ? `0${value}` : String(value);
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* Civil date of an epoch day (Howard Hinnant's days-from-civil inverse):
|
|
544
|
+
* pure integer arithmetic, no `Date` allocation and no ISO-string slicing.
|
|
545
|
+
*/
|
|
546
|
+
function civilDateOf(epochDay) {
|
|
547
|
+
const shifted = epochDay + CIVIL_EPOCH_DAY;
|
|
548
|
+
const era = Math.floor(shifted / 146097);
|
|
549
|
+
const dayOfEra = shifted - era * 146097;
|
|
550
|
+
const yearOfEra = Math.floor((dayOfEra - Math.floor(dayOfEra / 1460) + Math.floor(dayOfEra / 36524) - Math.floor(dayOfEra / 146096)) / 365);
|
|
551
|
+
const year = yearOfEra + era * 400;
|
|
552
|
+
const dayOfYear = dayOfEra - (365 * yearOfEra + Math.floor(yearOfEra / 4) - Math.floor(yearOfEra / 100));
|
|
553
|
+
const monthPrime = Math.floor((5 * dayOfYear + 2) / 153);
|
|
554
|
+
const month = monthPrime + (monthPrime < 10 ? 3 : -9);
|
|
555
|
+
return {
|
|
556
|
+
year: month <= 2 ? year + 1 : year,
|
|
557
|
+
month,
|
|
558
|
+
day: dayOfYear - Math.floor((153 * monthPrime + 2) / 5) + 1
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* Derive the Beijing hour, weekday, and calendar-day key of one timestamp with
|
|
563
|
+
* pure integer arithmetic — every timezone-sensitive read shares this one
|
|
564
|
+
* implementation, so the pieces cannot drift apart. Callers that filter by
|
|
565
|
+
* day and then price the same event reuse the returned view, so each event is
|
|
566
|
+
* parsed exactly once. (The hot fold path runs this per committed event; the
|
|
567
|
+
* previous `Date` + `toISOString().slice()` version allocated a `Date` and a
|
|
568
|
+
* 24-character string per call.)
|
|
394
569
|
* @param time - epoch milliseconds.
|
|
570
|
+
* @throws {RangeError} when `time` is not a finite number.
|
|
395
571
|
*/
|
|
396
|
-
function
|
|
397
|
-
|
|
572
|
+
function beijingPartsOf(time) {
|
|
573
|
+
if (!Number.isFinite(time)) throw new RangeError(`billing: event time is not finite (${String(time)})`);
|
|
574
|
+
const shifted = time + BEIJING_OFFSET_MS;
|
|
575
|
+
const epochDay = Math.floor(shifted / DAY_MS);
|
|
576
|
+
const msOfDay = shifted - epochDay * DAY_MS;
|
|
577
|
+
const civil = civilDateOf(epochDay);
|
|
398
578
|
return {
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
579
|
+
time,
|
|
580
|
+
hour: Math.floor(msOfDay / 36e5),
|
|
581
|
+
weekday: ((epochDay + 4) % 7 + 7) % 7,
|
|
582
|
+
dayKey: `${civil.year}-${pad2(civil.month)}-${pad2(civil.day)}`
|
|
402
583
|
};
|
|
403
584
|
}
|
|
404
585
|
/** The Beijing (Asia/Shanghai, UTC+8, no DST) calendar-day key of a timestamp. */
|
|
405
586
|
function beijingDayKey(now) {
|
|
406
|
-
return
|
|
587
|
+
return beijingPartsOf(now.getTime()).dayKey;
|
|
407
588
|
}
|
|
408
589
|
/**
|
|
409
590
|
* The durable inherited-prefix boundary of one session: the number of leading
|
|
@@ -443,41 +624,70 @@ function isPeakParts(billing, hour, weekday) {
|
|
|
443
624
|
* @returns true during a weekday peak hour.
|
|
444
625
|
*/
|
|
445
626
|
function isPeak(billing, now) {
|
|
446
|
-
const { hour, weekday } =
|
|
627
|
+
const { hour, weekday } = beijingPartsOf(now.getTime());
|
|
447
628
|
return isPeakParts(billing, hour, weekday);
|
|
448
629
|
}
|
|
449
630
|
/**
|
|
450
631
|
* Price one event at the official per-model rates, applying the peak/off-peak
|
|
451
632
|
* table by its Beijing-time hour and weekday (peak windows apply Monday–Friday
|
|
452
|
-
* only; weekends are off-peak)
|
|
633
|
+
* only; weekends are off-peak) and the rate revision in effect at its own
|
|
634
|
+
* timestamp. Each `assistant/message` event with usage
|
|
453
635
|
* contributes cache-hit input, cache-miss input (uncached input plus cache
|
|
454
636
|
* writes), and output (reasoning included) tokens at the rate of its own
|
|
455
|
-
* timestamp; a model with usage but no pricing row contributes nothing
|
|
456
|
-
* published table prices only the two V4 rows).
|
|
637
|
+
* timestamp; a model with usage but no pricing row contributes nothing.
|
|
457
638
|
* @param event - the event to price.
|
|
458
639
|
* @param billing - resolved pricing with peak-hour windows.
|
|
459
640
|
* @param names - model id → display label.
|
|
460
641
|
* @returns the priced contribution, or `undefined` when the event has no priced usage.
|
|
461
642
|
*/
|
|
462
643
|
function priceEvent(event, billing, names) {
|
|
644
|
+
return priceEventAt(beijingPartsOf(event.time), event, billing, names);
|
|
645
|
+
}
|
|
646
|
+
/**
|
|
647
|
+
* Price one event at the official per-model rates using a precomputed
|
|
648
|
+
* Beijing-time view — the day-filtering and pricing of one event share a
|
|
649
|
+
* single timezone parse (see {@link beijingPartsOf}). Semantics are identical
|
|
650
|
+
* to {@link priceEvent}.
|
|
651
|
+
* @param parts - the event's Beijing-time view.
|
|
652
|
+
* @param event - the event to price.
|
|
653
|
+
* @param billing - resolved pricing with peak-hour windows.
|
|
654
|
+
* @param names - model id → display label.
|
|
655
|
+
* @returns the priced contribution, or `undefined` when the event has no priced usage.
|
|
656
|
+
*/
|
|
657
|
+
function priceEventAt(parts, event, billing, names) {
|
|
463
658
|
if (event.type !== "assistant/message") return void 0;
|
|
464
659
|
const reported = event.data.usage;
|
|
465
660
|
if (reported === void 0) return void 0;
|
|
466
|
-
|
|
661
|
+
return priceUsage(parts, reported, event.data.message.source.model, billing, names);
|
|
662
|
+
}
|
|
663
|
+
/**
|
|
664
|
+
* Price one provider-reported usage sample for one model at the rates of the
|
|
665
|
+
* sample's own Beijing-time hour and weekday — the peak or off-peak price of
|
|
666
|
+
* the rate revision in effect at the sample's own timestamp (a re-priced series
|
|
667
|
+
* bills its history at the rates that applied then). `undefined` when the model
|
|
668
|
+
* has no pricing row.
|
|
669
|
+
* @param parts - the sample's Beijing-time view.
|
|
670
|
+
* @param usage - the reported token buckets.
|
|
671
|
+
* @param model - the wire model id the sample belongs to.
|
|
672
|
+
* @param billing - resolved pricing with peak-hour windows.
|
|
673
|
+
* @param names - model id → display label.
|
|
674
|
+
* @returns the priced contribution, or `undefined` when the model has no rate row.
|
|
675
|
+
*/
|
|
676
|
+
function priceUsage(parts, usage, model, billing, names) {
|
|
467
677
|
const pricing = billing.models.get(model);
|
|
468
678
|
if (pricing === void 0) return void 0;
|
|
469
|
-
const
|
|
470
|
-
const
|
|
471
|
-
const price = peak ?
|
|
472
|
-
const hit =
|
|
473
|
-
const miss =
|
|
474
|
-
const output =
|
|
679
|
+
const peak = isPeakParts(billing, parts.hour, parts.weekday);
|
|
680
|
+
const revision = ratesAt(pricing.revisions, parts.time);
|
|
681
|
+
const price = peak ? revision.peak : revision.offPeak;
|
|
682
|
+
const hit = usage.cacheReadTokens ?? 0;
|
|
683
|
+
const miss = usage.inputTokens + (usage.cacheWriteTokens ?? 0);
|
|
684
|
+
const output = usage.outputTokens;
|
|
475
685
|
const hitCost = hit * price.cacheHitInput / 1e6;
|
|
476
686
|
const missCost = miss * price.cacheMissInput / 1e6;
|
|
477
687
|
const outputCost = output * price.output / 1e6;
|
|
478
688
|
const cost = hitCost + missCost + outputCost;
|
|
479
689
|
return {
|
|
480
|
-
dayKey,
|
|
690
|
+
dayKey: parts.dayKey,
|
|
481
691
|
model,
|
|
482
692
|
displayName: names.get(model) ?? model,
|
|
483
693
|
cost,
|
|
@@ -556,6 +766,192 @@ var SpendAccumulator = class {
|
|
|
556
766
|
};
|
|
557
767
|
}
|
|
558
768
|
};
|
|
769
|
+
/** The additive inverse of one spend (pure): used to replace a priced sample. */
|
|
770
|
+
function negateSpend(spend) {
|
|
771
|
+
const negate = (value) => -value;
|
|
772
|
+
return {
|
|
773
|
+
total: negate(spend.total),
|
|
774
|
+
models: spend.models.map((row) => ({
|
|
775
|
+
...row,
|
|
776
|
+
cost: negate(row.cost),
|
|
777
|
+
peakCost: negate(row.peakCost),
|
|
778
|
+
offPeakCost: negate(row.offPeakCost),
|
|
779
|
+
cacheHitInputTokens: negate(row.cacheHitInputTokens),
|
|
780
|
+
cacheMissInputTokens: negate(row.cacheMissInputTokens),
|
|
781
|
+
outputTokens: negate(row.outputTokens),
|
|
782
|
+
cacheHitInputCost: negate(row.cacheHitInputCost),
|
|
783
|
+
cacheMissInputCost: negate(row.cacheMissInputCost),
|
|
784
|
+
outputCost: negate(row.outputCost)
|
|
785
|
+
}))
|
|
786
|
+
};
|
|
787
|
+
}
|
|
788
|
+
/**
|
|
789
|
+
* Subtract one spend from another (pure). Rows that cancel out completely are
|
|
790
|
+
* dropped so a replaced sample leaves no zero row behind.
|
|
791
|
+
* @param target - the spend to subtract from.
|
|
792
|
+
* @param source - the spend to remove.
|
|
793
|
+
* @returns the difference.
|
|
794
|
+
*/
|
|
795
|
+
function subtractSpend(target, source) {
|
|
796
|
+
const rows = /* @__PURE__ */ new Map();
|
|
797
|
+
for (const row of target.models) rows.set(row.model, row);
|
|
798
|
+
for (const row of source.models) {
|
|
799
|
+
const existing = rows.get(row.model);
|
|
800
|
+
if (existing === void 0) continue;
|
|
801
|
+
const next = mergeModelRows(existing, negateSpend({
|
|
802
|
+
total: 0,
|
|
803
|
+
models: [row]
|
|
804
|
+
}).models[0]);
|
|
805
|
+
if (next.cost === 0 && next.cacheHitInputTokens === 0 && next.cacheMissInputTokens === 0 && next.outputTokens === 0) rows.delete(row.model);
|
|
806
|
+
else rows.set(row.model, next);
|
|
807
|
+
}
|
|
808
|
+
return {
|
|
809
|
+
total: target.total - source.total,
|
|
810
|
+
models: [...rows.values()]
|
|
811
|
+
};
|
|
812
|
+
}
|
|
813
|
+
/** The empty fold state for one fork boundary. */
|
|
814
|
+
function emptyBillingFoldState(inheritedEventCount = 0) {
|
|
815
|
+
return {
|
|
816
|
+
dayKey: "",
|
|
817
|
+
spend: emptyTodaySpend(),
|
|
818
|
+
session: emptyTodaySpend(),
|
|
819
|
+
inheritedEventCount,
|
|
820
|
+
model: "",
|
|
821
|
+
last: null
|
|
822
|
+
};
|
|
823
|
+
}
|
|
824
|
+
/** Whether an unknown value looks like a provider usage report. */
|
|
825
|
+
function isTokenUsage(value) {
|
|
826
|
+
if (typeof value !== "object" || value === null) return false;
|
|
827
|
+
const candidate = value;
|
|
828
|
+
return typeof candidate.inputTokens === "number" && typeof candidate.outputTokens === "number";
|
|
829
|
+
}
|
|
830
|
+
/**
|
|
831
|
+
* The last `usage` sample embedded in an event's stream, if any. `assistant/
|
|
832
|
+
* attempt` and the embedded streams are newer than the plugin's npm baseline,
|
|
833
|
+
* so the stream is read structurally (a failed/retried attempt reports its
|
|
834
|
+
* usage only there).
|
|
835
|
+
*/
|
|
836
|
+
function streamUsageOf(event) {
|
|
837
|
+
const stream = event.data === void 0 ? void 0 : event.data.stream;
|
|
838
|
+
if (!Array.isArray(stream)) return void 0;
|
|
839
|
+
for (let index = stream.length - 1; index >= 0; index -= 1) {
|
|
840
|
+
const chunk = stream[index]?.chunk;
|
|
841
|
+
if (chunk === void 0 || chunk.type !== "usage") continue;
|
|
842
|
+
return isTokenUsage(chunk.usage) ? chunk.usage : void 0;
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
/** The contribution as a one-row spend (the shape a sample keeps for replacement). */
|
|
846
|
+
function contributionSpend(priced) {
|
|
847
|
+
return {
|
|
848
|
+
total: priced.cost,
|
|
849
|
+
models: [contributionModel(priced)]
|
|
850
|
+
};
|
|
851
|
+
}
|
|
852
|
+
/**
|
|
853
|
+
* Fold one committed event into a session's billed-spend state.
|
|
854
|
+
*
|
|
855
|
+
* Priced samples come from `assistant/message` (its own reported usage, or the
|
|
856
|
+
* stream's last usage chunk) and `assistant/attempt` (the stream's last usage
|
|
857
|
+
* chunk, priced with the model of the latest `request/header`, since an
|
|
858
|
+
* attempt carries no route). A sample for the same `(turn, step)` replaces the
|
|
859
|
+
* previous one; `llm/retry-started` closes the replacement slot so a retried
|
|
860
|
+
* attempt adds. Every other event is inert and returns the same state
|
|
861
|
+
* reference.
|
|
862
|
+
* @param state - the previous fold state.
|
|
863
|
+
* @param event - the committed event.
|
|
864
|
+
* @param billing - resolved pricing with peak-hour windows.
|
|
865
|
+
* @param names - model id → display label.
|
|
866
|
+
* @returns the next state (the same reference when nothing was priced).
|
|
867
|
+
*/
|
|
868
|
+
function applyBillingEvent(state, event, billing, names) {
|
|
869
|
+
if (event.seq < state.inheritedEventCount) return state;
|
|
870
|
+
const type = event.type;
|
|
871
|
+
if (type === "request/header") {
|
|
872
|
+
const model = event.data?.header?.config?.model;
|
|
873
|
+
return typeof model === "string" && model.length > 0 && model !== state.model ? {
|
|
874
|
+
...state,
|
|
875
|
+
model
|
|
876
|
+
} : state;
|
|
877
|
+
}
|
|
878
|
+
const data = event.data;
|
|
879
|
+
if (type === "llm/retry-started") {
|
|
880
|
+
if (typeof data?.turn !== "number" || typeof data.step !== "number") return state;
|
|
881
|
+
const last = state.last;
|
|
882
|
+
if (last === null || last.turn !== data.turn || last.step !== data.step) return state;
|
|
883
|
+
return {
|
|
884
|
+
...state,
|
|
885
|
+
last: null
|
|
886
|
+
};
|
|
887
|
+
}
|
|
888
|
+
if (type !== "assistant/message" && type !== "assistant/attempt") return state;
|
|
889
|
+
const usage = (type === "assistant/message" ? data?.usage : void 0) ?? streamUsageOf(event);
|
|
890
|
+
if (!isTokenUsage(usage)) return state;
|
|
891
|
+
const model = type === "assistant/message" ? data?.message?.source?.model : state.model;
|
|
892
|
+
if (typeof model !== "string" || model.length === 0) return state;
|
|
893
|
+
const priced = priceUsage(beijingPartsOf(event.time), usage, model, billing, names);
|
|
894
|
+
if (priced === void 0) return state;
|
|
895
|
+
let session = state.session;
|
|
896
|
+
let spend = state.spend;
|
|
897
|
+
let dayKey = state.dayKey;
|
|
898
|
+
const last = state.last;
|
|
899
|
+
const turn = typeof data?.turn === "number" ? data.turn : 0;
|
|
900
|
+
const step = typeof data?.step === "number" ? data.step : 0;
|
|
901
|
+
if (last !== null && last.turn === turn && last.step === step) {
|
|
902
|
+
session = subtractSpend(session, last.spend);
|
|
903
|
+
if (last.dayKey === dayKey) spend = subtractSpend(spend, last.spend);
|
|
904
|
+
}
|
|
905
|
+
session = addEventContribution(session, priced);
|
|
906
|
+
if (dayKey === priced.dayKey) spend = addEventContribution(spend, priced);
|
|
907
|
+
else if (dayKey === "" || priced.dayKey > dayKey) {
|
|
908
|
+
dayKey = priced.dayKey;
|
|
909
|
+
spend = addEventContribution(emptyTodaySpend(), priced);
|
|
910
|
+
}
|
|
911
|
+
return {
|
|
912
|
+
...state,
|
|
913
|
+
dayKey,
|
|
914
|
+
spend,
|
|
915
|
+
session,
|
|
916
|
+
last: {
|
|
917
|
+
turn,
|
|
918
|
+
step,
|
|
919
|
+
dayKey: priced.dayKey,
|
|
920
|
+
spend: contributionSpend(priced)
|
|
921
|
+
}
|
|
922
|
+
};
|
|
923
|
+
}
|
|
924
|
+
/**
|
|
925
|
+
* Mutable wrapper over {@link applyBillingEvent} for the pure pricing paths:
|
|
926
|
+
* feed events in order, read the folded spend.
|
|
927
|
+
*/
|
|
928
|
+
var BillingFolder = class {
|
|
929
|
+
billing;
|
|
930
|
+
state;
|
|
931
|
+
/**
|
|
932
|
+
* @param billing - resolved pricing with peak-hour windows.
|
|
933
|
+
* @param catalog - model display rows, in presentation order.
|
|
934
|
+
* @param inheritedEventCount - fork boundary to skip (default 0).
|
|
935
|
+
*/
|
|
936
|
+
constructor(billing, catalog, inheritedEventCount = 0) {
|
|
937
|
+
this.billing = billing;
|
|
938
|
+
this.names = new Map(catalog.map((model) => [model.id, model.name]));
|
|
939
|
+
this.state = emptyBillingFoldState(inheritedEventCount);
|
|
940
|
+
}
|
|
941
|
+
names;
|
|
942
|
+
/** Fold one event. */
|
|
943
|
+
add(event) {
|
|
944
|
+
this.state = applyBillingEvent(this.state, event, this.billing, this.names);
|
|
945
|
+
}
|
|
946
|
+
/** Fold every event, in order. */
|
|
947
|
+
addAll(events) {
|
|
948
|
+
for (const event of events) this.add(event);
|
|
949
|
+
}
|
|
950
|
+
/** The folded state (live reference; do not mutate). */
|
|
951
|
+
get fold() {
|
|
952
|
+
return this.state;
|
|
953
|
+
}
|
|
954
|
+
};
|
|
559
955
|
/**
|
|
560
956
|
* Merge one priced event's contribution into an accumulator spend (pure:
|
|
561
957
|
* returns a new spend, never mutates its input).
|
|
@@ -592,34 +988,11 @@ function mergeTodaySpend(target, source) {
|
|
|
592
988
|
};
|
|
593
989
|
}
|
|
594
990
|
/**
|
|
595
|
-
* Price
|
|
596
|
-
*
|
|
597
|
-
*
|
|
598
|
-
*
|
|
599
|
-
*
|
|
600
|
-
* tokens at the rate of its own timestamp, with the three component costs
|
|
601
|
-
* carried separately; a model with usage but no pricing row is omitted (the
|
|
602
|
-
* published table prices only the two V4 rows).
|
|
603
|
-
* @param events - the events to price.
|
|
604
|
-
* @param billing - resolved pricing with peak-hour windows.
|
|
605
|
-
* @param names - model id → display label.
|
|
606
|
-
* @param dayKey - when provided, only events on this Beijing calendar day contribute.
|
|
607
|
-
* @param startSeq - when provided, only events with `seq >= startSeq` contribute
|
|
608
|
-
* (a forked session's inherited prefix, `seq < startSeq`, is skipped).
|
|
609
|
-
* @returns the total cost plus one row per priced model.
|
|
610
|
-
*/
|
|
611
|
-
function priceEvents(events, billing, names, dayKey, startSeq = 0) {
|
|
612
|
-
const accumulator = new SpendAccumulator();
|
|
613
|
-
for (const event of events) {
|
|
614
|
-
if (event.seq < startSeq) continue;
|
|
615
|
-
const priced = priceEvent(event, billing, names);
|
|
616
|
-
if (priced === void 0 || dayKey !== void 0 && priced.dayKey !== dayKey) continue;
|
|
617
|
-
accumulator.add(priced);
|
|
618
|
-
}
|
|
619
|
-
return accumulator.finish();
|
|
620
|
-
}
|
|
621
|
-
/**
|
|
622
|
-
* Price one session's complete event log at the official per-model rates.
|
|
991
|
+
* Price one session's complete event log at the official per-model rates,
|
|
992
|
+
* with DSH's attempt semantics: every provider-reported sample (an
|
|
993
|
+
* `assistant/message`'s usage, or an `assistant/attempt`'s stream usage)
|
|
994
|
+
* contributes, a later sample for the same `(turn, step)` replaces the earlier
|
|
995
|
+
* one, and `llm/retry-started` makes the retried attempt add.
|
|
623
996
|
* @param events - one session's complete event log.
|
|
624
997
|
* @param billing - resolved pricing with peak-hour windows.
|
|
625
998
|
* @param catalog - model display rows, in presentation order.
|
|
@@ -630,16 +1003,18 @@ function priceEvents(events, billing, names, dayKey, startSeq = 0) {
|
|
|
630
1003
|
* @returns the session's total cost plus one row per priced model.
|
|
631
1004
|
*/
|
|
632
1005
|
function computeSessionSpend(events, billing, catalog, startSeq = 0) {
|
|
633
|
-
|
|
1006
|
+
const folder = new BillingFolder(billing, catalog, startSeq);
|
|
1007
|
+
folder.addAll(events);
|
|
1008
|
+
return folder.fold.session;
|
|
634
1009
|
}
|
|
635
1010
|
/**
|
|
636
|
-
* Price one completed Turn's billed usage
|
|
637
|
-
*
|
|
638
|
-
*
|
|
639
|
-
*
|
|
640
|
-
*
|
|
641
|
-
*
|
|
642
|
-
*
|
|
1011
|
+
* Price one completed Turn's billed usage, identified by its closing
|
|
1012
|
+
* assistant message id. The turn's events are those between its `turn/start`
|
|
1013
|
+
* and `turn/end` (both matched by the message's own turn coordinate), priced
|
|
1014
|
+
* with the same attempt semantics as {@link computeSessionSpend}. A message
|
|
1015
|
+
* that cannot be located, a turn without bracketing `turn/start` / `turn/end`
|
|
1016
|
+
* events (for example after compaction), or a session with no priced usage
|
|
1017
|
+
* prices to zero.
|
|
643
1018
|
* @param events - one session's complete event log.
|
|
644
1019
|
* @param billing - resolved pricing with peak-hour windows.
|
|
645
1020
|
* @param catalog - model display rows, in presentation order.
|
|
@@ -647,7 +1022,18 @@ function computeSessionSpend(events, billing, catalog, startSeq = 0) {
|
|
|
647
1022
|
* @returns the turn's total cost in CNY.
|
|
648
1023
|
*/
|
|
649
1024
|
function computeTurnSpend(events, billing, catalog, messageId) {
|
|
650
|
-
|
|
1025
|
+
return { total: turnCostOf(events, billing, catalog, messageId) };
|
|
1026
|
+
}
|
|
1027
|
+
/**
|
|
1028
|
+
* The total cost of the Turn containing `messageId`, folded with the shared
|
|
1029
|
+
* attempt semantics (see {@link applyBillingEvent}).
|
|
1030
|
+
* @param events - one session's complete event log.
|
|
1031
|
+
* @param billing - resolved pricing with peak-hour windows.
|
|
1032
|
+
* @param catalog - model display rows, in presentation order.
|
|
1033
|
+
* @param messageId - one assistant message inside the Turn.
|
|
1034
|
+
* @returns the Turn's total cost in CNY, or 0 when the Turn cannot be located.
|
|
1035
|
+
*/
|
|
1036
|
+
function turnCostOf(events, billing, catalog, messageId) {
|
|
651
1037
|
let turn;
|
|
652
1038
|
for (const event of events) {
|
|
653
1039
|
if (event.type !== "assistant/message") continue;
|
|
@@ -655,8 +1041,8 @@ function computeTurnSpend(events, billing, catalog, messageId) {
|
|
|
655
1041
|
turn = event.data.turn;
|
|
656
1042
|
break;
|
|
657
1043
|
}
|
|
658
|
-
if (turn === void 0) return
|
|
659
|
-
const
|
|
1044
|
+
if (turn === void 0) return 0;
|
|
1045
|
+
const folder = new BillingFolder(billing, catalog);
|
|
660
1046
|
let active = false;
|
|
661
1047
|
for (const event of events) {
|
|
662
1048
|
if (event.type === "turn/start" && event.data.turn === turn) {
|
|
@@ -665,16 +1051,116 @@ function computeTurnSpend(events, billing, catalog, messageId) {
|
|
|
665
1051
|
}
|
|
666
1052
|
if (event.type === "turn/end" && event.data.turn === turn) break;
|
|
667
1053
|
if (!active) continue;
|
|
668
|
-
|
|
669
|
-
if (priced !== void 0) accumulator.add(priced);
|
|
1054
|
+
folder.add(event);
|
|
670
1055
|
}
|
|
671
|
-
return
|
|
1056
|
+
return folder.fold.session.total;
|
|
1057
|
+
}
|
|
1058
|
+
/**
|
|
1059
|
+
* Incremental single-pass fold of one session's completed-Turn costs, keyed by
|
|
1060
|
+
* the id of every assistant message inside each Turn. Feeding the fold only
|
|
1061
|
+
* the appended tail keeps a growing session's map current in O(new events)
|
|
1062
|
+
* instead of re-scanning the whole log per message.
|
|
1063
|
+
*
|
|
1064
|
+
* Semantics are exactly {@link computeTurnSpend}'s: a Turn is the
|
|
1065
|
+
* `turn/start`..`turn/end` range (matched by the event's own turn coordinate),
|
|
1066
|
+
* every priced event inside it contributes at its own timestamp's rate, and a
|
|
1067
|
+
* message outside any bracket contributes nothing.
|
|
1068
|
+
*/
|
|
1069
|
+
var SessionTurnSpendFolder = class {
|
|
1070
|
+
billing;
|
|
1071
|
+
catalog;
|
|
1072
|
+
rows = [];
|
|
1073
|
+
ids = [];
|
|
1074
|
+
/** Events of the open Turn, folded with the shared attempt semantics on close. */
|
|
1075
|
+
events = [];
|
|
1076
|
+
open = false;
|
|
1077
|
+
/** Events already fed; a shorter log resets the fold. */
|
|
1078
|
+
cursor = 0;
|
|
1079
|
+
/**
|
|
1080
|
+
* @param billing - resolved pricing with peak-hour windows.
|
|
1081
|
+
* @param catalog - model display rows, in presentation order.
|
|
1082
|
+
*/
|
|
1083
|
+
constructor(billing, catalog) {
|
|
1084
|
+
this.billing = billing;
|
|
1085
|
+
this.catalog = catalog;
|
|
1086
|
+
}
|
|
1087
|
+
/** How many events have been folded so far (the host's incremental cursor). */
|
|
1088
|
+
get processed() {
|
|
1089
|
+
return this.cursor;
|
|
1090
|
+
}
|
|
1091
|
+
/**
|
|
1092
|
+
* Fold every event from the cursor to the end of the log. A log shorter than
|
|
1093
|
+
* the cursor (rewritten session) restarts the fold from an empty state.
|
|
1094
|
+
* @param events - the session's complete event log, in seq order.
|
|
1095
|
+
*/
|
|
1096
|
+
feed(events) {
|
|
1097
|
+
if (events.length < this.cursor) this.reset();
|
|
1098
|
+
for (let index = this.cursor; index < events.length; index += 1) {
|
|
1099
|
+
const event = events[index];
|
|
1100
|
+
if (event.type === "turn/start") {
|
|
1101
|
+
this.open = true;
|
|
1102
|
+
this.ids = [];
|
|
1103
|
+
this.events = [];
|
|
1104
|
+
continue;
|
|
1105
|
+
}
|
|
1106
|
+
if (event.type === "turn/end") {
|
|
1107
|
+
if (this.open) {
|
|
1108
|
+
const folder = new BillingFolder(this.billing, this.catalog);
|
|
1109
|
+
folder.addAll(this.events);
|
|
1110
|
+
const total = folder.fold.session.total;
|
|
1111
|
+
for (const messageId of this.ids) this.rows.push({
|
|
1112
|
+
messageId,
|
|
1113
|
+
total
|
|
1114
|
+
});
|
|
1115
|
+
}
|
|
1116
|
+
this.open = false;
|
|
1117
|
+
this.ids = [];
|
|
1118
|
+
this.events = [];
|
|
1119
|
+
continue;
|
|
1120
|
+
}
|
|
1121
|
+
if (!this.open) continue;
|
|
1122
|
+
if (event.type === "assistant/message") this.ids.push(event.data.message.id);
|
|
1123
|
+
this.events.push(event);
|
|
1124
|
+
}
|
|
1125
|
+
this.cursor = events.length;
|
|
1126
|
+
}
|
|
1127
|
+
/** The folded map; the fold stays usable afterwards. */
|
|
1128
|
+
finish() {
|
|
1129
|
+
return { turns: [...this.rows] };
|
|
1130
|
+
}
|
|
1131
|
+
/** Drop the fold state so the next feed starts from the log's beginning. */
|
|
1132
|
+
reset() {
|
|
1133
|
+
this.rows.length = 0;
|
|
1134
|
+
this.ids = [];
|
|
1135
|
+
this.events = [];
|
|
1136
|
+
this.open = false;
|
|
1137
|
+
this.cursor = 0;
|
|
1138
|
+
}
|
|
1139
|
+
};
|
|
1140
|
+
/**
|
|
1141
|
+
* Price every completed Turn of one session in a single pass (the pure
|
|
1142
|
+
* equivalent of {@link SessionTurnSpendFolder}).
|
|
1143
|
+
* @param events - one session's complete event log.
|
|
1144
|
+
* @param billing - resolved pricing with peak-hour windows.
|
|
1145
|
+
* @param catalog - model display rows, in presentation order.
|
|
1146
|
+
* @returns one row per assistant message inside a completed Turn, in log order.
|
|
1147
|
+
*/
|
|
1148
|
+
function computeSessionTurnSpends(events, billing, catalog) {
|
|
1149
|
+
const folder = new SessionTurnSpendFolder(billing, catalog);
|
|
1150
|
+
folder.feed(events);
|
|
1151
|
+
return folder.finish();
|
|
672
1152
|
}
|
|
673
1153
|
/**
|
|
674
|
-
* Price
|
|
675
|
-
*
|
|
676
|
-
*
|
|
677
|
-
*
|
|
1154
|
+
* Price one session's log for the Beijing-time calendar day of `now`. Events
|
|
1155
|
+
* after the reference day are ignored; the fold's latest-day state then
|
|
1156
|
+
* answers the query exactly (empty when the session's latest priced day is not
|
|
1157
|
+
* the reference day). Pricing follows {@link applyBillingEvent} (attempt
|
|
1158
|
+
* samples with same-step replacement).
|
|
1159
|
+
*
|
|
1160
|
+
* The fold's `(turn, step)` replacement slot is per session, so callers must
|
|
1161
|
+
* pass ONE session's log; aggregate across sessions with
|
|
1162
|
+
* {@link mergeTodaySpend}.
|
|
1163
|
+
* @param events - one session's complete event log.
|
|
678
1164
|
* @param billing - resolved pricing with peak-hour windows.
|
|
679
1165
|
* @param catalog - model display rows, in presentation order.
|
|
680
1166
|
* @param now - the reference moment whose Beijing-time calendar day is "today".
|
|
@@ -682,24 +1168,31 @@ function computeTurnSpend(events, billing, catalog, messageId) {
|
|
|
682
1168
|
*/
|
|
683
1169
|
function computeTodaySpend(events, billing, catalog, now = /* @__PURE__ */ new Date()) {
|
|
684
1170
|
const day = beijingDayKey(now);
|
|
685
|
-
|
|
1171
|
+
const folder = new BillingFolder(billing, catalog);
|
|
1172
|
+
for (const event of events) {
|
|
1173
|
+
if (beijingPartsOf(event.time).dayKey > day) continue;
|
|
1174
|
+
folder.add(event);
|
|
1175
|
+
}
|
|
1176
|
+
return folder.fold.dayKey === day ? folder.fold.spend : emptyTodaySpend();
|
|
686
1177
|
}
|
|
687
1178
|
//#endregion
|
|
688
1179
|
//#region lib/types/projection.js
|
|
689
1180
|
/**
|
|
690
|
-
* `billingTodaySpend` session-projection unit: per-session,
|
|
691
|
-
*
|
|
692
|
-
*
|
|
693
|
-
*
|
|
694
|
-
*
|
|
695
|
-
* the aggregate "today" read sums the units whose `dayKey`
|
|
696
|
-
* Beijing day — zero full-log scans once the fold is warm.
|
|
1181
|
+
* `billingTodaySpend` session-projection unit: per-session billed spend,
|
|
1182
|
+
* folded eagerly by the DSH projection drive over committed session events and
|
|
1183
|
+
* checkpointed by the projection cache. The state keeps the session's LATEST
|
|
1184
|
+
* priced Beijing day, its whole-session total, the fork boundary, the latest
|
|
1185
|
+
* request model, and the last priced attempt sample (DSH's same-step
|
|
1186
|
+
* replacement rule); the aggregate "today" read sums the units whose `dayKey`
|
|
1187
|
+
* matches the current Beijing day — zero full-log scans once the fold is warm.
|
|
697
1188
|
*
|
|
698
|
-
* The unit's fold
|
|
699
|
-
*
|
|
1189
|
+
* The unit's fold IS the shared pricing fold ({@link applyBillingEvent}), so
|
|
1190
|
+
* the projection path and the events-scan paths cannot drift. The unit is
|
|
700
1191
|
* client-visible (`wire` = identity) because the persisted-cache read ladder
|
|
701
|
-
* (`sessionProjectionCache.
|
|
702
|
-
* wired units
|
|
1192
|
+
* (`sessionProjectionCache.cachedSnapshot` / registry `restore`) serves only
|
|
1193
|
+
* wired units, and because the browser half reads this value through
|
|
1194
|
+
* `useProjection` instead of polling a Remote; the wire value is the state
|
|
1195
|
+
* itself.
|
|
703
1196
|
* @module @rayadesu/dsh-llm-billing/projection
|
|
704
1197
|
*/
|
|
705
1198
|
/** The projection key this unit owns. */
|
|
@@ -723,16 +1216,27 @@ const todaySpendSchema = z$1.object({
|
|
|
723
1216
|
}).strict();
|
|
724
1217
|
const billingUnitSchema = z$1.object({
|
|
725
1218
|
dayKey: z$1.string(),
|
|
726
|
-
spend: todaySpendSchema
|
|
1219
|
+
spend: todaySpendSchema,
|
|
1220
|
+
session: todaySpendSchema,
|
|
1221
|
+
inheritedEventCount: z$1.number().int().nonnegative(),
|
|
1222
|
+
model: z$1.string(),
|
|
1223
|
+
last: z$1.object({
|
|
1224
|
+
turn: z$1.number().int().nonnegative(),
|
|
1225
|
+
step: z$1.number().int().nonnegative(),
|
|
1226
|
+
dayKey: z$1.string(),
|
|
1227
|
+
spend: todaySpendSchema
|
|
1228
|
+
}).strict().nullable()
|
|
727
1229
|
}).strict();
|
|
728
1230
|
/**
|
|
729
|
-
* Build the `billingTodaySpend` unit for one resolved pricing table.
|
|
730
|
-
*
|
|
731
|
-
*
|
|
732
|
-
*
|
|
733
|
-
*
|
|
734
|
-
*
|
|
735
|
-
*
|
|
1231
|
+
* Build the `billingTodaySpend` unit for one resolved pricing table. Published
|
|
1232
|
+
* rate revisions travel inside the closure and are resolved per sample
|
|
1233
|
+
* timestamp, so a re-priced series bills its own history correctly however late
|
|
1234
|
+
* a log is folded; only a configuration change (editing `billing.models`) is
|
|
1235
|
+
* fixed at registration, and it re-prices just the events folded afterwards
|
|
1236
|
+
* (the events-scan paths re-price the whole log). Bump
|
|
1237
|
+
* {@link ProjectionDefinition.stateVersion} whenever the state shape or fold
|
|
1238
|
+
* semantics change, so persisted checkpoint rows are discarded instead of
|
|
1239
|
+
* folded forward.
|
|
736
1240
|
* @param billing - resolved pricing with peak-hour windows.
|
|
737
1241
|
* @param catalog - model display rows, in presentation order.
|
|
738
1242
|
* @returns the unit definition to register on `ctx.sessionProjections`.
|
|
@@ -741,25 +1245,10 @@ function billingTodaySpendDefinition(billing, catalog) {
|
|
|
741
1245
|
const names = new Map(catalog.map((model) => [model.id, model.name]));
|
|
742
1246
|
return {
|
|
743
1247
|
key: BILLING_UNIT_KEY,
|
|
744
|
-
stateVersion:
|
|
1248
|
+
stateVersion: 4,
|
|
745
1249
|
stateSchema: billingUnitSchema,
|
|
746
|
-
init: () => (
|
|
747
|
-
|
|
748
|
-
spend: emptyTodaySpend()
|
|
749
|
-
}),
|
|
750
|
-
apply: (state, event) => {
|
|
751
|
-
const priced = priceEvent(event, billing, names);
|
|
752
|
-
if (priced === void 0) return state;
|
|
753
|
-
if (state.dayKey === priced.dayKey) return {
|
|
754
|
-
dayKey: state.dayKey,
|
|
755
|
-
spend: addEventContribution(state.spend, priced)
|
|
756
|
-
};
|
|
757
|
-
if (state.dayKey !== "" && priced.dayKey < state.dayKey) return state;
|
|
758
|
-
return {
|
|
759
|
-
dayKey: priced.dayKey,
|
|
760
|
-
spend: addEventContribution(emptyTodaySpend(), priced)
|
|
761
|
-
};
|
|
762
|
-
},
|
|
1250
|
+
init: (_header, inheritedEventCount) => emptyBillingFoldState(Number(inheritedEventCount ?? 0)),
|
|
1251
|
+
apply: (state, event) => applyBillingEvent(state, event, billing, names),
|
|
763
1252
|
wire: {
|
|
764
1253
|
viewSchema: billingUnitSchema,
|
|
765
1254
|
view: (state) => state
|
|
@@ -800,11 +1289,13 @@ function foldOwnBilling(unit, events, seedLength = 0) {
|
|
|
800
1289
|
* compute the aggregate behind a cache miss:
|
|
801
1290
|
*
|
|
802
1291
|
* - projection path (plan C): live sessions read their eagerly folded
|
|
803
|
-
* `billingTodaySpend` projection cell; cold sessions
|
|
804
|
-
* projection-cache
|
|
805
|
-
*
|
|
806
|
-
*
|
|
807
|
-
*
|
|
1292
|
+
* `billingTodaySpend` projection cell; cold sessions are answered from the
|
|
1293
|
+
* zero-I/O projection-cache row whenever that row's own day is not the
|
|
1294
|
+
* queried one, and otherwise resolved through one detached local fold over
|
|
1295
|
+
* a full `inspect`. Persisted revisions gate every cold read, so a session
|
|
1296
|
+
* whose log did not change since the last resolution costs nothing — and a
|
|
1297
|
+
* failed resolution is remembered by revision instead of being retried on
|
|
1298
|
+
* every scan.
|
|
808
1299
|
* - events path (plans A2/A3): collect and price only today's events in one
|
|
809
1300
|
* pass (per-event Beijing-day filter during collection) with a hard cap,
|
|
810
1301
|
* skipping sessions whose persisted revision is unchanged since the last
|
|
@@ -816,14 +1307,15 @@ function foldOwnBilling(unit, events, seedLength = 0) {
|
|
|
816
1307
|
* unchanged log provably cannot change the aggregate.
|
|
817
1308
|
*
|
|
818
1309
|
* Forked sessions never double-count: a fork child's log opens with a
|
|
819
|
-
* verbatim copy of its source session's events (its inherited boundary),
|
|
820
|
-
*
|
|
821
|
-
*
|
|
822
|
-
*
|
|
823
|
-
*
|
|
824
|
-
*
|
|
825
|
-
* durable
|
|
826
|
-
*
|
|
1310
|
+
* verbatim copy of its source session's events (its inherited boundary), so
|
|
1311
|
+
* the scanner prices only the child's OWN events on every path. The
|
|
1312
|
+
* `billingTodaySpend` unit is boundary-aware (its state carries the inherited
|
|
1313
|
+
* cut, and `apply` skips events below it), so the eager cell is correct for a
|
|
1314
|
+
* fork child; the cold path skips the projection cache for a seeded session
|
|
1315
|
+
* (its cached row may predate the boundary) and folds its own events with the
|
|
1316
|
+
* durable cut instead. The boundary is the durable session state, read across
|
|
1317
|
+
* both DSH runtime families — a resumed fork child keeps its original
|
|
1318
|
+
* boundary and an unseeded session stays at 0.
|
|
827
1319
|
*
|
|
828
1320
|
* The live `Session` log surface changed in 0.1.2-alpha.4: `Session.events`
|
|
829
1321
|
* was removed and replaced by `Session.snapshotEvents()` / `ownEvents()`, and
|
|
@@ -862,6 +1354,15 @@ function liveSessionEvents(session) {
|
|
|
862
1354
|
if (session.snapshotEvents !== void 0) return session.snapshotEvents();
|
|
863
1355
|
throw new Error("llm-billing: session log surface is neither Session.events nor Session.snapshotEvents");
|
|
864
1356
|
}
|
|
1357
|
+
/**
|
|
1358
|
+
* Unwrap a handle read across both return shapes.
|
|
1359
|
+
* @param read - the handle's read result.
|
|
1360
|
+
* @returns the event array.
|
|
1361
|
+
*/
|
|
1362
|
+
function handleReadEvents(read) {
|
|
1363
|
+
if (Array.isArray(read)) return read;
|
|
1364
|
+
return read.events;
|
|
1365
|
+
}
|
|
865
1366
|
function isHandlePersistence(persistence) {
|
|
866
1367
|
return typeof persistence.open === "function";
|
|
867
1368
|
}
|
|
@@ -888,7 +1389,7 @@ async function persistenceInspect(persistence, id) {
|
|
|
888
1389
|
const handle = await persistence.open(id, "read");
|
|
889
1390
|
try {
|
|
890
1391
|
return {
|
|
891
|
-
events: await handle.read(),
|
|
1392
|
+
events: handleReadEvents(await handle.read()),
|
|
892
1393
|
seedLength: forkBoundaryOf(handle)
|
|
893
1394
|
};
|
|
894
1395
|
} finally {
|
|
@@ -952,7 +1453,7 @@ var TodaySpendCache = class {
|
|
|
952
1453
|
const now = this.now();
|
|
953
1454
|
const dayKey = beijingDayKey(now);
|
|
954
1455
|
if (!force && this.cachedDayKey === dayKey && this.cachedValue !== void 0 && now.getTime() - this.cachedAt < this.ttlMs) return Promise.resolve(this.cachedValue);
|
|
955
|
-
if (
|
|
1456
|
+
if (this.inFlight !== void 0) return this.inFlight;
|
|
956
1457
|
const run = (async () => {
|
|
957
1458
|
try {
|
|
958
1459
|
const value = await this.scan(dayKey);
|
|
@@ -964,10 +1465,24 @@ var TodaySpendCache = class {
|
|
|
964
1465
|
this.inFlight = void 0;
|
|
965
1466
|
}
|
|
966
1467
|
})();
|
|
967
|
-
|
|
1468
|
+
this.inFlight = run;
|
|
968
1469
|
return run;
|
|
969
1470
|
}
|
|
970
1471
|
};
|
|
1472
|
+
/** Max session-ids kept in the scanner's cold-resolution cache before eviction. */
|
|
1473
|
+
const COLD_RESOLVE_CACHE_LIMIT = 1024;
|
|
1474
|
+
/** Max session-ids kept in the scanner's cold-failure cache before eviction. */
|
|
1475
|
+
const COLD_FAILED_CACHE_LIMIT = 1024;
|
|
1476
|
+
/**
|
|
1477
|
+
* Bounded-map eviction: drop the oldest inserted entry once `size` reached
|
|
1478
|
+
* `limit`. Evicting one entry (instead of clearing) keeps the other sessions'
|
|
1479
|
+
* resolved state warm across scans.
|
|
1480
|
+
*/
|
|
1481
|
+
function evictOldest$1(map, limit) {
|
|
1482
|
+
if (map.size < limit) return;
|
|
1483
|
+
const oldest = map.keys().next().value;
|
|
1484
|
+
if (oldest !== void 0) map.delete(oldest);
|
|
1485
|
+
}
|
|
971
1486
|
/**
|
|
972
1487
|
* The aggregate computation behind a cache miss. Chooses the projection path
|
|
973
1488
|
* when the projection registry is composed, the events path otherwise; both
|
|
@@ -978,10 +1493,10 @@ var TodaySpendScanner = class {
|
|
|
978
1493
|
deps;
|
|
979
1494
|
/** Cold sessions resolved on the projection path: id → revision + unit state + title. */
|
|
980
1495
|
coldResolved = /* @__PURE__ */ new Map();
|
|
1496
|
+
/** Cold sessions whose resolution failed: id → revision (retried only when the log changes). */
|
|
1497
|
+
coldFailed = /* @__PURE__ */ new Map();
|
|
981
1498
|
/** Cold sessions resolved on the events path: id → revision (events were collected). */
|
|
982
1499
|
lastEventsScan;
|
|
983
|
-
/** Live fork children priced on the projection path: id → own-events count + folded state. */
|
|
984
|
-
ownStates = /* @__PURE__ */ new Map();
|
|
985
1500
|
constructor(deps) {
|
|
986
1501
|
this.deps = deps;
|
|
987
1502
|
}
|
|
@@ -991,9 +1506,7 @@ var TodaySpendScanner = class {
|
|
|
991
1506
|
* @returns today's spend across every session.
|
|
992
1507
|
*/
|
|
993
1508
|
async scan(dayKey) {
|
|
994
|
-
|
|
995
|
-
this.deps.ensureUnit?.();
|
|
996
|
-
return this.scanProjections(dayKey);
|
|
1509
|
+
return (await this.scanDetail(dayKey)).aggregate;
|
|
997
1510
|
}
|
|
998
1511
|
/**
|
|
999
1512
|
* Compute today's per-session spend for one Beijing day, sorted by cost
|
|
@@ -1003,157 +1516,169 @@ var TodaySpendScanner = class {
|
|
|
1003
1516
|
* @returns today's per-session rows, highest first.
|
|
1004
1517
|
*/
|
|
1005
1518
|
async scanSessions(dayKey) {
|
|
1006
|
-
|
|
1007
|
-
rows.sort((left, right) => right.total - left.total);
|
|
1008
|
-
return { sessions: rows };
|
|
1519
|
+
return { sessions: (await this.scanDetail(dayKey)).sessions };
|
|
1009
1520
|
}
|
|
1010
1521
|
/**
|
|
1011
|
-
*
|
|
1012
|
-
* the
|
|
1013
|
-
*
|
|
1014
|
-
*
|
|
1015
|
-
*
|
|
1016
|
-
*
|
|
1522
|
+
* Compute the day's aggregate AND its per-session ranking in ONE pass: the
|
|
1523
|
+
* aggregate is the sum of the rows, so the two reads share every session
|
|
1524
|
+
* read, unit fold, and title fold instead of scanning twice. Chooses the
|
|
1525
|
+
* projection path when the projection registry is composed, the events path
|
|
1526
|
+
* otherwise.
|
|
1527
|
+
* @param dayKey - the Beijing-time calendar-day key to aggregate.
|
|
1528
|
+
* @returns the aggregate plus per-session rows sorted by cost descending.
|
|
1529
|
+
*/
|
|
1530
|
+
async scanDetail(dayKey) {
|
|
1531
|
+
if (this.deps.projections?.() === void 0) return this.scanDetailEvents(dayKey);
|
|
1532
|
+
this.deps.ensureUnit?.();
|
|
1533
|
+
return this.scanDetailProjections(dayKey);
|
|
1534
|
+
}
|
|
1535
|
+
/**
|
|
1536
|
+
* Resolve one cold session's billing unit state and display title.
|
|
1537
|
+
*
|
|
1538
|
+
* The zero-I/O projection-cache row answers the query directly whenever its
|
|
1539
|
+
* own latest priced day is NOT the queried day: the row then proves the
|
|
1540
|
+
* session contributed nothing to the queried day, so the log is never read.
|
|
1541
|
+
* When the row IS the queried day (or no usable row exists) the session is
|
|
1542
|
+
* inspected and folded locally, because the row may trail the log (a crash
|
|
1543
|
+
* between the last checkpoint and the session's last event).
|
|
1544
|
+
*
|
|
1545
|
+
* A cache-served value carries no title (the ladder only stores projection
|
|
1546
|
+
* values), so such rows report `title: null`. A SEEDED session (fork child)
|
|
1547
|
+
* skips the cache entirely: its cached row was folded over the inherited
|
|
1017
1548
|
* prefix too, so it always detaches through inspect with the durable
|
|
1018
1549
|
* boundary (the inspect result's inherited count or `meta.seedLength`,
|
|
1019
1550
|
* depending on the runtime family) applied to the local fold.
|
|
1020
|
-
* @param
|
|
1021
|
-
* @param seeded - whether the session carries a fork-inherited prefix
|
|
1022
|
-
*
|
|
1023
|
-
* at and before the 0.1.1-rc.2 baseline).
|
|
1551
|
+
* @param header - the listed session header (the cache identity witness).
|
|
1552
|
+
* @param seeded - whether the session carries a fork-inherited prefix.
|
|
1553
|
+
* @param dayKey - the Beijing-time day being aggregated.
|
|
1024
1554
|
* @returns the resolved state and title, or `undefined` when unreadable.
|
|
1025
1555
|
*/
|
|
1026
|
-
async resolveCold(
|
|
1556
|
+
async resolveCold(header, seeded, dayKey) {
|
|
1027
1557
|
const { persistence, projectionCache, logger } = this.deps;
|
|
1028
|
-
const persistenceService = persistence?.();
|
|
1029
|
-
if (persistenceService === void 0) return void 0;
|
|
1030
1558
|
if (!seeded) {
|
|
1031
1559
|
const cache = projectionCache?.();
|
|
1032
1560
|
if (cache !== void 0) try {
|
|
1033
|
-
const value =
|
|
1034
|
-
if (value !== void 0) return {
|
|
1561
|
+
const value = cache.cachedSnapshot(header, 0, [BILLING_UNIT_KEY])?.values[BILLING_UNIT_KEY];
|
|
1562
|
+
if (value !== void 0 && value.dayKey !== dayKey) return {
|
|
1035
1563
|
value,
|
|
1036
1564
|
title: null
|
|
1037
1565
|
};
|
|
1038
1566
|
} catch (error) {
|
|
1039
|
-
logger.warn(`llm-billing: projection
|
|
1567
|
+
logger.warn(`llm-billing: projection cache read for session ${header.id} failed: ${String(error)}`);
|
|
1040
1568
|
}
|
|
1041
1569
|
}
|
|
1570
|
+
const persistenceService = persistence?.();
|
|
1571
|
+
if (persistenceService === void 0) return void 0;
|
|
1042
1572
|
try {
|
|
1043
|
-
const read = await persistenceInspect(persistenceService, id);
|
|
1573
|
+
const read = await persistenceInspect(persistenceService, header.id);
|
|
1044
1574
|
return {
|
|
1045
1575
|
value: foldOwnBilling(this.deps.unit, read.events, read.seedLength),
|
|
1046
1576
|
title: foldSessionTitle(read.events)
|
|
1047
1577
|
};
|
|
1048
1578
|
} catch (error) {
|
|
1049
|
-
logger.warn(`llm-billing: skipping unreadable session ${id}: ${String(error)}`);
|
|
1579
|
+
logger.warn(`llm-billing: skipping unreadable session ${header.id}: ${String(error)}`);
|
|
1050
1580
|
return;
|
|
1051
1581
|
}
|
|
1052
1582
|
}
|
|
1053
1583
|
/**
|
|
1054
|
-
*
|
|
1055
|
-
*
|
|
1056
|
-
*
|
|
1057
|
-
*
|
|
1058
|
-
* @param events - the session's complete log.
|
|
1059
|
-
* @param seedLength - the inherited-prefix boundary.
|
|
1060
|
-
* @returns the unit state over the session's own events.
|
|
1584
|
+
* Live-session entries of one projection-path scan: each session with its
|
|
1585
|
+
* eager `billingTodaySpend` cell. The cell is boundary-aware (the unit skips
|
|
1586
|
+
* a fork child's inherited prefix), so a fork child reads the same own-event
|
|
1587
|
+
* spend a non-fork session does.
|
|
1061
1588
|
*/
|
|
1062
|
-
|
|
1063
|
-
const
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
if (cached !== void 0 && cached.count < ownCount) {
|
|
1068
|
-
state = cached.state;
|
|
1069
|
-
for (const event of events) {
|
|
1070
|
-
if (event.seq < seedLength + cached.count) continue;
|
|
1071
|
-
state = this.deps.unit.apply(state, event);
|
|
1072
|
-
}
|
|
1073
|
-
} else state = foldOwnBilling(this.deps.unit, events, seedLength);
|
|
1074
|
-
this.ownStates.set(id, {
|
|
1075
|
-
count: ownCount,
|
|
1076
|
-
state
|
|
1077
|
-
});
|
|
1078
|
-
return state;
|
|
1589
|
+
*liveBillingEntries(store, projections) {
|
|
1590
|
+
for (const session of store.list()) yield {
|
|
1591
|
+
session,
|
|
1592
|
+
state: projections?.stateOf(session, BILLING_UNIT_KEY)
|
|
1593
|
+
};
|
|
1079
1594
|
}
|
|
1080
|
-
/**
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
const persistenceService = persistence?.();
|
|
1096
|
-
if (persistenceService === void 0) return total;
|
|
1097
|
-
const snapshots = await persistenceListSnapshots(persistenceService);
|
|
1595
|
+
/**
|
|
1596
|
+
* Cold-ladder adopt: for every stored session not live, either the
|
|
1597
|
+
* revision-gated resolution already in {@link coldResolved} is adopted
|
|
1598
|
+
* (unchanged log costs nothing) or the session is queued behind a bounded
|
|
1599
|
+
* parallel fan-out, resolved, remembered, and then adopted. A session whose
|
|
1600
|
+
* resolution failed is remembered too (by revision), so an unreadable log
|
|
1601
|
+
* is not re-read on every scan; a changed revision retries it. One
|
|
1602
|
+
* unreadable session never blanks the whole-day aggregate.
|
|
1603
|
+
* @param liveIds - ids of sessions already folded from the live store.
|
|
1604
|
+
* @param snapshots - stored snapshot list (either runtime family).
|
|
1605
|
+
* @param dayKey - the Beijing-time day being aggregated.
|
|
1606
|
+
* @param adopt - fold one resolved cold session into the scan's result.
|
|
1607
|
+
*/
|
|
1608
|
+
async coldAdopt(liveIds, snapshots, dayKey, adopt) {
|
|
1609
|
+
const persistenceAvailable = this.deps.persistence?.() !== void 0;
|
|
1098
1610
|
const pending = [];
|
|
1099
1611
|
for (const { header, revision } of snapshots) {
|
|
1100
1612
|
if (liveIds.has(header.id)) continue;
|
|
1101
1613
|
const seeded = isSeededSession(header);
|
|
1102
1614
|
const resolved = this.coldResolved.get(header.id);
|
|
1103
1615
|
if (resolved !== void 0 && resolved.revision === revision) {
|
|
1104
|
-
|
|
1616
|
+
adopt(header.id, resolved);
|
|
1105
1617
|
continue;
|
|
1106
1618
|
}
|
|
1619
|
+
if (this.coldFailed.get(header.id) === revision) continue;
|
|
1107
1620
|
pending.push({
|
|
1108
|
-
|
|
1621
|
+
header,
|
|
1109
1622
|
revision,
|
|
1110
1623
|
seeded
|
|
1111
1624
|
});
|
|
1112
1625
|
}
|
|
1113
|
-
await withConcurrency(pending, 8, async ({
|
|
1114
|
-
const resolved = await this.resolveCold(
|
|
1115
|
-
if (resolved !== void 0)
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1626
|
+
await withConcurrency(pending, 8, async ({ header, revision, seeded }) => {
|
|
1627
|
+
const resolved = await this.resolveCold(header, seeded, dayKey);
|
|
1628
|
+
if (resolved !== void 0) {
|
|
1629
|
+
this.coldFailed.delete(header.id);
|
|
1630
|
+
evictOldest$1(this.coldResolved, COLD_RESOLVE_CACHE_LIMIT);
|
|
1631
|
+
this.coldResolved.set(header.id, {
|
|
1632
|
+
revision,
|
|
1633
|
+
...resolved
|
|
1634
|
+
});
|
|
1635
|
+
} else if (persistenceAvailable) {
|
|
1636
|
+
evictOldest$1(this.coldFailed, COLD_FAILED_CACHE_LIMIT);
|
|
1637
|
+
this.coldFailed.set(header.id, revision);
|
|
1638
|
+
}
|
|
1119
1639
|
});
|
|
1120
|
-
for (const {
|
|
1121
|
-
const resolved = this.coldResolved.get(id);
|
|
1122
|
-
if (resolved !== void 0
|
|
1640
|
+
for (const { header } of pending) {
|
|
1641
|
+
const resolved = this.coldResolved.get(header.id);
|
|
1642
|
+
if (resolved !== void 0) adopt(header.id, resolved);
|
|
1123
1643
|
}
|
|
1124
|
-
return total;
|
|
1125
1644
|
}
|
|
1126
1645
|
/**
|
|
1127
|
-
* Events
|
|
1128
|
-
*
|
|
1129
|
-
*
|
|
1130
|
-
*
|
|
1646
|
+
* Events-path collection shared by both aggregate and per-session scans:
|
|
1647
|
+
* fold each session's log with the shared pricing fold (attempt samples with
|
|
1648
|
+
* same-step replacement) and announce the session's latest-day spend, gated
|
|
1649
|
+
* by revisions — a persisted session whose log did not change since the last
|
|
1650
|
+
* scan is skipped. A fork child's inherited prefix (`seq < seedLength`) is
|
|
1651
|
+
* skipped, so each model output is priced only in its source session. The
|
|
1652
|
+
* hard cap counts the queried day's events; the revision watermark only
|
|
1653
|
+
* advances on a complete pass.
|
|
1654
|
+
* @param dayKey - the Beijing-time calendar-day key to aggregate.
|
|
1655
|
+
* @param onSession - fold one session's state plus its complete log.
|
|
1656
|
+
* @returns whether the hard cap truncated the scan.
|
|
1131
1657
|
*/
|
|
1132
|
-
async
|
|
1658
|
+
async collectTodayEvents(dayKey, onSession) {
|
|
1133
1659
|
const { sessions, persistence, maxEvents, logger, billing, catalog } = this.deps;
|
|
1134
|
-
const names = new Map(catalog.map((model) => [model.id, model.name]));
|
|
1135
|
-
const accumulator = new SpendAccumulator();
|
|
1136
1660
|
const liveIds = /* @__PURE__ */ new Set();
|
|
1137
1661
|
let collected = 0;
|
|
1138
1662
|
let truncated = false;
|
|
1139
|
-
const collect = (events, seedLength) => {
|
|
1663
|
+
const collect = (id, events, seedLength) => {
|
|
1664
|
+
const folder = new BillingFolder(billing, catalog, seedLength);
|
|
1140
1665
|
for (const event of events) {
|
|
1141
|
-
if (event.
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1666
|
+
if (beijingPartsOf(event.time).dayKey === dayKey) {
|
|
1667
|
+
collected += 1;
|
|
1668
|
+
if (collected > maxEvents) {
|
|
1669
|
+
truncated = true;
|
|
1670
|
+
break;
|
|
1671
|
+
}
|
|
1147
1672
|
}
|
|
1148
|
-
|
|
1149
|
-
if (priced !== void 0) accumulator.add(priced);
|
|
1673
|
+
folder.add(event);
|
|
1150
1674
|
}
|
|
1675
|
+
onSession(id, folder.fold, events);
|
|
1151
1676
|
};
|
|
1152
1677
|
if (sessions !== void 0) {
|
|
1153
1678
|
const store = sessions();
|
|
1154
1679
|
if (store !== void 0) for (const session of store.list()) {
|
|
1155
1680
|
liveIds.add(session.id);
|
|
1156
|
-
collect(liveSessionEvents(session), forkBoundaryOf(session));
|
|
1681
|
+
collect(session.id, liveSessionEvents(session), forkBoundaryOf(session));
|
|
1157
1682
|
if (truncated) break;
|
|
1158
1683
|
}
|
|
1159
1684
|
}
|
|
@@ -1165,7 +1690,7 @@ var TodaySpendScanner = class {
|
|
|
1165
1690
|
if (this.lastEventsScan?.get(header.id) === revision) continue;
|
|
1166
1691
|
try {
|
|
1167
1692
|
const read = await persistenceInspect(persistenceService, header.id);
|
|
1168
|
-
collect(read.events, read.seedLength);
|
|
1693
|
+
collect(header.id, read.events, read.seedLength);
|
|
1169
1694
|
} catch (error) {
|
|
1170
1695
|
logger.warn(`llm-billing: skipping unreadable session ${header.id}: ${String(error)}`);
|
|
1171
1696
|
}
|
|
@@ -1174,146 +1699,87 @@ var TodaySpendScanner = class {
|
|
|
1174
1699
|
if (!truncated) this.lastEventsScan = new Map(snapshots.map((snapshot) => [snapshot.header.id, snapshot.revision]));
|
|
1175
1700
|
}
|
|
1176
1701
|
if (truncated) logger.warn(`llm-billing: today's events exceeded ${maxEvents}; result truncated`);
|
|
1177
|
-
return
|
|
1702
|
+
return truncated;
|
|
1178
1703
|
}
|
|
1179
1704
|
/**
|
|
1180
|
-
* Projection
|
|
1181
|
-
* folded from the live log, so a rename is reflected immediately),
|
|
1182
|
-
* revision-gated cold ladder for the rest (title resolved on inspect,
|
|
1183
|
-
*
|
|
1184
|
-
*
|
|
1705
|
+
* Projection path, one pass for both outputs: eager cells for live sessions
|
|
1706
|
+
* (title folded from the live log, so a rename is reflected immediately),
|
|
1707
|
+
* revision-gated cold ladder for the rest (title resolved on inspect, `null`
|
|
1708
|
+
* when answered from the projection cache). A fork child's cell covers its
|
|
1709
|
+
* inherited prefix, so its own-events fold supplies both outputs.
|
|
1185
1710
|
* @param dayKey - the Beijing-time calendar-day key to aggregate.
|
|
1186
|
-
* @returns
|
|
1711
|
+
* @returns the aggregate plus per-session rows, sorted by cost descending.
|
|
1187
1712
|
*/
|
|
1188
|
-
async
|
|
1713
|
+
async scanDetailProjections(dayKey) {
|
|
1189
1714
|
const { sessions, persistence, projections } = this.deps;
|
|
1190
1715
|
const projectionsService = projections?.();
|
|
1716
|
+
let aggregate = emptyTodaySpend();
|
|
1191
1717
|
const rows = /* @__PURE__ */ new Map();
|
|
1192
1718
|
const liveIds = /* @__PURE__ */ new Set();
|
|
1193
1719
|
if (sessions !== void 0) {
|
|
1194
1720
|
const store = sessions();
|
|
1195
|
-
if (store !== void 0) for (const session of
|
|
1721
|
+
if (store !== void 0) for (const { session, state } of this.liveBillingEntries(store, projectionsService)) {
|
|
1196
1722
|
liveIds.add(session.id);
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
if (state !== void 0 && state.dayKey === dayKey) rows.set(session.id, {
|
|
1723
|
+
if (state === void 0 || state.dayKey !== dayKey) continue;
|
|
1724
|
+
aggregate = mergeTodaySpend(aggregate, state.spend);
|
|
1725
|
+
rows.set(session.id, {
|
|
1201
1726
|
sessionId: session.id,
|
|
1202
|
-
title: foldSessionTitle(
|
|
1727
|
+
title: foldSessionTitle(liveSessionEvents(session)),
|
|
1203
1728
|
total: state.spend.total
|
|
1204
1729
|
});
|
|
1205
1730
|
}
|
|
1206
1731
|
}
|
|
1207
1732
|
const persistenceService = persistence?.();
|
|
1208
|
-
if (persistenceService
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
if (resolved !== void 0 && resolved.revision === revision) {
|
|
1216
|
-
if (resolved.value.dayKey === dayKey) rows.set(header.id, {
|
|
1217
|
-
sessionId: header.id,
|
|
1733
|
+
if (persistenceService !== void 0) {
|
|
1734
|
+
const snapshots = await persistenceListSnapshots(persistenceService);
|
|
1735
|
+
await this.coldAdopt(liveIds, snapshots, dayKey, (id, resolved) => {
|
|
1736
|
+
if (resolved.value.dayKey !== dayKey) return;
|
|
1737
|
+
aggregate = mergeTodaySpend(aggregate, resolved.value.spend);
|
|
1738
|
+
rows.set(id, {
|
|
1739
|
+
sessionId: id,
|
|
1218
1740
|
title: resolved.title,
|
|
1219
1741
|
total: resolved.value.spend.total
|
|
1220
1742
|
});
|
|
1221
|
-
continue;
|
|
1222
|
-
}
|
|
1223
|
-
pending.push({
|
|
1224
|
-
id: header.id,
|
|
1225
|
-
revision,
|
|
1226
|
-
seeded
|
|
1227
1743
|
});
|
|
1228
1744
|
}
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
...resolved
|
|
1234
|
-
});
|
|
1235
|
-
});
|
|
1236
|
-
for (const { id } of pending) {
|
|
1237
|
-
const resolved = this.coldResolved.get(id);
|
|
1238
|
-
if (resolved !== void 0 && resolved.value.dayKey === dayKey) rows.set(id, {
|
|
1239
|
-
sessionId: id,
|
|
1240
|
-
title: resolved.title,
|
|
1241
|
-
total: resolved.value.spend.total
|
|
1242
|
-
});
|
|
1243
|
-
}
|
|
1244
|
-
return [...rows.values()];
|
|
1745
|
+
return {
|
|
1746
|
+
aggregate,
|
|
1747
|
+
sessions: sortRows(rows)
|
|
1748
|
+
};
|
|
1245
1749
|
}
|
|
1246
1750
|
/**
|
|
1247
|
-
* Events
|
|
1248
|
-
*
|
|
1249
|
-
*
|
|
1250
|
-
*
|
|
1251
|
-
*
|
|
1252
|
-
*
|
|
1253
|
-
* is re-read.
|
|
1751
|
+
* Events path, one pass for both outputs: price today's events (per-event
|
|
1752
|
+
* Beijing-day filter during collection, hard cap), gated by revisions. A
|
|
1753
|
+
* fork child's inherited prefix (`seq < seedLength`) is skipped, so each
|
|
1754
|
+
* model output is priced only in its source session. Titles fold from each
|
|
1755
|
+
* session's complete log — a `session/title` event can predate today — so a
|
|
1756
|
+
* rename is reflected as soon as the session's log is re-read.
|
|
1254
1757
|
* @param dayKey - the Beijing-time calendar-day key to aggregate.
|
|
1255
|
-
* @returns
|
|
1758
|
+
* @returns the aggregate plus per-session rows, sorted by cost descending.
|
|
1256
1759
|
*/
|
|
1257
|
-
async
|
|
1258
|
-
|
|
1259
|
-
const
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
for (const event of events) {
|
|
1274
|
-
if (event.seq < seedLength) continue;
|
|
1275
|
-
if (beijingDayKey(new Date(event.time)) !== dayKey) continue;
|
|
1276
|
-
collected += 1;
|
|
1277
|
-
if (collected > maxEvents) {
|
|
1278
|
-
truncated = true;
|
|
1279
|
-
return;
|
|
1280
|
-
}
|
|
1281
|
-
const priced = priceEvent(event, billing, names);
|
|
1282
|
-
if (priced !== void 0) row.total += priced.cost;
|
|
1283
|
-
}
|
|
1760
|
+
async scanDetailEvents(dayKey) {
|
|
1761
|
+
let aggregate = emptyTodaySpend();
|
|
1762
|
+
const sessions = [];
|
|
1763
|
+
await this.collectTodayEvents(dayKey, (id, fold, events) => {
|
|
1764
|
+
if (fold.dayKey !== dayKey) return;
|
|
1765
|
+
aggregate = mergeTodaySpend(aggregate, fold.spend);
|
|
1766
|
+
sessions.push({
|
|
1767
|
+
sessionId: id,
|
|
1768
|
+
title: foldSessionTitle(events),
|
|
1769
|
+
total: fold.spend.total
|
|
1770
|
+
});
|
|
1771
|
+
});
|
|
1772
|
+
sessions.sort((left, right) => right.total - left.total);
|
|
1773
|
+
return {
|
|
1774
|
+
aggregate,
|
|
1775
|
+
sessions
|
|
1284
1776
|
};
|
|
1285
|
-
if (sessions !== void 0) {
|
|
1286
|
-
const store = sessions();
|
|
1287
|
-
if (store !== void 0) for (const session of store.list()) {
|
|
1288
|
-
liveIds.add(session.id);
|
|
1289
|
-
collect(session.id, liveSessionEvents(session), forkBoundaryOf(session));
|
|
1290
|
-
if (truncated) break;
|
|
1291
|
-
}
|
|
1292
|
-
}
|
|
1293
|
-
const persistenceService = persistence?.();
|
|
1294
|
-
if (!truncated && persistenceService !== void 0) {
|
|
1295
|
-
const snapshots = await persistenceListSnapshots(persistenceService);
|
|
1296
|
-
for (const { header, revision } of snapshots) {
|
|
1297
|
-
if (liveIds.has(header.id)) continue;
|
|
1298
|
-
if (this.lastEventsScan?.get(header.id) === revision) continue;
|
|
1299
|
-
try {
|
|
1300
|
-
const read = await persistenceInspect(persistenceService, header.id);
|
|
1301
|
-
collect(header.id, read.events, read.seedLength);
|
|
1302
|
-
} catch (error) {
|
|
1303
|
-
logger.warn(`llm-billing: skipping unreadable session ${header.id}: ${String(error)}`);
|
|
1304
|
-
}
|
|
1305
|
-
if (truncated) break;
|
|
1306
|
-
}
|
|
1307
|
-
if (!truncated) this.lastEventsScan = new Map(snapshots.map((snapshot) => [snapshot.header.id, snapshot.revision]));
|
|
1308
|
-
}
|
|
1309
|
-
if (truncated) logger.warn(`llm-billing: today's events exceeded ${maxEvents}; result truncated`);
|
|
1310
|
-
return [...rows.entries()].filter(([, row]) => row.total > 0).map(([sessionId, row]) => ({
|
|
1311
|
-
sessionId,
|
|
1312
|
-
title: row.title,
|
|
1313
|
-
total: row.total
|
|
1314
|
-
}));
|
|
1315
1777
|
}
|
|
1316
1778
|
};
|
|
1779
|
+
/** Per-session rows from the map, highest total first. */
|
|
1780
|
+
function sortRows(rows) {
|
|
1781
|
+
return [...rows.values()].sort((left, right) => right.total - left.total);
|
|
1782
|
+
}
|
|
1317
1783
|
//#endregion
|
|
1318
1784
|
//#region lib/types/index.js
|
|
1319
1785
|
/**
|
|
@@ -1343,11 +1809,26 @@ const DEFAULT_API_KEY_ENV = "DEEPSEEK_API_KEY";
|
|
|
1343
1809
|
const BASE_URL_ENV = "DEEPSEEK_BASE_URL";
|
|
1344
1810
|
/** Public API default; deployments may point elsewhere via $DEEPSEEK_BASE_URL. */
|
|
1345
1811
|
const PUBLIC_BASE_URL = "https://api.deepseek.com";
|
|
1812
|
+
/**
|
|
1813
|
+
* Advisory display rows mirroring the DSH `llm-deepseek` catalog (V4.1 Flash
|
|
1814
|
+
* first, its current default route), plus the MiMo-V2.5 series. The retired
|
|
1815
|
+
* preview id `deepseek-v4.1-flash-expires-on-0910` stays so the logs that used
|
|
1816
|
+
* it keep a readable label; rows never restrict which models are priced — the
|
|
1817
|
+
* pricing table does.
|
|
1818
|
+
*/
|
|
1346
1819
|
const DEFAULT_MODELS = [
|
|
1820
|
+
{
|
|
1821
|
+
id: "deepseek-flash",
|
|
1822
|
+
name: "DeepSeek-V41-Flash"
|
|
1823
|
+
},
|
|
1347
1824
|
{
|
|
1348
1825
|
id: "deepseek-v4-flash",
|
|
1349
1826
|
name: "DeepSeek-V4-Flash"
|
|
1350
1827
|
},
|
|
1828
|
+
{
|
|
1829
|
+
id: "deepseek-v4.1-flash-expires-on-0910",
|
|
1830
|
+
name: "DeepSeek-V4.1-Flash"
|
|
1831
|
+
},
|
|
1351
1832
|
{
|
|
1352
1833
|
id: "deepseek-v4-pro",
|
|
1353
1834
|
name: "DeepSeek-V4-Pro"
|
|
@@ -1374,16 +1855,18 @@ const tokenPrice = z.object({
|
|
|
1374
1855
|
cacheMissInput: z.number().min(0),
|
|
1375
1856
|
output: z.number().min(0)
|
|
1376
1857
|
});
|
|
1858
|
+
const billingRateRow = z.object({
|
|
1859
|
+
model: z.string().required(),
|
|
1860
|
+
peak: tokenPrice,
|
|
1861
|
+
offPeak: tokenPrice,
|
|
1862
|
+
effectiveFrom: z.number().min(0)
|
|
1863
|
+
});
|
|
1377
1864
|
const billingConfig = z.object({
|
|
1378
1865
|
peakHours: z.array(z.object({
|
|
1379
1866
|
start: z.number().step(1).min(0).max(23),
|
|
1380
1867
|
end: z.number().step(1).min(0).max(24)
|
|
1381
|
-
})).default(DEFAULT_PEAK_HOURS),
|
|
1382
|
-
models: z.array(
|
|
1383
|
-
model: z.string().required(),
|
|
1384
|
-
peak: tokenPrice,
|
|
1385
|
-
offPeak: tokenPrice
|
|
1386
|
-
})).default(DEFAULT_MODEL_PRICING)
|
|
1868
|
+
})).default([...DEFAULT_PEAK_HOURS]),
|
|
1869
|
+
models: z.array(billingRateRow).default([...DEFAULT_MODEL_PRICING])
|
|
1387
1870
|
});
|
|
1388
1871
|
const Config = z.object({
|
|
1389
1872
|
apiKeyEnv: z.string().role("credential-ref").default(DEFAULT_API_KEY_ENV),
|
|
@@ -1395,6 +1878,25 @@ const Config = z.object({
|
|
|
1395
1878
|
const TODAY_SPEND_CACHE_MS = 6e4;
|
|
1396
1879
|
/** Hard cap on today's events collected by the events scan path. */
|
|
1397
1880
|
const TODAY_SPEND_MAX_EVENTS = 2e5;
|
|
1881
|
+
/** Max session-spend rows kept for incremental recompute before eviction. */
|
|
1882
|
+
const SESSION_SPEND_CACHE_LIMIT = 1024;
|
|
1883
|
+
/** Max session-id entries kept in the per-turn-cost fold cache before eviction. */
|
|
1884
|
+
const SESSION_TURN_SPEND_CACHE_LIMIT = 64;
|
|
1885
|
+
/** How long one balance snapshot is reused before the host refetches it (15s). */
|
|
1886
|
+
const BALANCE_CACHE_MS = 15e3;
|
|
1887
|
+
/** Hard cap on one `/user/balance` request (5s); a hung endpoint never blocks the badge. */
|
|
1888
|
+
const BALANCE_TIMEOUT_MS = 5e3;
|
|
1889
|
+
/**
|
|
1890
|
+
* Bounded-map eviction: drop the oldest inserted entry once `size` reached
|
|
1891
|
+
* `limit`, so an unbounded session-id space grows the map no further. Evicting
|
|
1892
|
+
* one entry (instead of clearing) keeps the other sessions' incremental
|
|
1893
|
+
* spend warm.
|
|
1894
|
+
*/
|
|
1895
|
+
function evictOldest(map, limit) {
|
|
1896
|
+
if (map.size < limit) return;
|
|
1897
|
+
const oldest = map.keys().next().value;
|
|
1898
|
+
if (oldest !== void 0) map.delete(oldest);
|
|
1899
|
+
}
|
|
1398
1900
|
/**
|
|
1399
1901
|
* Read one session's event log and durable seed boundary: the live
|
|
1400
1902
|
* SessionStore first, then the persistence backend for a flushed session
|
|
@@ -1421,65 +1923,111 @@ async function sessionEvents(ctx, sessionId) {
|
|
|
1421
1923
|
}
|
|
1422
1924
|
throw new LlmError(`llm-billing: session ${sessionId} not found`, "NOT_FOUND");
|
|
1423
1925
|
}
|
|
1926
|
+
/** Resolve the plugin's static facts once: endpoint, credential ref, pricing table. */
|
|
1927
|
+
function resolveFacts(ctx, config) {
|
|
1928
|
+
return {
|
|
1929
|
+
baseURL: () => config.baseURL ?? launchEnvironmentOf(ctx).get(BASE_URL_ENV)?.value ?? "https://api.deepseek.com",
|
|
1930
|
+
apiKeyRef: credentialRef(config.apiKeyEnv ?? DEFAULT_API_KEY_ENV),
|
|
1931
|
+
billing: resolveBilling(config.billing),
|
|
1932
|
+
catalog: (config.models ?? DEFAULT_MODELS).map((model) => ({
|
|
1933
|
+
id: model.id,
|
|
1934
|
+
name: model.name ?? model.id
|
|
1935
|
+
}))
|
|
1936
|
+
};
|
|
1937
|
+
}
|
|
1424
1938
|
/**
|
|
1425
|
-
*
|
|
1426
|
-
*
|
|
1427
|
-
* @
|
|
1939
|
+
* Resolve the API key per call: the credentials service first, then the
|
|
1940
|
+
* launch environment fallback.
|
|
1941
|
+
* @throws {@link LlmError} with code `MISSING_CREDENTIAL` when neither yields a usable key.
|
|
1428
1942
|
*/
|
|
1429
|
-
function
|
|
1430
|
-
const
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
}));
|
|
1943
|
+
async function resolveApiKey(ctx, apiKeyRef) {
|
|
1944
|
+
const credentials = ctx.get("credentials");
|
|
1945
|
+
if (credentials !== void 0) {
|
|
1946
|
+
const hit = await credentials.resolve(apiKeyRef);
|
|
1947
|
+
if (hit !== void 0) return assertUsableApiKey(hit.value, "llm-billing", apiKeyRef);
|
|
1948
|
+
} else {
|
|
1949
|
+
const ambient = launchEnvironmentOf(ctx).get(apiKeyRef);
|
|
1950
|
+
if (ambient !== void 0 && ambient.value.length > 0) return assertUsableApiKey(ambient.value, "llm-billing", apiKeyRef);
|
|
1951
|
+
}
|
|
1952
|
+
throw new LlmError(`llm-billing: no API key; store ${apiKeyRef} through the credentials service or export it`, "MISSING_CREDENTIAL");
|
|
1953
|
+
}
|
|
1954
|
+
/**
|
|
1955
|
+
* Per-session incremental spend loader: a session log is append-only and
|
|
1956
|
+
* chronological (the same assumption the projection unit makes), so a spend
|
|
1957
|
+
* computed for `count` EVENTS OF THE SESSION'S OWN WORK (the log minus its
|
|
1958
|
+
* inherited fork prefix) stays valid while the log length is unchanged, and
|
|
1959
|
+
* only the appended tail needs pricing when it grows. A forked child's
|
|
1960
|
+
* inherited prefix (`seq < seedLength`) is priced only in its source
|
|
1961
|
+
* session; the cache is bounded (see {@link evictOldest}), so an unbounded
|
|
1962
|
+
* session-id space cannot grow it without bound.
|
|
1963
|
+
*/
|
|
1964
|
+
function createSessionSpendFetcher(ctx, facts) {
|
|
1452
1965
|
const sessionSpendCache = /* @__PURE__ */ new Map();
|
|
1453
|
-
|
|
1966
|
+
return async (sessionId) => {
|
|
1454
1967
|
const { events, seedLength } = await sessionEvents(ctx, sessionId);
|
|
1455
1968
|
const ownCount = events.length - seedLength;
|
|
1456
1969
|
const cached = sessionSpendCache.get(sessionId);
|
|
1457
|
-
if (cached !== void 0 && cached.count === ownCount)
|
|
1970
|
+
if (cached !== void 0 && cached.count === ownCount) {
|
|
1971
|
+
sessionSpendCache.delete(sessionId);
|
|
1972
|
+
sessionSpendCache.set(sessionId, cached);
|
|
1973
|
+
return cached.spend;
|
|
1974
|
+
}
|
|
1458
1975
|
if (cached !== void 0 && cached.count < ownCount) {
|
|
1459
|
-
const spend = mergeTodaySpend(cached.spend, computeSessionSpend(events.slice(seedLength + cached.count), billing, catalog));
|
|
1976
|
+
const spend = mergeTodaySpend(cached.spend, computeSessionSpend(events.slice(seedLength + cached.count), facts.billing, facts.catalog));
|
|
1977
|
+
evictOldest(sessionSpendCache, SESSION_SPEND_CACHE_LIMIT);
|
|
1460
1978
|
sessionSpendCache.set(sessionId, {
|
|
1461
1979
|
count: ownCount,
|
|
1462
1980
|
spend
|
|
1463
1981
|
});
|
|
1464
1982
|
return spend;
|
|
1465
1983
|
}
|
|
1466
|
-
const spend = computeSessionSpend(events, billing, catalog, seedLength);
|
|
1467
|
-
|
|
1984
|
+
const spend = computeSessionSpend(events, facts.billing, facts.catalog, seedLength);
|
|
1985
|
+
evictOldest(sessionSpendCache, SESSION_SPEND_CACHE_LIMIT);
|
|
1468
1986
|
sessionSpendCache.set(sessionId, {
|
|
1469
1987
|
count: ownCount,
|
|
1470
1988
|
spend
|
|
1471
1989
|
});
|
|
1472
1990
|
return spend;
|
|
1473
1991
|
};
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1992
|
+
}
|
|
1993
|
+
/**
|
|
1994
|
+
* Once-registrar for the billing projection unit: the first call that finds
|
|
1995
|
+
* the registry composed registers the shared unit and every later call is a
|
|
1996
|
+
* no-op. Registering as early as the registry exists lets DSH's projection
|
|
1997
|
+
* write-behind (mandatory at `turn/end`) checkpoint a billing row for every
|
|
1998
|
+
* session that runs in this process, which is what makes the zero-I/O cold
|
|
1999
|
+
* path in {@link TodaySpendScanner} hit after the next restart.
|
|
2000
|
+
* @param ctx - plugin context.
|
|
2001
|
+
* @param unit - the unit definition built once per plugin config.
|
|
2002
|
+
* @returns an idempotent registrar.
|
|
2003
|
+
*/
|
|
2004
|
+
function createUnitRegistrar(ctx, unit) {
|
|
2005
|
+
let registered = false;
|
|
2006
|
+
return () => {
|
|
2007
|
+
if (registered) return;
|
|
1478
2008
|
const registry = ctx.get("sessionProjections");
|
|
1479
2009
|
if (registry === void 0) return;
|
|
1480
2010
|
registry.register(unit);
|
|
1481
|
-
|
|
2011
|
+
registered = true;
|
|
1482
2012
|
};
|
|
2013
|
+
}
|
|
2014
|
+
/**
|
|
2015
|
+
* Today-spend loaders over one revision-gated scanner with two 60s
|
|
2016
|
+
* Beijing-day caches (in-flight coalescing and a `force` bypass):
|
|
2017
|
+
* - plan C uses the per-session spend projection unit registered by the
|
|
2018
|
+
* caller's {@link createUnitRegistrar} as early as the registry exists (the
|
|
2019
|
+
* registry builds cells lazily over the in-memory log, so events committed
|
|
2020
|
+
* before registration are folded on first touch); without the registry the
|
|
2021
|
+
* events path serves today's spend.
|
|
2022
|
+
* - plans A1–A3: the scanner chooses the projection path when the registry
|
|
2023
|
+
* is composed, the events path otherwise.
|
|
2024
|
+
* @param ctx - plugin context.
|
|
2025
|
+
* @param facts - resolved endpoint, credential, pricing, and catalog facts.
|
|
2026
|
+
* @param unit - the shared projection unit definition.
|
|
2027
|
+
* @param ensureUnit - idempotent unit registrar (last-resort registration).
|
|
2028
|
+
* @returns the two today-spend loaders.
|
|
2029
|
+
*/
|
|
2030
|
+
function createTodaySpendLoaders(ctx, facts, unit, ensureUnit) {
|
|
1483
2031
|
const scanner = new TodaySpendScanner({
|
|
1484
2032
|
sessions: () => ctx.get("sessions"),
|
|
1485
2033
|
persistence: () => ctx.get("sessionPersistence"),
|
|
@@ -1489,24 +2037,108 @@ function apply(ctx, config) {
|
|
|
1489
2037
|
unit,
|
|
1490
2038
|
maxEvents: TODAY_SPEND_MAX_EVENTS,
|
|
1491
2039
|
logger: ctx.logger,
|
|
1492
|
-
billing,
|
|
1493
|
-
catalog
|
|
2040
|
+
billing: facts.billing,
|
|
2041
|
+
catalog: facts.catalog
|
|
1494
2042
|
});
|
|
1495
|
-
const todayCache = new TodaySpendCache((dayKey) => scanner.
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
2043
|
+
const todayCache = new TodaySpendCache((dayKey) => scanner.scanDetail(dayKey), TODAY_SPEND_CACHE_MS);
|
|
2044
|
+
return {
|
|
2045
|
+
fetchTodaySpend: async (force = false) => (await todayCache.get(force)).aggregate,
|
|
2046
|
+
fetchTodaySessionsSpend: async (force = false) => ({ sessions: (await todayCache.get(force)).sessions })
|
|
2047
|
+
};
|
|
2048
|
+
}
|
|
2049
|
+
/** One completed Turn's spend loader, located by its closing message id. */
|
|
2050
|
+
function createTurnSpendFetcher(ctx, facts) {
|
|
2051
|
+
return async (sessionId, messageId) => {
|
|
1500
2052
|
const { events } = await sessionEvents(ctx, sessionId);
|
|
1501
|
-
return computeTurnSpend(events, billing, catalog, messageId);
|
|
2053
|
+
return computeTurnSpend(events, facts.billing, facts.catalog, messageId);
|
|
1502
2054
|
};
|
|
2055
|
+
}
|
|
2056
|
+
/**
|
|
2057
|
+
* Every completed Turn's cost in one session, folded incrementally per session
|
|
2058
|
+
* (session logs are append-only, so only the appended tail is priced on a
|
|
2059
|
+
* growing log). One call serves a whole transcript's per-message cost rows,
|
|
2060
|
+
* replacing the per-message `getTurnSpend` fan-out.
|
|
2061
|
+
*/
|
|
2062
|
+
function createTurnSpendsFetcher(ctx, facts) {
|
|
2063
|
+
const folders = /* @__PURE__ */ new Map();
|
|
2064
|
+
return async (sessionId) => {
|
|
2065
|
+
const { events } = await sessionEvents(ctx, sessionId);
|
|
2066
|
+
let entry = folders.get(sessionId);
|
|
2067
|
+
if (entry === void 0 || entry.count > events.length) {
|
|
2068
|
+
entry = {
|
|
2069
|
+
folder: new SessionTurnSpendFolder(facts.billing, facts.catalog),
|
|
2070
|
+
count: 0
|
|
2071
|
+
};
|
|
2072
|
+
evictOldest(folders, 64);
|
|
2073
|
+
folders.set(sessionId, entry);
|
|
2074
|
+
}
|
|
2075
|
+
if (entry.count !== events.length) {
|
|
2076
|
+
entry.folder.feed(events);
|
|
2077
|
+
entry.count = events.length;
|
|
2078
|
+
}
|
|
2079
|
+
return entry.folder.finish();
|
|
2080
|
+
};
|
|
2081
|
+
}
|
|
2082
|
+
/**
|
|
2083
|
+
* Balance loader with a short host-side TTL and a hard request timeout: the
|
|
2084
|
+
* credential resolves per call, a fresh snapshot is reused for
|
|
2085
|
+
* {@link BALANCE_CACHE_MS} (so several badge mounts and several browsers share
|
|
2086
|
+
* one `/user/balance` call), concurrent misses coalesce, and `force` bypasses
|
|
2087
|
+
* the TTL for the manual refresh. A hung endpoint aborts after
|
|
2088
|
+
* {@link BALANCE_TIMEOUT_MS} instead of holding the badge's fetch forever.
|
|
2089
|
+
* @param ctx - plugin context carrying the credential seam.
|
|
2090
|
+
* @param facts - resolved endpoint and credential facts.
|
|
2091
|
+
* @returns the balance loader.
|
|
2092
|
+
*/
|
|
2093
|
+
function createBalanceFetcher(ctx, facts) {
|
|
2094
|
+
let cached;
|
|
2095
|
+
let inflight;
|
|
2096
|
+
return async (force = false) => {
|
|
2097
|
+
if (!force && cached !== void 0 && Date.now() - cached.at < 15e3) return cached.value;
|
|
2098
|
+
if (inflight !== void 0) return inflight;
|
|
2099
|
+
const run = (async () => {
|
|
2100
|
+
try {
|
|
2101
|
+
const apiKey = await resolveApiKey(ctx, facts.apiKeyRef);
|
|
2102
|
+
const value = await fetchDeepSeekBalance(facts.baseURL(), apiKey, AbortSignal.timeout(BALANCE_TIMEOUT_MS));
|
|
2103
|
+
cached = {
|
|
2104
|
+
at: Date.now(),
|
|
2105
|
+
value
|
|
2106
|
+
};
|
|
2107
|
+
return value;
|
|
2108
|
+
} finally {
|
|
2109
|
+
inflight = void 0;
|
|
2110
|
+
}
|
|
2111
|
+
})();
|
|
2112
|
+
inflight = run;
|
|
2113
|
+
return run;
|
|
2114
|
+
};
|
|
2115
|
+
}
|
|
2116
|
+
/**
|
|
2117
|
+
* Register the `billing` Remote under the `billing` namespace. Assembly only:
|
|
2118
|
+
* facts resolve once, each loader owns its caches, and the gateway receives
|
|
2119
|
+
* the bound thunks.
|
|
2120
|
+
* @param ctx - owning plugin context.
|
|
2121
|
+
* @param config - validated plugin config.
|
|
2122
|
+
*/
|
|
2123
|
+
function apply(ctx, config) {
|
|
2124
|
+
const facts = resolveFacts(ctx, config);
|
|
2125
|
+
const unit = billingTodaySpendDefinition(facts.billing, facts.catalog);
|
|
2126
|
+
const ensureUnit = createUnitRegistrar(ctx, unit);
|
|
2127
|
+
ensureUnit();
|
|
2128
|
+
ctx.on("session/created", ensureUnit);
|
|
2129
|
+
const fetchBalance = createBalanceFetcher(ctx, facts);
|
|
2130
|
+
const fetchSessionSpend = createSessionSpendFetcher(ctx, facts);
|
|
2131
|
+
const { fetchTodaySpend, fetchTodaySessionsSpend } = createTodaySpendLoaders(ctx, facts, unit, ensureUnit);
|
|
2132
|
+
const fetchTurnSpend = createTurnSpendFetcher(ctx, facts);
|
|
2133
|
+
const fetchTurnSpends = createTurnSpendsFetcher(ctx, facts);
|
|
1503
2134
|
new DeepSeekBalanceGateway(ctx, {
|
|
1504
2135
|
fetchBalance,
|
|
1505
2136
|
fetchSessionSpend,
|
|
1506
2137
|
fetchTodaySpend,
|
|
1507
2138
|
fetchTodaySessionsSpend,
|
|
1508
|
-
fetchTurnSpend
|
|
2139
|
+
fetchTurnSpend,
|
|
2140
|
+
fetchTurnSpends
|
|
1509
2141
|
});
|
|
1510
2142
|
}
|
|
1511
2143
|
//#endregion
|
|
1512
|
-
export { BILLING_UNIT_KEY, Config, DEFAULT_MODEL_PRICING, DEFAULT_PEAK_HOURS, DeepSeekBalanceGateway, PUBLIC_BASE_URL, SpendAccumulator, TODAY_SPEND_CACHE_MS, TODAY_SPEND_MAX_EVENTS, TodaySpendCache, TodaySpendScanner, addEventContribution, apply, beijingDayKey, billingTodaySpendDefinition, computeSessionSpend, computeTodaySpend, computeTurnSpend, emptyTodaySpend, fetchDeepSeekBalance, foldBillingUnit, foldOwnBilling, foldSessionTitle, forkBoundaryOf, isPeak, isSeededSession, liveSessionEvents, mergeTodaySpend, name, parseDeepSeekBalance, persistenceInspect, persistenceListSnapshots, priceEvent, resolveBilling };
|
|
2144
|
+
export { BALANCE_CACHE_MS, BALANCE_TIMEOUT_MS, BILLING_UNIT_KEY, BillingFolder, Config, DEFAULT_MODEL_PRICING, DEFAULT_PEAK_HOURS, DeepSeekBalanceGateway, FLASH_SERIES_RATE_CHANGE_AT, PUBLIC_BASE_URL, SESSION_SPEND_CACHE_LIMIT, SESSION_TURN_SPEND_CACHE_LIMIT, SessionTurnSpendFolder, SpendAccumulator, TODAY_SPEND_CACHE_MS, TODAY_SPEND_MAX_EVENTS, TodaySpendCache, TodaySpendScanner, V4_PRO_ROUTE_SWITCH_AT, addEventContribution, apply, applyBillingEvent, beijingDayKey, billingTodaySpendDefinition, computeSessionSpend, computeSessionTurnSpends, computeTodaySpend, computeTurnSpend, emptyBillingFoldState, emptyTodaySpend, fetchDeepSeekBalance, foldBillingUnit, foldOwnBilling, foldSessionTitle, forkBoundaryOf, isPeak, isSeededSession, liveSessionEvents, mergeTodaySpend, name, negateSpend, parseDeepSeekBalance, persistenceInspect, persistenceListSnapshots, priceEvent, priceUsage, resolveBilling, subtractSpend };
|