@rayadesu/dsh-llm-billing 0.3.12 → 0.3.13
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 +17 -4
- package/README.zh.md +17 -4
- package/lib/index.js +302 -60
- package/lib/typert.host.js +79 -6
- package/lib/typert.remote-client.d.ts +3 -1
- package/lib/typert.remote-client.js +79 -6
- package/lib/types/balance.d.ts +22 -2
- package/lib/types/balance.js +19 -1
- package/lib/types/index.d.ts +2 -2
- package/lib/types/index.js +24 -5
- package/lib/types/today-spend.d.ts +192 -47
- package/lib/types/today-spend.js +270 -58
- package/lib/types/types.d.ts +50 -3
- package/package.json +20 -20
package/lib/types/today-spend.js
CHANGED
|
@@ -13,13 +13,23 @@
|
|
|
13
13
|
* every scan.
|
|
14
14
|
* - events path (plans A2/A3): collect and price only today's events in one
|
|
15
15
|
* pass (per-event Beijing-day filter during collection) with a hard cap,
|
|
16
|
-
*
|
|
17
|
-
*
|
|
16
|
+
* adopting the fold it already priced for a session whose persisted revision
|
|
17
|
+
* is unchanged since the last pass.
|
|
18
18
|
*
|
|
19
19
|
* Both strategies run behind the same {@link TodaySpendCache}, so a miss
|
|
20
20
|
* happens at most once per 60 seconds per process, and a manual refresh
|
|
21
21
|
* (`force`) bypasses the time window but keeps the revision caches — an
|
|
22
|
-
* unchanged log provably cannot change the aggregate.
|
|
22
|
+
* unchanged log provably cannot change the aggregate. That proof is what makes
|
|
23
|
+
* a revision gate a CACHE: both strategies therefore adopt the resolution they
|
|
24
|
+
* remember for an unchanged revision instead of skipping the session, so an
|
|
25
|
+
* unchanged log costs no I/O and still contributes its full spend (and title)
|
|
26
|
+
* to the aggregate and the ranking on every scan.
|
|
27
|
+
*
|
|
28
|
+
* The per-session ranking is a per-CONVERSATION ranking: a subagent child is
|
|
29
|
+
* work the delegating conversation paid for, not a session the user opened, so
|
|
30
|
+
* every subagent row is folded into the row of the top-level session at the
|
|
31
|
+
* root of its `parentSession` chain (see {@link rollUpSubagentSpend}). The
|
|
32
|
+
* aggregate is unaffected — it sums the same sessions either way.
|
|
23
33
|
*
|
|
24
34
|
* Forked sessions never double-count: a fork child's log opens with a
|
|
25
35
|
* verbatim copy of its source session's events (its inherited boundary), so
|
|
@@ -64,6 +74,80 @@ export function foldSessionTitle(events) {
|
|
|
64
74
|
}
|
|
65
75
|
return null;
|
|
66
76
|
}
|
|
77
|
+
/**
|
|
78
|
+
* Whether one session's durable header marks it as a subagent child. Either
|
|
79
|
+
* marker is enough: `origin` is DSH's navigation classification and
|
|
80
|
+
* `delegationDepth` is its persisted recursion budget, so a header carrying
|
|
81
|
+
* only the depth (or only the origin) is still a delegation child. A session
|
|
82
|
+
* created without either — an ordinary session, a user fork, or a cold resume
|
|
83
|
+
* — is top-level.
|
|
84
|
+
* @param header - the session's lineage slice; `undefined` reads as top-level.
|
|
85
|
+
* @returns true when the session was created as a subagent child.
|
|
86
|
+
*/
|
|
87
|
+
export function isSubagentSession(header) {
|
|
88
|
+
if (header === undefined)
|
|
89
|
+
return false;
|
|
90
|
+
return header.origin === 'subagent' || (header.delegationDepth ?? 0) > 0;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* The top-level session one session's ranking row belongs to: the session
|
|
94
|
+
* itself for a top-level session, and for a subagent child the first ancestor
|
|
95
|
+
* up the `parentSession` chain that is not itself a subagent child. A
|
|
96
|
+
* multi-generation delegation (a subagent that spawned subagents) therefore
|
|
97
|
+
* lands on the same root row as its parent, and a child whose parent header is
|
|
98
|
+
* unknown is attributed to the parent id its own header names — the parent is
|
|
99
|
+
* authoritative even when its log is not part of this scan.
|
|
100
|
+
* @param id - the session whose row is being attributed.
|
|
101
|
+
* @param lineage - lineage of every session this scan saw, by id.
|
|
102
|
+
* @returns the session id whose ranking row the input belongs to.
|
|
103
|
+
*/
|
|
104
|
+
export function topLevelSessionOf(id, lineage) {
|
|
105
|
+
let current = id;
|
|
106
|
+
// A malformed log could claim a delegation cycle; each step visits a
|
|
107
|
+
// distinct ancestor, so a repeat ends the walk instead of spinning.
|
|
108
|
+
const seen = new Set([current]);
|
|
109
|
+
for (;;) {
|
|
110
|
+
const header = lineage.get(current);
|
|
111
|
+
if (!isSubagentSession(header))
|
|
112
|
+
return current;
|
|
113
|
+
const parent = header?.parentSession;
|
|
114
|
+
if (parent === undefined || seen.has(parent))
|
|
115
|
+
return current;
|
|
116
|
+
seen.add(parent);
|
|
117
|
+
current = parent;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Fold every subagent child's row into the top-level row it belongs to
|
|
122
|
+
* ({@link topLevelSessionOf}), so the ranking lists conversations rather than
|
|
123
|
+
* every delegation a conversation started. A child's spend is added to its
|
|
124
|
+
* ancestor's `total`; the ancestor's `ownTotal` keeps its own spend only. A
|
|
125
|
+
* top-level session whose own day was empty but whose subagents priced
|
|
126
|
+
* something still gets a row (with `ownTotal` 0), carrying the title the scan
|
|
127
|
+
* resolved for it in `titles`.
|
|
128
|
+
* @param rows - one row per session that priced something today (own spends).
|
|
129
|
+
* @param lineage - lineage of every session this scan saw, by id.
|
|
130
|
+
* @param titles - resolved display titles by session id; a session absent from
|
|
131
|
+
* the map has no resolved title and its created row reports `null`.
|
|
132
|
+
* @returns the merged rows, sorted by `total` descending.
|
|
133
|
+
*/
|
|
134
|
+
export function rollUpSubagentSpend(rows, lineage, titles = new Map()) {
|
|
135
|
+
const merged = new Map();
|
|
136
|
+
for (const row of rows) {
|
|
137
|
+
const target = topLevelSessionOf(row.sessionId, lineage);
|
|
138
|
+
const carried = merged.get(target);
|
|
139
|
+
if (carried === undefined) {
|
|
140
|
+
// A row's own session keeps its own total as `ownTotal`; a row created
|
|
141
|
+
// for an ancestor that priced nothing today carries zero there.
|
|
142
|
+
merged.set(target, target === row.sessionId
|
|
143
|
+
? row
|
|
144
|
+
: { sessionId: target, title: titles.get(target) ?? null, total: row.total, ownTotal: 0 });
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
merged.set(target, { ...carried, total: carried.total + row.total });
|
|
148
|
+
}
|
|
149
|
+
return [...merged.values()].sort((left, right) => right.total - left.total);
|
|
150
|
+
}
|
|
67
151
|
/**
|
|
68
152
|
* Read one live session's complete event log across both runtime families.
|
|
69
153
|
* @throws when the session exposes neither the legacy `events` snapshot nor
|
|
@@ -218,23 +302,85 @@ function evictOldest(map, limit) {
|
|
|
218
302
|
if (oldest !== undefined)
|
|
219
303
|
map.delete(oldest);
|
|
220
304
|
}
|
|
305
|
+
/**
|
|
306
|
+
* The merged whole-session spend of every subagent session delegated FROM one
|
|
307
|
+
* session, transitively: the subagent subtotal a conversation's own log cannot
|
|
308
|
+
* price. Only sessions DSH marked as delegation children count, so a user fork
|
|
309
|
+
* (which names a `parentSession` too) is never billed into its source. A
|
|
310
|
+
* malformed lineage that points back at the queried session is ignored rather
|
|
311
|
+
* than counted twice.
|
|
312
|
+
* @param id - the session whose delegated subtree to sum.
|
|
313
|
+
* @param ownSpend - whole-session own spend per session, from one scan pass.
|
|
314
|
+
* @param lineage - lineage per session, from the same pass.
|
|
315
|
+
* @returns the merged subtree spend; empty when the session delegated nothing priced.
|
|
316
|
+
*/
|
|
317
|
+
export function delegatedSpendOf(id, ownSpend, lineage) {
|
|
318
|
+
const children = new Map();
|
|
319
|
+
for (const [childId, header] of lineage) {
|
|
320
|
+
const parent = header.parentSession;
|
|
321
|
+
if (parent === undefined || !isSubagentSession(header))
|
|
322
|
+
continue;
|
|
323
|
+
const siblings = children.get(parent);
|
|
324
|
+
if (siblings === undefined)
|
|
325
|
+
children.set(parent, [childId]);
|
|
326
|
+
else
|
|
327
|
+
siblings.push(childId);
|
|
328
|
+
}
|
|
329
|
+
// The queried session is pre-visited: a cycle that leads back to it must not
|
|
330
|
+
// add its own spend to its delegated subtotal.
|
|
331
|
+
const seen = new Set([id]);
|
|
332
|
+
const pending = [...children.get(id) ?? []];
|
|
333
|
+
for (const child of pending)
|
|
334
|
+
seen.add(child);
|
|
335
|
+
let total = emptyTodaySpend();
|
|
336
|
+
while (pending.length > 0) {
|
|
337
|
+
const current = pending.pop();
|
|
338
|
+
total = mergeTodaySpend(total, ownSpend.get(current) ?? emptyTodaySpend());
|
|
339
|
+
for (const child of children.get(current) ?? []) {
|
|
340
|
+
if (seen.has(child))
|
|
341
|
+
continue;
|
|
342
|
+
seen.add(child);
|
|
343
|
+
pending.push(child);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return total;
|
|
347
|
+
}
|
|
221
348
|
/**
|
|
222
349
|
* The aggregate computation behind a cache miss. Chooses the projection path
|
|
223
350
|
* when the projection registry is composed, the events path otherwise; both
|
|
224
|
-
* gate cold reads on persisted revisions
|
|
225
|
-
* sessions whose logs
|
|
351
|
+
* gate cold reads on persisted revisions AND adopt the fold they already hold
|
|
352
|
+
* for an unchanged log, so steady-state scans re-read only sessions whose logs
|
|
353
|
+
* actually changed while every unchanged session keeps contributing.
|
|
226
354
|
*/
|
|
227
355
|
export class TodaySpendScanner {
|
|
228
356
|
deps;
|
|
229
|
-
/**
|
|
357
|
+
/**
|
|
358
|
+
* Cold sessions resolved by either strategy: id → the persisted revision the
|
|
359
|
+
* resolution saw, the session's OWN-events fold, and its folded title.
|
|
360
|
+
*
|
|
361
|
+
* The two strategies differ in how they PRICE a cold log (an eager projection
|
|
362
|
+
* cell plus the cache ladder, or a local fold over the read log), never in
|
|
363
|
+
* what an unchanged log contributes to the day — so one memory serves both.
|
|
364
|
+
* The projection path reuses the resolved unit; the events path reuses the
|
|
365
|
+
* fold it priced on the previous pass. A strategy that skipped an unchanged
|
|
366
|
+
* log WITHOUT adopting its remembered fold would silently drop that session
|
|
367
|
+
* from the aggregate and the ranking on every scan after the first.
|
|
368
|
+
*/
|
|
230
369
|
coldResolved = new Map();
|
|
231
370
|
/** Cold sessions whose resolution failed: id → revision (retried only when the log changes). */
|
|
232
371
|
coldFailed = new Map();
|
|
233
|
-
/** Cold sessions resolved on the events path: id → revision (events were collected). */
|
|
234
|
-
lastEventsScan;
|
|
235
372
|
constructor(deps) {
|
|
236
373
|
this.deps = deps;
|
|
237
374
|
}
|
|
375
|
+
/**
|
|
376
|
+
* Remember one cold session's resolution, bounded by
|
|
377
|
+
* {@link COLD_RESOLVE_CACHE_LIMIT}: evicting the oldest entry (instead of
|
|
378
|
+
* clearing) keeps the other sessions' resolved state warm across scans.
|
|
379
|
+
*/
|
|
380
|
+
rememberCold(id, resolution) {
|
|
381
|
+
evictOldest(this.coldResolved, COLD_RESOLVE_CACHE_LIMIT);
|
|
382
|
+
this.coldResolved.set(id, resolution);
|
|
383
|
+
}
|
|
238
384
|
/**
|
|
239
385
|
* Compute today's aggregate for one Beijing day.
|
|
240
386
|
* @param dayKey - the Beijing-time calendar-day key to aggregate.
|
|
@@ -245,8 +391,11 @@ export class TodaySpendScanner {
|
|
|
245
391
|
}
|
|
246
392
|
/**
|
|
247
393
|
* Compute today's per-session spend for one Beijing day, sorted by cost
|
|
248
|
-
* descending.
|
|
249
|
-
*
|
|
394
|
+
* descending. One row per top-level session: sessions with no priced usage
|
|
395
|
+
* on the day are omitted (unless their subagents priced something, which the
|
|
396
|
+
* roll-up merges into their row), every subagent session is folded into the
|
|
397
|
+
* top-level session that delegated it, and each row carries the session's
|
|
398
|
+
* durable title folded from its log.
|
|
250
399
|
* @param dayKey - the Beijing-time calendar-day key to aggregate.
|
|
251
400
|
* @returns today's per-session rows, highest first.
|
|
252
401
|
*/
|
|
@@ -259,6 +408,9 @@ export class TodaySpendScanner {
|
|
|
259
408
|
* read, unit fold, and title fold instead of scanning twice. Chooses the
|
|
260
409
|
* projection path when the projection registry is composed, the events path
|
|
261
410
|
* otherwise.
|
|
411
|
+
*
|
|
412
|
+
* The aggregate sums every priced session, subagents included — the ranking's
|
|
413
|
+
* subagent roll-up only regroups rows, so neither total moves.
|
|
262
414
|
* @param dayKey - the Beijing-time calendar-day key to aggregate.
|
|
263
415
|
* @returns the aggregate plus per-session rows sorted by cost descending.
|
|
264
416
|
*/
|
|
@@ -269,7 +421,7 @@ export class TodaySpendScanner {
|
|
|
269
421
|
return this.scanDetailProjections(dayKey);
|
|
270
422
|
}
|
|
271
423
|
/**
|
|
272
|
-
* Resolve one cold session's billing
|
|
424
|
+
* Resolve one cold session's billing fold state and display title.
|
|
273
425
|
*
|
|
274
426
|
* The zero-I/O projection-cache row answers the query directly whenever its
|
|
275
427
|
* own latest priced day is NOT the queried day: the row then proves the
|
|
@@ -287,7 +439,7 @@ export class TodaySpendScanner {
|
|
|
287
439
|
* @param header - the listed session header (the cache identity witness).
|
|
288
440
|
* @param seeded - whether the session carries a fork-inherited prefix.
|
|
289
441
|
* @param dayKey - the Beijing-time day being aggregated.
|
|
290
|
-
* @returns the resolved state and title, or `undefined` when unreadable.
|
|
442
|
+
* @returns the resolved fold state and title, or `undefined` when unreadable.
|
|
291
443
|
*/
|
|
292
444
|
async resolveCold(header, seeded, dayKey) {
|
|
293
445
|
const { persistence, projectionCache, logger } = this.deps;
|
|
@@ -297,7 +449,7 @@ export class TodaySpendScanner {
|
|
|
297
449
|
try {
|
|
298
450
|
const value = cache.cachedSnapshot(header, 0, [BILLING_UNIT_KEY])?.values[BILLING_UNIT_KEY];
|
|
299
451
|
if (value !== undefined && value.dayKey !== dayKey)
|
|
300
|
-
return { value, title: null };
|
|
452
|
+
return { fold: value, title: null };
|
|
301
453
|
}
|
|
302
454
|
catch (error) {
|
|
303
455
|
logger.warn(`llm-billing: projection cache read for session ${header.id} failed: ${String(error)}`);
|
|
@@ -310,7 +462,7 @@ export class TodaySpendScanner {
|
|
|
310
462
|
try {
|
|
311
463
|
const read = await persistenceInspect(persistenceService, header.id);
|
|
312
464
|
return {
|
|
313
|
-
|
|
465
|
+
fold: foldOwnBilling(this.deps.unit, read.events, read.seedLength),
|
|
314
466
|
title: foldSessionTitle(read.events),
|
|
315
467
|
};
|
|
316
468
|
}
|
|
@@ -334,11 +486,11 @@ export class TodaySpendScanner {
|
|
|
334
486
|
/**
|
|
335
487
|
* Cold-ladder adopt: for every stored session not live, either the
|
|
336
488
|
* revision-gated resolution already in {@link coldResolved} is adopted
|
|
337
|
-
* (unchanged log costs nothing) or the session is queued
|
|
338
|
-
* parallel fan-out, resolved, remembered, and then adopted.
|
|
339
|
-
* resolution failed is remembered too (by revision), so an
|
|
340
|
-
* is not re-read on every scan; a changed revision retries it.
|
|
341
|
-
* unreadable session never blanks the whole-day aggregate.
|
|
489
|
+
* (unchanged log costs nothing and still counts) or the session is queued
|
|
490
|
+
* behind a bounded parallel fan-out, resolved, remembered, and then adopted.
|
|
491
|
+
* A session whose resolution failed is remembered too (by revision), so an
|
|
492
|
+
* unreadable log is not re-read on every scan; a changed revision retries it.
|
|
493
|
+
* One unreadable session never blanks the whole-day aggregate.
|
|
342
494
|
* @param liveIds - ids of sessions already folded from the live store.
|
|
343
495
|
* @param snapshots - stored snapshot list (either runtime family).
|
|
344
496
|
* @param dayKey - the Beijing-time day being aggregated.
|
|
@@ -364,8 +516,7 @@ export class TodaySpendScanner {
|
|
|
364
516
|
const resolved = await this.resolveCold(header, seeded, dayKey);
|
|
365
517
|
if (resolved !== undefined) {
|
|
366
518
|
this.coldFailed.delete(header.id);
|
|
367
|
-
|
|
368
|
-
this.coldResolved.set(header.id, { revision, ...resolved });
|
|
519
|
+
this.rememberCold(header.id, { revision, ...resolved });
|
|
369
520
|
}
|
|
370
521
|
else if (persistenceAvailable) {
|
|
371
522
|
evictOldest(this.coldFailed, COLD_FAILED_CACHE_LIMIT);
|
|
@@ -381,14 +532,17 @@ export class TodaySpendScanner {
|
|
|
381
532
|
/**
|
|
382
533
|
* Events-path collection shared by both aggregate and per-session scans:
|
|
383
534
|
* 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
|
|
385
|
-
*
|
|
386
|
-
*
|
|
387
|
-
*
|
|
388
|
-
*
|
|
389
|
-
*
|
|
535
|
+
* same-step replacement) and announce the session's latest-day spend. A
|
|
536
|
+
* persisted session whose log did not change since it was last resolved is
|
|
537
|
+
* answered from {@link coldResolved} instead of being re-read: it keeps
|
|
538
|
+
* counting toward the aggregate and the ranking at zero cost, which is what
|
|
539
|
+
* makes the revision gate a cache rather than a way to lose sessions. A fork
|
|
540
|
+
* child's inherited prefix (`seq < seedLength`) is skipped, so each model
|
|
541
|
+
* output is priced only in its source session. The hard cap counts the
|
|
542
|
+
* queried day's events; a truncated pass remembers nothing it read, so the
|
|
543
|
+
* next one re-reads whatever this one cut short.
|
|
390
544
|
* @param dayKey - the Beijing-time calendar-day key to aggregate.
|
|
391
|
-
* @param onSession -
|
|
545
|
+
* @param onSession - adopt one session's fold, title, and lineage.
|
|
392
546
|
* @returns whether the hard cap truncated the scan.
|
|
393
547
|
*/
|
|
394
548
|
async collectTodayEvents(dayKey, onSession) {
|
|
@@ -396,7 +550,8 @@ export class TodaySpendScanner {
|
|
|
396
550
|
const liveIds = new Set();
|
|
397
551
|
let collected = 0;
|
|
398
552
|
let truncated = false;
|
|
399
|
-
|
|
553
|
+
/** Price one complete log, announce it, and return what to remember. */
|
|
554
|
+
const collect = (id, events, seedLength, lineage) => {
|
|
400
555
|
const folder = new BillingFolder(billing, catalog, seedLength);
|
|
401
556
|
for (const event of events) {
|
|
402
557
|
// The cap counts the queried day's events; the fold still sees every
|
|
@@ -411,14 +566,17 @@ export class TodaySpendScanner {
|
|
|
411
566
|
}
|
|
412
567
|
folder.add(event);
|
|
413
568
|
}
|
|
414
|
-
|
|
569
|
+
const fold = folder.fold;
|
|
570
|
+
const title = foldSessionTitle(events);
|
|
571
|
+
onSession(id, fold, title, lineage);
|
|
572
|
+
return { fold, title };
|
|
415
573
|
};
|
|
416
574
|
if (sessions !== undefined) {
|
|
417
575
|
const store = sessions();
|
|
418
576
|
if (store !== undefined) {
|
|
419
577
|
for (const session of store.list()) {
|
|
420
578
|
liveIds.add(session.id);
|
|
421
|
-
collect(session.id, liveSessionEvents(session), forkBoundaryOf(session));
|
|
579
|
+
collect(session.id, liveSessionEvents(session), forkBoundaryOf(session), session.header ?? {});
|
|
422
580
|
if (truncated)
|
|
423
581
|
break;
|
|
424
582
|
}
|
|
@@ -430,11 +588,20 @@ export class TodaySpendScanner {
|
|
|
430
588
|
for (const { header, revision } of snapshots) {
|
|
431
589
|
if (liveIds.has(header.id))
|
|
432
590
|
continue;
|
|
433
|
-
|
|
591
|
+
const resolved = this.coldResolved.get(header.id);
|
|
592
|
+
if (resolved !== undefined && resolved.revision === revision) {
|
|
593
|
+
// Unchanged log: adopt the fold (and title) already priced for this
|
|
594
|
+
// exact revision — the session still counts, at zero I/O.
|
|
595
|
+
onSession(header.id, resolved.fold, resolved.title, header);
|
|
434
596
|
continue;
|
|
597
|
+
}
|
|
435
598
|
try {
|
|
436
599
|
const read = await persistenceInspect(persistenceService, header.id);
|
|
437
|
-
collect(header.id, read.events, read.seedLength);
|
|
600
|
+
const priced = collect(header.id, read.events, read.seedLength, header);
|
|
601
|
+
// A MAXED pass folds one log only partially: remembering it (or its
|
|
602
|
+
// revision) would pin the partial result, so the next pass re-reads.
|
|
603
|
+
if (!truncated)
|
|
604
|
+
this.rememberCold(header.id, { revision, ...priced });
|
|
438
605
|
}
|
|
439
606
|
catch (error) {
|
|
440
607
|
// One unreadable session must not blank the whole-day aggregate.
|
|
@@ -443,12 +610,6 @@ export class TodaySpendScanner {
|
|
|
443
610
|
if (truncated)
|
|
444
611
|
break;
|
|
445
612
|
}
|
|
446
|
-
// Only a complete pass may advance the revision watermark: a truncated
|
|
447
|
-
// pass left sessions unread, and recording them would skip their events
|
|
448
|
-
// on the next scan.
|
|
449
|
-
if (!truncated) {
|
|
450
|
-
this.lastEventsScan = new Map(snapshots.map(snapshot => [snapshot.header.id, snapshot.revision]));
|
|
451
|
-
}
|
|
452
613
|
}
|
|
453
614
|
if (truncated)
|
|
454
615
|
logger.warn(`llm-billing: today's events exceeded ${maxEvents}; result truncated`);
|
|
@@ -459,7 +620,9 @@ export class TodaySpendScanner {
|
|
|
459
620
|
* (title folded from the live log, so a rename is reflected immediately),
|
|
460
621
|
* revision-gated cold ladder for the rest (title resolved on inspect, `null`
|
|
461
622
|
* when answered from the projection cache). A fork child's cell covers its
|
|
462
|
-
* inherited prefix, so its own-events fold supplies both outputs.
|
|
623
|
+
* inherited prefix, so its own-events fold supplies both outputs. Lineage
|
|
624
|
+
* (which session delegated which) comes from the same headers the boundary
|
|
625
|
+
* does, so the ranking's subagent roll-up costs no extra read.
|
|
463
626
|
* @param dayKey - the Beijing-time calendar-day key to aggregate.
|
|
464
627
|
* @returns the aggregate plus per-session rows, sorted by cost descending.
|
|
465
628
|
*/
|
|
@@ -469,57 +632,106 @@ export class TodaySpendScanner {
|
|
|
469
632
|
const projectionsService = projections?.();
|
|
470
633
|
let aggregate = emptyTodaySpend();
|
|
471
634
|
const rows = new Map();
|
|
635
|
+
const lineage = new Map();
|
|
636
|
+
const titles = new Map();
|
|
637
|
+
const ownSpend = new Map();
|
|
638
|
+
const createdAt = new Map();
|
|
472
639
|
const liveIds = new Set();
|
|
473
640
|
if (sessions !== undefined) {
|
|
474
641
|
const store = sessions();
|
|
475
642
|
if (store !== undefined) {
|
|
476
643
|
for (const { session, state } of this.liveBillingEntries(store, projectionsService)) {
|
|
477
644
|
liveIds.add(session.id);
|
|
478
|
-
|
|
645
|
+
lineage.set(session.id, session.header ?? {});
|
|
646
|
+
const born = session.header?.createdAt;
|
|
647
|
+
if (born !== undefined)
|
|
648
|
+
createdAt.set(session.id, born);
|
|
649
|
+
// The eager cell carries no title; fold it from the live log for
|
|
650
|
+
// every live session, not just today's payers, so a parent that
|
|
651
|
+
// delegated but priced nothing itself still titles its merged row.
|
|
652
|
+
const title = foldSessionTitle(liveSessionEvents(session));
|
|
653
|
+
titles.set(session.id, title);
|
|
654
|
+
if (state === undefined)
|
|
655
|
+
continue;
|
|
656
|
+
// The cell's whole-session total feeds the conversation read even
|
|
657
|
+
// when the session priced nothing on the queried day.
|
|
658
|
+
ownSpend.set(session.id, state.session);
|
|
659
|
+
if (state.dayKey !== dayKey)
|
|
479
660
|
continue;
|
|
480
661
|
aggregate = mergeTodaySpend(aggregate, state.spend);
|
|
481
|
-
|
|
482
|
-
|
|
662
|
+
rows.set(session.id, {
|
|
663
|
+
sessionId: session.id,
|
|
664
|
+
title,
|
|
665
|
+
total: state.spend.total,
|
|
666
|
+
ownTotal: state.spend.total,
|
|
667
|
+
});
|
|
483
668
|
}
|
|
484
669
|
}
|
|
485
670
|
}
|
|
486
671
|
const persistenceService = persistence?.();
|
|
487
672
|
if (persistenceService !== undefined) {
|
|
488
673
|
const snapshots = await persistenceListSnapshots(persistenceService);
|
|
674
|
+
for (const { header } of snapshots) {
|
|
675
|
+
if (liveIds.has(header.id))
|
|
676
|
+
continue;
|
|
677
|
+
lineage.set(header.id, header);
|
|
678
|
+
if (header.createdAt !== undefined)
|
|
679
|
+
createdAt.set(header.id, header.createdAt);
|
|
680
|
+
}
|
|
489
681
|
await this.coldAdopt(liveIds, snapshots, dayKey, (id, resolved) => {
|
|
490
|
-
|
|
682
|
+
// A resolved title is worth keeping even when the session priced
|
|
683
|
+
// nothing today: its subagents' rows merge into this session's row.
|
|
684
|
+
if (resolved.title !== null && !titles.has(id))
|
|
685
|
+
titles.set(id, resolved.title);
|
|
686
|
+
// Same for the whole-session total: it is the conversation read's
|
|
687
|
+
// material whether or not the queried day is the one it last priced.
|
|
688
|
+
ownSpend.set(id, resolved.fold.session);
|
|
689
|
+
if (resolved.fold.dayKey !== dayKey)
|
|
491
690
|
return;
|
|
492
|
-
aggregate = mergeTodaySpend(aggregate, resolved.
|
|
493
|
-
rows.set(id, { sessionId: id, title: resolved.title, total: resolved.
|
|
691
|
+
aggregate = mergeTodaySpend(aggregate, resolved.fold.spend);
|
|
692
|
+
rows.set(id, { sessionId: id, title: resolved.title, total: resolved.fold.spend.total, ownTotal: resolved.fold.spend.total });
|
|
494
693
|
});
|
|
495
694
|
}
|
|
496
|
-
return { aggregate, sessions:
|
|
695
|
+
return { aggregate, sessions: rollUpSubagentSpend([...rows.values()], lineage, titles), dayKey, ownSpend, lineage, createdAt };
|
|
497
696
|
}
|
|
498
697
|
/**
|
|
499
698
|
* Events path, one pass for both outputs: price today's events (per-event
|
|
500
|
-
* Beijing-day filter during collection, hard cap), gated
|
|
699
|
+
* Beijing-day filter during collection, hard cap), revision-gated so an
|
|
700
|
+
* unchanged log is adopted from {@link coldResolved} instead of re-read. A
|
|
501
701
|
* fork child's inherited prefix (`seq < seedLength`) is skipped, so each
|
|
502
702
|
* model output is priced only in its source session. Titles fold from each
|
|
503
703
|
* 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
|
|
704
|
+
* rename is reflected as soon as the session's log is re-read, and an
|
|
705
|
+
* unchanged session keeps the title its earlier read folded. Lineage comes
|
|
706
|
+
* from the same headers, which the ranking roll-up needs.
|
|
505
707
|
* @param dayKey - the Beijing-time calendar-day key to aggregate.
|
|
506
708
|
* @returns the aggregate plus per-session rows, sorted by cost descending.
|
|
507
709
|
*/
|
|
508
710
|
async scanDetailEvents(dayKey) {
|
|
509
711
|
let aggregate = emptyTodaySpend();
|
|
510
|
-
const
|
|
511
|
-
|
|
712
|
+
const rows = [];
|
|
713
|
+
const lineage = new Map();
|
|
714
|
+
const titles = new Map();
|
|
715
|
+
const ownSpend = new Map();
|
|
716
|
+
const createdAt = new Map();
|
|
717
|
+
await this.collectTodayEvents(dayKey, (id, fold, title, sessionLineage) => {
|
|
718
|
+
lineage.set(id, sessionLineage);
|
|
719
|
+
// Titles cover every session the pass accounted for (not only today's
|
|
720
|
+
// payers), so a parent that delegated without pricing anything itself is
|
|
721
|
+
// titled even when its own row comes from the roll-up.
|
|
722
|
+
titles.set(id, title);
|
|
723
|
+
const born = sessionLineage.createdAt;
|
|
724
|
+
if (born !== undefined)
|
|
725
|
+
createdAt.set(id, born);
|
|
726
|
+
// The whole-session total is the conversation read's material even when
|
|
727
|
+
// the session priced nothing on the queried day.
|
|
728
|
+
ownSpend.set(id, fold.session);
|
|
512
729
|
if (fold.dayKey !== dayKey)
|
|
513
730
|
return;
|
|
514
731
|
aggregate = mergeTodaySpend(aggregate, fold.spend);
|
|
515
|
-
|
|
732
|
+
rows.push({ sessionId: id, title, total: fold.spend.total, ownTotal: fold.spend.total });
|
|
516
733
|
});
|
|
517
|
-
sessions
|
|
518
|
-
return { aggregate, sessions };
|
|
734
|
+
return { aggregate, sessions: rollUpSubagentSpend(rows, lineage, titles), dayKey, ownSpend, lineage, createdAt };
|
|
519
735
|
}
|
|
520
736
|
}
|
|
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
|
-
}
|
|
525
737
|
//# sourceMappingURL=today-spend.js.map
|
package/lib/types/types.d.ts
CHANGED
|
@@ -82,14 +82,61 @@ export interface DeepSeekTodaySessionSpend {
|
|
|
82
82
|
* `null` when the session has no title (or the title could not be resolved).
|
|
83
83
|
*/
|
|
84
84
|
title: string | null;
|
|
85
|
-
/**
|
|
85
|
+
/**
|
|
86
|
+
* Billed cost in CNY on the queried Beijing day for the whole conversation:
|
|
87
|
+
* the top-level session's own spend plus every subagent session it delegated
|
|
88
|
+
* (transitively), since a subagent child is the same conversation's work, not
|
|
89
|
+
* a session the user opened. A subagent row is therefore never reported on
|
|
90
|
+
* its own — its spend rides this total.
|
|
91
|
+
*/
|
|
86
92
|
total: number;
|
|
93
|
+
/**
|
|
94
|
+
* The session's own billed cost in CNY on the queried day, before its
|
|
95
|
+
* subagent descendants were merged into {@link total}; they are equal when no
|
|
96
|
+
* descendant priced anything that day. The row's decomposition fact: `total`
|
|
97
|
+
* is the conversation's day, this is the session's own share of it.
|
|
98
|
+
*/
|
|
99
|
+
ownTotal: number;
|
|
87
100
|
}
|
|
88
|
-
/**
|
|
101
|
+
/**
|
|
102
|
+
* Today's per-session billed spend. One row per top-level session that priced
|
|
103
|
+
* something today, with every subagent session's spend already folded into the
|
|
104
|
+
* row of the session that delegated it (so no subagent appears as its own row).
|
|
105
|
+
*/
|
|
89
106
|
export interface DeepSeekTodaySessionsSpend {
|
|
90
|
-
/**
|
|
107
|
+
/** Top-level sessions with today's spend, sorted by `total` descending. */
|
|
91
108
|
sessions: readonly DeepSeekTodaySessionSpend[];
|
|
92
109
|
}
|
|
110
|
+
/**
|
|
111
|
+
* The subagent part of one conversation's billed spend: every subagent session
|
|
112
|
+
* the queried session delegated (transitively), summed across every day its log
|
|
113
|
+
* covers. A session's own log cannot price its delegation children, so the
|
|
114
|
+
* browser adds this subtotal to the live own-session value to show what the
|
|
115
|
+
* conversation actually cost.
|
|
116
|
+
*/
|
|
117
|
+
export interface DeepSeekDelegatedSpend {
|
|
118
|
+
/** Merged billed cost in CNY across every delegated subagent session. */
|
|
119
|
+
total: number;
|
|
120
|
+
/** One row per model priced in those sessions; empty when the session delegated nothing priced. */
|
|
121
|
+
models: readonly DeepSeekSessionSpendModel[];
|
|
122
|
+
/**
|
|
123
|
+
* Whether the QUERIED session is itself a delegated subagent child. Its own
|
|
124
|
+
* spend then rides the ranking row of the top-level session that delegated
|
|
125
|
+
* it, so the panel has no row of its own to read a today share from.
|
|
126
|
+
*/
|
|
127
|
+
isSubagent: boolean;
|
|
128
|
+
/**
|
|
129
|
+
* Whether the queried session was created BEFORE the current Beijing day, so
|
|
130
|
+
* its billed spend can span more than today. This — not a comparison of two
|
|
131
|
+
* amounts — is what decides whether the panel renders the parenthesized today
|
|
132
|
+
* share: the live session amount and the 60-second-cached ranking row drift
|
|
133
|
+
* apart mid-turn, which would otherwise conjure a parenthesis for a session
|
|
134
|
+
* that started today. `false` when the session started on the current day or
|
|
135
|
+
* when its creation instant could not be resolved (an unproven crossing must
|
|
136
|
+
* not conjure one either).
|
|
137
|
+
*/
|
|
138
|
+
crossedDay: boolean;
|
|
139
|
+
}
|
|
93
140
|
/** The billed cost of one completed Turn. */
|
|
94
141
|
export interface DeepSeekTurnSpend {
|
|
95
142
|
/** Total billed cost in CNY across every priced model in the Turn. */
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rayadesu/dsh-llm-billing",
|
|
3
3
|
"description": "Standalone DeepSeek account-balance and session-spend provider exposed through the billing Remote",
|
|
4
|
-
"version": "0.3.
|
|
4
|
+
"version": "0.3.13",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
7
7
|
},
|
|
@@ -56,29 +56,29 @@
|
|
|
56
56
|
},
|
|
57
57
|
"license": "MIT",
|
|
58
58
|
"peerDependencies": {
|
|
59
|
-
"@deepseek-ai/dsh-credentials": "^0.1.
|
|
60
|
-
"@deepseek-ai/dsh-launch-environment": "^0.1.
|
|
61
|
-
"@deepseek-ai/dsh-invariants": "^0.1.
|
|
62
|
-
"@deepseek-ai/dsh-llm": "^0.1.
|
|
63
|
-
"@deepseek-ai/dsh-session": "^0.1.
|
|
64
|
-
"@deepseek-ai/dsh-session-persistence": "^0.1.
|
|
65
|
-
"@deepseek-ai/dsh-session-projection": "^0.1.
|
|
66
|
-
"@deepseek-ai/dsh-typert-protocol": "^0.1.
|
|
67
|
-
"@deepseek-ai/cordis": "^4.0.
|
|
59
|
+
"@deepseek-ai/dsh-credentials": "^0.1.6-alpha.1",
|
|
60
|
+
"@deepseek-ai/dsh-launch-environment": "^0.1.6-alpha.1",
|
|
61
|
+
"@deepseek-ai/dsh-invariants": "^0.1.6-alpha.1",
|
|
62
|
+
"@deepseek-ai/dsh-llm": "^0.1.6-alpha.1",
|
|
63
|
+
"@deepseek-ai/dsh-session": "^0.1.6-alpha.1",
|
|
64
|
+
"@deepseek-ai/dsh-session-persistence": "^0.1.6-alpha.1",
|
|
65
|
+
"@deepseek-ai/dsh-session-projection": "^0.1.6-alpha.1",
|
|
66
|
+
"@deepseek-ai/dsh-typert-protocol": "^0.1.6-alpha.1",
|
|
67
|
+
"@deepseek-ai/cordis": "^4.0.2"
|
|
68
68
|
},
|
|
69
69
|
"dependencies": {
|
|
70
|
-
"@deepseek-ai/schemastery": "^3.18.
|
|
70
|
+
"@deepseek-ai/schemastery": "^3.18.2",
|
|
71
71
|
"zod": "^4.4.3"
|
|
72
72
|
},
|
|
73
73
|
"devDependencies": {
|
|
74
|
-
"@deepseek-ai/dsh-credentials": "^0.1.
|
|
75
|
-
"@deepseek-ai/dsh-launch-environment": "^0.1.
|
|
76
|
-
"@deepseek-ai/dsh-invariants": "^0.1.
|
|
77
|
-
"@deepseek-ai/dsh-llm": "^0.1.
|
|
78
|
-
"@deepseek-ai/dsh-session": "^0.1.
|
|
79
|
-
"@deepseek-ai/dsh-session-persistence": "^0.1.
|
|
80
|
-
"@deepseek-ai/dsh-session-projection": "^0.1.
|
|
81
|
-
"@deepseek-ai/dsh-typert-protocol": "^0.1.
|
|
82
|
-
"@deepseek-ai/cordis": "^4.0.
|
|
74
|
+
"@deepseek-ai/dsh-credentials": "^0.1.6-alpha.1",
|
|
75
|
+
"@deepseek-ai/dsh-launch-environment": "^0.1.6-alpha.1",
|
|
76
|
+
"@deepseek-ai/dsh-invariants": "^0.1.6-alpha.1",
|
|
77
|
+
"@deepseek-ai/dsh-llm": "^0.1.6-alpha.1",
|
|
78
|
+
"@deepseek-ai/dsh-session": "^0.1.6-alpha.1",
|
|
79
|
+
"@deepseek-ai/dsh-session-persistence": "^0.1.6-alpha.1",
|
|
80
|
+
"@deepseek-ai/dsh-session-projection": "^0.1.6-alpha.1",
|
|
81
|
+
"@deepseek-ai/dsh-typert-protocol": "^0.1.6-alpha.1",
|
|
82
|
+
"@deepseek-ai/cordis": "^4.0.2"
|
|
83
83
|
}
|
|
84
84
|
}
|