@rayadesu/dsh-llm-billing 0.3.8 → 0.3.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.i18n.yaml +2 -2
- package/README.md +15 -11
- package/README.zh.md +15 -11
- package/lib/index.js +890 -382
- 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 +217 -21
- package/lib/types/billing.js +434 -71
- package/lib/types/index.d.ts +14 -4
- package/lib/types/index.js +211 -70
- package/lib/types/projection.d.ts +24 -25
- package/lib/types/projection.js +30 -31
- package/lib/types/today-spend.d.ts +138 -62
- package/lib/types/today-spend.js +209 -261
- package/lib/types/types.d.ts +30 -5
- package/lib/types/types.js +8 -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
|
|
@@ -312,6 +337,19 @@ const DEFAULT_MODEL_PRICING = [
|
|
|
312
337
|
output: 4.5
|
|
313
338
|
}
|
|
314
339
|
},
|
|
340
|
+
{
|
|
341
|
+
model: "deepseek-v4.1-flash-expires-on-0910",
|
|
342
|
+
peak: {
|
|
343
|
+
cacheHitInput: .1,
|
|
344
|
+
cacheMissInput: 3,
|
|
345
|
+
output: 9
|
|
346
|
+
},
|
|
347
|
+
offPeak: {
|
|
348
|
+
cacheHitInput: .05,
|
|
349
|
+
cacheMissInput: 1.5,
|
|
350
|
+
output: 4.5
|
|
351
|
+
}
|
|
352
|
+
},
|
|
315
353
|
{
|
|
316
354
|
model: "deepseek-v4-pro",
|
|
317
355
|
peak: {
|
|
@@ -387,23 +425,61 @@ function resolveBilling(config) {
|
|
|
387
425
|
models
|
|
388
426
|
};
|
|
389
427
|
}
|
|
428
|
+
/** Beijing is a fixed UTC+8 offset with no DST. */
|
|
429
|
+
const BEIJING_OFFSET_MS = 288e5;
|
|
430
|
+
/** Milliseconds in one day. */
|
|
431
|
+
const DAY_MS = 864e5;
|
|
432
|
+
/** Epoch day of 1970-01-01 in the civil-date algorithm below. */
|
|
433
|
+
const CIVIL_EPOCH_DAY = 719468;
|
|
434
|
+
/** Two-digit zero pad for a calendar field. */
|
|
435
|
+
function pad2(value) {
|
|
436
|
+
return value < 10 ? `0${value}` : String(value);
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* Civil date of an epoch day (Howard Hinnant's days-from-civil inverse):
|
|
440
|
+
* pure integer arithmetic, no `Date` allocation and no ISO-string slicing.
|
|
441
|
+
*/
|
|
442
|
+
function civilDateOf(epochDay) {
|
|
443
|
+
const shifted = epochDay + CIVIL_EPOCH_DAY;
|
|
444
|
+
const era = Math.floor(shifted / 146097);
|
|
445
|
+
const dayOfEra = shifted - era * 146097;
|
|
446
|
+
const yearOfEra = Math.floor((dayOfEra - Math.floor(dayOfEra / 1460) + Math.floor(dayOfEra / 36524) - Math.floor(dayOfEra / 146096)) / 365);
|
|
447
|
+
const year = yearOfEra + era * 400;
|
|
448
|
+
const dayOfYear = dayOfEra - (365 * yearOfEra + Math.floor(yearOfEra / 4) - Math.floor(yearOfEra / 100));
|
|
449
|
+
const monthPrime = Math.floor((5 * dayOfYear + 2) / 153);
|
|
450
|
+
const month = monthPrime + (monthPrime < 10 ? 3 : -9);
|
|
451
|
+
return {
|
|
452
|
+
year: month <= 2 ? year + 1 : year,
|
|
453
|
+
month,
|
|
454
|
+
day: dayOfYear - Math.floor((153 * monthPrime + 2) / 5) + 1
|
|
455
|
+
};
|
|
456
|
+
}
|
|
390
457
|
/**
|
|
391
|
-
* Derive the Beijing hour, weekday, and calendar-day key of one timestamp
|
|
392
|
-
*
|
|
393
|
-
* implementation, so the pieces cannot drift apart.
|
|
458
|
+
* Derive the Beijing hour, weekday, and calendar-day key of one timestamp with
|
|
459
|
+
* pure integer arithmetic — every timezone-sensitive read shares this one
|
|
460
|
+
* implementation, so the pieces cannot drift apart. Callers that filter by
|
|
461
|
+
* day and then price the same event reuse the returned view, so each event is
|
|
462
|
+
* parsed exactly once. (The hot fold path runs this per committed event; the
|
|
463
|
+
* previous `Date` + `toISOString().slice()` version allocated a `Date` and a
|
|
464
|
+
* 24-character string per call.)
|
|
394
465
|
* @param time - epoch milliseconds.
|
|
466
|
+
* @throws {RangeError} when `time` is not a finite number.
|
|
395
467
|
*/
|
|
396
|
-
function
|
|
397
|
-
|
|
468
|
+
function beijingPartsOf(time) {
|
|
469
|
+
if (!Number.isFinite(time)) throw new RangeError(`billing: event time is not finite (${String(time)})`);
|
|
470
|
+
const shifted = time + BEIJING_OFFSET_MS;
|
|
471
|
+
const epochDay = Math.floor(shifted / DAY_MS);
|
|
472
|
+
const msOfDay = shifted - epochDay * DAY_MS;
|
|
473
|
+
const civil = civilDateOf(epochDay);
|
|
398
474
|
return {
|
|
399
|
-
hour:
|
|
400
|
-
weekday:
|
|
401
|
-
dayKey:
|
|
475
|
+
hour: Math.floor(msOfDay / 36e5),
|
|
476
|
+
weekday: ((epochDay + 4) % 7 + 7) % 7,
|
|
477
|
+
dayKey: `${civil.year}-${pad2(civil.month)}-${pad2(civil.day)}`
|
|
402
478
|
};
|
|
403
479
|
}
|
|
404
480
|
/** The Beijing (Asia/Shanghai, UTC+8, no DST) calendar-day key of a timestamp. */
|
|
405
481
|
function beijingDayKey(now) {
|
|
406
|
-
return
|
|
482
|
+
return beijingPartsOf(now.getTime()).dayKey;
|
|
407
483
|
}
|
|
408
484
|
/**
|
|
409
485
|
* The durable inherited-prefix boundary of one session: the number of leading
|
|
@@ -443,7 +519,7 @@ function isPeakParts(billing, hour, weekday) {
|
|
|
443
519
|
* @returns true during a weekday peak hour.
|
|
444
520
|
*/
|
|
445
521
|
function isPeak(billing, now) {
|
|
446
|
-
const { hour, weekday } =
|
|
522
|
+
const { hour, weekday } = beijingPartsOf(now.getTime());
|
|
447
523
|
return isPeakParts(billing, hour, weekday);
|
|
448
524
|
}
|
|
449
525
|
/**
|
|
@@ -452,32 +528,57 @@ function isPeak(billing, now) {
|
|
|
452
528
|
* only; weekends are off-peak). Each `assistant/message` event with usage
|
|
453
529
|
* contributes cache-hit input, cache-miss input (uncached input plus cache
|
|
454
530
|
* 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).
|
|
531
|
+
* timestamp; a model with usage but no pricing row contributes nothing.
|
|
457
532
|
* @param event - the event to price.
|
|
458
533
|
* @param billing - resolved pricing with peak-hour windows.
|
|
459
534
|
* @param names - model id → display label.
|
|
460
535
|
* @returns the priced contribution, or `undefined` when the event has no priced usage.
|
|
461
536
|
*/
|
|
462
537
|
function priceEvent(event, billing, names) {
|
|
538
|
+
return priceEventAt(beijingPartsOf(event.time), event, billing, names);
|
|
539
|
+
}
|
|
540
|
+
/**
|
|
541
|
+
* Price one event at the official per-model rates using a precomputed
|
|
542
|
+
* Beijing-time view — the day-filtering and pricing of one event share a
|
|
543
|
+
* single timezone parse (see {@link beijingPartsOf}). Semantics are identical
|
|
544
|
+
* to {@link priceEvent}.
|
|
545
|
+
* @param parts - the event's Beijing-time view.
|
|
546
|
+
* @param event - the event to price.
|
|
547
|
+
* @param billing - resolved pricing with peak-hour windows.
|
|
548
|
+
* @param names - model id → display label.
|
|
549
|
+
* @returns the priced contribution, or `undefined` when the event has no priced usage.
|
|
550
|
+
*/
|
|
551
|
+
function priceEventAt(parts, event, billing, names) {
|
|
463
552
|
if (event.type !== "assistant/message") return void 0;
|
|
464
553
|
const reported = event.data.usage;
|
|
465
554
|
if (reported === void 0) return void 0;
|
|
466
|
-
|
|
555
|
+
return priceUsage(parts, reported, event.data.message.source.model, billing, names);
|
|
556
|
+
}
|
|
557
|
+
/**
|
|
558
|
+
* Price one provider-reported usage sample for one model at the rates of the
|
|
559
|
+
* sample's own Beijing-time hour and weekday. `undefined` when the model has
|
|
560
|
+
* no pricing row.
|
|
561
|
+
* @param parts - the sample's Beijing-time view.
|
|
562
|
+
* @param usage - the reported token buckets.
|
|
563
|
+
* @param model - the wire model id the sample belongs to.
|
|
564
|
+
* @param billing - resolved pricing with peak-hour windows.
|
|
565
|
+
* @param names - model id → display label.
|
|
566
|
+
* @returns the priced contribution, or `undefined` when the model has no rate row.
|
|
567
|
+
*/
|
|
568
|
+
function priceUsage(parts, usage, model, billing, names) {
|
|
467
569
|
const pricing = billing.models.get(model);
|
|
468
570
|
if (pricing === void 0) return void 0;
|
|
469
|
-
const
|
|
470
|
-
const peak = isPeakParts(billing, hour, weekday);
|
|
571
|
+
const peak = isPeakParts(billing, parts.hour, parts.weekday);
|
|
471
572
|
const price = peak ? pricing.peak : pricing.offPeak;
|
|
472
|
-
const hit =
|
|
473
|
-
const miss =
|
|
474
|
-
const output =
|
|
573
|
+
const hit = usage.cacheReadTokens ?? 0;
|
|
574
|
+
const miss = usage.inputTokens + (usage.cacheWriteTokens ?? 0);
|
|
575
|
+
const output = usage.outputTokens;
|
|
475
576
|
const hitCost = hit * price.cacheHitInput / 1e6;
|
|
476
577
|
const missCost = miss * price.cacheMissInput / 1e6;
|
|
477
578
|
const outputCost = output * price.output / 1e6;
|
|
478
579
|
const cost = hitCost + missCost + outputCost;
|
|
479
580
|
return {
|
|
480
|
-
dayKey,
|
|
581
|
+
dayKey: parts.dayKey,
|
|
481
582
|
model,
|
|
482
583
|
displayName: names.get(model) ?? model,
|
|
483
584
|
cost,
|
|
@@ -556,6 +657,192 @@ var SpendAccumulator = class {
|
|
|
556
657
|
};
|
|
557
658
|
}
|
|
558
659
|
};
|
|
660
|
+
/** The additive inverse of one spend (pure): used to replace a priced sample. */
|
|
661
|
+
function negateSpend(spend) {
|
|
662
|
+
const negate = (value) => -value;
|
|
663
|
+
return {
|
|
664
|
+
total: negate(spend.total),
|
|
665
|
+
models: spend.models.map((row) => ({
|
|
666
|
+
...row,
|
|
667
|
+
cost: negate(row.cost),
|
|
668
|
+
peakCost: negate(row.peakCost),
|
|
669
|
+
offPeakCost: negate(row.offPeakCost),
|
|
670
|
+
cacheHitInputTokens: negate(row.cacheHitInputTokens),
|
|
671
|
+
cacheMissInputTokens: negate(row.cacheMissInputTokens),
|
|
672
|
+
outputTokens: negate(row.outputTokens),
|
|
673
|
+
cacheHitInputCost: negate(row.cacheHitInputCost),
|
|
674
|
+
cacheMissInputCost: negate(row.cacheMissInputCost),
|
|
675
|
+
outputCost: negate(row.outputCost)
|
|
676
|
+
}))
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
/**
|
|
680
|
+
* Subtract one spend from another (pure). Rows that cancel out completely are
|
|
681
|
+
* dropped so a replaced sample leaves no zero row behind.
|
|
682
|
+
* @param target - the spend to subtract from.
|
|
683
|
+
* @param source - the spend to remove.
|
|
684
|
+
* @returns the difference.
|
|
685
|
+
*/
|
|
686
|
+
function subtractSpend(target, source) {
|
|
687
|
+
const rows = /* @__PURE__ */ new Map();
|
|
688
|
+
for (const row of target.models) rows.set(row.model, row);
|
|
689
|
+
for (const row of source.models) {
|
|
690
|
+
const existing = rows.get(row.model);
|
|
691
|
+
if (existing === void 0) continue;
|
|
692
|
+
const next = mergeModelRows(existing, negateSpend({
|
|
693
|
+
total: 0,
|
|
694
|
+
models: [row]
|
|
695
|
+
}).models[0]);
|
|
696
|
+
if (next.cost === 0 && next.cacheHitInputTokens === 0 && next.cacheMissInputTokens === 0 && next.outputTokens === 0) rows.delete(row.model);
|
|
697
|
+
else rows.set(row.model, next);
|
|
698
|
+
}
|
|
699
|
+
return {
|
|
700
|
+
total: target.total - source.total,
|
|
701
|
+
models: [...rows.values()]
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
/** The empty fold state for one fork boundary. */
|
|
705
|
+
function emptyBillingFoldState(inheritedEventCount = 0) {
|
|
706
|
+
return {
|
|
707
|
+
dayKey: "",
|
|
708
|
+
spend: emptyTodaySpend(),
|
|
709
|
+
session: emptyTodaySpend(),
|
|
710
|
+
inheritedEventCount,
|
|
711
|
+
model: "",
|
|
712
|
+
last: null
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
/** Whether an unknown value looks like a provider usage report. */
|
|
716
|
+
function isTokenUsage(value) {
|
|
717
|
+
if (typeof value !== "object" || value === null) return false;
|
|
718
|
+
const candidate = value;
|
|
719
|
+
return typeof candidate.inputTokens === "number" && typeof candidate.outputTokens === "number";
|
|
720
|
+
}
|
|
721
|
+
/**
|
|
722
|
+
* The last `usage` sample embedded in an event's stream, if any. `assistant/
|
|
723
|
+
* attempt` and the embedded streams are newer than the plugin's npm baseline,
|
|
724
|
+
* so the stream is read structurally (a failed/retried attempt reports its
|
|
725
|
+
* usage only there).
|
|
726
|
+
*/
|
|
727
|
+
function streamUsageOf(event) {
|
|
728
|
+
const stream = event.data === void 0 ? void 0 : event.data.stream;
|
|
729
|
+
if (!Array.isArray(stream)) return void 0;
|
|
730
|
+
for (let index = stream.length - 1; index >= 0; index -= 1) {
|
|
731
|
+
const chunk = stream[index]?.chunk;
|
|
732
|
+
if (chunk === void 0 || chunk.type !== "usage") continue;
|
|
733
|
+
return isTokenUsage(chunk.usage) ? chunk.usage : void 0;
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
/** The contribution as a one-row spend (the shape a sample keeps for replacement). */
|
|
737
|
+
function contributionSpend(priced) {
|
|
738
|
+
return {
|
|
739
|
+
total: priced.cost,
|
|
740
|
+
models: [contributionModel(priced)]
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
/**
|
|
744
|
+
* Fold one committed event into a session's billed-spend state.
|
|
745
|
+
*
|
|
746
|
+
* Priced samples come from `assistant/message` (its own reported usage, or the
|
|
747
|
+
* stream's last usage chunk) and `assistant/attempt` (the stream's last usage
|
|
748
|
+
* chunk, priced with the model of the latest `request/header`, since an
|
|
749
|
+
* attempt carries no route). A sample for the same `(turn, step)` replaces the
|
|
750
|
+
* previous one; `llm/retry-started` closes the replacement slot so a retried
|
|
751
|
+
* attempt adds. Every other event is inert and returns the same state
|
|
752
|
+
* reference.
|
|
753
|
+
* @param state - the previous fold state.
|
|
754
|
+
* @param event - the committed event.
|
|
755
|
+
* @param billing - resolved pricing with peak-hour windows.
|
|
756
|
+
* @param names - model id → display label.
|
|
757
|
+
* @returns the next state (the same reference when nothing was priced).
|
|
758
|
+
*/
|
|
759
|
+
function applyBillingEvent(state, event, billing, names) {
|
|
760
|
+
if (event.seq < state.inheritedEventCount) return state;
|
|
761
|
+
const type = event.type;
|
|
762
|
+
if (type === "request/header") {
|
|
763
|
+
const model = event.data?.header?.config?.model;
|
|
764
|
+
return typeof model === "string" && model.length > 0 && model !== state.model ? {
|
|
765
|
+
...state,
|
|
766
|
+
model
|
|
767
|
+
} : state;
|
|
768
|
+
}
|
|
769
|
+
const data = event.data;
|
|
770
|
+
if (type === "llm/retry-started") {
|
|
771
|
+
if (typeof data?.turn !== "number" || typeof data.step !== "number") return state;
|
|
772
|
+
const last = state.last;
|
|
773
|
+
if (last === null || last.turn !== data.turn || last.step !== data.step) return state;
|
|
774
|
+
return {
|
|
775
|
+
...state,
|
|
776
|
+
last: null
|
|
777
|
+
};
|
|
778
|
+
}
|
|
779
|
+
if (type !== "assistant/message" && type !== "assistant/attempt") return state;
|
|
780
|
+
const usage = (type === "assistant/message" ? data?.usage : void 0) ?? streamUsageOf(event);
|
|
781
|
+
if (!isTokenUsage(usage)) return state;
|
|
782
|
+
const model = type === "assistant/message" ? data?.message?.source?.model : state.model;
|
|
783
|
+
if (typeof model !== "string" || model.length === 0) return state;
|
|
784
|
+
const priced = priceUsage(beijingPartsOf(event.time), usage, model, billing, names);
|
|
785
|
+
if (priced === void 0) return state;
|
|
786
|
+
let session = state.session;
|
|
787
|
+
let spend = state.spend;
|
|
788
|
+
let dayKey = state.dayKey;
|
|
789
|
+
const last = state.last;
|
|
790
|
+
const turn = typeof data?.turn === "number" ? data.turn : 0;
|
|
791
|
+
const step = typeof data?.step === "number" ? data.step : 0;
|
|
792
|
+
if (last !== null && last.turn === turn && last.step === step) {
|
|
793
|
+
session = subtractSpend(session, last.spend);
|
|
794
|
+
if (last.dayKey === dayKey) spend = subtractSpend(spend, last.spend);
|
|
795
|
+
}
|
|
796
|
+
session = addEventContribution(session, priced);
|
|
797
|
+
if (dayKey === priced.dayKey) spend = addEventContribution(spend, priced);
|
|
798
|
+
else if (dayKey === "" || priced.dayKey > dayKey) {
|
|
799
|
+
dayKey = priced.dayKey;
|
|
800
|
+
spend = addEventContribution(emptyTodaySpend(), priced);
|
|
801
|
+
}
|
|
802
|
+
return {
|
|
803
|
+
...state,
|
|
804
|
+
dayKey,
|
|
805
|
+
spend,
|
|
806
|
+
session,
|
|
807
|
+
last: {
|
|
808
|
+
turn,
|
|
809
|
+
step,
|
|
810
|
+
dayKey: priced.dayKey,
|
|
811
|
+
spend: contributionSpend(priced)
|
|
812
|
+
}
|
|
813
|
+
};
|
|
814
|
+
}
|
|
815
|
+
/**
|
|
816
|
+
* Mutable wrapper over {@link applyBillingEvent} for the pure pricing paths:
|
|
817
|
+
* feed events in order, read the folded spend.
|
|
818
|
+
*/
|
|
819
|
+
var BillingFolder = class {
|
|
820
|
+
billing;
|
|
821
|
+
state;
|
|
822
|
+
/**
|
|
823
|
+
* @param billing - resolved pricing with peak-hour windows.
|
|
824
|
+
* @param catalog - model display rows, in presentation order.
|
|
825
|
+
* @param inheritedEventCount - fork boundary to skip (default 0).
|
|
826
|
+
*/
|
|
827
|
+
constructor(billing, catalog, inheritedEventCount = 0) {
|
|
828
|
+
this.billing = billing;
|
|
829
|
+
this.names = new Map(catalog.map((model) => [model.id, model.name]));
|
|
830
|
+
this.state = emptyBillingFoldState(inheritedEventCount);
|
|
831
|
+
}
|
|
832
|
+
names;
|
|
833
|
+
/** Fold one event. */
|
|
834
|
+
add(event) {
|
|
835
|
+
this.state = applyBillingEvent(this.state, event, this.billing, this.names);
|
|
836
|
+
}
|
|
837
|
+
/** Fold every event, in order. */
|
|
838
|
+
addAll(events) {
|
|
839
|
+
for (const event of events) this.add(event);
|
|
840
|
+
}
|
|
841
|
+
/** The folded state (live reference; do not mutate). */
|
|
842
|
+
get fold() {
|
|
843
|
+
return this.state;
|
|
844
|
+
}
|
|
845
|
+
};
|
|
559
846
|
/**
|
|
560
847
|
* Merge one priced event's contribution into an accumulator spend (pure:
|
|
561
848
|
* returns a new spend, never mutates its input).
|
|
@@ -592,34 +879,11 @@ function mergeTodaySpend(target, source) {
|
|
|
592
879
|
};
|
|
593
880
|
}
|
|
594
881
|
/**
|
|
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.
|
|
882
|
+
* Price one session's complete event log at the official per-model rates,
|
|
883
|
+
* with DSH's attempt semantics: every provider-reported sample (an
|
|
884
|
+
* `assistant/message`'s usage, or an `assistant/attempt`'s stream usage)
|
|
885
|
+
* contributes, a later sample for the same `(turn, step)` replaces the earlier
|
|
886
|
+
* one, and `llm/retry-started` makes the retried attempt add.
|
|
623
887
|
* @param events - one session's complete event log.
|
|
624
888
|
* @param billing - resolved pricing with peak-hour windows.
|
|
625
889
|
* @param catalog - model display rows, in presentation order.
|
|
@@ -630,16 +894,18 @@ function priceEvents(events, billing, names, dayKey, startSeq = 0) {
|
|
|
630
894
|
* @returns the session's total cost plus one row per priced model.
|
|
631
895
|
*/
|
|
632
896
|
function computeSessionSpend(events, billing, catalog, startSeq = 0) {
|
|
633
|
-
|
|
897
|
+
const folder = new BillingFolder(billing, catalog, startSeq);
|
|
898
|
+
folder.addAll(events);
|
|
899
|
+
return folder.fold.session;
|
|
634
900
|
}
|
|
635
901
|
/**
|
|
636
|
-
* Price one completed Turn's billed usage
|
|
637
|
-
*
|
|
638
|
-
*
|
|
639
|
-
*
|
|
640
|
-
*
|
|
641
|
-
*
|
|
642
|
-
*
|
|
902
|
+
* Price one completed Turn's billed usage, identified by its closing
|
|
903
|
+
* assistant message id. The turn's events are those between its `turn/start`
|
|
904
|
+
* and `turn/end` (both matched by the message's own turn coordinate), priced
|
|
905
|
+
* with the same attempt semantics as {@link computeSessionSpend}. A message
|
|
906
|
+
* that cannot be located, a turn without bracketing `turn/start` / `turn/end`
|
|
907
|
+
* events (for example after compaction), or a session with no priced usage
|
|
908
|
+
* prices to zero.
|
|
643
909
|
* @param events - one session's complete event log.
|
|
644
910
|
* @param billing - resolved pricing with peak-hour windows.
|
|
645
911
|
* @param catalog - model display rows, in presentation order.
|
|
@@ -647,7 +913,18 @@ function computeSessionSpend(events, billing, catalog, startSeq = 0) {
|
|
|
647
913
|
* @returns the turn's total cost in CNY.
|
|
648
914
|
*/
|
|
649
915
|
function computeTurnSpend(events, billing, catalog, messageId) {
|
|
650
|
-
|
|
916
|
+
return { total: turnCostOf(events, billing, catalog, messageId) };
|
|
917
|
+
}
|
|
918
|
+
/**
|
|
919
|
+
* The total cost of the Turn containing `messageId`, folded with the shared
|
|
920
|
+
* attempt semantics (see {@link applyBillingEvent}).
|
|
921
|
+
* @param events - one session's complete event log.
|
|
922
|
+
* @param billing - resolved pricing with peak-hour windows.
|
|
923
|
+
* @param catalog - model display rows, in presentation order.
|
|
924
|
+
* @param messageId - one assistant message inside the Turn.
|
|
925
|
+
* @returns the Turn's total cost in CNY, or 0 when the Turn cannot be located.
|
|
926
|
+
*/
|
|
927
|
+
function turnCostOf(events, billing, catalog, messageId) {
|
|
651
928
|
let turn;
|
|
652
929
|
for (const event of events) {
|
|
653
930
|
if (event.type !== "assistant/message") continue;
|
|
@@ -655,8 +932,8 @@ function computeTurnSpend(events, billing, catalog, messageId) {
|
|
|
655
932
|
turn = event.data.turn;
|
|
656
933
|
break;
|
|
657
934
|
}
|
|
658
|
-
if (turn === void 0) return
|
|
659
|
-
const
|
|
935
|
+
if (turn === void 0) return 0;
|
|
936
|
+
const folder = new BillingFolder(billing, catalog);
|
|
660
937
|
let active = false;
|
|
661
938
|
for (const event of events) {
|
|
662
939
|
if (event.type === "turn/start" && event.data.turn === turn) {
|
|
@@ -665,16 +942,116 @@ function computeTurnSpend(events, billing, catalog, messageId) {
|
|
|
665
942
|
}
|
|
666
943
|
if (event.type === "turn/end" && event.data.turn === turn) break;
|
|
667
944
|
if (!active) continue;
|
|
668
|
-
|
|
669
|
-
if (priced !== void 0) accumulator.add(priced);
|
|
945
|
+
folder.add(event);
|
|
670
946
|
}
|
|
671
|
-
return
|
|
947
|
+
return folder.fold.session.total;
|
|
672
948
|
}
|
|
673
949
|
/**
|
|
674
|
-
*
|
|
675
|
-
*
|
|
676
|
-
*
|
|
677
|
-
*
|
|
950
|
+
* Incremental single-pass fold of one session's completed-Turn costs, keyed by
|
|
951
|
+
* the id of every assistant message inside each Turn. Feeding the fold only
|
|
952
|
+
* the appended tail keeps a growing session's map current in O(new events)
|
|
953
|
+
* instead of re-scanning the whole log per message.
|
|
954
|
+
*
|
|
955
|
+
* Semantics are exactly {@link computeTurnSpend}'s: a Turn is the
|
|
956
|
+
* `turn/start`..`turn/end` range (matched by the event's own turn coordinate),
|
|
957
|
+
* every priced event inside it contributes at its own timestamp's rate, and a
|
|
958
|
+
* message outside any bracket contributes nothing.
|
|
959
|
+
*/
|
|
960
|
+
var SessionTurnSpendFolder = class {
|
|
961
|
+
billing;
|
|
962
|
+
catalog;
|
|
963
|
+
rows = [];
|
|
964
|
+
ids = [];
|
|
965
|
+
/** Events of the open Turn, folded with the shared attempt semantics on close. */
|
|
966
|
+
events = [];
|
|
967
|
+
open = false;
|
|
968
|
+
/** Events already fed; a shorter log resets the fold. */
|
|
969
|
+
cursor = 0;
|
|
970
|
+
/**
|
|
971
|
+
* @param billing - resolved pricing with peak-hour windows.
|
|
972
|
+
* @param catalog - model display rows, in presentation order.
|
|
973
|
+
*/
|
|
974
|
+
constructor(billing, catalog) {
|
|
975
|
+
this.billing = billing;
|
|
976
|
+
this.catalog = catalog;
|
|
977
|
+
}
|
|
978
|
+
/** How many events have been folded so far (the host's incremental cursor). */
|
|
979
|
+
get processed() {
|
|
980
|
+
return this.cursor;
|
|
981
|
+
}
|
|
982
|
+
/**
|
|
983
|
+
* Fold every event from the cursor to the end of the log. A log shorter than
|
|
984
|
+
* the cursor (rewritten session) restarts the fold from an empty state.
|
|
985
|
+
* @param events - the session's complete event log, in seq order.
|
|
986
|
+
*/
|
|
987
|
+
feed(events) {
|
|
988
|
+
if (events.length < this.cursor) this.reset();
|
|
989
|
+
for (let index = this.cursor; index < events.length; index += 1) {
|
|
990
|
+
const event = events[index];
|
|
991
|
+
if (event.type === "turn/start") {
|
|
992
|
+
this.open = true;
|
|
993
|
+
this.ids = [];
|
|
994
|
+
this.events = [];
|
|
995
|
+
continue;
|
|
996
|
+
}
|
|
997
|
+
if (event.type === "turn/end") {
|
|
998
|
+
if (this.open) {
|
|
999
|
+
const folder = new BillingFolder(this.billing, this.catalog);
|
|
1000
|
+
folder.addAll(this.events);
|
|
1001
|
+
const total = folder.fold.session.total;
|
|
1002
|
+
for (const messageId of this.ids) this.rows.push({
|
|
1003
|
+
messageId,
|
|
1004
|
+
total
|
|
1005
|
+
});
|
|
1006
|
+
}
|
|
1007
|
+
this.open = false;
|
|
1008
|
+
this.ids = [];
|
|
1009
|
+
this.events = [];
|
|
1010
|
+
continue;
|
|
1011
|
+
}
|
|
1012
|
+
if (!this.open) continue;
|
|
1013
|
+
if (event.type === "assistant/message") this.ids.push(event.data.message.id);
|
|
1014
|
+
this.events.push(event);
|
|
1015
|
+
}
|
|
1016
|
+
this.cursor = events.length;
|
|
1017
|
+
}
|
|
1018
|
+
/** The folded map; the fold stays usable afterwards. */
|
|
1019
|
+
finish() {
|
|
1020
|
+
return { turns: [...this.rows] };
|
|
1021
|
+
}
|
|
1022
|
+
/** Drop the fold state so the next feed starts from the log's beginning. */
|
|
1023
|
+
reset() {
|
|
1024
|
+
this.rows.length = 0;
|
|
1025
|
+
this.ids = [];
|
|
1026
|
+
this.events = [];
|
|
1027
|
+
this.open = false;
|
|
1028
|
+
this.cursor = 0;
|
|
1029
|
+
}
|
|
1030
|
+
};
|
|
1031
|
+
/**
|
|
1032
|
+
* Price every completed Turn of one session in a single pass (the pure
|
|
1033
|
+
* equivalent of {@link SessionTurnSpendFolder}).
|
|
1034
|
+
* @param events - one session's complete event log.
|
|
1035
|
+
* @param billing - resolved pricing with peak-hour windows.
|
|
1036
|
+
* @param catalog - model display rows, in presentation order.
|
|
1037
|
+
* @returns one row per assistant message inside a completed Turn, in log order.
|
|
1038
|
+
*/
|
|
1039
|
+
function computeSessionTurnSpends(events, billing, catalog) {
|
|
1040
|
+
const folder = new SessionTurnSpendFolder(billing, catalog);
|
|
1041
|
+
folder.feed(events);
|
|
1042
|
+
return folder.finish();
|
|
1043
|
+
}
|
|
1044
|
+
/**
|
|
1045
|
+
* Price one session's log for the Beijing-time calendar day of `now`. Events
|
|
1046
|
+
* after the reference day are ignored; the fold's latest-day state then
|
|
1047
|
+
* answers the query exactly (empty when the session's latest priced day is not
|
|
1048
|
+
* the reference day). Pricing follows {@link applyBillingEvent} (attempt
|
|
1049
|
+
* samples with same-step replacement).
|
|
1050
|
+
*
|
|
1051
|
+
* The fold's `(turn, step)` replacement slot is per session, so callers must
|
|
1052
|
+
* pass ONE session's log; aggregate across sessions with
|
|
1053
|
+
* {@link mergeTodaySpend}.
|
|
1054
|
+
* @param events - one session's complete event log.
|
|
678
1055
|
* @param billing - resolved pricing with peak-hour windows.
|
|
679
1056
|
* @param catalog - model display rows, in presentation order.
|
|
680
1057
|
* @param now - the reference moment whose Beijing-time calendar day is "today".
|
|
@@ -682,24 +1059,31 @@ function computeTurnSpend(events, billing, catalog, messageId) {
|
|
|
682
1059
|
*/
|
|
683
1060
|
function computeTodaySpend(events, billing, catalog, now = /* @__PURE__ */ new Date()) {
|
|
684
1061
|
const day = beijingDayKey(now);
|
|
685
|
-
|
|
1062
|
+
const folder = new BillingFolder(billing, catalog);
|
|
1063
|
+
for (const event of events) {
|
|
1064
|
+
if (beijingPartsOf(event.time).dayKey > day) continue;
|
|
1065
|
+
folder.add(event);
|
|
1066
|
+
}
|
|
1067
|
+
return folder.fold.dayKey === day ? folder.fold.spend : emptyTodaySpend();
|
|
686
1068
|
}
|
|
687
1069
|
//#endregion
|
|
688
1070
|
//#region lib/types/projection.js
|
|
689
1071
|
/**
|
|
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.
|
|
1072
|
+
* `billingTodaySpend` session-projection unit: per-session billed spend,
|
|
1073
|
+
* folded eagerly by the DSH projection drive over committed session events and
|
|
1074
|
+
* checkpointed by the projection cache. The state keeps the session's LATEST
|
|
1075
|
+
* priced Beijing day, its whole-session total, the fork boundary, the latest
|
|
1076
|
+
* request model, and the last priced attempt sample (DSH's same-step
|
|
1077
|
+
* replacement rule); the aggregate "today" read sums the units whose `dayKey`
|
|
1078
|
+
* matches the current Beijing day — zero full-log scans once the fold is warm.
|
|
697
1079
|
*
|
|
698
|
-
* The unit's fold
|
|
699
|
-
*
|
|
1080
|
+
* The unit's fold IS the shared pricing fold ({@link applyBillingEvent}), so
|
|
1081
|
+
* the projection path and the events-scan paths cannot drift. The unit is
|
|
700
1082
|
* client-visible (`wire` = identity) because the persisted-cache read ladder
|
|
701
|
-
* (`sessionProjectionCache.
|
|
702
|
-
* wired units
|
|
1083
|
+
* (`sessionProjectionCache.cachedSnapshot` / registry `restore`) serves only
|
|
1084
|
+
* wired units, and because the browser half reads this value through
|
|
1085
|
+
* `useProjection` instead of polling a Remote; the wire value is the state
|
|
1086
|
+
* itself.
|
|
703
1087
|
* @module @rayadesu/dsh-llm-billing/projection
|
|
704
1088
|
*/
|
|
705
1089
|
/** The projection key this unit owns. */
|
|
@@ -723,7 +1107,16 @@ const todaySpendSchema = z$1.object({
|
|
|
723
1107
|
}).strict();
|
|
724
1108
|
const billingUnitSchema = z$1.object({
|
|
725
1109
|
dayKey: z$1.string(),
|
|
726
|
-
spend: todaySpendSchema
|
|
1110
|
+
spend: todaySpendSchema,
|
|
1111
|
+
session: todaySpendSchema,
|
|
1112
|
+
inheritedEventCount: z$1.number().int().nonnegative(),
|
|
1113
|
+
model: z$1.string(),
|
|
1114
|
+
last: z$1.object({
|
|
1115
|
+
turn: z$1.number().int().nonnegative(),
|
|
1116
|
+
step: z$1.number().int().nonnegative(),
|
|
1117
|
+
dayKey: z$1.string(),
|
|
1118
|
+
spend: todaySpendSchema
|
|
1119
|
+
}).strict().nullable()
|
|
727
1120
|
}).strict();
|
|
728
1121
|
/**
|
|
729
1122
|
* Build the `billingTodaySpend` unit for one resolved pricing table. The
|
|
@@ -741,25 +1134,10 @@ function billingTodaySpendDefinition(billing, catalog) {
|
|
|
741
1134
|
const names = new Map(catalog.map((model) => [model.id, model.name]));
|
|
742
1135
|
return {
|
|
743
1136
|
key: BILLING_UNIT_KEY,
|
|
744
|
-
stateVersion:
|
|
1137
|
+
stateVersion: 3,
|
|
745
1138
|
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
|
-
},
|
|
1139
|
+
init: (_header, inheritedEventCount) => emptyBillingFoldState(Number(inheritedEventCount ?? 0)),
|
|
1140
|
+
apply: (state, event) => applyBillingEvent(state, event, billing, names),
|
|
763
1141
|
wire: {
|
|
764
1142
|
viewSchema: billingUnitSchema,
|
|
765
1143
|
view: (state) => state
|
|
@@ -800,11 +1178,13 @@ function foldOwnBilling(unit, events, seedLength = 0) {
|
|
|
800
1178
|
* compute the aggregate behind a cache miss:
|
|
801
1179
|
*
|
|
802
1180
|
* - projection path (plan C): live sessions read their eagerly folded
|
|
803
|
-
* `billingTodaySpend` projection cell; cold sessions
|
|
804
|
-
* projection-cache
|
|
805
|
-
*
|
|
806
|
-
*
|
|
807
|
-
*
|
|
1181
|
+
* `billingTodaySpend` projection cell; cold sessions are answered from the
|
|
1182
|
+
* zero-I/O projection-cache row whenever that row's own day is not the
|
|
1183
|
+
* queried one, and otherwise resolved through one detached local fold over
|
|
1184
|
+
* a full `inspect`. Persisted revisions gate every cold read, so a session
|
|
1185
|
+
* whose log did not change since the last resolution costs nothing — and a
|
|
1186
|
+
* failed resolution is remembered by revision instead of being retried on
|
|
1187
|
+
* every scan.
|
|
808
1188
|
* - events path (plans A2/A3): collect and price only today's events in one
|
|
809
1189
|
* pass (per-event Beijing-day filter during collection) with a hard cap,
|
|
810
1190
|
* skipping sessions whose persisted revision is unchanged since the last
|
|
@@ -816,14 +1196,15 @@ function foldOwnBilling(unit, events, seedLength = 0) {
|
|
|
816
1196
|
* unchanged log provably cannot change the aggregate.
|
|
817
1197
|
*
|
|
818
1198
|
* 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
|
-
*
|
|
1199
|
+
* verbatim copy of its source session's events (its inherited boundary), so
|
|
1200
|
+
* the scanner prices only the child's OWN events on every path. The
|
|
1201
|
+
* `billingTodaySpend` unit is boundary-aware (its state carries the inherited
|
|
1202
|
+
* cut, and `apply` skips events below it), so the eager cell is correct for a
|
|
1203
|
+
* fork child; the cold path skips the projection cache for a seeded session
|
|
1204
|
+
* (its cached row may predate the boundary) and folds its own events with the
|
|
1205
|
+
* durable cut instead. The boundary is the durable session state, read across
|
|
1206
|
+
* both DSH runtime families — a resumed fork child keeps its original
|
|
1207
|
+
* boundary and an unseeded session stays at 0.
|
|
827
1208
|
*
|
|
828
1209
|
* The live `Session` log surface changed in 0.1.2-alpha.4: `Session.events`
|
|
829
1210
|
* was removed and replaced by `Session.snapshotEvents()` / `ownEvents()`, and
|
|
@@ -862,6 +1243,15 @@ function liveSessionEvents(session) {
|
|
|
862
1243
|
if (session.snapshotEvents !== void 0) return session.snapshotEvents();
|
|
863
1244
|
throw new Error("llm-billing: session log surface is neither Session.events nor Session.snapshotEvents");
|
|
864
1245
|
}
|
|
1246
|
+
/**
|
|
1247
|
+
* Unwrap a handle read across both return shapes.
|
|
1248
|
+
* @param read - the handle's read result.
|
|
1249
|
+
* @returns the event array.
|
|
1250
|
+
*/
|
|
1251
|
+
function handleReadEvents(read) {
|
|
1252
|
+
if (Array.isArray(read)) return read;
|
|
1253
|
+
return read.events;
|
|
1254
|
+
}
|
|
865
1255
|
function isHandlePersistence(persistence) {
|
|
866
1256
|
return typeof persistence.open === "function";
|
|
867
1257
|
}
|
|
@@ -888,7 +1278,7 @@ async function persistenceInspect(persistence, id) {
|
|
|
888
1278
|
const handle = await persistence.open(id, "read");
|
|
889
1279
|
try {
|
|
890
1280
|
return {
|
|
891
|
-
events: await handle.read(),
|
|
1281
|
+
events: handleReadEvents(await handle.read()),
|
|
892
1282
|
seedLength: forkBoundaryOf(handle)
|
|
893
1283
|
};
|
|
894
1284
|
} finally {
|
|
@@ -952,7 +1342,7 @@ var TodaySpendCache = class {
|
|
|
952
1342
|
const now = this.now();
|
|
953
1343
|
const dayKey = beijingDayKey(now);
|
|
954
1344
|
if (!force && this.cachedDayKey === dayKey && this.cachedValue !== void 0 && now.getTime() - this.cachedAt < this.ttlMs) return Promise.resolve(this.cachedValue);
|
|
955
|
-
if (
|
|
1345
|
+
if (this.inFlight !== void 0) return this.inFlight;
|
|
956
1346
|
const run = (async () => {
|
|
957
1347
|
try {
|
|
958
1348
|
const value = await this.scan(dayKey);
|
|
@@ -964,10 +1354,24 @@ var TodaySpendCache = class {
|
|
|
964
1354
|
this.inFlight = void 0;
|
|
965
1355
|
}
|
|
966
1356
|
})();
|
|
967
|
-
|
|
1357
|
+
this.inFlight = run;
|
|
968
1358
|
return run;
|
|
969
1359
|
}
|
|
970
1360
|
};
|
|
1361
|
+
/** Max session-ids kept in the scanner's cold-resolution cache before eviction. */
|
|
1362
|
+
const COLD_RESOLVE_CACHE_LIMIT = 1024;
|
|
1363
|
+
/** Max session-ids kept in the scanner's cold-failure cache before eviction. */
|
|
1364
|
+
const COLD_FAILED_CACHE_LIMIT = 1024;
|
|
1365
|
+
/**
|
|
1366
|
+
* Bounded-map eviction: drop the oldest inserted entry once `size` reached
|
|
1367
|
+
* `limit`. Evicting one entry (instead of clearing) keeps the other sessions'
|
|
1368
|
+
* resolved state warm across scans.
|
|
1369
|
+
*/
|
|
1370
|
+
function evictOldest$1(map, limit) {
|
|
1371
|
+
if (map.size < limit) return;
|
|
1372
|
+
const oldest = map.keys().next().value;
|
|
1373
|
+
if (oldest !== void 0) map.delete(oldest);
|
|
1374
|
+
}
|
|
971
1375
|
/**
|
|
972
1376
|
* The aggregate computation behind a cache miss. Chooses the projection path
|
|
973
1377
|
* when the projection registry is composed, the events path otherwise; both
|
|
@@ -978,10 +1382,10 @@ var TodaySpendScanner = class {
|
|
|
978
1382
|
deps;
|
|
979
1383
|
/** Cold sessions resolved on the projection path: id → revision + unit state + title. */
|
|
980
1384
|
coldResolved = /* @__PURE__ */ new Map();
|
|
1385
|
+
/** Cold sessions whose resolution failed: id → revision (retried only when the log changes). */
|
|
1386
|
+
coldFailed = /* @__PURE__ */ new Map();
|
|
981
1387
|
/** Cold sessions resolved on the events path: id → revision (events were collected). */
|
|
982
1388
|
lastEventsScan;
|
|
983
|
-
/** Live fork children priced on the projection path: id → own-events count + folded state. */
|
|
984
|
-
ownStates = /* @__PURE__ */ new Map();
|
|
985
1389
|
constructor(deps) {
|
|
986
1390
|
this.deps = deps;
|
|
987
1391
|
}
|
|
@@ -991,9 +1395,7 @@ var TodaySpendScanner = class {
|
|
|
991
1395
|
* @returns today's spend across every session.
|
|
992
1396
|
*/
|
|
993
1397
|
async scan(dayKey) {
|
|
994
|
-
|
|
995
|
-
this.deps.ensureUnit?.();
|
|
996
|
-
return this.scanProjections(dayKey);
|
|
1398
|
+
return (await this.scanDetail(dayKey)).aggregate;
|
|
997
1399
|
}
|
|
998
1400
|
/**
|
|
999
1401
|
* Compute today's per-session spend for one Beijing day, sorted by cost
|
|
@@ -1003,157 +1405,169 @@ var TodaySpendScanner = class {
|
|
|
1003
1405
|
* @returns today's per-session rows, highest first.
|
|
1004
1406
|
*/
|
|
1005
1407
|
async scanSessions(dayKey) {
|
|
1006
|
-
|
|
1007
|
-
rows.sort((left, right) => right.total - left.total);
|
|
1008
|
-
return { sessions: rows };
|
|
1408
|
+
return { sessions: (await this.scanDetail(dayKey)).sessions };
|
|
1009
1409
|
}
|
|
1010
1410
|
/**
|
|
1011
|
-
*
|
|
1012
|
-
* the
|
|
1013
|
-
*
|
|
1014
|
-
*
|
|
1015
|
-
*
|
|
1016
|
-
*
|
|
1411
|
+
* Compute the day's aggregate AND its per-session ranking in ONE pass: the
|
|
1412
|
+
* aggregate is the sum of the rows, so the two reads share every session
|
|
1413
|
+
* read, unit fold, and title fold instead of scanning twice. Chooses the
|
|
1414
|
+
* projection path when the projection registry is composed, the events path
|
|
1415
|
+
* otherwise.
|
|
1416
|
+
* @param dayKey - the Beijing-time calendar-day key to aggregate.
|
|
1417
|
+
* @returns the aggregate plus per-session rows sorted by cost descending.
|
|
1418
|
+
*/
|
|
1419
|
+
async scanDetail(dayKey) {
|
|
1420
|
+
if (this.deps.projections?.() === void 0) return this.scanDetailEvents(dayKey);
|
|
1421
|
+
this.deps.ensureUnit?.();
|
|
1422
|
+
return this.scanDetailProjections(dayKey);
|
|
1423
|
+
}
|
|
1424
|
+
/**
|
|
1425
|
+
* Resolve one cold session's billing unit state and display title.
|
|
1426
|
+
*
|
|
1427
|
+
* The zero-I/O projection-cache row answers the query directly whenever its
|
|
1428
|
+
* own latest priced day is NOT the queried day: the row then proves the
|
|
1429
|
+
* session contributed nothing to the queried day, so the log is never read.
|
|
1430
|
+
* When the row IS the queried day (or no usable row exists) the session is
|
|
1431
|
+
* inspected and folded locally, because the row may trail the log (a crash
|
|
1432
|
+
* between the last checkpoint and the session's last event).
|
|
1433
|
+
*
|
|
1434
|
+
* A cache-served value carries no title (the ladder only stores projection
|
|
1435
|
+
* values), so such rows report `title: null`. A SEEDED session (fork child)
|
|
1436
|
+
* skips the cache entirely: its cached row was folded over the inherited
|
|
1017
1437
|
* prefix too, so it always detaches through inspect with the durable
|
|
1018
1438
|
* boundary (the inspect result's inherited count or `meta.seedLength`,
|
|
1019
1439
|
* 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).
|
|
1440
|
+
* @param header - the listed session header (the cache identity witness).
|
|
1441
|
+
* @param seeded - whether the session carries a fork-inherited prefix.
|
|
1442
|
+
* @param dayKey - the Beijing-time day being aggregated.
|
|
1024
1443
|
* @returns the resolved state and title, or `undefined` when unreadable.
|
|
1025
1444
|
*/
|
|
1026
|
-
async resolveCold(
|
|
1445
|
+
async resolveCold(header, seeded, dayKey) {
|
|
1027
1446
|
const { persistence, projectionCache, logger } = this.deps;
|
|
1028
|
-
const persistenceService = persistence?.();
|
|
1029
|
-
if (persistenceService === void 0) return void 0;
|
|
1030
1447
|
if (!seeded) {
|
|
1031
1448
|
const cache = projectionCache?.();
|
|
1032
1449
|
if (cache !== void 0) try {
|
|
1033
|
-
const value =
|
|
1034
|
-
if (value !== void 0) return {
|
|
1450
|
+
const value = cache.cachedSnapshot(header, 0, [BILLING_UNIT_KEY])?.values[BILLING_UNIT_KEY];
|
|
1451
|
+
if (value !== void 0 && value.dayKey !== dayKey) return {
|
|
1035
1452
|
value,
|
|
1036
1453
|
title: null
|
|
1037
1454
|
};
|
|
1038
1455
|
} catch (error) {
|
|
1039
|
-
logger.warn(`llm-billing: projection
|
|
1456
|
+
logger.warn(`llm-billing: projection cache read for session ${header.id} failed: ${String(error)}`);
|
|
1040
1457
|
}
|
|
1041
1458
|
}
|
|
1459
|
+
const persistenceService = persistence?.();
|
|
1460
|
+
if (persistenceService === void 0) return void 0;
|
|
1042
1461
|
try {
|
|
1043
|
-
const read = await persistenceInspect(persistenceService, id);
|
|
1462
|
+
const read = await persistenceInspect(persistenceService, header.id);
|
|
1044
1463
|
return {
|
|
1045
1464
|
value: foldOwnBilling(this.deps.unit, read.events, read.seedLength),
|
|
1046
1465
|
title: foldSessionTitle(read.events)
|
|
1047
1466
|
};
|
|
1048
1467
|
} catch (error) {
|
|
1049
|
-
logger.warn(`llm-billing: skipping unreadable session ${id}: ${String(error)}`);
|
|
1468
|
+
logger.warn(`llm-billing: skipping unreadable session ${header.id}: ${String(error)}`);
|
|
1050
1469
|
return;
|
|
1051
1470
|
}
|
|
1052
1471
|
}
|
|
1053
1472
|
/**
|
|
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.
|
|
1473
|
+
* Live-session entries of one projection-path scan: each session with its
|
|
1474
|
+
* eager `billingTodaySpend` cell. The cell is boundary-aware (the unit skips
|
|
1475
|
+
* a fork child's inherited prefix), so a fork child reads the same own-event
|
|
1476
|
+
* spend a non-fork session does.
|
|
1061
1477
|
*/
|
|
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;
|
|
1478
|
+
*liveBillingEntries(store, projections) {
|
|
1479
|
+
for (const session of store.list()) yield {
|
|
1480
|
+
session,
|
|
1481
|
+
state: projections?.stateOf(session, BILLING_UNIT_KEY)
|
|
1482
|
+
};
|
|
1079
1483
|
}
|
|
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);
|
|
1484
|
+
/**
|
|
1485
|
+
* Cold-ladder adopt: for every stored session not live, either the
|
|
1486
|
+
* revision-gated resolution already in {@link coldResolved} is adopted
|
|
1487
|
+
* (unchanged log costs nothing) or the session is queued behind a bounded
|
|
1488
|
+
* parallel fan-out, resolved, remembered, and then adopted. A session whose
|
|
1489
|
+
* resolution failed is remembered too (by revision), so an unreadable log
|
|
1490
|
+
* is not re-read on every scan; a changed revision retries it. One
|
|
1491
|
+
* unreadable session never blanks the whole-day aggregate.
|
|
1492
|
+
* @param liveIds - ids of sessions already folded from the live store.
|
|
1493
|
+
* @param snapshots - stored snapshot list (either runtime family).
|
|
1494
|
+
* @param dayKey - the Beijing-time day being aggregated.
|
|
1495
|
+
* @param adopt - fold one resolved cold session into the scan's result.
|
|
1496
|
+
*/
|
|
1497
|
+
async coldAdopt(liveIds, snapshots, dayKey, adopt) {
|
|
1498
|
+
const persistenceAvailable = this.deps.persistence?.() !== void 0;
|
|
1098
1499
|
const pending = [];
|
|
1099
1500
|
for (const { header, revision } of snapshots) {
|
|
1100
1501
|
if (liveIds.has(header.id)) continue;
|
|
1101
1502
|
const seeded = isSeededSession(header);
|
|
1102
1503
|
const resolved = this.coldResolved.get(header.id);
|
|
1103
1504
|
if (resolved !== void 0 && resolved.revision === revision) {
|
|
1104
|
-
|
|
1505
|
+
adopt(header.id, resolved);
|
|
1105
1506
|
continue;
|
|
1106
1507
|
}
|
|
1508
|
+
if (this.coldFailed.get(header.id) === revision) continue;
|
|
1107
1509
|
pending.push({
|
|
1108
|
-
|
|
1510
|
+
header,
|
|
1109
1511
|
revision,
|
|
1110
1512
|
seeded
|
|
1111
1513
|
});
|
|
1112
1514
|
}
|
|
1113
|
-
await withConcurrency(pending, 8, async ({
|
|
1114
|
-
const resolved = await this.resolveCold(
|
|
1115
|
-
if (resolved !== void 0)
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1515
|
+
await withConcurrency(pending, 8, async ({ header, revision, seeded }) => {
|
|
1516
|
+
const resolved = await this.resolveCold(header, seeded, dayKey);
|
|
1517
|
+
if (resolved !== void 0) {
|
|
1518
|
+
this.coldFailed.delete(header.id);
|
|
1519
|
+
evictOldest$1(this.coldResolved, COLD_RESOLVE_CACHE_LIMIT);
|
|
1520
|
+
this.coldResolved.set(header.id, {
|
|
1521
|
+
revision,
|
|
1522
|
+
...resolved
|
|
1523
|
+
});
|
|
1524
|
+
} else if (persistenceAvailable) {
|
|
1525
|
+
evictOldest$1(this.coldFailed, COLD_FAILED_CACHE_LIMIT);
|
|
1526
|
+
this.coldFailed.set(header.id, revision);
|
|
1527
|
+
}
|
|
1119
1528
|
});
|
|
1120
|
-
for (const {
|
|
1121
|
-
const resolved = this.coldResolved.get(id);
|
|
1122
|
-
if (resolved !== void 0
|
|
1529
|
+
for (const { header } of pending) {
|
|
1530
|
+
const resolved = this.coldResolved.get(header.id);
|
|
1531
|
+
if (resolved !== void 0) adopt(header.id, resolved);
|
|
1123
1532
|
}
|
|
1124
|
-
return total;
|
|
1125
1533
|
}
|
|
1126
1534
|
/**
|
|
1127
|
-
* Events
|
|
1128
|
-
*
|
|
1129
|
-
*
|
|
1130
|
-
*
|
|
1535
|
+
* Events-path collection shared by both aggregate and per-session scans:
|
|
1536
|
+
* fold each session's log with the shared pricing fold (attempt samples with
|
|
1537
|
+
* same-step replacement) and announce the session's latest-day spend, gated
|
|
1538
|
+
* by revisions — a persisted session whose log did not change since the last
|
|
1539
|
+
* scan is skipped. A fork child's inherited prefix (`seq < seedLength`) is
|
|
1540
|
+
* skipped, so each model output is priced only in its source session. The
|
|
1541
|
+
* hard cap counts the queried day's events; the revision watermark only
|
|
1542
|
+
* advances on a complete pass.
|
|
1543
|
+
* @param dayKey - the Beijing-time calendar-day key to aggregate.
|
|
1544
|
+
* @param onSession - fold one session's state plus its complete log.
|
|
1545
|
+
* @returns whether the hard cap truncated the scan.
|
|
1131
1546
|
*/
|
|
1132
|
-
async
|
|
1547
|
+
async collectTodayEvents(dayKey, onSession) {
|
|
1133
1548
|
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
1549
|
const liveIds = /* @__PURE__ */ new Set();
|
|
1137
1550
|
let collected = 0;
|
|
1138
1551
|
let truncated = false;
|
|
1139
|
-
const collect = (events, seedLength) => {
|
|
1552
|
+
const collect = (id, events, seedLength) => {
|
|
1553
|
+
const folder = new BillingFolder(billing, catalog, seedLength);
|
|
1140
1554
|
for (const event of events) {
|
|
1141
|
-
if (event.
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1555
|
+
if (beijingPartsOf(event.time).dayKey === dayKey) {
|
|
1556
|
+
collected += 1;
|
|
1557
|
+
if (collected > maxEvents) {
|
|
1558
|
+
truncated = true;
|
|
1559
|
+
break;
|
|
1560
|
+
}
|
|
1147
1561
|
}
|
|
1148
|
-
|
|
1149
|
-
if (priced !== void 0) accumulator.add(priced);
|
|
1562
|
+
folder.add(event);
|
|
1150
1563
|
}
|
|
1564
|
+
onSession(id, folder.fold, events);
|
|
1151
1565
|
};
|
|
1152
1566
|
if (sessions !== void 0) {
|
|
1153
1567
|
const store = sessions();
|
|
1154
1568
|
if (store !== void 0) for (const session of store.list()) {
|
|
1155
1569
|
liveIds.add(session.id);
|
|
1156
|
-
collect(liveSessionEvents(session), forkBoundaryOf(session));
|
|
1570
|
+
collect(session.id, liveSessionEvents(session), forkBoundaryOf(session));
|
|
1157
1571
|
if (truncated) break;
|
|
1158
1572
|
}
|
|
1159
1573
|
}
|
|
@@ -1165,7 +1579,7 @@ var TodaySpendScanner = class {
|
|
|
1165
1579
|
if (this.lastEventsScan?.get(header.id) === revision) continue;
|
|
1166
1580
|
try {
|
|
1167
1581
|
const read = await persistenceInspect(persistenceService, header.id);
|
|
1168
|
-
collect(read.events, read.seedLength);
|
|
1582
|
+
collect(header.id, read.events, read.seedLength);
|
|
1169
1583
|
} catch (error) {
|
|
1170
1584
|
logger.warn(`llm-billing: skipping unreadable session ${header.id}: ${String(error)}`);
|
|
1171
1585
|
}
|
|
@@ -1174,146 +1588,87 @@ var TodaySpendScanner = class {
|
|
|
1174
1588
|
if (!truncated) this.lastEventsScan = new Map(snapshots.map((snapshot) => [snapshot.header.id, snapshot.revision]));
|
|
1175
1589
|
}
|
|
1176
1590
|
if (truncated) logger.warn(`llm-billing: today's events exceeded ${maxEvents}; result truncated`);
|
|
1177
|
-
return
|
|
1591
|
+
return truncated;
|
|
1178
1592
|
}
|
|
1179
1593
|
/**
|
|
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
|
-
*
|
|
1594
|
+
* Projection path, one pass for both outputs: eager cells for live sessions
|
|
1595
|
+
* (title folded from the live log, so a rename is reflected immediately),
|
|
1596
|
+
* revision-gated cold ladder for the rest (title resolved on inspect, `null`
|
|
1597
|
+
* when answered from the projection cache). A fork child's cell covers its
|
|
1598
|
+
* inherited prefix, so its own-events fold supplies both outputs.
|
|
1185
1599
|
* @param dayKey - the Beijing-time calendar-day key to aggregate.
|
|
1186
|
-
* @returns
|
|
1600
|
+
* @returns the aggregate plus per-session rows, sorted by cost descending.
|
|
1187
1601
|
*/
|
|
1188
|
-
async
|
|
1602
|
+
async scanDetailProjections(dayKey) {
|
|
1189
1603
|
const { sessions, persistence, projections } = this.deps;
|
|
1190
1604
|
const projectionsService = projections?.();
|
|
1605
|
+
let aggregate = emptyTodaySpend();
|
|
1191
1606
|
const rows = /* @__PURE__ */ new Map();
|
|
1192
1607
|
const liveIds = /* @__PURE__ */ new Set();
|
|
1193
1608
|
if (sessions !== void 0) {
|
|
1194
1609
|
const store = sessions();
|
|
1195
|
-
if (store !== void 0) for (const session of
|
|
1610
|
+
if (store !== void 0) for (const { session, state } of this.liveBillingEntries(store, projectionsService)) {
|
|
1196
1611
|
liveIds.add(session.id);
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
if (state !== void 0 && state.dayKey === dayKey) rows.set(session.id, {
|
|
1612
|
+
if (state === void 0 || state.dayKey !== dayKey) continue;
|
|
1613
|
+
aggregate = mergeTodaySpend(aggregate, state.spend);
|
|
1614
|
+
rows.set(session.id, {
|
|
1201
1615
|
sessionId: session.id,
|
|
1202
|
-
title: foldSessionTitle(
|
|
1616
|
+
title: foldSessionTitle(liveSessionEvents(session)),
|
|
1203
1617
|
total: state.spend.total
|
|
1204
1618
|
});
|
|
1205
1619
|
}
|
|
1206
1620
|
}
|
|
1207
1621
|
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,
|
|
1622
|
+
if (persistenceService !== void 0) {
|
|
1623
|
+
const snapshots = await persistenceListSnapshots(persistenceService);
|
|
1624
|
+
await this.coldAdopt(liveIds, snapshots, dayKey, (id, resolved) => {
|
|
1625
|
+
if (resolved.value.dayKey !== dayKey) return;
|
|
1626
|
+
aggregate = mergeTodaySpend(aggregate, resolved.value.spend);
|
|
1627
|
+
rows.set(id, {
|
|
1628
|
+
sessionId: id,
|
|
1218
1629
|
title: resolved.title,
|
|
1219
1630
|
total: resolved.value.spend.total
|
|
1220
1631
|
});
|
|
1221
|
-
continue;
|
|
1222
|
-
}
|
|
1223
|
-
pending.push({
|
|
1224
|
-
id: header.id,
|
|
1225
|
-
revision,
|
|
1226
|
-
seeded
|
|
1227
1632
|
});
|
|
1228
1633
|
}
|
|
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()];
|
|
1634
|
+
return {
|
|
1635
|
+
aggregate,
|
|
1636
|
+
sessions: sortRows(rows)
|
|
1637
|
+
};
|
|
1245
1638
|
}
|
|
1246
1639
|
/**
|
|
1247
|
-
* Events
|
|
1248
|
-
*
|
|
1249
|
-
*
|
|
1250
|
-
*
|
|
1251
|
-
*
|
|
1252
|
-
*
|
|
1253
|
-
* is re-read.
|
|
1640
|
+
* Events path, one pass for both outputs: price today's events (per-event
|
|
1641
|
+
* Beijing-day filter during collection, hard cap), gated by revisions. A
|
|
1642
|
+
* fork child's inherited prefix (`seq < seedLength`) is skipped, so each
|
|
1643
|
+
* model output is priced only in its source session. Titles fold from each
|
|
1644
|
+
* session's complete log — a `session/title` event can predate today — so a
|
|
1645
|
+
* rename is reflected as soon as the session's log is re-read.
|
|
1254
1646
|
* @param dayKey - the Beijing-time calendar-day key to aggregate.
|
|
1255
|
-
* @returns
|
|
1647
|
+
* @returns the aggregate plus per-session rows, sorted by cost descending.
|
|
1256
1648
|
*/
|
|
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
|
-
}
|
|
1649
|
+
async scanDetailEvents(dayKey) {
|
|
1650
|
+
let aggregate = emptyTodaySpend();
|
|
1651
|
+
const sessions = [];
|
|
1652
|
+
await this.collectTodayEvents(dayKey, (id, fold, events) => {
|
|
1653
|
+
if (fold.dayKey !== dayKey) return;
|
|
1654
|
+
aggregate = mergeTodaySpend(aggregate, fold.spend);
|
|
1655
|
+
sessions.push({
|
|
1656
|
+
sessionId: id,
|
|
1657
|
+
title: foldSessionTitle(events),
|
|
1658
|
+
total: fold.spend.total
|
|
1659
|
+
});
|
|
1660
|
+
});
|
|
1661
|
+
sessions.sort((left, right) => right.total - left.total);
|
|
1662
|
+
return {
|
|
1663
|
+
aggregate,
|
|
1664
|
+
sessions
|
|
1284
1665
|
};
|
|
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
1666
|
}
|
|
1316
1667
|
};
|
|
1668
|
+
/** Per-session rows from the map, highest total first. */
|
|
1669
|
+
function sortRows(rows) {
|
|
1670
|
+
return [...rows.values()].sort((left, right) => right.total - left.total);
|
|
1671
|
+
}
|
|
1317
1672
|
//#endregion
|
|
1318
1673
|
//#region lib/types/index.js
|
|
1319
1674
|
/**
|
|
@@ -1348,6 +1703,10 @@ const DEFAULT_MODELS = [
|
|
|
1348
1703
|
id: "deepseek-v4-flash",
|
|
1349
1704
|
name: "DeepSeek-V4-Flash"
|
|
1350
1705
|
},
|
|
1706
|
+
{
|
|
1707
|
+
id: "deepseek-v4.1-flash-expires-on-0910",
|
|
1708
|
+
name: "DeepSeek-V4.1-Flash"
|
|
1709
|
+
},
|
|
1351
1710
|
{
|
|
1352
1711
|
id: "deepseek-v4-pro",
|
|
1353
1712
|
name: "DeepSeek-V4-Pro"
|
|
@@ -1378,12 +1737,12 @@ const billingConfig = z.object({
|
|
|
1378
1737
|
peakHours: z.array(z.object({
|
|
1379
1738
|
start: z.number().step(1).min(0).max(23),
|
|
1380
1739
|
end: z.number().step(1).min(0).max(24)
|
|
1381
|
-
})).default(DEFAULT_PEAK_HOURS),
|
|
1740
|
+
})).default([...DEFAULT_PEAK_HOURS]),
|
|
1382
1741
|
models: z.array(z.object({
|
|
1383
1742
|
model: z.string().required(),
|
|
1384
1743
|
peak: tokenPrice,
|
|
1385
1744
|
offPeak: tokenPrice
|
|
1386
|
-
})).default(DEFAULT_MODEL_PRICING)
|
|
1745
|
+
})).default([...DEFAULT_MODEL_PRICING])
|
|
1387
1746
|
});
|
|
1388
1747
|
const Config = z.object({
|
|
1389
1748
|
apiKeyEnv: z.string().role("credential-ref").default(DEFAULT_API_KEY_ENV),
|
|
@@ -1395,6 +1754,25 @@ const Config = z.object({
|
|
|
1395
1754
|
const TODAY_SPEND_CACHE_MS = 6e4;
|
|
1396
1755
|
/** Hard cap on today's events collected by the events scan path. */
|
|
1397
1756
|
const TODAY_SPEND_MAX_EVENTS = 2e5;
|
|
1757
|
+
/** Max session-spend rows kept for incremental recompute before eviction. */
|
|
1758
|
+
const SESSION_SPEND_CACHE_LIMIT = 1024;
|
|
1759
|
+
/** Max session-id entries kept in the per-turn-cost fold cache before eviction. */
|
|
1760
|
+
const SESSION_TURN_SPEND_CACHE_LIMIT = 64;
|
|
1761
|
+
/** How long one balance snapshot is reused before the host refetches it (15s). */
|
|
1762
|
+
const BALANCE_CACHE_MS = 15e3;
|
|
1763
|
+
/** Hard cap on one `/user/balance` request (5s); a hung endpoint never blocks the badge. */
|
|
1764
|
+
const BALANCE_TIMEOUT_MS = 5e3;
|
|
1765
|
+
/**
|
|
1766
|
+
* Bounded-map eviction: drop the oldest inserted entry once `size` reached
|
|
1767
|
+
* `limit`, so an unbounded session-id space grows the map no further. Evicting
|
|
1768
|
+
* one entry (instead of clearing) keeps the other sessions' incremental
|
|
1769
|
+
* spend warm.
|
|
1770
|
+
*/
|
|
1771
|
+
function evictOldest(map, limit) {
|
|
1772
|
+
if (map.size < limit) return;
|
|
1773
|
+
const oldest = map.keys().next().value;
|
|
1774
|
+
if (oldest !== void 0) map.delete(oldest);
|
|
1775
|
+
}
|
|
1398
1776
|
/**
|
|
1399
1777
|
* Read one session's event log and durable seed boundary: the live
|
|
1400
1778
|
* SessionStore first, then the persistence backend for a flushed session
|
|
@@ -1421,65 +1799,111 @@ async function sessionEvents(ctx, sessionId) {
|
|
|
1421
1799
|
}
|
|
1422
1800
|
throw new LlmError(`llm-billing: session ${sessionId} not found`, "NOT_FOUND");
|
|
1423
1801
|
}
|
|
1802
|
+
/** Resolve the plugin's static facts once: endpoint, credential ref, pricing table. */
|
|
1803
|
+
function resolveFacts(ctx, config) {
|
|
1804
|
+
return {
|
|
1805
|
+
baseURL: () => config.baseURL ?? launchEnvironmentOf(ctx).get(BASE_URL_ENV)?.value ?? "https://api.deepseek.com",
|
|
1806
|
+
apiKeyRef: credentialRef(config.apiKeyEnv ?? DEFAULT_API_KEY_ENV),
|
|
1807
|
+
billing: resolveBilling(config.billing),
|
|
1808
|
+
catalog: (config.models ?? DEFAULT_MODELS).map((model) => ({
|
|
1809
|
+
id: model.id,
|
|
1810
|
+
name: model.name ?? model.id
|
|
1811
|
+
}))
|
|
1812
|
+
};
|
|
1813
|
+
}
|
|
1424
1814
|
/**
|
|
1425
|
-
*
|
|
1426
|
-
*
|
|
1427
|
-
* @
|
|
1815
|
+
* Resolve the API key per call: the credentials service first, then the
|
|
1816
|
+
* launch environment fallback.
|
|
1817
|
+
* @throws {@link LlmError} with code `MISSING_CREDENTIAL` when neither yields a usable key.
|
|
1428
1818
|
*/
|
|
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
|
-
}));
|
|
1819
|
+
async function resolveApiKey(ctx, apiKeyRef) {
|
|
1820
|
+
const credentials = ctx.get("credentials");
|
|
1821
|
+
if (credentials !== void 0) {
|
|
1822
|
+
const hit = await credentials.resolve(apiKeyRef);
|
|
1823
|
+
if (hit !== void 0) return assertUsableApiKey(hit.value, "llm-billing", apiKeyRef);
|
|
1824
|
+
} else {
|
|
1825
|
+
const ambient = launchEnvironmentOf(ctx).get(apiKeyRef);
|
|
1826
|
+
if (ambient !== void 0 && ambient.value.length > 0) return assertUsableApiKey(ambient.value, "llm-billing", apiKeyRef);
|
|
1827
|
+
}
|
|
1828
|
+
throw new LlmError(`llm-billing: no API key; store ${apiKeyRef} through the credentials service or export it`, "MISSING_CREDENTIAL");
|
|
1829
|
+
}
|
|
1830
|
+
/**
|
|
1831
|
+
* Per-session incremental spend loader: a session log is append-only and
|
|
1832
|
+
* chronological (the same assumption the projection unit makes), so a spend
|
|
1833
|
+
* computed for `count` EVENTS OF THE SESSION'S OWN WORK (the log minus its
|
|
1834
|
+
* inherited fork prefix) stays valid while the log length is unchanged, and
|
|
1835
|
+
* only the appended tail needs pricing when it grows. A forked child's
|
|
1836
|
+
* inherited prefix (`seq < seedLength`) is priced only in its source
|
|
1837
|
+
* session; the cache is bounded (see {@link evictOldest}), so an unbounded
|
|
1838
|
+
* session-id space cannot grow it without bound.
|
|
1839
|
+
*/
|
|
1840
|
+
function createSessionSpendFetcher(ctx, facts) {
|
|
1452
1841
|
const sessionSpendCache = /* @__PURE__ */ new Map();
|
|
1453
|
-
|
|
1842
|
+
return async (sessionId) => {
|
|
1454
1843
|
const { events, seedLength } = await sessionEvents(ctx, sessionId);
|
|
1455
1844
|
const ownCount = events.length - seedLength;
|
|
1456
1845
|
const cached = sessionSpendCache.get(sessionId);
|
|
1457
|
-
if (cached !== void 0 && cached.count === ownCount)
|
|
1846
|
+
if (cached !== void 0 && cached.count === ownCount) {
|
|
1847
|
+
sessionSpendCache.delete(sessionId);
|
|
1848
|
+
sessionSpendCache.set(sessionId, cached);
|
|
1849
|
+
return cached.spend;
|
|
1850
|
+
}
|
|
1458
1851
|
if (cached !== void 0 && cached.count < ownCount) {
|
|
1459
|
-
const spend = mergeTodaySpend(cached.spend, computeSessionSpend(events.slice(seedLength + cached.count), billing, catalog));
|
|
1852
|
+
const spend = mergeTodaySpend(cached.spend, computeSessionSpend(events.slice(seedLength + cached.count), facts.billing, facts.catalog));
|
|
1853
|
+
evictOldest(sessionSpendCache, SESSION_SPEND_CACHE_LIMIT);
|
|
1460
1854
|
sessionSpendCache.set(sessionId, {
|
|
1461
1855
|
count: ownCount,
|
|
1462
1856
|
spend
|
|
1463
1857
|
});
|
|
1464
1858
|
return spend;
|
|
1465
1859
|
}
|
|
1466
|
-
const spend = computeSessionSpend(events, billing, catalog, seedLength);
|
|
1467
|
-
|
|
1860
|
+
const spend = computeSessionSpend(events, facts.billing, facts.catalog, seedLength);
|
|
1861
|
+
evictOldest(sessionSpendCache, SESSION_SPEND_CACHE_LIMIT);
|
|
1468
1862
|
sessionSpendCache.set(sessionId, {
|
|
1469
1863
|
count: ownCount,
|
|
1470
1864
|
spend
|
|
1471
1865
|
});
|
|
1472
1866
|
return spend;
|
|
1473
1867
|
};
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1868
|
+
}
|
|
1869
|
+
/**
|
|
1870
|
+
* Once-registrar for the billing projection unit: the first call that finds
|
|
1871
|
+
* the registry composed registers the shared unit and every later call is a
|
|
1872
|
+
* no-op. Registering as early as the registry exists lets DSH's projection
|
|
1873
|
+
* write-behind (mandatory at `turn/end`) checkpoint a billing row for every
|
|
1874
|
+
* session that runs in this process, which is what makes the zero-I/O cold
|
|
1875
|
+
* path in {@link TodaySpendScanner} hit after the next restart.
|
|
1876
|
+
* @param ctx - plugin context.
|
|
1877
|
+
* @param unit - the unit definition built once per plugin config.
|
|
1878
|
+
* @returns an idempotent registrar.
|
|
1879
|
+
*/
|
|
1880
|
+
function createUnitRegistrar(ctx, unit) {
|
|
1881
|
+
let registered = false;
|
|
1882
|
+
return () => {
|
|
1883
|
+
if (registered) return;
|
|
1478
1884
|
const registry = ctx.get("sessionProjections");
|
|
1479
1885
|
if (registry === void 0) return;
|
|
1480
1886
|
registry.register(unit);
|
|
1481
|
-
|
|
1887
|
+
registered = true;
|
|
1482
1888
|
};
|
|
1889
|
+
}
|
|
1890
|
+
/**
|
|
1891
|
+
* Today-spend loaders over one revision-gated scanner with two 60s
|
|
1892
|
+
* Beijing-day caches (in-flight coalescing and a `force` bypass):
|
|
1893
|
+
* - plan C uses the per-session spend projection unit registered by the
|
|
1894
|
+
* caller's {@link createUnitRegistrar} as early as the registry exists (the
|
|
1895
|
+
* registry builds cells lazily over the in-memory log, so events committed
|
|
1896
|
+
* before registration are folded on first touch); without the registry the
|
|
1897
|
+
* events path serves today's spend.
|
|
1898
|
+
* - plans A1–A3: the scanner chooses the projection path when the registry
|
|
1899
|
+
* is composed, the events path otherwise.
|
|
1900
|
+
* @param ctx - plugin context.
|
|
1901
|
+
* @param facts - resolved endpoint, credential, pricing, and catalog facts.
|
|
1902
|
+
* @param unit - the shared projection unit definition.
|
|
1903
|
+
* @param ensureUnit - idempotent unit registrar (last-resort registration).
|
|
1904
|
+
* @returns the two today-spend loaders.
|
|
1905
|
+
*/
|
|
1906
|
+
function createTodaySpendLoaders(ctx, facts, unit, ensureUnit) {
|
|
1483
1907
|
const scanner = new TodaySpendScanner({
|
|
1484
1908
|
sessions: () => ctx.get("sessions"),
|
|
1485
1909
|
persistence: () => ctx.get("sessionPersistence"),
|
|
@@ -1489,24 +1913,108 @@ function apply(ctx, config) {
|
|
|
1489
1913
|
unit,
|
|
1490
1914
|
maxEvents: TODAY_SPEND_MAX_EVENTS,
|
|
1491
1915
|
logger: ctx.logger,
|
|
1492
|
-
billing,
|
|
1493
|
-
catalog
|
|
1916
|
+
billing: facts.billing,
|
|
1917
|
+
catalog: facts.catalog
|
|
1494
1918
|
});
|
|
1495
|
-
const todayCache = new TodaySpendCache((dayKey) => scanner.
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1919
|
+
const todayCache = new TodaySpendCache((dayKey) => scanner.scanDetail(dayKey), TODAY_SPEND_CACHE_MS);
|
|
1920
|
+
return {
|
|
1921
|
+
fetchTodaySpend: async (force = false) => (await todayCache.get(force)).aggregate,
|
|
1922
|
+
fetchTodaySessionsSpend: async (force = false) => ({ sessions: (await todayCache.get(force)).sessions })
|
|
1923
|
+
};
|
|
1924
|
+
}
|
|
1925
|
+
/** One completed Turn's spend loader, located by its closing message id. */
|
|
1926
|
+
function createTurnSpendFetcher(ctx, facts) {
|
|
1927
|
+
return async (sessionId, messageId) => {
|
|
1500
1928
|
const { events } = await sessionEvents(ctx, sessionId);
|
|
1501
|
-
return computeTurnSpend(events, billing, catalog, messageId);
|
|
1929
|
+
return computeTurnSpend(events, facts.billing, facts.catalog, messageId);
|
|
1930
|
+
};
|
|
1931
|
+
}
|
|
1932
|
+
/**
|
|
1933
|
+
* Every completed Turn's cost in one session, folded incrementally per session
|
|
1934
|
+
* (session logs are append-only, so only the appended tail is priced on a
|
|
1935
|
+
* growing log). One call serves a whole transcript's per-message cost rows,
|
|
1936
|
+
* replacing the per-message `getTurnSpend` fan-out.
|
|
1937
|
+
*/
|
|
1938
|
+
function createTurnSpendsFetcher(ctx, facts) {
|
|
1939
|
+
const folders = /* @__PURE__ */ new Map();
|
|
1940
|
+
return async (sessionId) => {
|
|
1941
|
+
const { events } = await sessionEvents(ctx, sessionId);
|
|
1942
|
+
let entry = folders.get(sessionId);
|
|
1943
|
+
if (entry === void 0 || entry.count > events.length) {
|
|
1944
|
+
entry = {
|
|
1945
|
+
folder: new SessionTurnSpendFolder(facts.billing, facts.catalog),
|
|
1946
|
+
count: 0
|
|
1947
|
+
};
|
|
1948
|
+
evictOldest(folders, 64);
|
|
1949
|
+
folders.set(sessionId, entry);
|
|
1950
|
+
}
|
|
1951
|
+
if (entry.count !== events.length) {
|
|
1952
|
+
entry.folder.feed(events);
|
|
1953
|
+
entry.count = events.length;
|
|
1954
|
+
}
|
|
1955
|
+
return entry.folder.finish();
|
|
1502
1956
|
};
|
|
1957
|
+
}
|
|
1958
|
+
/**
|
|
1959
|
+
* Balance loader with a short host-side TTL and a hard request timeout: the
|
|
1960
|
+
* credential resolves per call, a fresh snapshot is reused for
|
|
1961
|
+
* {@link BALANCE_CACHE_MS} (so several badge mounts and several browsers share
|
|
1962
|
+
* one `/user/balance` call), concurrent misses coalesce, and `force` bypasses
|
|
1963
|
+
* the TTL for the manual refresh. A hung endpoint aborts after
|
|
1964
|
+
* {@link BALANCE_TIMEOUT_MS} instead of holding the badge's fetch forever.
|
|
1965
|
+
* @param ctx - plugin context carrying the credential seam.
|
|
1966
|
+
* @param facts - resolved endpoint and credential facts.
|
|
1967
|
+
* @returns the balance loader.
|
|
1968
|
+
*/
|
|
1969
|
+
function createBalanceFetcher(ctx, facts) {
|
|
1970
|
+
let cached;
|
|
1971
|
+
let inflight;
|
|
1972
|
+
return async (force = false) => {
|
|
1973
|
+
if (!force && cached !== void 0 && Date.now() - cached.at < 15e3) return cached.value;
|
|
1974
|
+
if (inflight !== void 0) return inflight;
|
|
1975
|
+
const run = (async () => {
|
|
1976
|
+
try {
|
|
1977
|
+
const apiKey = await resolveApiKey(ctx, facts.apiKeyRef);
|
|
1978
|
+
const value = await fetchDeepSeekBalance(facts.baseURL(), apiKey, AbortSignal.timeout(BALANCE_TIMEOUT_MS));
|
|
1979
|
+
cached = {
|
|
1980
|
+
at: Date.now(),
|
|
1981
|
+
value
|
|
1982
|
+
};
|
|
1983
|
+
return value;
|
|
1984
|
+
} finally {
|
|
1985
|
+
inflight = void 0;
|
|
1986
|
+
}
|
|
1987
|
+
})();
|
|
1988
|
+
inflight = run;
|
|
1989
|
+
return run;
|
|
1990
|
+
};
|
|
1991
|
+
}
|
|
1992
|
+
/**
|
|
1993
|
+
* Register the `billing` Remote under the `billing` namespace. Assembly only:
|
|
1994
|
+
* facts resolve once, each loader owns its caches, and the gateway receives
|
|
1995
|
+
* the bound thunks.
|
|
1996
|
+
* @param ctx - owning plugin context.
|
|
1997
|
+
* @param config - validated plugin config.
|
|
1998
|
+
*/
|
|
1999
|
+
function apply(ctx, config) {
|
|
2000
|
+
const facts = resolveFacts(ctx, config);
|
|
2001
|
+
const unit = billingTodaySpendDefinition(facts.billing, facts.catalog);
|
|
2002
|
+
const ensureUnit = createUnitRegistrar(ctx, unit);
|
|
2003
|
+
ensureUnit();
|
|
2004
|
+
ctx.on("session/created", ensureUnit);
|
|
2005
|
+
const fetchBalance = createBalanceFetcher(ctx, facts);
|
|
2006
|
+
const fetchSessionSpend = createSessionSpendFetcher(ctx, facts);
|
|
2007
|
+
const { fetchTodaySpend, fetchTodaySessionsSpend } = createTodaySpendLoaders(ctx, facts, unit, ensureUnit);
|
|
2008
|
+
const fetchTurnSpend = createTurnSpendFetcher(ctx, facts);
|
|
2009
|
+
const fetchTurnSpends = createTurnSpendsFetcher(ctx, facts);
|
|
1503
2010
|
new DeepSeekBalanceGateway(ctx, {
|
|
1504
2011
|
fetchBalance,
|
|
1505
2012
|
fetchSessionSpend,
|
|
1506
2013
|
fetchTodaySpend,
|
|
1507
2014
|
fetchTodaySessionsSpend,
|
|
1508
|
-
fetchTurnSpend
|
|
2015
|
+
fetchTurnSpend,
|
|
2016
|
+
fetchTurnSpends
|
|
1509
2017
|
});
|
|
1510
2018
|
}
|
|
1511
2019
|
//#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 };
|
|
2020
|
+
export { BALANCE_CACHE_MS, BALANCE_TIMEOUT_MS, BILLING_UNIT_KEY, BillingFolder, Config, DEFAULT_MODEL_PRICING, DEFAULT_PEAK_HOURS, DeepSeekBalanceGateway, PUBLIC_BASE_URL, SESSION_SPEND_CACHE_LIMIT, SESSION_TURN_SPEND_CACHE_LIMIT, SessionTurnSpendFolder, SpendAccumulator, TODAY_SPEND_CACHE_MS, TODAY_SPEND_MAX_EVENTS, TodaySpendCache, TodaySpendScanner, 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 };
|