@rayadesu/dsh-llm-billing 0.2.1 → 0.2.2

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,19 @@
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}, 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.
12
+ * @module @rayadesu/dsh-llm-billing/billing
13
+ */
14
+ /**
15
+ * Published peak-hour windows (Beijing time): 09:00–12:00 and 14:00–18:00,
16
+ * applied on weekdays (Monday–Friday) only — weekends are always off-peak
17
+ * (effective 2026-08-23).
7
18
  */
8
- /** Published peak-hour windows (Beijing time): 09:00–12:00 and 14:00–18:00. */
9
19
  export const DEFAULT_PEAK_HOURS = [
10
20
  { start: 9, end: 12 },
11
21
  { start: 14, end: 18 },
@@ -22,6 +32,13 @@ export const DEFAULT_MODEL_PRICING = [
22
32
  peak: { cacheHitInput: 0.30, cacheMissInput: 9.0, output: 27.0 },
23
33
  offPeak: { cacheHitInput: 0.15, cacheMissInput: 4.5, output: 13.5 },
24
34
  },
35
+ // deepseek-v4-flash-vision-exp bills at the same rates as deepseek-v4-flash;
36
+ // images are converted to tokens at the same per-token price.
37
+ {
38
+ model: 'deepseek-v4-flash-vision-exp',
39
+ peak: { cacheHitInput: 0.10, cacheMissInput: 3.0, output: 9.0 },
40
+ offPeak: { cacheHitInput: 0.05, cacheMissInput: 1.5, output: 4.5 },
41
+ },
25
42
  ];
26
43
  /**
27
44
  * Resolve optional configuration to a pricing table, defaulting omitted or
@@ -48,23 +65,157 @@ export function resolveBilling(config) {
48
65
  function beijingHour(now) {
49
66
  return new Date(now.getTime() + 8 * 3_600_000).getUTCHours();
50
67
  }
68
+ /**
69
+ * The Beijing (Asia/Shanghai, UTC+8, no DST) weekday of a timestamp, as
70
+ * `getUTCDay()`: `0` is Sunday, `6` is Saturday.
71
+ */
72
+ function beijingWeekday(now) {
73
+ return new Date(now.getTime() + 8 * 3_600_000).getUTCDay();
74
+ }
51
75
  /** The Beijing (Asia/Shanghai, UTC+8, no DST) calendar-day key of a timestamp. */
52
- function beijingDayKey(now) {
76
+ export function beijingDayKey(now) {
53
77
  return new Date(now.getTime() + 8 * 3_600_000).toISOString().slice(0, 10);
54
78
  }
55
79
  /**
56
- * Whether a timestamp falls inside any peak-hour window (Beijing time).
80
+ * Whether a timestamp falls inside any peak-hour window (Beijing time,
81
+ * weekdays Monday–Friday only). Weekends (Saturday and Sunday) are always
82
+ * off-peak, matching the published peak-hours rule.
57
83
  * @param billing - resolved pricing with peak-hour windows.
58
84
  * @param now - the moment to classify.
59
- * @returns true during peak hours.
85
+ * @returns true during a weekday peak hour.
60
86
  */
61
87
  export function isPeak(billing, now) {
88
+ const weekday = beijingWeekday(now);
89
+ if (weekday === 0 || weekday === 6)
90
+ return false;
62
91
  const hour = beijingHour(now);
63
92
  return billing.peakHours.some(({ start, end }) => hour >= start && hour < end);
64
93
  }
94
+ /**
95
+ * Price one event at the official per-model rates, applying the peak/off-peak
96
+ * table by its Beijing-time hour and weekday (peak windows apply Monday–Friday
97
+ * only; weekends are off-peak). Each `assistant/message` event with usage
98
+ * contributes cache-hit input, cache-miss input (uncached input plus cache
99
+ * writes), and output (reasoning included) tokens at the rate of its own
100
+ * timestamp; a model with usage but no pricing row contributes nothing (the
101
+ * published table prices only the two V4 rows).
102
+ * @param event - the event to price.
103
+ * @param billing - resolved pricing with peak-hour windows.
104
+ * @param names - model id → display label.
105
+ * @returns the priced contribution, or `undefined` when the event has no priced usage.
106
+ */
107
+ export function priceEvent(event, billing, names) {
108
+ if (event.type !== 'assistant/message')
109
+ return undefined;
110
+ const reported = event.data.usage;
111
+ if (reported === undefined)
112
+ return undefined;
113
+ const model = event.data.message.source.model;
114
+ const pricing = billing.models.get(model);
115
+ if (pricing === undefined)
116
+ return undefined;
117
+ const time = new Date(event.time);
118
+ const peak = isPeak(billing, time);
119
+ const price = peak ? pricing.peak : pricing.offPeak;
120
+ const hit = reported.cacheReadTokens ?? 0;
121
+ const miss = reported.inputTokens + (reported.cacheWriteTokens ?? 0);
122
+ const output = reported.outputTokens;
123
+ const hitCost = (hit * price.cacheHitInput) / 1_000_000;
124
+ const missCost = (miss * price.cacheMissInput) / 1_000_000;
125
+ const outputCost = (output * price.output) / 1_000_000;
126
+ const cost = hitCost + missCost + outputCost;
127
+ return {
128
+ dayKey: beijingDayKey(time),
129
+ model,
130
+ displayName: names.get(model) ?? model,
131
+ cost,
132
+ peakCost: peak ? cost : 0,
133
+ offPeakCost: peak ? 0 : cost,
134
+ cacheHitInputTokens: hit,
135
+ cacheMissInputTokens: miss,
136
+ outputTokens: output,
137
+ cacheHitInputCost: hitCost,
138
+ cacheMissInputCost: missCost,
139
+ outputCost,
140
+ };
141
+ }
142
+ /** A spend with no priced usage. */
143
+ export function emptyTodaySpend() {
144
+ return { total: 0, models: [] };
145
+ }
146
+ /** The today-spend shape of a single priced contribution. */
147
+ function contributionModel(priced) {
148
+ return {
149
+ model: priced.model,
150
+ displayName: priced.displayName,
151
+ cost: priced.cost,
152
+ peakCost: priced.peakCost,
153
+ offPeakCost: priced.offPeakCost,
154
+ cacheHitInputTokens: priced.cacheHitInputTokens,
155
+ cacheMissInputTokens: priced.cacheMissInputTokens,
156
+ outputTokens: priced.outputTokens,
157
+ cacheHitInputCost: priced.cacheHitInputCost,
158
+ cacheMissInputCost: priced.cacheMissInputCost,
159
+ outputCost: priced.outputCost,
160
+ };
161
+ }
162
+ /**
163
+ * Merge one priced event's contribution into an accumulator spend (pure:
164
+ * returns a new spend, never mutates its input).
165
+ * @param spend - the accumulator (per session and day, or across sessions).
166
+ * @param priced - the priced contribution to add.
167
+ * @returns the merged spend.
168
+ */
169
+ 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));
186
+ return { total: spend.total + priced.cost, models: rows };
187
+ }
188
+ /**
189
+ * Sum two spends (per session and day, or across sessions) into one (pure:
190
+ * returns a new spend, never mutates its inputs).
191
+ * @param target - the accumulator spend.
192
+ * @param source - the spend to add.
193
+ * @returns the summed spend.
194
+ */
195
+ export function mergeTodaySpend(target, source) {
196
+ let merged = target;
197
+ 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
+ });
212
+ }
213
+ return merged;
214
+ }
65
215
  /**
66
216
  * 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
217
+ * peak/off-peak table per event by its Beijing-time hour and weekday (peak
218
+ * windows apply Monday–Friday only; weekends are off-peak). Each
68
219
  * `assistant/message` event with usage contributes cache-hit input, cache-miss
69
220
  * input (uncached input plus cache writes), and output (reasoning included)
70
221
  * tokens at the rate of its own timestamp, with the three component costs
@@ -77,64 +228,14 @@ export function isPeak(billing, now) {
77
228
  */
78
229
  function priceEvents(events, billing, catalog) {
79
230
  const names = new Map(catalog.map(model => [model.id, model.name]));
80
- const rows = new Map();
231
+ let spend = { total: 0, models: [] };
81
232
  for (const event of events) {
82
- if (event.type !== 'assistant/message')
83
- continue;
84
- const reported = event.data.usage;
85
- if (reported === undefined)
233
+ const priced = priceEvent(event, billing, names);
234
+ if (priced === undefined)
86
235
  continue;
87
- const model = event.data.message.source.model;
88
- const pricing = billing.models.get(model);
89
- if (pricing === undefined)
90
- 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;
236
+ spend = addEventContribution(spend, priced);
120
237
  }
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
- };
238
+ return spend;
138
239
  }
139
240
  /**
140
241
  * Price one session's complete event log at the official per-model rates.
@@ -158,6 +259,14 @@ export function computeSessionSpend(events, billing, catalog) {
158
259
  */
159
260
  export function computeTodaySpend(events, billing, catalog, now = new Date()) {
160
261
  const day = beijingDayKey(now);
161
- return priceEvents(events.filter(event => beijingDayKey(new Date(event.time)) === day), billing, catalog);
262
+ 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;
162
271
  }
163
272
  //# sourceMappingURL=billing.js.map
@@ -4,15 +4,28 @@
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
+ * @module @rayadesu/dsh-llm-billing
8
17
  */
9
18
  import type { Context } from '@deepseek-ai/cordis';
10
19
  import z from '@deepseek-ai/schemastery';
11
20
  import type { BillingConfig } from './billing.ts';
12
21
  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';
22
+ export { addEventContribution, beijingDayKey, computeSessionSpend, computeTodaySpend, DEFAULT_MODEL_PRICING, DEFAULT_PEAK_HOURS, emptyTodaySpend, isPeak, mergeTodaySpend, priceEvent, resolveBilling, } from './billing.ts';
23
+ export type { BillingConfig, BillingConfigModel, BillingEventContribution, DeepSeekModelPricing, DeepSeekTokenPrice, PeakHourWindow, ResolvedBilling, } from './billing.ts';
15
24
  export type * from './types.ts';
25
+ export { BILLING_UNIT_KEY, billingTodaySpendDefinition, foldBillingUnit } from './projection.ts';
26
+ export type { BillingUnitState } from './projection.ts';
27
+ export { TodaySpendCache, TodaySpendScanner } from './today-spend.ts';
28
+ export type { ScannerPersistedHeader, ScannerSession, TodaySpendScannerDeps } from './today-spend.ts';
16
29
  export declare const name = "llm-billing";
17
30
  /** Public API default; deployments may point elsewhere via $DEEPSEEK_BASE_URL. */
18
31
  export declare const PUBLIC_BASE_URL = "https://api.deepseek.com";
@@ -34,12 +47,16 @@ export interface Config {
34
47
  apiKeyEnv?: string;
35
48
  /** Endpoint base; defaults to `$DEEPSEEK_BASE_URL`, then `https://api.deepseek.com`. */
36
49
  baseURL?: string;
37
- /** Advisory display rows, in presentation order; defaults to V4 Flash and V4 Pro. */
50
+ /** Advisory display rows, in presentation order; defaults to V4 Flash, V4 Pro, and V4 Flash Vision Exp. */
38
51
  models?: BillingModel[];
39
- /** Pricing table and peak-hour windows; omission uses the published defaults. */
52
+ /** Pricing table and peak-hour windows; omission uses the published defaults. Peak windows apply weekdays (Monday–Friday) only; weekends are always off-peak. */
40
53
  billing?: BillingConfig;
41
54
  }
42
55
  export declare const Config: z<Config>;
56
+ /** How often a Beijing-day "today spend" value may be recomputed (60s). */
57
+ export declare const TODAY_SPEND_CACHE_MS = 60000;
58
+ /** Hard cap on today's events collected by the events scan path. */
59
+ export declare const TODAY_SPEND_MAX_EVENTS = 200000;
43
60
  /**
44
61
  * Register the `billing` Remote under the `billing` namespace.
45
62
  * @param ctx - owning plugin context.
@@ -4,16 +4,29 @@
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
+ * @module @rayadesu/dsh-llm-billing
8
17
  */
9
18
  import z from '@deepseek-ai/schemastery';
10
19
  import { assertUsableApiKey, LlmError } from '@deepseek-ai/dsh-llm';
11
20
  import { credentialRef } from '@deepseek-ai/dsh-credentials';
12
21
  import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment';
13
22
  import { DeepSeekBalanceGateway, fetchDeepSeekBalance } from "./balance.js";
14
- import { computeSessionSpend, computeTodaySpend, DEFAULT_MODEL_PRICING, DEFAULT_PEAK_HOURS, resolveBilling, } from "./billing.js";
23
+ import { computeSessionSpend, DEFAULT_MODEL_PRICING, DEFAULT_PEAK_HOURS, resolveBilling, } from "./billing.js";
24
+ import { billingTodaySpendDefinition } from "./projection.js";
25
+ import { TodaySpendCache, TodaySpendScanner } from "./today-spend.js";
15
26
  export { DeepSeekBalanceGateway, fetchDeepSeekBalance, parseDeepSeekBalance } from "./balance.js";
16
- export { computeSessionSpend, computeTodaySpend, DEFAULT_MODEL_PRICING, DEFAULT_PEAK_HOURS, isPeak, resolveBilling, } from "./billing.js";
27
+ export { addEventContribution, beijingDayKey, computeSessionSpend, computeTodaySpend, DEFAULT_MODEL_PRICING, DEFAULT_PEAK_HOURS, emptyTodaySpend, isPeak, mergeTodaySpend, priceEvent, resolveBilling, } from "./billing.js";
28
+ export { BILLING_UNIT_KEY, billingTodaySpendDefinition, foldBillingUnit } from "./projection.js";
29
+ export { TodaySpendCache, TodaySpendScanner } from "./today-spend.js";
17
30
  export const name = 'llm-billing';
18
31
  const DEFAULT_API_KEY_ENV = 'DEEPSEEK_API_KEY';
19
32
  const BASE_URL_ENV = 'DEEPSEEK_BASE_URL';
@@ -22,6 +35,7 @@ export const PUBLIC_BASE_URL = 'https://api.deepseek.com';
22
35
  const DEFAULT_MODELS = [
23
36
  { id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash' },
24
37
  { id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro' },
38
+ { id: 'deepseek-v4-flash-vision-exp', name: 'DeepSeek-V4-Flash-Vision-Exp' },
25
39
  ];
26
40
  const billingModel = z.object({
27
41
  id: z.string().required(),
@@ -49,9 +63,14 @@ export const Config = z.object({
49
63
  models: z.array(billingModel).default(DEFAULT_MODELS),
50
64
  billing: billingConfig,
51
65
  });
66
+ /** How often a Beijing-day "today spend" value may be recomputed (60s). */
67
+ export const TODAY_SPEND_CACHE_MS = 60_000;
68
+ /** Hard cap on today's events collected by the events scan path. */
69
+ export const TODAY_SPEND_MAX_EVENTS = 200_000;
52
70
  /**
53
71
  * Read one session's event log: the live SessionStore first, then the
54
- * persistence backend for a flushed session.
72
+ * persistence backend for a flushed session (inspected directly by id — no
73
+ * header listing).
55
74
  * @param ctx - plugin context carrying the SessionStore and optional persistence.
56
75
  * @param sessionId - the session to read.
57
76
  * @returns the session's complete event log.
@@ -64,52 +83,14 @@ async function sessionEvents(ctx, sessionId) {
64
83
  return live.events;
65
84
  const persistence = ctx.get('sessionPersistence');
66
85
  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;
86
+ try {
87
+ return (await persistence.inspect(sessionId)).events;
72
88
  }
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);
89
+ catch (error) {
90
+ throw new LlmError(`llm-billing: session ${sessionId} not found`, 'NOT_FOUND', { cause: error });
94
91
  }
95
92
  }
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;
93
+ throw new LlmError(`llm-billing: session ${sessionId} not found`, 'NOT_FOUND');
113
94
  }
114
95
  /**
115
96
  * Register the `billing` Remote under the `billing` namespace.
@@ -140,16 +121,46 @@ export function apply(ctx, config) {
140
121
  const apiKey = await resolveApiKey();
141
122
  return fetchDeepSeekBalance(baseURL(), apiKey);
142
123
  };
124
+ const billing = resolveBilling(config.billing);
125
+ const catalog = (config.models ?? DEFAULT_MODELS).map(model => ({ id: model.id, name: model.name ?? model.id }));
143
126
  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
127
  return computeSessionSpend(await sessionEvents(ctx, sessionId), billing, catalog);
147
128
  };
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);
129
+ // Plan C: register the per-session spend projection unit on the projection
130
+ // registry. Registration is lazy — it happens on the first projection-path
131
+ // scan, not through `ctx.inject` (whose plugin-mount wait would also engage
132
+ // the test-invariant host in suites that never provide the registry). The
133
+ // registry builds cells lazily over the in-memory log, so events committed
134
+ // before registration are folded on first touch; without the registry the
135
+ // events path serves today's spend.
136
+ const unit = billingTodaySpendDefinition(billing, catalog);
137
+ let unitRegistered = false;
138
+ const ensureUnit = () => {
139
+ if (unitRegistered)
140
+ return;
141
+ const registry = ctx.get('sessionProjections');
142
+ if (registry === undefined)
143
+ return;
144
+ registry.register(unit);
145
+ unitRegistered = true;
152
146
  };
147
+ // Plans A1–A3: 60s Beijing-day cache with in-flight coalescing and a force
148
+ // bypass, over a revision-gated scanner (projection path when the registry
149
+ // is composed, events path otherwise).
150
+ const scanner = new TodaySpendScanner({
151
+ sessions: () => ctx.get('sessions'),
152
+ persistence: () => ctx.get('sessionPersistence'),
153
+ projections: () => ctx.get('sessionProjections'),
154
+ projectionCache: () => ctx.get('sessionProjectionCache'),
155
+ ensureUnit,
156
+ unit,
157
+ maxEvents: TODAY_SPEND_MAX_EVENTS,
158
+ logger: ctx.logger,
159
+ billing,
160
+ catalog,
161
+ });
162
+ const todayCache = new TodaySpendCache(dayKey => scanner.scan(dayKey), TODAY_SPEND_CACHE_MS);
163
+ const fetchTodaySpend = async (force = false) => todayCache.get(force);
153
164
  new DeepSeekBalanceGateway(ctx, { fetchBalance, fetchSessionSpend, fetchTodaySpend });
154
165
  }
155
166
  //# 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. */
@@ -0,0 +1,69 @@
1
+ /**
2
+ * `billingTodaySpend` session-projection unit: per-session, per-Beijing-day
3
+ * billed spend, folded eagerly by the DSH projection drive over committed
4
+ * session events and checkpointed by the projection cache. The unit keeps only
5
+ * the spend of the session's LATEST priced day (events are append-only and
6
+ * chronological, so a day strictly older than the state's day never returns);
7
+ * the aggregate "today" read sums the units whose `dayKey` matches the current
8
+ * Beijing day — zero full-log scans once the fold is warm.
9
+ *
10
+ * The unit's fold shares {@link priceEvent} with the events-scan paths
11
+ * (`computeTodaySpend`), so both price with the same table. The unit is
12
+ * client-visible (`wire` = identity) because the persisted-cache read ladder
13
+ * (`sessionProjectionCache.coldSnapshot` / registry `restore`) serves only
14
+ * wired units; the wire value is the state itself.
15
+ * @module @rayadesu/dsh-llm-billing/projection
16
+ */
17
+ import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection';
18
+ import type { SessionEvent } from '@deepseek-ai/dsh-session';
19
+ import type { ResolvedBilling } from './billing.ts';
20
+ import type { DeepSeekTodaySpend } from './types.ts';
21
+ /** The projection key this unit owns. */
22
+ export declare const BILLING_UNIT_KEY = "billingTodaySpend";
23
+ /**
24
+ * Per-session unit state: the Beijing day of the session's latest priced
25
+ * event and that day's billed spend. `dayKey` is `''` while the session has no
26
+ * priced usage, and the state only ever describes ONE day (the latest) —
27
+ * plain JSON, as the persisted-cache contract requires.
28
+ */
29
+ export interface BillingUnitState {
30
+ /** Beijing-time calendar-day key of the state's spend; `''` for no priced usage. */
31
+ dayKey: string;
32
+ /** The spend of the session's latest priced Beijing day. */
33
+ spend: DeepSeekTodaySpend;
34
+ }
35
+ declare module '@deepseek-ai/dsh-session-projection/types' {
36
+ interface SessionProjectionStateMap {
37
+ billingTodaySpend: BillingUnitState;
38
+ }
39
+ interface SessionProjectionMap {
40
+ billingTodaySpend: BillingUnitState;
41
+ }
42
+ }
43
+ /**
44
+ * The unit definition with a required `wire` — the shape {@link register}
45
+ * accepts for a client-visible unit (the plain `ProjectionDefinition` type
46
+ * leaves `wire` optional).
47
+ */
48
+ export type BillingUnitDefinition = Omit<ProjectionDefinition<'billingTodaySpend', BillingUnitState>, 'wire'> & {
49
+ wire: NonNullable<ProjectionDefinition<'billingTodaySpend', BillingUnitState>['wire']>;
50
+ };
51
+ /**
52
+ * Build the `billingTodaySpend` unit for one resolved pricing table. The
53
+ * pricing closure is fixed at registration; a pricing-table change therefore
54
+ * prices only events folded after the change (historical spend keeps its
55
+ * historical rates), unlike the events-scan paths which re-price the whole
56
+ * log. Bump {@link ProjectionDefinition.stateVersion} whenever the state
57
+ * shape or fold semantics change, so persisted checkpoint rows are discarded
58
+ * instead of folded forward.
59
+ * @param billing - resolved pricing with peak-hour windows.
60
+ * @param catalog - model display rows, in presentation order.
61
+ * @returns the unit definition to register on `ctx.sessionProjections`.
62
+ */
63
+ export declare function billingTodaySpendDefinition(billing: ResolvedBilling, catalog: readonly {
64
+ id: string;
65
+ name: string;
66
+ }[]): BillingUnitDefinition;
67
+ /** Fold a unit from init over one session's event log (the detached cold recipe). */
68
+ export declare function foldBillingUnit(unit: Pick<ProjectionDefinition<'billingTodaySpend', BillingUnitState>, 'init' | 'apply'>, events: readonly SessionEvent[]): BillingUnitState;
69
+ //# sourceMappingURL=projection.d.ts.map