@rayadesu/dsh-llm-billing 0.2.2 → 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.
@@ -4,11 +4,12 @@
4
4
  * Remote gateway stays transport-free and the whole spend is testable without
5
5
  * a key.
6
6
  *
7
- * The per-event pricing lives in {@link priceEvent}, the one shared fold
8
- * primitive: the events-scan paths ({@link computeSessionSpend},
9
- * {@link computeTodaySpend}) and the session-projection unit
10
- * (`billingTodaySpend` in projection.ts) all fold the same contribution, so a
11
- * pricing-table change cannot drift one path from the others.
7
+ * The per-event pricing lives in {@link priceEvent} and the fold in the
8
+ * {@link SpendAccumulator}: the events-scan paths ({@link computeSessionSpend},
9
+ * {@link computeTodaySpend}), the session-projection unit (`billingTodaySpend`
10
+ * in projection.ts), and the scanner's single-pass events path all price
11
+ * through the same primitives, so a pricing-table change cannot drift one path
12
+ * from the others.
12
13
  * @module @rayadesu/dsh-llm-billing/billing
13
14
  */
14
15
  /**
@@ -61,20 +62,29 @@ export function resolveBilling(config) {
61
62
  models.set(row.model, { peak: row.peak, offPeak: row.offPeak });
62
63
  return { peakHours, models };
63
64
  }
64
- /** The Beijing (Asia/Shanghai, UTC+8, no DST) hour of a timestamp. */
65
- function beijingHour(now) {
66
- return new Date(now.getTime() + 8 * 3_600_000).getUTCHours();
67
- }
68
65
  /**
69
- * The Beijing (Asia/Shanghai, UTC+8, no DST) weekday of a timestamp, as
70
- * `getUTCDay()`: `0` is Sunday, `6` is Saturday.
66
+ * Derive the Beijing hour, weekday, and calendar-day key of one timestamp from
67
+ * a single shifted `Date` every timezone-sensitive read shares this one
68
+ * implementation, so the pieces cannot drift apart.
69
+ * @param time - epoch milliseconds.
71
70
  */
72
- function beijingWeekday(now) {
73
- return new Date(now.getTime() + 8 * 3_600_000).getUTCDay();
71
+ function beijingParts(time) {
72
+ const shifted = new Date(time + 8 * 3_600_000);
73
+ return {
74
+ hour: shifted.getUTCHours(),
75
+ weekday: shifted.getUTCDay(),
76
+ dayKey: shifted.toISOString().slice(0, 10),
77
+ };
74
78
  }
75
79
  /** The Beijing (Asia/Shanghai, UTC+8, no DST) calendar-day key of a timestamp. */
76
80
  export function beijingDayKey(now) {
77
- return new Date(now.getTime() + 8 * 3_600_000).toISOString().slice(0, 10);
81
+ return beijingParts(now.getTime()).dayKey;
82
+ }
83
+ /** Whether a Beijing (hour, weekday) pair falls inside any peak-hour window. */
84
+ function isPeakParts(billing, hour, weekday) {
85
+ if (weekday === 0 || weekday === 6)
86
+ return false;
87
+ return billing.peakHours.some(({ start, end }) => hour >= start && hour < end);
78
88
  }
79
89
  /**
80
90
  * Whether a timestamp falls inside any peak-hour window (Beijing time,
@@ -85,11 +95,8 @@ export function beijingDayKey(now) {
85
95
  * @returns true during a weekday peak hour.
86
96
  */
87
97
  export function isPeak(billing, now) {
88
- const weekday = beijingWeekday(now);
89
- if (weekday === 0 || weekday === 6)
90
- return false;
91
- const hour = beijingHour(now);
92
- return billing.peakHours.some(({ start, end }) => hour >= start && hour < end);
98
+ const { hour, weekday } = beijingParts(now.getTime());
99
+ return isPeakParts(billing, hour, weekday);
93
100
  }
94
101
  /**
95
102
  * Price one event at the official per-model rates, applying the peak/off-peak
@@ -114,8 +121,8 @@ export function priceEvent(event, billing, names) {
114
121
  const pricing = billing.models.get(model);
115
122
  if (pricing === undefined)
116
123
  return undefined;
117
- const time = new Date(event.time);
118
- const peak = isPeak(billing, time);
124
+ const { hour, weekday, dayKey } = beijingParts(event.time);
125
+ const peak = isPeakParts(billing, hour, weekday);
119
126
  const price = peak ? pricing.peak : pricing.offPeak;
120
127
  const hit = reported.cacheReadTokens ?? 0;
121
128
  const miss = reported.inputTokens + (reported.cacheWriteTokens ?? 0);
@@ -125,7 +132,7 @@ export function priceEvent(event, billing, names) {
125
132
  const outputCost = (output * price.output) / 1_000_000;
126
133
  const cost = hitCost + missCost + outputCost;
127
134
  return {
128
- dayKey: beijingDayKey(time),
135
+ dayKey,
129
136
  model,
130
137
  displayName: names.get(model) ?? model,
131
138
  cost,
@@ -159,6 +166,45 @@ function contributionModel(priced) {
159
166
  outputCost: priced.outputCost,
160
167
  };
161
168
  }
169
+ /** Sum two model rows of the same model (pure). */
170
+ function mergeModelRows(left, right) {
171
+ return {
172
+ model: left.model,
173
+ displayName: left.displayName,
174
+ cost: left.cost + right.cost,
175
+ peakCost: left.peakCost + right.peakCost,
176
+ offPeakCost: left.offPeakCost + right.offPeakCost,
177
+ cacheHitInputTokens: left.cacheHitInputTokens + right.cacheHitInputTokens,
178
+ cacheMissInputTokens: left.cacheMissInputTokens + right.cacheMissInputTokens,
179
+ outputTokens: left.outputTokens + right.outputTokens,
180
+ cacheHitInputCost: left.cacheHitInputCost + right.cacheHitInputCost,
181
+ cacheMissInputCost: left.cacheMissInputCost + right.cacheMissInputCost,
182
+ outputCost: left.outputCost + right.outputCost,
183
+ };
184
+ }
185
+ /**
186
+ * Mutable model-row accumulator behind every spend fold. Rows keep first-seen
187
+ * model order — the same shape a pure `addEventContribution` chain produces —
188
+ * so the single-pass scan path and the pure public paths cannot diverge. One
189
+ * `Map` lookup per contribution instead of a per-event array copy: the huge
190
+ * event-log folds allocate one row object per model, not one intermediate
191
+ * array per event.
192
+ */
193
+ export class SpendAccumulator {
194
+ rows = new Map();
195
+ total = 0;
196
+ /** Add one priced contribution. */
197
+ add(priced) {
198
+ const row = contributionModel(priced);
199
+ const existing = this.rows.get(priced.model);
200
+ this.rows.set(priced.model, existing === undefined ? row : mergeModelRows(existing, row));
201
+ this.total += priced.cost;
202
+ }
203
+ /** The folded spend; the accumulator stays usable afterwards. */
204
+ finish() {
205
+ return { total: this.total, models: [...this.rows.values()] };
206
+ }
207
+ }
162
208
  /**
163
209
  * Merge one priced event's contribution into an accumulator spend (pure:
164
210
  * returns a new spend, never mutates its input).
@@ -167,22 +213,10 @@ function contributionModel(priced) {
167
213
  * @returns the merged spend.
168
214
  */
169
215
  export function addEventContribution(spend, priced) {
170
- const rows = spend.models.map(row => row.model === priced.model
171
- ? {
172
- ...row,
173
- cost: row.cost + priced.cost,
174
- peakCost: row.peakCost + priced.peakCost,
175
- offPeakCost: row.offPeakCost + priced.offPeakCost,
176
- cacheHitInputTokens: row.cacheHitInputTokens + priced.cacheHitInputTokens,
177
- cacheMissInputTokens: row.cacheMissInputTokens + priced.cacheMissInputTokens,
178
- outputTokens: row.outputTokens + priced.outputTokens,
179
- cacheHitInputCost: row.cacheHitInputCost + priced.cacheHitInputCost,
180
- cacheMissInputCost: row.cacheMissInputCost + priced.cacheMissInputCost,
181
- outputCost: row.outputCost + priced.outputCost,
182
- }
183
- : row);
184
- if (!rows.some(row => row.model === priced.model))
185
- rows.push(contributionModel(priced));
216
+ const row = contributionModel(priced);
217
+ const rows = spend.models.map(existing => existing.model === priced.model ? mergeModelRows(existing, row) : existing);
218
+ if (!rows.some(existing => existing.model === priced.model))
219
+ rows.push(row);
186
220
  return { total: spend.total + priced.cost, models: rows };
187
221
  }
188
222
  /**
@@ -193,24 +227,14 @@ export function addEventContribution(spend, priced) {
193
227
  * @returns the summed spend.
194
228
  */
195
229
  export function mergeTodaySpend(target, source) {
196
- let merged = target;
230
+ const rows = new Map();
231
+ for (const row of target.models)
232
+ rows.set(row.model, row);
197
233
  for (const row of source.models) {
198
- merged = addEventContribution(merged, {
199
- dayKey: '',
200
- model: row.model,
201
- displayName: row.displayName,
202
- cost: row.cost,
203
- peakCost: row.peakCost,
204
- offPeakCost: row.offPeakCost,
205
- cacheHitInputTokens: row.cacheHitInputTokens,
206
- cacheMissInputTokens: row.cacheMissInputTokens,
207
- outputTokens: row.outputTokens,
208
- cacheHitInputCost: row.cacheHitInputCost,
209
- cacheMissInputCost: row.cacheMissInputCost,
210
- outputCost: row.outputCost,
211
- });
234
+ const existing = rows.get(row.model);
235
+ rows.set(row.model, existing === undefined ? row : mergeModelRows(existing, row));
212
236
  }
213
- return merged;
237
+ return { total: target.total + source.total, models: [...rows.values()] };
214
238
  }
215
239
  /**
216
240
  * Price a set of billed events at the official per-model rates, applying the
@@ -223,19 +247,19 @@ export function mergeTodaySpend(target, source) {
223
247
  * published table prices only the two V4 rows).
224
248
  * @param events - the events to price.
225
249
  * @param billing - resolved pricing with peak-hour windows.
226
- * @param catalog - model display rows, in presentation order.
250
+ * @param names - model id display label.
251
+ * @param dayKey - when provided, only events on this Beijing calendar day contribute.
227
252
  * @returns the total cost plus one row per priced model.
228
253
  */
229
- function priceEvents(events, billing, catalog) {
230
- const names = new Map(catalog.map(model => [model.id, model.name]));
231
- let spend = { total: 0, models: [] };
254
+ function priceEvents(events, billing, names, dayKey) {
255
+ const accumulator = new SpendAccumulator();
232
256
  for (const event of events) {
233
257
  const priced = priceEvent(event, billing, names);
234
- if (priced === undefined)
258
+ if (priced === undefined || (dayKey !== undefined && priced.dayKey !== dayKey))
235
259
  continue;
236
- spend = addEventContribution(spend, priced);
260
+ accumulator.add(priced);
237
261
  }
238
- return spend;
262
+ return accumulator.finish();
239
263
  }
240
264
  /**
241
265
  * Price one session's complete event log at the official per-model rates.
@@ -245,7 +269,52 @@ function priceEvents(events, billing, catalog) {
245
269
  * @returns the session's total cost plus one row per priced model.
246
270
  */
247
271
  export function computeSessionSpend(events, billing, catalog) {
248
- return priceEvents(events, billing, catalog);
272
+ const names = new Map(catalog.map(model => [model.id, model.name]));
273
+ return priceEvents(events, billing, names);
274
+ }
275
+ /**
276
+ * Price one completed Turn's billed usage at the official per-model rates,
277
+ * identified by its closing assistant message id. The turn's events are those
278
+ * between its `turn/start` and `turn/end` (both matched by the message's own
279
+ * turn coordinate); each priced event applies the peak/off-peak table by its
280
+ * Beijing-time hour and weekday. A message that cannot be located, a turn
281
+ * without bracketing `turn/start` / `turn/end` events (for example after
282
+ * compaction), or a session with no priced usage prices to zero.
283
+ * @param events - one session's complete event log.
284
+ * @param billing - resolved pricing with peak-hour windows.
285
+ * @param catalog - model display rows, in presentation order.
286
+ * @param messageId - the closing assistant message's durable id.
287
+ * @returns the turn's total cost in CNY.
288
+ */
289
+ export function computeTurnSpend(events, billing, catalog, messageId) {
290
+ const names = new Map(catalog.map(model => [model.id, model.name]));
291
+ let turn;
292
+ for (const event of events) {
293
+ if (event.type !== 'assistant/message')
294
+ continue;
295
+ if (event.data.message.id !== messageId)
296
+ continue;
297
+ turn = event.data.turn;
298
+ break;
299
+ }
300
+ if (turn === undefined)
301
+ return { total: 0 };
302
+ const accumulator = new SpendAccumulator();
303
+ let active = false;
304
+ for (const event of events) {
305
+ if (event.type === 'turn/start' && event.data.turn === turn) {
306
+ active = true;
307
+ continue;
308
+ }
309
+ if (event.type === 'turn/end' && event.data.turn === turn)
310
+ break;
311
+ if (!active)
312
+ continue;
313
+ const priced = priceEvent(event, billing, names);
314
+ if (priced !== undefined)
315
+ accumulator.add(priced);
316
+ }
317
+ return { total: accumulator.finish().total };
249
318
  }
250
319
  /**
251
320
  * Price every event whose Beijing-time calendar day is the day of `now`,
@@ -260,13 +329,6 @@ export function computeSessionSpend(events, billing, catalog) {
260
329
  export function computeTodaySpend(events, billing, catalog, now = new Date()) {
261
330
  const day = beijingDayKey(now);
262
331
  const names = new Map(catalog.map(model => [model.id, model.name]));
263
- let spend = emptyTodaySpend();
264
- for (const event of events) {
265
- const priced = priceEvent(event, billing, names);
266
- if (priced === undefined || priced.dayKey !== day)
267
- continue;
268
- spend = addEventContribution(spend, priced);
269
- }
270
- return spend;
332
+ return priceEvents(events, billing, names, day);
271
333
  }
272
334
  //# sourceMappingURL=billing.js.map
@@ -13,18 +13,23 @@
13
13
  * session-projection registry is composed, the plugin additionally registers
14
14
  * the `billingTodaySpend` projection unit, which folds each session's spend
15
15
  * eagerly and lets cold reads ride the projection-cache ladder.
16
+ *
17
+ * A per-session spend cache makes the badge's turn-settled recompute
18
+ * incremental: session logs are append-only and chronological (the same
19
+ * assumption the projection unit makes), so the spend is reused while the log
20
+ * length is unchanged, and only the appended tail is priced when it grows.
16
21
  * @module @rayadesu/dsh-llm-billing
17
22
  */
18
23
  import type { Context } from '@deepseek-ai/cordis';
19
24
  import z from '@deepseek-ai/schemastery';
20
25
  import type { BillingConfig } from './billing.ts';
21
26
  export { DeepSeekBalanceGateway, fetchDeepSeekBalance, parseDeepSeekBalance } from './balance.ts';
22
- export { addEventContribution, beijingDayKey, computeSessionSpend, computeTodaySpend, DEFAULT_MODEL_PRICING, DEFAULT_PEAK_HOURS, emptyTodaySpend, isPeak, mergeTodaySpend, priceEvent, resolveBilling, } from './billing.ts';
27
+ export { addEventContribution, beijingDayKey, computeSessionSpend, computeTodaySpend, computeTurnSpend, DEFAULT_MODEL_PRICING, DEFAULT_PEAK_HOURS, emptyTodaySpend, isPeak, mergeTodaySpend, priceEvent, resolveBilling, SpendAccumulator, } from './billing.ts';
23
28
  export type { BillingConfig, BillingConfigModel, BillingEventContribution, DeepSeekModelPricing, DeepSeekTokenPrice, PeakHourWindow, ResolvedBilling, } from './billing.ts';
24
29
  export type * from './types.ts';
25
30
  export { BILLING_UNIT_KEY, billingTodaySpendDefinition, foldBillingUnit } from './projection.ts';
26
31
  export type { BillingUnitState } from './projection.ts';
27
- export { TodaySpendCache, TodaySpendScanner } from './today-spend.ts';
32
+ export { foldSessionTitle, TodaySpendCache, TodaySpendScanner } from './today-spend.ts';
28
33
  export type { ScannerPersistedHeader, ScannerSession, TodaySpendScannerDeps } from './today-spend.ts';
29
34
  export declare const name = "llm-billing";
30
35
  /** Public API default; deployments may point elsewhere via $DEEPSEEK_BASE_URL. */
@@ -13,6 +13,11 @@
13
13
  * session-projection registry is composed, the plugin additionally registers
14
14
  * the `billingTodaySpend` projection unit, which folds each session's spend
15
15
  * eagerly and lets cold reads ride the projection-cache ladder.
16
+ *
17
+ * A per-session spend cache makes the badge's turn-settled recompute
18
+ * incremental: session logs are append-only and chronological (the same
19
+ * assumption the projection unit makes), so the spend is reused while the log
20
+ * length is unchanged, and only the appended tail is priced when it grows.
16
21
  * @module @rayadesu/dsh-llm-billing
17
22
  */
18
23
  import z from '@deepseek-ai/schemastery';
@@ -20,13 +25,13 @@ import { assertUsableApiKey, LlmError } from '@deepseek-ai/dsh-llm';
20
25
  import { credentialRef } from '@deepseek-ai/dsh-credentials';
21
26
  import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment';
22
27
  import { DeepSeekBalanceGateway, fetchDeepSeekBalance } from "./balance.js";
23
- import { computeSessionSpend, DEFAULT_MODEL_PRICING, DEFAULT_PEAK_HOURS, resolveBilling, } from "./billing.js";
28
+ import { computeSessionSpend, computeTurnSpend, DEFAULT_MODEL_PRICING, DEFAULT_PEAK_HOURS, mergeTodaySpend, resolveBilling, } from "./billing.js";
24
29
  import { billingTodaySpendDefinition } from "./projection.js";
25
30
  import { TodaySpendCache, TodaySpendScanner } from "./today-spend.js";
26
31
  export { DeepSeekBalanceGateway, fetchDeepSeekBalance, parseDeepSeekBalance } from "./balance.js";
27
- export { addEventContribution, beijingDayKey, computeSessionSpend, computeTodaySpend, DEFAULT_MODEL_PRICING, DEFAULT_PEAK_HOURS, emptyTodaySpend, isPeak, mergeTodaySpend, priceEvent, resolveBilling, } from "./billing.js";
32
+ export { addEventContribution, beijingDayKey, computeSessionSpend, computeTodaySpend, computeTurnSpend, DEFAULT_MODEL_PRICING, DEFAULT_PEAK_HOURS, emptyTodaySpend, isPeak, mergeTodaySpend, priceEvent, resolveBilling, SpendAccumulator, } from "./billing.js";
28
33
  export { BILLING_UNIT_KEY, billingTodaySpendDefinition, foldBillingUnit } from "./projection.js";
29
- export { TodaySpendCache, TodaySpendScanner } from "./today-spend.js";
34
+ export { foldSessionTitle, TodaySpendCache, TodaySpendScanner } from "./today-spend.js";
30
35
  export const name = 'llm-billing';
31
36
  const DEFAULT_API_KEY_ENV = 'DEEPSEEK_API_KEY';
32
37
  const BASE_URL_ENV = 'DEEPSEEK_BASE_URL';
@@ -123,8 +128,29 @@ export function apply(ctx, config) {
123
128
  };
124
129
  const billing = resolveBilling(config.billing);
125
130
  const catalog = (config.models ?? DEFAULT_MODELS).map(model => ({ id: model.id, name: model.name ?? model.id }));
131
+ // Per-session incremental spend cache: a session log is append-only and
132
+ // chronological (the same assumption the projection unit makes), so a spend
133
+ // computed for `count` events stays valid while the log length is unchanged,
134
+ // and only the appended tail needs pricing when it grows. A pricing-table
135
+ // change does not retroactively reprice (same caveat as the projection
136
+ // path); the map is capped so an unbounded session-id space cannot grow it
137
+ // without bound.
138
+ const sessionSpendCache = new Map();
126
139
  const fetchSessionSpend = async (sessionId) => {
127
- return computeSessionSpend(await sessionEvents(ctx, sessionId), billing, catalog);
140
+ const events = await sessionEvents(ctx, sessionId);
141
+ const cached = sessionSpendCache.get(sessionId);
142
+ if (cached !== undefined && cached.count === events.length)
143
+ return cached.spend;
144
+ if (cached !== undefined && cached.count < events.length) {
145
+ const spend = mergeTodaySpend(cached.spend, computeSessionSpend(events.slice(cached.count), billing, catalog));
146
+ sessionSpendCache.set(sessionId, { count: events.length, spend });
147
+ return spend;
148
+ }
149
+ const spend = computeSessionSpend(events, billing, catalog);
150
+ if (sessionSpendCache.size >= 1024)
151
+ sessionSpendCache.clear();
152
+ sessionSpendCache.set(sessionId, { count: events.length, spend });
153
+ return spend;
128
154
  };
129
155
  // Plan C: register the per-session spend projection unit on the projection
130
156
  // registry. Registration is lazy — it happens on the first projection-path
@@ -160,7 +186,13 @@ export function apply(ctx, config) {
160
186
  catalog,
161
187
  });
162
188
  const todayCache = new TodaySpendCache(dayKey => scanner.scan(dayKey), TODAY_SPEND_CACHE_MS);
189
+ const todaySessionsCache = new TodaySpendCache(dayKey => scanner.scanSessions(dayKey), TODAY_SPEND_CACHE_MS);
163
190
  const fetchTodaySpend = async (force = false) => todayCache.get(force);
164
- new DeepSeekBalanceGateway(ctx, { fetchBalance, fetchSessionSpend, fetchTodaySpend });
191
+ const fetchTodaySessionsSpend = async (force = false) => todaySessionsCache.get(force);
192
+ const fetchTurnSpend = async (sessionId, messageId) => {
193
+ const events = await sessionEvents(ctx, sessionId);
194
+ return computeTurnSpend(events, billing, catalog, messageId);
195
+ };
196
+ new DeepSeekBalanceGateway(ctx, { fetchBalance, fetchSessionSpend, fetchTodaySpend, fetchTodaySessionsSpend, fetchTurnSpend });
165
197
  }
166
198
  //# sourceMappingURL=index.js.map
@@ -9,9 +9,10 @@
9
9
  * with write-back) or, without the cache service, one detached local fold
10
10
  * over a full `inspect`. Persisted revisions gate every cold read, so a
11
11
  * session whose log did not change since the last resolution costs nothing.
12
- * - events path (plans A2/A3): collect only today's events (per-event
13
- * Beijing-day filter during collection) with a hard cap, skipping sessions
14
- * whose persisted revision is unchanged since the last scan.
12
+ * - events path (plans A2/A3): collect and price only today's events in one
13
+ * pass (per-event Beijing-day filter during collection) with a hard cap,
14
+ * skipping sessions whose persisted revision is unchanged since the last
15
+ * scan.
15
16
  *
16
17
  * Both strategies run behind the same {@link TodaySpendCache}, so a miss
17
18
  * happens at most once per 60 seconds per process, and a manual refresh
@@ -22,9 +23,18 @@
22
23
  import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session';
23
24
  import type { SessionPersistenceRevision } from '@deepseek-ai/dsh-session-persistence';
24
25
  import type { ResolvedBilling } from './billing.ts';
25
- import type { DeepSeekTodaySpend } from './types.ts';
26
+ import type { DeepSeekTodaySessionsSpend, DeepSeekTodaySpend } from './types.ts';
26
27
  import { BILLING_UNIT_KEY, type BillingUnitState } from './projection.ts';
27
28
  import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection';
29
+ /**
30
+ * Fold one session's durable display title: the latest `session/title`
31
+ * event's text (last-wins, matching the `title` projection), or `null` before
32
+ * the first title lands. The fold runs over the complete log, so an explicit
33
+ * user rename is picked up as soon as its event commits.
34
+ * @param events - one session's complete event log.
35
+ * @returns the session's current title, or `null` when untitled.
36
+ */
37
+ export declare function foldSessionTitle(events: readonly SessionEvent[]): string | null;
28
38
  /** Structural slice of a live session the scanner reads. */
29
39
  export interface ScannerSession {
30
40
  readonly id: SessionId;
@@ -88,8 +98,10 @@ export interface TodaySpendScannerDeps {
88
98
  * coalesces concurrent misses, and a `force` bypass for the manual refresh
89
99
  * path. Cross-day invalidation is automatic (the day key changes); a failed
90
100
  * scan leaves the previous value in place and retries on the next call.
101
+ * @typeParam T - the cached aggregate's value shape (the spend or its
102
+ * per-session breakdown).
91
103
  */
92
- export declare class TodaySpendCache {
104
+ export declare class TodaySpendCache<T = DeepSeekTodaySpend> {
93
105
  private readonly scan;
94
106
  private readonly ttlMs;
95
107
  private readonly now;
@@ -98,18 +110,18 @@ export declare class TodaySpendCache {
98
110
  private cachedAt;
99
111
  private inFlight;
100
112
  /**
113
+ * @param scan - the aggregate computation behind a miss.
101
114
  * @param ttlMs - time window in milliseconds (default 60 000).
102
115
  * @param now - clock source (injectable for tests).
103
- * @param scan - the aggregate computation behind a miss.
104
116
  */
105
- constructor(scan: (dayKey: string) => Promise<DeepSeekTodaySpend>, ttlMs?: number, now?: () => Date);
117
+ constructor(scan: (dayKey: string) => Promise<T>, ttlMs?: number, now?: () => Date);
106
118
  /**
107
119
  * Read today's spend, cached per Beijing day within the TTL window.
108
120
  * @param force - bypass the time window (manual refresh); the day-key gate
109
121
  * and the in-flight coalescing still apply to non-force callers.
110
122
  * @returns today's spend.
111
123
  */
112
- get(force?: boolean): Promise<DeepSeekTodaySpend>;
124
+ get(force?: boolean): Promise<T>;
113
125
  }
114
126
  /**
115
127
  * The aggregate computation behind a cache miss. Chooses the projection path
@@ -119,7 +131,7 @@ export declare class TodaySpendCache {
119
131
  */
120
132
  export declare class TodaySpendScanner {
121
133
  private readonly deps;
122
- /** Cold sessions resolved on the projection path: id → revision + unit state. */
134
+ /** Cold sessions resolved on the projection path: id → revision + unit state + title. */
123
135
  private readonly coldResolved;
124
136
  /** Cold sessions resolved on the events path: id → revision (events were collected). */
125
137
  private lastEventsScan;
@@ -130,9 +142,49 @@ export declare class TodaySpendScanner {
130
142
  * @returns today's spend across every session.
131
143
  */
132
144
  scan(dayKey: string): Promise<DeepSeekTodaySpend>;
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
+ scanSessions(dayKey: string): Promise<DeepSeekTodaySessionsSpend>;
153
+ /**
154
+ * Resolve one cold session's billing unit state and display title through
155
+ * the projection-cache ladder (cached row first, then a detached local
156
+ * fold over a full inspect). A cache-served value carries no title (the
157
+ * ladder only stores projection values), so such rows report `title: null`
158
+ * until the session is inspected again.
159
+ * @param id - the cold session's id.
160
+ * @returns the resolved state and title, or `undefined` when unreadable.
161
+ */
162
+ private resolveCold;
133
163
  /** Projection path: eager cells for live sessions, revision-gated cold ladder for the rest. */
134
164
  private scanProjections;
135
- /** Events path: collect only today's events (capped), gated by revisions. */
165
+ /**
166
+ * Events path: price today's events in a single pass (per-event Beijing-day
167
+ * filter during collection, hard cap), gated by revisions.
168
+ */
136
169
  private scanEvents;
170
+ /**
171
+ * Projection-path per-session scan: eager cells for live sessions (title
172
+ * folded from the live log, so a rename is reflected immediately),
173
+ * revision-gated cold ladder for the rest (title resolved on inspect,
174
+ * `null` when served from the projection cache).
175
+ * @param dayKey - the Beijing-time calendar-day key to aggregate.
176
+ * @returns unsorted per-session rows for the day.
177
+ */
178
+ private scanSessionsProjections;
179
+ /**
180
+ * Events-path per-session scan: price today's events in a single pass,
181
+ * accumulating per session (per-event Beijing-day filter during collection,
182
+ * hard cap), gated by revisions. Titles fold from each session's complete
183
+ * log — a `session/title` event can predate today — so a rename is reflected
184
+ * as soon as the session's log is re-read.
185
+ * @param dayKey - the Beijing-time calendar-day key to aggregate.
186
+ * @returns unsorted per-session rows for the day.
187
+ */
188
+ private scanSessionsEvents;
137
189
  }
138
190
  //# sourceMappingURL=today-spend.d.ts.map