@ychris12138/dsh-usage-stats 0.2.10 → 0.3.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.md +99 -20
- package/SECURITY.md +3 -1
- package/docs/release-checklist.md +106 -0
- package/docs/release-notes-v0.3.0.md +25 -0
- package/lib/accounts.js +418 -171
- package/lib/balance.js +3 -5
- package/lib/billing.js +319 -0
- package/lib/client.js +622 -69
- package/lib/export.js +227 -0
- package/lib/index.js +435 -63
- package/lib/network.js +65 -0
- package/lib/pricing.js +391 -0
- package/lib/provider-identity.js +126 -0
- package/lib/usage.js +190 -13
- package/package.json +15 -5
package/lib/usage.js
CHANGED
|
@@ -17,6 +17,9 @@
|
|
|
17
17
|
* @module dsh-usage-stats/usage
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
+
import { resolveProviderIdentity } from "./provider-identity.js";
|
|
21
|
+
import { applyCostSample, costSampleOf, createCostAccumulator, mergeCostAccumulator, renderCost } from "./billing.js";
|
|
22
|
+
|
|
20
23
|
/** Local-calendar `YYYY-MM-DD` key for a millisecond epoch. */
|
|
21
24
|
export function dayKey(timeMs) {
|
|
22
25
|
const date = new Date(timeMs);
|
|
@@ -80,6 +83,77 @@ function subtractFrom(target, source) {
|
|
|
80
83
|
return target;
|
|
81
84
|
}
|
|
82
85
|
|
|
86
|
+
function billingEntryOf(byDay, day) {
|
|
87
|
+
let entry = byDay.get(day);
|
|
88
|
+
if (entry === void 0) {
|
|
89
|
+
entry = { total: createCostAccumulator(), models: new Map() };
|
|
90
|
+
byDay.set(day, entry);
|
|
91
|
+
}
|
|
92
|
+
return entry;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function modelCostOf(entry, model) {
|
|
96
|
+
let accumulator = entry.models.get(model);
|
|
97
|
+
if (accumulator === void 0) {
|
|
98
|
+
accumulator = createCostAccumulator();
|
|
99
|
+
entry.models.set(model, accumulator);
|
|
100
|
+
}
|
|
101
|
+
return accumulator;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function adjustIdentityCount(map, key, direction) {
|
|
105
|
+
const next = (map.get(key) ?? 0) + direction;
|
|
106
|
+
if (next <= 0) map.delete(key);
|
|
107
|
+
else map.set(key, next);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function createSessionBillingState() {
|
|
111
|
+
return {
|
|
112
|
+
total: createCostAccumulator(),
|
|
113
|
+
days: new Map(),
|
|
114
|
+
providers: new Map(),
|
|
115
|
+
models: new Map(),
|
|
116
|
+
sampleCount: 0,
|
|
117
|
+
firstAt: null,
|
|
118
|
+
lastAt: null,
|
|
119
|
+
penultimateAt: null
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function applySessionCost(state, sample, direction) {
|
|
124
|
+
const billing = state.billing;
|
|
125
|
+
applyCostSample(billing.total, sample.cost, direction);
|
|
126
|
+
const dayEntry = billingEntryOf(billing.days, sample.day);
|
|
127
|
+
applyCostSample(dayEntry.total, sample.cost, direction);
|
|
128
|
+
applyCostSample(modelCostOf(dayEntry, sample.model), sample.cost, direction);
|
|
129
|
+
if (sample.cost.counted !== true) return;
|
|
130
|
+
adjustIdentityCount(billing.providers, sample.providerId, direction);
|
|
131
|
+
adjustIdentityCount(billing.models, sample.model, direction);
|
|
132
|
+
if (direction < 0) {
|
|
133
|
+
billing.sampleCount = Math.max(0, billing.sampleCount - 1);
|
|
134
|
+
if (billing.sampleCount === 0) {
|
|
135
|
+
billing.firstAt = null;
|
|
136
|
+
billing.lastAt = null;
|
|
137
|
+
billing.penultimateAt = null;
|
|
138
|
+
} else {
|
|
139
|
+
billing.lastAt = billing.penultimateAt;
|
|
140
|
+
billing.penultimateAt = null;
|
|
141
|
+
}
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (billing.sampleCount === 0) billing.firstAt = sample.time;
|
|
145
|
+
billing.penultimateAt = billing.lastAt;
|
|
146
|
+
billing.lastAt = sample.time;
|
|
147
|
+
billing.sampleCount += 1;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function routeFromModelKey(key) {
|
|
151
|
+
if (typeof key !== "string") return { providerId: "unknown", model: "unknown" };
|
|
152
|
+
const slash = key.indexOf("/");
|
|
153
|
+
if (slash <= 0 || slash === key.length - 1) return { providerId: "unknown", model: "unknown" };
|
|
154
|
+
return { providerId: key.slice(0, slash), model: key.slice(slash + 1) };
|
|
155
|
+
}
|
|
156
|
+
|
|
83
157
|
/** Extract the usage sample an event carries, if any. */
|
|
84
158
|
function sampleOf(event) {
|
|
85
159
|
if (event.type === "assistant/chunk" && event.data?.chunk?.type === "usage") {
|
|
@@ -105,18 +179,29 @@ function sampleOf(event) {
|
|
|
105
179
|
* `request/header` `data.header.config`; samples with no model information
|
|
106
180
|
* land in the `unknown/unknown` bucket.
|
|
107
181
|
*/
|
|
108
|
-
function
|
|
182
|
+
export function routeModelOf(event) {
|
|
109
183
|
const source = event.data?.message?.source;
|
|
110
184
|
if (source !== void 0 && typeof source.model === "string") {
|
|
111
|
-
return
|
|
185
|
+
return {
|
|
186
|
+
providerId: typeof source.provider === "string" && source.provider.length > 0 ? source.provider : "unknown",
|
|
187
|
+
model: source.model
|
|
188
|
+
};
|
|
112
189
|
}
|
|
113
190
|
const config = event.data?.header?.config;
|
|
114
191
|
if (config !== void 0 && typeof config.model === "string") {
|
|
115
|
-
return
|
|
192
|
+
return {
|
|
193
|
+
providerId: typeof config.provider === "string" && config.provider.length > 0 ? config.provider : "unknown",
|
|
194
|
+
model: config.model
|
|
195
|
+
};
|
|
116
196
|
}
|
|
117
197
|
return void 0;
|
|
118
198
|
}
|
|
119
199
|
|
|
200
|
+
function modelOf(event) {
|
|
201
|
+
const route = routeModelOf(event);
|
|
202
|
+
return route === void 0 ? void 0 : `${route.providerId}/${route.model}`;
|
|
203
|
+
}
|
|
204
|
+
|
|
120
205
|
/** Day entry: totals plus a per-model bucket map. */
|
|
121
206
|
function entryOf(byDay, day) {
|
|
122
207
|
let entry = byDay.get(day);
|
|
@@ -134,14 +219,44 @@ function entryOf(byDay, day) {
|
|
|
134
219
|
* One session's incremental fold state. `days` holds the already-folded
|
|
135
220
|
* per-day entries; `lastSample`/`currentModel` let a later event slice keep
|
|
136
221
|
* the replace-last-sample semantics and model attribution across fold
|
|
137
|
-
* boundaries without replaying the whole log.
|
|
222
|
+
* boundaries without replaying the whole log. `currentRoute` is a lightweight
|
|
223
|
+
* provider/model projection over that same cursor, not a second usage ledger.
|
|
138
224
|
*/
|
|
139
225
|
export function createUsageState() {
|
|
140
|
-
return {
|
|
226
|
+
return resetUsageState({});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Reset only the incremental fold fields while preserving cache metadata. */
|
|
230
|
+
export function resetUsageState(state) {
|
|
231
|
+
return Object.assign(state, {
|
|
141
232
|
days: new Map(),
|
|
233
|
+
billing: createSessionBillingState(),
|
|
142
234
|
lastSample: null,
|
|
143
235
|
currentModel: null,
|
|
236
|
+
currentRoute: null,
|
|
144
237
|
consumed: 0
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Render the secret-free context for one session's latest provider/model pair.
|
|
243
|
+
* The provider object supplies connection identity only; credentials and
|
|
244
|
+
* monitor request details are intentionally excluded from the wire shape.
|
|
245
|
+
*/
|
|
246
|
+
export function currentSessionContext(sessionId, state, provider, config = { monitors: {} }) {
|
|
247
|
+
const current = state?.currentRoute;
|
|
248
|
+
if (current === null || current === void 0) return null;
|
|
249
|
+
const identity = resolveProviderIdentity({
|
|
250
|
+
...(provider ?? {}),
|
|
251
|
+
id: current.providerId
|
|
252
|
+
}, config);
|
|
253
|
+
return {
|
|
254
|
+
sessionId,
|
|
255
|
+
providerId: identity.routeId,
|
|
256
|
+
providerFamily: identity.providerFamily,
|
|
257
|
+
model: current.model,
|
|
258
|
+
accountId: identity.routeId,
|
|
259
|
+
updatedAt: current.updatedAt
|
|
145
260
|
};
|
|
146
261
|
}
|
|
147
262
|
|
|
@@ -154,18 +269,31 @@ export function createUsageState() {
|
|
|
154
269
|
* @param state - session fold state (mutated in place).
|
|
155
270
|
* @param events - the new events, in seq order, starting after the last fold.
|
|
156
271
|
*/
|
|
157
|
-
export function applyUsageDelta(state, events) {
|
|
272
|
+
export function applyUsageDelta(state, events, options = {}) {
|
|
158
273
|
let last = state.lastSample;
|
|
159
274
|
let currentModel = state.currentModel;
|
|
275
|
+
let currentRoute = state.currentRoute ?? null;
|
|
276
|
+
const estimateCost = typeof options.estimateCost === "function" ? options.estimateCost : null;
|
|
160
277
|
for (const event of events) {
|
|
161
|
-
if (event.type === "request/header") {
|
|
162
|
-
const
|
|
163
|
-
if (
|
|
278
|
+
if (event.type === "request/header" || event.type === "assistant/message") {
|
|
279
|
+
const route = routeModelOf(event);
|
|
280
|
+
if (route !== void 0) {
|
|
281
|
+
// Keep token attribution semantics unchanged: currentModel remains
|
|
282
|
+
// request/header-driven, while currentRoute may also use the model
|
|
283
|
+
// source on assistant/message for live context switching.
|
|
284
|
+
if (event.type === "request/header") currentModel = `${route.providerId}/${route.model}`;
|
|
285
|
+
currentRoute = {
|
|
286
|
+
providerId: route.providerId,
|
|
287
|
+
model: route.model,
|
|
288
|
+
updatedAt: Number.isFinite(event.time) ? event.time : currentRoute?.updatedAt ?? null
|
|
289
|
+
};
|
|
290
|
+
}
|
|
164
291
|
}
|
|
165
292
|
const sample = sampleOf(event);
|
|
166
293
|
if (sample === void 0) continue;
|
|
167
294
|
const buckets = bucketsOf(sample.usage);
|
|
168
295
|
const model = modelOf(event) ?? currentModel ?? "unknown/unknown";
|
|
296
|
+
const route = routeModelOf(event) ?? routeFromModelKey(model);
|
|
169
297
|
const day = dayKey(event.time);
|
|
170
298
|
const entry = entryOf(state.days, day);
|
|
171
299
|
if (last !== null && last.key === sample.key) {
|
|
@@ -176,6 +304,7 @@ export function applyUsageDelta(state, events) {
|
|
|
176
304
|
const previousModel = previous.models.get(last.model);
|
|
177
305
|
if (previousModel !== void 0) subtractFrom(previousModel, last.buckets);
|
|
178
306
|
}
|
|
307
|
+
if (last.cost !== void 0) applySessionCost(state, last, -1);
|
|
179
308
|
}
|
|
180
309
|
addInto(entry.totals, buckets);
|
|
181
310
|
let modelBucket = entry.models.get(model);
|
|
@@ -184,10 +313,26 @@ export function applyUsageDelta(state, events) {
|
|
|
184
313
|
entry.models.set(model, modelBucket);
|
|
185
314
|
}
|
|
186
315
|
addInto(modelBucket, buckets);
|
|
187
|
-
|
|
316
|
+
const estimate = estimateCost === null ? null : estimateCost({
|
|
317
|
+
providerId: route.providerId,
|
|
318
|
+
model: route.model,
|
|
319
|
+
timestamp: event.time,
|
|
320
|
+
buckets
|
|
321
|
+
});
|
|
322
|
+
last = {
|
|
323
|
+
key: sample.key,
|
|
324
|
+
day,
|
|
325
|
+
model,
|
|
326
|
+
providerId: route.providerId,
|
|
327
|
+
time: event.time,
|
|
328
|
+
buckets,
|
|
329
|
+
cost: costSampleOf(estimate, buckets)
|
|
330
|
+
};
|
|
331
|
+
applySessionCost(state, last, 1);
|
|
188
332
|
}
|
|
189
333
|
state.lastSample = last;
|
|
190
334
|
state.currentModel = currentModel;
|
|
335
|
+
state.currentRoute = currentRoute;
|
|
191
336
|
}
|
|
192
337
|
|
|
193
338
|
/**
|
|
@@ -222,6 +367,15 @@ export function mergeInto(byDay, sessionDays) {
|
|
|
222
367
|
}
|
|
223
368
|
}
|
|
224
369
|
|
|
370
|
+
/** Merge one session's monetary derivation into a global day map. */
|
|
371
|
+
export function mergeBillingInto(byDay, sessionDays) {
|
|
372
|
+
for (const [day, entry] of sessionDays) {
|
|
373
|
+
const target = billingEntryOf(byDay, day);
|
|
374
|
+
mergeCostAccumulator(target.total, entry.total);
|
|
375
|
+
for (const [model, accumulator] of entry.models) mergeCostAccumulator(modelCostOf(target, model), accumulator);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
225
379
|
/**
|
|
226
380
|
* Merge one session fold into a global per-day map (convenience wrapper).
|
|
227
381
|
* @param byDay - global map to mutate.
|
|
@@ -238,15 +392,17 @@ export function consumeEvents(byDay, events) {
|
|
|
238
392
|
* @returns `{ days, total, updatedAt }` with `days` sorted ascending; each
|
|
239
393
|
* day carries `models` (descending by tokens) and a `cacheHitRate` percent.
|
|
240
394
|
*/
|
|
241
|
-
export function renderUsage(byDay, updatedAt) {
|
|
395
|
+
export function renderUsage(byDay, updatedAt, billingByDay = new Map()) {
|
|
242
396
|
const days = [...byDay.entries()]
|
|
243
397
|
.map(([date, entry]) => {
|
|
398
|
+
const billing = billingByDay.get(date);
|
|
244
399
|
const models = [...entry.models.entries()]
|
|
245
400
|
.map(([model, buckets]) => ({
|
|
246
401
|
model,
|
|
247
402
|
...buckets,
|
|
248
403
|
tokens: totalTokens(buckets),
|
|
249
|
-
cacheHitRate: cacheHitRate(buckets)
|
|
404
|
+
cacheHitRate: cacheHitRate(buckets),
|
|
405
|
+
...renderCost(billing?.models.get(model) ?? createCostAccumulator())
|
|
250
406
|
}))
|
|
251
407
|
// All-zero buckets come from warmup requests that report
|
|
252
408
|
// {input:0, output:0}; rendering them produces empty "0 tokens"
|
|
@@ -258,19 +414,40 @@ export function renderUsage(byDay, updatedAt) {
|
|
|
258
414
|
...entry.totals,
|
|
259
415
|
tokens: totalTokens(entry.totals),
|
|
260
416
|
cacheHitRate: cacheHitRate(entry.totals),
|
|
417
|
+
...renderCost(billing?.total ?? createCostAccumulator()),
|
|
261
418
|
models
|
|
262
419
|
};
|
|
263
420
|
})
|
|
264
421
|
.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0));
|
|
265
422
|
const total = zeroBuckets();
|
|
266
423
|
for (const [, entry] of byDay) addInto(total, entry.totals);
|
|
424
|
+
const totalCost = createCostAccumulator();
|
|
425
|
+
for (const [, entry] of billingByDay) mergeCostAccumulator(totalCost, entry.total);
|
|
267
426
|
return {
|
|
268
427
|
days,
|
|
269
428
|
total: {
|
|
270
429
|
...total,
|
|
271
430
|
tokens: totalTokens(total),
|
|
272
|
-
cacheHitRate: cacheHitRate(total)
|
|
431
|
+
cacheHitRate: cacheHitRate(total),
|
|
432
|
+
...renderCost(totalCost)
|
|
273
433
|
},
|
|
274
434
|
updatedAt
|
|
275
435
|
};
|
|
276
436
|
}
|
|
437
|
+
|
|
438
|
+
/** Render one cached session without maintaining a second token total. */
|
|
439
|
+
export function renderSessionUsage(sessionId, state) {
|
|
440
|
+
const totals = zeroBuckets();
|
|
441
|
+
for (const [, entry] of state.days) addInto(totals, entry.totals);
|
|
442
|
+
return {
|
|
443
|
+
sessionId,
|
|
444
|
+
title: typeof state.title === "string" && state.title !== "" ? state.title : null,
|
|
445
|
+
providers: [...state.billing.providers.keys()].sort(),
|
|
446
|
+
models: [...state.billing.models.keys()].sort(),
|
|
447
|
+
...totals,
|
|
448
|
+
tokens: totalTokens(totals),
|
|
449
|
+
...renderCost(state.billing.total),
|
|
450
|
+
firstAt: Number.isFinite(state.billing.firstAt) ? new Date(state.billing.firstAt).toISOString() : null,
|
|
451
|
+
lastAt: Number.isFinite(state.billing.lastAt) ? new Date(state.billing.lastAt).toISOString() : null
|
|
452
|
+
};
|
|
453
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ychris12138/dsh-usage-stats",
|
|
3
|
-
"description": "Token usage
|
|
4
|
-
"version": "0.
|
|
3
|
+
"description": "Token usage, provider accounts, session cost estimates, budgets, and exports for the dsh web GUI",
|
|
4
|
+
"version": "0.3.0",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/Ychris12138/dsh-usage-stats.git"
|
|
@@ -21,6 +21,8 @@
|
|
|
21
21
|
"lib/",
|
|
22
22
|
"cordis.patch.yml",
|
|
23
23
|
"docs/images/usage-panel.svg",
|
|
24
|
+
"docs/release-checklist.md",
|
|
25
|
+
"docs/release-notes-v0.3.0.md",
|
|
24
26
|
"scripts/install.mjs",
|
|
25
27
|
"README.md",
|
|
26
28
|
"LICENSE",
|
|
@@ -31,6 +33,7 @@
|
|
|
31
33
|
"exports": {
|
|
32
34
|
".": "./lib/index.js",
|
|
33
35
|
"./client": "./lib/client.js",
|
|
36
|
+
"./pricing": "./lib/pricing.js",
|
|
34
37
|
"./usage": "./lib/usage.js",
|
|
35
38
|
"./package.json": "./package.json"
|
|
36
39
|
},
|
|
@@ -48,13 +51,19 @@
|
|
|
48
51
|
}
|
|
49
52
|
},
|
|
50
53
|
"scripts": {
|
|
51
|
-
"check": "node --check lib/index.js && node --check lib/usage.js && node --check lib/balance.js && node --check lib/subscriptions.js && node --check lib/accounts.js && node --check lib/client.js && node --check scripts/install.mjs && node --check scripts/smoke-client.mjs && node --check scripts/test-overlay-layering.mjs && node --check scripts/test-bundle.mjs && node --check scripts/test-install.mjs && node --check scripts/test-server.mjs && node --check scripts/test-balance.mjs && node --check scripts/test-subscriptions.mjs && node --check scripts/test-accounts.mjs && node --check scripts/validate-fold.mjs && node --check scripts/verify-raw.mjs && node --check scripts/check-balance.mjs",
|
|
52
|
-
"
|
|
54
|
+
"check": "npm run check:release && node --check lib/index.js && node --check lib/usage.js && node --check lib/billing.js && node --check lib/pricing.js && node --check lib/network.js && node --check lib/provider-identity.js && node --check lib/balance.js && node --check lib/subscriptions.js && node --check lib/accounts.js && node --check lib/export.js && node --check lib/client.js && node --check scripts/install.mjs && node --check scripts/smoke-client.mjs && node --check scripts/test-overlay-layering.mjs && node --check scripts/test-bundle.mjs && node --check scripts/test-install.mjs && node --check scripts/test-server.mjs && node --check scripts/test-export.mjs && node --check scripts/test-provider-identity.mjs && node --check scripts/test-pricing.mjs && node --check scripts/test-billing.mjs && node --check scripts/test-balance.mjs && node --check scripts/test-subscriptions.mjs && node --check scripts/test-accounts.mjs && node --check scripts/release-metadata.mjs && node --check scripts/check-release-metadata.mjs && node --check scripts/sync-release-version.mjs && node --check scripts/validate-fold.mjs && node --check scripts/verify-raw.mjs && node --check scripts/check-balance.mjs",
|
|
55
|
+
"check:release": "node scripts/check-release-metadata.mjs",
|
|
56
|
+
"release:sync": "node scripts/sync-release-version.mjs",
|
|
57
|
+
"test": "npm run test:bundle && npm run test:client && npm run test:overlay && npm run test:server && npm run test:export && npm run test:provider-identity && npm run test:pricing && npm run test:billing && npm run test:balance && npm run test:subscriptions && npm run test:accounts && npm run test:install",
|
|
53
58
|
"test:bundle": "node scripts/test-bundle.mjs",
|
|
54
59
|
"test:client": "node scripts/smoke-client.mjs",
|
|
55
60
|
"test:overlay": "node scripts/test-overlay-layering.mjs",
|
|
56
61
|
"test:install": "node scripts/test-install.mjs",
|
|
57
62
|
"test:server": "node scripts/test-server.mjs",
|
|
63
|
+
"test:export": "node scripts/test-export.mjs",
|
|
64
|
+
"test:provider-identity": "node scripts/test-provider-identity.mjs",
|
|
65
|
+
"test:pricing": "node scripts/test-pricing.mjs",
|
|
66
|
+
"test:billing": "node scripts/test-billing.mjs",
|
|
58
67
|
"test:balance": "node scripts/test-balance.mjs",
|
|
59
68
|
"test:subscriptions": "node scripts/test-subscriptions.mjs",
|
|
60
69
|
"test:accounts": "node scripts/test-accounts.mjs",
|
|
@@ -63,7 +72,8 @@
|
|
|
63
72
|
},
|
|
64
73
|
"devDependencies": {
|
|
65
74
|
"react": "^18.2.0",
|
|
66
|
-
"react-dom": "^18.2.0"
|
|
75
|
+
"react-dom": "^18.2.0",
|
|
76
|
+
"react-test-renderer": "^18.3.1"
|
|
67
77
|
},
|
|
68
78
|
"license": "MIT",
|
|
69
79
|
"publishConfig": {
|