@rayadesu/dsh-llm-billing 0.3.8 → 0.3.9

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.
@@ -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, forkBoundaryOf, isPeak, isSeededSession, mergeTodaySpend, negateSpend, priceEvent, priceUsage, resolveBilling, SessionTurnSpendFolder, SpendAccumulator, subtractSpend, } 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';
@@ -39,6 +39,7 @@ const BASE_URL_ENV = 'DEEPSEEK_BASE_URL';
39
39
  export const PUBLIC_BASE_URL = 'https://api.deepseek.com';
40
40
  const DEFAULT_MODELS = [
41
41
  { id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash' },
42
+ { id: 'deepseek-v4.1-flash-expires-on-0910', name: 'DeepSeek-V4.1-Flash' },
42
43
  { id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro' },
43
44
  { id: 'deepseek-v4-flash-vision-exp', name: 'DeepSeek-V4-Flash-Vision-Exp' },
44
45
  { id: 'mimo-v2.5-pro', name: 'MiMo-V2.5-Pro' },
@@ -54,15 +55,16 @@ const tokenPrice = z.object({
54
55
  output: z.number().min(0),
55
56
  });
56
57
  const billingConfig = z.object({
58
+ // Copies of the readonly published tables, taken once at module load.
57
59
  peakHours: z.array(z.object({
58
60
  start: z.number().step(1).min(0).max(23),
59
61
  end: z.number().step(1).min(0).max(24),
60
- })).default(DEFAULT_PEAK_HOURS),
62
+ })).default([...DEFAULT_PEAK_HOURS]),
61
63
  models: z.array(z.object({
62
64
  model: z.string().required(),
63
65
  peak: tokenPrice,
64
66
  offPeak: tokenPrice,
65
- })).default(DEFAULT_MODEL_PRICING),
67
+ })).default([...DEFAULT_MODEL_PRICING]),
66
68
  });
67
69
  export const Config = z.object({
68
70
  apiKeyEnv: z.string().role('credential-ref').default(DEFAULT_API_KEY_ENV),
@@ -74,6 +76,27 @@ export const Config = z.object({
74
76
  export const TODAY_SPEND_CACHE_MS = 60_000;
75
77
  /** Hard cap on today's events collected by the events scan path. */
76
78
  export const TODAY_SPEND_MAX_EVENTS = 200_000;
79
+ /** Max session-spend rows kept for incremental recompute before eviction. */
80
+ export const SESSION_SPEND_CACHE_LIMIT = 1024;
81
+ /** Max session-id entries kept in the per-turn-cost fold cache before eviction. */
82
+ export const SESSION_TURN_SPEND_CACHE_LIMIT = 64;
83
+ /** How long one balance snapshot is reused before the host refetches it (15s). */
84
+ export const BALANCE_CACHE_MS = 15_000;
85
+ /** Hard cap on one `/user/balance` request (5s); a hung endpoint never blocks the badge. */
86
+ export const BALANCE_TIMEOUT_MS = 5_000;
87
+ /**
88
+ * Bounded-map eviction: drop the oldest inserted entry once `size` reached
89
+ * `limit`, so an unbounded session-id space grows the map no further. Evicting
90
+ * one entry (instead of clearing) keeps the other sessions' incremental
91
+ * spend warm.
92
+ */
93
+ function evictOldest(map, limit) {
94
+ if (map.size < limit)
95
+ return;
96
+ const oldest = map.keys().next().value;
97
+ if (oldest !== undefined)
98
+ map.delete(oldest);
99
+ }
77
100
  /**
78
101
  * Read one session's event log and durable seed boundary: the live
79
102
  * SessionStore first, then the persistence backend for a flushed session
@@ -103,84 +126,111 @@ async function sessionEvents(ctx, sessionId) {
103
126
  }
104
127
  throw new LlmError(`llm-billing: session ${sessionId} not found`, 'NOT_FOUND');
105
128
  }
129
+ /** Resolve the plugin's static facts once: endpoint, credential ref, pricing table. */
130
+ function resolveFacts(ctx, config) {
131
+ return {
132
+ baseURL: () => config.baseURL
133
+ ?? launchEnvironmentOf(ctx).get(BASE_URL_ENV)?.value
134
+ ?? PUBLIC_BASE_URL,
135
+ apiKeyRef: credentialRef(config.apiKeyEnv ?? DEFAULT_API_KEY_ENV),
136
+ billing: resolveBilling(config.billing),
137
+ catalog: (config.models ?? DEFAULT_MODELS).map(model => ({ id: model.id, name: model.name ?? model.id })),
138
+ };
139
+ }
106
140
  /**
107
- * Register the `billing` Remote under the `billing` namespace.
108
- * @param ctx - owning plugin context.
109
- * @param config - validated plugin config.
141
+ * Resolve the API key per call: the credentials service first, then the
142
+ * launch environment fallback.
143
+ * @throws {@link LlmError} with code `MISSING_CREDENTIAL` when neither yields a usable key.
110
144
  */
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
- }
145
+ async function resolveApiKey(ctx, apiKeyRef) {
146
+ const credentials = ctx.get('credentials');
147
+ if (credentials !== undefined) {
148
+ const hit = await credentials.resolve(apiKeyRef);
149
+ if (hit !== undefined)
150
+ return assertUsableApiKey(hit.value, 'llm-billing', apiKeyRef);
151
+ }
152
+ else {
153
+ const ambient = launchEnvironmentOf(ctx).get(apiKeyRef);
154
+ if (ambient !== undefined && ambient.value.length > 0) {
155
+ return assertUsableApiKey(ambient.value, 'llm-billing', apiKeyRef);
128
156
  }
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.
157
+ }
158
+ throw new LlmError(`llm-billing: no API key; store ${apiKeyRef} through the credentials service or export it`, 'MISSING_CREDENTIAL');
159
+ }
160
+ /**
161
+ * Per-session incremental spend loader: a session log is append-only and
162
+ * chronological (the same assumption the projection unit makes), so a spend
163
+ * computed for `count` EVENTS OF THE SESSION'S OWN WORK (the log minus its
164
+ * inherited fork prefix) stays valid while the log length is unchanged, and
165
+ * only the appended tail needs pricing when it grows. A forked child's
166
+ * inherited prefix (`seq < seedLength`) is priced only in its source
167
+ * session; the cache is bounded (see {@link evictOldest}), so an unbounded
168
+ * session-id space cannot grow it without bound.
169
+ */
170
+ function createSessionSpendFetcher(ctx, facts) {
145
171
  const sessionSpendCache = new Map();
146
- const fetchSessionSpend = async (sessionId) => {
172
+ return async (sessionId) => {
147
173
  const { events, seedLength } = await sessionEvents(ctx, sessionId);
148
174
  const ownCount = events.length - seedLength;
149
175
  const cached = sessionSpendCache.get(sessionId);
150
- if (cached !== undefined && cached.count === ownCount)
176
+ if (cached !== undefined && cached.count === ownCount) {
177
+ // LRU touch: re-insert so the entry is evicted only after fresher ones.
178
+ sessionSpendCache.delete(sessionId);
179
+ sessionSpendCache.set(sessionId, cached);
151
180
  return cached.spend;
181
+ }
152
182
  if (cached !== undefined && cached.count < ownCount) {
153
- const spend = mergeTodaySpend(cached.spend, computeSessionSpend(events.slice(seedLength + cached.count), billing, catalog));
183
+ const spend = mergeTodaySpend(cached.spend, computeSessionSpend(events.slice(seedLength + cached.count), facts.billing, facts.catalog));
184
+ evictOldest(sessionSpendCache, SESSION_SPEND_CACHE_LIMIT);
154
185
  sessionSpendCache.set(sessionId, { count: ownCount, spend });
155
186
  return spend;
156
187
  }
157
- const spend = computeSessionSpend(events, billing, catalog, seedLength);
158
- if (sessionSpendCache.size >= 1024)
159
- sessionSpendCache.clear();
188
+ const spend = computeSessionSpend(events, facts.billing, facts.catalog, seedLength);
189
+ evictOldest(sessionSpendCache, SESSION_SPEND_CACHE_LIMIT);
160
190
  sessionSpendCache.set(sessionId, { count: ownCount, spend });
161
191
  return spend;
162
192
  };
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)
193
+ }
194
+ /**
195
+ * Once-registrar for the billing projection unit: the first call that finds
196
+ * the registry composed registers the shared unit and every later call is a
197
+ * no-op. Registering as early as the registry exists lets DSH's projection
198
+ * write-behind (mandatory at `turn/end`) checkpoint a billing row for every
199
+ * session that runs in this process, which is what makes the zero-I/O cold
200
+ * path in {@link TodaySpendScanner} hit after the next restart.
201
+ * @param ctx - plugin context.
202
+ * @param unit - the unit definition built once per plugin config.
203
+ * @returns an idempotent registrar.
204
+ */
205
+ function createUnitRegistrar(ctx, unit) {
206
+ let registered = false;
207
+ return () => {
208
+ if (registered)
174
209
  return;
175
210
  const registry = ctx.get('sessionProjections');
176
211
  if (registry === undefined)
177
212
  return;
178
213
  registry.register(unit);
179
- unitRegistered = true;
214
+ registered = true;
180
215
  };
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).
216
+ }
217
+ /**
218
+ * Today-spend loaders over one revision-gated scanner with two 60s
219
+ * Beijing-day caches (in-flight coalescing and a `force` bypass):
220
+ * - plan C uses the per-session spend projection unit registered by the
221
+ * caller's {@link createUnitRegistrar} as early as the registry exists (the
222
+ * registry builds cells lazily over the in-memory log, so events committed
223
+ * before registration are folded on first touch); without the registry the
224
+ * events path serves today's spend.
225
+ * - plans A1–A3: the scanner chooses the projection path when the registry
226
+ * is composed, the events path otherwise.
227
+ * @param ctx - plugin context.
228
+ * @param facts - resolved endpoint, credential, pricing, and catalog facts.
229
+ * @param unit - the shared projection unit definition.
230
+ * @param ensureUnit - idempotent unit registrar (last-resort registration).
231
+ * @returns the two today-spend loaders.
232
+ */
233
+ function createTodaySpendLoaders(ctx, facts, unit, ensureUnit) {
184
234
  const scanner = new TodaySpendScanner({
185
235
  sessions: () => ctx.get('sessions'),
186
236
  persistence: () => ctx.get('sessionPersistence'),
@@ -190,17 +240,108 @@ export function apply(ctx, config) {
190
240
  unit,
191
241
  maxEvents: TODAY_SPEND_MAX_EVENTS,
192
242
  logger: ctx.logger,
193
- billing,
194
- catalog,
243
+ billing: facts.billing,
244
+ catalog: facts.catalog,
195
245
  });
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) => {
246
+ const todayCache = new TodaySpendCache(dayKey => scanner.scanDetail(dayKey), TODAY_SPEND_CACHE_MS);
247
+ return {
248
+ fetchTodaySpend: async (force = false) => (await todayCache.get(force)).aggregate,
249
+ fetchTodaySessionsSpend: async (force = false) => ({ sessions: (await todayCache.get(force)).sessions }),
250
+ };
251
+ }
252
+ /** One completed Turn's spend loader, located by its closing message id. */
253
+ function createTurnSpendFetcher(ctx, facts) {
254
+ return async (sessionId, messageId) => {
255
+ const { events } = await sessionEvents(ctx, sessionId);
256
+ return computeTurnSpend(events, facts.billing, facts.catalog, messageId);
257
+ };
258
+ }
259
+ /**
260
+ * Every completed Turn's cost in one session, folded incrementally per session
261
+ * (session logs are append-only, so only the appended tail is priced on a
262
+ * growing log). One call serves a whole transcript's per-message cost rows,
263
+ * replacing the per-message `getTurnSpend` fan-out.
264
+ */
265
+ function createTurnSpendsFetcher(ctx, facts) {
266
+ const folders = new Map();
267
+ return async (sessionId) => {
201
268
  const { events } = await sessionEvents(ctx, sessionId);
202
- return computeTurnSpend(events, billing, catalog, messageId);
269
+ let entry = folders.get(sessionId);
270
+ if (entry === undefined || entry.count > events.length) {
271
+ entry = { folder: new SessionTurnSpendFolder(facts.billing, facts.catalog), count: 0 };
272
+ evictOldest(folders, SESSION_TURN_SPEND_CACHE_LIMIT);
273
+ folders.set(sessionId, entry);
274
+ }
275
+ if (entry.count !== events.length) {
276
+ entry.folder.feed(events);
277
+ entry.count = events.length;
278
+ }
279
+ return entry.folder.finish();
203
280
  };
204
- new DeepSeekBalanceGateway(ctx, { fetchBalance, fetchSessionSpend, fetchTodaySpend, fetchTodaySessionsSpend, fetchTurnSpend });
281
+ }
282
+ /**
283
+ * Balance loader with a short host-side TTL and a hard request timeout: the
284
+ * credential resolves per call, a fresh snapshot is reused for
285
+ * {@link BALANCE_CACHE_MS} (so several badge mounts and several browsers share
286
+ * one `/user/balance` call), concurrent misses coalesce, and `force` bypasses
287
+ * the TTL for the manual refresh. A hung endpoint aborts after
288
+ * {@link BALANCE_TIMEOUT_MS} instead of holding the badge's fetch forever.
289
+ * @param ctx - plugin context carrying the credential seam.
290
+ * @param facts - resolved endpoint and credential facts.
291
+ * @returns the balance loader.
292
+ */
293
+ function createBalanceFetcher(ctx, facts) {
294
+ let cached;
295
+ let inflight;
296
+ return async (force = false) => {
297
+ if (!force && cached !== undefined && Date.now() - cached.at < BALANCE_CACHE_MS)
298
+ return cached.value;
299
+ if (inflight !== undefined)
300
+ return inflight;
301
+ const run = (async () => {
302
+ try {
303
+ const apiKey = await resolveApiKey(ctx, facts.apiKeyRef);
304
+ const value = await fetchDeepSeekBalance(facts.baseURL(), apiKey, AbortSignal.timeout(BALANCE_TIMEOUT_MS));
305
+ cached = { at: Date.now(), value };
306
+ return value;
307
+ }
308
+ finally {
309
+ inflight = undefined;
310
+ }
311
+ })();
312
+ inflight = run;
313
+ return run;
314
+ };
315
+ }
316
+ /**
317
+ * Register the `billing` Remote under the `billing` namespace. Assembly only:
318
+ * facts resolve once, each loader owns its caches, and the gateway receives
319
+ * the bound thunks.
320
+ * @param ctx - owning plugin context.
321
+ * @param config - validated plugin config.
322
+ */
323
+ export function apply(ctx, config) {
324
+ const facts = resolveFacts(ctx, config);
325
+ // One unit instance per plugin config, registered as early as the registry
326
+ // exists (and again on the first session created, in case the registry is
327
+ // composed after this plugin): DSH's write-behind then checkpoints a
328
+ // billing row for every session that runs in this process.
329
+ const unit = billingTodaySpendDefinition(facts.billing, facts.catalog);
330
+ const ensureUnit = createUnitRegistrar(ctx, unit);
331
+ ensureUnit();
332
+ ctx.on('session/created', ensureUnit);
333
+ const fetchBalance = createBalanceFetcher(ctx, facts);
334
+ const fetchSessionSpend = createSessionSpendFetcher(ctx, facts);
335
+ const { fetchTodaySpend, fetchTodaySessionsSpend } = createTodaySpendLoaders(ctx, facts, unit, ensureUnit);
336
+ const fetchTurnSpend = createTurnSpendFetcher(ctx, facts);
337
+ const fetchTurnSpends = createTurnSpendsFetcher(ctx, facts);
338
+ new DeepSeekBalanceGateway(ctx, {
339
+ fetchBalance,
340
+ fetchSessionSpend,
341
+ fetchTodaySpend,
342
+ fetchTodaySessionsSpend,
343
+ fetchTurnSpend,
344
+ fetchTurnSpends,
345
+ });
205
346
  }
206
347
  //# 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;
@@ -69,8 +68,8 @@ export interface BillingUnitFold {
69
68
  /**
70
69
  * Initial state for the empty log. DSH ≤ 0.1.1-rc.2 declared `init()` with
71
70
  * 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.
71
+ * count. Detached folds call it with no arguments and apply the boundary
72
+ * themselves (see {@link foldOwnBilling}), so both call shapes stay valid.
74
73
  */
75
74
  init(...metadata: never[]): BillingUnitState;
76
75
  /** 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,6 +40,15 @@ 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
54
  * Build the `billingTodaySpend` unit for one resolved pricing table. The
@@ -55,26 +66,14 @@ export function billingTodaySpendDefinition(billing, catalog) {
55
66
  const names = new Map(catalog.map(model => [model.id, model.name]));
56
67
  return {
57
68
  key: BILLING_UNIT_KEY,
58
- stateVersion: 1,
69
+ // v3: DSH-aligned attempt pricing (assistant/attempt samples, same-step
70
+ // replacement, `llm/retry-started` closes the slot) on top of v2's
71
+ // boundary-aware fold and whole-session total; older checkpoint rows are
72
+ // discarded and refolded.
73
+ stateVersion: 3,
59
74
  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
- },
75
+ init: (_header, inheritedEventCount) => emptyBillingFoldState(Number(inheritedEventCount ?? 0)),
76
+ apply: (state, event) => applyBillingEvent(state, event, billing, names),
78
77
  wire: { viewSchema: billingUnitSchema, view: state => state },
79
78
  };
80
79
  }