@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/types/today-spend.js
CHANGED
|
@@ -4,11 +4,13 @@
|
|
|
4
4
|
* compute the aggregate behind a cache miss:
|
|
5
5
|
*
|
|
6
6
|
* - projection path (plan C): live sessions read their eagerly folded
|
|
7
|
-
* `billingTodaySpend` projection cell; cold sessions
|
|
8
|
-
* projection-cache
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
7
|
+
* `billingTodaySpend` projection cell; cold sessions are answered from the
|
|
8
|
+
* zero-I/O projection-cache row whenever that row's own day is not the
|
|
9
|
+
* queried one, and otherwise resolved through one detached local fold over
|
|
10
|
+
* a full `inspect`. Persisted revisions gate every cold read, so a session
|
|
11
|
+
* whose log did not change since the last resolution costs nothing — and a
|
|
12
|
+
* failed resolution is remembered by revision instead of being retried on
|
|
13
|
+
* every scan.
|
|
12
14
|
* - events path (plans A2/A3): collect and price only today's events in one
|
|
13
15
|
* pass (per-event Beijing-day filter during collection) with a hard cap,
|
|
14
16
|
* skipping sessions whose persisted revision is unchanged since the last
|
|
@@ -20,14 +22,15 @@
|
|
|
20
22
|
* unchanged log provably cannot change the aggregate.
|
|
21
23
|
*
|
|
22
24
|
* Forked sessions never double-count: a fork child's log opens with a
|
|
23
|
-
* verbatim copy of its source session's events (its inherited boundary),
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* durable
|
|
30
|
-
*
|
|
25
|
+
* verbatim copy of its source session's events (its inherited boundary), so
|
|
26
|
+
* the scanner prices only the child's OWN events on every path. The
|
|
27
|
+
* `billingTodaySpend` unit is boundary-aware (its state carries the inherited
|
|
28
|
+
* cut, and `apply` skips events below it), so the eager cell is correct for a
|
|
29
|
+
* fork child; the cold path skips the projection cache for a seeded session
|
|
30
|
+
* (its cached row may predate the boundary) and folds its own events with the
|
|
31
|
+
* durable cut instead. The boundary is the durable session state, read across
|
|
32
|
+
* both DSH runtime families — a resumed fork child keeps its original
|
|
33
|
+
* boundary and an unseeded session stays at 0.
|
|
31
34
|
*
|
|
32
35
|
* The live `Session` log surface changed in 0.1.2-alpha.4: `Session.events`
|
|
33
36
|
* was removed and replaced by `Session.snapshotEvents()` / `ownEvents()`, and
|
|
@@ -38,7 +41,7 @@
|
|
|
38
41
|
* and on the newer runtime.
|
|
39
42
|
* @module @rayadesu/dsh-llm-billing/today-spend
|
|
40
43
|
*/
|
|
41
|
-
import { beijingDayKey, emptyTodaySpend, forkBoundaryOf, isSeededSession, mergeTodaySpend
|
|
44
|
+
import { beijingDayKey, beijingPartsOf, BillingFolder, emptyTodaySpend, forkBoundaryOf, isSeededSession, mergeTodaySpend } from "./billing.js";
|
|
42
45
|
import { BILLING_UNIT_KEY, foldOwnBilling } from "./projection.js";
|
|
43
46
|
/**
|
|
44
47
|
* Fold one session's durable display title: the latest `session/title`
|
|
@@ -75,6 +78,18 @@ export function liveSessionEvents(session) {
|
|
|
75
78
|
return session.snapshotEvents();
|
|
76
79
|
throw new Error('llm-billing: session log surface is neither Session.events nor Session.snapshotEvents');
|
|
77
80
|
}
|
|
81
|
+
/**
|
|
82
|
+
* Unwrap a handle read across both return shapes.
|
|
83
|
+
* @param read - the handle's read result.
|
|
84
|
+
* @returns the event array.
|
|
85
|
+
*/
|
|
86
|
+
export function handleReadEvents(read) {
|
|
87
|
+
// `Array.isArray` does not narrow readonly arrays out of a union, so the
|
|
88
|
+
// branches are asserted explicitly.
|
|
89
|
+
if (Array.isArray(read))
|
|
90
|
+
return read;
|
|
91
|
+
return read.events;
|
|
92
|
+
}
|
|
78
93
|
function isHandlePersistence(persistence) {
|
|
79
94
|
return typeof persistence.open === 'function';
|
|
80
95
|
}
|
|
@@ -102,7 +117,7 @@ export async function persistenceInspect(persistence, id) {
|
|
|
102
117
|
if (isHandlePersistence(persistence)) {
|
|
103
118
|
const handle = await persistence.open(id, 'read');
|
|
104
119
|
try {
|
|
105
|
-
return { events: await handle.read(), seedLength: forkBoundaryOf(handle) };
|
|
120
|
+
return { events: handleReadEvents(await handle.read()), seedLength: forkBoundaryOf(handle) };
|
|
106
121
|
}
|
|
107
122
|
finally {
|
|
108
123
|
await handle.close();
|
|
@@ -165,7 +180,9 @@ export class TodaySpendCache {
|
|
|
165
180
|
&& now.getTime() - this.cachedAt < this.ttlMs) {
|
|
166
181
|
return Promise.resolve(this.cachedValue);
|
|
167
182
|
}
|
|
168
|
-
|
|
183
|
+
// A scan already in flight is fresh by definition, so a forced caller
|
|
184
|
+
// joins it instead of starting a second pass.
|
|
185
|
+
if (this.inFlight !== undefined)
|
|
169
186
|
return this.inFlight;
|
|
170
187
|
const run = (async () => {
|
|
171
188
|
try {
|
|
@@ -179,11 +196,28 @@ export class TodaySpendCache {
|
|
|
179
196
|
this.inFlight = undefined;
|
|
180
197
|
}
|
|
181
198
|
})();
|
|
182
|
-
|
|
183
|
-
this.inFlight = run;
|
|
199
|
+
this.inFlight = run;
|
|
184
200
|
return run;
|
|
185
201
|
}
|
|
186
202
|
}
|
|
203
|
+
/** Max session-ids kept in the scanner's cold-resolution cache before eviction. */
|
|
204
|
+
export const COLD_RESOLVE_CACHE_LIMIT = 1024;
|
|
205
|
+
/** Max session-ids kept in the scanner's cold-failure cache before eviction. */
|
|
206
|
+
export const COLD_FAILED_CACHE_LIMIT = 1024;
|
|
207
|
+
/** Bounded parallel fan-out for cold-session resolution. */
|
|
208
|
+
export const COLD_RESOLVE_CONCURRENCY = 8;
|
|
209
|
+
/**
|
|
210
|
+
* Bounded-map eviction: drop the oldest inserted entry once `size` reached
|
|
211
|
+
* `limit`. Evicting one entry (instead of clearing) keeps the other sessions'
|
|
212
|
+
* resolved state warm across scans.
|
|
213
|
+
*/
|
|
214
|
+
function evictOldest(map, limit) {
|
|
215
|
+
if (map.size < limit)
|
|
216
|
+
return;
|
|
217
|
+
const oldest = map.keys().next().value;
|
|
218
|
+
if (oldest !== undefined)
|
|
219
|
+
map.delete(oldest);
|
|
220
|
+
}
|
|
187
221
|
/**
|
|
188
222
|
* The aggregate computation behind a cache miss. Chooses the projection path
|
|
189
223
|
* when the projection registry is composed, the events path otherwise; both
|
|
@@ -194,10 +228,10 @@ export class TodaySpendScanner {
|
|
|
194
228
|
deps;
|
|
195
229
|
/** Cold sessions resolved on the projection path: id → revision + unit state + title. */
|
|
196
230
|
coldResolved = new Map();
|
|
231
|
+
/** Cold sessions whose resolution failed: id → revision (retried only when the log changes). */
|
|
232
|
+
coldFailed = new Map();
|
|
197
233
|
/** Cold sessions resolved on the events path: id → revision (events were collected). */
|
|
198
234
|
lastEventsScan;
|
|
199
|
-
/** Live fork children priced on the projection path: id → own-events count + folded state. */
|
|
200
|
-
ownStates = new Map();
|
|
201
235
|
constructor(deps) {
|
|
202
236
|
this.deps = deps;
|
|
203
237
|
}
|
|
@@ -207,10 +241,7 @@ export class TodaySpendScanner {
|
|
|
207
241
|
* @returns today's spend across every session.
|
|
208
242
|
*/
|
|
209
243
|
async scan(dayKey) {
|
|
210
|
-
|
|
211
|
-
return this.scanEvents(dayKey);
|
|
212
|
-
this.deps.ensureUnit?.();
|
|
213
|
-
return this.scanProjections(dayKey);
|
|
244
|
+
return (await this.scanDetail(dayKey)).aggregate;
|
|
214
245
|
}
|
|
215
246
|
/**
|
|
216
247
|
* Compute today's per-session spend for one Beijing day, sorted by cost
|
|
@@ -220,48 +251,64 @@ export class TodaySpendScanner {
|
|
|
220
251
|
* @returns today's per-session rows, highest first.
|
|
221
252
|
*/
|
|
222
253
|
async scanSessions(dayKey) {
|
|
223
|
-
|
|
224
|
-
? await this.scanSessionsEvents(dayKey)
|
|
225
|
-
: await this.scanSessionsProjections(dayKey);
|
|
226
|
-
rows.sort((left, right) => right.total - left.total);
|
|
227
|
-
return { sessions: rows };
|
|
254
|
+
return { sessions: (await this.scanDetail(dayKey)).sessions };
|
|
228
255
|
}
|
|
229
256
|
/**
|
|
230
|
-
*
|
|
231
|
-
* the
|
|
232
|
-
*
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
-
*
|
|
257
|
+
* Compute the day's aggregate AND its per-session ranking in ONE pass: the
|
|
258
|
+
* aggregate is the sum of the rows, so the two reads share every session
|
|
259
|
+
* read, unit fold, and title fold instead of scanning twice. Chooses the
|
|
260
|
+
* projection path when the projection registry is composed, the events path
|
|
261
|
+
* otherwise.
|
|
262
|
+
* @param dayKey - the Beijing-time calendar-day key to aggregate.
|
|
263
|
+
* @returns the aggregate plus per-session rows sorted by cost descending.
|
|
264
|
+
*/
|
|
265
|
+
async scanDetail(dayKey) {
|
|
266
|
+
if (this.deps.projections?.() === undefined)
|
|
267
|
+
return this.scanDetailEvents(dayKey);
|
|
268
|
+
this.deps.ensureUnit?.();
|
|
269
|
+
return this.scanDetailProjections(dayKey);
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Resolve one cold session's billing unit state and display title.
|
|
273
|
+
*
|
|
274
|
+
* The zero-I/O projection-cache row answers the query directly whenever its
|
|
275
|
+
* own latest priced day is NOT the queried day: the row then proves the
|
|
276
|
+
* session contributed nothing to the queried day, so the log is never read.
|
|
277
|
+
* When the row IS the queried day (or no usable row exists) the session is
|
|
278
|
+
* inspected and folded locally, because the row may trail the log (a crash
|
|
279
|
+
* between the last checkpoint and the session's last event).
|
|
280
|
+
*
|
|
281
|
+
* A cache-served value carries no title (the ladder only stores projection
|
|
282
|
+
* values), so such rows report `title: null`. A SEEDED session (fork child)
|
|
283
|
+
* skips the cache entirely: its cached row was folded over the inherited
|
|
236
284
|
* prefix too, so it always detaches through inspect with the durable
|
|
237
285
|
* boundary (the inspect result's inherited count or `meta.seedLength`,
|
|
238
286
|
* depending on the runtime family) applied to the local fold.
|
|
239
|
-
* @param
|
|
240
|
-
* @param seeded - whether the session carries a fork-inherited prefix
|
|
241
|
-
*
|
|
242
|
-
* at and before the 0.1.1-rc.2 baseline).
|
|
287
|
+
* @param header - the listed session header (the cache identity witness).
|
|
288
|
+
* @param seeded - whether the session carries a fork-inherited prefix.
|
|
289
|
+
* @param dayKey - the Beijing-time day being aggregated.
|
|
243
290
|
* @returns the resolved state and title, or `undefined` when unreadable.
|
|
244
291
|
*/
|
|
245
|
-
async resolveCold(
|
|
292
|
+
async resolveCold(header, seeded, dayKey) {
|
|
246
293
|
const { persistence, projectionCache, logger } = this.deps;
|
|
247
|
-
const persistenceService = persistence?.();
|
|
248
|
-
if (persistenceService === undefined)
|
|
249
|
-
return undefined;
|
|
250
294
|
if (!seeded) {
|
|
251
295
|
const cache = projectionCache?.();
|
|
252
296
|
if (cache !== undefined) {
|
|
253
297
|
try {
|
|
254
|
-
const value =
|
|
255
|
-
if (value !== undefined)
|
|
298
|
+
const value = cache.cachedSnapshot(header, 0, [BILLING_UNIT_KEY])?.values[BILLING_UNIT_KEY];
|
|
299
|
+
if (value !== undefined && value.dayKey !== dayKey)
|
|
256
300
|
return { value, title: null };
|
|
257
301
|
}
|
|
258
302
|
catch (error) {
|
|
259
|
-
logger.warn(`llm-billing: projection
|
|
303
|
+
logger.warn(`llm-billing: projection cache read for session ${header.id} failed: ${String(error)}`);
|
|
260
304
|
}
|
|
261
305
|
}
|
|
262
306
|
}
|
|
307
|
+
const persistenceService = persistence?.();
|
|
308
|
+
if (persistenceService === undefined)
|
|
309
|
+
return undefined;
|
|
263
310
|
try {
|
|
264
|
-
const read = await persistenceInspect(persistenceService, id);
|
|
311
|
+
const read = await persistenceInspect(persistenceService, header.id);
|
|
265
312
|
return {
|
|
266
313
|
value: foldOwnBilling(this.deps.unit, read.events, read.seedLength),
|
|
267
314
|
title: foldSessionTitle(read.events),
|
|
@@ -269,67 +316,36 @@ export class TodaySpendScanner {
|
|
|
269
316
|
}
|
|
270
317
|
catch (error) {
|
|
271
318
|
// One unreadable session must not blank the whole-day aggregate.
|
|
272
|
-
logger.warn(`llm-billing: skipping unreadable session ${id}: ${String(error)}`);
|
|
319
|
+
logger.warn(`llm-billing: skipping unreadable session ${header.id}: ${String(error)}`);
|
|
273
320
|
return undefined;
|
|
274
321
|
}
|
|
275
322
|
}
|
|
276
323
|
/**
|
|
277
|
-
*
|
|
278
|
-
*
|
|
279
|
-
*
|
|
280
|
-
*
|
|
281
|
-
* @param events - the session's complete log.
|
|
282
|
-
* @param seedLength - the inherited-prefix boundary.
|
|
283
|
-
* @returns the unit state over the session's own events.
|
|
324
|
+
* Live-session entries of one projection-path scan: each session with its
|
|
325
|
+
* eager `billingTodaySpend` cell. The cell is boundary-aware (the unit skips
|
|
326
|
+
* a fork child's inherited prefix), so a fork child reads the same own-event
|
|
327
|
+
* spend a non-fork session does.
|
|
284
328
|
*/
|
|
285
|
-
|
|
286
|
-
const
|
|
287
|
-
|
|
288
|
-
if (cached !== undefined && cached.count === ownCount)
|
|
289
|
-
return cached.state;
|
|
290
|
-
let state;
|
|
291
|
-
if (cached !== undefined && cached.count < ownCount) {
|
|
292
|
-
state = cached.state;
|
|
293
|
-
for (const event of events) {
|
|
294
|
-
if (event.seq < seedLength + cached.count)
|
|
295
|
-
continue;
|
|
296
|
-
state = this.deps.unit.apply(state, event);
|
|
297
|
-
}
|
|
329
|
+
*liveBillingEntries(store, projections) {
|
|
330
|
+
for (const session of store.list()) {
|
|
331
|
+
yield { session, state: projections?.stateOf(session, BILLING_UNIT_KEY) };
|
|
298
332
|
}
|
|
299
|
-
else {
|
|
300
|
-
state = foldOwnBilling(this.deps.unit, events, seedLength);
|
|
301
|
-
}
|
|
302
|
-
this.ownStates.set(id, { count: ownCount, state });
|
|
303
|
-
return state;
|
|
304
333
|
}
|
|
305
|
-
/**
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
const state = seedLength > 0
|
|
321
|
-
? this.ownBillingState(session.id, liveSessionEvents(session), seedLength)
|
|
322
|
-
: projectionsService?.stateOf(session, BILLING_UNIT_KEY);
|
|
323
|
-
if (state !== undefined && state.dayKey === dayKey) {
|
|
324
|
-
total = mergeTodaySpend(total, state.spend);
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
const persistenceService = persistence?.();
|
|
330
|
-
if (persistenceService === undefined)
|
|
331
|
-
return total;
|
|
332
|
-
const snapshots = await persistenceListSnapshots(persistenceService);
|
|
334
|
+
/**
|
|
335
|
+
* Cold-ladder adopt: for every stored session not live, either the
|
|
336
|
+
* revision-gated resolution already in {@link coldResolved} is adopted
|
|
337
|
+
* (unchanged log costs nothing) or the session is queued behind a bounded
|
|
338
|
+
* parallel fan-out, resolved, remembered, and then adopted. A session whose
|
|
339
|
+
* resolution failed is remembered too (by revision), so an unreadable log
|
|
340
|
+
* is not re-read on every scan; a changed revision retries it. One
|
|
341
|
+
* unreadable session never blanks the whole-day aggregate.
|
|
342
|
+
* @param liveIds - ids of sessions already folded from the live store.
|
|
343
|
+
* @param snapshots - stored snapshot list (either runtime family).
|
|
344
|
+
* @param dayKey - the Beijing-time day being aggregated.
|
|
345
|
+
* @param adopt - fold one resolved cold session into the scan's result.
|
|
346
|
+
*/
|
|
347
|
+
async coldAdopt(liveIds, snapshots, dayKey, adopt) {
|
|
348
|
+
const persistenceAvailable = this.deps.persistence?.() !== undefined;
|
|
333
349
|
const pending = [];
|
|
334
350
|
for (const { header, revision } of snapshots) {
|
|
335
351
|
if (liveIds.has(header.id))
|
|
@@ -337,60 +353,72 @@ export class TodaySpendScanner {
|
|
|
337
353
|
const seeded = isSeededSession(header);
|
|
338
354
|
const resolved = this.coldResolved.get(header.id);
|
|
339
355
|
if (resolved !== undefined && resolved.revision === revision) {
|
|
340
|
-
|
|
341
|
-
total = mergeTodaySpend(total, resolved.value.spend);
|
|
356
|
+
adopt(header.id, resolved);
|
|
342
357
|
continue;
|
|
343
358
|
}
|
|
344
|
-
|
|
359
|
+
if (this.coldFailed.get(header.id) === revision)
|
|
360
|
+
continue;
|
|
361
|
+
pending.push({ header, revision, seeded });
|
|
345
362
|
}
|
|
346
|
-
await withConcurrency(pending,
|
|
347
|
-
const resolved = await this.resolveCold(
|
|
348
|
-
if (resolved !== undefined)
|
|
349
|
-
this.
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
const resolved = this.coldResolved.get(id);
|
|
353
|
-
if (resolved !== undefined && resolved.value.dayKey === dayKey) {
|
|
354
|
-
total = mergeTodaySpend(total, resolved.value.spend);
|
|
363
|
+
await withConcurrency(pending, COLD_RESOLVE_CONCURRENCY, async ({ header, revision, seeded }) => {
|
|
364
|
+
const resolved = await this.resolveCold(header, seeded, dayKey);
|
|
365
|
+
if (resolved !== undefined) {
|
|
366
|
+
this.coldFailed.delete(header.id);
|
|
367
|
+
evictOldest(this.coldResolved, COLD_RESOLVE_CACHE_LIMIT);
|
|
368
|
+
this.coldResolved.set(header.id, { revision, ...resolved });
|
|
355
369
|
}
|
|
370
|
+
else if (persistenceAvailable) {
|
|
371
|
+
evictOldest(this.coldFailed, COLD_FAILED_CACHE_LIMIT);
|
|
372
|
+
this.coldFailed.set(header.id, revision);
|
|
373
|
+
}
|
|
374
|
+
});
|
|
375
|
+
for (const { header } of pending) {
|
|
376
|
+
const resolved = this.coldResolved.get(header.id);
|
|
377
|
+
if (resolved !== undefined)
|
|
378
|
+
adopt(header.id, resolved);
|
|
356
379
|
}
|
|
357
|
-
return total;
|
|
358
380
|
}
|
|
359
381
|
/**
|
|
360
|
-
* Events
|
|
361
|
-
*
|
|
362
|
-
*
|
|
363
|
-
*
|
|
382
|
+
* Events-path collection shared by both aggregate and per-session scans:
|
|
383
|
+
* fold each session's log with the shared pricing fold (attempt samples with
|
|
384
|
+
* same-step replacement) and announce the session's latest-day spend, gated
|
|
385
|
+
* by revisions — a persisted session whose log did not change since the last
|
|
386
|
+
* scan is skipped. A fork child's inherited prefix (`seq < seedLength`) is
|
|
387
|
+
* skipped, so each model output is priced only in its source session. The
|
|
388
|
+
* hard cap counts the queried day's events; the revision watermark only
|
|
389
|
+
* advances on a complete pass.
|
|
390
|
+
* @param dayKey - the Beijing-time calendar-day key to aggregate.
|
|
391
|
+
* @param onSession - fold one session's state plus its complete log.
|
|
392
|
+
* @returns whether the hard cap truncated the scan.
|
|
364
393
|
*/
|
|
365
|
-
async
|
|
394
|
+
async collectTodayEvents(dayKey, onSession) {
|
|
366
395
|
const { sessions, persistence, maxEvents, logger, billing, catalog } = this.deps;
|
|
367
|
-
const names = new Map(catalog.map(model => [model.id, model.name]));
|
|
368
|
-
const accumulator = new SpendAccumulator();
|
|
369
396
|
const liveIds = new Set();
|
|
370
397
|
let collected = 0;
|
|
371
398
|
let truncated = false;
|
|
372
|
-
const collect = (events, seedLength) => {
|
|
399
|
+
const collect = (id, events, seedLength) => {
|
|
400
|
+
const folder = new BillingFolder(billing, catalog, seedLength);
|
|
373
401
|
for (const event of events) {
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
402
|
+
// The cap counts the queried day's events; the fold still sees every
|
|
403
|
+
// event up to the cap (model tracking and attempt replacement need
|
|
404
|
+
// the surrounding events).
|
|
405
|
+
if (beijingPartsOf(event.time).dayKey === dayKey) {
|
|
406
|
+
collected += 1;
|
|
407
|
+
if (collected > maxEvents) {
|
|
408
|
+
truncated = true;
|
|
409
|
+
break;
|
|
410
|
+
}
|
|
382
411
|
}
|
|
383
|
-
|
|
384
|
-
if (priced !== undefined)
|
|
385
|
-
accumulator.add(priced);
|
|
412
|
+
folder.add(event);
|
|
386
413
|
}
|
|
414
|
+
onSession(id, folder.fold, events);
|
|
387
415
|
};
|
|
388
416
|
if (sessions !== undefined) {
|
|
389
417
|
const store = sessions();
|
|
390
418
|
if (store !== undefined) {
|
|
391
419
|
for (const session of store.list()) {
|
|
392
420
|
liveIds.add(session.id);
|
|
393
|
-
collect(liveSessionEvents(session), forkBoundaryOf(session));
|
|
421
|
+
collect(session.id, liveSessionEvents(session), forkBoundaryOf(session));
|
|
394
422
|
if (truncated)
|
|
395
423
|
break;
|
|
396
424
|
}
|
|
@@ -406,7 +434,7 @@ export class TodaySpendScanner {
|
|
|
406
434
|
continue;
|
|
407
435
|
try {
|
|
408
436
|
const read = await persistenceInspect(persistenceService, header.id);
|
|
409
|
-
collect(read.events, read.seedLength);
|
|
437
|
+
collect(header.id, read.events, read.seedLength);
|
|
410
438
|
}
|
|
411
439
|
catch (error) {
|
|
412
440
|
// One unreadable session must not blank the whole-day aggregate.
|
|
@@ -424,154 +452,74 @@ export class TodaySpendScanner {
|
|
|
424
452
|
}
|
|
425
453
|
if (truncated)
|
|
426
454
|
logger.warn(`llm-billing: today's events exceeded ${maxEvents}; result truncated`);
|
|
427
|
-
return
|
|
455
|
+
return truncated;
|
|
428
456
|
}
|
|
429
457
|
/**
|
|
430
|
-
* Projection
|
|
431
|
-
* folded from the live log, so a rename is reflected immediately),
|
|
432
|
-
* revision-gated cold ladder for the rest (title resolved on inspect,
|
|
433
|
-
*
|
|
434
|
-
*
|
|
458
|
+
* Projection path, one pass for both outputs: eager cells for live sessions
|
|
459
|
+
* (title folded from the live log, so a rename is reflected immediately),
|
|
460
|
+
* revision-gated cold ladder for the rest (title resolved on inspect, `null`
|
|
461
|
+
* when answered from the projection cache). A fork child's cell covers its
|
|
462
|
+
* inherited prefix, so its own-events fold supplies both outputs.
|
|
435
463
|
* @param dayKey - the Beijing-time calendar-day key to aggregate.
|
|
436
|
-
* @returns
|
|
464
|
+
* @returns the aggregate plus per-session rows, sorted by cost descending.
|
|
437
465
|
*/
|
|
438
|
-
async
|
|
466
|
+
async scanDetailProjections(dayKey) {
|
|
439
467
|
const { sessions, persistence, projections } = this.deps;
|
|
468
|
+
// Services resolve once per scan, not per session / per cold task.
|
|
440
469
|
const projectionsService = projections?.();
|
|
470
|
+
let aggregate = emptyTodaySpend();
|
|
441
471
|
const rows = new Map();
|
|
442
472
|
const liveIds = new Set();
|
|
443
473
|
if (sessions !== undefined) {
|
|
444
474
|
const store = sessions();
|
|
445
475
|
if (store !== undefined) {
|
|
446
|
-
for (const session of
|
|
476
|
+
for (const { session, state } of this.liveBillingEntries(store, projectionsService)) {
|
|
447
477
|
liveIds.add(session.id);
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
if (state !== undefined && state.dayKey === dayKey) {
|
|
454
|
-
rows.set(session.id, {
|
|
455
|
-
sessionId: session.id,
|
|
456
|
-
title: foldSessionTitle(events),
|
|
457
|
-
total: state.spend.total,
|
|
458
|
-
});
|
|
459
|
-
}
|
|
478
|
+
if (state === undefined || state.dayKey !== dayKey)
|
|
479
|
+
continue;
|
|
480
|
+
aggregate = mergeTodaySpend(aggregate, state.spend);
|
|
481
|
+
// The eager cell carries no title; fold it from the live log.
|
|
482
|
+
rows.set(session.id, { sessionId: session.id, title: foldSessionTitle(liveSessionEvents(session)), total: state.spend.total });
|
|
460
483
|
}
|
|
461
484
|
}
|
|
462
485
|
}
|
|
463
486
|
const persistenceService = persistence?.();
|
|
464
|
-
if (persistenceService
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
continue;
|
|
471
|
-
const seeded = isSeededSession(header);
|
|
472
|
-
const resolved = this.coldResolved.get(header.id);
|
|
473
|
-
if (resolved !== undefined && resolved.revision === revision) {
|
|
474
|
-
if (resolved.value.dayKey === dayKey) {
|
|
475
|
-
rows.set(header.id, { sessionId: header.id, title: resolved.title, total: resolved.value.spend.total });
|
|
476
|
-
}
|
|
477
|
-
continue;
|
|
478
|
-
}
|
|
479
|
-
pending.push({ id: header.id, revision, seeded });
|
|
480
|
-
}
|
|
481
|
-
await withConcurrency(pending, 8, async ({ id, revision, seeded }) => {
|
|
482
|
-
const resolved = await this.resolveCold(id, seeded);
|
|
483
|
-
if (resolved !== undefined)
|
|
484
|
-
this.coldResolved.set(id, { revision, ...resolved });
|
|
485
|
-
});
|
|
486
|
-
for (const { id } of pending) {
|
|
487
|
-
const resolved = this.coldResolved.get(id);
|
|
488
|
-
if (resolved !== undefined && resolved.value.dayKey === dayKey) {
|
|
487
|
+
if (persistenceService !== undefined) {
|
|
488
|
+
const snapshots = await persistenceListSnapshots(persistenceService);
|
|
489
|
+
await this.coldAdopt(liveIds, snapshots, dayKey, (id, resolved) => {
|
|
490
|
+
if (resolved.value.dayKey !== dayKey)
|
|
491
|
+
return;
|
|
492
|
+
aggregate = mergeTodaySpend(aggregate, resolved.value.spend);
|
|
489
493
|
rows.set(id, { sessionId: id, title: resolved.title, total: resolved.value.spend.total });
|
|
490
|
-
}
|
|
494
|
+
});
|
|
491
495
|
}
|
|
492
|
-
return
|
|
496
|
+
return { aggregate, sessions: sortRows(rows) };
|
|
493
497
|
}
|
|
494
498
|
/**
|
|
495
|
-
* Events
|
|
496
|
-
*
|
|
497
|
-
*
|
|
498
|
-
*
|
|
499
|
-
*
|
|
500
|
-
*
|
|
501
|
-
* is re-read.
|
|
499
|
+
* Events path, one pass for both outputs: price today's events (per-event
|
|
500
|
+
* Beijing-day filter during collection, hard cap), gated by revisions. A
|
|
501
|
+
* fork child's inherited prefix (`seq < seedLength`) is skipped, so each
|
|
502
|
+
* model output is priced only in its source session. Titles fold from each
|
|
503
|
+
* session's complete log — a `session/title` event can predate today — so a
|
|
504
|
+
* rename is reflected as soon as the session's log is re-read.
|
|
502
505
|
* @param dayKey - the Beijing-time calendar-day key to aggregate.
|
|
503
|
-
* @returns
|
|
506
|
+
* @returns the aggregate plus per-session rows, sorted by cost descending.
|
|
504
507
|
*/
|
|
505
|
-
async
|
|
506
|
-
|
|
507
|
-
const
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
rows.set(id, row);
|
|
517
|
-
}
|
|
518
|
-
for (const event of events) {
|
|
519
|
-
if (event.seq < seedLength)
|
|
520
|
-
continue;
|
|
521
|
-
if (beijingDayKey(new Date(event.time)) !== dayKey)
|
|
522
|
-
continue;
|
|
523
|
-
collected += 1;
|
|
524
|
-
if (collected > maxEvents) {
|
|
525
|
-
truncated = true;
|
|
526
|
-
return;
|
|
527
|
-
}
|
|
528
|
-
const priced = priceEvent(event, billing, names);
|
|
529
|
-
if (priced !== undefined)
|
|
530
|
-
row.total += priced.cost;
|
|
531
|
-
}
|
|
532
|
-
};
|
|
533
|
-
if (sessions !== undefined) {
|
|
534
|
-
const store = sessions();
|
|
535
|
-
if (store !== undefined) {
|
|
536
|
-
for (const session of store.list()) {
|
|
537
|
-
liveIds.add(session.id);
|
|
538
|
-
collect(session.id, liveSessionEvents(session), forkBoundaryOf(session));
|
|
539
|
-
if (truncated)
|
|
540
|
-
break;
|
|
541
|
-
}
|
|
542
|
-
}
|
|
543
|
-
}
|
|
544
|
-
const persistenceService = persistence?.();
|
|
545
|
-
if (!truncated && persistenceService !== undefined) {
|
|
546
|
-
const snapshots = await persistenceListSnapshots(persistenceService);
|
|
547
|
-
for (const { header, revision } of snapshots) {
|
|
548
|
-
if (liveIds.has(header.id))
|
|
549
|
-
continue;
|
|
550
|
-
if (this.lastEventsScan?.get(header.id) === revision)
|
|
551
|
-
continue;
|
|
552
|
-
try {
|
|
553
|
-
const read = await persistenceInspect(persistenceService, header.id);
|
|
554
|
-
collect(header.id, read.events, read.seedLength);
|
|
555
|
-
}
|
|
556
|
-
catch (error) {
|
|
557
|
-
// One unreadable session must not blank the whole-day aggregate.
|
|
558
|
-
logger.warn(`llm-billing: skipping unreadable session ${header.id}: ${String(error)}`);
|
|
559
|
-
}
|
|
560
|
-
if (truncated)
|
|
561
|
-
break;
|
|
562
|
-
}
|
|
563
|
-
// Only a complete pass may advance the revision watermark: a truncated
|
|
564
|
-
// pass left sessions unread, and recording them would skip their events
|
|
565
|
-
// on the next scan.
|
|
566
|
-
if (!truncated) {
|
|
567
|
-
this.lastEventsScan = new Map(snapshots.map(snapshot => [snapshot.header.id, snapshot.revision]));
|
|
568
|
-
}
|
|
569
|
-
}
|
|
570
|
-
if (truncated)
|
|
571
|
-
logger.warn(`llm-billing: today's events exceeded ${maxEvents}; result truncated`);
|
|
572
|
-
return [...rows.entries()]
|
|
573
|
-
.filter(([, row]) => row.total > 0)
|
|
574
|
-
.map(([sessionId, row]) => ({ sessionId, title: row.title, total: row.total }));
|
|
508
|
+
async scanDetailEvents(dayKey) {
|
|
509
|
+
let aggregate = emptyTodaySpend();
|
|
510
|
+
const sessions = [];
|
|
511
|
+
await this.collectTodayEvents(dayKey, (id, fold, events) => {
|
|
512
|
+
if (fold.dayKey !== dayKey)
|
|
513
|
+
return;
|
|
514
|
+
aggregate = mergeTodaySpend(aggregate, fold.spend);
|
|
515
|
+
sessions.push({ sessionId: id, title: foldSessionTitle(events), total: fold.spend.total });
|
|
516
|
+
});
|
|
517
|
+
sessions.sort((left, right) => right.total - left.total);
|
|
518
|
+
return { aggregate, sessions };
|
|
575
519
|
}
|
|
576
520
|
}
|
|
521
|
+
/** Per-session rows from the map, highest total first. */
|
|
522
|
+
function sortRows(rows) {
|
|
523
|
+
return [...rows.values()].sort((left, right) => right.total - left.total);
|
|
524
|
+
}
|
|
577
525
|
//# sourceMappingURL=today-spend.js.map
|