@rayadesu/dsh-llm-billing 0.2.1 → 0.2.3

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.
@@ -3,9 +3,20 @@
3
3
  * pricing. Pure functions over session events and the pricing table, so the
4
4
  * Remote gateway stays transport-free and the whole spend is testable without
5
5
  * a key.
6
- * @module @deepseek-ai/dsh-llm-billing/billing
6
+ *
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.
13
+ * @module @rayadesu/dsh-llm-billing/billing
14
+ */
15
+ /**
16
+ * Published peak-hour windows (Beijing time): 09:00–12:00 and 14:00–18:00,
17
+ * applied on weekdays (Monday–Friday) only — weekends are always off-peak
18
+ * (effective 2026-08-23).
7
19
  */
8
- /** Published peak-hour windows (Beijing time): 09:00–12:00 and 14:00–18:00. */
9
20
  export const DEFAULT_PEAK_HOURS = [
10
21
  { start: 9, end: 12 },
11
22
  { start: 14, end: 18 },
@@ -22,6 +33,13 @@ export const DEFAULT_MODEL_PRICING = [
22
33
  peak: { cacheHitInput: 0.30, cacheMissInput: 9.0, output: 27.0 },
23
34
  offPeak: { cacheHitInput: 0.15, cacheMissInput: 4.5, output: 13.5 },
24
35
  },
36
+ // deepseek-v4-flash-vision-exp bills at the same rates as deepseek-v4-flash;
37
+ // images are converted to tokens at the same per-token price.
38
+ {
39
+ model: 'deepseek-v4-flash-vision-exp',
40
+ peak: { cacheHitInput: 0.10, cacheMissInput: 3.0, output: 9.0 },
41
+ offPeak: { cacheHitInput: 0.05, cacheMissInput: 1.5, output: 4.5 },
42
+ },
25
43
  ];
26
44
  /**
27
45
  * Resolve optional configuration to a pricing table, defaulting omitted or
@@ -44,27 +62,184 @@ export function resolveBilling(config) {
44
62
  models.set(row.model, { peak: row.peak, offPeak: row.offPeak });
45
63
  return { peakHours, models };
46
64
  }
47
- /** The Beijing (Asia/Shanghai, UTC+8, no DST) hour of a timestamp. */
48
- function beijingHour(now) {
49
- return new Date(now.getTime() + 8 * 3_600_000).getUTCHours();
65
+ /**
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.
70
+ */
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
+ };
50
78
  }
51
79
  /** The Beijing (Asia/Shanghai, UTC+8, no DST) calendar-day key of a timestamp. */
52
- function beijingDayKey(now) {
53
- return new Date(now.getTime() + 8 * 3_600_000).toISOString().slice(0, 10);
80
+ export function beijingDayKey(now) {
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);
54
88
  }
55
89
  /**
56
- * Whether a timestamp falls inside any peak-hour window (Beijing time).
90
+ * Whether a timestamp falls inside any peak-hour window (Beijing time,
91
+ * weekdays Monday–Friday only). Weekends (Saturday and Sunday) are always
92
+ * off-peak, matching the published peak-hours rule.
57
93
  * @param billing - resolved pricing with peak-hour windows.
58
94
  * @param now - the moment to classify.
59
- * @returns true during peak hours.
95
+ * @returns true during a weekday peak hour.
60
96
  */
61
97
  export function isPeak(billing, now) {
62
- const hour = beijingHour(now);
63
- return billing.peakHours.some(({ start, end }) => hour >= start && hour < end);
98
+ const { hour, weekday } = beijingParts(now.getTime());
99
+ return isPeakParts(billing, hour, weekday);
100
+ }
101
+ /**
102
+ * Price one event at the official per-model rates, applying the peak/off-peak
103
+ * table by its Beijing-time hour and weekday (peak windows apply Monday–Friday
104
+ * only; weekends are off-peak). Each `assistant/message` event with usage
105
+ * contributes cache-hit input, cache-miss input (uncached input plus cache
106
+ * writes), and output (reasoning included) tokens at the rate of its own
107
+ * timestamp; a model with usage but no pricing row contributes nothing (the
108
+ * published table prices only the two V4 rows).
109
+ * @param event - the event to price.
110
+ * @param billing - resolved pricing with peak-hour windows.
111
+ * @param names - model id → display label.
112
+ * @returns the priced contribution, or `undefined` when the event has no priced usage.
113
+ */
114
+ export function priceEvent(event, billing, names) {
115
+ if (event.type !== 'assistant/message')
116
+ return undefined;
117
+ const reported = event.data.usage;
118
+ if (reported === undefined)
119
+ return undefined;
120
+ const model = event.data.message.source.model;
121
+ const pricing = billing.models.get(model);
122
+ if (pricing === undefined)
123
+ return undefined;
124
+ const { hour, weekday, dayKey } = beijingParts(event.time);
125
+ const peak = isPeakParts(billing, hour, weekday);
126
+ const price = peak ? pricing.peak : pricing.offPeak;
127
+ const hit = reported.cacheReadTokens ?? 0;
128
+ const miss = reported.inputTokens + (reported.cacheWriteTokens ?? 0);
129
+ const output = reported.outputTokens;
130
+ const hitCost = (hit * price.cacheHitInput) / 1_000_000;
131
+ const missCost = (miss * price.cacheMissInput) / 1_000_000;
132
+ const outputCost = (output * price.output) / 1_000_000;
133
+ const cost = hitCost + missCost + outputCost;
134
+ return {
135
+ dayKey,
136
+ model,
137
+ displayName: names.get(model) ?? model,
138
+ cost,
139
+ peakCost: peak ? cost : 0,
140
+ offPeakCost: peak ? 0 : cost,
141
+ cacheHitInputTokens: hit,
142
+ cacheMissInputTokens: miss,
143
+ outputTokens: output,
144
+ cacheHitInputCost: hitCost,
145
+ cacheMissInputCost: missCost,
146
+ outputCost,
147
+ };
148
+ }
149
+ /** A spend with no priced usage. */
150
+ export function emptyTodaySpend() {
151
+ return { total: 0, models: [] };
152
+ }
153
+ /** The today-spend shape of a single priced contribution. */
154
+ function contributionModel(priced) {
155
+ return {
156
+ model: priced.model,
157
+ displayName: priced.displayName,
158
+ cost: priced.cost,
159
+ peakCost: priced.peakCost,
160
+ offPeakCost: priced.offPeakCost,
161
+ cacheHitInputTokens: priced.cacheHitInputTokens,
162
+ cacheMissInputTokens: priced.cacheMissInputTokens,
163
+ outputTokens: priced.outputTokens,
164
+ cacheHitInputCost: priced.cacheHitInputCost,
165
+ cacheMissInputCost: priced.cacheMissInputCost,
166
+ outputCost: priced.outputCost,
167
+ };
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
+ }
208
+ /**
209
+ * Merge one priced event's contribution into an accumulator spend (pure:
210
+ * returns a new spend, never mutates its input).
211
+ * @param spend - the accumulator (per session and day, or across sessions).
212
+ * @param priced - the priced contribution to add.
213
+ * @returns the merged spend.
214
+ */
215
+ export function addEventContribution(spend, 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);
220
+ return { total: spend.total + priced.cost, models: rows };
221
+ }
222
+ /**
223
+ * Sum two spends (per session and day, or across sessions) into one (pure:
224
+ * returns a new spend, never mutates its inputs).
225
+ * @param target - the accumulator spend.
226
+ * @param source - the spend to add.
227
+ * @returns the summed spend.
228
+ */
229
+ export function mergeTodaySpend(target, source) {
230
+ const rows = new Map();
231
+ for (const row of target.models)
232
+ rows.set(row.model, row);
233
+ for (const row of source.models) {
234
+ const existing = rows.get(row.model);
235
+ rows.set(row.model, existing === undefined ? row : mergeModelRows(existing, row));
236
+ }
237
+ return { total: target.total + source.total, models: [...rows.values()] };
64
238
  }
65
239
  /**
66
240
  * Price a set of billed events at the official per-model rates, applying the
67
- * peak/off-peak table per event by its Beijing-time hour. Each
241
+ * peak/off-peak table per event by its Beijing-time hour and weekday (peak
242
+ * windows apply Monday–Friday only; weekends are off-peak). Each
68
243
  * `assistant/message` event with usage contributes cache-hit input, cache-miss
69
244
  * input (uncached input plus cache writes), and output (reasoning included)
70
245
  * tokens at the rate of its own timestamp, with the three component costs
@@ -72,69 +247,19 @@ export function isPeak(billing, now) {
72
247
  * published table prices only the two V4 rows).
73
248
  * @param events - the events to price.
74
249
  * @param billing - resolved pricing with peak-hour windows.
75
- * @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.
76
252
  * @returns the total cost plus one row per priced model.
77
253
  */
78
- function priceEvents(events, billing, catalog) {
79
- const names = new Map(catalog.map(model => [model.id, model.name]));
80
- const rows = new Map();
254
+ function priceEvents(events, billing, names, dayKey) {
255
+ const accumulator = new SpendAccumulator();
81
256
  for (const event of events) {
82
- if (event.type !== 'assistant/message')
83
- continue;
84
- const reported = event.data.usage;
85
- if (reported === undefined)
86
- continue;
87
- const model = event.data.message.source.model;
88
- const pricing = billing.models.get(model);
89
- if (pricing === undefined)
257
+ const priced = priceEvent(event, billing, names);
258
+ if (priced === undefined || (dayKey !== undefined && priced.dayKey !== dayKey))
90
259
  continue;
91
- const peak = isPeak(billing, new Date(event.time));
92
- const price = peak ? pricing.peak : pricing.offPeak;
93
- const hit = reported.cacheReadTokens ?? 0;
94
- const miss = reported.inputTokens + (reported.cacheWriteTokens ?? 0);
95
- const output = reported.outputTokens;
96
- const hitCost = (hit * price.cacheHitInput) / 1_000_000;
97
- const missCost = (miss * price.cacheMissInput) / 1_000_000;
98
- const outputCost = (output * price.output) / 1_000_000;
99
- const cost = hitCost + missCost + outputCost;
100
- let row = rows.get(model);
101
- if (row === undefined) {
102
- row = {
103
- cacheHitInputTokens: 0, cacheMissInputTokens: 0, outputTokens: 0,
104
- cost: 0, peakCost: 0, offPeakCost: 0,
105
- cacheHitInputCost: 0, cacheMissInputCost: 0, outputCost: 0,
106
- };
107
- rows.set(model, row);
108
- }
109
- row.cacheHitInputTokens += hit;
110
- row.cacheMissInputTokens += miss;
111
- row.outputTokens += output;
112
- row.cost += cost;
113
- row.cacheHitInputCost += hitCost;
114
- row.cacheMissInputCost += missCost;
115
- row.outputCost += outputCost;
116
- if (peak)
117
- row.peakCost += cost;
118
- else
119
- row.offPeakCost += cost;
260
+ accumulator.add(priced);
120
261
  }
121
- const models = [...rows.entries()].map(([model, row]) => ({
122
- model,
123
- displayName: names.get(model) ?? model,
124
- cost: row.cost,
125
- peakCost: row.peakCost,
126
- offPeakCost: row.offPeakCost,
127
- cacheHitInputTokens: row.cacheHitInputTokens,
128
- cacheMissInputTokens: row.cacheMissInputTokens,
129
- outputTokens: row.outputTokens,
130
- cacheHitInputCost: row.cacheHitInputCost,
131
- cacheMissInputCost: row.cacheMissInputCost,
132
- outputCost: row.outputCost,
133
- }));
134
- return {
135
- total: models.reduce((sum, model) => sum + model.cost, 0),
136
- models,
137
- };
262
+ return accumulator.finish();
138
263
  }
139
264
  /**
140
265
  * Price one session's complete event log at the official per-model rates.
@@ -144,7 +269,8 @@ function priceEvents(events, billing, catalog) {
144
269
  * @returns the session's total cost plus one row per priced model.
145
270
  */
146
271
  export function computeSessionSpend(events, billing, catalog) {
147
- return priceEvents(events, billing, catalog);
272
+ const names = new Map(catalog.map(model => [model.id, model.name]));
273
+ return priceEvents(events, billing, names);
148
274
  }
149
275
  /**
150
276
  * Price every event whose Beijing-time calendar day is the day of `now`,
@@ -158,6 +284,7 @@ export function computeSessionSpend(events, billing, catalog) {
158
284
  */
159
285
  export function computeTodaySpend(events, billing, catalog, now = new Date()) {
160
286
  const day = beijingDayKey(now);
161
- return priceEvents(events.filter(event => beijingDayKey(new Date(event.time)) === day), billing, catalog);
287
+ const names = new Map(catalog.map(model => [model.id, model.name]));
288
+ return priceEvents(events, billing, names, day);
162
289
  }
163
290
  //# sourceMappingURL=billing.js.map
@@ -4,15 +4,33 @@
4
4
  * the credential/environment seams, prices each session's billed usage with the
5
5
  * peak/off-peak table, and exposes the `billing` Remote (`getBalance`, the
6
6
  * per-session `getSessionSpend`, and the all-sessions `getTodaySpend`).
7
- * @module @deepseek-ai/dsh-llm-billing
7
+ *
8
+ * Today's spend never scans every session log per request: a 60-second
9
+ * Beijing-day cache with in-flight coalescing serves the message-triggered
10
+ * reads, the manual refresh may bypass the time window (`force`), and the
11
+ * computation behind a miss reads only sessions whose persisted revision
12
+ * changed since the last resolution (see today-spend.ts). When the
13
+ * session-projection registry is composed, the plugin additionally registers
14
+ * the `billingTodaySpend` projection unit, which folds each session's spend
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.
21
+ * @module @rayadesu/dsh-llm-billing
8
22
  */
9
23
  import type { Context } from '@deepseek-ai/cordis';
10
24
  import z from '@deepseek-ai/schemastery';
11
25
  import type { BillingConfig } from './billing.ts';
12
26
  export { DeepSeekBalanceGateway, fetchDeepSeekBalance, parseDeepSeekBalance } from './balance.ts';
13
- export { computeSessionSpend, computeTodaySpend, DEFAULT_MODEL_PRICING, DEFAULT_PEAK_HOURS, isPeak, resolveBilling, } from './billing.ts';
14
- export type { BillingConfig, BillingConfigModel, DeepSeekModelPricing, DeepSeekTokenPrice, PeakHourWindow, ResolvedBilling, } from './billing.ts';
27
+ export { addEventContribution, beijingDayKey, computeSessionSpend, computeTodaySpend, DEFAULT_MODEL_PRICING, DEFAULT_PEAK_HOURS, emptyTodaySpend, isPeak, mergeTodaySpend, priceEvent, resolveBilling, SpendAccumulator, } from './billing.ts';
28
+ export type { BillingConfig, BillingConfigModel, BillingEventContribution, DeepSeekModelPricing, DeepSeekTokenPrice, PeakHourWindow, ResolvedBilling, } from './billing.ts';
15
29
  export type * from './types.ts';
30
+ export { BILLING_UNIT_KEY, billingTodaySpendDefinition, foldBillingUnit } from './projection.ts';
31
+ export type { BillingUnitState } from './projection.ts';
32
+ export { TodaySpendCache, TodaySpendScanner } from './today-spend.ts';
33
+ export type { ScannerPersistedHeader, ScannerSession, TodaySpendScannerDeps } from './today-spend.ts';
16
34
  export declare const name = "llm-billing";
17
35
  /** Public API default; deployments may point elsewhere via $DEEPSEEK_BASE_URL. */
18
36
  export declare const PUBLIC_BASE_URL = "https://api.deepseek.com";
@@ -34,12 +52,16 @@ export interface Config {
34
52
  apiKeyEnv?: string;
35
53
  /** Endpoint base; defaults to `$DEEPSEEK_BASE_URL`, then `https://api.deepseek.com`. */
36
54
  baseURL?: string;
37
- /** Advisory display rows, in presentation order; defaults to V4 Flash and V4 Pro. */
55
+ /** Advisory display rows, in presentation order; defaults to V4 Flash, V4 Pro, and V4 Flash Vision Exp. */
38
56
  models?: BillingModel[];
39
- /** Pricing table and peak-hour windows; omission uses the published defaults. */
57
+ /** Pricing table and peak-hour windows; omission uses the published defaults. Peak windows apply weekdays (Monday–Friday) only; weekends are always off-peak. */
40
58
  billing?: BillingConfig;
41
59
  }
42
60
  export declare const Config: z<Config>;
61
+ /** How often a Beijing-day "today spend" value may be recomputed (60s). */
62
+ export declare const TODAY_SPEND_CACHE_MS = 60000;
63
+ /** Hard cap on today's events collected by the events scan path. */
64
+ export declare const TODAY_SPEND_MAX_EVENTS = 200000;
43
65
  /**
44
66
  * Register the `billing` Remote under the `billing` namespace.
45
67
  * @param ctx - owning plugin context.
@@ -4,16 +4,34 @@
4
4
  * the credential/environment seams, prices each session's billed usage with the
5
5
  * peak/off-peak table, and exposes the `billing` Remote (`getBalance`, the
6
6
  * per-session `getSessionSpend`, and the all-sessions `getTodaySpend`).
7
- * @module @deepseek-ai/dsh-llm-billing
7
+ *
8
+ * Today's spend never scans every session log per request: a 60-second
9
+ * Beijing-day cache with in-flight coalescing serves the message-triggered
10
+ * reads, the manual refresh may bypass the time window (`force`), and the
11
+ * computation behind a miss reads only sessions whose persisted revision
12
+ * changed since the last resolution (see today-spend.ts). When the
13
+ * session-projection registry is composed, the plugin additionally registers
14
+ * the `billingTodaySpend` projection unit, which folds each session's spend
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.
21
+ * @module @rayadesu/dsh-llm-billing
8
22
  */
9
23
  import z from '@deepseek-ai/schemastery';
10
24
  import { assertUsableApiKey, LlmError } from '@deepseek-ai/dsh-llm';
11
25
  import { credentialRef } from '@deepseek-ai/dsh-credentials';
12
26
  import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment';
13
27
  import { DeepSeekBalanceGateway, fetchDeepSeekBalance } from "./balance.js";
14
- import { computeSessionSpend, computeTodaySpend, DEFAULT_MODEL_PRICING, DEFAULT_PEAK_HOURS, resolveBilling, } from "./billing.js";
28
+ import { computeSessionSpend, DEFAULT_MODEL_PRICING, DEFAULT_PEAK_HOURS, mergeTodaySpend, resolveBilling, } from "./billing.js";
29
+ import { billingTodaySpendDefinition } from "./projection.js";
30
+ import { TodaySpendCache, TodaySpendScanner } from "./today-spend.js";
15
31
  export { DeepSeekBalanceGateway, fetchDeepSeekBalance, parseDeepSeekBalance } from "./balance.js";
16
- export { computeSessionSpend, computeTodaySpend, DEFAULT_MODEL_PRICING, DEFAULT_PEAK_HOURS, isPeak, resolveBilling, } from "./billing.js";
32
+ export { addEventContribution, beijingDayKey, computeSessionSpend, computeTodaySpend, DEFAULT_MODEL_PRICING, DEFAULT_PEAK_HOURS, emptyTodaySpend, isPeak, mergeTodaySpend, priceEvent, resolveBilling, SpendAccumulator, } from "./billing.js";
33
+ export { BILLING_UNIT_KEY, billingTodaySpendDefinition, foldBillingUnit } from "./projection.js";
34
+ export { TodaySpendCache, TodaySpendScanner } from "./today-spend.js";
17
35
  export const name = 'llm-billing';
18
36
  const DEFAULT_API_KEY_ENV = 'DEEPSEEK_API_KEY';
19
37
  const BASE_URL_ENV = 'DEEPSEEK_BASE_URL';
@@ -22,6 +40,7 @@ export const PUBLIC_BASE_URL = 'https://api.deepseek.com';
22
40
  const DEFAULT_MODELS = [
23
41
  { id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash' },
24
42
  { id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro' },
43
+ { id: 'deepseek-v4-flash-vision-exp', name: 'DeepSeek-V4-Flash-Vision-Exp' },
25
44
  ];
26
45
  const billingModel = z.object({
27
46
  id: z.string().required(),
@@ -49,9 +68,14 @@ export const Config = z.object({
49
68
  models: z.array(billingModel).default(DEFAULT_MODELS),
50
69
  billing: billingConfig,
51
70
  });
71
+ /** How often a Beijing-day "today spend" value may be recomputed (60s). */
72
+ export const TODAY_SPEND_CACHE_MS = 60_000;
73
+ /** Hard cap on today's events collected by the events scan path. */
74
+ export const TODAY_SPEND_MAX_EVENTS = 200_000;
52
75
  /**
53
76
  * Read one session's event log: the live SessionStore first, then the
54
- * persistence backend for a flushed session.
77
+ * persistence backend for a flushed session (inspected directly by id — no
78
+ * header listing).
55
79
  * @param ctx - plugin context carrying the SessionStore and optional persistence.
56
80
  * @param sessionId - the session to read.
57
81
  * @returns the session's complete event log.
@@ -64,52 +88,14 @@ async function sessionEvents(ctx, sessionId) {
64
88
  return live.events;
65
89
  const persistence = ctx.get('sessionPersistence');
66
90
  if (persistence !== undefined) {
67
- for (const header of await persistence.list()) {
68
- if (header.id !== sessionId)
69
- continue;
70
- const inspection = await persistence.inspect(sessionId);
71
- return inspection.events;
91
+ try {
92
+ return (await persistence.inspect(sessionId)).events;
72
93
  }
73
- }
74
- throw new LlmError(`llm-billing: session ${sessionId} not found`, 'NOT_FOUND');
75
- }
76
- /**
77
- * Read every session's event log, concatenated: each live SessionStore
78
- * session first (its log may hold events not yet flushed), then each persisted
79
- * session that is not live, so no event is counted twice. Events are appended
80
- * one at a time: spreading a very large log into `push(...)` exceeds the
81
- * engine's argument limit and throws a stack RangeError.
82
- * @param ctx - plugin context carrying the SessionStore and optional persistence.
83
- * @returns every session's complete event log, concatenated.
84
- */
85
- async function allSessionEvents(ctx) {
86
- const events = [];
87
- const sessions = ctx.get('sessions');
88
- const liveIds = new Set();
89
- if (sessions !== undefined) {
90
- for (const session of sessions.list()) {
91
- liveIds.add(session.id);
92
- for (const event of session.events)
93
- events.push(event);
94
+ catch (error) {
95
+ throw new LlmError(`llm-billing: session ${sessionId} not found`, 'NOT_FOUND', { cause: error });
94
96
  }
95
97
  }
96
- const persistence = ctx.get('sessionPersistence');
97
- if (persistence !== undefined) {
98
- for (const header of await persistence.list()) {
99
- if (liveIds.has(header.id))
100
- continue;
101
- try {
102
- const inspection = await persistence.inspect(header.id);
103
- for (const event of inspection.events)
104
- events.push(event);
105
- }
106
- catch (error) {
107
- // One unreadable session must not blank the whole-day aggregate.
108
- ctx.logger.warn(`llm-billing: skipping unreadable session ${header.id}: ${String(error)}`);
109
- }
110
- }
111
- }
112
- return events;
98
+ throw new LlmError(`llm-billing: session ${sessionId} not found`, 'NOT_FOUND');
113
99
  }
114
100
  /**
115
101
  * Register the `billing` Remote under the `billing` namespace.
@@ -140,16 +126,67 @@ export function apply(ctx, config) {
140
126
  const apiKey = await resolveApiKey();
141
127
  return fetchDeepSeekBalance(baseURL(), apiKey);
142
128
  };
129
+ const billing = resolveBilling(config.billing);
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();
143
139
  const fetchSessionSpend = async (sessionId) => {
144
- const billing = resolveBilling(config.billing);
145
- const catalog = (config.models ?? DEFAULT_MODELS).map(model => ({ id: model.id, name: model.name ?? model.id }));
146
- 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;
147
154
  };
148
- const fetchTodaySpend = async () => {
149
- const billing = resolveBilling(config.billing);
150
- const catalog = (config.models ?? DEFAULT_MODELS).map(model => ({ id: model.id, name: model.name ?? model.id }));
151
- return computeTodaySpend(await allSessionEvents(ctx), billing, catalog);
155
+ // Plan C: register the per-session spend projection unit on the projection
156
+ // registry. Registration is lazy — it happens on the first projection-path
157
+ // scan, not through `ctx.inject` (whose plugin-mount wait would also engage
158
+ // the test-invariant host in suites that never provide the registry). The
159
+ // registry builds cells lazily over the in-memory log, so events committed
160
+ // before registration are folded on first touch; without the registry the
161
+ // events path serves today's spend.
162
+ const unit = billingTodaySpendDefinition(billing, catalog);
163
+ let unitRegistered = false;
164
+ const ensureUnit = () => {
165
+ if (unitRegistered)
166
+ return;
167
+ const registry = ctx.get('sessionProjections');
168
+ if (registry === undefined)
169
+ return;
170
+ registry.register(unit);
171
+ unitRegistered = true;
152
172
  };
173
+ // Plans A1–A3: 60s Beijing-day cache with in-flight coalescing and a force
174
+ // bypass, over a revision-gated scanner (projection path when the registry
175
+ // is composed, events path otherwise).
176
+ const scanner = new TodaySpendScanner({
177
+ sessions: () => ctx.get('sessions'),
178
+ persistence: () => ctx.get('sessionPersistence'),
179
+ projections: () => ctx.get('sessionProjections'),
180
+ projectionCache: () => ctx.get('sessionProjectionCache'),
181
+ ensureUnit,
182
+ unit,
183
+ maxEvents: TODAY_SPEND_MAX_EVENTS,
184
+ logger: ctx.logger,
185
+ billing,
186
+ catalog,
187
+ });
188
+ const todayCache = new TodaySpendCache(dayKey => scanner.scan(dayKey), TODAY_SPEND_CACHE_MS);
189
+ const fetchTodaySpend = async (force = false) => todayCache.get(force);
153
190
  new DeepSeekBalanceGateway(ctx, { fetchBalance, fetchSessionSpend, fetchTodaySpend });
154
191
  }
155
192
  //# sourceMappingURL=index.js.map
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Package-owned invariant companion for `@deepseek-ai/dsh-llm-billing`.
3
- * @module @deepseek-ai/dsh-llm-billing/invariant
2
+ * Package-owned invariant companion for `@rayadesu/dsh-llm-billing`.
3
+ * @module @rayadesu/dsh-llm-billing/invariant
4
4
  */
5
5
  import type { Context } from '@deepseek-ai/cordis';
6
6
  /** Cordis companion plugin name. */
@@ -1,8 +1,8 @@
1
1
  /**
2
- * Package-owned invariant companion for `@deepseek-ai/dsh-llm-billing`.
3
- * @module @deepseek-ai/dsh-llm-billing/invariant
2
+ * Package-owned invariant companion for `@rayadesu/dsh-llm-billing`.
3
+ * @module @rayadesu/dsh-llm-billing/invariant
4
4
  */
5
- const PACKAGE_NAME = '@deepseek-ai/dsh-llm-billing';
5
+ const PACKAGE_NAME = '@rayadesu/dsh-llm-billing';
6
6
  /** Cordis companion plugin name. */
7
7
  export const name = 'llm-billing-invariant';
8
8
  /** Service required before the companion can reserve package ownership. */