@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 +2 -2
- package/README.md +4 -0
- package/README.zh.md +4 -0
- package/lib/index.js +162 -50
- package/lib/types/billing.d.ts +21 -1
- package/lib/types/billing.js +27 -3
- package/lib/types/index.d.ts +2 -2
- package/lib/types/index.js +25 -22
- package/lib/types/projection.d.ts +13 -0
- package/lib/types/projection.js +21 -0
- package/lib/types/today-spend.d.ts +47 -7
- package/lib/types/today-spend.js +103 -35
- package/package.json +1 -1
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:
|
|
6
|
-
README.zh.md:
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
1055
|
-
*
|
|
1056
|
-
*
|
|
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
|
-
|
|
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
|
|
1193
|
-
* persistence backend for a flushed session
|
|
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
|
|
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
|
-
|
|
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 ===
|
|
1244
|
-
if (cached !== void 0 && cached.count <
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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 };
|
package/lib/types/billing.d.ts
CHANGED
|
@@ -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
|
package/lib/types/billing.js
CHANGED
|
@@ -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,
|
package/lib/types/index.d.ts
CHANGED
|
@@ -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';
|
package/lib/types/index.js
CHANGED
|
@@ -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
|
|
77
|
-
* persistence backend for a flushed session
|
|
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
|
-
|
|
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`
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
//
|
|
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 ===
|
|
145
|
+
if (cached !== undefined && cached.count === ownCount)
|
|
143
146
|
return cached.spend;
|
|
144
|
-
if (cached !== undefined && cached.count <
|
|
145
|
-
const spend = mergeTodaySpend(cached.spend, computeSessionSpend(events.slice(cached.count), billing, catalog));
|
|
146
|
-
sessionSpendCache.set(sessionId, { count:
|
|
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:
|
|
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
|
package/lib/types/projection.js
CHANGED
|
@@ -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.
|
|
183
|
-
*
|
|
184
|
-
*
|
|
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
|
*/
|
package/lib/types/today-spend.js
CHANGED
|
@@ -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,
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
370
|
-
*
|
|
371
|
-
*
|
|
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
|
-
|
|
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