@rayadesu/dsh-llm-billing 0.3.8 → 0.3.10

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.
@@ -24,8 +24,8 @@ import type { Context } from '@deepseek-ai/cordis';
24
24
  import z from '@deepseek-ai/schemastery';
25
25
  import type { BillingConfig } from './billing.ts';
26
26
  export { DeepSeekBalanceGateway, fetchDeepSeekBalance, parseDeepSeekBalance } from './balance.ts';
27
- export { addEventContribution, beijingDayKey, computeSessionSpend, computeTodaySpend, computeTurnSpend, DEFAULT_MODEL_PRICING, DEFAULT_PEAK_HOURS, emptyTodaySpend, forkBoundaryOf, isPeak, isSeededSession, mergeTodaySpend, priceEvent, resolveBilling, SpendAccumulator, } from './billing.ts';
28
- export type { BillingConfig, BillingConfigModel, BillingEventContribution, DeepSeekModelPricing, DeepSeekTokenPrice, PeakHourWindow, ResolvedBilling, } from './billing.ts';
27
+ export { addEventContribution, applyBillingEvent, beijingDayKey, BillingFolder, computeSessionSpend, computeSessionTurnSpends, computeTodaySpend, computeTurnSpend, DEFAULT_MODEL_PRICING, DEFAULT_PEAK_HOURS, emptyBillingFoldState, emptyTodaySpend, FLASH_SERIES_RATE_CHANGE_AT, forkBoundaryOf, isPeak, isSeededSession, mergeTodaySpend, negateSpend, priceEvent, priceUsage, resolveBilling, SessionTurnSpendFolder, SpendAccumulator, subtractSpend, V4_PRO_ROUTE_SWITCH_AT, } from './billing.ts';
28
+ export type { BillingConfig, BillingConfigModel, BillingEventContribution, BillingFoldSample, BillingFoldState, DeepSeekModelPricing, DeepSeekRateRevision, DeepSeekTokenPrice, PeakHourWindow, ResolvedBilling, } from './billing.ts';
29
29
  export type * from './types.ts';
30
30
  export { BILLING_UNIT_KEY, billingTodaySpendDefinition, foldBillingUnit, foldOwnBilling } from './projection.ts';
31
31
  export type { BillingUnitFold, BillingUnitState } from './projection.ts';
@@ -52,9 +52,14 @@ export interface Config {
52
52
  apiKeyEnv?: string;
53
53
  /** Endpoint base; defaults to `$DEEPSEEK_BASE_URL`, then `https://api.deepseek.com`. */
54
54
  baseURL?: string;
55
- /** Advisory display rows, in presentation order; defaults to V4 Flash, V4 Pro, and V4 Flash Vision Exp. */
55
+ /** Advisory display rows, in presentation order; defaults to V4 Flash, V4.1 Flash, V4 Pro, and V4 Flash Vision Exp. */
56
56
  models?: BillingModel[];
57
- /** Pricing table and peak-hour windows; omission uses the published defaults. Peak windows apply weekdays (Monday–Friday) only; weekends are always off-peak. */
57
+ /**
58
+ * Pricing table and peak-hour windows; omission uses the published defaults
59
+ * (including the V4 Flash series re-pricing effective 2026-09-10 12:00
60
+ * Beijing). Peak windows apply weekdays (Monday–Friday) only; weekends are
61
+ * always off-peak.
62
+ */
58
63
  billing?: BillingConfig;
59
64
  }
60
65
  export declare const Config: z<Config>;
@@ -62,8 +67,18 @@ export declare const Config: z<Config>;
62
67
  export declare const TODAY_SPEND_CACHE_MS = 60000;
63
68
  /** Hard cap on today's events collected by the events scan path. */
64
69
  export declare const TODAY_SPEND_MAX_EVENTS = 200000;
70
+ /** Max session-spend rows kept for incremental recompute before eviction. */
71
+ export declare const SESSION_SPEND_CACHE_LIMIT = 1024;
72
+ /** Max session-id entries kept in the per-turn-cost fold cache before eviction. */
73
+ export declare const SESSION_TURN_SPEND_CACHE_LIMIT = 64;
74
+ /** How long one balance snapshot is reused before the host refetches it (15s). */
75
+ export declare const BALANCE_CACHE_MS = 15000;
76
+ /** Hard cap on one `/user/balance` request (5s); a hung endpoint never blocks the badge. */
77
+ export declare const BALANCE_TIMEOUT_MS = 5000;
65
78
  /**
66
- * Register the `billing` Remote under the `billing` namespace.
79
+ * Register the `billing` Remote under the `billing` namespace. Assembly only:
80
+ * facts resolve once, each loader owns its caches, and the gateway receives
81
+ * the bound thunks.
67
82
  * @param ctx - owning plugin context.
68
83
  * @param config - validated plugin config.
69
84
  */
@@ -25,11 +25,11 @@ import { assertUsableApiKey, LlmError } from '@deepseek-ai/dsh-llm';
25
25
  import { credentialRef } from '@deepseek-ai/dsh-credentials';
26
26
  import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment';
27
27
  import { DeepSeekBalanceGateway, fetchDeepSeekBalance } from "./balance.js";
28
- import { computeSessionSpend, computeTurnSpend, DEFAULT_MODEL_PRICING, DEFAULT_PEAK_HOURS, forkBoundaryOf, mergeTodaySpend, resolveBilling, } from "./billing.js";
28
+ import { computeSessionSpend, computeTurnSpend, DEFAULT_MODEL_PRICING, DEFAULT_PEAK_HOURS, forkBoundaryOf, mergeTodaySpend, resolveBilling, SessionTurnSpendFolder, } from "./billing.js";
29
29
  import { billingTodaySpendDefinition } from "./projection.js";
30
30
  import { liveSessionEvents, persistenceInspect, TodaySpendCache, TodaySpendScanner } from "./today-spend.js";
31
31
  export { DeepSeekBalanceGateway, fetchDeepSeekBalance, parseDeepSeekBalance } from "./balance.js";
32
- export { addEventContribution, beijingDayKey, computeSessionSpend, computeTodaySpend, computeTurnSpend, DEFAULT_MODEL_PRICING, DEFAULT_PEAK_HOURS, emptyTodaySpend, forkBoundaryOf, isPeak, isSeededSession, mergeTodaySpend, priceEvent, resolveBilling, SpendAccumulator, } from "./billing.js";
32
+ export { addEventContribution, applyBillingEvent, beijingDayKey, BillingFolder, computeSessionSpend, computeSessionTurnSpends, computeTodaySpend, computeTurnSpend, DEFAULT_MODEL_PRICING, DEFAULT_PEAK_HOURS, emptyBillingFoldState, emptyTodaySpend, FLASH_SERIES_RATE_CHANGE_AT, forkBoundaryOf, isPeak, isSeededSession, mergeTodaySpend, negateSpend, priceEvent, priceUsage, resolveBilling, SessionTurnSpendFolder, SpendAccumulator, subtractSpend, V4_PRO_ROUTE_SWITCH_AT, } from "./billing.js";
33
33
  export { BILLING_UNIT_KEY, billingTodaySpendDefinition, foldBillingUnit, foldOwnBilling } from "./projection.js";
34
34
  export { foldSessionTitle, liveSessionEvents, persistenceInspect, persistenceListSnapshots, TodaySpendCache, TodaySpendScanner } from "./today-spend.js";
35
35
  export const name = 'llm-billing';
@@ -37,8 +37,17 @@ const DEFAULT_API_KEY_ENV = 'DEEPSEEK_API_KEY';
37
37
  const BASE_URL_ENV = 'DEEPSEEK_BASE_URL';
38
38
  /** Public API default; deployments may point elsewhere via $DEEPSEEK_BASE_URL. */
39
39
  export const PUBLIC_BASE_URL = 'https://api.deepseek.com';
40
+ /**
41
+ * Advisory display rows mirroring the DSH `llm-deepseek` catalog (V4.1 Flash
42
+ * first, its current default route), plus the MiMo-V2.5 series. The retired
43
+ * preview id `deepseek-v4.1-flash-expires-on-0910` stays so the logs that used
44
+ * it keep a readable label; rows never restrict which models are priced — the
45
+ * pricing table does.
46
+ */
40
47
  const DEFAULT_MODELS = [
48
+ { id: 'deepseek-flash', name: 'DeepSeek-V41-Flash' },
41
49
  { id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash' },
50
+ { id: 'deepseek-v4.1-flash-expires-on-0910', name: 'DeepSeek-V4.1-Flash' },
42
51
  { id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro' },
43
52
  { id: 'deepseek-v4-flash-vision-exp', name: 'DeepSeek-V4-Flash-Vision-Exp' },
44
53
  { id: 'mimo-v2.5-pro', name: 'MiMo-V2.5-Pro' },
@@ -53,16 +62,22 @@ const tokenPrice = z.object({
53
62
  cacheMissInput: z.number().min(0),
54
63
  output: z.number().min(0),
55
64
  });
65
+ const billingRateRow = z.object({
66
+ model: z.string().required(),
67
+ peak: tokenPrice,
68
+ offPeak: tokenPrice,
69
+ // Several rows may share one model: each is a rate revision, and samples are
70
+ // priced by the revision in effect at their own timestamp (epoch ms,
71
+ // inclusive).
72
+ effectiveFrom: z.number().min(0),
73
+ });
56
74
  const billingConfig = z.object({
75
+ // Copies of the readonly published tables, taken once at module load.
57
76
  peakHours: z.array(z.object({
58
77
  start: z.number().step(1).min(0).max(23),
59
78
  end: z.number().step(1).min(0).max(24),
60
- })).default(DEFAULT_PEAK_HOURS),
61
- models: z.array(z.object({
62
- model: z.string().required(),
63
- peak: tokenPrice,
64
- offPeak: tokenPrice,
65
- })).default(DEFAULT_MODEL_PRICING),
79
+ })).default([...DEFAULT_PEAK_HOURS]),
80
+ models: z.array(billingRateRow).default([...DEFAULT_MODEL_PRICING]),
66
81
  });
67
82
  export const Config = z.object({
68
83
  apiKeyEnv: z.string().role('credential-ref').default(DEFAULT_API_KEY_ENV),
@@ -74,6 +89,27 @@ export const Config = z.object({
74
89
  export const TODAY_SPEND_CACHE_MS = 60_000;
75
90
  /** Hard cap on today's events collected by the events scan path. */
76
91
  export const TODAY_SPEND_MAX_EVENTS = 200_000;
92
+ /** Max session-spend rows kept for incremental recompute before eviction. */
93
+ export const SESSION_SPEND_CACHE_LIMIT = 1024;
94
+ /** Max session-id entries kept in the per-turn-cost fold cache before eviction. */
95
+ export const SESSION_TURN_SPEND_CACHE_LIMIT = 64;
96
+ /** How long one balance snapshot is reused before the host refetches it (15s). */
97
+ export const BALANCE_CACHE_MS = 15_000;
98
+ /** Hard cap on one `/user/balance` request (5s); a hung endpoint never blocks the badge. */
99
+ export const BALANCE_TIMEOUT_MS = 5_000;
100
+ /**
101
+ * Bounded-map eviction: drop the oldest inserted entry once `size` reached
102
+ * `limit`, so an unbounded session-id space grows the map no further. Evicting
103
+ * one entry (instead of clearing) keeps the other sessions' incremental
104
+ * spend warm.
105
+ */
106
+ function evictOldest(map, limit) {
107
+ if (map.size < limit)
108
+ return;
109
+ const oldest = map.keys().next().value;
110
+ if (oldest !== undefined)
111
+ map.delete(oldest);
112
+ }
77
113
  /**
78
114
  * Read one session's event log and durable seed boundary: the live
79
115
  * SessionStore first, then the persistence backend for a flushed session
@@ -103,84 +139,111 @@ async function sessionEvents(ctx, sessionId) {
103
139
  }
104
140
  throw new LlmError(`llm-billing: session ${sessionId} not found`, 'NOT_FOUND');
105
141
  }
142
+ /** Resolve the plugin's static facts once: endpoint, credential ref, pricing table. */
143
+ function resolveFacts(ctx, config) {
144
+ return {
145
+ baseURL: () => config.baseURL
146
+ ?? launchEnvironmentOf(ctx).get(BASE_URL_ENV)?.value
147
+ ?? PUBLIC_BASE_URL,
148
+ apiKeyRef: credentialRef(config.apiKeyEnv ?? DEFAULT_API_KEY_ENV),
149
+ billing: resolveBilling(config.billing),
150
+ catalog: (config.models ?? DEFAULT_MODELS).map(model => ({ id: model.id, name: model.name ?? model.id })),
151
+ };
152
+ }
106
153
  /**
107
- * Register the `billing` Remote under the `billing` namespace.
108
- * @param ctx - owning plugin context.
109
- * @param config - validated plugin config.
154
+ * Resolve the API key per call: the credentials service first, then the
155
+ * launch environment fallback.
156
+ * @throws {@link LlmError} with code `MISSING_CREDENTIAL` when neither yields a usable key.
110
157
  */
111
- export function apply(ctx, config) {
112
- const baseURL = () => config.baseURL
113
- ?? launchEnvironmentOf(ctx).get(BASE_URL_ENV)?.value
114
- ?? PUBLIC_BASE_URL;
115
- const apiKeyRef = credentialRef(config.apiKeyEnv ?? DEFAULT_API_KEY_ENV);
116
- const resolveApiKey = async () => {
117
- const credentials = ctx.get('credentials');
118
- if (credentials !== undefined) {
119
- const hit = await credentials.resolve(apiKeyRef);
120
- if (hit !== undefined)
121
- return assertUsableApiKey(hit.value, 'llm-billing', apiKeyRef);
122
- }
123
- else {
124
- const ambient = launchEnvironmentOf(ctx).get(apiKeyRef);
125
- if (ambient !== undefined && ambient.value.length > 0) {
126
- return assertUsableApiKey(ambient.value, 'llm-billing', apiKeyRef);
127
- }
158
+ async function resolveApiKey(ctx, apiKeyRef) {
159
+ const credentials = ctx.get('credentials');
160
+ if (credentials !== undefined) {
161
+ const hit = await credentials.resolve(apiKeyRef);
162
+ if (hit !== undefined)
163
+ return assertUsableApiKey(hit.value, 'llm-billing', apiKeyRef);
164
+ }
165
+ else {
166
+ const ambient = launchEnvironmentOf(ctx).get(apiKeyRef);
167
+ if (ambient !== undefined && ambient.value.length > 0) {
168
+ return assertUsableApiKey(ambient.value, 'llm-billing', apiKeyRef);
128
169
  }
129
- throw new LlmError(`llm-billing: no API key; store ${apiKeyRef} through the credentials service or export it`, 'MISSING_CREDENTIAL');
130
- };
131
- const fetchBalance = async () => {
132
- const apiKey = await resolveApiKey();
133
- return fetchDeepSeekBalance(baseURL(), apiKey);
134
- };
135
- const billing = resolveBilling(config.billing);
136
- const catalog = (config.models ?? DEFAULT_MODELS).map(model => ({ id: model.id, name: model.name ?? model.id }));
137
- // Per-session incremental spend cache: a session log is append-only and
138
- // chronological (the same assumption the projection unit makes), so a spend
139
- // computed for `count` EVENTS OF THE SESSION'S OWN WORK (the log minus its
140
- // inherited fork prefix) stays valid while the log length is unchanged, and
141
- // only the appended tail needs pricing when it grows. A forked child's
142
- // inherited prefix (`seq < seedLength`) is priced only in its source
143
- // session; the map is capped so an unbounded session-id space cannot grow
144
- // it without bound.
170
+ }
171
+ throw new LlmError(`llm-billing: no API key; store ${apiKeyRef} through the credentials service or export it`, 'MISSING_CREDENTIAL');
172
+ }
173
+ /**
174
+ * Per-session incremental spend loader: a session log is append-only and
175
+ * chronological (the same assumption the projection unit makes), so a spend
176
+ * computed for `count` EVENTS OF THE SESSION'S OWN WORK (the log minus its
177
+ * inherited fork prefix) stays valid while the log length is unchanged, and
178
+ * only the appended tail needs pricing when it grows. A forked child's
179
+ * inherited prefix (`seq < seedLength`) is priced only in its source
180
+ * session; the cache is bounded (see {@link evictOldest}), so an unbounded
181
+ * session-id space cannot grow it without bound.
182
+ */
183
+ function createSessionSpendFetcher(ctx, facts) {
145
184
  const sessionSpendCache = new Map();
146
- const fetchSessionSpend = async (sessionId) => {
185
+ return async (sessionId) => {
147
186
  const { events, seedLength } = await sessionEvents(ctx, sessionId);
148
187
  const ownCount = events.length - seedLength;
149
188
  const cached = sessionSpendCache.get(sessionId);
150
- if (cached !== undefined && cached.count === ownCount)
189
+ if (cached !== undefined && cached.count === ownCount) {
190
+ // LRU touch: re-insert so the entry is evicted only after fresher ones.
191
+ sessionSpendCache.delete(sessionId);
192
+ sessionSpendCache.set(sessionId, cached);
151
193
  return cached.spend;
194
+ }
152
195
  if (cached !== undefined && cached.count < ownCount) {
153
- const spend = mergeTodaySpend(cached.spend, computeSessionSpend(events.slice(seedLength + cached.count), billing, catalog));
196
+ const spend = mergeTodaySpend(cached.spend, computeSessionSpend(events.slice(seedLength + cached.count), facts.billing, facts.catalog));
197
+ evictOldest(sessionSpendCache, SESSION_SPEND_CACHE_LIMIT);
154
198
  sessionSpendCache.set(sessionId, { count: ownCount, spend });
155
199
  return spend;
156
200
  }
157
- const spend = computeSessionSpend(events, billing, catalog, seedLength);
158
- if (sessionSpendCache.size >= 1024)
159
- sessionSpendCache.clear();
201
+ const spend = computeSessionSpend(events, facts.billing, facts.catalog, seedLength);
202
+ evictOldest(sessionSpendCache, SESSION_SPEND_CACHE_LIMIT);
160
203
  sessionSpendCache.set(sessionId, { count: ownCount, spend });
161
204
  return spend;
162
205
  };
163
- // Plan C: register the per-session spend projection unit on the projection
164
- // registry. Registration is lazy — it happens on the first projection-path
165
- // scan, not through `ctx.inject` (whose plugin-mount wait would also engage
166
- // the test-invariant host in suites that never provide the registry). The
167
- // registry builds cells lazily over the in-memory log, so events committed
168
- // before registration are folded on first touch; without the registry the
169
- // events path serves today's spend.
170
- const unit = billingTodaySpendDefinition(billing, catalog);
171
- let unitRegistered = false;
172
- const ensureUnit = () => {
173
- if (unitRegistered)
206
+ }
207
+ /**
208
+ * Once-registrar for the billing projection unit: the first call that finds
209
+ * the registry composed registers the shared unit and every later call is a
210
+ * no-op. Registering as early as the registry exists lets DSH's projection
211
+ * write-behind (mandatory at `turn/end`) checkpoint a billing row for every
212
+ * session that runs in this process, which is what makes the zero-I/O cold
213
+ * path in {@link TodaySpendScanner} hit after the next restart.
214
+ * @param ctx - plugin context.
215
+ * @param unit - the unit definition built once per plugin config.
216
+ * @returns an idempotent registrar.
217
+ */
218
+ function createUnitRegistrar(ctx, unit) {
219
+ let registered = false;
220
+ return () => {
221
+ if (registered)
174
222
  return;
175
223
  const registry = ctx.get('sessionProjections');
176
224
  if (registry === undefined)
177
225
  return;
178
226
  registry.register(unit);
179
- unitRegistered = true;
227
+ registered = true;
180
228
  };
181
- // Plans A1–A3: 60s Beijing-day cache with in-flight coalescing and a force
182
- // bypass, over a revision-gated scanner (projection path when the registry
183
- // is composed, events path otherwise).
229
+ }
230
+ /**
231
+ * Today-spend loaders over one revision-gated scanner with two 60s
232
+ * Beijing-day caches (in-flight coalescing and a `force` bypass):
233
+ * - plan C uses the per-session spend projection unit registered by the
234
+ * caller's {@link createUnitRegistrar} as early as the registry exists (the
235
+ * registry builds cells lazily over the in-memory log, so events committed
236
+ * before registration are folded on first touch); without the registry the
237
+ * events path serves today's spend.
238
+ * - plans A1–A3: the scanner chooses the projection path when the registry
239
+ * is composed, the events path otherwise.
240
+ * @param ctx - plugin context.
241
+ * @param facts - resolved endpoint, credential, pricing, and catalog facts.
242
+ * @param unit - the shared projection unit definition.
243
+ * @param ensureUnit - idempotent unit registrar (last-resort registration).
244
+ * @returns the two today-spend loaders.
245
+ */
246
+ function createTodaySpendLoaders(ctx, facts, unit, ensureUnit) {
184
247
  const scanner = new TodaySpendScanner({
185
248
  sessions: () => ctx.get('sessions'),
186
249
  persistence: () => ctx.get('sessionPersistence'),
@@ -190,17 +253,108 @@ export function apply(ctx, config) {
190
253
  unit,
191
254
  maxEvents: TODAY_SPEND_MAX_EVENTS,
192
255
  logger: ctx.logger,
193
- billing,
194
- catalog,
256
+ billing: facts.billing,
257
+ catalog: facts.catalog,
195
258
  });
196
- const todayCache = new TodaySpendCache(dayKey => scanner.scan(dayKey), TODAY_SPEND_CACHE_MS);
197
- const todaySessionsCache = new TodaySpendCache(dayKey => scanner.scanSessions(dayKey), TODAY_SPEND_CACHE_MS);
198
- const fetchTodaySpend = async (force = false) => todayCache.get(force);
199
- const fetchTodaySessionsSpend = async (force = false) => todaySessionsCache.get(force);
200
- const fetchTurnSpend = async (sessionId, messageId) => {
259
+ const todayCache = new TodaySpendCache(dayKey => scanner.scanDetail(dayKey), TODAY_SPEND_CACHE_MS);
260
+ return {
261
+ fetchTodaySpend: async (force = false) => (await todayCache.get(force)).aggregate,
262
+ fetchTodaySessionsSpend: async (force = false) => ({ sessions: (await todayCache.get(force)).sessions }),
263
+ };
264
+ }
265
+ /** One completed Turn's spend loader, located by its closing message id. */
266
+ function createTurnSpendFetcher(ctx, facts) {
267
+ return async (sessionId, messageId) => {
268
+ const { events } = await sessionEvents(ctx, sessionId);
269
+ return computeTurnSpend(events, facts.billing, facts.catalog, messageId);
270
+ };
271
+ }
272
+ /**
273
+ * Every completed Turn's cost in one session, folded incrementally per session
274
+ * (session logs are append-only, so only the appended tail is priced on a
275
+ * growing log). One call serves a whole transcript's per-message cost rows,
276
+ * replacing the per-message `getTurnSpend` fan-out.
277
+ */
278
+ function createTurnSpendsFetcher(ctx, facts) {
279
+ const folders = new Map();
280
+ return async (sessionId) => {
201
281
  const { events } = await sessionEvents(ctx, sessionId);
202
- return computeTurnSpend(events, billing, catalog, messageId);
282
+ let entry = folders.get(sessionId);
283
+ if (entry === undefined || entry.count > events.length) {
284
+ entry = { folder: new SessionTurnSpendFolder(facts.billing, facts.catalog), count: 0 };
285
+ evictOldest(folders, SESSION_TURN_SPEND_CACHE_LIMIT);
286
+ folders.set(sessionId, entry);
287
+ }
288
+ if (entry.count !== events.length) {
289
+ entry.folder.feed(events);
290
+ entry.count = events.length;
291
+ }
292
+ return entry.folder.finish();
293
+ };
294
+ }
295
+ /**
296
+ * Balance loader with a short host-side TTL and a hard request timeout: the
297
+ * credential resolves per call, a fresh snapshot is reused for
298
+ * {@link BALANCE_CACHE_MS} (so several badge mounts and several browsers share
299
+ * one `/user/balance` call), concurrent misses coalesce, and `force` bypasses
300
+ * the TTL for the manual refresh. A hung endpoint aborts after
301
+ * {@link BALANCE_TIMEOUT_MS} instead of holding the badge's fetch forever.
302
+ * @param ctx - plugin context carrying the credential seam.
303
+ * @param facts - resolved endpoint and credential facts.
304
+ * @returns the balance loader.
305
+ */
306
+ function createBalanceFetcher(ctx, facts) {
307
+ let cached;
308
+ let inflight;
309
+ return async (force = false) => {
310
+ if (!force && cached !== undefined && Date.now() - cached.at < BALANCE_CACHE_MS)
311
+ return cached.value;
312
+ if (inflight !== undefined)
313
+ return inflight;
314
+ const run = (async () => {
315
+ try {
316
+ const apiKey = await resolveApiKey(ctx, facts.apiKeyRef);
317
+ const value = await fetchDeepSeekBalance(facts.baseURL(), apiKey, AbortSignal.timeout(BALANCE_TIMEOUT_MS));
318
+ cached = { at: Date.now(), value };
319
+ return value;
320
+ }
321
+ finally {
322
+ inflight = undefined;
323
+ }
324
+ })();
325
+ inflight = run;
326
+ return run;
203
327
  };
204
- new DeepSeekBalanceGateway(ctx, { fetchBalance, fetchSessionSpend, fetchTodaySpend, fetchTodaySessionsSpend, fetchTurnSpend });
328
+ }
329
+ /**
330
+ * Register the `billing` Remote under the `billing` namespace. Assembly only:
331
+ * facts resolve once, each loader owns its caches, and the gateway receives
332
+ * the bound thunks.
333
+ * @param ctx - owning plugin context.
334
+ * @param config - validated plugin config.
335
+ */
336
+ export function apply(ctx, config) {
337
+ const facts = resolveFacts(ctx, config);
338
+ // One unit instance per plugin config, registered as early as the registry
339
+ // exists (and again on the first session created, in case the registry is
340
+ // composed after this plugin): DSH's write-behind then checkpoints a
341
+ // billing row for every session that runs in this process.
342
+ const unit = billingTodaySpendDefinition(facts.billing, facts.catalog);
343
+ const ensureUnit = createUnitRegistrar(ctx, unit);
344
+ ensureUnit();
345
+ ctx.on('session/created', ensureUnit);
346
+ const fetchBalance = createBalanceFetcher(ctx, facts);
347
+ const fetchSessionSpend = createSessionSpendFetcher(ctx, facts);
348
+ const { fetchTodaySpend, fetchTodaySessionsSpend } = createTodaySpendLoaders(ctx, facts, unit, ensureUnit);
349
+ const fetchTurnSpend = createTurnSpendFetcher(ctx, facts);
350
+ const fetchTurnSpends = createTurnSpendsFetcher(ctx, facts);
351
+ new DeepSeekBalanceGateway(ctx, {
352
+ fetchBalance,
353
+ fetchSessionSpend,
354
+ fetchTodaySpend,
355
+ fetchTodaySessionsSpend,
356
+ fetchTurnSpend,
357
+ fetchTurnSpends,
358
+ });
205
359
  }
206
360
  //# sourceMappingURL=index.js.map
@@ -1,37 +1,36 @@
1
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.
2
+ * `billingTodaySpend` session-projection unit: per-session billed spend,
3
+ * folded eagerly by the DSH projection drive over committed session events and
4
+ * checkpointed by the projection cache. The state keeps the session's LATEST
5
+ * priced Beijing day, its whole-session total, the fork boundary, the latest
6
+ * request model, and the last priced attempt sample (DSH's same-step
7
+ * replacement rule); the aggregate "today" read sums the units whose `dayKey`
8
+ * matches the current Beijing day — zero full-log scans once the fold is warm.
9
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
10
+ * The unit's fold IS the shared pricing fold ({@link applyBillingEvent}), so
11
+ * the projection path and the events-scan paths cannot drift. The unit is
12
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.
13
+ * (`sessionProjectionCache.cachedSnapshot` / registry `restore`) serves only
14
+ * wired units, and because the browser half reads this value through
15
+ * `useProjection` instead of polling a Remote; the wire value is the state
16
+ * itself.
15
17
  * @module @rayadesu/dsh-llm-billing/projection
16
18
  */
17
19
  import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection';
18
20
  import type { SessionEvent } from '@deepseek-ai/dsh-session';
19
- import type { ResolvedBilling } from './billing.ts';
20
- import type { DeepSeekTodaySpend } from './types.ts';
21
+ import type { BillingFoldState, ResolvedBilling } from './billing.ts';
21
22
  /** The projection key this unit owns. */
22
23
  export declare const BILLING_UNIT_KEY = "billingTodaySpend";
23
24
  /**
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.
25
+ * The unit's state is the shared billing fold state: the latest priced
26
+ * Beijing day, the whole-session total, the fork boundary, the latest request
27
+ * model, and the last priced attempt sample. Plain JSON, as the
28
+ * persisted-cache contract requires. The fold is boundary-aware: it prices
29
+ * only the session's OWN events (a fork child's inherited prefix is skipped),
30
+ * so both totals match the Remote paths and the client can read them without
31
+ * a Remote call.
28
32
  */
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
- }
33
+ export type BillingUnitState = BillingFoldState;
35
34
  declare module '@deepseek-ai/dsh-session-projection/types' {
36
35
  interface SessionProjectionStateMap {
37
36
  billingTodaySpend: BillingUnitState;
@@ -49,13 +48,15 @@ export type BillingUnitDefinition = Omit<ProjectionDefinition<'billingTodaySpend
49
48
  wire: NonNullable<ProjectionDefinition<'billingTodaySpend', BillingUnitState>['wire']>;
50
49
  };
51
50
  /**
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.
51
+ * Build the `billingTodaySpend` unit for one resolved pricing table. Published
52
+ * rate revisions travel inside the closure and are resolved per sample
53
+ * timestamp, so a re-priced series bills its own history correctly however late
54
+ * a log is folded; only a configuration change (editing `billing.models`) is
55
+ * fixed at registration, and it re-prices just the events folded afterwards
56
+ * (the events-scan paths re-price the whole log). Bump
57
+ * {@link ProjectionDefinition.stateVersion} whenever the state shape or fold
58
+ * semantics change, so persisted checkpoint rows are discarded instead of
59
+ * folded forward.
59
60
  * @param billing - resolved pricing with peak-hour windows.
60
61
  * @param catalog - model display rows, in presentation order.
61
62
  * @returns the unit definition to register on `ctx.sessionProjections`.
@@ -69,8 +70,8 @@ export interface BillingUnitFold {
69
70
  /**
70
71
  * Initial state for the empty log. DSH ≤ 0.1.1-rc.2 declared `init()` with
71
72
  * no parameters; 0.1.2-alpha.5+ passes the Session header and inherited
72
- * count. The unit ignores both, so the structural type accepts either call
73
- * shape.
73
+ * count. Detached folds call it with no arguments and apply the boundary
74
+ * themselves (see {@link foldOwnBilling}), so both call shapes stay valid.
74
75
  */
75
76
  init(...metadata: never[]): BillingUnitState;
76
77
  /** Pure transition: previous state + one committed event → next state. */
@@ -1,21 +1,23 @@
1
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.
2
+ * `billingTodaySpend` session-projection unit: per-session billed spend,
3
+ * folded eagerly by the DSH projection drive over committed session events and
4
+ * checkpointed by the projection cache. The state keeps the session's LATEST
5
+ * priced Beijing day, its whole-session total, the fork boundary, the latest
6
+ * request model, and the last priced attempt sample (DSH's same-step
7
+ * replacement rule); the aggregate "today" read sums the units whose `dayKey`
8
+ * matches the current Beijing day — zero full-log scans once the fold is warm.
9
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
10
+ * The unit's fold IS the shared pricing fold ({@link applyBillingEvent}), so
11
+ * the projection path and the events-scan paths cannot drift. The unit is
12
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.
13
+ * (`sessionProjectionCache.cachedSnapshot` / registry `restore`) serves only
14
+ * wired units, and because the browser half reads this value through
15
+ * `useProjection` instead of polling a Remote; the wire value is the state
16
+ * itself.
15
17
  * @module @rayadesu/dsh-llm-billing/projection
16
18
  */
17
19
  import { z } from 'zod';
18
- import { addEventContribution, emptyTodaySpend, priceEvent } from "./billing.js";
20
+ import { applyBillingEvent, emptyBillingFoldState } from "./billing.js";
19
21
  /** The projection key this unit owns. */
20
22
  export const BILLING_UNIT_KEY = 'billingTodaySpend';
21
23
  const modelRowSchema = z.object({
@@ -38,15 +40,26 @@ const todaySpendSchema = z.object({
38
40
  const billingUnitSchema = z.object({
39
41
  dayKey: z.string(),
40
42
  spend: todaySpendSchema,
43
+ session: todaySpendSchema,
44
+ inheritedEventCount: z.number().int().nonnegative(),
45
+ model: z.string(),
46
+ last: z.object({
47
+ turn: z.number().int().nonnegative(),
48
+ step: z.number().int().nonnegative(),
49
+ dayKey: z.string(),
50
+ spend: todaySpendSchema,
51
+ }).strict().nullable(),
41
52
  }).strict();
42
53
  /**
43
- * Build the `billingTodaySpend` unit for one resolved pricing table. The
44
- * pricing closure is fixed at registration; a pricing-table change therefore
45
- * prices only events folded after the change (historical spend keeps its
46
- * historical rates), unlike the events-scan paths which re-price the whole
47
- * log. Bump {@link ProjectionDefinition.stateVersion} whenever the state
48
- * shape or fold semantics change, so persisted checkpoint rows are discarded
49
- * instead of folded forward.
54
+ * Build the `billingTodaySpend` unit for one resolved pricing table. Published
55
+ * rate revisions travel inside the closure and are resolved per sample
56
+ * timestamp, so a re-priced series bills its own history correctly however late
57
+ * a log is folded; only a configuration change (editing `billing.models`) is
58
+ * fixed at registration, and it re-prices just the events folded afterwards
59
+ * (the events-scan paths re-price the whole log). Bump
60
+ * {@link ProjectionDefinition.stateVersion} whenever the state shape or fold
61
+ * semantics change, so persisted checkpoint rows are discarded instead of
62
+ * folded forward.
50
63
  * @param billing - resolved pricing with peak-hour windows.
51
64
  * @param catalog - model display rows, in presentation order.
52
65
  * @returns the unit definition to register on `ctx.sessionProjections`.
@@ -55,26 +68,16 @@ export function billingTodaySpendDefinition(billing, catalog) {
55
68
  const names = new Map(catalog.map(model => [model.id, model.name]));
56
69
  return {
57
70
  key: BILLING_UNIT_KEY,
58
- stateVersion: 1,
71
+ // v4: rate revisions resolved per sample timestamp (the V4 Flash series is
72
+ // re-priced from 2026-09-10 12:00 Beijing), on top of v3's DSH-aligned
73
+ // attempt pricing, v2's boundary-aware fold, and the whole-session total.
74
+ // A row checkpointed by the previous version priced every sample at one
75
+ // flat pair of rates, so rows folded after the re-pricing instant would
76
+ // keep the superseded rates: bumping discards them and refolds.
77
+ stateVersion: 4,
59
78
  stateSchema: billingUnitSchema,
60
- init: () => ({ dayKey: '', spend: emptyTodaySpend() }),
61
- apply: (state, event) => {
62
- const priced = priceEvent(event, billing, names);
63
- if (priced === undefined)
64
- return state;
65
- if (state.dayKey === priced.dayKey) {
66
- return { dayKey: state.dayKey, spend: addEventContribution(state.spend, priced) };
67
- }
68
- // The session log is append-only and chronological, so an event whose
69
- // Beijing day is strictly older than the state's day cannot legally
70
- // follow it; ignore defensively to keep the persisted fold
71
- // deterministic under reordered or clock-skewed timestamps.
72
- if (state.dayKey !== '' && priced.dayKey < state.dayKey)
73
- return state;
74
- // First priced event, or the session's first priced event of a new day:
75
- // the state resets to that day's spend.
76
- return { dayKey: priced.dayKey, spend: addEventContribution(emptyTodaySpend(), priced) };
77
- },
79
+ init: (_header, inheritedEventCount) => emptyBillingFoldState(Number(inheritedEventCount ?? 0)),
80
+ apply: (state, event) => applyBillingEvent(state, event, billing, names),
78
81
  wire: { viewSchema: billingUnitSchema, view: state => state },
79
82
  };
80
83
  }