@rayadesu/dsh-llm-billing 0.2.2 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/index.js +158 -100
- package/lib/types/billing.d.ts +22 -5
- package/lib/types/billing.js +88 -70
- package/lib/types/index.d.ts +6 -1
- package/lib/types/index.js +29 -3
- package/lib/types/today-spend.d.ts +8 -4
- package/lib/types/today-spend.js +45 -36
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -228,11 +228,12 @@ let DeepSeekBalanceGateway = (() => {
|
|
|
228
228
|
* Remote gateway stays transport-free and the whole spend is testable without
|
|
229
229
|
* a key.
|
|
230
230
|
*
|
|
231
|
-
* The per-event pricing lives in {@link priceEvent}
|
|
232
|
-
*
|
|
233
|
-
* {@link computeTodaySpend})
|
|
234
|
-
*
|
|
235
|
-
* pricing-table change cannot drift one path
|
|
231
|
+
* The per-event pricing lives in {@link priceEvent} and the fold in the
|
|
232
|
+
* {@link SpendAccumulator}: the events-scan paths ({@link computeSessionSpend},
|
|
233
|
+
* {@link computeTodaySpend}), the session-projection unit (`billingTodaySpend`
|
|
234
|
+
* in projection.ts), and the scanner's single-pass events path all price
|
|
235
|
+
* through the same primitives, so a pricing-table change cannot drift one path
|
|
236
|
+
* from the others.
|
|
236
237
|
* @module @rayadesu/dsh-llm-billing/billing
|
|
237
238
|
*/
|
|
238
239
|
/**
|
|
@@ -311,20 +312,28 @@ function resolveBilling(config) {
|
|
|
311
312
|
models
|
|
312
313
|
};
|
|
313
314
|
}
|
|
314
|
-
/** The Beijing (Asia/Shanghai, UTC+8, no DST) hour of a timestamp. */
|
|
315
|
-
function beijingHour(now) {
|
|
316
|
-
return new Date(now.getTime() + 288e5).getUTCHours();
|
|
317
|
-
}
|
|
318
315
|
/**
|
|
319
|
-
*
|
|
320
|
-
*
|
|
316
|
+
* Derive the Beijing hour, weekday, and calendar-day key of one timestamp from
|
|
317
|
+
* a single shifted `Date` — every timezone-sensitive read shares this one
|
|
318
|
+
* implementation, so the pieces cannot drift apart.
|
|
319
|
+
* @param time - epoch milliseconds.
|
|
321
320
|
*/
|
|
322
|
-
function
|
|
323
|
-
|
|
321
|
+
function beijingParts(time) {
|
|
322
|
+
const shifted = new Date(time + 288e5);
|
|
323
|
+
return {
|
|
324
|
+
hour: shifted.getUTCHours(),
|
|
325
|
+
weekday: shifted.getUTCDay(),
|
|
326
|
+
dayKey: shifted.toISOString().slice(0, 10)
|
|
327
|
+
};
|
|
324
328
|
}
|
|
325
329
|
/** The Beijing (Asia/Shanghai, UTC+8, no DST) calendar-day key of a timestamp. */
|
|
326
330
|
function beijingDayKey(now) {
|
|
327
|
-
return
|
|
331
|
+
return beijingParts(now.getTime()).dayKey;
|
|
332
|
+
}
|
|
333
|
+
/** Whether a Beijing (hour, weekday) pair falls inside any peak-hour window. */
|
|
334
|
+
function isPeakParts(billing, hour, weekday) {
|
|
335
|
+
if (weekday === 0 || weekday === 6) return false;
|
|
336
|
+
return billing.peakHours.some(({ start, end }) => hour >= start && hour < end);
|
|
328
337
|
}
|
|
329
338
|
/**
|
|
330
339
|
* Whether a timestamp falls inside any peak-hour window (Beijing time,
|
|
@@ -335,10 +344,8 @@ function beijingDayKey(now) {
|
|
|
335
344
|
* @returns true during a weekday peak hour.
|
|
336
345
|
*/
|
|
337
346
|
function isPeak(billing, now) {
|
|
338
|
-
const weekday =
|
|
339
|
-
|
|
340
|
-
const hour = beijingHour(now);
|
|
341
|
-
return billing.peakHours.some(({ start, end }) => hour >= start && hour < end);
|
|
347
|
+
const { hour, weekday } = beijingParts(now.getTime());
|
|
348
|
+
return isPeakParts(billing, hour, weekday);
|
|
342
349
|
}
|
|
343
350
|
/**
|
|
344
351
|
* Price one event at the official per-model rates, applying the peak/off-peak
|
|
@@ -360,8 +367,8 @@ function priceEvent(event, billing, names) {
|
|
|
360
367
|
const model = event.data.message.source.model;
|
|
361
368
|
const pricing = billing.models.get(model);
|
|
362
369
|
if (pricing === void 0) return void 0;
|
|
363
|
-
const
|
|
364
|
-
const peak =
|
|
370
|
+
const { hour, weekday, dayKey } = beijingParts(event.time);
|
|
371
|
+
const peak = isPeakParts(billing, hour, weekday);
|
|
365
372
|
const price = peak ? pricing.peak : pricing.offPeak;
|
|
366
373
|
const hit = reported.cacheReadTokens ?? 0;
|
|
367
374
|
const miss = reported.inputTokens + (reported.cacheWriteTokens ?? 0);
|
|
@@ -371,7 +378,7 @@ function priceEvent(event, billing, names) {
|
|
|
371
378
|
const outputCost = output * price.output / 1e6;
|
|
372
379
|
const cost = hitCost + missCost + outputCost;
|
|
373
380
|
return {
|
|
374
|
-
dayKey
|
|
381
|
+
dayKey,
|
|
375
382
|
model,
|
|
376
383
|
displayName: names.get(model) ?? model,
|
|
377
384
|
cost,
|
|
@@ -408,6 +415,48 @@ function contributionModel(priced) {
|
|
|
408
415
|
outputCost: priced.outputCost
|
|
409
416
|
};
|
|
410
417
|
}
|
|
418
|
+
/** Sum two model rows of the same model (pure). */
|
|
419
|
+
function mergeModelRows(left, right) {
|
|
420
|
+
return {
|
|
421
|
+
model: left.model,
|
|
422
|
+
displayName: left.displayName,
|
|
423
|
+
cost: left.cost + right.cost,
|
|
424
|
+
peakCost: left.peakCost + right.peakCost,
|
|
425
|
+
offPeakCost: left.offPeakCost + right.offPeakCost,
|
|
426
|
+
cacheHitInputTokens: left.cacheHitInputTokens + right.cacheHitInputTokens,
|
|
427
|
+
cacheMissInputTokens: left.cacheMissInputTokens + right.cacheMissInputTokens,
|
|
428
|
+
outputTokens: left.outputTokens + right.outputTokens,
|
|
429
|
+
cacheHitInputCost: left.cacheHitInputCost + right.cacheHitInputCost,
|
|
430
|
+
cacheMissInputCost: left.cacheMissInputCost + right.cacheMissInputCost,
|
|
431
|
+
outputCost: left.outputCost + right.outputCost
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* Mutable model-row accumulator behind every spend fold. Rows keep first-seen
|
|
436
|
+
* model order — the same shape a pure `addEventContribution` chain produces —
|
|
437
|
+
* so the single-pass scan path and the pure public paths cannot diverge. One
|
|
438
|
+
* `Map` lookup per contribution instead of a per-event array copy: the huge
|
|
439
|
+
* event-log folds allocate one row object per model, not one intermediate
|
|
440
|
+
* array per event.
|
|
441
|
+
*/
|
|
442
|
+
var SpendAccumulator = class {
|
|
443
|
+
rows = /* @__PURE__ */ new Map();
|
|
444
|
+
total = 0;
|
|
445
|
+
/** Add one priced contribution. */
|
|
446
|
+
add(priced) {
|
|
447
|
+
const row = contributionModel(priced);
|
|
448
|
+
const existing = this.rows.get(priced.model);
|
|
449
|
+
this.rows.set(priced.model, existing === void 0 ? row : mergeModelRows(existing, row));
|
|
450
|
+
this.total += priced.cost;
|
|
451
|
+
}
|
|
452
|
+
/** The folded spend; the accumulator stays usable afterwards. */
|
|
453
|
+
finish() {
|
|
454
|
+
return {
|
|
455
|
+
total: this.total,
|
|
456
|
+
models: [...this.rows.values()]
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
};
|
|
411
460
|
/**
|
|
412
461
|
* Merge one priced event's contribution into an accumulator spend (pure:
|
|
413
462
|
* returns a new spend, never mutates its input).
|
|
@@ -416,19 +465,9 @@ function contributionModel(priced) {
|
|
|
416
465
|
* @returns the merged spend.
|
|
417
466
|
*/
|
|
418
467
|
function addEventContribution(spend, priced) {
|
|
419
|
-
const
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
peakCost: row.peakCost + priced.peakCost,
|
|
423
|
-
offPeakCost: row.offPeakCost + priced.offPeakCost,
|
|
424
|
-
cacheHitInputTokens: row.cacheHitInputTokens + priced.cacheHitInputTokens,
|
|
425
|
-
cacheMissInputTokens: row.cacheMissInputTokens + priced.cacheMissInputTokens,
|
|
426
|
-
outputTokens: row.outputTokens + priced.outputTokens,
|
|
427
|
-
cacheHitInputCost: row.cacheHitInputCost + priced.cacheHitInputCost,
|
|
428
|
-
cacheMissInputCost: row.cacheMissInputCost + priced.cacheMissInputCost,
|
|
429
|
-
outputCost: row.outputCost + priced.outputCost
|
|
430
|
-
} : row);
|
|
431
|
-
if (!rows.some((row) => row.model === priced.model)) rows.push(contributionModel(priced));
|
|
468
|
+
const row = contributionModel(priced);
|
|
469
|
+
const rows = spend.models.map((existing) => existing.model === priced.model ? mergeModelRows(existing, row) : existing);
|
|
470
|
+
if (!rows.some((existing) => existing.model === priced.model)) rows.push(row);
|
|
432
471
|
return {
|
|
433
472
|
total: spend.total + priced.cost,
|
|
434
473
|
models: rows
|
|
@@ -442,22 +481,16 @@ function addEventContribution(spend, priced) {
|
|
|
442
481
|
* @returns the summed spend.
|
|
443
482
|
*/
|
|
444
483
|
function mergeTodaySpend(target, source) {
|
|
445
|
-
|
|
446
|
-
for (const row of
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
outputTokens: row.outputTokens,
|
|
456
|
-
cacheHitInputCost: row.cacheHitInputCost,
|
|
457
|
-
cacheMissInputCost: row.cacheMissInputCost,
|
|
458
|
-
outputCost: row.outputCost
|
|
459
|
-
});
|
|
460
|
-
return merged;
|
|
484
|
+
const rows = /* @__PURE__ */ new Map();
|
|
485
|
+
for (const row of target.models) rows.set(row.model, row);
|
|
486
|
+
for (const row of source.models) {
|
|
487
|
+
const existing = rows.get(row.model);
|
|
488
|
+
rows.set(row.model, existing === void 0 ? row : mergeModelRows(existing, row));
|
|
489
|
+
}
|
|
490
|
+
return {
|
|
491
|
+
total: target.total + source.total,
|
|
492
|
+
models: [...rows.values()]
|
|
493
|
+
};
|
|
461
494
|
}
|
|
462
495
|
/**
|
|
463
496
|
* Price a set of billed events at the official per-model rates, applying the
|
|
@@ -470,21 +503,18 @@ function mergeTodaySpend(target, source) {
|
|
|
470
503
|
* published table prices only the two V4 rows).
|
|
471
504
|
* @param events - the events to price.
|
|
472
505
|
* @param billing - resolved pricing with peak-hour windows.
|
|
473
|
-
* @param
|
|
506
|
+
* @param names - model id → display label.
|
|
507
|
+
* @param dayKey - when provided, only events on this Beijing calendar day contribute.
|
|
474
508
|
* @returns the total cost plus one row per priced model.
|
|
475
509
|
*/
|
|
476
|
-
function priceEvents(events, billing,
|
|
477
|
-
const
|
|
478
|
-
let spend = {
|
|
479
|
-
total: 0,
|
|
480
|
-
models: []
|
|
481
|
-
};
|
|
510
|
+
function priceEvents(events, billing, names, dayKey) {
|
|
511
|
+
const accumulator = new SpendAccumulator();
|
|
482
512
|
for (const event of events) {
|
|
483
513
|
const priced = priceEvent(event, billing, names);
|
|
484
|
-
if (priced === void 0) continue;
|
|
485
|
-
|
|
514
|
+
if (priced === void 0 || dayKey !== void 0 && priced.dayKey !== dayKey) continue;
|
|
515
|
+
accumulator.add(priced);
|
|
486
516
|
}
|
|
487
|
-
return
|
|
517
|
+
return accumulator.finish();
|
|
488
518
|
}
|
|
489
519
|
/**
|
|
490
520
|
* Price one session's complete event log at the official per-model rates.
|
|
@@ -494,7 +524,7 @@ function priceEvents(events, billing, catalog) {
|
|
|
494
524
|
* @returns the session's total cost plus one row per priced model.
|
|
495
525
|
*/
|
|
496
526
|
function computeSessionSpend(events, billing, catalog) {
|
|
497
|
-
return priceEvents(events, billing, catalog);
|
|
527
|
+
return priceEvents(events, billing, new Map(catalog.map((model) => [model.id, model.name])));
|
|
498
528
|
}
|
|
499
529
|
/**
|
|
500
530
|
* Price every event whose Beijing-time calendar day is the day of `now`,
|
|
@@ -508,14 +538,7 @@ function computeSessionSpend(events, billing, catalog) {
|
|
|
508
538
|
*/
|
|
509
539
|
function computeTodaySpend(events, billing, catalog, now = /* @__PURE__ */ new Date()) {
|
|
510
540
|
const day = beijingDayKey(now);
|
|
511
|
-
|
|
512
|
-
let spend = emptyTodaySpend();
|
|
513
|
-
for (const event of events) {
|
|
514
|
-
const priced = priceEvent(event, billing, names);
|
|
515
|
-
if (priced === void 0 || priced.dayKey !== day) continue;
|
|
516
|
-
spend = addEventContribution(spend, priced);
|
|
517
|
-
}
|
|
518
|
-
return spend;
|
|
541
|
+
return priceEvents(events, billing, new Map(catalog.map((model) => [model.id, model.name])), day);
|
|
519
542
|
}
|
|
520
543
|
//#endregion
|
|
521
544
|
//#region lib/types/projection.js
|
|
@@ -618,9 +641,10 @@ function foldBillingUnit(unit, events) {
|
|
|
618
641
|
* with write-back) or, without the cache service, one detached local fold
|
|
619
642
|
* over a full `inspect`. Persisted revisions gate every cold read, so a
|
|
620
643
|
* session whose log did not change since the last resolution costs nothing.
|
|
621
|
-
* - events path (plans A2/A3): collect only today's events
|
|
622
|
-
* Beijing-day filter during collection) with a hard cap,
|
|
623
|
-
* whose persisted revision is unchanged since the last
|
|
644
|
+
* - events path (plans A2/A3): collect and price only today's events in one
|
|
645
|
+
* pass (per-event Beijing-day filter during collection) with a hard cap,
|
|
646
|
+
* skipping sessions whose persisted revision is unchanged since the last
|
|
647
|
+
* scan.
|
|
624
648
|
*
|
|
625
649
|
* Both strategies run behind the same {@link TodaySpendCache}, so a miss
|
|
626
650
|
* happens at most once per 60 seconds per process, and a manual refresh
|
|
@@ -628,11 +652,19 @@ function foldBillingUnit(unit, events) {
|
|
|
628
652
|
* unchanged log provably cannot change the aggregate.
|
|
629
653
|
* @module @rayadesu/dsh-llm-billing/today-spend
|
|
630
654
|
*/
|
|
631
|
-
/**
|
|
655
|
+
/**
|
|
656
|
+
* Bounded parallel fan-out: run `run` over `items` with at most `limit` in
|
|
657
|
+
* flight. A shared index counter hands each worker its next job, so the
|
|
658
|
+
* dispatch is O(n) overall (array `shift()` would be O(n) per pop).
|
|
659
|
+
*/
|
|
632
660
|
async function withConcurrency(items, limit, run) {
|
|
633
|
-
const
|
|
634
|
-
|
|
635
|
-
|
|
661
|
+
const total = items.length;
|
|
662
|
+
let next = 0;
|
|
663
|
+
await Promise.all(Array.from({ length: Math.min(limit, total) }, async () => {
|
|
664
|
+
for (let job = next; job < total; job = next) {
|
|
665
|
+
next += 1;
|
|
666
|
+
await run(items[job]);
|
|
667
|
+
}
|
|
636
668
|
}));
|
|
637
669
|
}
|
|
638
670
|
/**
|
|
@@ -713,13 +745,15 @@ var TodaySpendScanner = class {
|
|
|
713
745
|
/** Projection path: eager cells for live sessions, revision-gated cold ladder for the rest. */
|
|
714
746
|
async scanProjections(dayKey) {
|
|
715
747
|
const { sessions, persistence, projections, projectionCache, unit, logger } = this.deps;
|
|
748
|
+
const projectionsService = projections?.();
|
|
749
|
+
const cache = projectionCache?.();
|
|
716
750
|
let total = emptyTodaySpend();
|
|
717
751
|
const liveIds = /* @__PURE__ */ new Set();
|
|
718
752
|
if (sessions !== void 0) {
|
|
719
753
|
const store = sessions();
|
|
720
754
|
if (store !== void 0) for (const session of store.list()) {
|
|
721
755
|
liveIds.add(session.id);
|
|
722
|
-
const state =
|
|
756
|
+
const state = projectionsService?.stateOf(session, BILLING_UNIT_KEY);
|
|
723
757
|
if (state !== void 0 && state.dayKey === dayKey) total = mergeTodaySpend(total, state.spend);
|
|
724
758
|
}
|
|
725
759
|
}
|
|
@@ -741,7 +775,6 @@ var TodaySpendScanner = class {
|
|
|
741
775
|
}
|
|
742
776
|
await withConcurrency(pending, 8, async ({ id, revision }) => {
|
|
743
777
|
let value;
|
|
744
|
-
const cache = projectionCache?.();
|
|
745
778
|
if (cache !== void 0) try {
|
|
746
779
|
value = (await cache.coldSnapshot(id)).values[BILLING_UNIT_KEY];
|
|
747
780
|
} catch (error) {
|
|
@@ -764,24 +797,34 @@ var TodaySpendScanner = class {
|
|
|
764
797
|
}
|
|
765
798
|
return total;
|
|
766
799
|
}
|
|
767
|
-
/**
|
|
800
|
+
/**
|
|
801
|
+
* Events path: price today's events in a single pass (per-event Beijing-day
|
|
802
|
+
* filter during collection, hard cap), gated by revisions.
|
|
803
|
+
*/
|
|
768
804
|
async scanEvents(dayKey) {
|
|
769
805
|
const { sessions, persistence, maxEvents, logger, billing, catalog } = this.deps;
|
|
770
|
-
const
|
|
806
|
+
const names = new Map(catalog.map((model) => [model.id, model.name]));
|
|
807
|
+
const accumulator = new SpendAccumulator();
|
|
771
808
|
const liveIds = /* @__PURE__ */ new Set();
|
|
809
|
+
let collected = 0;
|
|
772
810
|
let truncated = false;
|
|
811
|
+
const collect = (events) => {
|
|
812
|
+
for (const event of events) {
|
|
813
|
+
if (beijingDayKey(new Date(event.time)) !== dayKey) continue;
|
|
814
|
+
collected += 1;
|
|
815
|
+
if (collected > maxEvents) {
|
|
816
|
+
truncated = true;
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
const priced = priceEvent(event, billing, names);
|
|
820
|
+
if (priced !== void 0) accumulator.add(priced);
|
|
821
|
+
}
|
|
822
|
+
};
|
|
773
823
|
if (sessions !== void 0) {
|
|
774
824
|
const store = sessions();
|
|
775
825
|
if (store !== void 0) for (const session of store.list()) {
|
|
776
826
|
liveIds.add(session.id);
|
|
777
|
-
|
|
778
|
-
if (beijingDayKey(new Date(event.time)) !== dayKey) continue;
|
|
779
|
-
events.push(event);
|
|
780
|
-
if (events.length >= maxEvents) {
|
|
781
|
-
truncated = true;
|
|
782
|
-
break;
|
|
783
|
-
}
|
|
784
|
-
}
|
|
827
|
+
collect(session.events);
|
|
785
828
|
if (truncated) break;
|
|
786
829
|
}
|
|
787
830
|
}
|
|
@@ -792,15 +835,7 @@ var TodaySpendScanner = class {
|
|
|
792
835
|
if (liveIds.has(header.id)) continue;
|
|
793
836
|
if (this.lastEventsScan?.get(header.id) === revision) continue;
|
|
794
837
|
try {
|
|
795
|
-
|
|
796
|
-
for (const event of inspection.events) {
|
|
797
|
-
if (beijingDayKey(new Date(event.time)) !== dayKey) continue;
|
|
798
|
-
events.push(event);
|
|
799
|
-
if (events.length >= maxEvents) {
|
|
800
|
-
truncated = true;
|
|
801
|
-
break;
|
|
802
|
-
}
|
|
803
|
-
}
|
|
838
|
+
collect((await persistenceService.inspect(header.id)).events);
|
|
804
839
|
} catch (error) {
|
|
805
840
|
logger.warn(`llm-billing: skipping unreadable session ${header.id}: ${String(error)}`);
|
|
806
841
|
}
|
|
@@ -809,7 +844,7 @@ var TodaySpendScanner = class {
|
|
|
809
844
|
if (!truncated) this.lastEventsScan = new Map(snapshots.map((snapshot) => [snapshot.header.id, snapshot.revision]));
|
|
810
845
|
}
|
|
811
846
|
if (truncated) logger.warn(`llm-billing: today's events exceeded ${maxEvents}; result truncated`);
|
|
812
|
-
return
|
|
847
|
+
return accumulator.finish();
|
|
813
848
|
}
|
|
814
849
|
};
|
|
815
850
|
//#endregion
|
|
@@ -829,6 +864,11 @@ var TodaySpendScanner = class {
|
|
|
829
864
|
* session-projection registry is composed, the plugin additionally registers
|
|
830
865
|
* the `billingTodaySpend` projection unit, which folds each session's spend
|
|
831
866
|
* eagerly and lets cold reads ride the projection-cache ladder.
|
|
867
|
+
*
|
|
868
|
+
* A per-session spend cache makes the badge's turn-settled recompute
|
|
869
|
+
* incremental: session logs are append-only and chronological (the same
|
|
870
|
+
* assumption the projection unit makes), so the spend is reused while the log
|
|
871
|
+
* length is unchanged, and only the appended tail is priced when it grows.
|
|
832
872
|
* @module @rayadesu/dsh-llm-billing
|
|
833
873
|
*/
|
|
834
874
|
const name = "llm-billing";
|
|
@@ -928,8 +968,26 @@ function apply(ctx, config) {
|
|
|
928
968
|
id: model.id,
|
|
929
969
|
name: model.name ?? model.id
|
|
930
970
|
}));
|
|
971
|
+
const sessionSpendCache = /* @__PURE__ */ new Map();
|
|
931
972
|
const fetchSessionSpend = async (sessionId) => {
|
|
932
|
-
|
|
973
|
+
const events = await sessionEvents(ctx, sessionId);
|
|
974
|
+
const cached = sessionSpendCache.get(sessionId);
|
|
975
|
+
if (cached !== void 0 && cached.count === events.length) return cached.spend;
|
|
976
|
+
if (cached !== void 0 && cached.count < events.length) {
|
|
977
|
+
const spend = mergeTodaySpend(cached.spend, computeSessionSpend(events.slice(cached.count), billing, catalog));
|
|
978
|
+
sessionSpendCache.set(sessionId, {
|
|
979
|
+
count: events.length,
|
|
980
|
+
spend
|
|
981
|
+
});
|
|
982
|
+
return spend;
|
|
983
|
+
}
|
|
984
|
+
const spend = computeSessionSpend(events, billing, catalog);
|
|
985
|
+
if (sessionSpendCache.size >= 1024) sessionSpendCache.clear();
|
|
986
|
+
sessionSpendCache.set(sessionId, {
|
|
987
|
+
count: events.length,
|
|
988
|
+
spend
|
|
989
|
+
});
|
|
990
|
+
return spend;
|
|
933
991
|
};
|
|
934
992
|
const unit = billingTodaySpendDefinition(billing, catalog);
|
|
935
993
|
let unitRegistered = false;
|
|
@@ -961,4 +1019,4 @@ function apply(ctx, config) {
|
|
|
961
1019
|
});
|
|
962
1020
|
}
|
|
963
1021
|
//#endregion
|
|
964
|
-
export { BILLING_UNIT_KEY, Config, DEFAULT_MODEL_PRICING, DEFAULT_PEAK_HOURS, DeepSeekBalanceGateway, PUBLIC_BASE_URL, TODAY_SPEND_CACHE_MS, TODAY_SPEND_MAX_EVENTS, TodaySpendCache, TodaySpendScanner, addEventContribution, apply, beijingDayKey, billingTodaySpendDefinition, computeSessionSpend, computeTodaySpend, emptyTodaySpend, fetchDeepSeekBalance, foldBillingUnit, isPeak, mergeTodaySpend, name, parseDeepSeekBalance, priceEvent, resolveBilling };
|
|
1022
|
+
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, emptyTodaySpend, fetchDeepSeekBalance, foldBillingUnit, isPeak, mergeTodaySpend, name, parseDeepSeekBalance, priceEvent, resolveBilling };
|
package/lib/types/billing.d.ts
CHANGED
|
@@ -4,11 +4,12 @@
|
|
|
4
4
|
* Remote gateway stays transport-free and the whole spend is testable without
|
|
5
5
|
* a key.
|
|
6
6
|
*
|
|
7
|
-
* The per-event pricing lives in {@link priceEvent}
|
|
8
|
-
*
|
|
9
|
-
* {@link computeTodaySpend})
|
|
10
|
-
*
|
|
11
|
-
* pricing-table change cannot drift one path
|
|
7
|
+
* The per-event pricing lives in {@link priceEvent} and the fold in the
|
|
8
|
+
* {@link SpendAccumulator}: the events-scan paths ({@link computeSessionSpend},
|
|
9
|
+
* {@link computeTodaySpend}), the session-projection unit (`billingTodaySpend`
|
|
10
|
+
* in projection.ts), and the scanner's single-pass events path all price
|
|
11
|
+
* through the same primitives, so a pricing-table change cannot drift one path
|
|
12
|
+
* from the others.
|
|
12
13
|
* @module @rayadesu/dsh-llm-billing/billing
|
|
13
14
|
*/
|
|
14
15
|
import type { SessionEvent } from '@deepseek-ai/dsh-session';
|
|
@@ -143,6 +144,22 @@ export interface BillingEventContribution {
|
|
|
143
144
|
export declare function priceEvent(event: SessionEvent, billing: ResolvedBilling, names: ReadonlyMap<string, string>): BillingEventContribution | undefined;
|
|
144
145
|
/** A spend with no priced usage. */
|
|
145
146
|
export declare function emptyTodaySpend(): DeepSeekTodaySpend;
|
|
147
|
+
/**
|
|
148
|
+
* Mutable model-row accumulator behind every spend fold. Rows keep first-seen
|
|
149
|
+
* model order — the same shape a pure `addEventContribution` chain produces —
|
|
150
|
+
* so the single-pass scan path and the pure public paths cannot diverge. One
|
|
151
|
+
* `Map` lookup per contribution instead of a per-event array copy: the huge
|
|
152
|
+
* event-log folds allocate one row object per model, not one intermediate
|
|
153
|
+
* array per event.
|
|
154
|
+
*/
|
|
155
|
+
export declare class SpendAccumulator {
|
|
156
|
+
private readonly rows;
|
|
157
|
+
private total;
|
|
158
|
+
/** Add one priced contribution. */
|
|
159
|
+
add(priced: BillingEventContribution): void;
|
|
160
|
+
/** The folded spend; the accumulator stays usable afterwards. */
|
|
161
|
+
finish(): DeepSeekTodaySpend;
|
|
162
|
+
}
|
|
146
163
|
/**
|
|
147
164
|
* Merge one priced event's contribution into an accumulator spend (pure:
|
|
148
165
|
* returns a new spend, never mutates its input).
|
package/lib/types/billing.js
CHANGED
|
@@ -4,11 +4,12 @@
|
|
|
4
4
|
* Remote gateway stays transport-free and the whole spend is testable without
|
|
5
5
|
* a key.
|
|
6
6
|
*
|
|
7
|
-
* The per-event pricing lives in {@link priceEvent}
|
|
8
|
-
*
|
|
9
|
-
* {@link computeTodaySpend})
|
|
10
|
-
*
|
|
11
|
-
* pricing-table change cannot drift one path
|
|
7
|
+
* The per-event pricing lives in {@link priceEvent} and the fold in the
|
|
8
|
+
* {@link SpendAccumulator}: the events-scan paths ({@link computeSessionSpend},
|
|
9
|
+
* {@link computeTodaySpend}), the session-projection unit (`billingTodaySpend`
|
|
10
|
+
* in projection.ts), and the scanner's single-pass events path all price
|
|
11
|
+
* through the same primitives, so a pricing-table change cannot drift one path
|
|
12
|
+
* from the others.
|
|
12
13
|
* @module @rayadesu/dsh-llm-billing/billing
|
|
13
14
|
*/
|
|
14
15
|
/**
|
|
@@ -61,20 +62,29 @@ export function resolveBilling(config) {
|
|
|
61
62
|
models.set(row.model, { peak: row.peak, offPeak: row.offPeak });
|
|
62
63
|
return { peakHours, models };
|
|
63
64
|
}
|
|
64
|
-
/** The Beijing (Asia/Shanghai, UTC+8, no DST) hour of a timestamp. */
|
|
65
|
-
function beijingHour(now) {
|
|
66
|
-
return new Date(now.getTime() + 8 * 3_600_000).getUTCHours();
|
|
67
|
-
}
|
|
68
65
|
/**
|
|
69
|
-
*
|
|
70
|
-
*
|
|
66
|
+
* Derive the Beijing hour, weekday, and calendar-day key of one timestamp from
|
|
67
|
+
* a single shifted `Date` — every timezone-sensitive read shares this one
|
|
68
|
+
* implementation, so the pieces cannot drift apart.
|
|
69
|
+
* @param time - epoch milliseconds.
|
|
71
70
|
*/
|
|
72
|
-
function
|
|
73
|
-
|
|
71
|
+
function beijingParts(time) {
|
|
72
|
+
const shifted = new Date(time + 8 * 3_600_000);
|
|
73
|
+
return {
|
|
74
|
+
hour: shifted.getUTCHours(),
|
|
75
|
+
weekday: shifted.getUTCDay(),
|
|
76
|
+
dayKey: shifted.toISOString().slice(0, 10),
|
|
77
|
+
};
|
|
74
78
|
}
|
|
75
79
|
/** The Beijing (Asia/Shanghai, UTC+8, no DST) calendar-day key of a timestamp. */
|
|
76
80
|
export function beijingDayKey(now) {
|
|
77
|
-
return
|
|
81
|
+
return beijingParts(now.getTime()).dayKey;
|
|
82
|
+
}
|
|
83
|
+
/** Whether a Beijing (hour, weekday) pair falls inside any peak-hour window. */
|
|
84
|
+
function isPeakParts(billing, hour, weekday) {
|
|
85
|
+
if (weekday === 0 || weekday === 6)
|
|
86
|
+
return false;
|
|
87
|
+
return billing.peakHours.some(({ start, end }) => hour >= start && hour < end);
|
|
78
88
|
}
|
|
79
89
|
/**
|
|
80
90
|
* Whether a timestamp falls inside any peak-hour window (Beijing time,
|
|
@@ -85,11 +95,8 @@ export function beijingDayKey(now) {
|
|
|
85
95
|
* @returns true during a weekday peak hour.
|
|
86
96
|
*/
|
|
87
97
|
export function isPeak(billing, now) {
|
|
88
|
-
const weekday =
|
|
89
|
-
|
|
90
|
-
return false;
|
|
91
|
-
const hour = beijingHour(now);
|
|
92
|
-
return billing.peakHours.some(({ start, end }) => hour >= start && hour < end);
|
|
98
|
+
const { hour, weekday } = beijingParts(now.getTime());
|
|
99
|
+
return isPeakParts(billing, hour, weekday);
|
|
93
100
|
}
|
|
94
101
|
/**
|
|
95
102
|
* Price one event at the official per-model rates, applying the peak/off-peak
|
|
@@ -114,8 +121,8 @@ export function priceEvent(event, billing, names) {
|
|
|
114
121
|
const pricing = billing.models.get(model);
|
|
115
122
|
if (pricing === undefined)
|
|
116
123
|
return undefined;
|
|
117
|
-
const
|
|
118
|
-
const peak =
|
|
124
|
+
const { hour, weekday, dayKey } = beijingParts(event.time);
|
|
125
|
+
const peak = isPeakParts(billing, hour, weekday);
|
|
119
126
|
const price = peak ? pricing.peak : pricing.offPeak;
|
|
120
127
|
const hit = reported.cacheReadTokens ?? 0;
|
|
121
128
|
const miss = reported.inputTokens + (reported.cacheWriteTokens ?? 0);
|
|
@@ -125,7 +132,7 @@ export function priceEvent(event, billing, names) {
|
|
|
125
132
|
const outputCost = (output * price.output) / 1_000_000;
|
|
126
133
|
const cost = hitCost + missCost + outputCost;
|
|
127
134
|
return {
|
|
128
|
-
dayKey
|
|
135
|
+
dayKey,
|
|
129
136
|
model,
|
|
130
137
|
displayName: names.get(model) ?? model,
|
|
131
138
|
cost,
|
|
@@ -159,6 +166,45 @@ function contributionModel(priced) {
|
|
|
159
166
|
outputCost: priced.outputCost,
|
|
160
167
|
};
|
|
161
168
|
}
|
|
169
|
+
/** Sum two model rows of the same model (pure). */
|
|
170
|
+
function mergeModelRows(left, right) {
|
|
171
|
+
return {
|
|
172
|
+
model: left.model,
|
|
173
|
+
displayName: left.displayName,
|
|
174
|
+
cost: left.cost + right.cost,
|
|
175
|
+
peakCost: left.peakCost + right.peakCost,
|
|
176
|
+
offPeakCost: left.offPeakCost + right.offPeakCost,
|
|
177
|
+
cacheHitInputTokens: left.cacheHitInputTokens + right.cacheHitInputTokens,
|
|
178
|
+
cacheMissInputTokens: left.cacheMissInputTokens + right.cacheMissInputTokens,
|
|
179
|
+
outputTokens: left.outputTokens + right.outputTokens,
|
|
180
|
+
cacheHitInputCost: left.cacheHitInputCost + right.cacheHitInputCost,
|
|
181
|
+
cacheMissInputCost: left.cacheMissInputCost + right.cacheMissInputCost,
|
|
182
|
+
outputCost: left.outputCost + right.outputCost,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Mutable model-row accumulator behind every spend fold. Rows keep first-seen
|
|
187
|
+
* model order — the same shape a pure `addEventContribution` chain produces —
|
|
188
|
+
* so the single-pass scan path and the pure public paths cannot diverge. One
|
|
189
|
+
* `Map` lookup per contribution instead of a per-event array copy: the huge
|
|
190
|
+
* event-log folds allocate one row object per model, not one intermediate
|
|
191
|
+
* array per event.
|
|
192
|
+
*/
|
|
193
|
+
export class SpendAccumulator {
|
|
194
|
+
rows = new Map();
|
|
195
|
+
total = 0;
|
|
196
|
+
/** Add one priced contribution. */
|
|
197
|
+
add(priced) {
|
|
198
|
+
const row = contributionModel(priced);
|
|
199
|
+
const existing = this.rows.get(priced.model);
|
|
200
|
+
this.rows.set(priced.model, existing === undefined ? row : mergeModelRows(existing, row));
|
|
201
|
+
this.total += priced.cost;
|
|
202
|
+
}
|
|
203
|
+
/** The folded spend; the accumulator stays usable afterwards. */
|
|
204
|
+
finish() {
|
|
205
|
+
return { total: this.total, models: [...this.rows.values()] };
|
|
206
|
+
}
|
|
207
|
+
}
|
|
162
208
|
/**
|
|
163
209
|
* Merge one priced event's contribution into an accumulator spend (pure:
|
|
164
210
|
* returns a new spend, never mutates its input).
|
|
@@ -167,22 +213,10 @@ function contributionModel(priced) {
|
|
|
167
213
|
* @returns the merged spend.
|
|
168
214
|
*/
|
|
169
215
|
export function addEventContribution(spend, priced) {
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
peakCost: row.peakCost + priced.peakCost,
|
|
175
|
-
offPeakCost: row.offPeakCost + priced.offPeakCost,
|
|
176
|
-
cacheHitInputTokens: row.cacheHitInputTokens + priced.cacheHitInputTokens,
|
|
177
|
-
cacheMissInputTokens: row.cacheMissInputTokens + priced.cacheMissInputTokens,
|
|
178
|
-
outputTokens: row.outputTokens + priced.outputTokens,
|
|
179
|
-
cacheHitInputCost: row.cacheHitInputCost + priced.cacheHitInputCost,
|
|
180
|
-
cacheMissInputCost: row.cacheMissInputCost + priced.cacheMissInputCost,
|
|
181
|
-
outputCost: row.outputCost + priced.outputCost,
|
|
182
|
-
}
|
|
183
|
-
: row);
|
|
184
|
-
if (!rows.some(row => row.model === priced.model))
|
|
185
|
-
rows.push(contributionModel(priced));
|
|
216
|
+
const row = contributionModel(priced);
|
|
217
|
+
const rows = spend.models.map(existing => existing.model === priced.model ? mergeModelRows(existing, row) : existing);
|
|
218
|
+
if (!rows.some(existing => existing.model === priced.model))
|
|
219
|
+
rows.push(row);
|
|
186
220
|
return { total: spend.total + priced.cost, models: rows };
|
|
187
221
|
}
|
|
188
222
|
/**
|
|
@@ -193,24 +227,14 @@ export function addEventContribution(spend, priced) {
|
|
|
193
227
|
* @returns the summed spend.
|
|
194
228
|
*/
|
|
195
229
|
export function mergeTodaySpend(target, source) {
|
|
196
|
-
|
|
230
|
+
const rows = new Map();
|
|
231
|
+
for (const row of target.models)
|
|
232
|
+
rows.set(row.model, row);
|
|
197
233
|
for (const row of source.models) {
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
model: row.model,
|
|
201
|
-
displayName: row.displayName,
|
|
202
|
-
cost: row.cost,
|
|
203
|
-
peakCost: row.peakCost,
|
|
204
|
-
offPeakCost: row.offPeakCost,
|
|
205
|
-
cacheHitInputTokens: row.cacheHitInputTokens,
|
|
206
|
-
cacheMissInputTokens: row.cacheMissInputTokens,
|
|
207
|
-
outputTokens: row.outputTokens,
|
|
208
|
-
cacheHitInputCost: row.cacheHitInputCost,
|
|
209
|
-
cacheMissInputCost: row.cacheMissInputCost,
|
|
210
|
-
outputCost: row.outputCost,
|
|
211
|
-
});
|
|
234
|
+
const existing = rows.get(row.model);
|
|
235
|
+
rows.set(row.model, existing === undefined ? row : mergeModelRows(existing, row));
|
|
212
236
|
}
|
|
213
|
-
return
|
|
237
|
+
return { total: target.total + source.total, models: [...rows.values()] };
|
|
214
238
|
}
|
|
215
239
|
/**
|
|
216
240
|
* Price a set of billed events at the official per-model rates, applying the
|
|
@@ -223,19 +247,19 @@ export function mergeTodaySpend(target, source) {
|
|
|
223
247
|
* published table prices only the two V4 rows).
|
|
224
248
|
* @param events - the events to price.
|
|
225
249
|
* @param billing - resolved pricing with peak-hour windows.
|
|
226
|
-
* @param
|
|
250
|
+
* @param names - model id → display label.
|
|
251
|
+
* @param dayKey - when provided, only events on this Beijing calendar day contribute.
|
|
227
252
|
* @returns the total cost plus one row per priced model.
|
|
228
253
|
*/
|
|
229
|
-
function priceEvents(events, billing,
|
|
230
|
-
const
|
|
231
|
-
let spend = { total: 0, models: [] };
|
|
254
|
+
function priceEvents(events, billing, names, dayKey) {
|
|
255
|
+
const accumulator = new SpendAccumulator();
|
|
232
256
|
for (const event of events) {
|
|
233
257
|
const priced = priceEvent(event, billing, names);
|
|
234
|
-
if (priced === undefined)
|
|
258
|
+
if (priced === undefined || (dayKey !== undefined && priced.dayKey !== dayKey))
|
|
235
259
|
continue;
|
|
236
|
-
|
|
260
|
+
accumulator.add(priced);
|
|
237
261
|
}
|
|
238
|
-
return
|
|
262
|
+
return accumulator.finish();
|
|
239
263
|
}
|
|
240
264
|
/**
|
|
241
265
|
* Price one session's complete event log at the official per-model rates.
|
|
@@ -245,7 +269,8 @@ function priceEvents(events, billing, catalog) {
|
|
|
245
269
|
* @returns the session's total cost plus one row per priced model.
|
|
246
270
|
*/
|
|
247
271
|
export function computeSessionSpend(events, billing, catalog) {
|
|
248
|
-
|
|
272
|
+
const names = new Map(catalog.map(model => [model.id, model.name]));
|
|
273
|
+
return priceEvents(events, billing, names);
|
|
249
274
|
}
|
|
250
275
|
/**
|
|
251
276
|
* Price every event whose Beijing-time calendar day is the day of `now`,
|
|
@@ -260,13 +285,6 @@ export function computeSessionSpend(events, billing, catalog) {
|
|
|
260
285
|
export function computeTodaySpend(events, billing, catalog, now = new Date()) {
|
|
261
286
|
const day = beijingDayKey(now);
|
|
262
287
|
const names = new Map(catalog.map(model => [model.id, model.name]));
|
|
263
|
-
|
|
264
|
-
for (const event of events) {
|
|
265
|
-
const priced = priceEvent(event, billing, names);
|
|
266
|
-
if (priced === undefined || priced.dayKey !== day)
|
|
267
|
-
continue;
|
|
268
|
-
spend = addEventContribution(spend, priced);
|
|
269
|
-
}
|
|
270
|
-
return spend;
|
|
288
|
+
return priceEvents(events, billing, names, day);
|
|
271
289
|
}
|
|
272
290
|
//# sourceMappingURL=billing.js.map
|
package/lib/types/index.d.ts
CHANGED
|
@@ -13,13 +13,18 @@
|
|
|
13
13
|
* session-projection registry is composed, the plugin additionally registers
|
|
14
14
|
* the `billingTodaySpend` projection unit, which folds each session's spend
|
|
15
15
|
* eagerly and lets cold reads ride the projection-cache ladder.
|
|
16
|
+
*
|
|
17
|
+
* A per-session spend cache makes the badge's turn-settled recompute
|
|
18
|
+
* incremental: session logs are append-only and chronological (the same
|
|
19
|
+
* assumption the projection unit makes), so the spend is reused while the log
|
|
20
|
+
* length is unchanged, and only the appended tail is priced when it grows.
|
|
16
21
|
* @module @rayadesu/dsh-llm-billing
|
|
17
22
|
*/
|
|
18
23
|
import type { Context } from '@deepseek-ai/cordis';
|
|
19
24
|
import z from '@deepseek-ai/schemastery';
|
|
20
25
|
import type { BillingConfig } from './billing.ts';
|
|
21
26
|
export { DeepSeekBalanceGateway, fetchDeepSeekBalance, parseDeepSeekBalance } from './balance.ts';
|
|
22
|
-
export { addEventContribution, beijingDayKey, computeSessionSpend, computeTodaySpend, DEFAULT_MODEL_PRICING, DEFAULT_PEAK_HOURS, emptyTodaySpend, isPeak, mergeTodaySpend, priceEvent, resolveBilling, } from './billing.ts';
|
|
27
|
+
export { addEventContribution, beijingDayKey, computeSessionSpend, computeTodaySpend, DEFAULT_MODEL_PRICING, DEFAULT_PEAK_HOURS, emptyTodaySpend, isPeak, mergeTodaySpend, priceEvent, resolveBilling, SpendAccumulator, } from './billing.ts';
|
|
23
28
|
export type { BillingConfig, BillingConfigModel, BillingEventContribution, DeepSeekModelPricing, DeepSeekTokenPrice, PeakHourWindow, ResolvedBilling, } from './billing.ts';
|
|
24
29
|
export type * from './types.ts';
|
|
25
30
|
export { BILLING_UNIT_KEY, billingTodaySpendDefinition, foldBillingUnit } from './projection.ts';
|
package/lib/types/index.js
CHANGED
|
@@ -13,6 +13,11 @@
|
|
|
13
13
|
* session-projection registry is composed, the plugin additionally registers
|
|
14
14
|
* the `billingTodaySpend` projection unit, which folds each session's spend
|
|
15
15
|
* eagerly and lets cold reads ride the projection-cache ladder.
|
|
16
|
+
*
|
|
17
|
+
* A per-session spend cache makes the badge's turn-settled recompute
|
|
18
|
+
* incremental: session logs are append-only and chronological (the same
|
|
19
|
+
* assumption the projection unit makes), so the spend is reused while the log
|
|
20
|
+
* length is unchanged, and only the appended tail is priced when it grows.
|
|
16
21
|
* @module @rayadesu/dsh-llm-billing
|
|
17
22
|
*/
|
|
18
23
|
import z from '@deepseek-ai/schemastery';
|
|
@@ -20,11 +25,11 @@ import { assertUsableApiKey, LlmError } from '@deepseek-ai/dsh-llm';
|
|
|
20
25
|
import { credentialRef } from '@deepseek-ai/dsh-credentials';
|
|
21
26
|
import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment';
|
|
22
27
|
import { DeepSeekBalanceGateway, fetchDeepSeekBalance } from "./balance.js";
|
|
23
|
-
import { computeSessionSpend, DEFAULT_MODEL_PRICING, DEFAULT_PEAK_HOURS, resolveBilling, } from "./billing.js";
|
|
28
|
+
import { computeSessionSpend, DEFAULT_MODEL_PRICING, DEFAULT_PEAK_HOURS, mergeTodaySpend, resolveBilling, } from "./billing.js";
|
|
24
29
|
import { billingTodaySpendDefinition } from "./projection.js";
|
|
25
30
|
import { TodaySpendCache, TodaySpendScanner } from "./today-spend.js";
|
|
26
31
|
export { DeepSeekBalanceGateway, fetchDeepSeekBalance, parseDeepSeekBalance } from "./balance.js";
|
|
27
|
-
export { addEventContribution, beijingDayKey, computeSessionSpend, computeTodaySpend, DEFAULT_MODEL_PRICING, DEFAULT_PEAK_HOURS, emptyTodaySpend, isPeak, mergeTodaySpend, priceEvent, resolveBilling, } from "./billing.js";
|
|
32
|
+
export { addEventContribution, beijingDayKey, computeSessionSpend, computeTodaySpend, DEFAULT_MODEL_PRICING, DEFAULT_PEAK_HOURS, emptyTodaySpend, isPeak, mergeTodaySpend, priceEvent, resolveBilling, SpendAccumulator, } from "./billing.js";
|
|
28
33
|
export { BILLING_UNIT_KEY, billingTodaySpendDefinition, foldBillingUnit } from "./projection.js";
|
|
29
34
|
export { TodaySpendCache, TodaySpendScanner } from "./today-spend.js";
|
|
30
35
|
export const name = 'llm-billing';
|
|
@@ -123,8 +128,29 @@ export function apply(ctx, config) {
|
|
|
123
128
|
};
|
|
124
129
|
const billing = resolveBilling(config.billing);
|
|
125
130
|
const catalog = (config.models ?? DEFAULT_MODELS).map(model => ({ id: model.id, name: model.name ?? model.id }));
|
|
131
|
+
// Per-session incremental spend cache: a session log is append-only and
|
|
132
|
+
// chronological (the same assumption the projection unit makes), so a spend
|
|
133
|
+
// computed for `count` events stays valid while the log length is unchanged,
|
|
134
|
+
// and only the appended tail needs pricing when it grows. A pricing-table
|
|
135
|
+
// change does not retroactively reprice (same caveat as the projection
|
|
136
|
+
// path); the map is capped so an unbounded session-id space cannot grow it
|
|
137
|
+
// without bound.
|
|
138
|
+
const sessionSpendCache = new Map();
|
|
126
139
|
const fetchSessionSpend = async (sessionId) => {
|
|
127
|
-
|
|
140
|
+
const events = await sessionEvents(ctx, sessionId);
|
|
141
|
+
const cached = sessionSpendCache.get(sessionId);
|
|
142
|
+
if (cached !== undefined && cached.count === events.length)
|
|
143
|
+
return cached.spend;
|
|
144
|
+
if (cached !== undefined && cached.count < events.length) {
|
|
145
|
+
const spend = mergeTodaySpend(cached.spend, computeSessionSpend(events.slice(cached.count), billing, catalog));
|
|
146
|
+
sessionSpendCache.set(sessionId, { count: events.length, spend });
|
|
147
|
+
return spend;
|
|
148
|
+
}
|
|
149
|
+
const spend = computeSessionSpend(events, billing, catalog);
|
|
150
|
+
if (sessionSpendCache.size >= 1024)
|
|
151
|
+
sessionSpendCache.clear();
|
|
152
|
+
sessionSpendCache.set(sessionId, { count: events.length, spend });
|
|
153
|
+
return spend;
|
|
128
154
|
};
|
|
129
155
|
// Plan C: register the per-session spend projection unit on the projection
|
|
130
156
|
// registry. Registration is lazy — it happens on the first projection-path
|
|
@@ -9,9 +9,10 @@
|
|
|
9
9
|
* with write-back) or, without the cache service, one detached local fold
|
|
10
10
|
* over a full `inspect`. Persisted revisions gate every cold read, so a
|
|
11
11
|
* session whose log did not change since the last resolution costs nothing.
|
|
12
|
-
* - events path (plans A2/A3): collect only today's events
|
|
13
|
-
* Beijing-day filter during collection) with a hard cap,
|
|
14
|
-
* whose persisted revision is unchanged since the last
|
|
12
|
+
* - events path (plans A2/A3): collect and price only today's events in one
|
|
13
|
+
* pass (per-event Beijing-day filter during collection) with a hard cap,
|
|
14
|
+
* skipping sessions whose persisted revision is unchanged since the last
|
|
15
|
+
* scan.
|
|
15
16
|
*
|
|
16
17
|
* Both strategies run behind the same {@link TodaySpendCache}, so a miss
|
|
17
18
|
* happens at most once per 60 seconds per process, and a manual refresh
|
|
@@ -132,7 +133,10 @@ export declare class TodaySpendScanner {
|
|
|
132
133
|
scan(dayKey: string): Promise<DeepSeekTodaySpend>;
|
|
133
134
|
/** Projection path: eager cells for live sessions, revision-gated cold ladder for the rest. */
|
|
134
135
|
private scanProjections;
|
|
135
|
-
/**
|
|
136
|
+
/**
|
|
137
|
+
* Events path: price today's events in a single pass (per-event Beijing-day
|
|
138
|
+
* filter during collection, hard cap), gated by revisions.
|
|
139
|
+
*/
|
|
136
140
|
private scanEvents;
|
|
137
141
|
}
|
|
138
142
|
//# sourceMappingURL=today-spend.d.ts.map
|
package/lib/types/today-spend.js
CHANGED
|
@@ -9,9 +9,10 @@
|
|
|
9
9
|
* with write-back) or, without the cache service, one detached local fold
|
|
10
10
|
* over a full `inspect`. Persisted revisions gate every cold read, so a
|
|
11
11
|
* session whose log did not change since the last resolution costs nothing.
|
|
12
|
-
* - events path (plans A2/A3): collect only today's events
|
|
13
|
-
* Beijing-day filter during collection) with a hard cap,
|
|
14
|
-
* whose persisted revision is unchanged since the last
|
|
12
|
+
* - events path (plans A2/A3): collect and price only today's events in one
|
|
13
|
+
* pass (per-event Beijing-day filter during collection) with a hard cap,
|
|
14
|
+
* skipping sessions whose persisted revision is unchanged since the last
|
|
15
|
+
* scan.
|
|
15
16
|
*
|
|
16
17
|
* Both strategies run behind the same {@link TodaySpendCache}, so a miss
|
|
17
18
|
* happens at most once per 60 seconds per process, and a manual refresh
|
|
@@ -19,14 +20,21 @@
|
|
|
19
20
|
* unchanged log provably cannot change the aggregate.
|
|
20
21
|
* @module @rayadesu/dsh-llm-billing/today-spend
|
|
21
22
|
*/
|
|
22
|
-
import { beijingDayKey,
|
|
23
|
+
import { beijingDayKey, emptyTodaySpend, mergeTodaySpend, priceEvent, SpendAccumulator } from "./billing.js";
|
|
23
24
|
import { BILLING_UNIT_KEY, foldBillingUnit } from "./projection.js";
|
|
24
|
-
/**
|
|
25
|
+
/**
|
|
26
|
+
* Bounded parallel fan-out: run `run` over `items` with at most `limit` in
|
|
27
|
+
* flight. A shared index counter hands each worker its next job, so the
|
|
28
|
+
* dispatch is O(n) overall (array `shift()` would be O(n) per pop).
|
|
29
|
+
*/
|
|
25
30
|
async function withConcurrency(items, limit, run) {
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
31
|
+
const total = items.length;
|
|
32
|
+
let next = 0;
|
|
33
|
+
await Promise.all(Array.from({ length: Math.min(limit, total) }, async () => {
|
|
34
|
+
for (let job = next; job < total; job = next) {
|
|
35
|
+
next += 1;
|
|
36
|
+
await run(items[job]);
|
|
37
|
+
}
|
|
30
38
|
}));
|
|
31
39
|
}
|
|
32
40
|
/**
|
|
@@ -114,6 +122,9 @@ export class TodaySpendScanner {
|
|
|
114
122
|
/** Projection path: eager cells for live sessions, revision-gated cold ladder for the rest. */
|
|
115
123
|
async scanProjections(dayKey) {
|
|
116
124
|
const { sessions, persistence, projections, projectionCache, unit, logger } = this.deps;
|
|
125
|
+
// Services resolve once per scan, not per session / per cold task.
|
|
126
|
+
const projectionsService = projections?.();
|
|
127
|
+
const cache = projectionCache?.();
|
|
117
128
|
let total = emptyTodaySpend();
|
|
118
129
|
const liveIds = new Set();
|
|
119
130
|
if (sessions !== undefined) {
|
|
@@ -121,7 +132,7 @@ export class TodaySpendScanner {
|
|
|
121
132
|
if (store !== undefined) {
|
|
122
133
|
for (const session of store.list()) {
|
|
123
134
|
liveIds.add(session.id);
|
|
124
|
-
const state =
|
|
135
|
+
const state = projectionsService?.stateOf(session, BILLING_UNIT_KEY);
|
|
125
136
|
if (state !== undefined && state.dayKey === dayKey) {
|
|
126
137
|
total = mergeTodaySpend(total, state.spend);
|
|
127
138
|
}
|
|
@@ -146,7 +157,6 @@ export class TodaySpendScanner {
|
|
|
146
157
|
}
|
|
147
158
|
await withConcurrency(pending, 8, async ({ id, revision }) => {
|
|
148
159
|
let value;
|
|
149
|
-
const cache = projectionCache?.();
|
|
150
160
|
if (cache !== undefined) {
|
|
151
161
|
try {
|
|
152
162
|
value = (await cache.coldSnapshot(id)).values[BILLING_UNIT_KEY];
|
|
@@ -176,26 +186,37 @@ export class TodaySpendScanner {
|
|
|
176
186
|
}
|
|
177
187
|
return total;
|
|
178
188
|
}
|
|
179
|
-
/**
|
|
189
|
+
/**
|
|
190
|
+
* Events path: price today's events in a single pass (per-event Beijing-day
|
|
191
|
+
* filter during collection, hard cap), gated by revisions.
|
|
192
|
+
*/
|
|
180
193
|
async scanEvents(dayKey) {
|
|
181
194
|
const { sessions, persistence, maxEvents, logger, billing, catalog } = this.deps;
|
|
182
|
-
const
|
|
195
|
+
const names = new Map(catalog.map(model => [model.id, model.name]));
|
|
196
|
+
const accumulator = new SpendAccumulator();
|
|
183
197
|
const liveIds = new Set();
|
|
198
|
+
let collected = 0;
|
|
184
199
|
let truncated = false;
|
|
200
|
+
const collect = (events) => {
|
|
201
|
+
for (const event of events) {
|
|
202
|
+
if (beijingDayKey(new Date(event.time)) !== dayKey)
|
|
203
|
+
continue;
|
|
204
|
+
collected += 1;
|
|
205
|
+
if (collected > maxEvents) {
|
|
206
|
+
truncated = true;
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
const priced = priceEvent(event, billing, names);
|
|
210
|
+
if (priced !== undefined)
|
|
211
|
+
accumulator.add(priced);
|
|
212
|
+
}
|
|
213
|
+
};
|
|
185
214
|
if (sessions !== undefined) {
|
|
186
215
|
const store = sessions();
|
|
187
216
|
if (store !== undefined) {
|
|
188
217
|
for (const session of store.list()) {
|
|
189
218
|
liveIds.add(session.id);
|
|
190
|
-
|
|
191
|
-
if (beijingDayKey(new Date(event.time)) !== dayKey)
|
|
192
|
-
continue;
|
|
193
|
-
events.push(event);
|
|
194
|
-
if (events.length >= maxEvents) {
|
|
195
|
-
truncated = true;
|
|
196
|
-
break;
|
|
197
|
-
}
|
|
198
|
-
}
|
|
219
|
+
collect(session.events);
|
|
199
220
|
if (truncated)
|
|
200
221
|
break;
|
|
201
222
|
}
|
|
@@ -210,16 +231,7 @@ export class TodaySpendScanner {
|
|
|
210
231
|
if (this.lastEventsScan?.get(header.id) === revision)
|
|
211
232
|
continue;
|
|
212
233
|
try {
|
|
213
|
-
|
|
214
|
-
for (const event of inspection.events) {
|
|
215
|
-
if (beijingDayKey(new Date(event.time)) !== dayKey)
|
|
216
|
-
continue;
|
|
217
|
-
events.push(event);
|
|
218
|
-
if (events.length >= maxEvents) {
|
|
219
|
-
truncated = true;
|
|
220
|
-
break;
|
|
221
|
-
}
|
|
222
|
-
}
|
|
234
|
+
collect((await persistenceService.inspect(header.id)).events);
|
|
223
235
|
}
|
|
224
236
|
catch (error) {
|
|
225
237
|
// One unreadable session must not blank the whole-day aggregate.
|
|
@@ -237,10 +249,7 @@ export class TodaySpendScanner {
|
|
|
237
249
|
}
|
|
238
250
|
if (truncated)
|
|
239
251
|
logger.warn(`llm-billing: today's events exceeded ${maxEvents}; result truncated`);
|
|
240
|
-
|
|
241
|
-
// date is 08:00 Beijing the same day), so the pricing re-check cannot
|
|
242
|
-
// drift from the collection filter across a Beijing-day boundary.
|
|
243
|
-
return computeTodaySpend(events, billing, catalog, new Date(`${dayKey}T00:00:00Z`));
|
|
252
|
+
return accumulator.finish();
|
|
244
253
|
}
|
|
245
254
|
}
|
|
246
255
|
//# sourceMappingURL=today-spend.js.map
|
package/package.json
CHANGED