@rayadesu/dsh-llm-billing 0.1.0 → 0.2.0
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 +14 -2
- package/README.zh.md +14 -2
- package/lib/index.js +490 -108
- package/lib/invariant.js +1 -1
- package/lib/typert.host.js +23 -11
- package/lib/typert.remote-client.d.ts +3 -3
- package/lib/typert.remote-client.js +23 -11
- package/lib/types/balance.d.ts +10 -3
- package/lib/types/balance.js +5 -2
- package/lib/types/billing.d.ts +72 -0
- package/lib/types/billing.js +142 -57
- package/lib/types/index.d.ts +19 -2
- package/lib/types/index.js +62 -52
- package/lib/types/projection.d.ts +69 -0
- package/lib/types/projection.js +88 -0
- package/lib/types/today-spend.d.ts +138 -0
- package/lib/types/today-spend.js +246 -0
- package/package.json +3 -1
package/lib/index.js
CHANGED
|
@@ -3,7 +3,8 @@ import { LlmError, assertUsableApiKey } from "@deepseek-ai/dsh-llm";
|
|
|
3
3
|
import { credentialRef } from "@deepseek-ai/dsh-credentials";
|
|
4
4
|
import { launchEnvironmentOf } from "@deepseek-ai/dsh-launch-environment";
|
|
5
5
|
import { Remote, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
|
|
6
|
-
|
|
6
|
+
import { z as z$1 } from "zod";
|
|
7
|
+
//#region lib/types/balance.js
|
|
7
8
|
/**
|
|
8
9
|
* DeepSeek account-balance capability: the `GET /user/balance` transport and
|
|
9
10
|
* the Remote gateway that exposes one snapshot to trusted clients. The fetch
|
|
@@ -207,20 +208,29 @@ let DeepSeekBalanceGateway = (() => {
|
|
|
207
208
|
/**
|
|
208
209
|
* Read today's billed spend across every session, priced per event by its
|
|
209
210
|
* Beijing-time calendar day, hour, and weekday (weekends are always off-peak).
|
|
211
|
+
* @param force - bypass the host-side 60s cache (manual refresh); omitted
|
|
212
|
+
* means a cached read. Remote parameters cannot carry default values, so
|
|
213
|
+
* the thunk receives `undefined` for an omitted argument.
|
|
210
214
|
* @returns today's total cost plus one row per priced model.
|
|
211
215
|
*/
|
|
212
|
-
getTodaySpend() {
|
|
213
|
-
return this.options.fetchTodaySpend();
|
|
216
|
+
getTodaySpend(force) {
|
|
217
|
+
return this.options.fetchTodaySpend(force ?? false);
|
|
214
218
|
}
|
|
215
219
|
};
|
|
216
220
|
})();
|
|
217
221
|
//#endregion
|
|
218
|
-
//#region
|
|
222
|
+
//#region lib/types/billing.js
|
|
219
223
|
/**
|
|
220
224
|
* DeepSeek billing: the peak/off-peak pricing table and the per-session spend
|
|
221
225
|
* pricing. Pure functions over session events and the pricing table, so the
|
|
222
226
|
* Remote gateway stays transport-free and the whole spend is testable without
|
|
223
227
|
* a key.
|
|
228
|
+
*
|
|
229
|
+
* The per-event pricing lives in {@link priceEvent}, the one shared fold
|
|
230
|
+
* primitive: the events-scan paths ({@link computeSessionSpend},
|
|
231
|
+
* {@link computeTodaySpend}) and the session-projection unit
|
|
232
|
+
* (`billingTodaySpend` in projection.ts) all fold the same contribution, so a
|
|
233
|
+
* pricing-table change cannot drift one path from the others.
|
|
224
234
|
* @module @rayadesu/dsh-llm-billing/billing
|
|
225
235
|
*/
|
|
226
236
|
/**
|
|
@@ -329,6 +339,125 @@ function isPeak(billing, now) {
|
|
|
329
339
|
return billing.peakHours.some(({ start, end }) => hour >= start && hour < end);
|
|
330
340
|
}
|
|
331
341
|
/**
|
|
342
|
+
* Price one event at the official per-model rates, applying the peak/off-peak
|
|
343
|
+
* table by its Beijing-time hour and weekday (peak windows apply Monday–Friday
|
|
344
|
+
* only; weekends are off-peak). Each `assistant/message` event with usage
|
|
345
|
+
* contributes cache-hit input, cache-miss input (uncached input plus cache
|
|
346
|
+
* writes), and output (reasoning included) tokens at the rate of its own
|
|
347
|
+
* timestamp; a model with usage but no pricing row contributes nothing (the
|
|
348
|
+
* published table prices only the two V4 rows).
|
|
349
|
+
* @param event - the event to price.
|
|
350
|
+
* @param billing - resolved pricing with peak-hour windows.
|
|
351
|
+
* @param names - model id → display label.
|
|
352
|
+
* @returns the priced contribution, or `undefined` when the event has no priced usage.
|
|
353
|
+
*/
|
|
354
|
+
function priceEvent(event, billing, names) {
|
|
355
|
+
if (event.type !== "assistant/message") return void 0;
|
|
356
|
+
const reported = event.data.usage;
|
|
357
|
+
if (reported === void 0) return void 0;
|
|
358
|
+
const model = event.data.message.source.model;
|
|
359
|
+
const pricing = billing.models.get(model);
|
|
360
|
+
if (pricing === void 0) return void 0;
|
|
361
|
+
const time = new Date(event.time);
|
|
362
|
+
const peak = isPeak(billing, time);
|
|
363
|
+
const price = peak ? pricing.peak : pricing.offPeak;
|
|
364
|
+
const hit = reported.cacheReadTokens ?? 0;
|
|
365
|
+
const miss = reported.inputTokens + (reported.cacheWriteTokens ?? 0);
|
|
366
|
+
const output = reported.outputTokens;
|
|
367
|
+
const hitCost = hit * price.cacheHitInput / 1e6;
|
|
368
|
+
const missCost = miss * price.cacheMissInput / 1e6;
|
|
369
|
+
const outputCost = output * price.output / 1e6;
|
|
370
|
+
const cost = hitCost + missCost + outputCost;
|
|
371
|
+
return {
|
|
372
|
+
dayKey: beijingDayKey(time),
|
|
373
|
+
model,
|
|
374
|
+
displayName: names.get(model) ?? model,
|
|
375
|
+
cost,
|
|
376
|
+
peakCost: peak ? cost : 0,
|
|
377
|
+
offPeakCost: peak ? 0 : cost,
|
|
378
|
+
cacheHitInputTokens: hit,
|
|
379
|
+
cacheMissInputTokens: miss,
|
|
380
|
+
outputTokens: output,
|
|
381
|
+
cacheHitInputCost: hitCost,
|
|
382
|
+
cacheMissInputCost: missCost,
|
|
383
|
+
outputCost
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
/** A spend with no priced usage. */
|
|
387
|
+
function emptyTodaySpend() {
|
|
388
|
+
return {
|
|
389
|
+
total: 0,
|
|
390
|
+
models: []
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
/** The today-spend shape of a single priced contribution. */
|
|
394
|
+
function contributionModel(priced) {
|
|
395
|
+
return {
|
|
396
|
+
model: priced.model,
|
|
397
|
+
displayName: priced.displayName,
|
|
398
|
+
cost: priced.cost,
|
|
399
|
+
peakCost: priced.peakCost,
|
|
400
|
+
offPeakCost: priced.offPeakCost,
|
|
401
|
+
cacheHitInputTokens: priced.cacheHitInputTokens,
|
|
402
|
+
cacheMissInputTokens: priced.cacheMissInputTokens,
|
|
403
|
+
outputTokens: priced.outputTokens,
|
|
404
|
+
cacheHitInputCost: priced.cacheHitInputCost,
|
|
405
|
+
cacheMissInputCost: priced.cacheMissInputCost,
|
|
406
|
+
outputCost: priced.outputCost
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* Merge one priced event's contribution into an accumulator spend (pure:
|
|
411
|
+
* returns a new spend, never mutates its input).
|
|
412
|
+
* @param spend - the accumulator (per session and day, or across sessions).
|
|
413
|
+
* @param priced - the priced contribution to add.
|
|
414
|
+
* @returns the merged spend.
|
|
415
|
+
*/
|
|
416
|
+
function addEventContribution(spend, priced) {
|
|
417
|
+
const rows = spend.models.map((row) => row.model === priced.model ? {
|
|
418
|
+
...row,
|
|
419
|
+
cost: row.cost + priced.cost,
|
|
420
|
+
peakCost: row.peakCost + priced.peakCost,
|
|
421
|
+
offPeakCost: row.offPeakCost + priced.offPeakCost,
|
|
422
|
+
cacheHitInputTokens: row.cacheHitInputTokens + priced.cacheHitInputTokens,
|
|
423
|
+
cacheMissInputTokens: row.cacheMissInputTokens + priced.cacheMissInputTokens,
|
|
424
|
+
outputTokens: row.outputTokens + priced.outputTokens,
|
|
425
|
+
cacheHitInputCost: row.cacheHitInputCost + priced.cacheHitInputCost,
|
|
426
|
+
cacheMissInputCost: row.cacheMissInputCost + priced.cacheMissInputCost,
|
|
427
|
+
outputCost: row.outputCost + priced.outputCost
|
|
428
|
+
} : row);
|
|
429
|
+
if (!rows.some((row) => row.model === priced.model)) rows.push(contributionModel(priced));
|
|
430
|
+
return {
|
|
431
|
+
total: spend.total + priced.cost,
|
|
432
|
+
models: rows
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* Sum two spends (per session and day, or across sessions) into one (pure:
|
|
437
|
+
* returns a new spend, never mutates its inputs).
|
|
438
|
+
* @param target - the accumulator spend.
|
|
439
|
+
* @param source - the spend to add.
|
|
440
|
+
* @returns the summed spend.
|
|
441
|
+
*/
|
|
442
|
+
function mergeTodaySpend(target, source) {
|
|
443
|
+
let merged = target;
|
|
444
|
+
for (const row of source.models) merged = addEventContribution(merged, {
|
|
445
|
+
dayKey: "",
|
|
446
|
+
model: row.model,
|
|
447
|
+
displayName: row.displayName,
|
|
448
|
+
cost: row.cost,
|
|
449
|
+
peakCost: row.peakCost,
|
|
450
|
+
offPeakCost: row.offPeakCost,
|
|
451
|
+
cacheHitInputTokens: row.cacheHitInputTokens,
|
|
452
|
+
cacheMissInputTokens: row.cacheMissInputTokens,
|
|
453
|
+
outputTokens: row.outputTokens,
|
|
454
|
+
cacheHitInputCost: row.cacheHitInputCost,
|
|
455
|
+
cacheMissInputCost: row.cacheMissInputCost,
|
|
456
|
+
outputCost: row.outputCost
|
|
457
|
+
});
|
|
458
|
+
return merged;
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
332
461
|
* Price a set of billed events at the official per-model rates, applying the
|
|
333
462
|
* peak/off-peak table per event by its Beijing-time hour and weekday (peak
|
|
334
463
|
* windows apply Monday–Friday only; weekends are off-peak). Each
|
|
@@ -344,65 +473,16 @@ function isPeak(billing, now) {
|
|
|
344
473
|
*/
|
|
345
474
|
function priceEvents(events, billing, catalog) {
|
|
346
475
|
const names = new Map(catalog.map((model) => [model.id, model.name]));
|
|
347
|
-
|
|
476
|
+
let spend = {
|
|
477
|
+
total: 0,
|
|
478
|
+
models: []
|
|
479
|
+
};
|
|
348
480
|
for (const event of events) {
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
const model = event.data.message.source.model;
|
|
353
|
-
const pricing = billing.models.get(model);
|
|
354
|
-
if (pricing === void 0) continue;
|
|
355
|
-
const peak = isPeak(billing, new Date(event.time));
|
|
356
|
-
const price = peak ? pricing.peak : pricing.offPeak;
|
|
357
|
-
const hit = reported.cacheReadTokens ?? 0;
|
|
358
|
-
const miss = reported.inputTokens + (reported.cacheWriteTokens ?? 0);
|
|
359
|
-
const output = reported.outputTokens;
|
|
360
|
-
const hitCost = hit * price.cacheHitInput / 1e6;
|
|
361
|
-
const missCost = miss * price.cacheMissInput / 1e6;
|
|
362
|
-
const outputCost = output * price.output / 1e6;
|
|
363
|
-
const cost = hitCost + missCost + outputCost;
|
|
364
|
-
let row = rows.get(model);
|
|
365
|
-
if (row === void 0) {
|
|
366
|
-
row = {
|
|
367
|
-
cacheHitInputTokens: 0,
|
|
368
|
-
cacheMissInputTokens: 0,
|
|
369
|
-
outputTokens: 0,
|
|
370
|
-
cost: 0,
|
|
371
|
-
peakCost: 0,
|
|
372
|
-
offPeakCost: 0,
|
|
373
|
-
cacheHitInputCost: 0,
|
|
374
|
-
cacheMissInputCost: 0,
|
|
375
|
-
outputCost: 0
|
|
376
|
-
};
|
|
377
|
-
rows.set(model, row);
|
|
378
|
-
}
|
|
379
|
-
row.cacheHitInputTokens += hit;
|
|
380
|
-
row.cacheMissInputTokens += miss;
|
|
381
|
-
row.outputTokens += output;
|
|
382
|
-
row.cost += cost;
|
|
383
|
-
row.cacheHitInputCost += hitCost;
|
|
384
|
-
row.cacheMissInputCost += missCost;
|
|
385
|
-
row.outputCost += outputCost;
|
|
386
|
-
if (peak) row.peakCost += cost;
|
|
387
|
-
else row.offPeakCost += cost;
|
|
481
|
+
const priced = priceEvent(event, billing, names);
|
|
482
|
+
if (priced === void 0) continue;
|
|
483
|
+
spend = addEventContribution(spend, priced);
|
|
388
484
|
}
|
|
389
|
-
|
|
390
|
-
model,
|
|
391
|
-
displayName: names.get(model) ?? model,
|
|
392
|
-
cost: row.cost,
|
|
393
|
-
peakCost: row.peakCost,
|
|
394
|
-
offPeakCost: row.offPeakCost,
|
|
395
|
-
cacheHitInputTokens: row.cacheHitInputTokens,
|
|
396
|
-
cacheMissInputTokens: row.cacheMissInputTokens,
|
|
397
|
-
outputTokens: row.outputTokens,
|
|
398
|
-
cacheHitInputCost: row.cacheHitInputCost,
|
|
399
|
-
cacheMissInputCost: row.cacheMissInputCost,
|
|
400
|
-
outputCost: row.outputCost
|
|
401
|
-
}));
|
|
402
|
-
return {
|
|
403
|
-
total: models.reduce((sum, model) => sum + model.cost, 0),
|
|
404
|
-
models
|
|
405
|
-
};
|
|
485
|
+
return spend;
|
|
406
486
|
}
|
|
407
487
|
/**
|
|
408
488
|
* Price one session's complete event log at the official per-model rates.
|
|
@@ -426,16 +506,326 @@ function computeSessionSpend(events, billing, catalog) {
|
|
|
426
506
|
*/
|
|
427
507
|
function computeTodaySpend(events, billing, catalog, now = /* @__PURE__ */ new Date()) {
|
|
428
508
|
const day = beijingDayKey(now);
|
|
429
|
-
|
|
509
|
+
const names = new Map(catalog.map((model) => [model.id, model.name]));
|
|
510
|
+
let spend = emptyTodaySpend();
|
|
511
|
+
for (const event of events) {
|
|
512
|
+
const priced = priceEvent(event, billing, names);
|
|
513
|
+
if (priced === void 0 || priced.dayKey !== day) continue;
|
|
514
|
+
spend = addEventContribution(spend, priced);
|
|
515
|
+
}
|
|
516
|
+
return spend;
|
|
430
517
|
}
|
|
431
518
|
//#endregion
|
|
432
|
-
//#region
|
|
519
|
+
//#region lib/types/projection.js
|
|
520
|
+
/**
|
|
521
|
+
* `billingTodaySpend` session-projection unit: per-session, per-Beijing-day
|
|
522
|
+
* billed spend, folded eagerly by the DSH projection drive over committed
|
|
523
|
+
* session events and checkpointed by the projection cache. The unit keeps only
|
|
524
|
+
* the spend of the session's LATEST priced day (events are append-only and
|
|
525
|
+
* chronological, so a day strictly older than the state's day never returns);
|
|
526
|
+
* the aggregate "today" read sums the units whose `dayKey` matches the current
|
|
527
|
+
* Beijing day — zero full-log scans once the fold is warm.
|
|
528
|
+
*
|
|
529
|
+
* The unit's fold shares {@link priceEvent} with the events-scan paths
|
|
530
|
+
* (`computeTodaySpend`), so both price with the same table. The unit is
|
|
531
|
+
* client-visible (`wire` = identity) because the persisted-cache read ladder
|
|
532
|
+
* (`sessionProjectionCache.coldSnapshot` / registry `restore`) serves only
|
|
533
|
+
* wired units; the wire value is the state itself.
|
|
534
|
+
* @module @rayadesu/dsh-llm-billing/projection
|
|
535
|
+
*/
|
|
536
|
+
/** The projection key this unit owns. */
|
|
537
|
+
const BILLING_UNIT_KEY = "billingTodaySpend";
|
|
538
|
+
const modelRowSchema = z$1.object({
|
|
539
|
+
model: z$1.string(),
|
|
540
|
+
displayName: z$1.string(),
|
|
541
|
+
cost: z$1.number().nonnegative(),
|
|
542
|
+
peakCost: z$1.number().nonnegative(),
|
|
543
|
+
offPeakCost: z$1.number().nonnegative(),
|
|
544
|
+
cacheHitInputTokens: z$1.number().int().nonnegative(),
|
|
545
|
+
cacheMissInputTokens: z$1.number().int().nonnegative(),
|
|
546
|
+
outputTokens: z$1.number().int().nonnegative(),
|
|
547
|
+
cacheHitInputCost: z$1.number().nonnegative(),
|
|
548
|
+
cacheMissInputCost: z$1.number().nonnegative(),
|
|
549
|
+
outputCost: z$1.number().nonnegative()
|
|
550
|
+
}).strict();
|
|
551
|
+
const todaySpendSchema = z$1.object({
|
|
552
|
+
total: z$1.number().nonnegative(),
|
|
553
|
+
models: z$1.array(modelRowSchema)
|
|
554
|
+
}).strict();
|
|
555
|
+
const billingUnitSchema = z$1.object({
|
|
556
|
+
dayKey: z$1.string(),
|
|
557
|
+
spend: todaySpendSchema
|
|
558
|
+
}).strict();
|
|
559
|
+
/**
|
|
560
|
+
* Build the `billingTodaySpend` unit for one resolved pricing table. The
|
|
561
|
+
* pricing closure is fixed at registration; a pricing-table change therefore
|
|
562
|
+
* prices only events folded after the change (historical spend keeps its
|
|
563
|
+
* historical rates), unlike the events-scan paths which re-price the whole
|
|
564
|
+
* log. Bump {@link ProjectionDefinition.stateVersion} whenever the state
|
|
565
|
+
* shape or fold semantics change, so persisted checkpoint rows are discarded
|
|
566
|
+
* instead of folded forward.
|
|
567
|
+
* @param billing - resolved pricing with peak-hour windows.
|
|
568
|
+
* @param catalog - model display rows, in presentation order.
|
|
569
|
+
* @returns the unit definition to register on `ctx.sessionProjections`.
|
|
570
|
+
*/
|
|
571
|
+
function billingTodaySpendDefinition(billing, catalog) {
|
|
572
|
+
const names = new Map(catalog.map((model) => [model.id, model.name]));
|
|
573
|
+
return {
|
|
574
|
+
key: BILLING_UNIT_KEY,
|
|
575
|
+
stateVersion: 1,
|
|
576
|
+
stateSchema: billingUnitSchema,
|
|
577
|
+
init: () => ({
|
|
578
|
+
dayKey: "",
|
|
579
|
+
spend: emptyTodaySpend()
|
|
580
|
+
}),
|
|
581
|
+
apply: (state, event) => {
|
|
582
|
+
const priced = priceEvent(event, billing, names);
|
|
583
|
+
if (priced === void 0) return state;
|
|
584
|
+
if (state.dayKey === priced.dayKey) return {
|
|
585
|
+
dayKey: state.dayKey,
|
|
586
|
+
spend: addEventContribution(state.spend, priced)
|
|
587
|
+
};
|
|
588
|
+
if (state.dayKey !== "" && priced.dayKey < state.dayKey) return state;
|
|
589
|
+
return {
|
|
590
|
+
dayKey: priced.dayKey,
|
|
591
|
+
spend: addEventContribution(emptyTodaySpend(), priced)
|
|
592
|
+
};
|
|
593
|
+
},
|
|
594
|
+
wire: {
|
|
595
|
+
viewSchema: billingUnitSchema,
|
|
596
|
+
view: (state) => state
|
|
597
|
+
}
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
/** Fold a unit from init over one session's event log (the detached cold recipe). */
|
|
601
|
+
function foldBillingUnit(unit, events) {
|
|
602
|
+
let state = unit.init();
|
|
603
|
+
for (const event of events) state = unit.apply(state, event);
|
|
604
|
+
return state;
|
|
605
|
+
}
|
|
606
|
+
//#endregion
|
|
607
|
+
//#region lib/types/today-spend.js
|
|
608
|
+
/**
|
|
609
|
+
* Today-spend read path: the 60-second Beijing-day cache with in-flight
|
|
610
|
+
* coalescing and a force bypass (plan A1), plus the two scan strategies that
|
|
611
|
+
* compute the aggregate behind a cache miss:
|
|
612
|
+
*
|
|
613
|
+
* - projection path (plan C): live sessions read their eagerly folded
|
|
614
|
+
* `billingTodaySpend` projection cell; cold sessions resolve through the
|
|
615
|
+
* projection-cache ladder (cached row + tail replay + registry restore,
|
|
616
|
+
* with write-back) or, without the cache service, one detached local fold
|
|
617
|
+
* over a full `inspect`. Persisted revisions gate every cold read, so a
|
|
618
|
+
* session whose log did not change since the last resolution costs nothing.
|
|
619
|
+
* - events path (plans A2/A3): collect only today's events (per-event
|
|
620
|
+
* Beijing-day filter during collection) with a hard cap, skipping sessions
|
|
621
|
+
* whose persisted revision is unchanged since the last scan.
|
|
622
|
+
*
|
|
623
|
+
* Both strategies run behind the same {@link TodaySpendCache}, so a miss
|
|
624
|
+
* happens at most once per 60 seconds per process, and a manual refresh
|
|
625
|
+
* (`force`) bypasses the time window but keeps the revision caches — an
|
|
626
|
+
* unchanged log provably cannot change the aggregate.
|
|
627
|
+
* @module @rayadesu/dsh-llm-billing/today-spend
|
|
628
|
+
*/
|
|
629
|
+
/** Bounded parallel fan-out: run `run` over `items` with at most `limit` in flight. */
|
|
630
|
+
async function withConcurrency(items, limit, run) {
|
|
631
|
+
const queue = [...items];
|
|
632
|
+
await Promise.all(Array.from({ length: Math.min(limit, queue.length) }, async () => {
|
|
633
|
+
for (let job = queue.shift(); job !== void 0; job = queue.shift()) await run(job);
|
|
634
|
+
}));
|
|
635
|
+
}
|
|
636
|
+
/**
|
|
637
|
+
* The A1 cache: one Beijing-day key + a 60s window, an in-flight promise that
|
|
638
|
+
* coalesces concurrent misses, and a `force` bypass for the manual refresh
|
|
639
|
+
* path. Cross-day invalidation is automatic (the day key changes); a failed
|
|
640
|
+
* scan leaves the previous value in place and retries on the next call.
|
|
641
|
+
*/
|
|
642
|
+
var TodaySpendCache = class {
|
|
643
|
+
scan;
|
|
644
|
+
ttlMs;
|
|
645
|
+
now;
|
|
646
|
+
cachedDayKey;
|
|
647
|
+
cachedValue;
|
|
648
|
+
cachedAt = 0;
|
|
649
|
+
inFlight;
|
|
650
|
+
/**
|
|
651
|
+
* @param ttlMs - time window in milliseconds (default 60 000).
|
|
652
|
+
* @param now - clock source (injectable for tests).
|
|
653
|
+
* @param scan - the aggregate computation behind a miss.
|
|
654
|
+
*/
|
|
655
|
+
constructor(scan, ttlMs = 6e4, now = () => /* @__PURE__ */ new Date()) {
|
|
656
|
+
this.scan = scan;
|
|
657
|
+
this.ttlMs = ttlMs;
|
|
658
|
+
this.now = now;
|
|
659
|
+
}
|
|
660
|
+
/**
|
|
661
|
+
* Read today's spend, cached per Beijing day within the TTL window.
|
|
662
|
+
* @param force - bypass the time window (manual refresh); the day-key gate
|
|
663
|
+
* and the in-flight coalescing still apply to non-force callers.
|
|
664
|
+
* @returns today's spend.
|
|
665
|
+
*/
|
|
666
|
+
get(force = false) {
|
|
667
|
+
const now = this.now();
|
|
668
|
+
const dayKey = beijingDayKey(now);
|
|
669
|
+
if (!force && this.cachedDayKey === dayKey && this.cachedValue !== void 0 && now.getTime() - this.cachedAt < this.ttlMs) return Promise.resolve(this.cachedValue);
|
|
670
|
+
if (!force && this.inFlight !== void 0) return this.inFlight;
|
|
671
|
+
const run = (async () => {
|
|
672
|
+
try {
|
|
673
|
+
const value = await this.scan(dayKey);
|
|
674
|
+
this.cachedDayKey = dayKey;
|
|
675
|
+
this.cachedValue = value;
|
|
676
|
+
this.cachedAt = now.getTime();
|
|
677
|
+
return value;
|
|
678
|
+
} finally {
|
|
679
|
+
this.inFlight = void 0;
|
|
680
|
+
}
|
|
681
|
+
})();
|
|
682
|
+
if (!force) this.inFlight = run;
|
|
683
|
+
return run;
|
|
684
|
+
}
|
|
685
|
+
};
|
|
686
|
+
/**
|
|
687
|
+
* The aggregate computation behind a cache miss. Chooses the projection path
|
|
688
|
+
* when the projection registry is composed, the events path otherwise; both
|
|
689
|
+
* gate cold reads on persisted revisions so steady-state scans touch only
|
|
690
|
+
* sessions whose logs actually changed.
|
|
691
|
+
*/
|
|
692
|
+
var TodaySpendScanner = class {
|
|
693
|
+
deps;
|
|
694
|
+
/** Cold sessions resolved on the projection path: id → revision + unit state. */
|
|
695
|
+
coldResolved = /* @__PURE__ */ new Map();
|
|
696
|
+
/** Cold sessions resolved on the events path: id → revision (events were collected). */
|
|
697
|
+
lastEventsScan;
|
|
698
|
+
constructor(deps) {
|
|
699
|
+
this.deps = deps;
|
|
700
|
+
}
|
|
701
|
+
/**
|
|
702
|
+
* Compute today's aggregate for one Beijing day.
|
|
703
|
+
* @param dayKey - the Beijing-time calendar-day key to aggregate.
|
|
704
|
+
* @returns today's spend across every session.
|
|
705
|
+
*/
|
|
706
|
+
async scan(dayKey) {
|
|
707
|
+
if (this.deps.projections?.() === void 0) return this.scanEvents(dayKey);
|
|
708
|
+
this.deps.ensureUnit?.();
|
|
709
|
+
return this.scanProjections(dayKey);
|
|
710
|
+
}
|
|
711
|
+
/** Projection path: eager cells for live sessions, revision-gated cold ladder for the rest. */
|
|
712
|
+
async scanProjections(dayKey) {
|
|
713
|
+
const { sessions, persistence, projections, projectionCache, unit, logger } = this.deps;
|
|
714
|
+
let total = emptyTodaySpend();
|
|
715
|
+
const liveIds = /* @__PURE__ */ new Set();
|
|
716
|
+
if (sessions !== void 0) {
|
|
717
|
+
const store = sessions();
|
|
718
|
+
if (store !== void 0) for (const session of store.list()) {
|
|
719
|
+
liveIds.add(session.id);
|
|
720
|
+
const state = projections?.()?.stateOf(session, BILLING_UNIT_KEY);
|
|
721
|
+
if (state !== void 0 && state.dayKey === dayKey) total = mergeTodaySpend(total, state.spend);
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
const persistenceService = persistence?.();
|
|
725
|
+
if (persistenceService === void 0) return total;
|
|
726
|
+
const snapshots = await persistenceService.listSnapshots();
|
|
727
|
+
const pending = [];
|
|
728
|
+
for (const { header, revision } of snapshots) {
|
|
729
|
+
if (liveIds.has(header.id)) continue;
|
|
730
|
+
const resolved = this.coldResolved.get(header.id);
|
|
731
|
+
if (resolved !== void 0 && resolved.revision === revision) {
|
|
732
|
+
if (resolved.value.dayKey === dayKey) total = mergeTodaySpend(total, resolved.value.spend);
|
|
733
|
+
continue;
|
|
734
|
+
}
|
|
735
|
+
pending.push({
|
|
736
|
+
id: header.id,
|
|
737
|
+
revision
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
await withConcurrency(pending, 8, async ({ id, revision }) => {
|
|
741
|
+
let value;
|
|
742
|
+
const cache = projectionCache?.();
|
|
743
|
+
if (cache !== void 0) try {
|
|
744
|
+
value = (await cache.coldSnapshot(id)).values[BILLING_UNIT_KEY];
|
|
745
|
+
} catch (error) {
|
|
746
|
+
logger.warn(`llm-billing: projection cold read for session ${id} failed: ${String(error)}`);
|
|
747
|
+
}
|
|
748
|
+
if (value === void 0) try {
|
|
749
|
+
value = foldBillingUnit(unit, (await persistenceService.inspect(id)).events);
|
|
750
|
+
} catch (error) {
|
|
751
|
+
logger.warn(`llm-billing: skipping unreadable session ${id}: ${String(error)}`);
|
|
752
|
+
}
|
|
753
|
+
if (value !== void 0) this.coldResolved.set(id, {
|
|
754
|
+
revision,
|
|
755
|
+
value
|
|
756
|
+
});
|
|
757
|
+
});
|
|
758
|
+
for (const { id } of pending) {
|
|
759
|
+
const resolved = this.coldResolved.get(id);
|
|
760
|
+
if (resolved !== void 0 && resolved.value.dayKey === dayKey) total = mergeTodaySpend(total, resolved.value.spend);
|
|
761
|
+
}
|
|
762
|
+
return total;
|
|
763
|
+
}
|
|
764
|
+
/** Events path: collect only today's events (capped), gated by revisions. */
|
|
765
|
+
async scanEvents(dayKey) {
|
|
766
|
+
const { sessions, persistence, maxEvents, logger, billing, catalog } = this.deps;
|
|
767
|
+
const events = [];
|
|
768
|
+
const liveIds = /* @__PURE__ */ new Set();
|
|
769
|
+
let truncated = false;
|
|
770
|
+
if (sessions !== void 0) {
|
|
771
|
+
const store = sessions();
|
|
772
|
+
if (store !== void 0) for (const session of store.list()) {
|
|
773
|
+
liveIds.add(session.id);
|
|
774
|
+
for (const event of session.events) {
|
|
775
|
+
if (beijingDayKey(new Date(event.time)) !== dayKey) continue;
|
|
776
|
+
events.push(event);
|
|
777
|
+
if (events.length >= maxEvents) {
|
|
778
|
+
truncated = true;
|
|
779
|
+
break;
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
if (truncated) break;
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
const persistenceService = persistence?.();
|
|
786
|
+
if (!truncated && persistenceService !== void 0) {
|
|
787
|
+
const snapshots = await persistenceService.listSnapshots();
|
|
788
|
+
for (const { header, revision } of snapshots) {
|
|
789
|
+
if (liveIds.has(header.id)) continue;
|
|
790
|
+
if (this.lastEventsScan?.get(header.id) === revision) continue;
|
|
791
|
+
try {
|
|
792
|
+
const inspection = await persistenceService.inspect(header.id);
|
|
793
|
+
for (const event of inspection.events) {
|
|
794
|
+
if (beijingDayKey(new Date(event.time)) !== dayKey) continue;
|
|
795
|
+
events.push(event);
|
|
796
|
+
if (events.length >= maxEvents) {
|
|
797
|
+
truncated = true;
|
|
798
|
+
break;
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
} catch (error) {
|
|
802
|
+
logger.warn(`llm-billing: skipping unreadable session ${header.id}: ${String(error)}`);
|
|
803
|
+
}
|
|
804
|
+
if (truncated) break;
|
|
805
|
+
}
|
|
806
|
+
if (!truncated) this.lastEventsScan = new Map(snapshots.map((snapshot) => [snapshot.header.id, snapshot.revision]));
|
|
807
|
+
}
|
|
808
|
+
if (truncated) logger.warn(`llm-billing: today's events exceeded ${maxEvents}; result truncated`);
|
|
809
|
+
return computeTodaySpend(events, billing, catalog, /* @__PURE__ */ new Date(`${dayKey}T00:00:00Z`));
|
|
810
|
+
}
|
|
811
|
+
};
|
|
812
|
+
//#endregion
|
|
813
|
+
//#region lib/types/index.js
|
|
433
814
|
/**
|
|
434
815
|
* DeepSeek account balance and session-spend provider, as a standalone host
|
|
435
816
|
* plugin. It resolves the DeepSeek endpoint and API key from its own config and
|
|
436
817
|
* the credential/environment seams, prices each session's billed usage with the
|
|
437
818
|
* peak/off-peak table, and exposes the `billing` Remote (`getBalance`, the
|
|
438
819
|
* per-session `getSessionSpend`, and the all-sessions `getTodaySpend`).
|
|
820
|
+
*
|
|
821
|
+
* Today's spend never scans every session log per request: a 60-second
|
|
822
|
+
* Beijing-day cache with in-flight coalescing serves the message-triggered
|
|
823
|
+
* reads, the manual refresh may bypass the time window (`force`), and the
|
|
824
|
+
* computation behind a miss reads only sessions whose persisted revision
|
|
825
|
+
* changed since the last resolution (see today-spend.ts). When the
|
|
826
|
+
* session-projection registry is composed, the plugin additionally registers
|
|
827
|
+
* the `billingTodaySpend` projection unit, which folds each session's spend
|
|
828
|
+
* eagerly and lets cold reads ride the projection-cache ladder.
|
|
439
829
|
* @module @rayadesu/dsh-llm-billing
|
|
440
830
|
*/
|
|
441
831
|
const name = "llm-billing";
|
|
@@ -483,9 +873,14 @@ const Config = z.object({
|
|
|
483
873
|
models: z.array(billingModel).default(DEFAULT_MODELS),
|
|
484
874
|
billing: billingConfig
|
|
485
875
|
});
|
|
876
|
+
/** How often a Beijing-day "today spend" value may be recomputed (60s). */
|
|
877
|
+
const TODAY_SPEND_CACHE_MS = 6e4;
|
|
878
|
+
/** Hard cap on today's events collected by the events scan path. */
|
|
879
|
+
const TODAY_SPEND_MAX_EVENTS = 2e5;
|
|
486
880
|
/**
|
|
487
881
|
* Read one session's event log: the live SessionStore first, then the
|
|
488
|
-
* persistence backend for a flushed session
|
|
882
|
+
* persistence backend for a flushed session (inspected directly by id — no
|
|
883
|
+
* header listing).
|
|
489
884
|
* @param ctx - plugin context carrying the SessionStore and optional persistence.
|
|
490
885
|
* @param sessionId - the session to read.
|
|
491
886
|
* @returns the session's complete event log.
|
|
@@ -495,42 +890,14 @@ async function sessionEvents(ctx, sessionId) {
|
|
|
495
890
|
const live = ctx.get("sessions")?.get(sessionId);
|
|
496
891
|
if (live !== void 0) return live.events;
|
|
497
892
|
const persistence = ctx.get("sessionPersistence");
|
|
498
|
-
if (persistence !== void 0)
|
|
499
|
-
if (header.id !== sessionId) continue;
|
|
893
|
+
if (persistence !== void 0) try {
|
|
500
894
|
return (await persistence.inspect(sessionId)).events;
|
|
895
|
+
} catch (error) {
|
|
896
|
+
throw new LlmError(`llm-billing: session ${sessionId} not found`, "NOT_FOUND", { cause: error });
|
|
501
897
|
}
|
|
502
898
|
throw new LlmError(`llm-billing: session ${sessionId} not found`, "NOT_FOUND");
|
|
503
899
|
}
|
|
504
900
|
/**
|
|
505
|
-
* Read every session's event log, concatenated: each live SessionStore
|
|
506
|
-
* session first (its log may hold events not yet flushed), then each persisted
|
|
507
|
-
* session that is not live, so no event is counted twice. Events are appended
|
|
508
|
-
* one at a time: spreading a very large log into `push(...)` exceeds the
|
|
509
|
-
* engine's argument limit and throws a stack RangeError.
|
|
510
|
-
* @param ctx - plugin context carrying the SessionStore and optional persistence.
|
|
511
|
-
* @returns every session's complete event log, concatenated.
|
|
512
|
-
*/
|
|
513
|
-
async function allSessionEvents(ctx) {
|
|
514
|
-
const events = [];
|
|
515
|
-
const sessions = ctx.get("sessions");
|
|
516
|
-
const liveIds = /* @__PURE__ */ new Set();
|
|
517
|
-
if (sessions !== void 0) for (const session of sessions.list()) {
|
|
518
|
-
liveIds.add(session.id);
|
|
519
|
-
for (const event of session.events) events.push(event);
|
|
520
|
-
}
|
|
521
|
-
const persistence = ctx.get("sessionPersistence");
|
|
522
|
-
if (persistence !== void 0) for (const header of await persistence.list()) {
|
|
523
|
-
if (liveIds.has(header.id)) continue;
|
|
524
|
-
try {
|
|
525
|
-
const inspection = await persistence.inspect(header.id);
|
|
526
|
-
for (const event of inspection.events) events.push(event);
|
|
527
|
-
} catch (error) {
|
|
528
|
-
ctx.logger.warn(`llm-billing: skipping unreadable session ${header.id}: ${String(error)}`);
|
|
529
|
-
}
|
|
530
|
-
}
|
|
531
|
-
return events;
|
|
532
|
-
}
|
|
533
|
-
/**
|
|
534
901
|
* Register the `billing` Remote under the `billing` namespace.
|
|
535
902
|
* @param ctx - owning plugin context.
|
|
536
903
|
* @param config - validated plugin config.
|
|
@@ -553,22 +920,37 @@ function apply(ctx, config) {
|
|
|
553
920
|
const apiKey = await resolveApiKey();
|
|
554
921
|
return fetchDeepSeekBalance(baseURL(), apiKey);
|
|
555
922
|
};
|
|
923
|
+
const billing = resolveBilling(config.billing);
|
|
924
|
+
const catalog = (config.models ?? DEFAULT_MODELS).map((model) => ({
|
|
925
|
+
id: model.id,
|
|
926
|
+
name: model.name ?? model.id
|
|
927
|
+
}));
|
|
556
928
|
const fetchSessionSpend = async (sessionId) => {
|
|
557
|
-
const billing = resolveBilling(config.billing);
|
|
558
|
-
const catalog = (config.models ?? DEFAULT_MODELS).map((model) => ({
|
|
559
|
-
id: model.id,
|
|
560
|
-
name: model.name ?? model.id
|
|
561
|
-
}));
|
|
562
929
|
return computeSessionSpend(await sessionEvents(ctx, sessionId), billing, catalog);
|
|
563
930
|
};
|
|
564
|
-
const
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
931
|
+
const unit = billingTodaySpendDefinition(billing, catalog);
|
|
932
|
+
let unitRegistered = false;
|
|
933
|
+
const ensureUnit = () => {
|
|
934
|
+
if (unitRegistered) return;
|
|
935
|
+
const registry = ctx.get("sessionProjections");
|
|
936
|
+
if (registry === void 0) return;
|
|
937
|
+
registry.register(unit);
|
|
938
|
+
unitRegistered = true;
|
|
571
939
|
};
|
|
940
|
+
const scanner = new TodaySpendScanner({
|
|
941
|
+
sessions: () => ctx.get("sessions"),
|
|
942
|
+
persistence: () => ctx.get("sessionPersistence"),
|
|
943
|
+
projections: () => ctx.get("sessionProjections"),
|
|
944
|
+
projectionCache: () => ctx.get("sessionProjectionCache"),
|
|
945
|
+
ensureUnit,
|
|
946
|
+
unit,
|
|
947
|
+
maxEvents: TODAY_SPEND_MAX_EVENTS,
|
|
948
|
+
logger: ctx.logger,
|
|
949
|
+
billing,
|
|
950
|
+
catalog
|
|
951
|
+
});
|
|
952
|
+
const todayCache = new TodaySpendCache((dayKey) => scanner.scan(dayKey), TODAY_SPEND_CACHE_MS);
|
|
953
|
+
const fetchTodaySpend = async (force = false) => todayCache.get(force);
|
|
572
954
|
new DeepSeekBalanceGateway(ctx, {
|
|
573
955
|
fetchBalance,
|
|
574
956
|
fetchSessionSpend,
|
|
@@ -576,4 +958,4 @@ function apply(ctx, config) {
|
|
|
576
958
|
});
|
|
577
959
|
}
|
|
578
960
|
//#endregion
|
|
579
|
-
export { Config, DEFAULT_MODEL_PRICING, DEFAULT_PEAK_HOURS, DeepSeekBalanceGateway, PUBLIC_BASE_URL, apply, computeSessionSpend, computeTodaySpend, fetchDeepSeekBalance, isPeak, name, parseDeepSeekBalance, resolveBilling };
|
|
961
|
+
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 };
|
package/lib/invariant.js
CHANGED