@rayadesu/dsh-llm-billing 0.3.7 → 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,8 +39,11 @@ 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' },
45
+ { id: 'mimo-v2.5-pro', name: 'MiMo-V2.5-Pro' },
46
+ { id: 'mimo-v2.5', name: 'MiMo-V2.5' },
44
47
  ];
45
48
  const billingModel = z.object({
46
49
  id: z.string().required(),
@@ -52,15 +55,16 @@ const tokenPrice = z.object({
52
55
  output: z.number().min(0),
53
56
  });
54
57
  const billingConfig = z.object({
58
+ // Copies of the readonly published tables, taken once at module load.
55
59
  peakHours: z.array(z.object({
56
60
  start: z.number().step(1).min(0).max(23),
57
61
  end: z.number().step(1).min(0).max(24),
58
- })).default(DEFAULT_PEAK_HOURS),
62
+ })).default([...DEFAULT_PEAK_HOURS]),
59
63
  models: z.array(z.object({
60
64
  model: z.string().required(),
61
65
  peak: tokenPrice,
62
66
  offPeak: tokenPrice,
63
- })).default(DEFAULT_MODEL_PRICING),
67
+ })).default([...DEFAULT_MODEL_PRICING]),
64
68
  });
65
69
  export const Config = z.object({
66
70
  apiKeyEnv: z.string().role('credential-ref').default(DEFAULT_API_KEY_ENV),
@@ -72,6 +76,27 @@ export const Config = z.object({
72
76
  export const TODAY_SPEND_CACHE_MS = 60_000;
73
77
  /** Hard cap on today's events collected by the events scan path. */
74
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
+ }
75
100
  /**
76
101
  * Read one session's event log and durable seed boundary: the live
77
102
  * SessionStore first, then the persistence backend for a flushed session
@@ -101,84 +126,111 @@ async function sessionEvents(ctx, sessionId) {
101
126
  }
102
127
  throw new LlmError(`llm-billing: session ${sessionId} not found`, 'NOT_FOUND');
103
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
+ }
104
140
  /**
105
- * Register the `billing` Remote under the `billing` namespace.
106
- * @param ctx - owning plugin context.
107
- * @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.
108
144
  */
109
- export function apply(ctx, config) {
110
- const baseURL = () => config.baseURL
111
- ?? launchEnvironmentOf(ctx).get(BASE_URL_ENV)?.value
112
- ?? PUBLIC_BASE_URL;
113
- const apiKeyRef = credentialRef(config.apiKeyEnv ?? DEFAULT_API_KEY_ENV);
114
- const resolveApiKey = async () => {
115
- const credentials = ctx.get('credentials');
116
- if (credentials !== undefined) {
117
- const hit = await credentials.resolve(apiKeyRef);
118
- if (hit !== undefined)
119
- return assertUsableApiKey(hit.value, 'llm-billing', apiKeyRef);
120
- }
121
- else {
122
- const ambient = launchEnvironmentOf(ctx).get(apiKeyRef);
123
- if (ambient !== undefined && ambient.value.length > 0) {
124
- return assertUsableApiKey(ambient.value, 'llm-billing', apiKeyRef);
125
- }
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);
126
156
  }
127
- throw new LlmError(`llm-billing: no API key; store ${apiKeyRef} through the credentials service or export it`, 'MISSING_CREDENTIAL');
128
- };
129
- const fetchBalance = async () => {
130
- const apiKey = await resolveApiKey();
131
- return fetchDeepSeekBalance(baseURL(), apiKey);
132
- };
133
- const billing = resolveBilling(config.billing);
134
- const catalog = (config.models ?? DEFAULT_MODELS).map(model => ({ id: model.id, name: model.name ?? model.id }));
135
- // Per-session incremental spend cache: a session log is append-only and
136
- // chronological (the same assumption the projection unit makes), so a spend
137
- // computed for `count` EVENTS OF THE SESSION'S OWN WORK (the log minus its
138
- // inherited fork prefix) stays valid while the log length is unchanged, and
139
- // only the appended tail needs pricing when it grows. A forked child's
140
- // inherited prefix (`seq < seedLength`) is priced only in its source
141
- // session; the map is capped so an unbounded session-id space cannot grow
142
- // 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) {
143
171
  const sessionSpendCache = new Map();
144
- const fetchSessionSpend = async (sessionId) => {
172
+ return async (sessionId) => {
145
173
  const { events, seedLength } = await sessionEvents(ctx, sessionId);
146
174
  const ownCount = events.length - seedLength;
147
175
  const cached = sessionSpendCache.get(sessionId);
148
- 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);
149
180
  return cached.spend;
181
+ }
150
182
  if (cached !== undefined && cached.count < ownCount) {
151
- 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);
152
185
  sessionSpendCache.set(sessionId, { count: ownCount, spend });
153
186
  return spend;
154
187
  }
155
- const spend = computeSessionSpend(events, billing, catalog, seedLength);
156
- if (sessionSpendCache.size >= 1024)
157
- sessionSpendCache.clear();
188
+ const spend = computeSessionSpend(events, facts.billing, facts.catalog, seedLength);
189
+ evictOldest(sessionSpendCache, SESSION_SPEND_CACHE_LIMIT);
158
190
  sessionSpendCache.set(sessionId, { count: ownCount, spend });
159
191
  return spend;
160
192
  };
161
- // Plan C: register the per-session spend projection unit on the projection
162
- // registry. Registration is lazy — it happens on the first projection-path
163
- // scan, not through `ctx.inject` (whose plugin-mount wait would also engage
164
- // the test-invariant host in suites that never provide the registry). The
165
- // registry builds cells lazily over the in-memory log, so events committed
166
- // before registration are folded on first touch; without the registry the
167
- // events path serves today's spend.
168
- const unit = billingTodaySpendDefinition(billing, catalog);
169
- let unitRegistered = false;
170
- const ensureUnit = () => {
171
- 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)
172
209
  return;
173
210
  const registry = ctx.get('sessionProjections');
174
211
  if (registry === undefined)
175
212
  return;
176
213
  registry.register(unit);
177
- unitRegistered = true;
214
+ registered = true;
178
215
  };
179
- // Plans A1–A3: 60s Beijing-day cache with in-flight coalescing and a force
180
- // bypass, over a revision-gated scanner (projection path when the registry
181
- // 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) {
182
234
  const scanner = new TodaySpendScanner({
183
235
  sessions: () => ctx.get('sessions'),
184
236
  persistence: () => ctx.get('sessionPersistence'),
@@ -188,17 +240,108 @@ export function apply(ctx, config) {
188
240
  unit,
189
241
  maxEvents: TODAY_SPEND_MAX_EVENTS,
190
242
  logger: ctx.logger,
191
- billing,
192
- catalog,
243
+ billing: facts.billing,
244
+ catalog: facts.catalog,
193
245
  });
194
- const todayCache = new TodaySpendCache(dayKey => scanner.scan(dayKey), TODAY_SPEND_CACHE_MS);
195
- const todaySessionsCache = new TodaySpendCache(dayKey => scanner.scanSessions(dayKey), TODAY_SPEND_CACHE_MS);
196
- const fetchTodaySpend = async (force = false) => todayCache.get(force);
197
- const fetchTodaySessionsSpend = async (force = false) => todaySessionsCache.get(force);
198
- 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) => {
199
268
  const { events } = await sessionEvents(ctx, sessionId);
200
- 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();
201
280
  };
202
- 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
+ });
203
346
  }
204
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
  }