@rayadesu/dsh-llm-billing 0.3.4 → 0.3.6

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.
package/README.i18n.yaml CHANGED
@@ -2,5 +2,5 @@
2
2
  # last confirmed-consistent state. Both languages carry equal authority; after
3
3
  # editing either side, bring the other along and re-record both hashes with:
4
4
  # git hash-object README.md README.zh.md
5
- README.md: ed1a2bc7ef768d789ae4e6ba55c92254104b3a14
6
- README.zh.md: 3c84b79a1e1bc48fa43f8577bd81853f658a7087
5
+ README.md: 85c6cc052cd3332f23351db8123259565a669602
6
+ README.zh.md: 96d0d9d70fe94bfb0afa2e2af3f97231103d3590
package/README.md CHANGED
@@ -31,7 +31,11 @@ Note: the projection path prices a session's history once, at the rates in effec
31
31
 
32
32
  ## Forked sessions
33
33
 
34
- A forked session (DSH's "fork" of a conversation) opens its log with a verbatim copy of its source session's events. Without special handling, the same model outputs would be billed once per copy: the child's session spend would include the inherited prefix, and today's spend would count it a second time alongside the parent's. The plugin prices only a session's OWN events — the durable `header.seedLength` is the fork boundary, and every event with `seq < seedLength` is treated as already billed in the source session. Fork children are therefore billed from their first new exchange onward (a freshly forked session prices to zero), today's spend counts each model output exactly once, and the same lineage-safe rule covers multi-generation forks and subagent forks (spawned with `context: 'fork'`). The boundary is the persisted header value, so a resumed fork child keeps its original boundary, while a session created without a seed — ordinary sessions and cold resumes included — carries no boundary and is billed in full.
34
+ A forked session (DSH's "fork" of a conversation) opens its log with a verbatim copy of its source session's events. Without special handling, the same model outputs would be billed once per copy: the child's session spend would include the inherited prefix, and today's spend would count it a second time alongside the parent's. The plugin prices only a session's OWN events — the fork boundary is the session's persisted state (`header.seedLength` on the 0.1.1-rc.2 runtime, `Session.inheritedEventCount` / `inspect().inheritedEventCount` on 0.1.2-alpha.4+, both read structurally), and every event with `seq < boundary` is treated as already billed in the source session. Fork children are therefore billed from their first new exchange onward (a freshly forked session prices to zero), today's spend counts each model output exactly once, and the same lineage-safe rule covers multi-generation forks and subagent forks (spawned with `context: 'fork'`). The boundary is the persisted value, so a resumed fork child keeps its original boundary, while a session created without a seed — ordinary sessions and cold resumes included — carries no boundary and is billed in full.
35
+
36
+ ## Runtime compatibility
37
+
38
+ Since 0.1.2-alpha.4, DSH replaced the live `Session` log surface `Session.events` with `Session.snapshotEvents()` (no args = the full current log) and `Session.ownEvents()`, and moved `SessionHeader.seedLength` to `Session.inheritedEventCount` (the persistence `inspect()` result carries the value beside `meta`; `listSnapshots()` headers keep only the boolean `isSeeded`). Every log read in the plugin goes through the structural adapters `liveSessionEvents` / `forkBoundaryOf` / `isSeededSession`, which accept both the ≤ 0.1.1-rc.2 and the 0.1.2-alpha.4+ shapes — the npm release baseline (`^0.1.1-rc.2`) and the ahead-of-npm monorepo runtime both work without modification. On an unknown surface that has neither shape the plugin fails loudly rather than silently pricing an empty log.
35
39
 
36
40
  ## Configuration
37
41
 
package/README.zh.md CHANGED
@@ -31,7 +31,11 @@
31
31
 
32
32
  ## 分叉会话
33
33
 
34
- 分叉会话(DSH 的「分叉会话」)的日志以来源会话事件的逐字节副本开头。若不特殊处理,同一批模型输出会按副本数重复计费:子会话的会话花费会包含继承前缀,今日花费也会在父会话之外再计一次。插件只对会话的**自有事件**计费——持久的 `header.seedLength` 即分叉边界,凡是 `seq < seedLength` 的事件都视为已在来源会话计费。因此分叉子会话从分叉后的第一次新交流开始计费(刚分叉的会话花费为零),今日花费对每个模型输出只计一次,同一血缘规则同样覆盖多代分叉与 subagent 分叉(`context: 'fork'` 生成)。边界取自已持久化的 header 值,所以恢复后的分叉子会话保持原边界;而创建时没有 seed 的会话——包括普通会话与冷恢复——不带边界,正常全额计费。
34
+ 分叉会话(DSH 的「分叉会话」)的日志以来源会话事件的逐字节副本开头。若不特殊处理,同一批模型输出会按副本数重复计费:子会话的会话花费会包含继承前缀,今日花费也会在父会话之外再计一次。插件只对会话的**自有事件**计费——分叉边界取自已持久化的会话状态(≤ 0.1.1-rc.2 运行时为 `header.seedLength`;0.1.2-alpha.4+ 运行时为 `Session.inheritedEventCount` / `inspect().inheritedEventCount`,两者都以结构方式读取),凡是 `seq < 边界` 的事件都视为已在来源会话计费。因此分叉子会话从分叉后的第一次新交流开始计费(刚分叉的会话花费为零),今日花费对每个模型输出只计一次,同一血缘规则同样覆盖多代分叉与 subagent 分叉(`context: 'fork'` 生成)。边界取自已持久化的值,所以恢复后的分叉子会话保持原边界;而创建时没有 seed 的会话——包括普通会话与冷恢复——不带边界,正常全额计费。
35
+
36
+ ## 运行时兼容性
37
+
38
+ 0.1.2-alpha.4 起,DSH 把 live `Session` 的日志读取表面从 `Session.events` 改为 `Session.snapshotEvents()`(无参 = 当前全量日志)与 `Session.ownEvents()`,并把 `SessionHeader.seedLength` 移至 `Session.inheritedEventCount`(持久化侧 `inspect()` 的结果在 `meta` 之外携带该值,`listSnapshots()` 的 header 只剩布尔 `isSeeded`)。插件的所有日志读取都走结构适配器 `liveSessionEvents` / `forkBoundaryOf` / `isSeededSession`,同时接受 ≤ 0.1.1-rc.2 与 0.1.2-alpha.4+ 两种形状——npm 发布基线(`^0.1.1-rc.2`)与超前于它的 monorepo 运行时代码均无需改动即可工作。遇到两种形状都没有的未知运行时表面时,插件会显式失败而不是静默按零花费计价。
35
39
 
36
40
  ## 配置
37
41
 
package/lib/index.js CHANGED
@@ -380,20 +380,28 @@ function beijingDayKey(now) {
380
380
  return beijingParts(now.getTime()).dayKey;
381
381
  }
382
382
  /**
383
- * The durable inherited-prefix boundary of one session: its header's
384
- * `seedLength`, or 0 for a session created without a seed. A forked session
385
- * (or any seeded replay) carries the number of events it inherited verbatim
386
- * from its source session in its header; every event with `seq < seedLength`
387
- * is a copy of an event already billed in that source session, so pricing
388
- * must skip them or the same model output is counted once per copy.
389
- * The value is the PERSISTED header field (not the in-memory constructor
390
- * seed length), so a resumed fork child keeps its original boundary and a
391
- * resume of an unseeded session stays at 0.
392
- * @param header - the session's durable header, when available.
383
+ * The durable inherited-prefix boundary of one session: the number of leading
384
+ * events it inherited verbatim from its fork source, 0 for a session created
385
+ * without a seed. A forked session (or any seeded replay) carries that count
386
+ * in its session state; every event with `seq < seedLength` is a copy of an
387
+ * event already billed in that source session, so pricing must skip them or
388
+ * the same model output is counted once per copy. Accepts the durable field
389
+ * of both DSH runtime families (see {@link ForkBoundarySource}).
390
+ * @param source - the session, inspection result, header slice, or durable
391
+ * header carrying the boundary; `undefined` reads as 0.
393
392
  * @returns the inherited-prefix length; 0 for an unseeded session.
394
393
  */
395
- function forkBoundaryOf(header) {
396
- return header?.seedLength ?? 0;
394
+ function forkBoundaryOf(source) {
395
+ if (source === void 0) return 0;
396
+ const inherited = source.inheritedEventCount;
397
+ if (inherited !== void 0 && Number.isSafeInteger(inherited)) return inherited;
398
+ return source.header?.seedLength ?? source.meta?.seedLength ?? source.seedLength ?? 0;
399
+ }
400
+ /** Whether a durable header marks a fork-inherited (seeded) session across both runtime families. */
401
+ function isSeededSession(header) {
402
+ if (header === void 0) return false;
403
+ if (header.isSeeded === true) return true;
404
+ return (header.seedLength ?? 0) > 0;
397
405
  }
398
406
  /** Whether a Beijing (hour, weekday) pair falls inside any peak-hour window. */
399
407
  function isPeakParts(billing, hour, weekday) {
@@ -782,14 +790,22 @@ function foldOwnBilling(unit, events, seedLength = 0) {
782
790
  * unchanged log provably cannot change the aggregate.
783
791
  *
784
792
  * Forked sessions never double-count: a fork child's log opens with a
785
- * verbatim copy of its source session's events (`header.seedLength` of them),
786
- * so the scanner prices only the child's OWN events (`seq >= seedLength`) on
787
- * every path — the projection path bypasses the eager cell for a seeded
788
- * session and folds its own events instead (the cell covers the inherited
789
- * prefix too), and the cold ladder skips the projection cache for a seeded
790
- * session (its cached row predates the boundary and covers inherited events).
791
- * The boundary is the durable session header, so a resumed fork child keeps
792
- * its original boundary and an unseeded session stays at 0.
793
+ * verbatim copy of its source session's events (its inherited boundary),
794
+ * so the scanner prices only the child's OWN events on every path — the
795
+ * projection path bypasses the eager cell for a seeded session and folds its
796
+ * own events instead (the cell covers the inherited prefix too), and the cold
797
+ * ladder skips the projection cache for a seeded session (its cached row
798
+ * predates the boundary and covers inherited events). The boundary is the
799
+ * durable session state, read across both DSH runtime families — a resumed
800
+ * fork child keeps its original boundary and an unseeded session stays at 0.
801
+ *
802
+ * The live `Session` log surface changed in 0.1.2-alpha.4: `Session.events`
803
+ * was removed and replaced by `Session.snapshotEvents()` / `ownEvents()`, and
804
+ * `SessionHeader.seedLength` moved to `Session.inheritedEventCount` (the
805
+ * persistence `inspect` result carries it alongside `meta`). Reads go through
806
+ * {@link liveSessionEvents} / {@link forkBoundaryOf}, which accept both
807
+ * families structurally, so the scanner runs on the ≤ 0.1.1-rc.2 npm baseline
808
+ * and on the newer runtime.
793
809
  * @module @rayadesu/dsh-llm-billing/today-spend
794
810
  */
795
811
  /**
@@ -810,6 +826,17 @@ function foldSessionTitle(events) {
810
826
  return null;
811
827
  }
812
828
  /**
829
+ * Read one live session's complete event log across both runtime families.
830
+ * @throws when the session exposes neither the legacy `events` snapshot nor
831
+ * the newer `snapshotEvents()` reader — an unknown runtime surface must
832
+ * fail loudly rather than silently price an empty log.
833
+ */
834
+ function liveSessionEvents(session) {
835
+ if (session.events !== void 0) return session.events;
836
+ if (session.snapshotEvents !== void 0) return session.snapshotEvents();
837
+ throw new Error("llm-billing: session log surface is neither Session.events nor Session.snapshotEvents");
838
+ }
839
+ /**
813
840
  * Bounded parallel fan-out: run `run` over `items` with at most `limit` in
814
841
  * flight. A shared index counter hands each worker its next job, so the
815
842
  * dispatch is O(n) overall (array `shift()` would be O(n) per pop).
@@ -923,16 +950,19 @@ var TodaySpendScanner = class {
923
950
  * until the session is inspected again. A SEEDED session (fork child)
924
951
  * skips the ladder entirely: its cached row was folded over the inherited
925
952
  * prefix too, so it always detaches through inspect with the durable
926
- * boundary applied to the local fold.
953
+ * boundary (the inspect result's inherited count or `meta.seedLength`,
954
+ * depending on the runtime family) applied to the local fold.
927
955
  * @param id - the cold session's id.
928
- * @param seedLength - the durable inherited-prefix boundary (0 for unseeded).
956
+ * @param seeded - whether the session carries a fork-inherited prefix
957
+ * (from the snapshot header: `isSeeded` on 0.1.2-alpha.4+, `seedLength`
958
+ * at and before the 0.1.1-rc.2 baseline).
929
959
  * @returns the resolved state and title, or `undefined` when unreadable.
930
960
  */
931
- async resolveCold(id, seedLength) {
932
- const { persistence, projectionCache, unit, logger } = this.deps;
961
+ async resolveCold(id, seeded) {
962
+ const { persistence, projectionCache, logger } = this.deps;
933
963
  const persistenceService = persistence?.();
934
964
  if (persistenceService === void 0) return void 0;
935
- if (seedLength <= 0) {
965
+ if (!seeded) {
936
966
  const cache = projectionCache?.();
937
967
  if (cache !== void 0) try {
938
968
  const value = (await cache.coldSnapshot(id)).values[BILLING_UNIT_KEY];
@@ -947,7 +977,7 @@ var TodaySpendScanner = class {
947
977
  try {
948
978
  const inspection = await persistenceService.inspect(id);
949
979
  return {
950
- value: foldOwnBilling(unit, inspection.events, seedLength),
980
+ value: foldOwnBilling(this.deps.unit, inspection.events, forkBoundaryOf(inspection)),
951
981
  title: foldSessionTitle(inspection.events)
952
982
  };
953
983
  } catch (error) {
@@ -992,8 +1022,8 @@ var TodaySpendScanner = class {
992
1022
  const store = sessions();
993
1023
  if (store !== void 0) for (const session of store.list()) {
994
1024
  liveIds.add(session.id);
995
- const seedLength = forkBoundaryOf(session.header);
996
- const state = seedLength > 0 ? this.ownBillingState(session.id, session.events, seedLength) : projectionsService?.stateOf(session, BILLING_UNIT_KEY);
1025
+ const seedLength = forkBoundaryOf(session);
1026
+ const state = seedLength > 0 ? this.ownBillingState(session.id, liveSessionEvents(session), seedLength) : projectionsService?.stateOf(session, BILLING_UNIT_KEY);
997
1027
  if (state !== void 0 && state.dayKey === dayKey) total = mergeTodaySpend(total, state.spend);
998
1028
  }
999
1029
  }
@@ -1003,7 +1033,7 @@ var TodaySpendScanner = class {
1003
1033
  const pending = [];
1004
1034
  for (const { header, revision } of snapshots) {
1005
1035
  if (liveIds.has(header.id)) continue;
1006
- const seedLength = forkBoundaryOf(header);
1036
+ const seeded = isSeededSession(header);
1007
1037
  const resolved = this.coldResolved.get(header.id);
1008
1038
  if (resolved !== void 0 && resolved.revision === revision) {
1009
1039
  if (resolved.value.dayKey === dayKey) total = mergeTodaySpend(total, resolved.value.spend);
@@ -1012,11 +1042,11 @@ var TodaySpendScanner = class {
1012
1042
  pending.push({
1013
1043
  id: header.id,
1014
1044
  revision,
1015
- seedLength
1045
+ seeded
1016
1046
  });
1017
1047
  }
1018
- await withConcurrency(pending, 8, async ({ id, revision, seedLength }) => {
1019
- const resolved = await this.resolveCold(id, seedLength);
1048
+ await withConcurrency(pending, 8, async ({ id, revision, seeded }) => {
1049
+ const resolved = await this.resolveCold(id, seeded);
1020
1050
  if (resolved !== void 0) this.coldResolved.set(id, {
1021
1051
  revision,
1022
1052
  ...resolved
@@ -1058,7 +1088,7 @@ var TodaySpendScanner = class {
1058
1088
  const store = sessions();
1059
1089
  if (store !== void 0) for (const session of store.list()) {
1060
1090
  liveIds.add(session.id);
1061
- collect(session.events, forkBoundaryOf(session.header));
1091
+ collect(liveSessionEvents(session), forkBoundaryOf(session));
1062
1092
  if (truncated) break;
1063
1093
  }
1064
1094
  }
@@ -1070,7 +1100,7 @@ var TodaySpendScanner = class {
1070
1100
  if (this.lastEventsScan?.get(header.id) === revision) continue;
1071
1101
  try {
1072
1102
  const inspection = await persistenceService.inspect(header.id);
1073
- collect(inspection.events, forkBoundaryOf(inspection.meta));
1103
+ collect(inspection.events, forkBoundaryOf(inspection));
1074
1104
  } catch (error) {
1075
1105
  logger.warn(`llm-billing: skipping unreadable session ${header.id}: ${String(error)}`);
1076
1106
  }
@@ -1099,11 +1129,12 @@ var TodaySpendScanner = class {
1099
1129
  const store = sessions();
1100
1130
  if (store !== void 0) for (const session of store.list()) {
1101
1131
  liveIds.add(session.id);
1102
- const seedLength = forkBoundaryOf(session.header);
1103
- const state = seedLength > 0 ? this.ownBillingState(session.id, session.events, seedLength) : projectionsService?.stateOf(session, BILLING_UNIT_KEY);
1132
+ const seedLength = forkBoundaryOf(session);
1133
+ const events = liveSessionEvents(session);
1134
+ const state = seedLength > 0 ? this.ownBillingState(session.id, events, seedLength) : projectionsService?.stateOf(session, BILLING_UNIT_KEY);
1104
1135
  if (state !== void 0 && state.dayKey === dayKey) rows.set(session.id, {
1105
1136
  sessionId: session.id,
1106
- title: foldSessionTitle(session.events),
1137
+ title: foldSessionTitle(events),
1107
1138
  total: state.spend.total
1108
1139
  });
1109
1140
  }
@@ -1114,7 +1145,7 @@ var TodaySpendScanner = class {
1114
1145
  const pending = [];
1115
1146
  for (const { header, revision } of snapshots) {
1116
1147
  if (liveIds.has(header.id)) continue;
1117
- const seedLength = forkBoundaryOf(header);
1148
+ const seeded = isSeededSession(header);
1118
1149
  const resolved = this.coldResolved.get(header.id);
1119
1150
  if (resolved !== void 0 && resolved.revision === revision) {
1120
1151
  if (resolved.value.dayKey === dayKey) rows.set(header.id, {
@@ -1127,11 +1158,11 @@ var TodaySpendScanner = class {
1127
1158
  pending.push({
1128
1159
  id: header.id,
1129
1160
  revision,
1130
- seedLength
1161
+ seeded
1131
1162
  });
1132
1163
  }
1133
- await withConcurrency(pending, 8, async ({ id, revision, seedLength }) => {
1134
- const resolved = await this.resolveCold(id, seedLength);
1164
+ await withConcurrency(pending, 8, async ({ id, revision, seeded }) => {
1165
+ const resolved = await this.resolveCold(id, seeded);
1135
1166
  if (resolved !== void 0) this.coldResolved.set(id, {
1136
1167
  revision,
1137
1168
  ...resolved
@@ -1190,7 +1221,7 @@ var TodaySpendScanner = class {
1190
1221
  const store = sessions();
1191
1222
  if (store !== void 0) for (const session of store.list()) {
1192
1223
  liveIds.add(session.id);
1193
- collect(session.id, session.events, forkBoundaryOf(session.header));
1224
+ collect(session.id, liveSessionEvents(session), forkBoundaryOf(session));
1194
1225
  if (truncated) break;
1195
1226
  }
1196
1227
  }
@@ -1202,7 +1233,7 @@ var TodaySpendScanner = class {
1202
1233
  if (this.lastEventsScan?.get(header.id) === revision) continue;
1203
1234
  try {
1204
1235
  const inspection = await persistenceService.inspect(header.id);
1205
- collect(header.id, inspection.events, forkBoundaryOf(inspection.meta));
1236
+ collect(header.id, inspection.events, forkBoundaryOf(inspection));
1206
1237
  } catch (error) {
1207
1238
  logger.warn(`llm-billing: skipping unreadable session ${header.id}: ${String(error)}`);
1208
1239
  }
@@ -1294,7 +1325,10 @@ const TODAY_SPEND_MAX_EVENTS = 2e5;
1294
1325
  /**
1295
1326
  * Read one session's event log and durable seed boundary: the live
1296
1327
  * SessionStore first, then the persistence backend for a flushed session
1297
- * (inspected directly by id — no header listing).
1328
+ * (inspected directly by id — no header listing). The live-session surface
1329
+ * is read structurally across both DSH runtime families — `Session.events`
1330
+ * (≤ 0.1.1-rc.2) or `Session.snapshotEvents()` + `Session.inheritedEventCount`
1331
+ * (0.1.2-alpha.4+) — via {@link liveSessionEvents} / {@link forkBoundaryOf}.
1298
1332
  * @param ctx - plugin context carrying the SessionStore and optional persistence.
1299
1333
  * @param sessionId - the session to read.
1300
1334
  * @returns the session's complete event log plus its inherited-prefix boundary.
@@ -1303,15 +1337,15 @@ const TODAY_SPEND_MAX_EVENTS = 2e5;
1303
1337
  async function sessionEvents(ctx, sessionId) {
1304
1338
  const live = ctx.get("sessions")?.get(sessionId);
1305
1339
  if (live !== void 0) return {
1306
- events: live.events,
1307
- seedLength: forkBoundaryOf(live.header)
1340
+ events: liveSessionEvents(live),
1341
+ seedLength: forkBoundaryOf(live)
1308
1342
  };
1309
1343
  const persistence = ctx.get("sessionPersistence");
1310
1344
  if (persistence !== void 0) try {
1311
1345
  const inspection = await persistence.inspect(sessionId);
1312
1346
  return {
1313
1347
  events: inspection.events,
1314
- seedLength: forkBoundaryOf(inspection.meta)
1348
+ seedLength: forkBoundaryOf(inspection)
1315
1349
  };
1316
1350
  } catch (error) {
1317
1351
  throw new LlmError(`llm-billing: session ${sessionId} not found`, "NOT_FOUND", { cause: error });
@@ -1406,4 +1440,4 @@ function apply(ctx, config) {
1406
1440
  });
1407
1441
  }
1408
1442
  //#endregion
1409
- export { BILLING_UNIT_KEY, Config, DEFAULT_MODEL_PRICING, DEFAULT_PEAK_HOURS, DeepSeekBalanceGateway, PUBLIC_BASE_URL, SpendAccumulator, TODAY_SPEND_CACHE_MS, TODAY_SPEND_MAX_EVENTS, TodaySpendCache, TodaySpendScanner, addEventContribution, apply, beijingDayKey, billingTodaySpendDefinition, computeSessionSpend, computeTodaySpend, computeTurnSpend, emptyTodaySpend, fetchDeepSeekBalance, foldBillingUnit, foldOwnBilling, foldSessionTitle, forkBoundaryOf, isPeak, mergeTodaySpend, name, parseDeepSeekBalance, priceEvent, resolveBilling };
1443
+ export { BILLING_UNIT_KEY, Config, DEFAULT_MODEL_PRICING, DEFAULT_PEAK_HOURS, DeepSeekBalanceGateway, PUBLIC_BASE_URL, SpendAccumulator, TODAY_SPEND_CACHE_MS, TODAY_SPEND_MAX_EVENTS, TodaySpendCache, TodaySpendScanner, addEventContribution, apply, beijingDayKey, billingTodaySpendDefinition, computeSessionSpend, computeTodaySpend, computeTurnSpend, emptyTodaySpend, fetchDeepSeekBalance, foldBillingUnit, foldOwnBilling, foldSessionTitle, forkBoundaryOf, isPeak, isSeededSession, liveSessionEvents, mergeTodaySpend, name, parseDeepSeekBalance, priceEvent, resolveBilling };
@@ -88,21 +88,52 @@ export declare function resolveBilling(config: BillingConfig | undefined): Resol
88
88
  /** The Beijing (Asia/Shanghai, UTC+8, no DST) calendar-day key of a timestamp. */
89
89
  export declare function beijingDayKey(now: Date): string;
90
90
  /**
91
- * The durable inherited-prefix boundary of one session: its header's
92
- * `seedLength`, or 0 for a session created without a seed. A forked session
93
- * (or any seeded replay) carries the number of events it inherited verbatim
94
- * from its source session in its header; every event with `seq < seedLength`
95
- * is a copy of an event already billed in that source session, so pricing
96
- * must skip them or the same model output is counted once per copy.
97
- * The value is the PERSISTED header field (not the in-memory constructor
98
- * seed length), so a resumed fork child keeps its original boundary and a
99
- * resume of an unseeded session stays at 0.
100
- * @param header - the session's durable header, when available.
91
+ * Structural source of a session's durable inherited-prefix boundary. The
92
+ * field moved between DSH runtimes:
93
+ *
94
+ * - since 0.1.2-alpha.4, `SessionHeader.seedLength` was removed (the header
95
+ * now carries only `isSeeded`: boolean) and the exact cut moved to
96
+ * `Session.inheritedEventCount` / `SessionInspection.inheritedEventCount`;
97
+ * - at and before the 0.1.1-rc.2 npm baseline, the cut lived on the durable
98
+ * header as `seedLength` (optional; absent for an unseeded session) and on
99
+ * the persistence `inspect` result as `meta.seedLength`.
100
+ *
101
+ * The reader accepts any of the three shapes and prefers the newer,
102
+ * exact-count field, so the same plugin code prices correctly on both
103
+ * families of runtime.
104
+ */
105
+ export interface ForkBoundarySource {
106
+ /** 0.1.2-alpha.4+: `Session.inheritedEventCount` / `SessionInspection.inheritedEventCount`. */
107
+ readonly inheritedEventCount?: number;
108
+ /** 0.1.1-rc.2 and earlier: `SessionHeader.seedLength`. */
109
+ readonly seedLength?: number;
110
+ /** ≤ 0.1.1-rc.2: the header slice of a `Session` (its `header.seedLength`). */
111
+ readonly header?: {
112
+ readonly seedLength?: number;
113
+ };
114
+ /** ≤ 0.1.1-rc.2: the `meta` of a persistence `inspect` result (its `seedLength`). */
115
+ readonly meta?: {
116
+ readonly seedLength?: number;
117
+ };
118
+ }
119
+ /**
120
+ * The durable inherited-prefix boundary of one session: the number of leading
121
+ * events it inherited verbatim from its fork source, 0 for a session created
122
+ * without a seed. A forked session (or any seeded replay) carries that count
123
+ * in its session state; every event with `seq < seedLength` is a copy of an
124
+ * event already billed in that source session, so pricing must skip them or
125
+ * the same model output is counted once per copy. Accepts the durable field
126
+ * of both DSH runtime families (see {@link ForkBoundarySource}).
127
+ * @param source - the session, inspection result, header slice, or durable
128
+ * header carrying the boundary; `undefined` reads as 0.
101
129
  * @returns the inherited-prefix length; 0 for an unseeded session.
102
130
  */
103
- export declare function forkBoundaryOf(header: {
131
+ export declare function forkBoundaryOf(source: ForkBoundarySource | undefined): number;
132
+ /** Whether a durable header marks a fork-inherited (seeded) session across both runtime families. */
133
+ export declare function isSeededSession(header: {
104
134
  readonly seedLength?: number;
105
- } | undefined): number;
135
+ readonly isSeeded?: boolean;
136
+ } | undefined): boolean;
106
137
  /**
107
138
  * Whether a timestamp falls inside any peak-hour window (Beijing time,
108
139
  * weekdays Monday–Friday only). Weekends (Saturday and Sunday) are always
@@ -81,20 +81,32 @@ export function beijingDayKey(now) {
81
81
  return beijingParts(now.getTime()).dayKey;
82
82
  }
83
83
  /**
84
- * The durable inherited-prefix boundary of one session: its header's
85
- * `seedLength`, or 0 for a session created without a seed. A forked session
86
- * (or any seeded replay) carries the number of events it inherited verbatim
87
- * from its source session in its header; every event with `seq < seedLength`
88
- * is a copy of an event already billed in that source session, so pricing
89
- * must skip them or the same model output is counted once per copy.
90
- * The value is the PERSISTED header field (not the in-memory constructor
91
- * seed length), so a resumed fork child keeps its original boundary and a
92
- * resume of an unseeded session stays at 0.
93
- * @param header - the session's durable header, when available.
84
+ * The durable inherited-prefix boundary of one session: the number of leading
85
+ * events it inherited verbatim from its fork source, 0 for a session created
86
+ * without a seed. A forked session (or any seeded replay) carries that count
87
+ * in its session state; every event with `seq < seedLength` is a copy of an
88
+ * event already billed in that source session, so pricing must skip them or
89
+ * the same model output is counted once per copy. Accepts the durable field
90
+ * of both DSH runtime families (see {@link ForkBoundarySource}).
91
+ * @param source - the session, inspection result, header slice, or durable
92
+ * header carrying the boundary; `undefined` reads as 0.
94
93
  * @returns the inherited-prefix length; 0 for an unseeded session.
95
94
  */
96
- export function forkBoundaryOf(header) {
97
- return header?.seedLength ?? 0;
95
+ export function forkBoundaryOf(source) {
96
+ if (source === undefined)
97
+ return 0;
98
+ const inherited = source.inheritedEventCount;
99
+ if (inherited !== undefined && Number.isSafeInteger(inherited))
100
+ return inherited;
101
+ return source.header?.seedLength ?? source.meta?.seedLength ?? source.seedLength ?? 0;
102
+ }
103
+ /** Whether a durable header marks a fork-inherited (seeded) session across both runtime families. */
104
+ export function isSeededSession(header) {
105
+ if (header === undefined)
106
+ return false;
107
+ if (header.isSeeded === true)
108
+ return true;
109
+ return (header.seedLength ?? 0) > 0;
98
110
  }
99
111
  /** Whether a Beijing (hour, weekday) pair falls inside any peak-hour window. */
100
112
  function isPeakParts(billing, hour, weekday) {
@@ -24,12 +24,12 @@ 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, mergeTodaySpend, priceEvent, resolveBilling, SpendAccumulator, } from './billing.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
28
  export type { BillingConfig, BillingConfigModel, BillingEventContribution, DeepSeekModelPricing, 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 { BillingUnitState } from './projection.ts';
32
- export { foldSessionTitle, TodaySpendCache, TodaySpendScanner } from './today-spend.ts';
32
+ export { foldSessionTitle, liveSessionEvents, TodaySpendCache, TodaySpendScanner } from './today-spend.ts';
33
33
  export type { ScannerPersistedHeader, ScannerSession, TodaySpendScannerDeps } from './today-spend.ts';
34
34
  export declare const name = "llm-billing";
35
35
  /** Public API default; deployments may point elsewhere via $DEEPSEEK_BASE_URL. */
@@ -27,11 +27,11 @@ import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment';
27
27
  import { DeepSeekBalanceGateway, fetchDeepSeekBalance } from "./balance.js";
28
28
  import { computeSessionSpend, computeTurnSpend, DEFAULT_MODEL_PRICING, DEFAULT_PEAK_HOURS, forkBoundaryOf, mergeTodaySpend, resolveBilling, } from "./billing.js";
29
29
  import { billingTodaySpendDefinition } from "./projection.js";
30
- import { TodaySpendCache, TodaySpendScanner } from "./today-spend.js";
30
+ import { liveSessionEvents, 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, mergeTodaySpend, priceEvent, resolveBilling, SpendAccumulator, } from "./billing.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";
33
33
  export { BILLING_UNIT_KEY, billingTodaySpendDefinition, foldBillingUnit, foldOwnBilling } from "./projection.js";
34
- export { foldSessionTitle, TodaySpendCache, TodaySpendScanner } from "./today-spend.js";
34
+ export { foldSessionTitle, liveSessionEvents, TodaySpendCache, TodaySpendScanner } from "./today-spend.js";
35
35
  export const name = 'llm-billing';
36
36
  const DEFAULT_API_KEY_ENV = 'DEEPSEEK_API_KEY';
37
37
  const BASE_URL_ENV = 'DEEPSEEK_BASE_URL';
@@ -75,7 +75,10 @@ export const TODAY_SPEND_MAX_EVENTS = 200_000;
75
75
  /**
76
76
  * Read one session's event log and durable seed boundary: the live
77
77
  * SessionStore first, then the persistence backend for a flushed session
78
- * (inspected directly by id — no header listing).
78
+ * (inspected directly by id — no header listing). The live-session surface
79
+ * is read structurally across both DSH runtime families — `Session.events`
80
+ * (≤ 0.1.1-rc.2) or `Session.snapshotEvents()` + `Session.inheritedEventCount`
81
+ * (0.1.2-alpha.4+) — via {@link liveSessionEvents} / {@link forkBoundaryOf}.
79
82
  * @param ctx - plugin context carrying the SessionStore and optional persistence.
80
83
  * @param sessionId - the session to read.
81
84
  * @returns the session's complete event log plus its inherited-prefix boundary.
@@ -84,13 +87,14 @@ export const TODAY_SPEND_MAX_EVENTS = 200_000;
84
87
  async function sessionEvents(ctx, sessionId) {
85
88
  const sessions = ctx.get('sessions');
86
89
  const live = sessions?.get(sessionId);
87
- if (live !== undefined)
88
- return { events: live.events, seedLength: forkBoundaryOf(live.header) };
90
+ if (live !== undefined) {
91
+ return { events: liveSessionEvents(live), seedLength: forkBoundaryOf(live) };
92
+ }
89
93
  const persistence = ctx.get('sessionPersistence');
90
94
  if (persistence !== undefined) {
91
95
  try {
92
96
  const inspection = await persistence.inspect(sessionId);
93
- return { events: inspection.events, seedLength: forkBoundaryOf(inspection.meta) };
97
+ return { events: inspection.events, seedLength: forkBoundaryOf(inspection) };
94
98
  }
95
99
  catch (error) {
96
100
  throw new LlmError(`llm-billing: session ${sessionId} not found`, 'NOT_FOUND', { cause: error });
@@ -20,14 +20,22 @@
20
20
  * unchanged log provably cannot change the aggregate.
21
21
  *
22
22
  * Forked sessions never double-count: a fork child's log opens with a
23
- * verbatim copy of its source session's events (`header.seedLength` of them),
24
- * so the scanner prices only the child's OWN events (`seq >= seedLength`) on
25
- * every path — the projection path bypasses the eager cell for a seeded
26
- * session and folds its own events instead (the cell covers the inherited
27
- * prefix too), and the cold ladder skips the projection cache for a seeded
28
- * session (its cached row predates the boundary and covers inherited events).
29
- * The boundary is the durable session header, so a resumed fork child keeps
30
- * its original boundary and an unseeded session stays at 0.
23
+ * verbatim copy of its source session's events (its inherited boundary),
24
+ * so the scanner prices only the child's OWN events on every path — the
25
+ * projection path bypasses the eager cell for a seeded session and folds its
26
+ * own events instead (the cell covers the inherited prefix too), and the cold
27
+ * ladder skips the projection cache for a seeded session (its cached row
28
+ * predates the boundary and covers inherited events). The boundary is the
29
+ * durable session state, read across both DSH runtime families — a resumed
30
+ * fork child keeps its original boundary and an unseeded session stays at 0.
31
+ *
32
+ * The live `Session` log surface changed in 0.1.2-alpha.4: `Session.events`
33
+ * was removed and replaced by `Session.snapshotEvents()` / `ownEvents()`, and
34
+ * `SessionHeader.seedLength` moved to `Session.inheritedEventCount` (the
35
+ * persistence `inspect` result carries it alongside `meta`). Reads go through
36
+ * {@link liveSessionEvents} / {@link forkBoundaryOf}, which accept both
37
+ * families structurally, so the scanner runs on the ≤ 0.1.1-rc.2 npm baseline
38
+ * and on the newer runtime.
31
39
  * @module @rayadesu/dsh-llm-billing/today-spend
32
40
  */
33
41
  import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session';
@@ -45,20 +53,40 @@ import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection';
45
53
  * @returns the session's current title, or `null` when untitled.
46
54
  */
47
55
  export declare function foldSessionTitle(events: readonly SessionEvent[]): string | null;
48
- /** Structural slice of a live session the scanner reads. */
56
+ /**
57
+ * Structural slice of a live session the scanner reads, accepting both DSH
58
+ * runtime families: the ≤ 0.1.1-rc.2 baseline exposes the log as
59
+ * `events` (+ `header.seedLength`), while 0.1.2-alpha.4+ exposes
60
+ * `snapshotEvents()` and `inheritedEventCount` (and dropped the header field).
61
+ */
49
62
  export interface ScannerSession {
50
63
  readonly id: SessionId;
51
- readonly events: readonly SessionEvent[];
52
- /** Durable header slice; `seedLength` marks a fork child's inherited prefix. */
64
+ /** ≤ 0.1.1-rc.2: the full event log snapshot. */
65
+ readonly events?: readonly SessionEvent[];
66
+ /** 0.1.2-alpha.4+: materialize an immutable log snapshot; no args = the full current log. */
67
+ readonly snapshotEvents?: (fromSeq?: number, toSeqExclusive?: number) => readonly SessionEvent[];
68
+ /** 0.1.2-alpha.4+: the durable inherited-prefix length (0 for an unseeded session). */
69
+ readonly inheritedEventCount?: number;
70
+ /** Durable header slice: `seedLength` (older runtime) or `isSeeded` (newer runtime). */
53
71
  readonly header?: {
54
72
  readonly seedLength?: number;
73
+ readonly isSeeded?: boolean;
55
74
  };
56
75
  }
76
+ /**
77
+ * Read one live session's complete event log across both runtime families.
78
+ * @throws when the session exposes neither the legacy `events` snapshot nor
79
+ * the newer `snapshotEvents()` reader — an unknown runtime surface must
80
+ * fail loudly rather than silently price an empty log.
81
+ */
82
+ export declare function liveSessionEvents(session: ScannerSession): readonly SessionEvent[];
57
83
  /** Structural slice of a listed persisted session (the snapshot header is a full SessionHeader). */
58
84
  export interface ScannerPersistedHeader {
59
85
  readonly id: SessionId;
60
- /** Durable fork boundary carried by the snapshot header; absent for an unseeded session. */
86
+ /** 0.1.1-rc.2: the durable fork boundary carried by the snapshot header; absent for an unseeded session. */
61
87
  readonly seedLength?: number;
88
+ /** 0.1.2-alpha.4+: whether the session has a fork-inherited prefix (exact cut arrives with the inspect result). */
89
+ readonly isSeeded?: boolean;
62
90
  }
63
91
  /** Structural slices of the optional services the scanner reads through. */
64
92
  export interface TodaySpendScannerDeps {
@@ -73,9 +101,12 @@ export interface TodaySpendScannerDeps {
73
101
  revision: SessionPersistenceRevision;
74
102
  }[]>;
75
103
  inspect(id: SessionId): Promise<{
76
- meta: {
77
- seedLength?: number;
104
+ meta?: {
105
+ readonly seedLength?: number;
106
+ readonly isSeeded?: boolean;
78
107
  };
108
+ /** 0.1.2-alpha.4+: the exact inherited cut travels beside, not inside, the header. */
109
+ inheritedEventCount?: number;
79
110
  events: readonly SessionEvent[];
80
111
  }>;
81
112
  } | undefined;
@@ -179,9 +210,12 @@ export declare class TodaySpendScanner {
179
210
  * until the session is inspected again. A SEEDED session (fork child)
180
211
  * skips the ladder entirely: its cached row was folded over the inherited
181
212
  * prefix too, so it always detaches through inspect with the durable
182
- * boundary applied to the local fold.
213
+ * boundary (the inspect result's inherited count or `meta.seedLength`,
214
+ * depending on the runtime family) applied to the local fold.
183
215
  * @param id - the cold session's id.
184
- * @param seedLength - the durable inherited-prefix boundary (0 for unseeded).
216
+ * @param seeded - whether the session carries a fork-inherited prefix
217
+ * (from the snapshot header: `isSeeded` on 0.1.2-alpha.4+, `seedLength`
218
+ * at and before the 0.1.1-rc.2 baseline).
185
219
  * @returns the resolved state and title, or `undefined` when unreadable.
186
220
  */
187
221
  private resolveCold;
@@ -20,17 +20,25 @@
20
20
  * unchanged log provably cannot change the aggregate.
21
21
  *
22
22
  * Forked sessions never double-count: a fork child's log opens with a
23
- * verbatim copy of its source session's events (`header.seedLength` of them),
24
- * so the scanner prices only the child's OWN events (`seq >= seedLength`) on
25
- * every path — the projection path bypasses the eager cell for a seeded
26
- * session and folds its own events instead (the cell covers the inherited
27
- * prefix too), and the cold ladder skips the projection cache for a seeded
28
- * session (its cached row predates the boundary and covers inherited events).
29
- * The boundary is the durable session header, so a resumed fork child keeps
30
- * its original boundary and an unseeded session stays at 0.
23
+ * verbatim copy of its source session's events (its inherited boundary),
24
+ * so the scanner prices only the child's OWN events on every path — the
25
+ * projection path bypasses the eager cell for a seeded session and folds its
26
+ * own events instead (the cell covers the inherited prefix too), and the cold
27
+ * ladder skips the projection cache for a seeded session (its cached row
28
+ * predates the boundary and covers inherited events). The boundary is the
29
+ * durable session state, read across both DSH runtime families — a resumed
30
+ * fork child keeps its original boundary and an unseeded session stays at 0.
31
+ *
32
+ * The live `Session` log surface changed in 0.1.2-alpha.4: `Session.events`
33
+ * was removed and replaced by `Session.snapshotEvents()` / `ownEvents()`, and
34
+ * `SessionHeader.seedLength` moved to `Session.inheritedEventCount` (the
35
+ * persistence `inspect` result carries it alongside `meta`). Reads go through
36
+ * {@link liveSessionEvents} / {@link forkBoundaryOf}, which accept both
37
+ * families structurally, so the scanner runs on the ≤ 0.1.1-rc.2 npm baseline
38
+ * and on the newer runtime.
31
39
  * @module @rayadesu/dsh-llm-billing/today-spend
32
40
  */
33
- import { beijingDayKey, emptyTodaySpend, forkBoundaryOf, mergeTodaySpend, priceEvent, SpendAccumulator } from "./billing.js";
41
+ import { beijingDayKey, emptyTodaySpend, forkBoundaryOf, isSeededSession, mergeTodaySpend, priceEvent, SpendAccumulator } from "./billing.js";
34
42
  import { BILLING_UNIT_KEY, foldOwnBilling } from "./projection.js";
35
43
  /**
36
44
  * Fold one session's durable display title: the latest `session/title`
@@ -53,6 +61,20 @@ export function foldSessionTitle(events) {
53
61
  }
54
62
  return null;
55
63
  }
64
+ /**
65
+ * Read one live session's complete event log across both runtime families.
66
+ * @throws when the session exposes neither the legacy `events` snapshot nor
67
+ * the newer `snapshotEvents()` reader — an unknown runtime surface must
68
+ * fail loudly rather than silently price an empty log.
69
+ */
70
+ export function liveSessionEvents(session) {
71
+ if (session.events !== undefined)
72
+ return session.events;
73
+ // Optional-call form keeps the receiver bound (snapshotEvents uses `this`).
74
+ if (session.snapshotEvents !== undefined)
75
+ return session.snapshotEvents();
76
+ throw new Error('llm-billing: session log surface is neither Session.events nor Session.snapshotEvents');
77
+ }
56
78
  /**
57
79
  * Bounded parallel fan-out: run `run` over `items` with at most `limit` in
58
80
  * flight. A shared index counter hands each worker its next job, so the
@@ -176,17 +198,20 @@ export class TodaySpendScanner {
176
198
  * until the session is inspected again. A SEEDED session (fork child)
177
199
  * skips the ladder entirely: its cached row was folded over the inherited
178
200
  * prefix too, so it always detaches through inspect with the durable
179
- * boundary applied to the local fold.
201
+ * boundary (the inspect result's inherited count or `meta.seedLength`,
202
+ * depending on the runtime family) applied to the local fold.
180
203
  * @param id - the cold session's id.
181
- * @param seedLength - the durable inherited-prefix boundary (0 for unseeded).
204
+ * @param seeded - whether the session carries a fork-inherited prefix
205
+ * (from the snapshot header: `isSeeded` on 0.1.2-alpha.4+, `seedLength`
206
+ * at and before the 0.1.1-rc.2 baseline).
182
207
  * @returns the resolved state and title, or `undefined` when unreadable.
183
208
  */
184
- async resolveCold(id, seedLength) {
185
- const { persistence, projectionCache, unit, logger } = this.deps;
209
+ async resolveCold(id, seeded) {
210
+ const { persistence, projectionCache, logger } = this.deps;
186
211
  const persistenceService = persistence?.();
187
212
  if (persistenceService === undefined)
188
213
  return undefined;
189
- if (seedLength <= 0) {
214
+ if (!seeded) {
190
215
  const cache = projectionCache?.();
191
216
  if (cache !== undefined) {
192
217
  try {
@@ -201,7 +226,10 @@ export class TodaySpendScanner {
201
226
  }
202
227
  try {
203
228
  const inspection = await persistenceService.inspect(id);
204
- return { value: foldOwnBilling(unit, inspection.events, seedLength), title: foldSessionTitle(inspection.events) };
229
+ return {
230
+ value: foldOwnBilling(this.deps.unit, inspection.events, forkBoundaryOf(inspection)),
231
+ title: foldSessionTitle(inspection.events),
232
+ };
205
233
  }
206
234
  catch (error) {
207
235
  // One unreadable session must not blank the whole-day aggregate.
@@ -250,11 +278,11 @@ export class TodaySpendScanner {
250
278
  if (store !== undefined) {
251
279
  for (const session of store.list()) {
252
280
  liveIds.add(session.id);
253
- const seedLength = forkBoundaryOf(session.header);
281
+ const seedLength = forkBoundaryOf(session);
254
282
  // A fork child's eager cell covers its inherited prefix too; price
255
283
  // its own events directly instead.
256
284
  const state = seedLength > 0
257
- ? this.ownBillingState(session.id, session.events, seedLength)
285
+ ? this.ownBillingState(session.id, liveSessionEvents(session), seedLength)
258
286
  : projectionsService?.stateOf(session, BILLING_UNIT_KEY);
259
287
  if (state !== undefined && state.dayKey === dayKey) {
260
288
  total = mergeTodaySpend(total, state.spend);
@@ -270,17 +298,17 @@ export class TodaySpendScanner {
270
298
  for (const { header, revision } of snapshots) {
271
299
  if (liveIds.has(header.id))
272
300
  continue;
273
- const seedLength = forkBoundaryOf(header);
301
+ const seeded = isSeededSession(header);
274
302
  const resolved = this.coldResolved.get(header.id);
275
303
  if (resolved !== undefined && resolved.revision === revision) {
276
304
  if (resolved.value.dayKey === dayKey)
277
305
  total = mergeTodaySpend(total, resolved.value.spend);
278
306
  continue;
279
307
  }
280
- pending.push({ id: header.id, revision, seedLength });
308
+ pending.push({ id: header.id, revision, seeded });
281
309
  }
282
- await withConcurrency(pending, 8, async ({ id, revision, seedLength }) => {
283
- const resolved = await this.resolveCold(id, seedLength);
310
+ await withConcurrency(pending, 8, async ({ id, revision, seeded }) => {
311
+ const resolved = await this.resolveCold(id, seeded);
284
312
  if (resolved !== undefined)
285
313
  this.coldResolved.set(id, { revision, ...resolved });
286
314
  });
@@ -326,7 +354,7 @@ export class TodaySpendScanner {
326
354
  if (store !== undefined) {
327
355
  for (const session of store.list()) {
328
356
  liveIds.add(session.id);
329
- collect(session.events, forkBoundaryOf(session.header));
357
+ collect(liveSessionEvents(session), forkBoundaryOf(session));
330
358
  if (truncated)
331
359
  break;
332
360
  }
@@ -342,7 +370,7 @@ export class TodaySpendScanner {
342
370
  continue;
343
371
  try {
344
372
  const inspection = await persistenceService.inspect(header.id);
345
- collect(inspection.events, forkBoundaryOf(inspection.meta));
373
+ collect(inspection.events, forkBoundaryOf(inspection));
346
374
  }
347
375
  catch (error) {
348
376
  // One unreadable session must not blank the whole-day aggregate.
@@ -381,14 +409,15 @@ export class TodaySpendScanner {
381
409
  if (store !== undefined) {
382
410
  for (const session of store.list()) {
383
411
  liveIds.add(session.id);
384
- const seedLength = forkBoundaryOf(session.header);
412
+ const seedLength = forkBoundaryOf(session);
413
+ const events = liveSessionEvents(session);
385
414
  const state = seedLength > 0
386
- ? this.ownBillingState(session.id, session.events, seedLength)
415
+ ? this.ownBillingState(session.id, events, seedLength)
387
416
  : projectionsService?.stateOf(session, BILLING_UNIT_KEY);
388
417
  if (state !== undefined && state.dayKey === dayKey) {
389
418
  rows.set(session.id, {
390
419
  sessionId: session.id,
391
- title: foldSessionTitle(session.events),
420
+ title: foldSessionTitle(events),
392
421
  total: state.spend.total,
393
422
  });
394
423
  }
@@ -403,7 +432,7 @@ export class TodaySpendScanner {
403
432
  for (const { header, revision } of snapshots) {
404
433
  if (liveIds.has(header.id))
405
434
  continue;
406
- const seedLength = forkBoundaryOf(header);
435
+ const seeded = isSeededSession(header);
407
436
  const resolved = this.coldResolved.get(header.id);
408
437
  if (resolved !== undefined && resolved.revision === revision) {
409
438
  if (resolved.value.dayKey === dayKey) {
@@ -411,10 +440,10 @@ export class TodaySpendScanner {
411
440
  }
412
441
  continue;
413
442
  }
414
- pending.push({ id: header.id, revision, seedLength });
443
+ pending.push({ id: header.id, revision, seeded });
415
444
  }
416
- await withConcurrency(pending, 8, async ({ id, revision, seedLength }) => {
417
- const resolved = await this.resolveCold(id, seedLength);
445
+ await withConcurrency(pending, 8, async ({ id, revision, seeded }) => {
446
+ const resolved = await this.resolveCold(id, seeded);
418
447
  if (resolved !== undefined)
419
448
  this.coldResolved.set(id, { revision, ...resolved });
420
449
  });
@@ -470,7 +499,7 @@ export class TodaySpendScanner {
470
499
  if (store !== undefined) {
471
500
  for (const session of store.list()) {
472
501
  liveIds.add(session.id);
473
- collect(session.id, session.events, forkBoundaryOf(session.header));
502
+ collect(session.id, liveSessionEvents(session), forkBoundaryOf(session));
474
503
  if (truncated)
475
504
  break;
476
505
  }
@@ -486,7 +515,7 @@ export class TodaySpendScanner {
486
515
  continue;
487
516
  try {
488
517
  const inspection = await persistenceService.inspect(header.id);
489
- collect(header.id, inspection.events, forkBoundaryOf(inspection.meta));
518
+ collect(header.id, inspection.events, forkBoundaryOf(inspection));
490
519
  }
491
520
  catch (error) {
492
521
  // One unreadable session must not blank the whole-day aggregate.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rayadesu/dsh-llm-billing",
3
3
  "description": "Standalone DeepSeek account-balance and session-spend provider exposed through the billing Remote",
4
- "version": "0.3.4",
4
+ "version": "0.3.6",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },