@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/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 modelOf(event) {
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 `${typeof source.provider === "string" && source.provider.length > 0 ? source.provider : "unknown"}/${source.model}`;
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 `${typeof config.provider === "string" && config.provider.length > 0 ? config.provider : "unknown"}/${config.model}`;
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 model = modelOf(event);
163
- if (model !== void 0) currentModel = model;
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
- last = { key: sample.key, day, model, buckets };
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 heatmap, provider balances, and subscription quotas for the dsh web GUI",
4
- "version": "0.2.10",
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
- "test": "npm run test:bundle && npm run test:client && npm run test:overlay && npm run test:server && npm run test:balance && npm run test:subscriptions && npm run test:accounts && npm run test:install",
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": {