@rayadesu/dsh-llm-billing 0.3.15 → 0.3.17
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 +18 -7
- package/README.zh.md +19 -8
- package/icon.svg +7 -0
- package/lib/index.js +791 -423
- package/lib/typert.host.js +17 -7
- package/lib/typert.remote-client.js +17 -7
- package/lib/types/balance.d.ts +10 -0
- package/lib/types/balance.js +79 -13
- package/lib/types/beijing-time.d.ts +52 -0
- package/lib/types/beijing-time.js +88 -0
- package/lib/types/billing.d.ts +27 -388
- package/lib/types/billing.js +28 -614
- package/lib/types/cache.d.ts +110 -0
- package/lib/types/cache.js +155 -0
- package/lib/types/fold.d.ts +93 -0
- package/lib/types/fold.js +188 -0
- package/lib/types/index.d.ts +1 -1
- package/lib/types/index.js +8 -5
- package/lib/types/persistence.d.ts +113 -0
- package/lib/types/persistence.js +63 -0
- package/lib/types/pricing-table.d.ts +43 -0
- package/lib/types/pricing-table.js +118 -0
- package/lib/types/pricing.d.ts +203 -0
- package/lib/types/pricing.js +203 -0
- package/lib/types/projection.js +14 -0
- package/lib/types/session-lineage.d.ts +70 -0
- package/lib/types/session-lineage.js +99 -0
- package/lib/types/spend.d.ts +58 -0
- package/lib/types/spend.js +190 -0
- package/lib/types/today-spend.d.ts +27 -233
- package/lib/types/today-spend.js +73 -254
- package/lib/types/types.d.ts +28 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +5 -1
package/lib/types/today-spend.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* `billingTodaySpend` projection cell; cold sessions are answered from the
|
|
8
8
|
* zero-I/O projection-cache row whenever that row's own day is not the
|
|
9
9
|
* queried one, and otherwise resolved through one detached local fold over
|
|
10
|
-
* a full `
|
|
10
|
+
* a full `open` + handle read. Persisted revisions gate every cold read, so a session
|
|
11
11
|
* whose log did not change since the last resolution costs nothing — and a
|
|
12
12
|
* failed resolution is remembered by revision instead of being retried on
|
|
13
13
|
* every scan.
|
|
@@ -38,258 +38,29 @@
|
|
|
38
38
|
* cut, and `apply` skips events below it), so the eager cell is correct for a
|
|
39
39
|
* fork child; the cold path skips the projection cache for a seeded session
|
|
40
40
|
* (its cached row may predate the boundary) and folds its own events with the
|
|
41
|
-
* durable cut instead. The boundary is the durable session state, read
|
|
42
|
-
*
|
|
41
|
+
* durable cut instead. The boundary is the durable session state, read from the
|
|
42
|
+
* live-session and handle surfaces — a resumed fork child keeps its original
|
|
43
43
|
* boundary and an unseeded session stays at 0.
|
|
44
44
|
*
|
|
45
45
|
* The live `Session` log surface changed in 0.1.2-alpha.4: `Session.events`
|
|
46
46
|
* was removed and replaced by `Session.snapshotEvents()` / `ownEvents()`, and
|
|
47
47
|
* `SessionHeader.seedLength` moved to `Session.inheritedEventCount` (the
|
|
48
|
-
*
|
|
49
|
-
* {@link liveSessionEvents} / {@link forkBoundaryOf}, which accept
|
|
50
|
-
*
|
|
51
|
-
* and on the newer runtime.
|
|
48
|
+
* opened `SessionHandle` carries it alongside `header`). Reads go through
|
|
49
|
+
* {@link liveSessionEvents} / {@link forkBoundaryOf}, which accept the
|
|
50
|
+
* live-session and handle shapes structurally, so the scanner runs on the
|
|
51
|
+
* older `events` runtime and on the newer `snapshotEvents` runtime.
|
|
52
52
|
* @module @rayadesu/dsh-llm-billing/today-spend
|
|
53
53
|
*/
|
|
54
|
-
import {
|
|
54
|
+
import { beijingDayRangeOfKey, beijingPartsOf, BillingFolder, emptyTodaySpend, forkBoundaryOf, isSeededSession, mergeTodaySpend } from "./billing.js";
|
|
55
55
|
import { BILLING_UNIT_KEY, foldOwnBilling } from "./projection.js";
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
|
|
64
|
-
export function foldSessionTitle(events) {
|
|
65
|
-
for (let index = events.length - 1; index >= 0; index--) {
|
|
66
|
-
const event = events[index];
|
|
67
|
-
// `session/title` joined the SessionEvent union after the npm
|
|
68
|
-
// 0.1.1-rc.2 baseline this package builds against; read its payload
|
|
69
|
-
// through the structural escape hatch (runtime logs carry it).
|
|
70
|
-
if (event.type !== 'session/title')
|
|
71
|
-
continue;
|
|
72
|
-
const data = event.data;
|
|
73
|
-
return typeof data.title === 'string' ? data.title : null;
|
|
74
|
-
}
|
|
75
|
-
return null;
|
|
76
|
-
}
|
|
77
|
-
/**
|
|
78
|
-
* Whether one session's durable header marks it as a subagent child. Either
|
|
79
|
-
* marker is enough: `origin` is DSH's navigation classification and
|
|
80
|
-
* `delegationDepth` is its persisted recursion budget, so a header carrying
|
|
81
|
-
* only the depth (or only the origin) is still a delegation child. A session
|
|
82
|
-
* created without either — an ordinary session, a user fork, or a cold resume
|
|
83
|
-
* — is top-level.
|
|
84
|
-
* @param header - the session's lineage slice; `undefined` reads as top-level.
|
|
85
|
-
* @returns true when the session was created as a subagent child.
|
|
86
|
-
*/
|
|
87
|
-
export function isSubagentSession(header) {
|
|
88
|
-
if (header === undefined)
|
|
89
|
-
return false;
|
|
90
|
-
return header.origin === 'subagent' || (header.delegationDepth ?? 0) > 0;
|
|
91
|
-
}
|
|
92
|
-
/**
|
|
93
|
-
* The top-level session one session's ranking row belongs to: the session
|
|
94
|
-
* itself for a top-level session, and for a subagent child the first ancestor
|
|
95
|
-
* up the `parentSession` chain that is not itself a subagent child. A
|
|
96
|
-
* multi-generation delegation (a subagent that spawned subagents) therefore
|
|
97
|
-
* lands on the same root row as its parent, and a child whose parent header is
|
|
98
|
-
* unknown is attributed to the parent id its own header names — the parent is
|
|
99
|
-
* authoritative even when its log is not part of this scan.
|
|
100
|
-
* @param id - the session whose row is being attributed.
|
|
101
|
-
* @param lineage - lineage of every session this scan saw, by id.
|
|
102
|
-
* @returns the session id whose ranking row the input belongs to.
|
|
103
|
-
*/
|
|
104
|
-
export function topLevelSessionOf(id, lineage) {
|
|
105
|
-
let current = id;
|
|
106
|
-
// A malformed log could claim a delegation cycle; each step visits a
|
|
107
|
-
// distinct ancestor, so a repeat ends the walk instead of spinning.
|
|
108
|
-
const seen = new Set([current]);
|
|
109
|
-
for (;;) {
|
|
110
|
-
const header = lineage.get(current);
|
|
111
|
-
if (!isSubagentSession(header))
|
|
112
|
-
return current;
|
|
113
|
-
const parent = header?.parentSession;
|
|
114
|
-
if (parent === undefined || seen.has(parent))
|
|
115
|
-
return current;
|
|
116
|
-
seen.add(parent);
|
|
117
|
-
current = parent;
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
/**
|
|
121
|
-
* Fold every subagent child's row into the top-level row it belongs to
|
|
122
|
-
* ({@link topLevelSessionOf}), so the ranking lists conversations rather than
|
|
123
|
-
* every delegation a conversation started. A child's spend is added to its
|
|
124
|
-
* ancestor's `total`; the ancestor's `ownTotal` keeps its own spend only. A
|
|
125
|
-
* top-level session whose own day was empty but whose subagents priced
|
|
126
|
-
* something still gets a row (with `ownTotal` 0), carrying the title the scan
|
|
127
|
-
* resolved for it in `titles`.
|
|
128
|
-
* @param rows - one row per session that priced something today (own spends).
|
|
129
|
-
* @param lineage - lineage of every session this scan saw, by id.
|
|
130
|
-
* @param titles - resolved display titles by session id; a session absent from
|
|
131
|
-
* the map has no resolved title and its created row reports `null`.
|
|
132
|
-
* @returns the merged rows, sorted by `total` descending.
|
|
133
|
-
*/
|
|
134
|
-
export function rollUpSubagentSpend(rows, lineage, titles = new Map()) {
|
|
135
|
-
const merged = new Map();
|
|
136
|
-
for (const row of rows) {
|
|
137
|
-
const target = topLevelSessionOf(row.sessionId, lineage);
|
|
138
|
-
const carried = merged.get(target);
|
|
139
|
-
if (carried === undefined) {
|
|
140
|
-
// A row's own session keeps its own total as `ownTotal`; a row created
|
|
141
|
-
// for an ancestor that priced nothing today carries zero there.
|
|
142
|
-
merged.set(target, target === row.sessionId
|
|
143
|
-
? row
|
|
144
|
-
: { sessionId: target, title: titles.get(target) ?? null, total: row.total, ownTotal: 0 });
|
|
145
|
-
continue;
|
|
146
|
-
}
|
|
147
|
-
merged.set(target, { ...carried, total: carried.total + row.total });
|
|
148
|
-
}
|
|
149
|
-
return [...merged.values()].sort((left, right) => right.total - left.total);
|
|
150
|
-
}
|
|
151
|
-
/**
|
|
152
|
-
* Read one live session's complete event log across both runtime families.
|
|
153
|
-
* @throws when the session exposes neither the legacy `events` snapshot nor
|
|
154
|
-
* the newer `snapshotEvents()` reader — an unknown runtime surface must
|
|
155
|
-
* fail loudly rather than silently price an empty log.
|
|
156
|
-
*/
|
|
157
|
-
export function liveSessionEvents(session) {
|
|
158
|
-
if (session.events !== undefined)
|
|
159
|
-
return session.events;
|
|
160
|
-
// Optional-call form keeps the receiver bound (snapshotEvents uses `this`).
|
|
161
|
-
if (session.snapshotEvents !== undefined)
|
|
162
|
-
return session.snapshotEvents();
|
|
163
|
-
throw new Error('llm-billing: session log surface is neither Session.events nor Session.snapshotEvents');
|
|
164
|
-
}
|
|
165
|
-
/**
|
|
166
|
-
* Unwrap a handle read across both return shapes.
|
|
167
|
-
* @param read - the handle's read result.
|
|
168
|
-
* @returns the event array.
|
|
169
|
-
*/
|
|
170
|
-
export function handleReadEvents(read) {
|
|
171
|
-
// `Array.isArray` does not narrow readonly arrays out of a union, so the
|
|
172
|
-
// branches are asserted explicitly.
|
|
173
|
-
if (Array.isArray(read))
|
|
174
|
-
return read;
|
|
175
|
-
return read.events;
|
|
176
|
-
}
|
|
177
|
-
function isHandlePersistence(persistence) {
|
|
178
|
-
return typeof persistence.open === 'function';
|
|
179
|
-
}
|
|
180
|
-
/**
|
|
181
|
-
* List every stored session snapshot across both persistence runtime families:
|
|
182
|
-
* `listSnapshots` (≤ 0.1.1-rc.2) or `list` (0.1.2-alpha.5+).
|
|
183
|
-
* @param persistence - the persistence service slice.
|
|
184
|
-
* @returns one snapshot per stored session.
|
|
185
|
-
*/
|
|
186
|
-
export function persistenceListSnapshots(persistence) {
|
|
187
|
-
return isHandlePersistence(persistence)
|
|
188
|
-
? persistence.list()
|
|
189
|
-
: persistence.listSnapshots();
|
|
190
|
-
}
|
|
191
|
-
/**
|
|
192
|
-
* Read one stored session's complete event log and durable inherited boundary
|
|
193
|
-
* across both persistence runtime families: legacy `inspect` (≤ 0.1.1-rc.2)
|
|
194
|
-
* or `open` + handle `read` (0.1.2-alpha.5+; the handle is closed after the
|
|
195
|
-
* read). Both throw when the session does not exist.
|
|
196
|
-
* @param persistence - the persistence service slice.
|
|
197
|
-
* @param id - the stored session to read.
|
|
198
|
-
* @returns the session's complete event log plus its inherited-prefix boundary.
|
|
199
|
-
*/
|
|
200
|
-
export async function persistenceInspect(persistence, id) {
|
|
201
|
-
if (isHandlePersistence(persistence)) {
|
|
202
|
-
const handle = await persistence.open(id, 'read');
|
|
203
|
-
try {
|
|
204
|
-
return { events: handleReadEvents(await handle.read()), seedLength: forkBoundaryOf(handle) };
|
|
205
|
-
}
|
|
206
|
-
finally {
|
|
207
|
-
await handle.close();
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
const inspection = await persistence.inspect(id);
|
|
211
|
-
return { events: inspection.events, seedLength: forkBoundaryOf(inspection) };
|
|
212
|
-
}
|
|
213
|
-
/**
|
|
214
|
-
* Bounded parallel fan-out: run `run` over `items` with at most `limit` in
|
|
215
|
-
* flight. A shared index counter hands each worker its next job, so the
|
|
216
|
-
* dispatch is O(n) overall (array `shift()` would be O(n) per pop).
|
|
217
|
-
*/
|
|
218
|
-
async function withConcurrency(items, limit, run) {
|
|
219
|
-
const total = items.length;
|
|
220
|
-
let next = 0;
|
|
221
|
-
await Promise.all(Array.from({ length: Math.min(limit, total) }, async () => {
|
|
222
|
-
for (let job = next; job < total; job = next) {
|
|
223
|
-
next += 1;
|
|
224
|
-
await run(items[job]);
|
|
225
|
-
}
|
|
226
|
-
}));
|
|
227
|
-
}
|
|
228
|
-
/**
|
|
229
|
-
* The A1 cache: one Beijing-day key + a 60s window, an in-flight promise that
|
|
230
|
-
* coalesces concurrent misses, and a `force` bypass for the manual refresh
|
|
231
|
-
* path. Cross-day invalidation is automatic (the day key changes); a failed
|
|
232
|
-
* scan leaves the previous value in place and retries on the next call.
|
|
233
|
-
* @typeParam T - the cached aggregate's value shape (the spend or its
|
|
234
|
-
* per-session breakdown).
|
|
235
|
-
*/
|
|
236
|
-
export class TodaySpendCache {
|
|
237
|
-
scan;
|
|
238
|
-
ttlMs;
|
|
239
|
-
now;
|
|
240
|
-
cachedDayKey;
|
|
241
|
-
cachedValue;
|
|
242
|
-
cachedAt = 0;
|
|
243
|
-
inFlight;
|
|
244
|
-
/**
|
|
245
|
-
* @param scan - the aggregate computation behind a miss.
|
|
246
|
-
* @param ttlMs - time window in milliseconds (default 60 000).
|
|
247
|
-
* @param now - clock source (injectable for tests).
|
|
248
|
-
*/
|
|
249
|
-
constructor(scan, ttlMs = 60_000, now = () => new Date()) {
|
|
250
|
-
this.scan = scan;
|
|
251
|
-
this.ttlMs = ttlMs;
|
|
252
|
-
this.now = now;
|
|
253
|
-
}
|
|
254
|
-
/**
|
|
255
|
-
* Read today's spend, cached per Beijing day within the TTL window.
|
|
256
|
-
* @param force - bypass the time window (manual refresh); the day-key gate
|
|
257
|
-
* and the in-flight coalescing still apply to non-force callers.
|
|
258
|
-
* @returns today's spend.
|
|
259
|
-
*/
|
|
260
|
-
get(force = false) {
|
|
261
|
-
const now = this.now();
|
|
262
|
-
const dayKey = beijingDayKey(now);
|
|
263
|
-
if (!force && this.cachedDayKey === dayKey && this.cachedValue !== undefined
|
|
264
|
-
&& now.getTime() - this.cachedAt < this.ttlMs) {
|
|
265
|
-
return Promise.resolve(this.cachedValue);
|
|
266
|
-
}
|
|
267
|
-
// A scan already in flight is fresh by definition, so a forced caller
|
|
268
|
-
// joins it instead of starting a second pass.
|
|
269
|
-
if (this.inFlight !== undefined)
|
|
270
|
-
return this.inFlight;
|
|
271
|
-
const run = (async () => {
|
|
272
|
-
try {
|
|
273
|
-
const value = await this.scan(dayKey);
|
|
274
|
-
this.cachedDayKey = dayKey;
|
|
275
|
-
this.cachedValue = value;
|
|
276
|
-
this.cachedAt = now.getTime();
|
|
277
|
-
return value;
|
|
278
|
-
}
|
|
279
|
-
finally {
|
|
280
|
-
this.inFlight = undefined;
|
|
281
|
-
}
|
|
282
|
-
})();
|
|
283
|
-
this.inFlight = run;
|
|
284
|
-
return run;
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
/** Max session-ids kept in the scanner's cold-resolution cache before eviction. */
|
|
288
|
-
export const COLD_RESOLVE_CACHE_LIMIT = 1024;
|
|
289
|
-
/** Max session-ids kept in the scanner's cold-failure cache before eviction. */
|
|
290
|
-
export const COLD_FAILED_CACHE_LIMIT = 1024;
|
|
291
|
-
/** Bounded parallel fan-out for cold-session resolution. */
|
|
292
|
-
export const COLD_RESOLVE_CONCURRENCY = 8;
|
|
56
|
+
import { foldSessionTitle, isSubagentSession, rollUpSubagentSpend } from "./session-lineage.js";
|
|
57
|
+
import { COLD_FAILED_CACHE_LIMIT, COLD_FAILED_RETRY_MS, COLD_RESOLVE_CACHE_LIMIT, COLD_RESOLVE_CONCURRENCY, SCAN_YIELD_SESSIONS, withConcurrency, yieldToEventLoop } from "./cache.js";
|
|
58
|
+
import { liveSessionEvents, persistenceInspect, persistenceListSnapshots } from "./persistence.js";
|
|
59
|
+
// Re-exported so the package's import surface is unchanged: these names were
|
|
60
|
+
// declared here before the module was split.
|
|
61
|
+
export * from "./session-lineage.js";
|
|
62
|
+
export * from "./persistence.js";
|
|
63
|
+
export * from "./cache.js";
|
|
293
64
|
/**
|
|
294
65
|
* Bounded-map eviction: drop the oldest inserted entry once `size` reached
|
|
295
66
|
* `limit`. Evicting one entry (instead of clearing) keeps the other sessions'
|
|
@@ -367,7 +138,12 @@ export class TodaySpendScanner {
|
|
|
367
138
|
* from the aggregate and the ranking on every scan after the first.
|
|
368
139
|
*/
|
|
369
140
|
coldResolved = new Map();
|
|
370
|
-
/**
|
|
141
|
+
/**
|
|
142
|
+
* Cold sessions whose resolution failed, id → the revision it failed at plus
|
|
143
|
+
* when. Keyed by revision too, so an unchanged log is not re-read every scan,
|
|
144
|
+
* but the retry is TIME-boxed: a purely revision-keyed failure would hide a
|
|
145
|
+
* session permanently when its log never changes again.
|
|
146
|
+
*/
|
|
371
147
|
coldFailed = new Map();
|
|
372
148
|
constructor(deps) {
|
|
373
149
|
this.deps = deps;
|
|
@@ -410,7 +186,10 @@ export class TodaySpendScanner {
|
|
|
410
186
|
* otherwise.
|
|
411
187
|
*
|
|
412
188
|
* The aggregate sums every priced session, subagents included — the ranking's
|
|
413
|
-
* subagent roll-up only regroups rows, so neither total moves.
|
|
189
|
+
* subagent roll-up only regroups rows, so neither total moves. Both paths
|
|
190
|
+
* fold in slices and hand the host's event loop back every
|
|
191
|
+
* {@link SCAN_YIELD_SESSIONS} sessions, so a long scan never starves the
|
|
192
|
+
* GUI's own round trips.
|
|
414
193
|
* @param dayKey - the Beijing-time calendar-day key to aggregate.
|
|
415
194
|
* @returns the aggregate plus per-session rows sorted by cost descending.
|
|
416
195
|
*/
|
|
@@ -427,14 +206,14 @@ export class TodaySpendScanner {
|
|
|
427
206
|
* own latest priced day is NOT the queried day: the row then proves the
|
|
428
207
|
* session contributed nothing to the queried day, so the log is never read.
|
|
429
208
|
* When the row IS the queried day (or no usable row exists) the session is
|
|
430
|
-
*
|
|
209
|
+
* opened and folded locally, because the row may trail the log (a crash
|
|
431
210
|
* between the last checkpoint and the session's last event).
|
|
432
211
|
*
|
|
433
212
|
* A cache-served value carries no title (the ladder only stores projection
|
|
434
213
|
* values), so such rows report `title: null`. A SEEDED session (fork child)
|
|
435
214
|
* skips the cache entirely: its cached row was folded over the inherited
|
|
436
|
-
* prefix too, so it always detaches through
|
|
437
|
-
* boundary (the
|
|
215
|
+
* prefix too, so it always detaches through a handle read with the durable
|
|
216
|
+
* boundary (the handle's `inheritedEventCount` or `header.seedLength`,
|
|
438
217
|
* depending on the runtime family) applied to the local fold.
|
|
439
218
|
* @param header - the listed session header (the cache identity witness).
|
|
440
219
|
* @param seeded - whether the session carries a fork-inherited prefix.
|
|
@@ -508,11 +287,23 @@ export class TodaySpendScanner {
|
|
|
508
287
|
adopt(header.id, resolved);
|
|
509
288
|
continue;
|
|
510
289
|
}
|
|
511
|
-
|
|
290
|
+
// Skipped while the SAME revision is still inside its retry window; once
|
|
291
|
+
// the window lapses the read is attempted again even though nothing
|
|
292
|
+
// changed, which is what rescues a transient failure.
|
|
293
|
+
const failed = this.coldFailed.get(header.id);
|
|
294
|
+
if (failed !== undefined && failed.revision === revision
|
|
295
|
+
&& Date.now() - failed.at < COLD_FAILED_RETRY_MS)
|
|
512
296
|
continue;
|
|
513
297
|
pending.push({ header, revision, seeded });
|
|
514
298
|
}
|
|
299
|
+
let resolvedCount = 0;
|
|
515
300
|
await withConcurrency(pending, COLD_RESOLVE_CONCURRENCY, async ({ header, revision, seeded }) => {
|
|
301
|
+
// A resolution answered straight from the projection cache performs no
|
|
302
|
+
// I/O, so without this the fan-out would run a long microtask chain that
|
|
303
|
+
// never reaches the event loop's poll phase.
|
|
304
|
+
resolvedCount += 1;
|
|
305
|
+
if (resolvedCount % SCAN_YIELD_SESSIONS === 0)
|
|
306
|
+
await yieldToEventLoop();
|
|
516
307
|
const resolved = await this.resolveCold(header, seeded, dayKey);
|
|
517
308
|
if (resolved !== undefined) {
|
|
518
309
|
this.coldFailed.delete(header.id);
|
|
@@ -520,10 +311,16 @@ export class TodaySpendScanner {
|
|
|
520
311
|
}
|
|
521
312
|
else if (persistenceAvailable) {
|
|
522
313
|
evictOldest(this.coldFailed, COLD_FAILED_CACHE_LIMIT);
|
|
523
|
-
this.coldFailed.set(header.id, revision);
|
|
314
|
+
this.coldFailed.set(header.id, { revision, at: Date.now() });
|
|
524
315
|
}
|
|
525
316
|
});
|
|
317
|
+
let adopted = 0;
|
|
526
318
|
for (const { header } of pending) {
|
|
319
|
+
// The adopt below merges synchronously; a day with many cold sessions
|
|
320
|
+
// must not fold them all in one uninterrupted slice.
|
|
321
|
+
adopted += 1;
|
|
322
|
+
if (adopted % SCAN_YIELD_SESSIONS === 0)
|
|
323
|
+
await yieldToEventLoop();
|
|
527
324
|
const resolved = this.coldResolved.get(header.id);
|
|
528
325
|
if (resolved !== undefined)
|
|
529
326
|
adopt(header.id, resolved);
|
|
@@ -550,6 +347,14 @@ export class TodaySpendScanner {
|
|
|
550
347
|
const liveIds = new Set();
|
|
551
348
|
let collected = 0;
|
|
552
349
|
let truncated = false;
|
|
350
|
+
// The queried day as numbers — one conversion per scan instead of a date
|
|
351
|
+
// string per event, since the counting below walks every event of every
|
|
352
|
+
// log. A key that does not parse falls back to the string test rather than
|
|
353
|
+
// silently claiming no event belongs to the day.
|
|
354
|
+
const dayRange = beijingDayRangeOfKey(dayKey);
|
|
355
|
+
const onDay = dayRange === undefined
|
|
356
|
+
? (event) => beijingPartsOf(event.time).dayKey === dayKey
|
|
357
|
+
: (event) => event.time >= dayRange.start && event.time < dayRange.end;
|
|
553
358
|
/** Price one complete log, announce it, and return what to remember. */
|
|
554
359
|
const collect = (id, events, seedLength, lineage) => {
|
|
555
360
|
const folder = new BillingFolder(billing, catalog, seedLength);
|
|
@@ -557,7 +362,7 @@ export class TodaySpendScanner {
|
|
|
557
362
|
// The cap counts the queried day's events; the fold still sees every
|
|
558
363
|
// event up to the cap (model tracking and attempt replacement need
|
|
559
364
|
// the surrounding events).
|
|
560
|
-
if (
|
|
365
|
+
if (onDay(event)) {
|
|
561
366
|
collected += 1;
|
|
562
367
|
if (collected > maxEvents) {
|
|
563
368
|
truncated = true;
|
|
@@ -574,7 +379,14 @@ export class TodaySpendScanner {
|
|
|
574
379
|
if (sessions !== undefined) {
|
|
575
380
|
const store = sessions();
|
|
576
381
|
if (store !== undefined) {
|
|
382
|
+
let folded = 0;
|
|
577
383
|
for (const session of store.list()) {
|
|
384
|
+
// The fold below walks this session's whole log synchronously on the
|
|
385
|
+
// host's main loop; hand the loop back every few sessions so the GUI's
|
|
386
|
+
// own round trips still get served during a long scan.
|
|
387
|
+
folded += 1;
|
|
388
|
+
if (folded % SCAN_YIELD_SESSIONS === 0)
|
|
389
|
+
await yieldToEventLoop();
|
|
578
390
|
liveIds.add(session.id);
|
|
579
391
|
collect(session.id, liveSessionEvents(session), forkBoundaryOf(session), session.header ?? {});
|
|
580
392
|
if (truncated)
|
|
@@ -618,7 +430,7 @@ export class TodaySpendScanner {
|
|
|
618
430
|
/**
|
|
619
431
|
* Projection path, one pass for both outputs: eager cells for live sessions
|
|
620
432
|
* (title folded from the live log, so a rename is reflected immediately),
|
|
621
|
-
* revision-gated cold ladder for the rest (title resolved on
|
|
433
|
+
* revision-gated cold ladder for the rest (title resolved on the handle read, `null`
|
|
622
434
|
* when answered from the projection cache). A fork child's cell covers its
|
|
623
435
|
* inherited prefix, so its own-events fold supplies both outputs. Lineage
|
|
624
436
|
* (which session delegated which) comes from the same headers the boundary
|
|
@@ -640,7 +452,14 @@ export class TodaySpendScanner {
|
|
|
640
452
|
if (sessions !== undefined) {
|
|
641
453
|
const store = sessions();
|
|
642
454
|
if (store !== undefined) {
|
|
455
|
+
let folded = 0;
|
|
643
456
|
for (const { session, state } of this.liveBillingEntries(store, projectionsService)) {
|
|
457
|
+
// The title fold below materialises and walks this session's whole
|
|
458
|
+
// log synchronously on the host's main loop; hand the loop back every
|
|
459
|
+
// few sessions so the GUI's own round trips still get served.
|
|
460
|
+
folded += 1;
|
|
461
|
+
if (folded % SCAN_YIELD_SESSIONS === 0)
|
|
462
|
+
await yieldToEventLoop();
|
|
644
463
|
liveIds.add(session.id);
|
|
645
464
|
lineage.set(session.id, session.header ?? {});
|
|
646
465
|
const born = session.header?.createdAt;
|
package/lib/types/types.d.ts
CHANGED
|
@@ -65,6 +65,26 @@ export interface DeepSeekSessionSpend {
|
|
|
65
65
|
total: number;
|
|
66
66
|
/** One row per model that reported usage AND has a pricing row; empty when the session has no priced usage. */
|
|
67
67
|
models: readonly DeepSeekSessionSpendModel[];
|
|
68
|
+
/**
|
|
69
|
+
* Samples that carried usage but have NO pricing row, and so contribute
|
|
70
|
+
* nothing to {@link total} — so a session that only ever used an unlisted
|
|
71
|
+
* model does not read as "no usage" beside a today row that says otherwise.
|
|
72
|
+
*/
|
|
73
|
+
unpriced?: DeepSeekUnpricedUsage | undefined;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Provider-reported usage that could NOT be billed: it matched no pricing row,
|
|
77
|
+
* and no rate is ever guessed from a similar-looking model id. Purely advisory
|
|
78
|
+
* — it never changes a spend's `total`, and the whole field is omitted when
|
|
79
|
+
* every sample was priced against its own row.
|
|
80
|
+
*/
|
|
81
|
+
export interface DeepSeekUnpricedUsage {
|
|
82
|
+
/** Number of usage samples affected. */
|
|
83
|
+
events: number;
|
|
84
|
+
/** Token count affected: input + cache read + cache write + output tokens. */
|
|
85
|
+
tokens: number;
|
|
86
|
+
/** The wire model ids affected, in first-seen order. */
|
|
87
|
+
models: readonly string[];
|
|
68
88
|
}
|
|
69
89
|
/** The billed spend of every session on one Beijing-time calendar day. */
|
|
70
90
|
export interface DeepSeekTodaySpend {
|
|
@@ -72,6 +92,14 @@ export interface DeepSeekTodaySpend {
|
|
|
72
92
|
total: number;
|
|
73
93
|
/** One row per model that reported usage AND has a pricing row; empty when today has no priced usage. */
|
|
74
94
|
models: readonly DeepSeekSessionSpendModel[];
|
|
95
|
+
/**
|
|
96
|
+
* Samples that carried usage but have NO pricing row, and so contribute
|
|
97
|
+
* nothing to {@link total}. Without this, an upstream model that ships before
|
|
98
|
+
* its rate row reads exactly like "no usage" (see MiMo-V2.6, which arrived
|
|
99
|
+
* before the table knew it) — the difference is what this field exists to
|
|
100
|
+
* report. Add the row under `billing.models` to price these.
|
|
101
|
+
*/
|
|
102
|
+
unpriced?: DeepSeekUnpricedUsage | undefined;
|
|
75
103
|
}
|
|
76
104
|
/** One session's billed spend on one Beijing-time calendar day. */
|
|
77
105
|
export interface DeepSeekTodaySessionSpend {
|
package/locale/en.json
ADDED
package/locale/zh.json
ADDED
package/package.json
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
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
|
-
"
|
|
4
|
+
"icon": "./icon.svg",
|
|
5
|
+
"version": "0.3.17",
|
|
5
6
|
"publishConfig": {
|
|
6
7
|
"access": "public"
|
|
7
8
|
},
|
|
@@ -39,9 +40,12 @@
|
|
|
39
40
|
"default": "./lib/typert.remote-client.js"
|
|
40
41
|
},
|
|
41
42
|
"./src/*": "./src/*",
|
|
43
|
+
"./locale/*.json": "./locale/*.json",
|
|
42
44
|
"./package.json": "./package.json"
|
|
43
45
|
},
|
|
44
46
|
"files": [
|
|
47
|
+
"locale/*.json",
|
|
48
|
+
"icon.svg",
|
|
45
49
|
"lib/index.js",
|
|
46
50
|
"lib/invariant.js",
|
|
47
51
|
"lib/types/**/*.js",
|