@rayadesu/dsh-llm-billing 0.1.0 → 0.2.0

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.
@@ -0,0 +1,246 @@
1
+ /**
2
+ * Today-spend read path: the 60-second Beijing-day cache with in-flight
3
+ * coalescing and a force bypass (plan A1), plus the two scan strategies that
4
+ * compute the aggregate behind a cache miss:
5
+ *
6
+ * - projection path (plan C): live sessions read their eagerly folded
7
+ * `billingTodaySpend` projection cell; cold sessions resolve through the
8
+ * projection-cache ladder (cached row + tail replay + registry restore,
9
+ * with write-back) or, without the cache service, one detached local fold
10
+ * over a full `inspect`. Persisted revisions gate every cold read, so a
11
+ * session whose log did not change since the last resolution costs nothing.
12
+ * - events path (plans A2/A3): collect only today's events (per-event
13
+ * Beijing-day filter during collection) with a hard cap, skipping sessions
14
+ * whose persisted revision is unchanged since the last scan.
15
+ *
16
+ * Both strategies run behind the same {@link TodaySpendCache}, so a miss
17
+ * happens at most once per 60 seconds per process, and a manual refresh
18
+ * (`force`) bypasses the time window but keeps the revision caches — an
19
+ * unchanged log provably cannot change the aggregate.
20
+ * @module @rayadesu/dsh-llm-billing/today-spend
21
+ */
22
+ import { beijingDayKey, computeTodaySpend, emptyTodaySpend, mergeTodaySpend } from "./billing.js";
23
+ import { BILLING_UNIT_KEY, foldBillingUnit } from "./projection.js";
24
+ /** Bounded parallel fan-out: run `run` over `items` with at most `limit` in flight. */
25
+ async function withConcurrency(items, limit, run) {
26
+ const queue = [...items];
27
+ await Promise.all(Array.from({ length: Math.min(limit, queue.length) }, async () => {
28
+ for (let job = queue.shift(); job !== undefined; job = queue.shift())
29
+ await run(job);
30
+ }));
31
+ }
32
+ /**
33
+ * The A1 cache: one Beijing-day key + a 60s window, an in-flight promise that
34
+ * coalesces concurrent misses, and a `force` bypass for the manual refresh
35
+ * path. Cross-day invalidation is automatic (the day key changes); a failed
36
+ * scan leaves the previous value in place and retries on the next call.
37
+ */
38
+ export class TodaySpendCache {
39
+ scan;
40
+ ttlMs;
41
+ now;
42
+ cachedDayKey;
43
+ cachedValue;
44
+ cachedAt = 0;
45
+ inFlight;
46
+ /**
47
+ * @param ttlMs - time window in milliseconds (default 60 000).
48
+ * @param now - clock source (injectable for tests).
49
+ * @param scan - the aggregate computation behind a miss.
50
+ */
51
+ constructor(scan, ttlMs = 60_000, now = () => new Date()) {
52
+ this.scan = scan;
53
+ this.ttlMs = ttlMs;
54
+ this.now = now;
55
+ }
56
+ /**
57
+ * Read today's spend, cached per Beijing day within the TTL window.
58
+ * @param force - bypass the time window (manual refresh); the day-key gate
59
+ * and the in-flight coalescing still apply to non-force callers.
60
+ * @returns today's spend.
61
+ */
62
+ get(force = false) {
63
+ const now = this.now();
64
+ const dayKey = beijingDayKey(now);
65
+ if (!force && this.cachedDayKey === dayKey && this.cachedValue !== undefined
66
+ && now.getTime() - this.cachedAt < this.ttlMs) {
67
+ return Promise.resolve(this.cachedValue);
68
+ }
69
+ if (!force && this.inFlight !== undefined)
70
+ return this.inFlight;
71
+ const run = (async () => {
72
+ try {
73
+ const value = await this.scan(dayKey);
74
+ this.cachedDayKey = dayKey;
75
+ this.cachedValue = value;
76
+ this.cachedAt = now.getTime();
77
+ return value;
78
+ }
79
+ finally {
80
+ this.inFlight = undefined;
81
+ }
82
+ })();
83
+ if (!force)
84
+ this.inFlight = run;
85
+ return run;
86
+ }
87
+ }
88
+ /**
89
+ * The aggregate computation behind a cache miss. Chooses the projection path
90
+ * when the projection registry is composed, the events path otherwise; both
91
+ * gate cold reads on persisted revisions so steady-state scans touch only
92
+ * sessions whose logs actually changed.
93
+ */
94
+ export class TodaySpendScanner {
95
+ deps;
96
+ /** Cold sessions resolved on the projection path: id → revision + unit state. */
97
+ coldResolved = new Map();
98
+ /** Cold sessions resolved on the events path: id → revision (events were collected). */
99
+ lastEventsScan;
100
+ constructor(deps) {
101
+ this.deps = deps;
102
+ }
103
+ /**
104
+ * Compute today's aggregate for one Beijing day.
105
+ * @param dayKey - the Beijing-time calendar-day key to aggregate.
106
+ * @returns today's spend across every session.
107
+ */
108
+ async scan(dayKey) {
109
+ if (this.deps.projections?.() === undefined)
110
+ return this.scanEvents(dayKey);
111
+ this.deps.ensureUnit?.();
112
+ return this.scanProjections(dayKey);
113
+ }
114
+ /** Projection path: eager cells for live sessions, revision-gated cold ladder for the rest. */
115
+ async scanProjections(dayKey) {
116
+ const { sessions, persistence, projections, projectionCache, unit, logger } = this.deps;
117
+ let total = emptyTodaySpend();
118
+ const liveIds = new Set();
119
+ if (sessions !== undefined) {
120
+ const store = sessions();
121
+ if (store !== undefined) {
122
+ for (const session of store.list()) {
123
+ liveIds.add(session.id);
124
+ const state = projections?.()?.stateOf(session, BILLING_UNIT_KEY);
125
+ if (state !== undefined && state.dayKey === dayKey) {
126
+ total = mergeTodaySpend(total, state.spend);
127
+ }
128
+ }
129
+ }
130
+ }
131
+ const persistenceService = persistence?.();
132
+ if (persistenceService === undefined)
133
+ return total;
134
+ const snapshots = await persistenceService.listSnapshots();
135
+ const pending = [];
136
+ for (const { header, revision } of snapshots) {
137
+ if (liveIds.has(header.id))
138
+ continue;
139
+ const resolved = this.coldResolved.get(header.id);
140
+ if (resolved !== undefined && resolved.revision === revision) {
141
+ if (resolved.value.dayKey === dayKey)
142
+ total = mergeTodaySpend(total, resolved.value.spend);
143
+ continue;
144
+ }
145
+ pending.push({ id: header.id, revision });
146
+ }
147
+ await withConcurrency(pending, 8, async ({ id, revision }) => {
148
+ let value;
149
+ const cache = projectionCache?.();
150
+ if (cache !== undefined) {
151
+ try {
152
+ value = (await cache.coldSnapshot(id)).values[BILLING_UNIT_KEY];
153
+ }
154
+ catch (error) {
155
+ logger.warn(`llm-billing: projection cold read for session ${id} failed: ${String(error)}`);
156
+ }
157
+ }
158
+ if (value === undefined) {
159
+ try {
160
+ const inspection = await persistenceService.inspect(id);
161
+ value = foldBillingUnit(unit, inspection.events);
162
+ }
163
+ catch (error) {
164
+ // One unreadable session must not blank the whole-day aggregate.
165
+ logger.warn(`llm-billing: skipping unreadable session ${id}: ${String(error)}`);
166
+ }
167
+ }
168
+ if (value !== undefined)
169
+ this.coldResolved.set(id, { revision, value });
170
+ });
171
+ for (const { id } of pending) {
172
+ const resolved = this.coldResolved.get(id);
173
+ if (resolved !== undefined && resolved.value.dayKey === dayKey) {
174
+ total = mergeTodaySpend(total, resolved.value.spend);
175
+ }
176
+ }
177
+ return total;
178
+ }
179
+ /** Events path: collect only today's events (capped), gated by revisions. */
180
+ async scanEvents(dayKey) {
181
+ const { sessions, persistence, maxEvents, logger, billing, catalog } = this.deps;
182
+ const events = [];
183
+ const liveIds = new Set();
184
+ let truncated = false;
185
+ if (sessions !== undefined) {
186
+ const store = sessions();
187
+ if (store !== undefined) {
188
+ for (const session of store.list()) {
189
+ liveIds.add(session.id);
190
+ for (const event of session.events) {
191
+ if (beijingDayKey(new Date(event.time)) !== dayKey)
192
+ continue;
193
+ events.push(event);
194
+ if (events.length >= maxEvents) {
195
+ truncated = true;
196
+ break;
197
+ }
198
+ }
199
+ if (truncated)
200
+ break;
201
+ }
202
+ }
203
+ }
204
+ const persistenceService = persistence?.();
205
+ if (!truncated && persistenceService !== undefined) {
206
+ const snapshots = await persistenceService.listSnapshots();
207
+ for (const { header, revision } of snapshots) {
208
+ if (liveIds.has(header.id))
209
+ continue;
210
+ if (this.lastEventsScan?.get(header.id) === revision)
211
+ continue;
212
+ try {
213
+ const inspection = await persistenceService.inspect(header.id);
214
+ for (const event of inspection.events) {
215
+ if (beijingDayKey(new Date(event.time)) !== dayKey)
216
+ continue;
217
+ events.push(event);
218
+ if (events.length >= maxEvents) {
219
+ truncated = true;
220
+ break;
221
+ }
222
+ }
223
+ }
224
+ catch (error) {
225
+ // One unreadable session must not blank the whole-day aggregate.
226
+ logger.warn(`llm-billing: skipping unreadable session ${header.id}: ${String(error)}`);
227
+ }
228
+ if (truncated)
229
+ break;
230
+ }
231
+ // Only a complete pass may advance the revision watermark: a truncated
232
+ // pass left sessions unread, and recording them would skip their events
233
+ // on the next scan.
234
+ if (!truncated) {
235
+ this.lastEventsScan = new Map(snapshots.map(snapshot => [snapshot.header.id, snapshot.revision]));
236
+ }
237
+ }
238
+ if (truncated)
239
+ logger.warn(`llm-billing: today's events exceeded ${maxEvents}; result truncated`);
240
+ // The reference moment is derived from the day key (UTC midnight on that
241
+ // date is 08:00 Beijing the same day), so the pricing re-check cannot
242
+ // drift from the collection filter across a Beijing-day boundary.
243
+ return computeTodaySpend(events, billing, catalog, new Date(`${dayKey}T00:00:00Z`));
244
+ }
245
+ }
246
+ //# sourceMappingURL=today-spend.js.map
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.1.0",
4
+ "version": "0.2.0",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -55,6 +55,7 @@
55
55
  "@deepseek-ai/dsh-llm": "^0.1.0-rc.8",
56
56
  "@deepseek-ai/dsh-session": "^0.1.0-rc.8",
57
57
  "@deepseek-ai/dsh-session-persistence": "^0.1.0-rc.8",
58
+ "@deepseek-ai/dsh-session-projection": "^0.1.0-rc.8",
58
59
  "@deepseek-ai/dsh-typert-protocol": "^0.1.0-rc.8",
59
60
  "@deepseek-ai/cordis": "^4.0.1"
60
61
  },
@@ -69,6 +70,7 @@
69
70
  "@deepseek-ai/dsh-llm": "^0.1.0-rc.8",
70
71
  "@deepseek-ai/dsh-session": "^0.1.0-rc.8",
71
72
  "@deepseek-ai/dsh-session-persistence": "^0.1.0-rc.8",
73
+ "@deepseek-ai/dsh-session-projection": "^0.1.0-rc.8",
72
74
  "@deepseek-ai/dsh-typert-protocol": "^0.1.0-rc.8",
73
75
  "@deepseek-ai/cordis": "^4.0.1"
74
76
  }