@ychris12138/dsh-usage-stats 0.2.9 → 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/index.js CHANGED
@@ -1,12 +1,16 @@
1
1
  /**
2
2
  * dsh-usage-stats — server half.
3
3
  *
4
- * Registers five read-only, loopback-only endpoints on the web server:
4
+ * Registers nine read-only, loopback-only endpoints on the web server:
5
5
  * GET /api/usage-stats/usage — per-day token usage across every session
6
6
  * GET /api/usage-stats/providers — configured providers + balance schemes
7
7
  * GET /api/usage-stats/balance — balance for one provider (?provider=<id>)
8
8
  * GET /api/usage-stats/subscriptions — OpenCode Go + Z.ai quota windows
9
9
  * GET /api/usage-stats/account — unified account snapshot for one provider
10
+ * GET /api/usage-stats/session-context — provider/model context for one live session
11
+ * GET /api/usage-stats/export/daily.csv — daily provider/model usage export
12
+ * GET /api/usage-stats/export/sessions.csv — per-session usage export
13
+ * GET /api/usage-stats/export.json — versioned usage/account-safe export
10
14
  *
11
15
  * Provider configuration is read straight from the harness settings
12
16
  * (`llm-deepseek` for the official DeepSeek route, `llm-pi-ai` for every
@@ -33,8 +37,10 @@
33
37
  import { homedir } from "node:os";
34
38
  import { join, dirname } from "node:path";
35
39
  import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
36
- import { applyUsageDelta, createUsageState, mergeInto, renderUsage, totalTokens, zeroBuckets } from "./usage.js";
40
+ import { applyUsageDelta, createUsageState, currentSessionContext, mergeBillingInto, mergeInto, renderSessionUsage, renderUsage, resetUsageState, totalTokens, zeroBuckets } from "./usage.js";
37
41
  import { ACCOUNT_REFRESH_MS, createAccountService, validateAccountConfig } from "./accounts.js";
42
+ import { changedProviderPricingRoutes, createUsageCostEstimator, parseCostAccumulator, pricingFingerprint, renderBudgetSummary, serializeCostAccumulator, validateBudgetConfig } from "./billing.js";
43
+ import { dailyCsv, jsonExport, sessionsCsv } from "./export.js";
38
44
 
39
45
  /** Stable Cordis plugin name. */
40
46
  const name = "usage-stats";
@@ -47,8 +53,12 @@ const PROVIDERS_PATH = "/api/usage-stats/providers";
47
53
  const BALANCE_PATH = "/api/usage-stats/balance";
48
54
  const SUBSCRIPTIONS_PATH = "/api/usage-stats/subscriptions";
49
55
  const ACCOUNT_PATH = "/api/usage-stats/account";
56
+ const SESSION_CONTEXT_PATH = "/api/usage-stats/session-context";
57
+ const DAILY_EXPORT_PATH = "/api/usage-stats/export/daily.csv";
58
+ const SESSIONS_EXPORT_PATH = "/api/usage-stats/export/sessions.csv";
59
+ const JSON_EXPORT_PATH = "/api/usage-stats/export.json";
50
60
  const UPSTREAM_TIMEOUT_MS = 15000;
51
- const CACHE_VERSION = 3;
61
+ const CACHE_VERSION = 5;
52
62
 
53
63
  /** Default DeepSeek connection facts when the settings namespace is absent. */
54
64
  const DEEPSEEK_DEFAULTS = {
@@ -66,6 +76,16 @@ function json(res, status, value) {
66
76
  res.end(body);
67
77
  }
68
78
 
79
+ function attachment(res, contentType, filename, body) {
80
+ res.writeHead(200, {
81
+ "content-type": contentType,
82
+ "content-disposition": `attachment; filename="${filename}"`,
83
+ "cache-control": "no-store",
84
+ "x-content-type-options": "nosniff"
85
+ });
86
+ res.end(body);
87
+ }
88
+
69
89
  /**
70
90
  * Loopback fence, primary on the PEER SOCKET address (not the
71
91
  * client-controllable Host header): the request must come from a loopback
@@ -139,6 +159,7 @@ function serializeSession(state) {
139
159
  }
140
160
  return {
141
161
  kind: state.kind ?? "persisted",
162
+ title: typeof state.title === "string" ? state.title : null,
142
163
  consumed: state.consumed ?? 0,
143
164
  ...(state.revision === void 0 ? {} : { revision: state.revision }),
144
165
  days,
@@ -146,9 +167,30 @@ function serializeSession(state) {
146
167
  key: state.lastSample.key,
147
168
  day: state.lastSample.day,
148
169
  model: state.lastSample.model,
170
+ providerId: state.lastSample.providerId,
171
+ time: state.lastSample.time,
172
+ cost: state.lastSample.cost,
149
173
  buckets: { ...state.lastSample.buckets }
150
174
  },
151
- currentModel: state.currentModel
175
+ billing: {
176
+ total: serializeCostAccumulator(state.billing.total),
177
+ days: Object.fromEntries([...state.billing.days].map(([date, entry]) => [date, {
178
+ total: serializeCostAccumulator(entry.total),
179
+ models: Object.fromEntries([...entry.models].map(([model, accumulator]) => [model, serializeCostAccumulator(accumulator)]))
180
+ }])),
181
+ providers: Object.fromEntries(state.billing.providers),
182
+ models: Object.fromEntries(state.billing.models),
183
+ sampleCount: state.billing.sampleCount,
184
+ firstAt: state.billing.firstAt,
185
+ lastAt: state.billing.lastAt,
186
+ penultimateAt: state.billing.penultimateAt
187
+ },
188
+ currentModel: state.currentModel,
189
+ currentRoute: state.currentRoute === null || state.currentRoute === void 0 ? null : {
190
+ providerId: state.currentRoute.providerId,
191
+ model: state.currentRoute.model,
192
+ updatedAt: state.currentRoute.updatedAt
193
+ }
152
194
  };
153
195
  }
154
196
 
@@ -157,6 +199,7 @@ function parseSession(raw) {
157
199
  const state = createUsageState();
158
200
  if (raw === null || typeof raw !== "object") return state;
159
201
  state.kind = typeof raw.kind === "string" ? raw.kind : "persisted";
202
+ state.title = typeof raw.title === "string" ? raw.title : null;
160
203
  state.consumed = Number.isSafeInteger(raw.consumed) ? raw.consumed : 0;
161
204
  if (typeof raw.revision === "string") state.revision = raw.revision;
162
205
  if (raw.days !== null && typeof raw.days === "object") {
@@ -190,6 +233,9 @@ function parseSession(raw) {
190
233
  key: raw.lastSample.key,
191
234
  day: raw.lastSample.day,
192
235
  model: typeof raw.lastSample.model === "string" ? raw.lastSample.model : "unknown",
236
+ providerId: typeof raw.lastSample.providerId === "string" ? raw.lastSample.providerId : "unknown",
237
+ time: Number.isFinite(raw.lastSample.time) ? raw.lastSample.time : null,
238
+ cost: raw.lastSample.cost !== null && typeof raw.lastSample.cost === "object" ? { ...raw.lastSample.cost } : { counted: true, complete: false },
193
239
  buckets: {
194
240
  inputTokens: Number.isFinite(buckets.inputTokens) ? buckets.inputTokens : 0,
195
241
  outputTokens: Number.isFinite(buckets.outputTokens) ? buckets.outputTokens : 0,
@@ -198,24 +244,97 @@ function parseSession(raw) {
198
244
  }
199
245
  };
200
246
  }
247
+ if (raw.billing !== null && typeof raw.billing === "object" && !Array.isArray(raw.billing)) {
248
+ state.billing.total = parseCostAccumulator(raw.billing.total);
249
+ for (const [date, entry] of Object.entries(raw.billing.days ?? {})) {
250
+ if (entry === null || typeof entry !== "object" || Array.isArray(entry)) continue;
251
+ const restored = { total: parseCostAccumulator(entry.total), models: new Map() };
252
+ for (const [model, accumulator] of Object.entries(entry.models ?? {})) restored.models.set(model, parseCostAccumulator(accumulator));
253
+ state.billing.days.set(date, restored);
254
+ }
255
+ for (const [providerId, count] of Object.entries(raw.billing.providers ?? {})) if (Number.isSafeInteger(count) && count > 0) state.billing.providers.set(providerId, count);
256
+ for (const [model, count] of Object.entries(raw.billing.models ?? {})) if (Number.isSafeInteger(count) && count > 0) state.billing.models.set(model, count);
257
+ state.billing.sampleCount = Number.isSafeInteger(raw.billing.sampleCount) && raw.billing.sampleCount >= 0 ? raw.billing.sampleCount : 0;
258
+ state.billing.firstAt = Number.isFinite(raw.billing.firstAt) ? raw.billing.firstAt : null;
259
+ state.billing.lastAt = Number.isFinite(raw.billing.lastAt) ? raw.billing.lastAt : null;
260
+ state.billing.penultimateAt = Number.isFinite(raw.billing.penultimateAt) ? raw.billing.penultimateAt : null;
261
+ }
201
262
  if (typeof raw.currentModel === "string") state.currentModel = raw.currentModel;
263
+ if (raw.currentRoute !== null && typeof raw.currentRoute === "object"
264
+ && typeof raw.currentRoute.providerId === "string" && raw.currentRoute.providerId.length > 0
265
+ && typeof raw.currentRoute.model === "string" && raw.currentRoute.model.length > 0) {
266
+ state.currentRoute = {
267
+ providerId: raw.currentRoute.providerId,
268
+ model: raw.currentRoute.model,
269
+ updatedAt: Number.isFinite(raw.currentRoute.updatedAt) ? raw.currentRoute.updatedAt : null
270
+ };
271
+ }
202
272
  return state;
203
273
  }
204
274
 
205
- /** Load the cache once per process; any corruption degrades to a fresh cache. */
206
- async function loadCache() {
207
- if (loadedCache !== null) return loadedCache;
275
+ function parsePricingIdentityCutoffs(raw) {
276
+ const cutoffs = Object.create(null);
277
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return cutoffs;
278
+ for (const [routeId, cutoff] of Object.entries(raw)) {
279
+ if (routeId !== "" && Number.isFinite(cutoff) && cutoff >= 0) cutoffs[routeId] = cutoff;
280
+ }
281
+ return cutoffs;
282
+ }
283
+
284
+ function freshCache(pricingFingerprintValue, previous = null, transitionAt = Date.now()) {
285
+ const pricingIdentityCutoffs = parsePricingIdentityCutoffs(previous?.pricingIdentityCutoffs);
286
+ let pricingIdentityCutoffAll = Number.isFinite(previous?.pricingIdentityCutoffAll) && previous.pricingIdentityCutoffAll >= 0
287
+ ? previous.pricingIdentityCutoffAll
288
+ : null;
289
+ if (previous !== null && previous.pricingFingerprint !== pricingFingerprintValue) {
290
+ const changedRoutes = changedProviderPricingRoutes(previous.pricingFingerprint, pricingFingerprintValue);
291
+ if (changedRoutes === null) pricingIdentityCutoffAll = Math.max(pricingIdentityCutoffAll ?? 0, transitionAt);
292
+ else for (const routeId of changedRoutes) pricingIdentityCutoffs[routeId] = Math.max(pricingIdentityCutoffs[routeId] ?? 0, transitionAt);
293
+ }
294
+ return {
295
+ version: CACHE_VERSION,
296
+ pricingFingerprint: pricingFingerprintValue,
297
+ pricingIdentityCutoffAll,
298
+ pricingIdentityCutoffs,
299
+ sessions: {}
300
+ };
301
+ }
302
+
303
+ function restoredCache(parsed, pricingFingerprintValue) {
304
+ const sessions = {};
305
+ for (const [id, entry] of Object.entries(parsed.sessions)) {
306
+ if (typeof id === "string" && id.length > 0) sessions[id] = parseSession(entry);
307
+ }
308
+ return {
309
+ version: CACHE_VERSION,
310
+ pricingFingerprint: pricingFingerprintValue,
311
+ pricingIdentityCutoffAll: Number.isFinite(parsed.pricingIdentityCutoffAll) && parsed.pricingIdentityCutoffAll >= 0
312
+ ? parsed.pricingIdentityCutoffAll
313
+ : null,
314
+ pricingIdentityCutoffs: parsePricingIdentityCutoffs(parsed.pricingIdentityCutoffs),
315
+ sessions
316
+ };
317
+ }
318
+
319
+ /** Load against the current runtime provider/pricing fingerprint. */
320
+ async function loadCache(pricingFingerprintValue) {
321
+ if (loadedCache !== null) {
322
+ if (loadedCache.pricingFingerprint !== pricingFingerprintValue) {
323
+ loadedCache = freshCache(pricingFingerprintValue, loadedCache);
324
+ loadPromise = Promise.resolve(loadedCache);
325
+ }
326
+ return loadedCache;
327
+ }
208
328
  loadPromise ??= (async () => {
209
- const fresh = { version: CACHE_VERSION, sessions: {} };
329
+ const fresh = freshCache(pricingFingerprintValue);
210
330
  try {
211
331
  const raw = await readFile(cachePath(), "utf8");
212
332
  const parsed = JSON.parse(raw);
213
- if (parsed !== null && typeof parsed === "object" && parsed.version === CACHE_VERSION && parsed.sessions !== null && typeof parsed.sessions === "object") {
214
- const sessions = {};
215
- for (const [id, entry] of Object.entries(parsed.sessions)) {
216
- if (typeof id === "string" && id.length > 0) sessions[id] = parseSession(entry);
217
- }
218
- return { version: CACHE_VERSION, sessions };
333
+ if (parsed !== null && typeof parsed === "object" && parsed.version === CACHE_VERSION
334
+ && typeof parsed.pricingFingerprint === "string"
335
+ && parsed.sessions !== null && typeof parsed.sessions === "object") {
336
+ if (parsed.pricingFingerprint === pricingFingerprintValue) return restoredCache(parsed, pricingFingerprintValue);
337
+ return freshCache(pricingFingerprintValue, parsed);
219
338
  }
220
339
  } catch {
221
340
  /* first run or corrupt cache */
@@ -231,7 +350,13 @@ async function saveCache(ctx, cache) {
231
350
  try {
232
351
  const path = cachePath();
233
352
  await mkdir(dirname(path), { recursive: true });
234
- const serialized = { version: CACHE_VERSION, sessions: {} };
353
+ const serialized = {
354
+ version: CACHE_VERSION,
355
+ pricingFingerprint: cache.pricingFingerprint,
356
+ pricingIdentityCutoffAll: cache.pricingIdentityCutoffAll,
357
+ pricingIdentityCutoffs: cache.pricingIdentityCutoffs,
358
+ sessions: {}
359
+ };
235
360
  for (const [id, state] of Object.entries(cache.sessions)) serialized.sessions[id] = serializeSession(state);
236
361
  const tmp = `${path}.tmp`;
237
362
  await writeFile(tmp, JSON.stringify(serialized), "utf8");
@@ -266,21 +391,30 @@ function withLock(run) {
266
391
  * Sessions that vanished are dropped, and a session switching between
267
392
  * live/persisted is refolded from scratch to stay exact.
268
393
  */
269
- export async function collectUsage(ctx) {
394
+ export async function collectUsage(ctx, config = { monitors: {}, budgets: validateBudgetConfig() }) {
270
395
  return withLock(async () => {
271
- const cache = await loadCache();
396
+ const providers = await configuredProviders(ctx);
397
+ const runtimePricingFingerprint = pricingFingerprint({ providers, config });
398
+ const cache = await loadCache(runtimePricingFingerprint);
399
+ const estimateCurrentCost = createUsageCostEstimator(providers, config);
400
+ const estimateCost = (input) => {
401
+ const routeCutoff = Object.hasOwn(cache.pricingIdentityCutoffs, input.providerId)
402
+ ? cache.pricingIdentityCutoffs[input.providerId]
403
+ : null;
404
+ const cutoff = Math.max(cache.pricingIdentityCutoffAll ?? 0, routeCutoff ?? 0);
405
+ if (cutoff > 0 && (!Number.isFinite(input.timestamp) || input.timestamp <= cutoff)) return null;
406
+ return estimateCurrentCost(input);
407
+ };
272
408
  const live = ctx.get("sessions");
273
409
  const attached = new Set();
274
410
  if (live !== void 0) {
275
411
  for (const session of live.list()) {
276
412
  attached.add(session.id);
277
413
  const state = cache.sessions[session.id] ?? createUsageState();
414
+ if (typeof session.title === "string") state.title = session.title;
278
415
  if (state.kind !== "live") {
279
416
  // Live/persisted transition: refold the whole in-memory log.
280
- state.days = new Map();
281
- state.lastSample = null;
282
- state.currentModel = null;
283
- state.consumed = 0;
417
+ resetUsageState(state);
284
418
  }
285
419
  const count = session.events.length;
286
420
  if (count < (state.consumed ?? 0)) {
@@ -290,13 +424,10 @@ export async function collectUsage(ctx) {
290
424
  // full log would silently freeze this session's stats
291
425
  // forever (#23). The cursor is meaningless against the
292
426
  // rebuilt log: refold it from scratch.
293
- state.days = new Map();
294
- state.lastSample = null;
295
- state.currentModel = null;
296
- state.consumed = 0;
427
+ resetUsageState(state);
297
428
  }
298
429
  if ((state.consumed ?? 0) < count) {
299
- applyUsageDelta(state, session.events.slice(state.consumed ?? 0));
430
+ applyUsageDelta(state, session.events.slice(state.consumed ?? 0), { estimateCost });
300
431
  state.consumed = count;
301
432
  }
302
433
  state.kind = "live";
@@ -323,6 +454,7 @@ export async function collectUsage(ctx) {
323
454
  persistedIds.add(meta.id);
324
455
  if (attached.has(meta.id)) continue;
325
456
  const state = cache.sessions[meta.id] ?? createUsageState();
457
+ if (typeof meta.title === "string") state.title = meta.title;
326
458
  const revision = revisionOf.get(meta.id);
327
459
  const changed = state.kind !== "persisted" || (revision !== void 0 && revision !== state.revision) || revision === void 0;
328
460
  if (changed) {
@@ -331,24 +463,18 @@ export async function collectUsage(ctx) {
331
463
  const fromSeq = wasPersisted ? state.consumed : 0;
332
464
  const { events } = await persistence.readFrom(meta.id, fromSeq);
333
465
  if (!wasPersisted) {
334
- state.days = new Map();
335
- state.lastSample = null;
336
- state.currentModel = null;
337
- state.consumed = 0;
466
+ resetUsageState(state);
338
467
  }
339
468
  const fresh = wasPersisted ? events.filter((event) => event.seq > (state.consumed ?? 0)) : events;
340
469
  const contiguous = fresh.length === 0 ? state.consumed === 0 : fresh[0].seq === state.consumed + 1;
341
470
  if (!contiguous && state.consumed > 0) {
342
471
  // Log truncated or rewritten: refold the whole log.
343
- state.days = new Map();
344
- state.lastSample = null;
345
- state.currentModel = null;
346
- state.consumed = 0;
472
+ resetUsageState(state);
347
473
  const { events: allEvents } = await persistence.readFrom(meta.id, 0);
348
- applyUsageDelta(state, allEvents);
474
+ applyUsageDelta(state, allEvents, { estimateCost });
349
475
  state.consumed = allEvents.length > 0 ? allEvents[allEvents.length - 1].seq : 0;
350
476
  } else if (fresh.length > 0) {
351
- applyUsageDelta(state, fresh);
477
+ applyUsageDelta(state, fresh, { estimateCost });
352
478
  state.consumed = fresh[fresh.length - 1].seq;
353
479
  }
354
480
  state.kind = "persisted";
@@ -364,18 +490,28 @@ export async function collectUsage(ctx) {
364
490
  if (!attached.has(id) && !persistedIds.has(id)) delete cache.sessions[id];
365
491
  }
366
492
  const byDay = new Map();
367
- for (const state of Object.values(cache.sessions)) mergeInto(byDay, state.days);
493
+ const billingByDay = new Map();
494
+ for (const state of Object.values(cache.sessions)) {
495
+ mergeInto(byDay, state.days);
496
+ mergeBillingInto(billingByDay, state.billing.days);
497
+ }
368
498
  // Keep the atomic cache write inside the single-flight section. Otherwise
369
499
  // overlapping saves can race on the same temporary file.
370
500
  await saveCache(ctx, cache);
371
- return renderUsage(byDay, Date.now());
501
+ const updatedAt = Date.now();
502
+ const rendered = renderUsage(byDay, updatedAt, billingByDay);
503
+ return {
504
+ ...rendered,
505
+ sessions: Object.entries(cache.sessions).map(([sessionId, state]) => renderSessionUsage(sessionId, state)).filter((session) => session.tokens > 0),
506
+ budgets: renderBudgetSummary(billingByDay, config.budgets ?? validateBudgetConfig(), updatedAt)
507
+ };
372
508
  });
373
509
  }
374
510
 
375
- async function handleUsage(ctx, req, res) {
511
+ async function handleUsage(ctx, config, req, res) {
376
512
  if (rejectForeignCaller(req, res)) return;
377
513
  try {
378
- const result = await collectUsage(ctx);
514
+ const result = await collectUsage(ctx, config);
379
515
  json(res, 200, { ok: true, ...result });
380
516
  } catch (error) {
381
517
  ctx.logger.warn(`usage-stats: usage aggregation failed: ${String(error)}`);
@@ -383,6 +519,40 @@ async function handleUsage(ctx, req, res) {
383
519
  }
384
520
  }
385
521
 
522
+ function exportFailure(ctx, kind, error, res) {
523
+ const name = error instanceof Error && typeof error.name === "string" ? error.name : "Error";
524
+ ctx.logger.warn(`usage-stats: ${kind} export failed (${name})`);
525
+ json(res, 500, { ok: false, error: "internal", message: "export failed" });
526
+ }
527
+
528
+ async function handleDailyExport(ctx, config, req, res) {
529
+ if (rejectForeignCaller(req, res)) return;
530
+ try {
531
+ attachment(res, "text/csv; charset=utf-8", "dsh-usage-daily.csv", dailyCsv(await collectUsage(ctx, config)));
532
+ } catch (error) {
533
+ exportFailure(ctx, "daily CSV", error, res);
534
+ }
535
+ }
536
+
537
+ async function handleSessionsExport(ctx, config, req, res) {
538
+ if (rejectForeignCaller(req, res)) return;
539
+ try {
540
+ attachment(res, "text/csv; charset=utf-8", "dsh-usage-sessions.csv", sessionsCsv(await collectUsage(ctx, config)));
541
+ } catch (error) {
542
+ exportFailure(ctx, "session CSV", error, res);
543
+ }
544
+ }
545
+
546
+ async function handleJsonExport(ctx, config, accounts, req, res) {
547
+ if (rejectForeignCaller(req, res)) return;
548
+ try {
549
+ const [usage, providerViews] = await Promise.all([collectUsage(ctx, config), accounts.providerViews()]);
550
+ attachment(res, "application/json; charset=utf-8", "dsh-usage-stats.json", JSON.stringify(jsonExport(usage, providerViews)));
551
+ } catch (error) {
552
+ exportFailure(ctx, "JSON", error, res);
553
+ }
554
+ }
555
+
386
556
  /**
387
557
  * Enumerate the harness's configured providers: the official DeepSeek route
388
558
  * (`llm-deepseek` settings namespace) plus every pi-ai provider profile
@@ -423,6 +593,106 @@ async function configuredProviders(ctx) {
423
593
  return providers;
424
594
  }
425
595
 
596
+ /**
597
+ * Resolve one live DSH session's provider/model pair. A bounded route hint from
598
+ * the formal per-session model selector wins for immediate pre-turn switches;
599
+ * the incremental event fold remains the no-hint source and history fallback.
600
+ * Both paths still pass through the shared identity resolver, remain O(new
601
+ * events), and add no stream listener, provider request, cache, or usage ledger.
602
+ */
603
+ export async function collectSessionContext(ctx, sessionId, config = { monitors: {} }, selectedRoute = null) {
604
+ await collectUsage(ctx, config);
605
+ const sessions = ctx.get("sessions");
606
+ const live = sessions?.get?.(sessionId) ?? sessions?.list?.().find((session) => session.id === sessionId);
607
+ if (live === void 0) return null;
608
+ const cache = loadedCache;
609
+ if (cache === null) return null;
610
+ const state = cache.sessions[sessionId];
611
+ if (state?.kind !== "live") return null;
612
+ const currentRoute = selectedRoute ?? state.currentRoute;
613
+ if (currentRoute === null || currentRoute === void 0) return null;
614
+ const providers = await configuredProviders(ctx);
615
+ const provider = providers.find((entry) => entry.id === currentRoute.providerId)
616
+ ?? { id: currentRoute.providerId, displayName: currentRoute.providerId };
617
+ return {
618
+ ...currentSessionContext(sessionId, { ...state, currentRoute }, provider, config),
619
+ session: renderSessionUsage(sessionId, state)
620
+ };
621
+ }
622
+
623
+ /** Resolve the current account ids for every live session without a new ledger or provider guess. */
624
+ export async function collectActiveAccountIds(ctx, config = { monitors: {} }) {
625
+ await collectUsage(ctx, config);
626
+ const sessions = ctx.get("sessions")?.list?.() ?? [];
627
+ const cache = loadedCache;
628
+ if (cache === null) return [];
629
+ const providers = await configuredProviders(ctx);
630
+ const byId = new Map(providers.map((provider) => [provider.id, provider]));
631
+ const accountIds = new Set();
632
+ for (const session of sessions) {
633
+ const state = cache.sessions[session.id];
634
+ if (state?.kind !== "live" || state.currentRoute === null || state.currentRoute === void 0) continue;
635
+ const provider = byId.get(state.currentRoute.providerId)
636
+ ?? { id: state.currentRoute.providerId, displayName: state.currentRoute.providerId };
637
+ const context = currentSessionContext(session.id, state, provider, config);
638
+ if (typeof context?.accountId === "string" && context.accountId !== "") accountIds.add(context.accountId);
639
+ }
640
+ return [...accountIds];
641
+ }
642
+
643
+ /** Session context is explicit in multi-session DSH; a single live session is unambiguous. */
644
+ async function handleSessionContext(ctx, config, accounts, req, res) {
645
+ if (rejectForeignCaller(req, res)) return;
646
+ try {
647
+ // Client loader entries carry module identity, not the server Cordis
648
+ // config. Keep the display policy server-owned on this existing request:
649
+ // a hidden Pill performs no usage fold or account read and renders null.
650
+ if (config.display?.currentSessionPill === false) {
651
+ json(res, 200, { ok: true, context: null, display: { currentSessionPill: false } });
652
+ return;
653
+ }
654
+ const url = new URL(req.url ?? "/", "http://x");
655
+ const requested = url.searchParams.get("session");
656
+ const selectedProvider = url.searchParams.get("provider");
657
+ const selectedModel = url.searchParams.get("model");
658
+ if ((selectedProvider === null) !== (selectedModel === null)
659
+ || selectedProvider !== null && (selectedProvider === "" || selectedProvider.length > 256 || selectedProvider.includes("\0"))
660
+ || selectedModel !== null && (selectedModel === "" || selectedModel.length > 512 || selectedModel.includes("\0"))) {
661
+ json(res, 400, { ok: false, error: "invalid-selection", message: "provider and model must be supplied together as bounded non-empty values" });
662
+ return;
663
+ }
664
+ const sessions = ctx.get("sessions")?.list?.() ?? [];
665
+ let sessionId = requested === null || requested === "" ? null : requested;
666
+ if (sessionId === null && sessions.length === 1) sessionId = sessions[0].id;
667
+ if (sessionId === null && sessions.length > 1) {
668
+ json(res, 400, { ok: false, error: "session-required", message: "session query parameter is required when multiple sessions are live" });
669
+ return;
670
+ }
671
+ if (sessionId === null) {
672
+ json(res, 200, { ok: true, context: null });
673
+ return;
674
+ }
675
+ if (!sessions.some((session) => session.id === sessionId)) {
676
+ json(res, 404, { ok: false, error: "unknown-session", message: `session "${sessionId}" is not live` });
677
+ return;
678
+ }
679
+ // The browser hint comes from DSH's formal per-session model directory.
680
+ // It carries route identity only; configuredProviders + the shared resolver
681
+ // below remain authoritative for family/account normalization.
682
+ const selectedRoute = selectedProvider === null ? null : {
683
+ providerId: selectedProvider,
684
+ model: selectedModel,
685
+ updatedAt: null
686
+ };
687
+ const context = await collectSessionContext(ctx, sessionId, config, selectedRoute);
688
+ if (typeof context?.accountId === "string" && context.accountId !== "") accounts.touch?.(context.accountId, "active");
689
+ json(res, 200, { ok: true, context, display: { currentSessionPill: true } });
690
+ } catch (error) {
691
+ ctx.logger.warn(`usage-stats: session context failed: ${String(error)}`);
692
+ json(res, 500, { ok: false, error: "internal", message: error instanceof Error ? error.message : String(error) });
693
+ }
694
+ }
695
+
426
696
  async function handleProviders(ctx, accounts, req, res) {
427
697
  if (rejectForeignCaller(req, res)) return;
428
698
  try {
@@ -450,7 +720,9 @@ async function handleAccount(ctx, accounts, req, res) {
450
720
  try {
451
721
  const url = new URL(req.url ?? "/", "http://x");
452
722
  const providerId = await selectedProviderId(req, accounts);
453
- const account = providerId === null ? null : await accounts.get(providerId, { force: url.searchParams.get("refresh") === "1" });
723
+ const requestedActivity = url.searchParams.get("activity");
724
+ const activity = requestedActivity === "active" || requestedActivity === "detail" ? requestedActivity : null;
725
+ const account = providerId === null ? null : await accounts.get(providerId, { force: url.searchParams.get("refresh") === "1", activity });
454
726
  if (account === null) {
455
727
  json(res, 200, { ok: false, error: "unknown-provider", message: `provider "${providerId}" is not configured` });
456
728
  return;
@@ -526,41 +798,104 @@ async function handleSubscriptions(ctx, accounts, req, res) {
526
798
  }
527
799
  }
528
800
 
529
- /** Start an immediate refresh and repeat account + local usage refresh every 5 minutes. */
801
+ /** Existing five-minute usage fold plus an optional adaptive account scheduler. */
530
802
  export function startBackgroundRefresh(ctx, accounts, deps = {}) {
531
803
  let running = false;
532
804
  let stopped = false;
533
805
  let active = Promise.resolve();
534
- const run = async () => {
535
- if (running || stopped) return;
806
+ let timer = null;
807
+ let scheduleGeneration = 0;
808
+ let nextUsageAt = 0;
809
+ const now = deps.now ?? Date.now;
810
+ const usageIntervalMs = deps.usageIntervalMs ?? ACCOUNT_REFRESH_MS;
811
+ const setTimer = deps.setTimeout ?? setTimeout;
812
+ const clearTimer = deps.clearTimeout ?? clearTimeout;
813
+ const config = deps.config ?? { monitors: {} };
814
+ const accountRefreshEnabled = deps.accountRefreshEnabled !== false;
815
+
816
+ const clearScheduled = () => {
817
+ if (timer === null) return;
818
+ clearTimer(timer);
819
+ timer = null;
820
+ };
821
+
822
+ const schedule = async () => {
823
+ if (stopped) return;
824
+ const generation = ++scheduleGeneration;
825
+ clearScheduled();
826
+ let accountNext = null;
827
+ if (accountRefreshEnabled) {
828
+ try {
829
+ accountNext = await accounts.nextRefreshAt();
830
+ } catch (error) {
831
+ ctx.logger.warn(`usage-stats: refresh scheduling failed: ${String(error)}`);
832
+ }
833
+ }
834
+ if (stopped || generation !== scheduleGeneration) return;
835
+ const target = Math.min(accountNext ?? Infinity, nextUsageAt);
836
+ const delay = Math.max(1000, Number.isFinite(target) ? target - now() : usageIntervalMs);
837
+ timer = setTimer(() => {
838
+ timer = null;
839
+ void run();
840
+ }, delay);
841
+ timer?.unref?.();
842
+ };
843
+
844
+ const run = async (force = false) => {
845
+ if (stopped) return;
846
+ if (running) {
847
+ await active;
848
+ return force && !stopped ? run(true) : void 0;
849
+ }
850
+ clearScheduled();
536
851
  running = true;
537
852
  active = (async () => {
538
- const results = await Promise.allSettled([accounts.refreshAll(), collectUsage(ctx)]);
539
- for (const result of results) if (result.status === "rejected") ctx.logger.warn(`usage-stats: background refresh failed: ${String(result.reason)}`);
853
+ const at = now();
854
+ if (force || at >= nextUsageAt) {
855
+ try {
856
+ if (accountRefreshEnabled) accounts.setActiveProviders(await collectActiveAccountIds(ctx, config));
857
+ else await collectUsage(ctx, config);
858
+ } catch (error) {
859
+ ctx.logger.warn(`usage-stats: background usage refresh failed: ${String(error)}`);
860
+ }
861
+ nextUsageAt = now() + usageIntervalMs;
862
+ }
863
+ if (accountRefreshEnabled) {
864
+ try {
865
+ await accounts.refreshDue({ force });
866
+ } catch (error) {
867
+ ctx.logger.warn(`usage-stats: background account refresh failed: ${String(error)}`);
868
+ }
869
+ }
540
870
  })().finally(() => {
541
871
  running = false;
542
872
  });
543
- return active;
873
+ await active;
874
+ await schedule();
544
875
  };
545
- void run();
546
- const setTimer = deps.setInterval ?? setInterval;
547
- const clearTimer = deps.clearInterval ?? clearInterval;
548
- const timer = setTimer(run, deps.intervalMs ?? ACCOUNT_REFRESH_MS);
549
- timer?.unref?.();
876
+ let unsubscribePolicyChanges = () => {};
877
+ if (accountRefreshEnabled) {
878
+ unsubscribePolicyChanges = accounts.subscribePolicyChanges?.(() => {
879
+ // Activity changes only rearm this one central timer. The service remains
880
+ // responsible for deciding whether an upstream refresh is actually due.
881
+ if (!stopped && !running) void schedule();
882
+ }) ?? unsubscribePolicyChanges;
883
+ }
884
+ const ready = run();
550
885
  const stop = async () => {
551
886
  stopped = true;
552
- clearTimer(timer);
553
- await active;
554
- };
555
- stop.refreshNow = async () => {
887
+ scheduleGeneration += 1;
888
+ clearScheduled();
889
+ unsubscribePolicyChanges();
556
890
  await active;
557
- return run();
558
891
  };
892
+ stop.ready = ready;
893
+ stop.refreshNow = () => run(true);
559
894
  return stop;
560
895
  }
561
896
 
562
897
  /**
563
- * Plugin body: register the five exact routes and start background refresh.
898
+ * Plugin body: register the nine exact routes and start background refresh.
564
899
  * @param ctx - plugin context carrying webServer, credentials, sessions, sessionPersistence, settings, and llm.
565
900
  */
566
901
  const Config = {
@@ -569,7 +904,7 @@ const Config = {
569
904
  vendor: "dsh-usage-stats",
570
905
  validate(value) {
571
906
  try {
572
- return { value: validateAccountConfig(value ?? {}) };
907
+ return { value: validateConfig(value ?? {}) };
573
908
  } catch (error) {
574
909
  return { issues: [{ message: error instanceof Error ? error.message : String(error) }] };
575
910
  }
@@ -577,8 +912,22 @@ const Config = {
577
912
  }
578
913
  };
579
914
 
915
+ function validateConfig(raw = {}) {
916
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) throw new Error("plugin config must be an object");
917
+ const display = raw.display ?? {};
918
+ if (display === null || typeof display !== "object" || Array.isArray(display)) throw new Error("display must be an object");
919
+ if (display.currentSessionPill !== void 0 && typeof display.currentSessionPill !== "boolean") {
920
+ throw new Error("display.currentSessionPill must be a boolean");
921
+ }
922
+ return {
923
+ ...validateAccountConfig(raw),
924
+ budgets: validateBudgetConfig(raw.budgets),
925
+ display: { currentSessionPill: display.currentSessionPill !== false }
926
+ };
927
+ }
928
+
580
929
  async function apply(ctx, rawConfig = {}, deps = {}) {
581
- const config = validateAccountConfig(rawConfig);
930
+ const config = validateConfig(rawConfig);
582
931
  const accounts = deps.accounts ?? createAccountService({
583
932
  credentials: ctx.get("credentials") ?? ctx.credentials,
584
933
  getProviders: () => configuredProviders(ctx),
@@ -591,7 +940,7 @@ async function apply(ctx, rawConfig = {}, deps = {}) {
591
940
  ctx.effect(() => ctx.webServer.register({
592
941
  kind: "exact",
593
942
  path: USAGE_PATH,
594
- handler: (req, res) => handleUsage(ctx, req, res)
943
+ handler: (req, res) => handleUsage(ctx, config, req, res)
595
944
  }), "usage-stats: usage route");
596
945
  ctx.effect(() => ctx.webServer.register({
597
946
  kind: "exact",
@@ -613,7 +962,30 @@ async function apply(ctx, rawConfig = {}, deps = {}) {
613
962
  path: SUBSCRIPTIONS_PATH,
614
963
  handler: (req, res) => handleSubscriptions(ctx, accounts, req, res)
615
964
  }), "usage-stats: subscriptions route");
616
- if (deps.disableBackgroundRefresh !== true) ctx.effect(() => startBackgroundRefresh(ctx, accounts), "usage-stats: background account refresh");
965
+ ctx.effect(() => ctx.webServer.register({
966
+ kind: "exact",
967
+ path: SESSION_CONTEXT_PATH,
968
+ handler: (req, res) => handleSessionContext(ctx, config, accounts, req, res)
969
+ }), "usage-stats: session context route");
970
+ ctx.effect(() => ctx.webServer.register({
971
+ kind: "exact",
972
+ path: DAILY_EXPORT_PATH,
973
+ handler: (req, res) => handleDailyExport(ctx, config, req, res)
974
+ }), "usage-stats: daily CSV export route");
975
+ ctx.effect(() => ctx.webServer.register({
976
+ kind: "exact",
977
+ path: SESSIONS_EXPORT_PATH,
978
+ handler: (req, res) => handleSessionsExport(ctx, config, req, res)
979
+ }), "usage-stats: sessions CSV export route");
980
+ ctx.effect(() => ctx.webServer.register({
981
+ kind: "exact",
982
+ path: JSON_EXPORT_PATH,
983
+ handler: (req, res) => handleJsonExport(ctx, config, accounts, req, res)
984
+ }), "usage-stats: JSON export route");
985
+ if (deps.disableBackgroundRefresh !== true) ctx.effect(() => startBackgroundRefresh(ctx, accounts, {
986
+ config,
987
+ accountRefreshEnabled: config.refresh.enabled
988
+ }), "usage-stats: background usage/account refresh");
617
989
  }
618
990
 
619
- export { apply, Config, inject, name, USAGE_PATH, PROVIDERS_PATH, BALANCE_PATH, SUBSCRIPTIONS_PATH, ACCOUNT_PATH, configuredProviders, totalTokens, zeroBuckets };
991
+ export { apply, Config, inject, name, USAGE_PATH, PROVIDERS_PATH, BALANCE_PATH, SUBSCRIPTIONS_PATH, ACCOUNT_PATH, SESSION_CONTEXT_PATH, DAILY_EXPORT_PATH, SESSIONS_EXPORT_PATH, JSON_EXPORT_PATH, configuredProviders, totalTokens, validateConfig, zeroBuckets };