@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.
- package/README.i18n.yaml +2 -2
- package/README.md +14 -2
- package/README.zh.md +14 -2
- package/lib/index.js +490 -108
- package/lib/invariant.js +1 -1
- package/lib/typert.host.js +23 -11
- package/lib/typert.remote-client.d.ts +3 -3
- package/lib/typert.remote-client.js +23 -11
- package/lib/types/balance.d.ts +10 -3
- package/lib/types/balance.js +5 -2
- package/lib/types/billing.d.ts +72 -0
- package/lib/types/billing.js +142 -57
- package/lib/types/index.d.ts +19 -2
- package/lib/types/index.js +62 -52
- package/lib/types/projection.d.ts +69 -0
- package/lib/types/projection.js +88 -0
- package/lib/types/today-spend.d.ts +138 -0
- package/lib/types/today-spend.js +246 -0
- package/package.json +3 -1
|
@@ -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.
|
|
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
|
}
|