@rayadesu/dsh-llm-billing 0.3.2 → 0.3.4

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: 9fc18328397e8824803ad7ed5335ee4b8a1063fe
6
- README.zh.md: 330b3ae531177a9b934137b4c2d1e32f949c16e0
5
+ README.md: ed1a2bc7ef768d789ae4e6ba55c92254104b3a14
6
+ README.zh.md: 3c84b79a1e1bc48fa43f8577bd81853f658a7087
package/README.md CHANGED
@@ -29,6 +29,10 @@ After the first resolution per process, steady-state reads cost only the session
29
29
 
30
30
  Note: the projection path prices a session's history once, at the rates in effect when its events were folded — changing `billing.models` re-prices only events folded after the change (the events path re-prices the whole log).
31
31
 
32
+ ## Forked sessions
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.
35
+
32
36
  ## Configuration
33
37
 
34
38
  | Field | Default | Meaning |
package/README.zh.md CHANGED
@@ -29,6 +29,10 @@
29
29
 
30
30
  注意:投影路径对每个会话的历史只计价一次,按事件被折叠时的费率——修改 `billing.models` 只影响变更后折叠的事件(事件路径会重算整个日志)。
31
31
 
32
+ ## 分叉会话
33
+
34
+ 分叉会话(DSH 的「分叉会话」)的日志以来源会话事件的逐字节副本开头。若不特殊处理,同一批模型输出会按副本数重复计费:子会话的会话花费会包含继承前缀,今日花费也会在父会话之外再计一次。插件只对会话的**自有事件**计费——持久的 `header.seedLength` 即分叉边界,凡是 `seq < seedLength` 的事件都视为已在来源会话计费。因此分叉子会话从分叉后的第一次新交流开始计费(刚分叉的会话花费为零),今日花费对每个模型输出只计一次,同一血缘规则同样覆盖多代分叉与 subagent 分叉(`context: 'fork'` 生成)。边界取自已持久化的 header 值,所以恢复后的分叉子会话保持原边界;而创建时没有 seed 的会话——包括普通会话与冷恢复——不带边界,正常全额计费。
35
+
32
36
  ## 配置
33
37
 
34
38
  | 字段 | 默认 | 含义 |
package/lib/index.js CHANGED
@@ -379,6 +379,22 @@ function beijingParts(time) {
379
379
  function beijingDayKey(now) {
380
380
  return beijingParts(now.getTime()).dayKey;
381
381
  }
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.
393
+ * @returns the inherited-prefix length; 0 for an unseeded session.
394
+ */
395
+ function forkBoundaryOf(header) {
396
+ return header?.seedLength ?? 0;
397
+ }
382
398
  /** Whether a Beijing (hour, weekday) pair falls inside any peak-hour window. */
383
399
  function isPeakParts(billing, hour, weekday) {
384
400
  if (weekday === 0 || weekday === 6) return false;
@@ -554,11 +570,14 @@ function mergeTodaySpend(target, source) {
554
570
  * @param billing - resolved pricing with peak-hour windows.
555
571
  * @param names - model id → display label.
556
572
  * @param dayKey - when provided, only events on this Beijing calendar day contribute.
573
+ * @param startSeq - when provided, only events with `seq >= startSeq` contribute
574
+ * (a forked session's inherited prefix, `seq < startSeq`, is skipped).
557
575
  * @returns the total cost plus one row per priced model.
558
576
  */
559
- function priceEvents(events, billing, names, dayKey) {
577
+ function priceEvents(events, billing, names, dayKey, startSeq = 0) {
560
578
  const accumulator = new SpendAccumulator();
561
579
  for (const event of events) {
580
+ if (event.seq < startSeq) continue;
562
581
  const priced = priceEvent(event, billing, names);
563
582
  if (priced === void 0 || dayKey !== void 0 && priced.dayKey !== dayKey) continue;
564
583
  accumulator.add(priced);
@@ -570,10 +589,14 @@ function priceEvents(events, billing, names, dayKey) {
570
589
  * @param events - one session's complete event log.
571
590
  * @param billing - resolved pricing with peak-hour windows.
572
591
  * @param catalog - model display rows, in presentation order.
592
+ * @param startSeq - when provided, only events with `seq >= startSeq`
593
+ * contribute: a forked session's inherited prefix (see {@link forkBoundaryOf})
594
+ * is skipped, so each model output is billed only in the session that
595
+ * produced it.
573
596
  * @returns the session's total cost plus one row per priced model.
574
597
  */
575
- function computeSessionSpend(events, billing, catalog) {
576
- return priceEvents(events, billing, new Map(catalog.map((model) => [model.id, model.name])));
598
+ function computeSessionSpend(events, billing, catalog, startSeq = 0) {
599
+ return priceEvents(events, billing, new Map(catalog.map((model) => [model.id, model.name])), void 0, startSeq);
577
600
  }
578
601
  /**
579
602
  * Price one completed Turn's billed usage at the official per-model rates,
@@ -715,6 +738,26 @@ function foldBillingUnit(unit, events) {
715
738
  for (const event of events) state = unit.apply(state, event);
716
739
  return state;
717
740
  }
741
+ /**
742
+ * Fold a unit from init over one session's OWN events only: the complete log
743
+ * minus its inherited fork prefix (`seq < seedLength`). A forked child's
744
+ * prefix is a verbatim copy of events already billed in its source session,
745
+ * so the detached cold recipe must skip it, or the same model output is
746
+ * priced once per copy.
747
+ * @param unit - the billing unit's fold halves.
748
+ * @param events - the session's complete event log (in seq order).
749
+ * @param seedLength - the durable inherited-prefix boundary
750
+ * ({@link forkBoundaryOf}); 0 for an unseeded session.
751
+ * @returns the unit state folded over the session's own events.
752
+ */
753
+ function foldOwnBilling(unit, events, seedLength = 0) {
754
+ let state = unit.init();
755
+ for (const event of events) {
756
+ if (event.seq < seedLength) continue;
757
+ state = unit.apply(state, event);
758
+ }
759
+ return state;
760
+ }
718
761
  //#endregion
719
762
  //#region lib/types/today-spend.js
720
763
  /**
@@ -737,6 +780,16 @@ function foldBillingUnit(unit, events) {
737
780
  * happens at most once per 60 seconds per process, and a manual refresh
738
781
  * (`force`) bypasses the time window but keeps the revision caches — an
739
782
  * unchanged log provably cannot change the aggregate.
783
+ *
784
+ * 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.
740
793
  * @module @rayadesu/dsh-llm-billing/today-spend
741
794
  */
742
795
  /**
@@ -835,6 +888,8 @@ var TodaySpendScanner = class {
835
888
  coldResolved = /* @__PURE__ */ new Map();
836
889
  /** Cold sessions resolved on the events path: id → revision (events were collected). */
837
890
  lastEventsScan;
891
+ /** Live fork children priced on the projection path: id → own-events count + folded state. */
892
+ ownStates = /* @__PURE__ */ new Map();
838
893
  constructor(deps) {
839
894
  this.deps = deps;
840
895
  }
@@ -865,28 +920,34 @@ var TodaySpendScanner = class {
865
920
  * the projection-cache ladder (cached row first, then a detached local
866
921
  * fold over a full inspect). A cache-served value carries no title (the
867
922
  * ladder only stores projection values), so such rows report `title: null`
868
- * until the session is inspected again.
923
+ * until the session is inspected again. A SEEDED session (fork child)
924
+ * skips the ladder entirely: its cached row was folded over the inherited
925
+ * prefix too, so it always detaches through inspect with the durable
926
+ * boundary applied to the local fold.
869
927
  * @param id - the cold session's id.
928
+ * @param seedLength - the durable inherited-prefix boundary (0 for unseeded).
870
929
  * @returns the resolved state and title, or `undefined` when unreadable.
871
930
  */
872
- async resolveCold(id) {
931
+ async resolveCold(id, seedLength) {
873
932
  const { persistence, projectionCache, unit, logger } = this.deps;
874
- const cache = projectionCache?.();
875
- if (cache !== void 0) try {
876
- const value = (await cache.coldSnapshot(id)).values[BILLING_UNIT_KEY];
877
- if (value !== void 0) return {
878
- value,
879
- title: null
880
- };
881
- } catch (error) {
882
- logger.warn(`llm-billing: projection cold read for session ${id} failed: ${String(error)}`);
883
- }
884
933
  const persistenceService = persistence?.();
885
934
  if (persistenceService === void 0) return void 0;
935
+ if (seedLength <= 0) {
936
+ const cache = projectionCache?.();
937
+ if (cache !== void 0) try {
938
+ const value = (await cache.coldSnapshot(id)).values[BILLING_UNIT_KEY];
939
+ if (value !== void 0) return {
940
+ value,
941
+ title: null
942
+ };
943
+ } catch (error) {
944
+ logger.warn(`llm-billing: projection cold read for session ${id} failed: ${String(error)}`);
945
+ }
946
+ }
886
947
  try {
887
948
  const inspection = await persistenceService.inspect(id);
888
949
  return {
889
- value: foldBillingUnit(unit, inspection.events),
950
+ value: foldOwnBilling(unit, inspection.events, seedLength),
890
951
  title: foldSessionTitle(inspection.events)
891
952
  };
892
953
  } catch (error) {
@@ -894,6 +955,33 @@ var TodaySpendScanner = class {
894
955
  return;
895
956
  }
896
957
  }
958
+ /**
959
+ * Fold one fork child's OWN events (its log minus the inherited prefix)
960
+ * with the billing unit, incrementally: the fold is reused while the log
961
+ * length is unchanged and only the new tail is applied when it grows.
962
+ * @param id - the session id (the own-state cache key).
963
+ * @param events - the session's complete log.
964
+ * @param seedLength - the inherited-prefix boundary.
965
+ * @returns the unit state over the session's own events.
966
+ */
967
+ ownBillingState(id, events, seedLength) {
968
+ const cached = this.ownStates.get(id);
969
+ const ownCount = events.length - seedLength;
970
+ if (cached !== void 0 && cached.count === ownCount) return cached.state;
971
+ let state;
972
+ if (cached !== void 0 && cached.count < ownCount) {
973
+ state = cached.state;
974
+ for (const event of events) {
975
+ if (event.seq < seedLength + cached.count) continue;
976
+ state = this.deps.unit.apply(state, event);
977
+ }
978
+ } else state = foldOwnBilling(this.deps.unit, events, seedLength);
979
+ this.ownStates.set(id, {
980
+ count: ownCount,
981
+ state
982
+ });
983
+ return state;
984
+ }
897
985
  /** Projection path: eager cells for live sessions, revision-gated cold ladder for the rest. */
898
986
  async scanProjections(dayKey) {
899
987
  const { sessions, persistence, projections } = this.deps;
@@ -904,7 +992,8 @@ var TodaySpendScanner = class {
904
992
  const store = sessions();
905
993
  if (store !== void 0) for (const session of store.list()) {
906
994
  liveIds.add(session.id);
907
- const state = projectionsService?.stateOf(session, BILLING_UNIT_KEY);
995
+ const seedLength = forkBoundaryOf(session.header);
996
+ const state = seedLength > 0 ? this.ownBillingState(session.id, session.events, seedLength) : projectionsService?.stateOf(session, BILLING_UNIT_KEY);
908
997
  if (state !== void 0 && state.dayKey === dayKey) total = mergeTodaySpend(total, state.spend);
909
998
  }
910
999
  }
@@ -914,6 +1003,7 @@ var TodaySpendScanner = class {
914
1003
  const pending = [];
915
1004
  for (const { header, revision } of snapshots) {
916
1005
  if (liveIds.has(header.id)) continue;
1006
+ const seedLength = forkBoundaryOf(header);
917
1007
  const resolved = this.coldResolved.get(header.id);
918
1008
  if (resolved !== void 0 && resolved.revision === revision) {
919
1009
  if (resolved.value.dayKey === dayKey) total = mergeTodaySpend(total, resolved.value.spend);
@@ -921,11 +1011,12 @@ var TodaySpendScanner = class {
921
1011
  }
922
1012
  pending.push({
923
1013
  id: header.id,
924
- revision
1014
+ revision,
1015
+ seedLength
925
1016
  });
926
1017
  }
927
- await withConcurrency(pending, 8, async ({ id, revision }) => {
928
- const resolved = await this.resolveCold(id);
1018
+ await withConcurrency(pending, 8, async ({ id, revision, seedLength }) => {
1019
+ const resolved = await this.resolveCold(id, seedLength);
929
1020
  if (resolved !== void 0) this.coldResolved.set(id, {
930
1021
  revision,
931
1022
  ...resolved
@@ -939,7 +1030,9 @@ var TodaySpendScanner = class {
939
1030
  }
940
1031
  /**
941
1032
  * Events path: price today's events in a single pass (per-event Beijing-day
942
- * filter during collection, hard cap), gated by revisions.
1033
+ * filter during collection, hard cap), gated by revisions. A fork child's
1034
+ * inherited prefix (`seq < seedLength`) is skipped, so each model output is
1035
+ * priced only in its source session.
943
1036
  */
944
1037
  async scanEvents(dayKey) {
945
1038
  const { sessions, persistence, maxEvents, logger, billing, catalog } = this.deps;
@@ -948,8 +1041,9 @@ var TodaySpendScanner = class {
948
1041
  const liveIds = /* @__PURE__ */ new Set();
949
1042
  let collected = 0;
950
1043
  let truncated = false;
951
- const collect = (events) => {
1044
+ const collect = (events, seedLength) => {
952
1045
  for (const event of events) {
1046
+ if (event.seq < seedLength) continue;
953
1047
  if (beijingDayKey(new Date(event.time)) !== dayKey) continue;
954
1048
  collected += 1;
955
1049
  if (collected > maxEvents) {
@@ -964,7 +1058,7 @@ var TodaySpendScanner = class {
964
1058
  const store = sessions();
965
1059
  if (store !== void 0) for (const session of store.list()) {
966
1060
  liveIds.add(session.id);
967
- collect(session.events);
1061
+ collect(session.events, forkBoundaryOf(session.header));
968
1062
  if (truncated) break;
969
1063
  }
970
1064
  }
@@ -975,7 +1069,8 @@ var TodaySpendScanner = class {
975
1069
  if (liveIds.has(header.id)) continue;
976
1070
  if (this.lastEventsScan?.get(header.id) === revision) continue;
977
1071
  try {
978
- collect((await persistenceService.inspect(header.id)).events);
1072
+ const inspection = await persistenceService.inspect(header.id);
1073
+ collect(inspection.events, forkBoundaryOf(inspection.meta));
979
1074
  } catch (error) {
980
1075
  logger.warn(`llm-billing: skipping unreadable session ${header.id}: ${String(error)}`);
981
1076
  }
@@ -990,7 +1085,8 @@ var TodaySpendScanner = class {
990
1085
  * Projection-path per-session scan: eager cells for live sessions (title
991
1086
  * folded from the live log, so a rename is reflected immediately),
992
1087
  * revision-gated cold ladder for the rest (title resolved on inspect,
993
- * `null` when served from the projection cache).
1088
+ * `null` when served from the projection cache). A fork child's row prices
1089
+ * its OWN events only (the cell covers the inherited prefix too).
994
1090
  * @param dayKey - the Beijing-time calendar-day key to aggregate.
995
1091
  * @returns unsorted per-session rows for the day.
996
1092
  */
@@ -1003,7 +1099,8 @@ var TodaySpendScanner = class {
1003
1099
  const store = sessions();
1004
1100
  if (store !== void 0) for (const session of store.list()) {
1005
1101
  liveIds.add(session.id);
1006
- const state = projectionsService?.stateOf(session, BILLING_UNIT_KEY);
1102
+ const seedLength = forkBoundaryOf(session.header);
1103
+ const state = seedLength > 0 ? this.ownBillingState(session.id, session.events, seedLength) : projectionsService?.stateOf(session, BILLING_UNIT_KEY);
1007
1104
  if (state !== void 0 && state.dayKey === dayKey) rows.set(session.id, {
1008
1105
  sessionId: session.id,
1009
1106
  title: foldSessionTitle(session.events),
@@ -1017,6 +1114,7 @@ var TodaySpendScanner = class {
1017
1114
  const pending = [];
1018
1115
  for (const { header, revision } of snapshots) {
1019
1116
  if (liveIds.has(header.id)) continue;
1117
+ const seedLength = forkBoundaryOf(header);
1020
1118
  const resolved = this.coldResolved.get(header.id);
1021
1119
  if (resolved !== void 0 && resolved.revision === revision) {
1022
1120
  if (resolved.value.dayKey === dayKey) rows.set(header.id, {
@@ -1028,11 +1126,12 @@ var TodaySpendScanner = class {
1028
1126
  }
1029
1127
  pending.push({
1030
1128
  id: header.id,
1031
- revision
1129
+ revision,
1130
+ seedLength
1032
1131
  });
1033
1132
  }
1034
- await withConcurrency(pending, 8, async ({ id, revision }) => {
1035
- const resolved = await this.resolveCold(id);
1133
+ await withConcurrency(pending, 8, async ({ id, revision, seedLength }) => {
1134
+ const resolved = await this.resolveCold(id, seedLength);
1036
1135
  if (resolved !== void 0) this.coldResolved.set(id, {
1037
1136
  revision,
1038
1137
  ...resolved
@@ -1051,9 +1150,11 @@ var TodaySpendScanner = class {
1051
1150
  /**
1052
1151
  * Events-path per-session scan: price today's events in a single pass,
1053
1152
  * accumulating per session (per-event Beijing-day filter during collection,
1054
- * hard cap), gated by revisions. Titles fold from each session's complete
1055
- * log a `session/title` event can predate today so a rename is reflected
1056
- * as soon as the session's log is re-read.
1153
+ * hard cap), gated by revisions. A fork child's inherited prefix
1154
+ * (`seq < seedLength`) is skipped, so each row is the session's OWN spend.
1155
+ * Titles fold from each session's complete log a `session/title` event
1156
+ * can predate today — so a rename is reflected as soon as the session's log
1157
+ * is re-read.
1057
1158
  * @param dayKey - the Beijing-time calendar-day key to aggregate.
1058
1159
  * @returns unsorted per-session rows for the day.
1059
1160
  */
@@ -1064,7 +1165,7 @@ var TodaySpendScanner = class {
1064
1165
  const liveIds = /* @__PURE__ */ new Set();
1065
1166
  let collected = 0;
1066
1167
  let truncated = false;
1067
- const collect = (id, events) => {
1168
+ const collect = (id, events, seedLength) => {
1068
1169
  let row = rows.get(id);
1069
1170
  if (row === void 0) {
1070
1171
  row = {
@@ -1074,6 +1175,7 @@ var TodaySpendScanner = class {
1074
1175
  rows.set(id, row);
1075
1176
  }
1076
1177
  for (const event of events) {
1178
+ if (event.seq < seedLength) continue;
1077
1179
  if (beijingDayKey(new Date(event.time)) !== dayKey) continue;
1078
1180
  collected += 1;
1079
1181
  if (collected > maxEvents) {
@@ -1088,7 +1190,7 @@ var TodaySpendScanner = class {
1088
1190
  const store = sessions();
1089
1191
  if (store !== void 0) for (const session of store.list()) {
1090
1192
  liveIds.add(session.id);
1091
- collect(session.id, session.events);
1193
+ collect(session.id, session.events, forkBoundaryOf(session.header));
1092
1194
  if (truncated) break;
1093
1195
  }
1094
1196
  }
@@ -1099,7 +1201,8 @@ var TodaySpendScanner = class {
1099
1201
  if (liveIds.has(header.id)) continue;
1100
1202
  if (this.lastEventsScan?.get(header.id) === revision) continue;
1101
1203
  try {
1102
- collect(header.id, (await persistenceService.inspect(header.id)).events);
1204
+ const inspection = await persistenceService.inspect(header.id);
1205
+ collect(header.id, inspection.events, forkBoundaryOf(inspection.meta));
1103
1206
  } catch (error) {
1104
1207
  logger.warn(`llm-billing: skipping unreadable session ${header.id}: ${String(error)}`);
1105
1208
  }
@@ -1189,20 +1292,27 @@ const TODAY_SPEND_CACHE_MS = 6e4;
1189
1292
  /** Hard cap on today's events collected by the events scan path. */
1190
1293
  const TODAY_SPEND_MAX_EVENTS = 2e5;
1191
1294
  /**
1192
- * Read one session's event log: the live SessionStore first, then the
1193
- * persistence backend for a flushed session (inspected directly by id — no
1194
- * header listing).
1295
+ * Read one session's event log and durable seed boundary: the live
1296
+ * SessionStore first, then the persistence backend for a flushed session
1297
+ * (inspected directly by id — no header listing).
1195
1298
  * @param ctx - plugin context carrying the SessionStore and optional persistence.
1196
1299
  * @param sessionId - the session to read.
1197
- * @returns the session's complete event log.
1300
+ * @returns the session's complete event log plus its inherited-prefix boundary.
1198
1301
  * @throws {@link LlmError} with code `NOT_FOUND` when the session is unknown.
1199
1302
  */
1200
1303
  async function sessionEvents(ctx, sessionId) {
1201
1304
  const live = ctx.get("sessions")?.get(sessionId);
1202
- if (live !== void 0) return live.events;
1305
+ if (live !== void 0) return {
1306
+ events: live.events,
1307
+ seedLength: forkBoundaryOf(live.header)
1308
+ };
1203
1309
  const persistence = ctx.get("sessionPersistence");
1204
1310
  if (persistence !== void 0) try {
1205
- return (await persistence.inspect(sessionId)).events;
1311
+ const inspection = await persistence.inspect(sessionId);
1312
+ return {
1313
+ events: inspection.events,
1314
+ seedLength: forkBoundaryOf(inspection.meta)
1315
+ };
1206
1316
  } catch (error) {
1207
1317
  throw new LlmError(`llm-billing: session ${sessionId} not found`, "NOT_FOUND", { cause: error });
1208
1318
  }
@@ -1238,21 +1348,22 @@ function apply(ctx, config) {
1238
1348
  }));
1239
1349
  const sessionSpendCache = /* @__PURE__ */ new Map();
1240
1350
  const fetchSessionSpend = async (sessionId) => {
1241
- const events = await sessionEvents(ctx, sessionId);
1351
+ const { events, seedLength } = await sessionEvents(ctx, sessionId);
1352
+ const ownCount = events.length - seedLength;
1242
1353
  const cached = sessionSpendCache.get(sessionId);
1243
- if (cached !== void 0 && cached.count === events.length) return cached.spend;
1244
- if (cached !== void 0 && cached.count < events.length) {
1245
- const spend = mergeTodaySpend(cached.spend, computeSessionSpend(events.slice(cached.count), billing, catalog));
1354
+ if (cached !== void 0 && cached.count === ownCount) return cached.spend;
1355
+ if (cached !== void 0 && cached.count < ownCount) {
1356
+ const spend = mergeTodaySpend(cached.spend, computeSessionSpend(events.slice(seedLength + cached.count), billing, catalog));
1246
1357
  sessionSpendCache.set(sessionId, {
1247
- count: events.length,
1358
+ count: ownCount,
1248
1359
  spend
1249
1360
  });
1250
1361
  return spend;
1251
1362
  }
1252
- const spend = computeSessionSpend(events, billing, catalog);
1363
+ const spend = computeSessionSpend(events, billing, catalog, seedLength);
1253
1364
  if (sessionSpendCache.size >= 1024) sessionSpendCache.clear();
1254
1365
  sessionSpendCache.set(sessionId, {
1255
- count: events.length,
1366
+ count: ownCount,
1256
1367
  spend
1257
1368
  });
1258
1369
  return spend;
@@ -1283,7 +1394,8 @@ function apply(ctx, config) {
1283
1394
  const fetchTodaySpend = async (force = false) => todayCache.get(force);
1284
1395
  const fetchTodaySessionsSpend = async (force = false) => todaySessionsCache.get(force);
1285
1396
  const fetchTurnSpend = async (sessionId, messageId) => {
1286
- return computeTurnSpend(await sessionEvents(ctx, sessionId), billing, catalog, messageId);
1397
+ const { events } = await sessionEvents(ctx, sessionId);
1398
+ return computeTurnSpend(events, billing, catalog, messageId);
1287
1399
  };
1288
1400
  new DeepSeekBalanceGateway(ctx, {
1289
1401
  fetchBalance,
@@ -1294,4 +1406,4 @@ function apply(ctx, config) {
1294
1406
  });
1295
1407
  }
1296
1408
  //#endregion
1297
- 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, foldSessionTitle, isPeak, mergeTodaySpend, name, parseDeepSeekBalance, priceEvent, resolveBilling };
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 };
@@ -87,6 +87,22 @@ export interface ResolvedBilling {
87
87
  export declare function resolveBilling(config: BillingConfig | undefined): ResolvedBilling;
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
+ /**
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.
101
+ * @returns the inherited-prefix length; 0 for an unseeded session.
102
+ */
103
+ export declare function forkBoundaryOf(header: {
104
+ readonly seedLength?: number;
105
+ } | undefined): number;
90
106
  /**
91
107
  * Whether a timestamp falls inside any peak-hour window (Beijing time,
92
108
  * weekdays Monday–Friday only). Weekends (Saturday and Sunday) are always
@@ -181,12 +197,16 @@ export declare function mergeTodaySpend(target: DeepSeekTodaySpend, source: Deep
181
197
  * @param events - one session's complete event log.
182
198
  * @param billing - resolved pricing with peak-hour windows.
183
199
  * @param catalog - model display rows, in presentation order.
200
+ * @param startSeq - when provided, only events with `seq >= startSeq`
201
+ * contribute: a forked session's inherited prefix (see {@link forkBoundaryOf})
202
+ * is skipped, so each model output is billed only in the session that
203
+ * produced it.
184
204
  * @returns the session's total cost plus one row per priced model.
185
205
  */
186
206
  export declare function computeSessionSpend(events: readonly SessionEvent[], billing: ResolvedBilling, catalog: readonly {
187
207
  id: string;
188
208
  name: string;
189
- }[]): DeepSeekSessionSpend;
209
+ }[], startSeq?: number): DeepSeekSessionSpend;
190
210
  /**
191
211
  * Price one completed Turn's billed usage at the official per-model rates,
192
212
  * identified by its closing assistant message id. The turn's events are those
@@ -80,6 +80,22 @@ function beijingParts(time) {
80
80
  export function beijingDayKey(now) {
81
81
  return beijingParts(now.getTime()).dayKey;
82
82
  }
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.
94
+ * @returns the inherited-prefix length; 0 for an unseeded session.
95
+ */
96
+ export function forkBoundaryOf(header) {
97
+ return header?.seedLength ?? 0;
98
+ }
83
99
  /** Whether a Beijing (hour, weekday) pair falls inside any peak-hour window. */
84
100
  function isPeakParts(billing, hour, weekday) {
85
101
  if (weekday === 0 || weekday === 6)
@@ -249,11 +265,15 @@ export function mergeTodaySpend(target, source) {
249
265
  * @param billing - resolved pricing with peak-hour windows.
250
266
  * @param names - model id → display label.
251
267
  * @param dayKey - when provided, only events on this Beijing calendar day contribute.
268
+ * @param startSeq - when provided, only events with `seq >= startSeq` contribute
269
+ * (a forked session's inherited prefix, `seq < startSeq`, is skipped).
252
270
  * @returns the total cost plus one row per priced model.
253
271
  */
254
- function priceEvents(events, billing, names, dayKey) {
272
+ function priceEvents(events, billing, names, dayKey, startSeq = 0) {
255
273
  const accumulator = new SpendAccumulator();
256
274
  for (const event of events) {
275
+ if (event.seq < startSeq)
276
+ continue;
257
277
  const priced = priceEvent(event, billing, names);
258
278
  if (priced === undefined || (dayKey !== undefined && priced.dayKey !== dayKey))
259
279
  continue;
@@ -266,11 +286,15 @@ function priceEvents(events, billing, names, dayKey) {
266
286
  * @param events - one session's complete event log.
267
287
  * @param billing - resolved pricing with peak-hour windows.
268
288
  * @param catalog - model display rows, in presentation order.
289
+ * @param startSeq - when provided, only events with `seq >= startSeq`
290
+ * contribute: a forked session's inherited prefix (see {@link forkBoundaryOf})
291
+ * is skipped, so each model output is billed only in the session that
292
+ * produced it.
269
293
  * @returns the session's total cost plus one row per priced model.
270
294
  */
271
- export function computeSessionSpend(events, billing, catalog) {
295
+ export function computeSessionSpend(events, billing, catalog, startSeq = 0) {
272
296
  const names = new Map(catalog.map(model => [model.id, model.name]));
273
- return priceEvents(events, billing, names);
297
+ return priceEvents(events, billing, names, undefined, startSeq);
274
298
  }
275
299
  /**
276
300
  * Price one completed Turn's billed usage at the official per-model rates,
@@ -24,10 +24,10 @@ 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, isPeak, mergeTodaySpend, priceEvent, resolveBilling, SpendAccumulator, } from './billing.ts';
27
+ export { addEventContribution, beijingDayKey, computeSessionSpend, computeTodaySpend, computeTurnSpend, DEFAULT_MODEL_PRICING, DEFAULT_PEAK_HOURS, emptyTodaySpend, forkBoundaryOf, isPeak, 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
- export { BILLING_UNIT_KEY, billingTodaySpendDefinition, foldBillingUnit } from './projection.ts';
30
+ export { BILLING_UNIT_KEY, billingTodaySpendDefinition, foldBillingUnit, foldOwnBilling } from './projection.ts';
31
31
  export type { BillingUnitState } from './projection.ts';
32
32
  export { foldSessionTitle, TodaySpendCache, TodaySpendScanner } from './today-spend.ts';
33
33
  export type { ScannerPersistedHeader, ScannerSession, TodaySpendScannerDeps } from './today-spend.ts';
@@ -25,12 +25,12 @@ 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, mergeTodaySpend, resolveBilling, } from "./billing.js";
28
+ import { computeSessionSpend, computeTurnSpend, DEFAULT_MODEL_PRICING, DEFAULT_PEAK_HOURS, forkBoundaryOf, mergeTodaySpend, resolveBilling, } from "./billing.js";
29
29
  import { billingTodaySpendDefinition } from "./projection.js";
30
30
  import { 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, isPeak, mergeTodaySpend, priceEvent, resolveBilling, SpendAccumulator, } from "./billing.js";
33
- export { BILLING_UNIT_KEY, billingTodaySpendDefinition, foldBillingUnit } from "./projection.js";
32
+ export { addEventContribution, beijingDayKey, computeSessionSpend, computeTodaySpend, computeTurnSpend, DEFAULT_MODEL_PRICING, DEFAULT_PEAK_HOURS, emptyTodaySpend, forkBoundaryOf, isPeak, mergeTodaySpend, priceEvent, resolveBilling, SpendAccumulator, } from "./billing.js";
33
+ export { BILLING_UNIT_KEY, billingTodaySpendDefinition, foldBillingUnit, foldOwnBilling } from "./projection.js";
34
34
  export { foldSessionTitle, TodaySpendCache, TodaySpendScanner } from "./today-spend.js";
35
35
  export const name = 'llm-billing';
36
36
  const DEFAULT_API_KEY_ENV = 'DEEPSEEK_API_KEY';
@@ -73,23 +73,24 @@ export const TODAY_SPEND_CACHE_MS = 60_000;
73
73
  /** Hard cap on today's events collected by the events scan path. */
74
74
  export const TODAY_SPEND_MAX_EVENTS = 200_000;
75
75
  /**
76
- * Read one session's event log: the live SessionStore first, then the
77
- * persistence backend for a flushed session (inspected directly by id — no
78
- * header listing).
76
+ * Read one session's event log and durable seed boundary: the live
77
+ * SessionStore first, then the persistence backend for a flushed session
78
+ * (inspected directly by id — no header listing).
79
79
  * @param ctx - plugin context carrying the SessionStore and optional persistence.
80
80
  * @param sessionId - the session to read.
81
- * @returns the session's complete event log.
81
+ * @returns the session's complete event log plus its inherited-prefix boundary.
82
82
  * @throws {@link LlmError} with code `NOT_FOUND` when the session is unknown.
83
83
  */
84
84
  async function sessionEvents(ctx, sessionId) {
85
85
  const sessions = ctx.get('sessions');
86
86
  const live = sessions?.get(sessionId);
87
87
  if (live !== undefined)
88
- return live.events;
88
+ return { events: live.events, seedLength: forkBoundaryOf(live.header) };
89
89
  const persistence = ctx.get('sessionPersistence');
90
90
  if (persistence !== undefined) {
91
91
  try {
92
- return (await persistence.inspect(sessionId)).events;
92
+ const inspection = await persistence.inspect(sessionId);
93
+ return { events: inspection.events, seedLength: forkBoundaryOf(inspection.meta) };
93
94
  }
94
95
  catch (error) {
95
96
  throw new LlmError(`llm-billing: session ${sessionId} not found`, 'NOT_FOUND', { cause: error });
@@ -130,26 +131,28 @@ export function apply(ctx, config) {
130
131
  const catalog = (config.models ?? DEFAULT_MODELS).map(model => ({ id: model.id, name: model.name ?? model.id }));
131
132
  // Per-session incremental spend cache: a session log is append-only and
132
133
  // chronological (the same assumption the projection unit makes), so a spend
133
- // computed for `count` events stays valid while the log length is unchanged,
134
- // and only the appended tail needs pricing when it grows. A pricing-table
135
- // change does not retroactively reprice (same caveat as the projection
136
- // path); the map is capped so an unbounded session-id space cannot grow it
137
- // without bound.
134
+ // computed for `count` EVENTS OF THE SESSION'S OWN WORK (the log minus its
135
+ // inherited fork prefix) stays valid while the log length is unchanged, and
136
+ // only the appended tail needs pricing when it grows. A forked child's
137
+ // inherited prefix (`seq < seedLength`) is priced only in its source
138
+ // session; the map is capped so an unbounded session-id space cannot grow
139
+ // it without bound.
138
140
  const sessionSpendCache = new Map();
139
141
  const fetchSessionSpend = async (sessionId) => {
140
- const events = await sessionEvents(ctx, sessionId);
142
+ const { events, seedLength } = await sessionEvents(ctx, sessionId);
143
+ const ownCount = events.length - seedLength;
141
144
  const cached = sessionSpendCache.get(sessionId);
142
- if (cached !== undefined && cached.count === events.length)
145
+ if (cached !== undefined && cached.count === ownCount)
143
146
  return cached.spend;
144
- if (cached !== undefined && cached.count < events.length) {
145
- const spend = mergeTodaySpend(cached.spend, computeSessionSpend(events.slice(cached.count), billing, catalog));
146
- sessionSpendCache.set(sessionId, { count: events.length, spend });
147
+ if (cached !== undefined && cached.count < ownCount) {
148
+ const spend = mergeTodaySpend(cached.spend, computeSessionSpend(events.slice(seedLength + cached.count), billing, catalog));
149
+ sessionSpendCache.set(sessionId, { count: ownCount, spend });
147
150
  return spend;
148
151
  }
149
- const spend = computeSessionSpend(events, billing, catalog);
152
+ const spend = computeSessionSpend(events, billing, catalog, seedLength);
150
153
  if (sessionSpendCache.size >= 1024)
151
154
  sessionSpendCache.clear();
152
- sessionSpendCache.set(sessionId, { count: events.length, spend });
155
+ sessionSpendCache.set(sessionId, { count: ownCount, spend });
153
156
  return spend;
154
157
  };
155
158
  // Plan C: register the per-session spend projection unit on the projection
@@ -190,7 +193,7 @@ export function apply(ctx, config) {
190
193
  const fetchTodaySpend = async (force = false) => todayCache.get(force);
191
194
  const fetchTodaySessionsSpend = async (force = false) => todaySessionsCache.get(force);
192
195
  const fetchTurnSpend = async (sessionId, messageId) => {
193
- const events = await sessionEvents(ctx, sessionId);
196
+ const { events } = await sessionEvents(ctx, sessionId);
194
197
  return computeTurnSpend(events, billing, catalog, messageId);
195
198
  };
196
199
  new DeepSeekBalanceGateway(ctx, { fetchBalance, fetchSessionSpend, fetchTodaySpend, fetchTodaySessionsSpend, fetchTurnSpend });
@@ -66,4 +66,17 @@ export declare function billingTodaySpendDefinition(billing: ResolvedBilling, ca
66
66
  }[]): BillingUnitDefinition;
67
67
  /** Fold a unit from init over one session's event log (the detached cold recipe). */
68
68
  export declare function foldBillingUnit(unit: Pick<ProjectionDefinition<'billingTodaySpend', BillingUnitState>, 'init' | 'apply'>, events: readonly SessionEvent[]): BillingUnitState;
69
+ /**
70
+ * Fold a unit from init over one session's OWN events only: the complete log
71
+ * minus its inherited fork prefix (`seq < seedLength`). A forked child's
72
+ * prefix is a verbatim copy of events already billed in its source session,
73
+ * so the detached cold recipe must skip it, or the same model output is
74
+ * priced once per copy.
75
+ * @param unit - the billing unit's fold halves.
76
+ * @param events - the session's complete event log (in seq order).
77
+ * @param seedLength - the durable inherited-prefix boundary
78
+ * ({@link forkBoundaryOf}); 0 for an unseeded session.
79
+ * @returns the unit state folded over the session's own events.
80
+ */
81
+ export declare function foldOwnBilling(unit: Pick<ProjectionDefinition<'billingTodaySpend', BillingUnitState>, 'init' | 'apply'>, events: readonly SessionEvent[], seedLength?: number): BillingUnitState;
69
82
  //# sourceMappingURL=projection.d.ts.map
@@ -85,4 +85,25 @@ export function foldBillingUnit(unit, events) {
85
85
  state = unit.apply(state, event);
86
86
  return state;
87
87
  }
88
+ /**
89
+ * Fold a unit from init over one session's OWN events only: the complete log
90
+ * minus its inherited fork prefix (`seq < seedLength`). A forked child's
91
+ * prefix is a verbatim copy of events already billed in its source session,
92
+ * so the detached cold recipe must skip it, or the same model output is
93
+ * priced once per copy.
94
+ * @param unit - the billing unit's fold halves.
95
+ * @param events - the session's complete event log (in seq order).
96
+ * @param seedLength - the durable inherited-prefix boundary
97
+ * ({@link forkBoundaryOf}); 0 for an unseeded session.
98
+ * @returns the unit state folded over the session's own events.
99
+ */
100
+ export function foldOwnBilling(unit, events, seedLength = 0) {
101
+ let state = unit.init();
102
+ for (const event of events) {
103
+ if (event.seq < seedLength)
104
+ continue;
105
+ state = unit.apply(state, event);
106
+ }
107
+ return state;
108
+ }
88
109
  //# sourceMappingURL=projection.js.map
@@ -18,6 +18,16 @@
18
18
  * happens at most once per 60 seconds per process, and a manual refresh
19
19
  * (`force`) bypasses the time window but keeps the revision caches — an
20
20
  * unchanged log provably cannot change the aggregate.
21
+ *
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.
21
31
  * @module @rayadesu/dsh-llm-billing/today-spend
22
32
  */
23
33
  import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session';
@@ -39,10 +49,16 @@ export declare function foldSessionTitle(events: readonly SessionEvent[]): strin
39
49
  export interface ScannerSession {
40
50
  readonly id: SessionId;
41
51
  readonly events: readonly SessionEvent[];
52
+ /** Durable header slice; `seedLength` marks a fork child's inherited prefix. */
53
+ readonly header?: {
54
+ readonly seedLength?: number;
55
+ };
42
56
  }
43
- /** Structural slice of a listed persisted session. */
57
+ /** Structural slice of a listed persisted session (the snapshot header is a full SessionHeader). */
44
58
  export interface ScannerPersistedHeader {
45
59
  readonly id: SessionId;
60
+ /** Durable fork boundary carried by the snapshot header; absent for an unseeded session. */
61
+ readonly seedLength?: number;
46
62
  }
47
63
  /** Structural slices of the optional services the scanner reads through. */
48
64
  export interface TodaySpendScannerDeps {
@@ -57,6 +73,9 @@ export interface TodaySpendScannerDeps {
57
73
  revision: SessionPersistenceRevision;
58
74
  }[]>;
59
75
  inspect(id: SessionId): Promise<{
76
+ meta: {
77
+ seedLength?: number;
78
+ };
60
79
  events: readonly SessionEvent[];
61
80
  }>;
62
81
  } | undefined;
@@ -135,6 +154,8 @@ export declare class TodaySpendScanner {
135
154
  private readonly coldResolved;
136
155
  /** Cold sessions resolved on the events path: id → revision (events were collected). */
137
156
  private lastEventsScan;
157
+ /** Live fork children priced on the projection path: id → own-events count + folded state. */
158
+ private readonly ownStates;
138
159
  constructor(deps: TodaySpendScannerDeps);
139
160
  /**
140
161
  * Compute today's aggregate for one Beijing day.
@@ -155,23 +176,40 @@ export declare class TodaySpendScanner {
155
176
  * the projection-cache ladder (cached row first, then a detached local
156
177
  * fold over a full inspect). A cache-served value carries no title (the
157
178
  * ladder only stores projection values), so such rows report `title: null`
158
- * until the session is inspected again.
179
+ * until the session is inspected again. A SEEDED session (fork child)
180
+ * skips the ladder entirely: its cached row was folded over the inherited
181
+ * prefix too, so it always detaches through inspect with the durable
182
+ * boundary applied to the local fold.
159
183
  * @param id - the cold session's id.
184
+ * @param seedLength - the durable inherited-prefix boundary (0 for unseeded).
160
185
  * @returns the resolved state and title, or `undefined` when unreadable.
161
186
  */
162
187
  private resolveCold;
188
+ /**
189
+ * Fold one fork child's OWN events (its log minus the inherited prefix)
190
+ * with the billing unit, incrementally: the fold is reused while the log
191
+ * length is unchanged and only the new tail is applied when it grows.
192
+ * @param id - the session id (the own-state cache key).
193
+ * @param events - the session's complete log.
194
+ * @param seedLength - the inherited-prefix boundary.
195
+ * @returns the unit state over the session's own events.
196
+ */
197
+ private ownBillingState;
163
198
  /** Projection path: eager cells for live sessions, revision-gated cold ladder for the rest. */
164
199
  private scanProjections;
165
200
  /**
166
201
  * Events path: price today's events in a single pass (per-event Beijing-day
167
- * filter during collection, hard cap), gated by revisions.
202
+ * filter during collection, hard cap), gated by revisions. A fork child's
203
+ * inherited prefix (`seq < seedLength`) is skipped, so each model output is
204
+ * priced only in its source session.
168
205
  */
169
206
  private scanEvents;
170
207
  /**
171
208
  * Projection-path per-session scan: eager cells for live sessions (title
172
209
  * folded from the live log, so a rename is reflected immediately),
173
210
  * revision-gated cold ladder for the rest (title resolved on inspect,
174
- * `null` when served from the projection cache).
211
+ * `null` when served from the projection cache). A fork child's row prices
212
+ * its OWN events only (the cell covers the inherited prefix too).
175
213
  * @param dayKey - the Beijing-time calendar-day key to aggregate.
176
214
  * @returns unsorted per-session rows for the day.
177
215
  */
@@ -179,9 +217,11 @@ export declare class TodaySpendScanner {
179
217
  /**
180
218
  * Events-path per-session scan: price today's events in a single pass,
181
219
  * accumulating per session (per-event Beijing-day filter during collection,
182
- * hard cap), gated by revisions. Titles fold from each session's complete
183
- * log a `session/title` event can predate today so a rename is reflected
184
- * as soon as the session's log is re-read.
220
+ * hard cap), gated by revisions. A fork child's inherited prefix
221
+ * (`seq < seedLength`) is skipped, so each row is the session's OWN spend.
222
+ * Titles fold from each session's complete log a `session/title` event
223
+ * can predate today — so a rename is reflected as soon as the session's log
224
+ * is re-read.
185
225
  * @param dayKey - the Beijing-time calendar-day key to aggregate.
186
226
  * @returns unsorted per-session rows for the day.
187
227
  */
@@ -18,10 +18,20 @@
18
18
  * happens at most once per 60 seconds per process, and a manual refresh
19
19
  * (`force`) bypasses the time window but keeps the revision caches — an
20
20
  * unchanged log provably cannot change the aggregate.
21
+ *
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.
21
31
  * @module @rayadesu/dsh-llm-billing/today-spend
22
32
  */
23
- import { beijingDayKey, emptyTodaySpend, mergeTodaySpend, priceEvent, SpendAccumulator } from "./billing.js";
24
- import { BILLING_UNIT_KEY, foldBillingUnit } from "./projection.js";
33
+ import { beijingDayKey, emptyTodaySpend, forkBoundaryOf, mergeTodaySpend, priceEvent, SpendAccumulator } from "./billing.js";
34
+ import { BILLING_UNIT_KEY, foldOwnBilling } from "./projection.js";
25
35
  /**
26
36
  * Fold one session's durable display title: the latest `session/title`
27
37
  * event's text (last-wins, matching the `title` projection), or `null` before
@@ -128,6 +138,8 @@ export class TodaySpendScanner {
128
138
  coldResolved = new Map();
129
139
  /** Cold sessions resolved on the events path: id → revision (events were collected). */
130
140
  lastEventsScan;
141
+ /** Live fork children priced on the projection path: id → own-events count + folded state. */
142
+ ownStates = new Map();
131
143
  constructor(deps) {
132
144
  this.deps = deps;
133
145
  }
@@ -161,29 +173,35 @@ export class TodaySpendScanner {
161
173
  * the projection-cache ladder (cached row first, then a detached local
162
174
  * fold over a full inspect). A cache-served value carries no title (the
163
175
  * ladder only stores projection values), so such rows report `title: null`
164
- * until the session is inspected again.
176
+ * until the session is inspected again. A SEEDED session (fork child)
177
+ * skips the ladder entirely: its cached row was folded over the inherited
178
+ * prefix too, so it always detaches through inspect with the durable
179
+ * boundary applied to the local fold.
165
180
  * @param id - the cold session's id.
181
+ * @param seedLength - the durable inherited-prefix boundary (0 for unseeded).
166
182
  * @returns the resolved state and title, or `undefined` when unreadable.
167
183
  */
168
- async resolveCold(id) {
184
+ async resolveCold(id, seedLength) {
169
185
  const { persistence, projectionCache, unit, logger } = this.deps;
170
- const cache = projectionCache?.();
171
- if (cache !== undefined) {
172
- try {
173
- const value = (await cache.coldSnapshot(id)).values[BILLING_UNIT_KEY];
174
- if (value !== undefined)
175
- return { value, title: null };
176
- }
177
- catch (error) {
178
- logger.warn(`llm-billing: projection cold read for session ${id} failed: ${String(error)}`);
179
- }
180
- }
181
186
  const persistenceService = persistence?.();
182
187
  if (persistenceService === undefined)
183
188
  return undefined;
189
+ if (seedLength <= 0) {
190
+ const cache = projectionCache?.();
191
+ if (cache !== undefined) {
192
+ try {
193
+ const value = (await cache.coldSnapshot(id)).values[BILLING_UNIT_KEY];
194
+ if (value !== undefined)
195
+ return { value, title: null };
196
+ }
197
+ catch (error) {
198
+ logger.warn(`llm-billing: projection cold read for session ${id} failed: ${String(error)}`);
199
+ }
200
+ }
201
+ }
184
202
  try {
185
203
  const inspection = await persistenceService.inspect(id);
186
- return { value: foldBillingUnit(unit, inspection.events), title: foldSessionTitle(inspection.events) };
204
+ return { value: foldOwnBilling(unit, inspection.events, seedLength), title: foldSessionTitle(inspection.events) };
187
205
  }
188
206
  catch (error) {
189
207
  // One unreadable session must not blank the whole-day aggregate.
@@ -191,6 +209,35 @@ export class TodaySpendScanner {
191
209
  return undefined;
192
210
  }
193
211
  }
212
+ /**
213
+ * Fold one fork child's OWN events (its log minus the inherited prefix)
214
+ * with the billing unit, incrementally: the fold is reused while the log
215
+ * length is unchanged and only the new tail is applied when it grows.
216
+ * @param id - the session id (the own-state cache key).
217
+ * @param events - the session's complete log.
218
+ * @param seedLength - the inherited-prefix boundary.
219
+ * @returns the unit state over the session's own events.
220
+ */
221
+ ownBillingState(id, events, seedLength) {
222
+ const cached = this.ownStates.get(id);
223
+ const ownCount = events.length - seedLength;
224
+ if (cached !== undefined && cached.count === ownCount)
225
+ return cached.state;
226
+ let state;
227
+ if (cached !== undefined && cached.count < ownCount) {
228
+ state = cached.state;
229
+ for (const event of events) {
230
+ if (event.seq < seedLength + cached.count)
231
+ continue;
232
+ state = this.deps.unit.apply(state, event);
233
+ }
234
+ }
235
+ else {
236
+ state = foldOwnBilling(this.deps.unit, events, seedLength);
237
+ }
238
+ this.ownStates.set(id, { count: ownCount, state });
239
+ return state;
240
+ }
194
241
  /** Projection path: eager cells for live sessions, revision-gated cold ladder for the rest. */
195
242
  async scanProjections(dayKey) {
196
243
  const { sessions, persistence, projections } = this.deps;
@@ -203,7 +250,12 @@ export class TodaySpendScanner {
203
250
  if (store !== undefined) {
204
251
  for (const session of store.list()) {
205
252
  liveIds.add(session.id);
206
- const state = projectionsService?.stateOf(session, BILLING_UNIT_KEY);
253
+ const seedLength = forkBoundaryOf(session.header);
254
+ // A fork child's eager cell covers its inherited prefix too; price
255
+ // its own events directly instead.
256
+ const state = seedLength > 0
257
+ ? this.ownBillingState(session.id, session.events, seedLength)
258
+ : projectionsService?.stateOf(session, BILLING_UNIT_KEY);
207
259
  if (state !== undefined && state.dayKey === dayKey) {
208
260
  total = mergeTodaySpend(total, state.spend);
209
261
  }
@@ -218,16 +270,17 @@ export class TodaySpendScanner {
218
270
  for (const { header, revision } of snapshots) {
219
271
  if (liveIds.has(header.id))
220
272
  continue;
273
+ const seedLength = forkBoundaryOf(header);
221
274
  const resolved = this.coldResolved.get(header.id);
222
275
  if (resolved !== undefined && resolved.revision === revision) {
223
276
  if (resolved.value.dayKey === dayKey)
224
277
  total = mergeTodaySpend(total, resolved.value.spend);
225
278
  continue;
226
279
  }
227
- pending.push({ id: header.id, revision });
280
+ pending.push({ id: header.id, revision, seedLength });
228
281
  }
229
- await withConcurrency(pending, 8, async ({ id, revision }) => {
230
- const resolved = await this.resolveCold(id);
282
+ await withConcurrency(pending, 8, async ({ id, revision, seedLength }) => {
283
+ const resolved = await this.resolveCold(id, seedLength);
231
284
  if (resolved !== undefined)
232
285
  this.coldResolved.set(id, { revision, ...resolved });
233
286
  });
@@ -241,7 +294,9 @@ export class TodaySpendScanner {
241
294
  }
242
295
  /**
243
296
  * Events path: price today's events in a single pass (per-event Beijing-day
244
- * filter during collection, hard cap), gated by revisions.
297
+ * filter during collection, hard cap), gated by revisions. A fork child's
298
+ * inherited prefix (`seq < seedLength`) is skipped, so each model output is
299
+ * priced only in its source session.
245
300
  */
246
301
  async scanEvents(dayKey) {
247
302
  const { sessions, persistence, maxEvents, logger, billing, catalog } = this.deps;
@@ -250,8 +305,10 @@ export class TodaySpendScanner {
250
305
  const liveIds = new Set();
251
306
  let collected = 0;
252
307
  let truncated = false;
253
- const collect = (events) => {
308
+ const collect = (events, seedLength) => {
254
309
  for (const event of events) {
310
+ if (event.seq < seedLength)
311
+ continue;
255
312
  if (beijingDayKey(new Date(event.time)) !== dayKey)
256
313
  continue;
257
314
  collected += 1;
@@ -269,7 +326,7 @@ export class TodaySpendScanner {
269
326
  if (store !== undefined) {
270
327
  for (const session of store.list()) {
271
328
  liveIds.add(session.id);
272
- collect(session.events);
329
+ collect(session.events, forkBoundaryOf(session.header));
273
330
  if (truncated)
274
331
  break;
275
332
  }
@@ -284,7 +341,8 @@ export class TodaySpendScanner {
284
341
  if (this.lastEventsScan?.get(header.id) === revision)
285
342
  continue;
286
343
  try {
287
- collect((await persistenceService.inspect(header.id)).events);
344
+ const inspection = await persistenceService.inspect(header.id);
345
+ collect(inspection.events, forkBoundaryOf(inspection.meta));
288
346
  }
289
347
  catch (error) {
290
348
  // One unreadable session must not blank the whole-day aggregate.
@@ -308,7 +366,8 @@ export class TodaySpendScanner {
308
366
  * Projection-path per-session scan: eager cells for live sessions (title
309
367
  * folded from the live log, so a rename is reflected immediately),
310
368
  * revision-gated cold ladder for the rest (title resolved on inspect,
311
- * `null` when served from the projection cache).
369
+ * `null` when served from the projection cache). A fork child's row prices
370
+ * its OWN events only (the cell covers the inherited prefix too).
312
371
  * @param dayKey - the Beijing-time calendar-day key to aggregate.
313
372
  * @returns unsorted per-session rows for the day.
314
373
  */
@@ -322,7 +381,10 @@ export class TodaySpendScanner {
322
381
  if (store !== undefined) {
323
382
  for (const session of store.list()) {
324
383
  liveIds.add(session.id);
325
- const state = projectionsService?.stateOf(session, BILLING_UNIT_KEY);
384
+ const seedLength = forkBoundaryOf(session.header);
385
+ const state = seedLength > 0
386
+ ? this.ownBillingState(session.id, session.events, seedLength)
387
+ : projectionsService?.stateOf(session, BILLING_UNIT_KEY);
326
388
  if (state !== undefined && state.dayKey === dayKey) {
327
389
  rows.set(session.id, {
328
390
  sessionId: session.id,
@@ -341,6 +403,7 @@ export class TodaySpendScanner {
341
403
  for (const { header, revision } of snapshots) {
342
404
  if (liveIds.has(header.id))
343
405
  continue;
406
+ const seedLength = forkBoundaryOf(header);
344
407
  const resolved = this.coldResolved.get(header.id);
345
408
  if (resolved !== undefined && resolved.revision === revision) {
346
409
  if (resolved.value.dayKey === dayKey) {
@@ -348,10 +411,10 @@ export class TodaySpendScanner {
348
411
  }
349
412
  continue;
350
413
  }
351
- pending.push({ id: header.id, revision });
414
+ pending.push({ id: header.id, revision, seedLength });
352
415
  }
353
- await withConcurrency(pending, 8, async ({ id, revision }) => {
354
- const resolved = await this.resolveCold(id);
416
+ await withConcurrency(pending, 8, async ({ id, revision, seedLength }) => {
417
+ const resolved = await this.resolveCold(id, seedLength);
355
418
  if (resolved !== undefined)
356
419
  this.coldResolved.set(id, { revision, ...resolved });
357
420
  });
@@ -366,9 +429,11 @@ export class TodaySpendScanner {
366
429
  /**
367
430
  * Events-path per-session scan: price today's events in a single pass,
368
431
  * accumulating per session (per-event Beijing-day filter during collection,
369
- * hard cap), gated by revisions. Titles fold from each session's complete
370
- * log a `session/title` event can predate today so a rename is reflected
371
- * as soon as the session's log is re-read.
432
+ * hard cap), gated by revisions. A fork child's inherited prefix
433
+ * (`seq < seedLength`) is skipped, so each row is the session's OWN spend.
434
+ * Titles fold from each session's complete log a `session/title` event
435
+ * can predate today — so a rename is reflected as soon as the session's log
436
+ * is re-read.
372
437
  * @param dayKey - the Beijing-time calendar-day key to aggregate.
373
438
  * @returns unsorted per-session rows for the day.
374
439
  */
@@ -379,13 +444,15 @@ export class TodaySpendScanner {
379
444
  const liveIds = new Set();
380
445
  let collected = 0;
381
446
  let truncated = false;
382
- const collect = (id, events) => {
447
+ const collect = (id, events, seedLength) => {
383
448
  let row = rows.get(id);
384
449
  if (row === undefined) {
385
450
  row = { title: foldSessionTitle(events), total: 0 };
386
451
  rows.set(id, row);
387
452
  }
388
453
  for (const event of events) {
454
+ if (event.seq < seedLength)
455
+ continue;
389
456
  if (beijingDayKey(new Date(event.time)) !== dayKey)
390
457
  continue;
391
458
  collected += 1;
@@ -403,7 +470,7 @@ export class TodaySpendScanner {
403
470
  if (store !== undefined) {
404
471
  for (const session of store.list()) {
405
472
  liveIds.add(session.id);
406
- collect(session.id, session.events);
473
+ collect(session.id, session.events, forkBoundaryOf(session.header));
407
474
  if (truncated)
408
475
  break;
409
476
  }
@@ -418,7 +485,8 @@ export class TodaySpendScanner {
418
485
  if (this.lastEventsScan?.get(header.id) === revision)
419
486
  continue;
420
487
  try {
421
- collect(header.id, (await persistenceService.inspect(header.id)).events);
488
+ const inspection = await persistenceService.inspect(header.id);
489
+ collect(header.id, inspection.events, forkBoundaryOf(inspection.meta));
422
490
  }
423
491
  catch (error) {
424
492
  // 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.2",
4
+ "version": "0.3.4",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },