@rayadesu/dsh-llm-billing 0.2.3 → 0.2.4

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.
@@ -22,6 +22,27 @@
22
22
  */
23
23
  import { beijingDayKey, emptyTodaySpend, mergeTodaySpend, priceEvent, SpendAccumulator } from "./billing.js";
24
24
  import { BILLING_UNIT_KEY, foldBillingUnit } from "./projection.js";
25
+ /**
26
+ * Fold one session's durable display title: the latest `session/title`
27
+ * event's text (last-wins, matching the `title` projection), or `null` before
28
+ * the first title lands. The fold runs over the complete log, so an explicit
29
+ * user rename is picked up as soon as its event commits.
30
+ * @param events - one session's complete event log.
31
+ * @returns the session's current title, or `null` when untitled.
32
+ */
33
+ export function foldSessionTitle(events) {
34
+ for (let index = events.length - 1; index >= 0; index--) {
35
+ const event = events[index];
36
+ // `session/title` joined the SessionEvent union after the npm
37
+ // 0.1.1-rc.2 baseline this package builds against; read its payload
38
+ // through the structural escape hatch (runtime logs carry it).
39
+ if (event.type !== 'session/title')
40
+ continue;
41
+ const data = event.data;
42
+ return typeof data.title === 'string' ? data.title : null;
43
+ }
44
+ return null;
45
+ }
25
46
  /**
26
47
  * Bounded parallel fan-out: run `run` over `items` with at most `limit` in
27
48
  * flight. A shared index counter hands each worker its next job, so the
@@ -42,6 +63,8 @@ async function withConcurrency(items, limit, run) {
42
63
  * coalesces concurrent misses, and a `force` bypass for the manual refresh
43
64
  * path. Cross-day invalidation is automatic (the day key changes); a failed
44
65
  * scan leaves the previous value in place and retries on the next call.
66
+ * @typeParam T - the cached aggregate's value shape (the spend or its
67
+ * per-session breakdown).
45
68
  */
46
69
  export class TodaySpendCache {
47
70
  scan;
@@ -52,9 +75,9 @@ export class TodaySpendCache {
52
75
  cachedAt = 0;
53
76
  inFlight;
54
77
  /**
78
+ * @param scan - the aggregate computation behind a miss.
55
79
  * @param ttlMs - time window in milliseconds (default 60 000).
56
80
  * @param now - clock source (injectable for tests).
57
- * @param scan - the aggregate computation behind a miss.
58
81
  */
59
82
  constructor(scan, ttlMs = 60_000, now = () => new Date()) {
60
83
  this.scan = scan;
@@ -101,7 +124,7 @@ export class TodaySpendCache {
101
124
  */
102
125
  export class TodaySpendScanner {
103
126
  deps;
104
- /** Cold sessions resolved on the projection path: id → revision + unit state. */
127
+ /** Cold sessions resolved on the projection path: id → revision + unit state + title. */
105
128
  coldResolved = new Map();
106
129
  /** Cold sessions resolved on the events path: id → revision (events were collected). */
107
130
  lastEventsScan;
@@ -119,12 +142,60 @@ export class TodaySpendScanner {
119
142
  this.deps.ensureUnit?.();
120
143
  return this.scanProjections(dayKey);
121
144
  }
145
+ /**
146
+ * Compute today's per-session spend for one Beijing day, sorted by cost
147
+ * descending. Sessions with no priced usage on the day are omitted; each
148
+ * row carries the session's durable title folded from its log.
149
+ * @param dayKey - the Beijing-time calendar-day key to aggregate.
150
+ * @returns today's per-session rows, highest first.
151
+ */
152
+ async scanSessions(dayKey) {
153
+ const rows = this.deps.projections?.() === undefined
154
+ ? await this.scanSessionsEvents(dayKey)
155
+ : await this.scanSessionsProjections(dayKey);
156
+ rows.sort((left, right) => right.total - left.total);
157
+ return { sessions: rows };
158
+ }
159
+ /**
160
+ * Resolve one cold session's billing unit state and display title through
161
+ * the projection-cache ladder (cached row first, then a detached local
162
+ * fold over a full inspect). A cache-served value carries no title (the
163
+ * ladder only stores projection values), so such rows report `title: null`
164
+ * until the session is inspected again.
165
+ * @param id - the cold session's id.
166
+ * @returns the resolved state and title, or `undefined` when unreadable.
167
+ */
168
+ async resolveCold(id) {
169
+ const { persistence, projectionCache, unit, logger } = this.deps;
170
+ const cache = projectionCache?.();
171
+ if (cache !== undefined) {
172
+ try {
173
+ const value = (await cache.coldSnapshot(id)).values[BILLING_UNIT_KEY];
174
+ if (value !== undefined)
175
+ return { value, title: null };
176
+ }
177
+ catch (error) {
178
+ logger.warn(`llm-billing: projection cold read for session ${id} failed: ${String(error)}`);
179
+ }
180
+ }
181
+ const persistenceService = persistence?.();
182
+ if (persistenceService === undefined)
183
+ return undefined;
184
+ try {
185
+ const inspection = await persistenceService.inspect(id);
186
+ return { value: foldBillingUnit(unit, inspection.events), title: foldSessionTitle(inspection.events) };
187
+ }
188
+ catch (error) {
189
+ // One unreadable session must not blank the whole-day aggregate.
190
+ logger.warn(`llm-billing: skipping unreadable session ${id}: ${String(error)}`);
191
+ return undefined;
192
+ }
193
+ }
122
194
  /** Projection path: eager cells for live sessions, revision-gated cold ladder for the rest. */
123
195
  async scanProjections(dayKey) {
124
- const { sessions, persistence, projections, projectionCache, unit, logger } = this.deps;
196
+ const { sessions, persistence, projections } = this.deps;
125
197
  // Services resolve once per scan, not per session / per cold task.
126
198
  const projectionsService = projections?.();
127
- const cache = projectionCache?.();
128
199
  let total = emptyTodaySpend();
129
200
  const liveIds = new Set();
130
201
  if (sessions !== undefined) {
@@ -156,27 +227,9 @@ export class TodaySpendScanner {
156
227
  pending.push({ id: header.id, revision });
157
228
  }
158
229
  await withConcurrency(pending, 8, async ({ id, revision }) => {
159
- let value;
160
- if (cache !== undefined) {
161
- try {
162
- value = (await cache.coldSnapshot(id)).values[BILLING_UNIT_KEY];
163
- }
164
- catch (error) {
165
- logger.warn(`llm-billing: projection cold read for session ${id} failed: ${String(error)}`);
166
- }
167
- }
168
- if (value === undefined) {
169
- try {
170
- const inspection = await persistenceService.inspect(id);
171
- value = foldBillingUnit(unit, inspection.events);
172
- }
173
- catch (error) {
174
- // One unreadable session must not blank the whole-day aggregate.
175
- logger.warn(`llm-billing: skipping unreadable session ${id}: ${String(error)}`);
176
- }
177
- }
178
- if (value !== undefined)
179
- this.coldResolved.set(id, { revision, value });
230
+ const resolved = await this.resolveCold(id);
231
+ if (resolved !== undefined)
232
+ this.coldResolved.set(id, { revision, ...resolved });
180
233
  });
181
234
  for (const { id } of pending) {
182
235
  const resolved = this.coldResolved.get(id);
@@ -251,5 +304,141 @@ export class TodaySpendScanner {
251
304
  logger.warn(`llm-billing: today's events exceeded ${maxEvents}; result truncated`);
252
305
  return accumulator.finish();
253
306
  }
307
+ /**
308
+ * Projection-path per-session scan: eager cells for live sessions (title
309
+ * folded from the live log, so a rename is reflected immediately),
310
+ * revision-gated cold ladder for the rest (title resolved on inspect,
311
+ * `null` when served from the projection cache).
312
+ * @param dayKey - the Beijing-time calendar-day key to aggregate.
313
+ * @returns unsorted per-session rows for the day.
314
+ */
315
+ async scanSessionsProjections(dayKey) {
316
+ const { sessions, persistence, projections } = this.deps;
317
+ const projectionsService = projections?.();
318
+ const rows = new Map();
319
+ const liveIds = new Set();
320
+ if (sessions !== undefined) {
321
+ const store = sessions();
322
+ if (store !== undefined) {
323
+ for (const session of store.list()) {
324
+ liveIds.add(session.id);
325
+ const state = projectionsService?.stateOf(session, BILLING_UNIT_KEY);
326
+ if (state !== undefined && state.dayKey === dayKey) {
327
+ rows.set(session.id, {
328
+ sessionId: session.id,
329
+ title: foldSessionTitle(session.events),
330
+ total: state.spend.total,
331
+ });
332
+ }
333
+ }
334
+ }
335
+ }
336
+ const persistenceService = persistence?.();
337
+ if (persistenceService === undefined)
338
+ return [...rows.values()];
339
+ const snapshots = await persistenceService.listSnapshots();
340
+ const pending = [];
341
+ for (const { header, revision } of snapshots) {
342
+ if (liveIds.has(header.id))
343
+ continue;
344
+ const resolved = this.coldResolved.get(header.id);
345
+ if (resolved !== undefined && resolved.revision === revision) {
346
+ if (resolved.value.dayKey === dayKey) {
347
+ rows.set(header.id, { sessionId: header.id, title: resolved.title, total: resolved.value.spend.total });
348
+ }
349
+ continue;
350
+ }
351
+ pending.push({ id: header.id, revision });
352
+ }
353
+ await withConcurrency(pending, 8, async ({ id, revision }) => {
354
+ const resolved = await this.resolveCold(id);
355
+ if (resolved !== undefined)
356
+ this.coldResolved.set(id, { revision, ...resolved });
357
+ });
358
+ for (const { id } of pending) {
359
+ const resolved = this.coldResolved.get(id);
360
+ if (resolved !== undefined && resolved.value.dayKey === dayKey) {
361
+ rows.set(id, { sessionId: id, title: resolved.title, total: resolved.value.spend.total });
362
+ }
363
+ }
364
+ return [...rows.values()];
365
+ }
366
+ /**
367
+ * Events-path per-session scan: price today's events in a single pass,
368
+ * accumulating per session (per-event Beijing-day filter during collection,
369
+ * hard cap), gated by revisions. Titles fold from each session's complete
370
+ * log — a `session/title` event can predate today — so a rename is reflected
371
+ * as soon as the session's log is re-read.
372
+ * @param dayKey - the Beijing-time calendar-day key to aggregate.
373
+ * @returns unsorted per-session rows for the day.
374
+ */
375
+ async scanSessionsEvents(dayKey) {
376
+ const { sessions, persistence, maxEvents, logger, billing, catalog } = this.deps;
377
+ const names = new Map(catalog.map(model => [model.id, model.name]));
378
+ const rows = new Map();
379
+ const liveIds = new Set();
380
+ let collected = 0;
381
+ let truncated = false;
382
+ const collect = (id, events) => {
383
+ let row = rows.get(id);
384
+ if (row === undefined) {
385
+ row = { title: foldSessionTitle(events), total: 0 };
386
+ rows.set(id, row);
387
+ }
388
+ for (const event of events) {
389
+ if (beijingDayKey(new Date(event.time)) !== dayKey)
390
+ continue;
391
+ collected += 1;
392
+ if (collected > maxEvents) {
393
+ truncated = true;
394
+ return;
395
+ }
396
+ const priced = priceEvent(event, billing, names);
397
+ if (priced !== undefined)
398
+ row.total += priced.cost;
399
+ }
400
+ };
401
+ if (sessions !== undefined) {
402
+ const store = sessions();
403
+ if (store !== undefined) {
404
+ for (const session of store.list()) {
405
+ liveIds.add(session.id);
406
+ collect(session.id, session.events);
407
+ if (truncated)
408
+ break;
409
+ }
410
+ }
411
+ }
412
+ const persistenceService = persistence?.();
413
+ if (!truncated && persistenceService !== undefined) {
414
+ const snapshots = await persistenceService.listSnapshots();
415
+ for (const { header, revision } of snapshots) {
416
+ if (liveIds.has(header.id))
417
+ continue;
418
+ if (this.lastEventsScan?.get(header.id) === revision)
419
+ continue;
420
+ try {
421
+ collect(header.id, (await persistenceService.inspect(header.id)).events);
422
+ }
423
+ catch (error) {
424
+ // One unreadable session must not blank the whole-day aggregate.
425
+ logger.warn(`llm-billing: skipping unreadable session ${header.id}: ${String(error)}`);
426
+ }
427
+ if (truncated)
428
+ break;
429
+ }
430
+ // Only a complete pass may advance the revision watermark: a truncated
431
+ // pass left sessions unread, and recording them would skip their events
432
+ // on the next scan.
433
+ if (!truncated) {
434
+ this.lastEventsScan = new Map(snapshots.map(snapshot => [snapshot.header.id, snapshot.revision]));
435
+ }
436
+ }
437
+ if (truncated)
438
+ logger.warn(`llm-billing: today's events exceeded ${maxEvents}; result truncated`);
439
+ return [...rows.entries()]
440
+ .filter(([, row]) => row.total > 0)
441
+ .map(([sessionId, row]) => ({ sessionId, title: row.title, total: row.total }));
442
+ }
254
443
  }
255
444
  //# sourceMappingURL=today-spend.js.map
@@ -3,6 +3,7 @@
3
3
  * its generated artifacts, and the web UI.
4
4
  * @module @rayadesu/dsh-llm-billing/types
5
5
  */
6
+ import type { SessionId } from '@deepseek-ai/dsh-session';
6
7
  /** One currency line of the account balance returned by `GET /user/balance`. */
7
8
  export interface DeepSeekBalanceLine {
8
9
  /** Currency code, e.g. `CNY` or `USD`. */
@@ -60,4 +61,26 @@ export interface DeepSeekTodaySpend {
60
61
  /** One row per model that reported usage AND has a pricing row; empty when today has no priced usage. */
61
62
  models: readonly DeepSeekSessionSpendModel[];
62
63
  }
64
+ /** One session's billed spend on one Beijing-time calendar day, priced per event by its Beijing-time hour and weekday (peak hours apply Monday–Friday only; weekends are off-peak). */
65
+ export interface DeepSeekTodaySessionSpend {
66
+ /** The session's durable identity. */
67
+ sessionId: SessionId;
68
+ /**
69
+ * The session's display title: the latest `session/title` event's text, or
70
+ * `null` when the session has no title (or the title could not be resolved).
71
+ */
72
+ title: string | null;
73
+ /** Billed cost in CNY on the queried Beijing day. */
74
+ total: number;
75
+ }
76
+ /** Today's per-session billed spend across every session with a non-zero cost. */
77
+ export interface DeepSeekTodaySessionsSpend {
78
+ /** Sessions with today's spend, sorted by `total` descending. */
79
+ sessions: readonly DeepSeekTodaySessionSpend[];
80
+ }
81
+ /** The billed cost of one completed Turn, priced per event by its Beijing-time hour and weekday (peak hours apply Monday–Friday only; weekends are off-peak). */
82
+ export interface DeepSeekTurnSpend {
83
+ /** Total billed cost in CNY across every priced model in the Turn. */
84
+ total: number;
85
+ }
63
86
  //# sourceMappingURL=types.d.ts.map
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.2.3",
4
+ "version": "0.2.4",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },