@rayadesu/dsh-llm-billing 0.3.16 → 0.3.18
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 +19 -8
- package/README.zh.md +20 -9
- package/icon.svg +14 -0
- package/lib/index.js +749 -416
- package/lib/typert.host.js +17 -7
- package/lib/typert.remote-client.js +17 -7
- package/lib/types/balance.d.ts +10 -0
- package/lib/types/balance.js +79 -13
- package/lib/types/beijing-time.d.ts +52 -0
- package/lib/types/beijing-time.js +88 -0
- package/lib/types/billing.d.ts +27 -388
- package/lib/types/billing.js +28 -627
- package/lib/types/cache.d.ts +110 -0
- package/lib/types/cache.js +155 -0
- package/lib/types/fold.d.ts +93 -0
- package/lib/types/fold.js +188 -0
- package/lib/types/index.d.ts +1 -1
- package/lib/types/index.js +1 -1
- package/lib/types/persistence.d.ts +113 -0
- package/lib/types/persistence.js +63 -0
- package/lib/types/pricing-table.d.ts +43 -0
- package/lib/types/pricing-table.js +118 -0
- package/lib/types/pricing.d.ts +203 -0
- package/lib/types/pricing.js +203 -0
- package/lib/types/projection.js +14 -0
- package/lib/types/session-lineage.d.ts +70 -0
- package/lib/types/session-lineage.js +99 -0
- package/lib/types/spend.d.ts +58 -0
- package/lib/types/spend.js +190 -0
- package/lib/types/today-spend.d.ts +27 -233
- package/lib/types/today-spend.js +73 -254
- package/lib/types/types.d.ts +28 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +21 -17
package/lib/index.js
CHANGED
|
@@ -92,14 +92,35 @@ function parseDeepSeekBalance(body) {
|
|
|
92
92
|
};
|
|
93
93
|
}
|
|
94
94
|
/**
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
* @returns the validated balance snapshot.
|
|
100
|
-
* @throws {@link LlmError} for transport, HTTP, or malformed-response failures.
|
|
95
|
+
* Whether another attempt can plausibly succeed. Only faults transient BY
|
|
96
|
+
* NATURE qualify: a dropped socket (no status), a rate limit, or a server-side
|
|
97
|
+
* error. A settled 4xx is final — retrying a bad key three times only reports
|
|
98
|
+
* the truth three times later.
|
|
101
99
|
*/
|
|
102
|
-
|
|
100
|
+
function isRetryableStatus(status) {
|
|
101
|
+
if (status === void 0) return true;
|
|
102
|
+
return status === 429 || status >= 500;
|
|
103
|
+
}
|
|
104
|
+
/** Wait `ms` before the next attempt, abandoning the wait if the caller aborts. */
|
|
105
|
+
function waitBalanceRetry(ms, signal) {
|
|
106
|
+
return new Promise((resolve, reject) => {
|
|
107
|
+
if (signal?.aborted === true) {
|
|
108
|
+
reject(signal.reason);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const onAbort = () => {
|
|
112
|
+
clearTimeout(timer);
|
|
113
|
+
reject(signal?.reason);
|
|
114
|
+
};
|
|
115
|
+
const timer = setTimeout(() => {
|
|
116
|
+
signal?.removeEventListener("abort", onAbort);
|
|
117
|
+
resolve();
|
|
118
|
+
}, ms);
|
|
119
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
/** One balance request attempt, REPORTED rather than thrown so the caller decides. */
|
|
123
|
+
async function requestBalanceOnce(baseURL, apiKey, signal) {
|
|
103
124
|
let response;
|
|
104
125
|
try {
|
|
105
126
|
response = await fetch(`${baseURL}/user/balance`, {
|
|
@@ -111,17 +132,59 @@ async function fetchDeepSeekBalance(baseURL, apiKey, signal) {
|
|
|
111
132
|
...signal === void 0 ? {} : { signal }
|
|
112
133
|
});
|
|
113
134
|
} catch (error) {
|
|
114
|
-
|
|
115
|
-
|
|
135
|
+
return {
|
|
136
|
+
ok: false,
|
|
137
|
+
status: void 0,
|
|
138
|
+
error: signal?.aborted === true ? new LlmError("DeepSeek balance request aborted by caller", "ABORTED", { cause: error }) : new LlmError(`DeepSeek balance request to ${baseURL} failed`, "TRANSPORT", { cause: error })
|
|
139
|
+
};
|
|
116
140
|
}
|
|
117
|
-
if (!response.ok)
|
|
141
|
+
if (!response.ok) return {
|
|
142
|
+
ok: false,
|
|
143
|
+
status: response.status,
|
|
144
|
+
error: new LlmError(`DeepSeek balance request failed (HTTP ${response.status})`, httpErrorCode(response.status), { status: response.status })
|
|
145
|
+
};
|
|
118
146
|
let body;
|
|
119
147
|
try {
|
|
120
148
|
body = await response.json();
|
|
121
149
|
} catch (error) {
|
|
122
|
-
|
|
150
|
+
return {
|
|
151
|
+
ok: false,
|
|
152
|
+
status: response.status,
|
|
153
|
+
error: new LlmError("DeepSeek balance response was not valid JSON", "TRANSPORT", { cause: error })
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
return {
|
|
157
|
+
ok: true,
|
|
158
|
+
value: parseDeepSeekBalance(body)
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Fetch one account-balance snapshot from `{baseURL}/user/balance`.
|
|
163
|
+
*
|
|
164
|
+
* Retries the faults that can plausibly clear — a dropped socket, a 429, a 5xx
|
|
165
|
+
* — up to {@link BALANCE_FETCH_ATTEMPTS} attempts, because the badge polls
|
|
166
|
+
* every few minutes and one lost packet would otherwise blank the balance
|
|
167
|
+
* until the next interval. Permanent answers (including a rejected key) fail
|
|
168
|
+
* on the first attempt.
|
|
169
|
+
* @param baseURL - resolved endpoint base; `/user/balance` is appended.
|
|
170
|
+
* @param apiKey - resolved bearer token for this endpoint.
|
|
171
|
+
* @param signal - optional cancellation.
|
|
172
|
+
* @returns the validated balance snapshot.
|
|
173
|
+
* @throws {@link LlmError} for transport, HTTP, or malformed-response failures.
|
|
174
|
+
*/
|
|
175
|
+
async function fetchDeepSeekBalance(baseURL, apiKey, signal) {
|
|
176
|
+
for (let attempt = 1;; attempt += 1) {
|
|
177
|
+
const outcome = await requestBalanceOnce(baseURL, apiKey, signal);
|
|
178
|
+
if (outcome.ok) return outcome.value;
|
|
179
|
+
const exhausted = attempt >= 3;
|
|
180
|
+
const aborted = signal?.aborted === true;
|
|
181
|
+
if (!exhausted && !aborted && isRetryableStatus(outcome.status)) {
|
|
182
|
+
await waitBalanceRetry(150 * 2 ** (attempt - 1), signal);
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
if (outcome.status === 401 || outcome.status === 403) throw new LlmError(`DeepSeek balance request was rejected (HTTP ${outcome.status}) — the API key in effect for ${baseURL} is missing or unauthorized (check DEEPSEEK_API_KEY)`, httpErrorCode(outcome.status), { status: outcome.status });
|
|
186
|
+
throw outcome.error;
|
|
123
187
|
}
|
|
124
|
-
return parseDeepSeekBalance(body);
|
|
125
188
|
}
|
|
126
189
|
/**
|
|
127
190
|
* Remote-only service exposing the DeepSeek account balance and session spend.
|
|
@@ -323,20 +386,307 @@ let DeepSeekBalanceGateway = (() => {
|
|
|
323
386
|
};
|
|
324
387
|
})();
|
|
325
388
|
//#endregion
|
|
326
|
-
//#region lib/types/
|
|
389
|
+
//#region lib/types/beijing-time.js
|
|
390
|
+
/**
|
|
391
|
+
* Beijing wall-clock arithmetic (Asia/Shanghai: a fixed UTC+8, no DST) — the
|
|
392
|
+
* calendar fields a sample's rate depends on, and the day boundaries a scan is
|
|
393
|
+
* clipped to. Split out of `billing.ts`: pricing asks this module questions, it
|
|
394
|
+
* never asks about pricing.
|
|
395
|
+
* @module @rayadesu/dsh-llm-billing/beijing-time
|
|
396
|
+
*/
|
|
397
|
+
/** Beijing is a fixed UTC+8 offset with no DST. */
|
|
398
|
+
const BEIJING_OFFSET_MS = 288e5;
|
|
399
|
+
/** Milliseconds in one day. */
|
|
400
|
+
const DAY_MS = 864e5;
|
|
401
|
+
/** Epoch day of 1970-01-01 in the civil-date algorithm below. */
|
|
402
|
+
const CIVIL_EPOCH_DAY = 719468;
|
|
403
|
+
/** Two-digit zero pad for a calendar field. */
|
|
404
|
+
function pad2(value) {
|
|
405
|
+
return value < 10 ? `0${value}` : String(value);
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* Civil date of an epoch day (Howard Hinnant's days-from-civil inverse):
|
|
409
|
+
* pure integer arithmetic, no `Date` allocation and no ISO-string slicing.
|
|
410
|
+
*/
|
|
411
|
+
function civilDateOf(epochDay) {
|
|
412
|
+
const shifted = epochDay + CIVIL_EPOCH_DAY;
|
|
413
|
+
const era = Math.floor(shifted / 146097);
|
|
414
|
+
const dayOfEra = shifted - era * 146097;
|
|
415
|
+
const yearOfEra = Math.floor((dayOfEra - Math.floor(dayOfEra / 1460) + Math.floor(dayOfEra / 36524) - Math.floor(dayOfEra / 146096)) / 365);
|
|
416
|
+
const year = yearOfEra + era * 400;
|
|
417
|
+
const dayOfYear = dayOfEra - (365 * yearOfEra + Math.floor(yearOfEra / 4) - Math.floor(yearOfEra / 100));
|
|
418
|
+
const monthPrime = Math.floor((5 * dayOfYear + 2) / 153);
|
|
419
|
+
const month = monthPrime + (monthPrime < 10 ? 3 : -9);
|
|
420
|
+
return {
|
|
421
|
+
year: month <= 2 ? year + 1 : year,
|
|
422
|
+
month,
|
|
423
|
+
day: dayOfYear - Math.floor((153 * monthPrime + 2) / 5) + 1
|
|
424
|
+
};
|
|
425
|
+
}
|
|
327
426
|
/**
|
|
328
|
-
*
|
|
329
|
-
*
|
|
330
|
-
*
|
|
331
|
-
*
|
|
427
|
+
* Derive the Beijing hour, weekday, and calendar-day key of one timestamp with
|
|
428
|
+
* pure integer arithmetic — every timezone-sensitive read shares this one
|
|
429
|
+
* implementation, so the pieces cannot drift apart. Callers that filter by
|
|
430
|
+
* day and then price the same event reuse the returned view, so each event is
|
|
431
|
+
* parsed exactly once. (The hot fold path runs this per committed event; the
|
|
432
|
+
* previous `Date` + `toISOString().slice()` version allocated a `Date` and a
|
|
433
|
+
* 24-character string per call.)
|
|
434
|
+
* @param time - epoch milliseconds.
|
|
435
|
+
* @throws {RangeError} when `time` is not a finite number.
|
|
436
|
+
*/
|
|
437
|
+
function beijingPartsOf(time) {
|
|
438
|
+
if (!Number.isFinite(time)) throw new RangeError(`billing: event time is not finite (${String(time)})`);
|
|
439
|
+
const shifted = time + BEIJING_OFFSET_MS;
|
|
440
|
+
const epochDay = Math.floor(shifted / DAY_MS);
|
|
441
|
+
const msOfDay = shifted - epochDay * DAY_MS;
|
|
442
|
+
const civil = civilDateOf(epochDay);
|
|
443
|
+
return {
|
|
444
|
+
time,
|
|
445
|
+
hour: Math.floor(msOfDay / 36e5),
|
|
446
|
+
weekday: ((epochDay + 4) % 7 + 7) % 7,
|
|
447
|
+
dayKey: `${civil.year}-${pad2(civil.month)}-${pad2(civil.day)}`
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
/** The Beijing (Asia/Shanghai, UTC+8, no DST) calendar-day key of a timestamp. */
|
|
451
|
+
function beijingDayKey(now) {
|
|
452
|
+
return beijingPartsOf(now.getTime()).dayKey;
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* The Beijing calendar day NAMED by `dayKey` as an epoch-millisecond range:
|
|
456
|
+
* `start` inclusive, `end` exclusive. Derived from the key itself rather than
|
|
457
|
+
* from a clock, because a scan prices whatever day it was asked for — today,
|
|
458
|
+
* or a day a caller is replaying.
|
|
332
459
|
*
|
|
333
|
-
* The
|
|
334
|
-
*
|
|
335
|
-
*
|
|
336
|
-
*
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
460
|
+
* The conversion needs no calendar arithmetic: `Date.UTC` builds midnight UTC
|
|
461
|
+
* of that civil date, and Beijing midnight is a fixed offset earlier (no DST).
|
|
462
|
+
* @param dayKey - a `YYYY-MM-DD` Beijing day key.
|
|
463
|
+
* @returns the day's bounds, or `undefined` when the key is not that shape.
|
|
464
|
+
*/
|
|
465
|
+
function beijingDayRangeOfKey(dayKey) {
|
|
466
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dayKey);
|
|
467
|
+
if (match === null) return void 0;
|
|
468
|
+
const utcMidnight = Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
|
|
469
|
+
if (Number.isNaN(utcMidnight)) return void 0;
|
|
470
|
+
return {
|
|
471
|
+
start: utcMidnight - BEIJING_OFFSET_MS,
|
|
472
|
+
end: utcMidnight + DAY_MS - BEIJING_OFFSET_MS
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
//#endregion
|
|
476
|
+
//#region lib/types/spend.js
|
|
477
|
+
/**
|
|
478
|
+
* Spend arithmetic: how two spends combine, how one is inverted, and how the
|
|
479
|
+
* samples that could NOT be priced are tallied beside them. Pure, and knowing
|
|
480
|
+
* nothing about rates, timezones or event shapes — the pricing engine
|
|
481
|
+
* (`billing.ts`) owns all of that.
|
|
482
|
+
* @module @rayadesu/dsh-llm-billing/spend
|
|
483
|
+
*/
|
|
484
|
+
/** A spend with no priced usage. */
|
|
485
|
+
function emptyTodaySpend() {
|
|
486
|
+
return {
|
|
487
|
+
total: 0,
|
|
488
|
+
models: []
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
/** Sum two usage tallies (pure); an absent side stays absent. */
|
|
492
|
+
function mergeUsageTally(left, right) {
|
|
493
|
+
if (left === void 0) return right;
|
|
494
|
+
if (right === void 0) return left;
|
|
495
|
+
return {
|
|
496
|
+
events: left.events + right.events,
|
|
497
|
+
tokens: left.tokens + right.tokens,
|
|
498
|
+
models: [.../* @__PURE__ */ new Set([...left.models, ...right.models])]
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
/** Add one affected sample to a tally (pure). */
|
|
502
|
+
function addUsageTally(previous, model, tokens) {
|
|
503
|
+
const models = previous?.models ?? [];
|
|
504
|
+
return {
|
|
505
|
+
events: (previous?.events ?? 0) + 1,
|
|
506
|
+
tokens: (previous?.tokens ?? 0) + tokens,
|
|
507
|
+
models: models.includes(model) ? models : [...models, model]
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* The advisory tallies carried BESIDE the priced rows, summed over `spends` as
|
|
512
|
+
* a partial you can spread into any spend literal. Keys stay absent rather
|
|
513
|
+
* than becoming `undefined` (`exactOptionalPropertyTypes`), so a fully priced
|
|
514
|
+
* spend stays structurally identical to what every existing test asserts.
|
|
515
|
+
*/
|
|
516
|
+
function usageTalliesOf(spends) {
|
|
517
|
+
let unpriced;
|
|
518
|
+
for (const spend of spends) unpriced = mergeUsageTally(unpriced, spend.unpriced);
|
|
519
|
+
return { ...unpriced === void 0 ? {} : { unpriced } };
|
|
520
|
+
}
|
|
521
|
+
/** Note one sample that matched no pricing row and was therefore not billed. */
|
|
522
|
+
function noteUnpriced(spend, model, tokens) {
|
|
523
|
+
return {
|
|
524
|
+
total: spend.total,
|
|
525
|
+
models: spend.models,
|
|
526
|
+
unpriced: addUsageTally(spend.unpriced, model, tokens)
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
/** The today-spend shape of a single priced contribution. */
|
|
530
|
+
function contributionModel(priced) {
|
|
531
|
+
return {
|
|
532
|
+
model: priced.model,
|
|
533
|
+
displayName: priced.displayName,
|
|
534
|
+
cost: priced.cost,
|
|
535
|
+
peakCost: priced.peakCost,
|
|
536
|
+
offPeakCost: priced.offPeakCost,
|
|
537
|
+
cacheHitInputTokens: priced.cacheHitInputTokens,
|
|
538
|
+
cacheMissInputTokens: priced.cacheMissInputTokens,
|
|
539
|
+
outputTokens: priced.outputTokens,
|
|
540
|
+
cacheHitInputCost: priced.cacheHitInputCost,
|
|
541
|
+
cacheMissInputCost: priced.cacheMissInputCost,
|
|
542
|
+
outputCost: priced.outputCost
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
/** Sum two model rows of the same model (pure). */
|
|
546
|
+
function mergeModelRows(left, right) {
|
|
547
|
+
return {
|
|
548
|
+
model: left.model,
|
|
549
|
+
displayName: left.displayName,
|
|
550
|
+
cost: left.cost + right.cost,
|
|
551
|
+
peakCost: left.peakCost + right.peakCost,
|
|
552
|
+
offPeakCost: left.offPeakCost + right.offPeakCost,
|
|
553
|
+
cacheHitInputTokens: left.cacheHitInputTokens + right.cacheHitInputTokens,
|
|
554
|
+
cacheMissInputTokens: left.cacheMissInputTokens + right.cacheMissInputTokens,
|
|
555
|
+
outputTokens: left.outputTokens + right.outputTokens,
|
|
556
|
+
cacheHitInputCost: left.cacheHitInputCost + right.cacheHitInputCost,
|
|
557
|
+
cacheMissInputCost: left.cacheMissInputCost + right.cacheMissInputCost,
|
|
558
|
+
outputCost: left.outputCost + right.outputCost
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* Mutable model-row accumulator behind every spend fold. Rows keep first-seen
|
|
563
|
+
* model order — the same shape a pure `addEventContribution` chain produces —
|
|
564
|
+
* so the single-pass scan path and the pure public paths cannot diverge. One
|
|
565
|
+
* `Map` lookup per contribution instead of a per-event array copy: the huge
|
|
566
|
+
* event-log folds allocate one row object per model, not one intermediate
|
|
567
|
+
* array per event.
|
|
568
|
+
*/
|
|
569
|
+
var SpendAccumulator = class {
|
|
570
|
+
rows = /* @__PURE__ */ new Map();
|
|
571
|
+
total = 0;
|
|
572
|
+
/** Add one priced contribution. */
|
|
573
|
+
add(priced) {
|
|
574
|
+
const row = contributionModel(priced);
|
|
575
|
+
const existing = this.rows.get(priced.model);
|
|
576
|
+
this.rows.set(priced.model, existing === void 0 ? row : mergeModelRows(existing, row));
|
|
577
|
+
this.total += priced.cost;
|
|
578
|
+
}
|
|
579
|
+
/** The folded spend; the accumulator stays usable afterwards. */
|
|
580
|
+
finish() {
|
|
581
|
+
return {
|
|
582
|
+
total: this.total,
|
|
583
|
+
models: [...this.rows.values()]
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
};
|
|
587
|
+
/** The additive inverse of one spend (pure): used to replace a priced sample. */
|
|
588
|
+
function negateSpend(spend) {
|
|
589
|
+
const negate = (value) => -value;
|
|
590
|
+
return {
|
|
591
|
+
total: negate(spend.total),
|
|
592
|
+
...usageTalliesOf([spend]),
|
|
593
|
+
models: spend.models.map((row) => ({
|
|
594
|
+
...row,
|
|
595
|
+
cost: negate(row.cost),
|
|
596
|
+
peakCost: negate(row.peakCost),
|
|
597
|
+
offPeakCost: negate(row.offPeakCost),
|
|
598
|
+
cacheHitInputTokens: negate(row.cacheHitInputTokens),
|
|
599
|
+
cacheMissInputTokens: negate(row.cacheMissInputTokens),
|
|
600
|
+
outputTokens: negate(row.outputTokens),
|
|
601
|
+
cacheHitInputCost: negate(row.cacheHitInputCost),
|
|
602
|
+
cacheMissInputCost: negate(row.cacheMissInputCost),
|
|
603
|
+
outputCost: negate(row.outputCost)
|
|
604
|
+
}))
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
/**
|
|
608
|
+
* Subtract one spend from another (pure). Rows that cancel out completely are
|
|
609
|
+
* dropped so a replaced sample leaves no zero row behind.
|
|
610
|
+
* @param target - the spend to subtract from.
|
|
611
|
+
* @param source - the spend to remove.
|
|
612
|
+
* @returns the difference.
|
|
613
|
+
*/
|
|
614
|
+
function subtractSpend(target, source) {
|
|
615
|
+
const rows = /* @__PURE__ */ new Map();
|
|
616
|
+
for (const row of target.models) rows.set(row.model, row);
|
|
617
|
+
for (const row of source.models) {
|
|
618
|
+
const existing = rows.get(row.model);
|
|
619
|
+
if (existing === void 0) continue;
|
|
620
|
+
const next = mergeModelRows(existing, negateSpend({
|
|
621
|
+
total: 0,
|
|
622
|
+
models: [row]
|
|
623
|
+
}).models[0]);
|
|
624
|
+
if (next.cost === 0 && next.cacheHitInputTokens === 0 && next.cacheMissInputTokens === 0 && next.outputTokens === 0) rows.delete(row.model);
|
|
625
|
+
else rows.set(row.model, next);
|
|
626
|
+
}
|
|
627
|
+
return {
|
|
628
|
+
total: target.total - source.total,
|
|
629
|
+
models: [...rows.values()],
|
|
630
|
+
...usageTalliesOf([target])
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
/** The contribution as a one-row spend (the shape a sample keeps for replacement). */
|
|
634
|
+
function contributionSpend(priced) {
|
|
635
|
+
return {
|
|
636
|
+
total: priced.cost,
|
|
637
|
+
models: [contributionModel(priced)]
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
/**
|
|
641
|
+
* Merge one priced event's contribution into an accumulator spend (pure:
|
|
642
|
+
* returns a new spend, never mutates its input).
|
|
643
|
+
* @param spend - the accumulator (per session and day, or across sessions).
|
|
644
|
+
* @param priced - the priced contribution to add.
|
|
645
|
+
* @returns the merged spend.
|
|
646
|
+
*/
|
|
647
|
+
function addEventContribution(spend, priced) {
|
|
648
|
+
const row = contributionModel(priced);
|
|
649
|
+
const rows = spend.models.map((existing) => existing.model === priced.model ? mergeModelRows(existing, row) : existing);
|
|
650
|
+
if (!rows.some((existing) => existing.model === priced.model)) rows.push(row);
|
|
651
|
+
return {
|
|
652
|
+
total: spend.total + priced.cost,
|
|
653
|
+
models: rows,
|
|
654
|
+
...usageTalliesOf([spend])
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
/**
|
|
658
|
+
* Sum two spends (per session and day, or across sessions) into one (pure:
|
|
659
|
+
* returns a new spend, never mutates its inputs).
|
|
660
|
+
* @param target - the accumulator spend.
|
|
661
|
+
* @param source - the spend to add.
|
|
662
|
+
* @returns the summed spend.
|
|
663
|
+
*/
|
|
664
|
+
function mergeTodaySpend(target, source) {
|
|
665
|
+
const rows = /* @__PURE__ */ new Map();
|
|
666
|
+
for (const row of target.models) rows.set(row.model, row);
|
|
667
|
+
for (const row of source.models) {
|
|
668
|
+
const existing = rows.get(row.model);
|
|
669
|
+
rows.set(row.model, existing === void 0 ? row : mergeModelRows(existing, row));
|
|
670
|
+
}
|
|
671
|
+
return {
|
|
672
|
+
total: target.total + source.total,
|
|
673
|
+
models: [...rows.values()],
|
|
674
|
+
...usageTalliesOf([target, source])
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
//#endregion
|
|
678
|
+
//#region lib/types/pricing-table.js
|
|
679
|
+
/**
|
|
680
|
+
* The published rate schedule: which models cost what, and since when.
|
|
681
|
+
*
|
|
682
|
+
* Data only — every policy decision about how a rate is chosen (revision
|
|
683
|
+
* lookup, peak windows, day boundaries) lives in `billing.ts`. Keeping them
|
|
684
|
+
* apart means adding a model never touches the pricing engine: the change is
|
|
685
|
+
* one row here, plus a test.
|
|
686
|
+
*
|
|
687
|
+
* Rates are CNY per 1M tokens. Rows sharing a model are that model's rate
|
|
688
|
+
* history (see {@link resolveBilling}).
|
|
689
|
+
* @module @rayadesu/dsh-llm-billing/pricing-table
|
|
340
690
|
*/
|
|
341
691
|
/**
|
|
342
692
|
* Published peak-hour windows (Beijing time): 09:00–12:00 and 14:00–18:00,
|
|
@@ -516,6 +866,20 @@ const DEFAULT_MODEL_PRICING = [
|
|
|
516
866
|
}
|
|
517
867
|
}
|
|
518
868
|
];
|
|
869
|
+
//#endregion
|
|
870
|
+
//#region lib/types/pricing.js
|
|
871
|
+
/**
|
|
872
|
+
* DeepSeek pricing: the peak/off-peak pricing table, the per-event pricing, and
|
|
873
|
+
* the resolved-billing shape. Pure functions over token usage and the pricing
|
|
874
|
+
* table, so the Remote gateway stays transport-free and the whole spend is
|
|
875
|
+
* testable without a key.
|
|
876
|
+
*
|
|
877
|
+
* The per-event pricing lives in {@link priceEvent} / {@link priceUsage}; the
|
|
878
|
+
* fold state machine that consumes it (`BillingFolder` / {@link applyBillingEvent})
|
|
879
|
+
* lives in `fold.ts`, and every scan path prices through these primitives so a
|
|
880
|
+
* pricing-table change cannot drift one path from the others.
|
|
881
|
+
* @module @rayadesu/dsh-llm-billing/pricing
|
|
882
|
+
*/
|
|
519
883
|
/**
|
|
520
884
|
* Resolve optional configuration to a pricing table, defaulting omitted or
|
|
521
885
|
* empty rows to the published rates. Schemastery materializes an absent
|
|
@@ -583,87 +947,6 @@ function ratesAt(revisions, time) {
|
|
|
583
947
|
}
|
|
584
948
|
return chosen;
|
|
585
949
|
}
|
|
586
|
-
/** Beijing is a fixed UTC+8 offset with no DST. */
|
|
587
|
-
const BEIJING_OFFSET_MS = 288e5;
|
|
588
|
-
/** Milliseconds in one day. */
|
|
589
|
-
const DAY_MS = 864e5;
|
|
590
|
-
/** Epoch day of 1970-01-01 in the civil-date algorithm below. */
|
|
591
|
-
const CIVIL_EPOCH_DAY = 719468;
|
|
592
|
-
/** Two-digit zero pad for a calendar field. */
|
|
593
|
-
function pad2(value) {
|
|
594
|
-
return value < 10 ? `0${value}` : String(value);
|
|
595
|
-
}
|
|
596
|
-
/**
|
|
597
|
-
* Civil date of an epoch day (Howard Hinnant's days-from-civil inverse):
|
|
598
|
-
* pure integer arithmetic, no `Date` allocation and no ISO-string slicing.
|
|
599
|
-
*/
|
|
600
|
-
function civilDateOf(epochDay) {
|
|
601
|
-
const shifted = epochDay + CIVIL_EPOCH_DAY;
|
|
602
|
-
const era = Math.floor(shifted / 146097);
|
|
603
|
-
const dayOfEra = shifted - era * 146097;
|
|
604
|
-
const yearOfEra = Math.floor((dayOfEra - Math.floor(dayOfEra / 1460) + Math.floor(dayOfEra / 36524) - Math.floor(dayOfEra / 146096)) / 365);
|
|
605
|
-
const year = yearOfEra + era * 400;
|
|
606
|
-
const dayOfYear = dayOfEra - (365 * yearOfEra + Math.floor(yearOfEra / 4) - Math.floor(yearOfEra / 100));
|
|
607
|
-
const monthPrime = Math.floor((5 * dayOfYear + 2) / 153);
|
|
608
|
-
const month = monthPrime + (monthPrime < 10 ? 3 : -9);
|
|
609
|
-
return {
|
|
610
|
-
year: month <= 2 ? year + 1 : year,
|
|
611
|
-
month,
|
|
612
|
-
day: dayOfYear - Math.floor((153 * monthPrime + 2) / 5) + 1
|
|
613
|
-
};
|
|
614
|
-
}
|
|
615
|
-
/**
|
|
616
|
-
* Derive the Beijing hour, weekday, and calendar-day key of one timestamp with
|
|
617
|
-
* pure integer arithmetic — every timezone-sensitive read shares this one
|
|
618
|
-
* implementation, so the pieces cannot drift apart. Callers that filter by
|
|
619
|
-
* day and then price the same event reuse the returned view, so each event is
|
|
620
|
-
* parsed exactly once. (The hot fold path runs this per committed event; the
|
|
621
|
-
* previous `Date` + `toISOString().slice()` version allocated a `Date` and a
|
|
622
|
-
* 24-character string per call.)
|
|
623
|
-
* @param time - epoch milliseconds.
|
|
624
|
-
* @throws {RangeError} when `time` is not a finite number.
|
|
625
|
-
*/
|
|
626
|
-
function beijingPartsOf(time) {
|
|
627
|
-
if (!Number.isFinite(time)) throw new RangeError(`billing: event time is not finite (${String(time)})`);
|
|
628
|
-
const shifted = time + BEIJING_OFFSET_MS;
|
|
629
|
-
const epochDay = Math.floor(shifted / DAY_MS);
|
|
630
|
-
const msOfDay = shifted - epochDay * DAY_MS;
|
|
631
|
-
const civil = civilDateOf(epochDay);
|
|
632
|
-
return {
|
|
633
|
-
time,
|
|
634
|
-
hour: Math.floor(msOfDay / 36e5),
|
|
635
|
-
weekday: ((epochDay + 4) % 7 + 7) % 7,
|
|
636
|
-
dayKey: `${civil.year}-${pad2(civil.month)}-${pad2(civil.day)}`
|
|
637
|
-
};
|
|
638
|
-
}
|
|
639
|
-
/** The Beijing (Asia/Shanghai, UTC+8, no DST) calendar-day key of a timestamp. */
|
|
640
|
-
function beijingDayKey(now) {
|
|
641
|
-
return beijingPartsOf(now.getTime()).dayKey;
|
|
642
|
-
}
|
|
643
|
-
/**
|
|
644
|
-
* The durable inherited-prefix boundary of one session: the number of leading
|
|
645
|
-
* events it inherited verbatim from its fork source, 0 for a session created
|
|
646
|
-
* without a seed. A forked session (or any seeded replay) carries that count
|
|
647
|
-
* in its session state; every event with `seq < seedLength` is a copy of an
|
|
648
|
-
* event already billed in that source session, so pricing must skip them or
|
|
649
|
-
* the same model output is counted once per copy. Accepts the durable field
|
|
650
|
-
* of both DSH runtime families (see {@link ForkBoundarySource}).
|
|
651
|
-
* @param source - the session, inspection result, header slice, or durable
|
|
652
|
-
* header carrying the boundary; `undefined` reads as 0.
|
|
653
|
-
* @returns the inherited-prefix length; 0 for an unseeded session.
|
|
654
|
-
*/
|
|
655
|
-
function forkBoundaryOf(source) {
|
|
656
|
-
if (source === void 0) return 0;
|
|
657
|
-
const inherited = source.inheritedEventCount;
|
|
658
|
-
if (inherited !== void 0 && Number.isSafeInteger(inherited)) return inherited;
|
|
659
|
-
return source.header?.seedLength ?? source.meta?.seedLength ?? source.seedLength ?? 0;
|
|
660
|
-
}
|
|
661
|
-
/** Whether a durable header marks a fork-inherited (seeded) session across both runtime families. */
|
|
662
|
-
function isSeededSession(header) {
|
|
663
|
-
if (header === void 0) return false;
|
|
664
|
-
if (header.isSeeded === true) return true;
|
|
665
|
-
return (header.seedLength ?? 0) > 0;
|
|
666
|
-
}
|
|
667
950
|
/** Whether a Beijing (hour, weekday) pair falls inside any peak-hour window. */
|
|
668
951
|
function isPeakParts(billing, hour, weekday) {
|
|
669
952
|
if (weekday === 0 || weekday === 6) return false;
|
|
@@ -714,6 +997,31 @@ function priceEventAt(parts, event, billing, names) {
|
|
|
714
997
|
if (reported === void 0) return void 0;
|
|
715
998
|
return priceUsage(parts, reported, event.data.message.source.model, billing, names);
|
|
716
999
|
}
|
|
1000
|
+
/** Every token one usage sample reports, whether or not it gets priced. */
|
|
1001
|
+
function usageTokens(usage) {
|
|
1002
|
+
return usage.inputTokens + usage.outputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0);
|
|
1003
|
+
}
|
|
1004
|
+
/** Max distinct model ids remembered for the once-only warning. */
|
|
1005
|
+
const UNPRICED_WARN_LIMIT = 64;
|
|
1006
|
+
/** Model ids already reported, so a long-running host logs each one once. */
|
|
1007
|
+
const reportedUnpricedModels = /* @__PURE__ */ new Set();
|
|
1008
|
+
/**
|
|
1009
|
+
* Report a model carrying no rate row, at most once per id (and at most
|
|
1010
|
+
* {@link UNPRICED_WARN_LIMIT} ids per process). Every figure reads as zero when
|
|
1011
|
+
* the upstream model is unknown, which looks exactly like "no usage" rather
|
|
1012
|
+
* than a missing row — the one thing this warning exists to separate.
|
|
1013
|
+
*
|
|
1014
|
+
* Deliberately NOT guessed: model ids are chosen by whoever ships the model, so
|
|
1015
|
+
* a similar-looking name says nothing about a similar price — and a wrong number
|
|
1016
|
+
* that LOOKS authoritative is worse than a visible gap. The fix is one row under
|
|
1017
|
+
* `billing.models`, which is exactly what the warning points at.
|
|
1018
|
+
* @param model - the unlisted wire model id.
|
|
1019
|
+
*/
|
|
1020
|
+
function reportUnpricedModel(model) {
|
|
1021
|
+
if (reportedUnpricedModels.has(model) || reportedUnpricedModels.size >= UNPRICED_WARN_LIMIT) return;
|
|
1022
|
+
reportedUnpricedModels.add(model);
|
|
1023
|
+
console.warn(`[llm-billing] no pricing row for model "${model}" — its usage is NOT billed; add its row under billing.models to price it`);
|
|
1024
|
+
}
|
|
717
1025
|
/**
|
|
718
1026
|
* Price one provider-reported usage sample for one model at the rates of the
|
|
719
1027
|
* sample's own Beijing-time hour and weekday — the peak or off-peak price of
|
|
@@ -755,115 +1063,17 @@ function priceUsage(parts, usage, model, billing, names) {
|
|
|
755
1063
|
outputCost
|
|
756
1064
|
};
|
|
757
1065
|
}
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
return {
|
|
761
|
-
total: 0,
|
|
762
|
-
models: []
|
|
763
|
-
};
|
|
764
|
-
}
|
|
765
|
-
/** The today-spend shape of a single priced contribution. */
|
|
766
|
-
function contributionModel(priced) {
|
|
767
|
-
return {
|
|
768
|
-
model: priced.model,
|
|
769
|
-
displayName: priced.displayName,
|
|
770
|
-
cost: priced.cost,
|
|
771
|
-
peakCost: priced.peakCost,
|
|
772
|
-
offPeakCost: priced.offPeakCost,
|
|
773
|
-
cacheHitInputTokens: priced.cacheHitInputTokens,
|
|
774
|
-
cacheMissInputTokens: priced.cacheMissInputTokens,
|
|
775
|
-
outputTokens: priced.outputTokens,
|
|
776
|
-
cacheHitInputCost: priced.cacheHitInputCost,
|
|
777
|
-
cacheMissInputCost: priced.cacheMissInputCost,
|
|
778
|
-
outputCost: priced.outputCost
|
|
779
|
-
};
|
|
780
|
-
}
|
|
781
|
-
/** Sum two model rows of the same model (pure). */
|
|
782
|
-
function mergeModelRows(left, right) {
|
|
783
|
-
return {
|
|
784
|
-
model: left.model,
|
|
785
|
-
displayName: left.displayName,
|
|
786
|
-
cost: left.cost + right.cost,
|
|
787
|
-
peakCost: left.peakCost + right.peakCost,
|
|
788
|
-
offPeakCost: left.offPeakCost + right.offPeakCost,
|
|
789
|
-
cacheHitInputTokens: left.cacheHitInputTokens + right.cacheHitInputTokens,
|
|
790
|
-
cacheMissInputTokens: left.cacheMissInputTokens + right.cacheMissInputTokens,
|
|
791
|
-
outputTokens: left.outputTokens + right.outputTokens,
|
|
792
|
-
cacheHitInputCost: left.cacheHitInputCost + right.cacheHitInputCost,
|
|
793
|
-
cacheMissInputCost: left.cacheMissInputCost + right.cacheMissInputCost,
|
|
794
|
-
outputCost: left.outputCost + right.outputCost
|
|
795
|
-
};
|
|
796
|
-
}
|
|
797
|
-
/**
|
|
798
|
-
* Mutable model-row accumulator behind every spend fold. Rows keep first-seen
|
|
799
|
-
* model order — the same shape a pure `addEventContribution` chain produces —
|
|
800
|
-
* so the single-pass scan path and the pure public paths cannot diverge. One
|
|
801
|
-
* `Map` lookup per contribution instead of a per-event array copy: the huge
|
|
802
|
-
* event-log folds allocate one row object per model, not one intermediate
|
|
803
|
-
* array per event.
|
|
804
|
-
*/
|
|
805
|
-
var SpendAccumulator = class {
|
|
806
|
-
rows = /* @__PURE__ */ new Map();
|
|
807
|
-
total = 0;
|
|
808
|
-
/** Add one priced contribution. */
|
|
809
|
-
add(priced) {
|
|
810
|
-
const row = contributionModel(priced);
|
|
811
|
-
const existing = this.rows.get(priced.model);
|
|
812
|
-
this.rows.set(priced.model, existing === void 0 ? row : mergeModelRows(existing, row));
|
|
813
|
-
this.total += priced.cost;
|
|
814
|
-
}
|
|
815
|
-
/** The folded spend; the accumulator stays usable afterwards. */
|
|
816
|
-
finish() {
|
|
817
|
-
return {
|
|
818
|
-
total: this.total,
|
|
819
|
-
models: [...this.rows.values()]
|
|
820
|
-
};
|
|
821
|
-
}
|
|
822
|
-
};
|
|
823
|
-
/** The additive inverse of one spend (pure): used to replace a priced sample. */
|
|
824
|
-
function negateSpend(spend) {
|
|
825
|
-
const negate = (value) => -value;
|
|
826
|
-
return {
|
|
827
|
-
total: negate(spend.total),
|
|
828
|
-
models: spend.models.map((row) => ({
|
|
829
|
-
...row,
|
|
830
|
-
cost: negate(row.cost),
|
|
831
|
-
peakCost: negate(row.peakCost),
|
|
832
|
-
offPeakCost: negate(row.offPeakCost),
|
|
833
|
-
cacheHitInputTokens: negate(row.cacheHitInputTokens),
|
|
834
|
-
cacheMissInputTokens: negate(row.cacheMissInputTokens),
|
|
835
|
-
outputTokens: negate(row.outputTokens),
|
|
836
|
-
cacheHitInputCost: negate(row.cacheHitInputCost),
|
|
837
|
-
cacheMissInputCost: negate(row.cacheMissInputCost),
|
|
838
|
-
outputCost: negate(row.outputCost)
|
|
839
|
-
}))
|
|
840
|
-
};
|
|
841
|
-
}
|
|
1066
|
+
//#endregion
|
|
1067
|
+
//#region lib/types/fold.js
|
|
842
1068
|
/**
|
|
843
|
-
*
|
|
844
|
-
*
|
|
845
|
-
*
|
|
846
|
-
*
|
|
847
|
-
* @
|
|
1069
|
+
* The single-pass fold state machine that turns a committed event log into a
|
|
1070
|
+
* session's billed spend. Consumes the pricing primitives in `pricing.ts`
|
|
1071
|
+
* (`priceUsage` / `reportUnpricedModel`) and the spend algebra in `spend.ts`
|
|
1072
|
+
* (`addEventContribution` / `subtractSpend` / `noteUnpriced` / `emptyTodaySpend`);
|
|
1073
|
+
* every scan path prices through {@link applyBillingEvent} so a pricing-table
|
|
1074
|
+
* change cannot drift one path from the others.
|
|
1075
|
+
* @module @rayadesu/dsh-llm-billing/fold
|
|
848
1076
|
*/
|
|
849
|
-
function subtractSpend(target, source) {
|
|
850
|
-
const rows = /* @__PURE__ */ new Map();
|
|
851
|
-
for (const row of target.models) rows.set(row.model, row);
|
|
852
|
-
for (const row of source.models) {
|
|
853
|
-
const existing = rows.get(row.model);
|
|
854
|
-
if (existing === void 0) continue;
|
|
855
|
-
const next = mergeModelRows(existing, negateSpend({
|
|
856
|
-
total: 0,
|
|
857
|
-
models: [row]
|
|
858
|
-
}).models[0]);
|
|
859
|
-
if (next.cost === 0 && next.cacheHitInputTokens === 0 && next.cacheMissInputTokens === 0 && next.outputTokens === 0) rows.delete(row.model);
|
|
860
|
-
else rows.set(row.model, next);
|
|
861
|
-
}
|
|
862
|
-
return {
|
|
863
|
-
total: target.total - source.total,
|
|
864
|
-
models: [...rows.values()]
|
|
865
|
-
};
|
|
866
|
-
}
|
|
867
1077
|
/** The empty fold state for one fork boundary. */
|
|
868
1078
|
function emptyBillingFoldState(inheritedEventCount = 0) {
|
|
869
1079
|
return {
|
|
@@ -896,13 +1106,6 @@ function streamUsageOf(event) {
|
|
|
896
1106
|
return isTokenUsage(chunk.usage) ? chunk.usage : void 0;
|
|
897
1107
|
}
|
|
898
1108
|
}
|
|
899
|
-
/** The contribution as a one-row spend (the shape a sample keeps for replacement). */
|
|
900
|
-
function contributionSpend(priced) {
|
|
901
|
-
return {
|
|
902
|
-
total: priced.cost,
|
|
903
|
-
models: [contributionModel(priced)]
|
|
904
|
-
};
|
|
905
|
-
}
|
|
906
1109
|
/**
|
|
907
1110
|
* Fold one committed event into a session's billed-spend state.
|
|
908
1111
|
*
|
|
@@ -944,7 +1147,20 @@ function applyBillingEvent(state, event, billing, names) {
|
|
|
944
1147
|
if (!isTokenUsage(usage)) return state;
|
|
945
1148
|
const model = type === "assistant/message" ? data?.message?.source?.model : state.model;
|
|
946
1149
|
if (typeof model !== "string" || model.length === 0) return state;
|
|
947
|
-
const
|
|
1150
|
+
const parts = beijingPartsOf(event.time);
|
|
1151
|
+
if (billing.models.get(model) === void 0) {
|
|
1152
|
+
reportUnpricedModel(model);
|
|
1153
|
+
const tokens = usageTokens(usage);
|
|
1154
|
+
const opens = state.dayKey === "" || parts.dayKey > state.dayKey;
|
|
1155
|
+
const sameDay = parts.dayKey === state.dayKey;
|
|
1156
|
+
return {
|
|
1157
|
+
...state,
|
|
1158
|
+
dayKey: opens ? parts.dayKey : state.dayKey,
|
|
1159
|
+
spend: opens ? noteUnpriced(emptyTodaySpend(), model, tokens) : sameDay ? noteUnpriced(state.spend, model, tokens) : state.spend,
|
|
1160
|
+
session: noteUnpriced(state.session, model, tokens)
|
|
1161
|
+
};
|
|
1162
|
+
}
|
|
1163
|
+
const priced = priceUsage(parts, usage, model, billing, names);
|
|
948
1164
|
if (priced === void 0) return state;
|
|
949
1165
|
let session = state.session;
|
|
950
1166
|
let spend = state.spend;
|
|
@@ -1006,40 +1222,43 @@ var BillingFolder = class {
|
|
|
1006
1222
|
return this.state;
|
|
1007
1223
|
}
|
|
1008
1224
|
};
|
|
1225
|
+
//#endregion
|
|
1226
|
+
//#region lib/types/billing.js
|
|
1009
1227
|
/**
|
|
1010
|
-
*
|
|
1011
|
-
*
|
|
1012
|
-
*
|
|
1013
|
-
*
|
|
1014
|
-
*
|
|
1228
|
+
* DeepSeek billing aggregation: the session/turn/today spend computations
|
|
1229
|
+
* over the pricing engine (`pricing.ts`) and the per-event fold state machine
|
|
1230
|
+
* (`fold.ts`). Pure functions over session events, so the whole spend is
|
|
1231
|
+
* testable without a key and the Remote gateway stays transport-free.
|
|
1232
|
+
*
|
|
1233
|
+
* The fold in {@link BillingFolder} / {@link applyBillingEvent} (see `fold.ts`)
|
|
1234
|
+
* and the per-event pricing in `pricing.ts` are the single source of truth the
|
|
1235
|
+
* events-scan paths, the session-projection unit, and the scanner all price
|
|
1236
|
+
* through, so a pricing-table change cannot drift one path from the others.
|
|
1237
|
+
* @module @rayadesu/dsh-llm-billing/billing
|
|
1015
1238
|
*/
|
|
1016
|
-
function addEventContribution(spend, priced) {
|
|
1017
|
-
const row = contributionModel(priced);
|
|
1018
|
-
const rows = spend.models.map((existing) => existing.model === priced.model ? mergeModelRows(existing, row) : existing);
|
|
1019
|
-
if (!rows.some((existing) => existing.model === priced.model)) rows.push(row);
|
|
1020
|
-
return {
|
|
1021
|
-
total: spend.total + priced.cost,
|
|
1022
|
-
models: rows
|
|
1023
|
-
};
|
|
1024
|
-
}
|
|
1025
1239
|
/**
|
|
1026
|
-
*
|
|
1027
|
-
*
|
|
1028
|
-
*
|
|
1029
|
-
*
|
|
1030
|
-
*
|
|
1240
|
+
* The durable inherited-prefix boundary of one session: the number of leading
|
|
1241
|
+
* events it inherited verbatim from its fork source, 0 for a session created
|
|
1242
|
+
* without a seed. A forked session (or any seeded replay) carries that count
|
|
1243
|
+
* in its session state; every event with `seq < seedLength` is a copy of an
|
|
1244
|
+
* event already billed in that source session, so pricing must skip them or
|
|
1245
|
+
* the same model output is counted once per copy. Accepts the durable field
|
|
1246
|
+
* of the live-session and handle surfaces (see {@link ForkBoundarySource}).
|
|
1247
|
+
* @param source - the session, header slice, or opened handle carrying the
|
|
1248
|
+
* boundary; `undefined` reads as 0.
|
|
1249
|
+
* @returns the inherited-prefix length; 0 for an unseeded session.
|
|
1031
1250
|
*/
|
|
1032
|
-
function
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1251
|
+
function forkBoundaryOf(source) {
|
|
1252
|
+
if (source === void 0) return 0;
|
|
1253
|
+
const inherited = source.inheritedEventCount;
|
|
1254
|
+
if (inherited !== void 0 && Number.isSafeInteger(inherited)) return inherited;
|
|
1255
|
+
return source.header?.seedLength ?? source.seedLength ?? 0;
|
|
1256
|
+
}
|
|
1257
|
+
/** Whether a durable header marks a fork-inherited (seeded) session across the live-session and handle surfaces. */
|
|
1258
|
+
function isSeededSession(header) {
|
|
1259
|
+
if (header === void 0) return false;
|
|
1260
|
+
if (header.isSeeded === true) return true;
|
|
1261
|
+
return (header.seedLength ?? 0) > 0;
|
|
1043
1262
|
}
|
|
1044
1263
|
/**
|
|
1045
1264
|
* Price one session's complete event log at the official per-model rates,
|
|
@@ -1222,9 +1441,10 @@ function computeSessionTurnSpends(events, billing, catalog) {
|
|
|
1222
1441
|
*/
|
|
1223
1442
|
function computeTodaySpend(events, billing, catalog, now = /* @__PURE__ */ new Date()) {
|
|
1224
1443
|
const day = beijingDayKey(now);
|
|
1444
|
+
const dayEnd = beijingDayRangeOfKey(day)?.end ?? Number.POSITIVE_INFINITY;
|
|
1225
1445
|
const folder = new BillingFolder(billing, catalog);
|
|
1226
1446
|
for (const event of events) {
|
|
1227
|
-
if (
|
|
1447
|
+
if (event.time >= dayEnd) continue;
|
|
1228
1448
|
folder.add(event);
|
|
1229
1449
|
}
|
|
1230
1450
|
return folder.fold.dayKey === day ? folder.fold.spend : emptyTodaySpend();
|
|
@@ -1264,9 +1484,23 @@ const modelRowSchema = z$1.object({
|
|
|
1264
1484
|
cacheMissInputCost: z$1.number().nonnegative(),
|
|
1265
1485
|
outputCost: z$1.number().nonnegative()
|
|
1266
1486
|
}).strict();
|
|
1487
|
+
/**
|
|
1488
|
+
* The advisory tally of usage that could not be billed at its own rate (see
|
|
1489
|
+
* `DeepSeekTodaySpend.unpriced`). Declared here because the
|
|
1490
|
+
* unit's schemas are `.strict()`: an undeclared key REJECTS the whole unit
|
|
1491
|
+
* rather than being stripped, so adding these fields to the fold without
|
|
1492
|
+
* declaring them here would fail every projection fold the moment a sample
|
|
1493
|
+
* went unpriced.
|
|
1494
|
+
*/
|
|
1495
|
+
const usageTallySchema = z$1.object({
|
|
1496
|
+
events: z$1.number().int().nonnegative(),
|
|
1497
|
+
tokens: z$1.number().int().nonnegative(),
|
|
1498
|
+
models: z$1.array(z$1.string())
|
|
1499
|
+
}).strict();
|
|
1267
1500
|
const todaySpendSchema = z$1.object({
|
|
1268
1501
|
total: z$1.number().nonnegative(),
|
|
1269
|
-
models: z$1.array(modelRowSchema)
|
|
1502
|
+
models: z$1.array(modelRowSchema),
|
|
1503
|
+
unpriced: usageTallySchema.optional()
|
|
1270
1504
|
}).strict();
|
|
1271
1505
|
const billingUnitSchema = z$1.object({
|
|
1272
1506
|
dayKey: z$1.string(),
|
|
@@ -1336,67 +1570,13 @@ function foldOwnBilling(unit, events, seedLength = 0) {
|
|
|
1336
1570
|
return state;
|
|
1337
1571
|
}
|
|
1338
1572
|
//#endregion
|
|
1339
|
-
//#region lib/types/
|
|
1340
|
-
/**
|
|
1341
|
-
* Today-spend read path: the 60-second Beijing-day cache with in-flight
|
|
1342
|
-
* coalescing and a force bypass (plan A1), plus the two scan strategies that
|
|
1343
|
-
* compute the aggregate behind a cache miss:
|
|
1344
|
-
*
|
|
1345
|
-
* - projection path (plan C): live sessions read their eagerly folded
|
|
1346
|
-
* `billingTodaySpend` projection cell; cold sessions are answered from the
|
|
1347
|
-
* zero-I/O projection-cache row whenever that row's own day is not the
|
|
1348
|
-
* queried one, and otherwise resolved through one detached local fold over
|
|
1349
|
-
* a full `inspect`. Persisted revisions gate every cold read, so a session
|
|
1350
|
-
* whose log did not change since the last resolution costs nothing — and a
|
|
1351
|
-
* failed resolution is remembered by revision instead of being retried on
|
|
1352
|
-
* every scan.
|
|
1353
|
-
* - events path (plans A2/A3): collect and price only today's events in one
|
|
1354
|
-
* pass (per-event Beijing-day filter during collection) with a hard cap,
|
|
1355
|
-
* adopting the fold it already priced for a session whose persisted revision
|
|
1356
|
-
* is unchanged since the last pass.
|
|
1357
|
-
*
|
|
1358
|
-
* Both strategies run behind the same {@link TodaySpendCache}, so a miss
|
|
1359
|
-
* happens at most once per 60 seconds per process, and a manual refresh
|
|
1360
|
-
* (`force`) bypasses the time window but keeps the revision caches — an
|
|
1361
|
-
* unchanged log provably cannot change the aggregate. That proof is what makes
|
|
1362
|
-
* a revision gate a CACHE: both strategies therefore adopt the resolution they
|
|
1363
|
-
* remember for an unchanged revision instead of skipping the session, so an
|
|
1364
|
-
* unchanged log costs no I/O and still contributes its full spend (and title)
|
|
1365
|
-
* to the aggregate and the ranking on every scan.
|
|
1366
|
-
*
|
|
1367
|
-
* The per-session ranking is a per-CONVERSATION ranking: a subagent child is
|
|
1368
|
-
* work the delegating conversation paid for, not a session the user opened, so
|
|
1369
|
-
* every subagent row is folded into the row of the top-level session at the
|
|
1370
|
-
* root of its `parentSession` chain (see {@link rollUpSubagentSpend}). The
|
|
1371
|
-
* aggregate is unaffected — it sums the same sessions either way.
|
|
1372
|
-
*
|
|
1373
|
-
* Forked sessions never double-count: a fork child's log opens with a
|
|
1374
|
-
* verbatim copy of its source session's events (its inherited boundary), so
|
|
1375
|
-
* the scanner prices only the child's OWN events on every path. The
|
|
1376
|
-
* `billingTodaySpend` unit is boundary-aware (its state carries the inherited
|
|
1377
|
-
* cut, and `apply` skips events below it), so the eager cell is correct for a
|
|
1378
|
-
* fork child; the cold path skips the projection cache for a seeded session
|
|
1379
|
-
* (its cached row may predate the boundary) and folds its own events with the
|
|
1380
|
-
* durable cut instead. The boundary is the durable session state, read across
|
|
1381
|
-
* both DSH runtime families — a resumed fork child keeps its original
|
|
1382
|
-
* boundary and an unseeded session stays at 0.
|
|
1383
|
-
*
|
|
1384
|
-
* The live `Session` log surface changed in 0.1.2-alpha.4: `Session.events`
|
|
1385
|
-
* was removed and replaced by `Session.snapshotEvents()` / `ownEvents()`, and
|
|
1386
|
-
* `SessionHeader.seedLength` moved to `Session.inheritedEventCount` (the
|
|
1387
|
-
* persistence `inspect` result carries it alongside `meta`). Reads go through
|
|
1388
|
-
* {@link liveSessionEvents} / {@link forkBoundaryOf}, which accept both
|
|
1389
|
-
* families structurally, so the scanner runs on the ≤ 0.1.1-rc.2 npm baseline
|
|
1390
|
-
* and on the newer runtime.
|
|
1391
|
-
* @module @rayadesu/dsh-llm-billing/today-spend
|
|
1392
|
-
*/
|
|
1573
|
+
//#region lib/types/session-lineage.js
|
|
1393
1574
|
/**
|
|
1394
|
-
*
|
|
1395
|
-
*
|
|
1396
|
-
*
|
|
1397
|
-
*
|
|
1398
|
-
* @
|
|
1399
|
-
* @returns the session's current title, or `null` when untitled.
|
|
1575
|
+
* Session lineage and titles: which session delegated which, where a subtree's
|
|
1576
|
+
* spend belongs, and the title a ranking row is labelled with. Split out of
|
|
1577
|
+
* `today-spend.ts` because neither the cache nor the scan strategies care
|
|
1578
|
+
* about lineage for their own sake — they only ask for it.
|
|
1579
|
+
* @module @rayadesu/dsh-llm-billing/session-lineage
|
|
1400
1580
|
*/
|
|
1401
1581
|
function foldSessionTitle(events) {
|
|
1402
1582
|
for (let index = events.length - 1; index >= 0; index--) {
|
|
@@ -1481,68 +1661,23 @@ function rollUpSubagentSpend(rows, lineage, titles = /* @__PURE__ */ new Map())
|
|
|
1481
1661
|
return [...merged.values()].sort((left, right) => right.total - left.total);
|
|
1482
1662
|
}
|
|
1483
1663
|
/**
|
|
1484
|
-
*
|
|
1485
|
-
*
|
|
1486
|
-
* the newer `snapshotEvents()` reader — an unknown runtime surface must
|
|
1487
|
-
* fail loudly rather than silently price an empty log.
|
|
1488
|
-
*/
|
|
1489
|
-
function liveSessionEvents(session) {
|
|
1490
|
-
if (session.events !== void 0) return session.events;
|
|
1491
|
-
if (session.snapshotEvents !== void 0) return session.snapshotEvents();
|
|
1492
|
-
throw new Error("llm-billing: session log surface is neither Session.events nor Session.snapshotEvents");
|
|
1493
|
-
}
|
|
1494
|
-
/**
|
|
1495
|
-
* Unwrap a handle read across both return shapes.
|
|
1496
|
-
* @param read - the handle's read result.
|
|
1497
|
-
* @returns the event array.
|
|
1498
|
-
*/
|
|
1499
|
-
function handleReadEvents(read) {
|
|
1500
|
-
if (Array.isArray(read)) return read;
|
|
1501
|
-
return read.events;
|
|
1502
|
-
}
|
|
1503
|
-
function isHandlePersistence(persistence) {
|
|
1504
|
-
return typeof persistence.open === "function";
|
|
1505
|
-
}
|
|
1506
|
-
/**
|
|
1507
|
-
* List every stored session snapshot across both persistence runtime families:
|
|
1508
|
-
* `listSnapshots` (≤ 0.1.1-rc.2) or `list` (0.1.2-alpha.5+).
|
|
1509
|
-
* @param persistence - the persistence service slice.
|
|
1510
|
-
* @returns one snapshot per stored session.
|
|
1664
|
+
* Structural slice of a live session's header: the fork boundary of both DSH
|
|
1665
|
+
* runtime families plus the delegation lineage the ranking roll-up reads.
|
|
1511
1666
|
*/
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
}
|
|
1667
|
+
//#endregion
|
|
1668
|
+
//#region lib/types/cache.js
|
|
1515
1669
|
/**
|
|
1516
|
-
*
|
|
1517
|
-
*
|
|
1518
|
-
*
|
|
1519
|
-
*
|
|
1520
|
-
* @
|
|
1521
|
-
* @param id - the stored session to read.
|
|
1522
|
-
* @returns the session's complete event log plus its inherited-prefix boundary.
|
|
1670
|
+
* The read-path cache: one 60-second Beijing-day window per process with
|
|
1671
|
+
* in-flight coalescing and stale-while-revalidate, plus the bounded fan-out
|
|
1672
|
+
* helper the cold scan parallelises with. Split out of `today-spend.ts` — it is
|
|
1673
|
+
* the one piece that knows nothing about sessions at all.
|
|
1674
|
+
* @module @rayadesu/dsh-llm-billing/cache
|
|
1523
1675
|
*/
|
|
1524
|
-
async function persistenceInspect(persistence, id) {
|
|
1525
|
-
if (isHandlePersistence(persistence)) {
|
|
1526
|
-
const handle = await persistence.open(id, "read");
|
|
1527
|
-
try {
|
|
1528
|
-
return {
|
|
1529
|
-
events: handleReadEvents(await handle.read()),
|
|
1530
|
-
seedLength: forkBoundaryOf(handle)
|
|
1531
|
-
};
|
|
1532
|
-
} finally {
|
|
1533
|
-
await handle.close();
|
|
1534
|
-
}
|
|
1535
|
-
}
|
|
1536
|
-
const inspection = await persistence.inspect(id);
|
|
1537
|
-
return {
|
|
1538
|
-
events: inspection.events,
|
|
1539
|
-
seedLength: forkBoundaryOf(inspection)
|
|
1540
|
-
};
|
|
1541
|
-
}
|
|
1542
1676
|
/**
|
|
1543
1677
|
* Bounded parallel fan-out: run `run` over `items` with at most `limit` in
|
|
1544
1678
|
* flight. A shared index counter hands each worker its next job, so the
|
|
1545
|
-
* dispatch is O(n) overall (array `shift()` would be O(n) per pop).
|
|
1679
|
+
* dispatch is O(n) overall (array `shift()` would be O(n) per pop). The cold
|
|
1680
|
+
* scan parallelises its session reads through this.
|
|
1546
1681
|
*/
|
|
1547
1682
|
async function withConcurrency(items, limit, run) {
|
|
1548
1683
|
const total = items.length;
|
|
@@ -1555,10 +1690,29 @@ async function withConcurrency(items, limit, run) {
|
|
|
1555
1690
|
}));
|
|
1556
1691
|
}
|
|
1557
1692
|
/**
|
|
1693
|
+
* Hand the host's event loop back between session folds.
|
|
1694
|
+
*
|
|
1695
|
+
* The folds are synchronous CPU work — the pricing fold and `foldSessionTitle`
|
|
1696
|
+
* walk a whole log without an `await` — and they run on the same main loop that
|
|
1697
|
+
* serves the GUI's own round trips (switching model, creating a session). A
|
|
1698
|
+
* scan that never yields blocks those requests for its entire duration, which
|
|
1699
|
+
* is exactly what a cold scan did. `setImmediate` schedules the continuation
|
|
1700
|
+
* for the check phase, so the poll phase is served first: pending I/O — the
|
|
1701
|
+
* GUI's requests among it — completes before the next slice of folding.
|
|
1702
|
+
*/
|
|
1703
|
+
function yieldToEventLoop() {
|
|
1704
|
+
return new Promise((resolve) => {
|
|
1705
|
+
setImmediate(resolve);
|
|
1706
|
+
});
|
|
1707
|
+
}
|
|
1708
|
+
/**
|
|
1558
1709
|
* The A1 cache: one Beijing-day key + a 60s window, an in-flight promise that
|
|
1559
|
-
* coalesces concurrent misses,
|
|
1560
|
-
*
|
|
1561
|
-
*
|
|
1710
|
+
* coalesces concurrent misses, a stale-while-revalidate path so a lapsed window
|
|
1711
|
+
* never makes a plain reader wait on the day's scan, and a `force` bypass that
|
|
1712
|
+
* makes a reader wait for a fresh scan instead. Cross-day invalidation is
|
|
1713
|
+
* automatic (the day key changes, and the previous day's value is never
|
|
1714
|
+
* served); a failed scan leaves the previous value in place and retries on the
|
|
1715
|
+
* next call.
|
|
1562
1716
|
* @typeParam T - the cached aggregate's value shape (the spend or its
|
|
1563
1717
|
* per-session breakdown).
|
|
1564
1718
|
*/
|
|
@@ -1582,26 +1736,60 @@ var TodaySpendCache = class {
|
|
|
1582
1736
|
}
|
|
1583
1737
|
/**
|
|
1584
1738
|
* Read today's spend, cached per Beijing day within the TTL window.
|
|
1585
|
-
*
|
|
1586
|
-
*
|
|
1739
|
+
*
|
|
1740
|
+
* Two reads share this entry point, and `force` is the switch between them —
|
|
1741
|
+
* its two values are exactly the two policies a caller can want, so there is
|
|
1742
|
+
* no third mode:
|
|
1743
|
+
*
|
|
1744
|
+
* - **plain (`force: false`)** — stale-while-revalidate. A value for the
|
|
1745
|
+
* queried day is returned AT ONCE; once its window has lapsed the scan that
|
|
1746
|
+
* refreshes it runs behind the answer. This is the read for everything the
|
|
1747
|
+
* user did not just cause: a mount, a panel open, a poll, a session switch.
|
|
1748
|
+
* - **forced (`force: true`)** — recompute now and WAIT: both the window and
|
|
1749
|
+
* the stale shortcut are bypassed. This is the read for a manual refresh and
|
|
1750
|
+
* for a settled turn, which have just changed the answer and must not be
|
|
1751
|
+
* handed the value from before they ran. A plain read there would report the
|
|
1752
|
+
* day's pre-turn figure one turn late.
|
|
1753
|
+
*
|
|
1754
|
+
* A day with no value yet (the first read of the day, or a Beijing-day
|
|
1755
|
+
* rollover — yesterday's total is never served as today's) waits either way,
|
|
1756
|
+
* because there is nothing honest to show. A failed revalidation leaves the
|
|
1757
|
+
* previous value in place, and a pass already in flight is joined rather than
|
|
1758
|
+
* duplicated.
|
|
1759
|
+
* @param force - `true` recomputes now and waits; `false` serves the value on
|
|
1760
|
+
* hand and refreshes behind it.
|
|
1587
1761
|
* @returns today's spend.
|
|
1588
1762
|
*/
|
|
1589
1763
|
get(force = false) {
|
|
1590
1764
|
const now = this.now();
|
|
1591
1765
|
const dayKey = beijingDayKey(now);
|
|
1592
|
-
|
|
1766
|
+
const cached = this.cachedDayKey === dayKey ? this.cachedValue : void 0;
|
|
1767
|
+
if (!force && cached !== void 0) {
|
|
1768
|
+
if (now.getTime() - this.cachedAt < this.ttlMs) return Promise.resolve(cached);
|
|
1769
|
+
if (this.inFlight === void 0) this.run(dayKey).catch(() => void 0);
|
|
1770
|
+
return Promise.resolve(cached);
|
|
1771
|
+
}
|
|
1593
1772
|
if (this.inFlight !== void 0) return this.inFlight;
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1773
|
+
return this.run(dayKey);
|
|
1774
|
+
}
|
|
1775
|
+
/**
|
|
1776
|
+
* Run one scan and publish its result.
|
|
1777
|
+
*
|
|
1778
|
+
* `cachedAt` is stamped when the scan COMPLETES, not when it starts: a pass
|
|
1779
|
+
* slower than the TTL would otherwise be born expired, and every read after
|
|
1780
|
+
* it would start another pass back-to-back.
|
|
1781
|
+
* @param dayKey - the Beijing day the pass aggregates.
|
|
1782
|
+
* @returns the freshly scanned value.
|
|
1783
|
+
*/
|
|
1784
|
+
run(dayKey) {
|
|
1785
|
+
const run = this.scan(dayKey).then((value) => {
|
|
1786
|
+
this.cachedDayKey = dayKey;
|
|
1787
|
+
this.cachedValue = value;
|
|
1788
|
+
this.cachedAt = this.now().getTime();
|
|
1789
|
+
return value;
|
|
1790
|
+
}).finally(() => {
|
|
1791
|
+
this.inFlight = void 0;
|
|
1792
|
+
});
|
|
1605
1793
|
this.inFlight = run;
|
|
1606
1794
|
return run;
|
|
1607
1795
|
}
|
|
@@ -1610,6 +1798,121 @@ var TodaySpendCache = class {
|
|
|
1610
1798
|
const COLD_RESOLVE_CACHE_LIMIT = 1024;
|
|
1611
1799
|
/** Max session-ids kept in the scanner's cold-failure cache before eviction. */
|
|
1612
1800
|
const COLD_FAILED_CACHE_LIMIT = 1024;
|
|
1801
|
+
/** The live SessionStore slice a scan reads (resolved once per scan). */
|
|
1802
|
+
//#endregion
|
|
1803
|
+
//#region lib/types/persistence.js
|
|
1804
|
+
/**
|
|
1805
|
+
* The persistence seams a cold scan reads through. DSH ships a single
|
|
1806
|
+
* persistence runtime family — the handle-based `list` / `open` +
|
|
1807
|
+
* `SessionHandle` surface (0.1.2-alpha.5+, the only one the plugin's
|
|
1808
|
+
* `^0.2.0-rc.1` peer targets). This module is the only place that knows
|
|
1809
|
+
* the handle shape. Split out of `today-spend.ts` so the scan strategies read
|
|
1810
|
+
* one shape.
|
|
1811
|
+
* @module @rayadesu/dsh-llm-billing/persistence
|
|
1812
|
+
*/
|
|
1813
|
+
/**
|
|
1814
|
+
* Read one live session's complete event log across both runtime families.
|
|
1815
|
+
* @throws when the session exposes neither the legacy `events` snapshot nor
|
|
1816
|
+
* the newer `snapshotEvents()` reader — an unknown runtime surface must
|
|
1817
|
+
* fail loudly rather than silently price an empty log.
|
|
1818
|
+
*/
|
|
1819
|
+
function liveSessionEvents(session) {
|
|
1820
|
+
if (session.events !== void 0) return session.events;
|
|
1821
|
+
if (session.snapshotEvents !== void 0) return session.snapshotEvents();
|
|
1822
|
+
throw new Error("llm-billing: session log surface is neither Session.events nor Session.snapshotEvents");
|
|
1823
|
+
}
|
|
1824
|
+
/**
|
|
1825
|
+
* Unwrap a handle read across both return shapes.
|
|
1826
|
+
* @param read - the handle's read result.
|
|
1827
|
+
* @returns the event array.
|
|
1828
|
+
*/
|
|
1829
|
+
function handleReadEvents(read) {
|
|
1830
|
+
if (Array.isArray(read)) return read;
|
|
1831
|
+
return read.events;
|
|
1832
|
+
}
|
|
1833
|
+
/**
|
|
1834
|
+
* List every stored session snapshot through the handle-based persistence
|
|
1835
|
+
* service (`list`).
|
|
1836
|
+
* @param persistence - the persistence service slice.
|
|
1837
|
+
* @returns one snapshot per stored session.
|
|
1838
|
+
*/
|
|
1839
|
+
function persistenceListSnapshots(persistence) {
|
|
1840
|
+
return persistence.list();
|
|
1841
|
+
}
|
|
1842
|
+
/**
|
|
1843
|
+
* Read one stored session's complete event log and durable inherited boundary
|
|
1844
|
+
* through the handle-based persistence service (`open` + handle `read`; the
|
|
1845
|
+
* handle is closed after the read). Throws when the session does not exist.
|
|
1846
|
+
* @param persistence - the persistence service slice.
|
|
1847
|
+
* @param id - the stored session to read.
|
|
1848
|
+
* @returns the session's complete event log plus its inherited-prefix boundary.
|
|
1849
|
+
*/
|
|
1850
|
+
async function persistenceInspect(persistence, id) {
|
|
1851
|
+
const handle = await persistence.open(id, "read");
|
|
1852
|
+
try {
|
|
1853
|
+
return {
|
|
1854
|
+
events: handleReadEvents(await handle.read()),
|
|
1855
|
+
seedLength: forkBoundaryOf(handle)
|
|
1856
|
+
};
|
|
1857
|
+
} finally {
|
|
1858
|
+
await handle.close();
|
|
1859
|
+
}
|
|
1860
|
+
}
|
|
1861
|
+
//#endregion
|
|
1862
|
+
//#region lib/types/today-spend.js
|
|
1863
|
+
/**
|
|
1864
|
+
* Today-spend read path: the 60-second Beijing-day cache with in-flight
|
|
1865
|
+
* coalescing and a force bypass (plan A1), plus the two scan strategies that
|
|
1866
|
+
* compute the aggregate behind a cache miss:
|
|
1867
|
+
*
|
|
1868
|
+
* - projection path (plan C): live sessions read their eagerly folded
|
|
1869
|
+
* `billingTodaySpend` projection cell; cold sessions are answered from the
|
|
1870
|
+
* zero-I/O projection-cache row whenever that row's own day is not the
|
|
1871
|
+
* queried one, and otherwise resolved through one detached local fold over
|
|
1872
|
+
* a full `open` + handle read. Persisted revisions gate every cold read, so a session
|
|
1873
|
+
* whose log did not change since the last resolution costs nothing — and a
|
|
1874
|
+
* failed resolution is remembered by revision instead of being retried on
|
|
1875
|
+
* every scan.
|
|
1876
|
+
* - events path (plans A2/A3): collect and price only today's events in one
|
|
1877
|
+
* pass (per-event Beijing-day filter during collection) with a hard cap,
|
|
1878
|
+
* adopting the fold it already priced for a session whose persisted revision
|
|
1879
|
+
* is unchanged since the last pass.
|
|
1880
|
+
*
|
|
1881
|
+
* Both strategies run behind the same {@link TodaySpendCache}, so a miss
|
|
1882
|
+
* happens at most once per 60 seconds per process, and a manual refresh
|
|
1883
|
+
* (`force`) bypasses the time window but keeps the revision caches — an
|
|
1884
|
+
* unchanged log provably cannot change the aggregate. That proof is what makes
|
|
1885
|
+
* a revision gate a CACHE: both strategies therefore adopt the resolution they
|
|
1886
|
+
* remember for an unchanged revision instead of skipping the session, so an
|
|
1887
|
+
* unchanged log costs no I/O and still contributes its full spend (and title)
|
|
1888
|
+
* to the aggregate and the ranking on every scan.
|
|
1889
|
+
*
|
|
1890
|
+
* The per-session ranking is a per-CONVERSATION ranking: a subagent child is
|
|
1891
|
+
* work the delegating conversation paid for, not a session the user opened, so
|
|
1892
|
+
* every subagent row is folded into the row of the top-level session at the
|
|
1893
|
+
* root of its `parentSession` chain (see {@link rollUpSubagentSpend}). The
|
|
1894
|
+
* aggregate is unaffected — it sums the same sessions either way.
|
|
1895
|
+
*
|
|
1896
|
+
* Forked sessions never double-count: a fork child's log opens with a
|
|
1897
|
+
* verbatim copy of its source session's events (its inherited boundary), so
|
|
1898
|
+
* the scanner prices only the child's OWN events on every path. The
|
|
1899
|
+
* `billingTodaySpend` unit is boundary-aware (its state carries the inherited
|
|
1900
|
+
* cut, and `apply` skips events below it), so the eager cell is correct for a
|
|
1901
|
+
* fork child; the cold path skips the projection cache for a seeded session
|
|
1902
|
+
* (its cached row may predate the boundary) and folds its own events with the
|
|
1903
|
+
* durable cut instead. The boundary is the durable session state, read from the
|
|
1904
|
+
* live-session and handle surfaces — a resumed fork child keeps its original
|
|
1905
|
+
* boundary and an unseeded session stays at 0.
|
|
1906
|
+
*
|
|
1907
|
+
* The live `Session` log surface changed in 0.1.2-alpha.4: `Session.events`
|
|
1908
|
+
* was removed and replaced by `Session.snapshotEvents()` / `ownEvents()`, and
|
|
1909
|
+
* `SessionHeader.seedLength` moved to `Session.inheritedEventCount` (the
|
|
1910
|
+
* opened `SessionHandle` carries it alongside `header`). Reads go through
|
|
1911
|
+
* {@link liveSessionEvents} / {@link forkBoundaryOf}, which accept the
|
|
1912
|
+
* live-session and handle shapes structurally, so the scanner runs on the
|
|
1913
|
+
* older `events` runtime and on the newer `snapshotEvents` runtime.
|
|
1914
|
+
* @module @rayadesu/dsh-llm-billing/today-spend
|
|
1915
|
+
*/
|
|
1613
1916
|
/**
|
|
1614
1917
|
* Bounded-map eviction: drop the oldest inserted entry once `size` reached
|
|
1615
1918
|
* `limit`. Evicting one entry (instead of clearing) keeps the other sessions'
|
|
@@ -1678,7 +1981,12 @@ var TodaySpendScanner = class {
|
|
|
1678
1981
|
* from the aggregate and the ranking on every scan after the first.
|
|
1679
1982
|
*/
|
|
1680
1983
|
coldResolved = /* @__PURE__ */ new Map();
|
|
1681
|
-
/**
|
|
1984
|
+
/**
|
|
1985
|
+
* Cold sessions whose resolution failed, id → the revision it failed at plus
|
|
1986
|
+
* when. Keyed by revision too, so an unchanged log is not re-read every scan,
|
|
1987
|
+
* but the retry is TIME-boxed: a purely revision-keyed failure would hide a
|
|
1988
|
+
* session permanently when its log never changes again.
|
|
1989
|
+
*/
|
|
1682
1990
|
coldFailed = /* @__PURE__ */ new Map();
|
|
1683
1991
|
constructor(deps) {
|
|
1684
1992
|
this.deps = deps;
|
|
@@ -1721,7 +2029,10 @@ var TodaySpendScanner = class {
|
|
|
1721
2029
|
* otherwise.
|
|
1722
2030
|
*
|
|
1723
2031
|
* The aggregate sums every priced session, subagents included — the ranking's
|
|
1724
|
-
* subagent roll-up only regroups rows, so neither total moves.
|
|
2032
|
+
* subagent roll-up only regroups rows, so neither total moves. Both paths
|
|
2033
|
+
* fold in slices and hand the host's event loop back every
|
|
2034
|
+
* {@link SCAN_YIELD_SESSIONS} sessions, so a long scan never starves the
|
|
2035
|
+
* GUI's own round trips.
|
|
1725
2036
|
* @param dayKey - the Beijing-time calendar-day key to aggregate.
|
|
1726
2037
|
* @returns the aggregate plus per-session rows sorted by cost descending.
|
|
1727
2038
|
*/
|
|
@@ -1737,14 +2048,14 @@ var TodaySpendScanner = class {
|
|
|
1737
2048
|
* own latest priced day is NOT the queried day: the row then proves the
|
|
1738
2049
|
* session contributed nothing to the queried day, so the log is never read.
|
|
1739
2050
|
* When the row IS the queried day (or no usable row exists) the session is
|
|
1740
|
-
*
|
|
2051
|
+
* opened and folded locally, because the row may trail the log (a crash
|
|
1741
2052
|
* between the last checkpoint and the session's last event).
|
|
1742
2053
|
*
|
|
1743
2054
|
* A cache-served value carries no title (the ladder only stores projection
|
|
1744
2055
|
* values), so such rows report `title: null`. A SEEDED session (fork child)
|
|
1745
2056
|
* skips the cache entirely: its cached row was folded over the inherited
|
|
1746
|
-
* prefix too, so it always detaches through
|
|
1747
|
-
* boundary (the
|
|
2057
|
+
* prefix too, so it always detaches through a handle read with the durable
|
|
2058
|
+
* boundary (the handle's `inheritedEventCount` or `header.seedLength`,
|
|
1748
2059
|
* depending on the runtime family) applied to the local fold.
|
|
1749
2060
|
* @param header - the listed session header (the cache identity witness).
|
|
1750
2061
|
* @param seeded - whether the session carries a fork-inherited prefix.
|
|
@@ -1814,14 +2125,18 @@ var TodaySpendScanner = class {
|
|
|
1814
2125
|
adopt(header.id, resolved);
|
|
1815
2126
|
continue;
|
|
1816
2127
|
}
|
|
1817
|
-
|
|
2128
|
+
const failed = this.coldFailed.get(header.id);
|
|
2129
|
+
if (failed !== void 0 && failed.revision === revision && Date.now() - failed.at < 3e5) continue;
|
|
1818
2130
|
pending.push({
|
|
1819
2131
|
header,
|
|
1820
2132
|
revision,
|
|
1821
2133
|
seeded
|
|
1822
2134
|
});
|
|
1823
2135
|
}
|
|
2136
|
+
let resolvedCount = 0;
|
|
1824
2137
|
await withConcurrency(pending, 8, async ({ header, revision, seeded }) => {
|
|
2138
|
+
resolvedCount += 1;
|
|
2139
|
+
if (resolvedCount % 8 === 0) await yieldToEventLoop();
|
|
1825
2140
|
const resolved = await this.resolveCold(header, seeded, dayKey);
|
|
1826
2141
|
if (resolved !== void 0) {
|
|
1827
2142
|
this.coldFailed.delete(header.id);
|
|
@@ -1831,10 +2146,16 @@ var TodaySpendScanner = class {
|
|
|
1831
2146
|
});
|
|
1832
2147
|
} else if (persistenceAvailable) {
|
|
1833
2148
|
evictOldest$1(this.coldFailed, COLD_FAILED_CACHE_LIMIT);
|
|
1834
|
-
this.coldFailed.set(header.id,
|
|
2149
|
+
this.coldFailed.set(header.id, {
|
|
2150
|
+
revision,
|
|
2151
|
+
at: Date.now()
|
|
2152
|
+
});
|
|
1835
2153
|
}
|
|
1836
2154
|
});
|
|
2155
|
+
let adopted = 0;
|
|
1837
2156
|
for (const { header } of pending) {
|
|
2157
|
+
adopted += 1;
|
|
2158
|
+
if (adopted % 8 === 0) await yieldToEventLoop();
|
|
1838
2159
|
const resolved = this.coldResolved.get(header.id);
|
|
1839
2160
|
if (resolved !== void 0) adopt(header.id, resolved);
|
|
1840
2161
|
}
|
|
@@ -1860,11 +2181,13 @@ var TodaySpendScanner = class {
|
|
|
1860
2181
|
const liveIds = /* @__PURE__ */ new Set();
|
|
1861
2182
|
let collected = 0;
|
|
1862
2183
|
let truncated = false;
|
|
2184
|
+
const dayRange = beijingDayRangeOfKey(dayKey);
|
|
2185
|
+
const onDay = dayRange === void 0 ? (event) => beijingPartsOf(event.time).dayKey === dayKey : (event) => event.time >= dayRange.start && event.time < dayRange.end;
|
|
1863
2186
|
/** Price one complete log, announce it, and return what to remember. */
|
|
1864
2187
|
const collect = (id, events, seedLength, lineage) => {
|
|
1865
2188
|
const folder = new BillingFolder(billing, catalog, seedLength);
|
|
1866
2189
|
for (const event of events) {
|
|
1867
|
-
if (
|
|
2190
|
+
if (onDay(event)) {
|
|
1868
2191
|
collected += 1;
|
|
1869
2192
|
if (collected > maxEvents) {
|
|
1870
2193
|
truncated = true;
|
|
@@ -1883,10 +2206,15 @@ var TodaySpendScanner = class {
|
|
|
1883
2206
|
};
|
|
1884
2207
|
if (sessions !== void 0) {
|
|
1885
2208
|
const store = sessions();
|
|
1886
|
-
if (store !== void 0)
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
2209
|
+
if (store !== void 0) {
|
|
2210
|
+
let folded = 0;
|
|
2211
|
+
for (const session of store.list()) {
|
|
2212
|
+
folded += 1;
|
|
2213
|
+
if (folded % 8 === 0) await yieldToEventLoop();
|
|
2214
|
+
liveIds.add(session.id);
|
|
2215
|
+
collect(session.id, liveSessionEvents(session), forkBoundaryOf(session), session.header ?? {});
|
|
2216
|
+
if (truncated) break;
|
|
2217
|
+
}
|
|
1890
2218
|
}
|
|
1891
2219
|
}
|
|
1892
2220
|
const persistenceService = persistence?.();
|
|
@@ -1918,7 +2246,7 @@ var TodaySpendScanner = class {
|
|
|
1918
2246
|
/**
|
|
1919
2247
|
* Projection path, one pass for both outputs: eager cells for live sessions
|
|
1920
2248
|
* (title folded from the live log, so a rename is reflected immediately),
|
|
1921
|
-
* revision-gated cold ladder for the rest (title resolved on
|
|
2249
|
+
* revision-gated cold ladder for the rest (title resolved on the handle read, `null`
|
|
1922
2250
|
* when answered from the projection cache). A fork child's cell covers its
|
|
1923
2251
|
* inherited prefix, so its own-events fold supplies both outputs. Lineage
|
|
1924
2252
|
* (which session delegated which) comes from the same headers the boundary
|
|
@@ -1938,23 +2266,28 @@ var TodaySpendScanner = class {
|
|
|
1938
2266
|
const liveIds = /* @__PURE__ */ new Set();
|
|
1939
2267
|
if (sessions !== void 0) {
|
|
1940
2268
|
const store = sessions();
|
|
1941
|
-
if (store !== void 0)
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
2269
|
+
if (store !== void 0) {
|
|
2270
|
+
let folded = 0;
|
|
2271
|
+
for (const { session, state } of this.liveBillingEntries(store, projectionsService)) {
|
|
2272
|
+
folded += 1;
|
|
2273
|
+
if (folded % 8 === 0) await yieldToEventLoop();
|
|
2274
|
+
liveIds.add(session.id);
|
|
2275
|
+
lineage.set(session.id, session.header ?? {});
|
|
2276
|
+
const born = session.header?.createdAt;
|
|
2277
|
+
if (born !== void 0) createdAt.set(session.id, born);
|
|
2278
|
+
const title = foldSessionTitle(liveSessionEvents(session));
|
|
2279
|
+
titles.set(session.id, title);
|
|
2280
|
+
if (state === void 0) continue;
|
|
2281
|
+
ownSpend.set(session.id, state.session);
|
|
2282
|
+
if (state.dayKey !== dayKey) continue;
|
|
2283
|
+
aggregate = mergeTodaySpend(aggregate, state.spend);
|
|
2284
|
+
rows.set(session.id, {
|
|
2285
|
+
sessionId: session.id,
|
|
2286
|
+
title,
|
|
2287
|
+
total: state.spend.total,
|
|
2288
|
+
ownTotal: state.spend.total
|
|
2289
|
+
});
|
|
2290
|
+
}
|
|
1958
2291
|
}
|
|
1959
2292
|
}
|
|
1960
2293
|
const persistenceService = persistence?.();
|
|
@@ -2161,7 +2494,7 @@ function evictOldest(map, limit) {
|
|
|
2161
2494
|
/**
|
|
2162
2495
|
* Read one session's event log and durable seed boundary: the live
|
|
2163
2496
|
* SessionStore first, then the persistence backend for a flushed session
|
|
2164
|
-
* (
|
|
2497
|
+
* (opened directly by id — no header listing). The live-session surface
|
|
2165
2498
|
* is read structurally across both DSH runtime families — `Session.events`
|
|
2166
2499
|
* (≤ 0.1.1-rc.2) or `Session.snapshotEvents()` + `Session.inheritedEventCount`
|
|
2167
2500
|
* (0.1.2-alpha.4+) — via {@link liveSessionEvents} / {@link forkBoundaryOf}.
|