@ychris12138/dsh-usage-stats 0.2.10 → 0.3.1

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,18 @@
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 data endpoints plus one explicit
5
+ * loopback-only OrcaRouter settings action on the web server:
5
6
  * GET /api/usage-stats/usage — per-day token usage across every session
6
7
  * GET /api/usage-stats/providers — configured providers + balance schemes
7
8
  * GET /api/usage-stats/balance — balance for one provider (?provider=<id>)
8
9
  * GET /api/usage-stats/subscriptions — OpenCode Go + Z.ai quota windows
9
10
  * GET /api/usage-stats/account — unified account snapshot for one provider
11
+ * GET /api/usage-stats/session-context — provider/model context for one live session
12
+ * GET /api/usage-stats/export/daily.csv — daily provider/model usage export
13
+ * GET /api/usage-stats/export/sessions.csv — per-session usage export
14
+ * GET /api/usage-stats/export.json — versioned usage/account-safe export
15
+ * GET|POST /api/usage-stats/integrations/orcarouter — status / explicit add
10
16
  *
11
17
  * Provider configuration is read straight from the harness settings
12
18
  * (`llm-deepseek` for the official DeepSeek route, `llm-pi-ai` for every
@@ -33,8 +39,11 @@
33
39
  import { homedir } from "node:os";
34
40
  import { join, dirname } from "node:path";
35
41
  import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
36
- import { applyUsageDelta, createUsageState, mergeInto, renderUsage, totalTokens, zeroBuckets } from "./usage.js";
42
+ import { applyUsageDelta, createUsageState, currentSessionContext, mergeBillingInto, mergeInto, renderSessionUsage, renderUsage, resetUsageState, totalTokens, zeroBuckets } from "./usage.js";
37
43
  import { ACCOUNT_REFRESH_MS, createAccountService, validateAccountConfig } from "./accounts.js";
44
+ import { changedProviderPricingRoutes, createUsageCostEstimator, parseCostAccumulator, pricingFingerprint, renderBudgetSummary, serializeCostAccumulator, validateBudgetConfig } from "./billing.js";
45
+ import { dailyCsv, jsonExport, sessionsCsv } from "./export.js";
46
+ import { addOrcaRouterPreset, orcaRouterIntegrationState } from "./orcarouter.js";
38
47
 
39
48
  /** Stable Cordis plugin name. */
40
49
  const name = "usage-stats";
@@ -47,8 +56,13 @@ const PROVIDERS_PATH = "/api/usage-stats/providers";
47
56
  const BALANCE_PATH = "/api/usage-stats/balance";
48
57
  const SUBSCRIPTIONS_PATH = "/api/usage-stats/subscriptions";
49
58
  const ACCOUNT_PATH = "/api/usage-stats/account";
59
+ const SESSION_CONTEXT_PATH = "/api/usage-stats/session-context";
60
+ const DAILY_EXPORT_PATH = "/api/usage-stats/export/daily.csv";
61
+ const SESSIONS_EXPORT_PATH = "/api/usage-stats/export/sessions.csv";
62
+ const JSON_EXPORT_PATH = "/api/usage-stats/export.json";
63
+ const ORCAROUTER_INTEGRATION_PATH = "/api/usage-stats/integrations/orcarouter";
50
64
  const UPSTREAM_TIMEOUT_MS = 15000;
51
- const CACHE_VERSION = 3;
65
+ const CACHE_VERSION = 5;
52
66
 
53
67
  /** Default DeepSeek connection facts when the settings namespace is absent. */
54
68
  const DEEPSEEK_DEFAULTS = {
@@ -66,6 +80,16 @@ function json(res, status, value) {
66
80
  res.end(body);
67
81
  }
68
82
 
83
+ function attachment(res, contentType, filename, body) {
84
+ res.writeHead(200, {
85
+ "content-type": contentType,
86
+ "content-disposition": `attachment; filename="${filename}"`,
87
+ "cache-control": "no-store",
88
+ "x-content-type-options": "nosniff"
89
+ });
90
+ res.end(body);
91
+ }
92
+
69
93
  /**
70
94
  * Loopback fence, primary on the PEER SOCKET address (not the
71
95
  * client-controllable Host header): the request must come from a loopback
@@ -118,6 +142,29 @@ function rejectForeignCaller(req, res) {
118
142
  return true;
119
143
  }
120
144
 
145
+ /**
146
+ * Fence the sole settings mutation. The custom action header makes this a
147
+ * non-simple browser request, so a foreign page cannot CSRF the loopback route
148
+ * without a CORS preflight (the exact route never grants CORS).
149
+ */
150
+ function rejectForeignMutation(req, res) {
151
+ if (req.method !== "POST") {
152
+ json(res, 405, { ok: false, error: "method-not-allowed" });
153
+ return true;
154
+ }
155
+ const peer = req.socket?.remoteAddress;
156
+ if (!isLoopbackAddress(peer) || !isLoopbackHostHeader(req)) {
157
+ json(res, 403, { ok: false, error: "forbidden" });
158
+ return true;
159
+ }
160
+ const contentType = typeof req.headers["content-type"] === "string" ? req.headers["content-type"].toLowerCase() : "";
161
+ if (!contentType.startsWith("application/json") || req.headers["x-dsh-usage-stats-action"] !== "add-orcarouter") {
162
+ json(res, 403, { ok: false, error: "forbidden-action" });
163
+ return true;
164
+ }
165
+ return false;
166
+ }
167
+
121
168
  //#region incremental cache
122
169
  /** Cache file location under the dsh home. */
123
170
  function cachePath() {
@@ -139,6 +186,7 @@ function serializeSession(state) {
139
186
  }
140
187
  return {
141
188
  kind: state.kind ?? "persisted",
189
+ title: typeof state.title === "string" ? state.title : null,
142
190
  consumed: state.consumed ?? 0,
143
191
  ...(state.revision === void 0 ? {} : { revision: state.revision }),
144
192
  days,
@@ -146,9 +194,30 @@ function serializeSession(state) {
146
194
  key: state.lastSample.key,
147
195
  day: state.lastSample.day,
148
196
  model: state.lastSample.model,
197
+ providerId: state.lastSample.providerId,
198
+ time: state.lastSample.time,
199
+ cost: state.lastSample.cost,
149
200
  buckets: { ...state.lastSample.buckets }
150
201
  },
151
- currentModel: state.currentModel
202
+ billing: {
203
+ total: serializeCostAccumulator(state.billing.total),
204
+ days: Object.fromEntries([...state.billing.days].map(([date, entry]) => [date, {
205
+ total: serializeCostAccumulator(entry.total),
206
+ models: Object.fromEntries([...entry.models].map(([model, accumulator]) => [model, serializeCostAccumulator(accumulator)]))
207
+ }])),
208
+ providers: Object.fromEntries(state.billing.providers),
209
+ models: Object.fromEntries(state.billing.models),
210
+ sampleCount: state.billing.sampleCount,
211
+ firstAt: state.billing.firstAt,
212
+ lastAt: state.billing.lastAt,
213
+ penultimateAt: state.billing.penultimateAt
214
+ },
215
+ currentModel: state.currentModel,
216
+ currentRoute: state.currentRoute === null || state.currentRoute === void 0 ? null : {
217
+ providerId: state.currentRoute.providerId,
218
+ model: state.currentRoute.model,
219
+ updatedAt: state.currentRoute.updatedAt
220
+ }
152
221
  };
153
222
  }
154
223
 
@@ -157,6 +226,7 @@ function parseSession(raw) {
157
226
  const state = createUsageState();
158
227
  if (raw === null || typeof raw !== "object") return state;
159
228
  state.kind = typeof raw.kind === "string" ? raw.kind : "persisted";
229
+ state.title = typeof raw.title === "string" ? raw.title : null;
160
230
  state.consumed = Number.isSafeInteger(raw.consumed) ? raw.consumed : 0;
161
231
  if (typeof raw.revision === "string") state.revision = raw.revision;
162
232
  if (raw.days !== null && typeof raw.days === "object") {
@@ -190,6 +260,9 @@ function parseSession(raw) {
190
260
  key: raw.lastSample.key,
191
261
  day: raw.lastSample.day,
192
262
  model: typeof raw.lastSample.model === "string" ? raw.lastSample.model : "unknown",
263
+ providerId: typeof raw.lastSample.providerId === "string" ? raw.lastSample.providerId : "unknown",
264
+ time: Number.isFinite(raw.lastSample.time) ? raw.lastSample.time : null,
265
+ cost: raw.lastSample.cost !== null && typeof raw.lastSample.cost === "object" ? { ...raw.lastSample.cost } : { counted: true, complete: false },
193
266
  buckets: {
194
267
  inputTokens: Number.isFinite(buckets.inputTokens) ? buckets.inputTokens : 0,
195
268
  outputTokens: Number.isFinite(buckets.outputTokens) ? buckets.outputTokens : 0,
@@ -198,24 +271,97 @@ function parseSession(raw) {
198
271
  }
199
272
  };
200
273
  }
274
+ if (raw.billing !== null && typeof raw.billing === "object" && !Array.isArray(raw.billing)) {
275
+ state.billing.total = parseCostAccumulator(raw.billing.total);
276
+ for (const [date, entry] of Object.entries(raw.billing.days ?? {})) {
277
+ if (entry === null || typeof entry !== "object" || Array.isArray(entry)) continue;
278
+ const restored = { total: parseCostAccumulator(entry.total), models: new Map() };
279
+ for (const [model, accumulator] of Object.entries(entry.models ?? {})) restored.models.set(model, parseCostAccumulator(accumulator));
280
+ state.billing.days.set(date, restored);
281
+ }
282
+ for (const [providerId, count] of Object.entries(raw.billing.providers ?? {})) if (Number.isSafeInteger(count) && count > 0) state.billing.providers.set(providerId, count);
283
+ for (const [model, count] of Object.entries(raw.billing.models ?? {})) if (Number.isSafeInteger(count) && count > 0) state.billing.models.set(model, count);
284
+ state.billing.sampleCount = Number.isSafeInteger(raw.billing.sampleCount) && raw.billing.sampleCount >= 0 ? raw.billing.sampleCount : 0;
285
+ state.billing.firstAt = Number.isFinite(raw.billing.firstAt) ? raw.billing.firstAt : null;
286
+ state.billing.lastAt = Number.isFinite(raw.billing.lastAt) ? raw.billing.lastAt : null;
287
+ state.billing.penultimateAt = Number.isFinite(raw.billing.penultimateAt) ? raw.billing.penultimateAt : null;
288
+ }
201
289
  if (typeof raw.currentModel === "string") state.currentModel = raw.currentModel;
290
+ if (raw.currentRoute !== null && typeof raw.currentRoute === "object"
291
+ && typeof raw.currentRoute.providerId === "string" && raw.currentRoute.providerId.length > 0
292
+ && typeof raw.currentRoute.model === "string" && raw.currentRoute.model.length > 0) {
293
+ state.currentRoute = {
294
+ providerId: raw.currentRoute.providerId,
295
+ model: raw.currentRoute.model,
296
+ updatedAt: Number.isFinite(raw.currentRoute.updatedAt) ? raw.currentRoute.updatedAt : null
297
+ };
298
+ }
202
299
  return state;
203
300
  }
204
301
 
205
- /** Load the cache once per process; any corruption degrades to a fresh cache. */
206
- async function loadCache() {
207
- if (loadedCache !== null) return loadedCache;
302
+ function parsePricingIdentityCutoffs(raw) {
303
+ const cutoffs = Object.create(null);
304
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return cutoffs;
305
+ for (const [routeId, cutoff] of Object.entries(raw)) {
306
+ if (routeId !== "" && Number.isFinite(cutoff) && cutoff >= 0) cutoffs[routeId] = cutoff;
307
+ }
308
+ return cutoffs;
309
+ }
310
+
311
+ function freshCache(pricingFingerprintValue, previous = null, transitionAt = Date.now()) {
312
+ const pricingIdentityCutoffs = parsePricingIdentityCutoffs(previous?.pricingIdentityCutoffs);
313
+ let pricingIdentityCutoffAll = Number.isFinite(previous?.pricingIdentityCutoffAll) && previous.pricingIdentityCutoffAll >= 0
314
+ ? previous.pricingIdentityCutoffAll
315
+ : null;
316
+ if (previous !== null && previous.pricingFingerprint !== pricingFingerprintValue) {
317
+ const changedRoutes = changedProviderPricingRoutes(previous.pricingFingerprint, pricingFingerprintValue);
318
+ if (changedRoutes === null) pricingIdentityCutoffAll = Math.max(pricingIdentityCutoffAll ?? 0, transitionAt);
319
+ else for (const routeId of changedRoutes) pricingIdentityCutoffs[routeId] = Math.max(pricingIdentityCutoffs[routeId] ?? 0, transitionAt);
320
+ }
321
+ return {
322
+ version: CACHE_VERSION,
323
+ pricingFingerprint: pricingFingerprintValue,
324
+ pricingIdentityCutoffAll,
325
+ pricingIdentityCutoffs,
326
+ sessions: {}
327
+ };
328
+ }
329
+
330
+ function restoredCache(parsed, pricingFingerprintValue) {
331
+ const sessions = {};
332
+ for (const [id, entry] of Object.entries(parsed.sessions)) {
333
+ if (typeof id === "string" && id.length > 0) sessions[id] = parseSession(entry);
334
+ }
335
+ return {
336
+ version: CACHE_VERSION,
337
+ pricingFingerprint: pricingFingerprintValue,
338
+ pricingIdentityCutoffAll: Number.isFinite(parsed.pricingIdentityCutoffAll) && parsed.pricingIdentityCutoffAll >= 0
339
+ ? parsed.pricingIdentityCutoffAll
340
+ : null,
341
+ pricingIdentityCutoffs: parsePricingIdentityCutoffs(parsed.pricingIdentityCutoffs),
342
+ sessions
343
+ };
344
+ }
345
+
346
+ /** Load against the current runtime provider/pricing fingerprint. */
347
+ async function loadCache(pricingFingerprintValue) {
348
+ if (loadedCache !== null) {
349
+ if (loadedCache.pricingFingerprint !== pricingFingerprintValue) {
350
+ loadedCache = freshCache(pricingFingerprintValue, loadedCache);
351
+ loadPromise = Promise.resolve(loadedCache);
352
+ }
353
+ return loadedCache;
354
+ }
208
355
  loadPromise ??= (async () => {
209
- const fresh = { version: CACHE_VERSION, sessions: {} };
356
+ const fresh = freshCache(pricingFingerprintValue);
210
357
  try {
211
358
  const raw = await readFile(cachePath(), "utf8");
212
359
  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 };
360
+ if (parsed !== null && typeof parsed === "object" && parsed.version === CACHE_VERSION
361
+ && typeof parsed.pricingFingerprint === "string"
362
+ && parsed.sessions !== null && typeof parsed.sessions === "object") {
363
+ if (parsed.pricingFingerprint === pricingFingerprintValue) return restoredCache(parsed, pricingFingerprintValue);
364
+ return freshCache(pricingFingerprintValue, parsed);
219
365
  }
220
366
  } catch {
221
367
  /* first run or corrupt cache */
@@ -231,7 +377,13 @@ async function saveCache(ctx, cache) {
231
377
  try {
232
378
  const path = cachePath();
233
379
  await mkdir(dirname(path), { recursive: true });
234
- const serialized = { version: CACHE_VERSION, sessions: {} };
380
+ const serialized = {
381
+ version: CACHE_VERSION,
382
+ pricingFingerprint: cache.pricingFingerprint,
383
+ pricingIdentityCutoffAll: cache.pricingIdentityCutoffAll,
384
+ pricingIdentityCutoffs: cache.pricingIdentityCutoffs,
385
+ sessions: {}
386
+ };
235
387
  for (const [id, state] of Object.entries(cache.sessions)) serialized.sessions[id] = serializeSession(state);
236
388
  const tmp = `${path}.tmp`;
237
389
  await writeFile(tmp, JSON.stringify(serialized), "utf8");
@@ -266,21 +418,30 @@ function withLock(run) {
266
418
  * Sessions that vanished are dropped, and a session switching between
267
419
  * live/persisted is refolded from scratch to stay exact.
268
420
  */
269
- export async function collectUsage(ctx) {
421
+ export async function collectUsage(ctx, config = { monitors: {}, budgets: validateBudgetConfig() }) {
270
422
  return withLock(async () => {
271
- const cache = await loadCache();
423
+ const providers = await configuredProviders(ctx);
424
+ const runtimePricingFingerprint = pricingFingerprint({ providers, config });
425
+ const cache = await loadCache(runtimePricingFingerprint);
426
+ const estimateCurrentCost = createUsageCostEstimator(providers, config);
427
+ const estimateCost = (input) => {
428
+ const routeCutoff = Object.hasOwn(cache.pricingIdentityCutoffs, input.providerId)
429
+ ? cache.pricingIdentityCutoffs[input.providerId]
430
+ : null;
431
+ const cutoff = Math.max(cache.pricingIdentityCutoffAll ?? 0, routeCutoff ?? 0);
432
+ if (cutoff > 0 && (!Number.isFinite(input.timestamp) || input.timestamp <= cutoff)) return null;
433
+ return estimateCurrentCost(input);
434
+ };
272
435
  const live = ctx.get("sessions");
273
436
  const attached = new Set();
274
437
  if (live !== void 0) {
275
438
  for (const session of live.list()) {
276
439
  attached.add(session.id);
277
440
  const state = cache.sessions[session.id] ?? createUsageState();
441
+ if (typeof session.title === "string") state.title = session.title;
278
442
  if (state.kind !== "live") {
279
443
  // 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;
444
+ resetUsageState(state);
284
445
  }
285
446
  const count = session.events.length;
286
447
  if (count < (state.consumed ?? 0)) {
@@ -290,13 +451,10 @@ export async function collectUsage(ctx) {
290
451
  // full log would silently freeze this session's stats
291
452
  // forever (#23). The cursor is meaningless against the
292
453
  // rebuilt log: refold it from scratch.
293
- state.days = new Map();
294
- state.lastSample = null;
295
- state.currentModel = null;
296
- state.consumed = 0;
454
+ resetUsageState(state);
297
455
  }
298
456
  if ((state.consumed ?? 0) < count) {
299
- applyUsageDelta(state, session.events.slice(state.consumed ?? 0));
457
+ applyUsageDelta(state, session.events.slice(state.consumed ?? 0), { estimateCost });
300
458
  state.consumed = count;
301
459
  }
302
460
  state.kind = "live";
@@ -323,6 +481,7 @@ export async function collectUsage(ctx) {
323
481
  persistedIds.add(meta.id);
324
482
  if (attached.has(meta.id)) continue;
325
483
  const state = cache.sessions[meta.id] ?? createUsageState();
484
+ if (typeof meta.title === "string") state.title = meta.title;
326
485
  const revision = revisionOf.get(meta.id);
327
486
  const changed = state.kind !== "persisted" || (revision !== void 0 && revision !== state.revision) || revision === void 0;
328
487
  if (changed) {
@@ -331,24 +490,25 @@ export async function collectUsage(ctx) {
331
490
  const fromSeq = wasPersisted ? state.consumed : 0;
332
491
  const { events } = await persistence.readFrom(meta.id, fromSeq);
333
492
  if (!wasPersisted) {
334
- state.days = new Map();
335
- state.lastSample = null;
336
- state.currentModel = null;
337
- state.consumed = 0;
493
+ resetUsageState(state);
338
494
  }
339
495
  const fresh = wasPersisted ? events.filter((event) => event.seq > (state.consumed ?? 0)) : events;
340
- const contiguous = fresh.length === 0 ? state.consumed === 0 : fresh[0].seq === state.consumed + 1;
496
+ // readFrom is inclusive: an unchanged log returns exactly the
497
+ // already-folded cursor event (seq === consumed). An empty
498
+ // fresh slice with the cursor still present is therefore NOT a
499
+ // rewrite — only a log that no longer contains the cursor
500
+ // (truncated/rewritten) must refold from scratch.
501
+ const contiguous = fresh.length === 0
502
+ ? wasPersisted && events.some((event) => event.seq === (state.consumed ?? 0))
503
+ : fresh[0].seq === state.consumed + 1;
341
504
  if (!contiguous && state.consumed > 0) {
342
505
  // 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;
506
+ resetUsageState(state);
347
507
  const { events: allEvents } = await persistence.readFrom(meta.id, 0);
348
- applyUsageDelta(state, allEvents);
508
+ applyUsageDelta(state, allEvents, { estimateCost });
349
509
  state.consumed = allEvents.length > 0 ? allEvents[allEvents.length - 1].seq : 0;
350
510
  } else if (fresh.length > 0) {
351
- applyUsageDelta(state, fresh);
511
+ applyUsageDelta(state, fresh, { estimateCost });
352
512
  state.consumed = fresh[fresh.length - 1].seq;
353
513
  }
354
514
  state.kind = "persisted";
@@ -364,18 +524,28 @@ export async function collectUsage(ctx) {
364
524
  if (!attached.has(id) && !persistedIds.has(id)) delete cache.sessions[id];
365
525
  }
366
526
  const byDay = new Map();
367
- for (const state of Object.values(cache.sessions)) mergeInto(byDay, state.days);
527
+ const billingByDay = new Map();
528
+ for (const state of Object.values(cache.sessions)) {
529
+ mergeInto(byDay, state.days);
530
+ mergeBillingInto(billingByDay, state.billing.days);
531
+ }
368
532
  // Keep the atomic cache write inside the single-flight section. Otherwise
369
533
  // overlapping saves can race on the same temporary file.
370
534
  await saveCache(ctx, cache);
371
- return renderUsage(byDay, Date.now());
535
+ const updatedAt = Date.now();
536
+ const rendered = renderUsage(byDay, updatedAt, billingByDay);
537
+ return {
538
+ ...rendered,
539
+ sessions: Object.entries(cache.sessions).map(([sessionId, state]) => renderSessionUsage(sessionId, state)).filter((session) => session.tokens > 0),
540
+ budgets: renderBudgetSummary(billingByDay, config.budgets ?? validateBudgetConfig(), updatedAt)
541
+ };
372
542
  });
373
543
  }
374
544
 
375
- async function handleUsage(ctx, req, res) {
545
+ async function handleUsage(ctx, config, req, res) {
376
546
  if (rejectForeignCaller(req, res)) return;
377
547
  try {
378
- const result = await collectUsage(ctx);
548
+ const result = await collectUsage(ctx, config);
379
549
  json(res, 200, { ok: true, ...result });
380
550
  } catch (error) {
381
551
  ctx.logger.warn(`usage-stats: usage aggregation failed: ${String(error)}`);
@@ -383,6 +553,40 @@ async function handleUsage(ctx, req, res) {
383
553
  }
384
554
  }
385
555
 
556
+ function exportFailure(ctx, kind, error, res) {
557
+ const name = error instanceof Error && typeof error.name === "string" ? error.name : "Error";
558
+ ctx.logger.warn(`usage-stats: ${kind} export failed (${name})`);
559
+ json(res, 500, { ok: false, error: "internal", message: "export failed" });
560
+ }
561
+
562
+ async function handleDailyExport(ctx, config, req, res) {
563
+ if (rejectForeignCaller(req, res)) return;
564
+ try {
565
+ attachment(res, "text/csv; charset=utf-8", "dsh-usage-daily.csv", dailyCsv(await collectUsage(ctx, config)));
566
+ } catch (error) {
567
+ exportFailure(ctx, "daily CSV", error, res);
568
+ }
569
+ }
570
+
571
+ async function handleSessionsExport(ctx, config, req, res) {
572
+ if (rejectForeignCaller(req, res)) return;
573
+ try {
574
+ attachment(res, "text/csv; charset=utf-8", "dsh-usage-sessions.csv", sessionsCsv(await collectUsage(ctx, config)));
575
+ } catch (error) {
576
+ exportFailure(ctx, "session CSV", error, res);
577
+ }
578
+ }
579
+
580
+ async function handleJsonExport(ctx, config, accounts, req, res) {
581
+ if (rejectForeignCaller(req, res)) return;
582
+ try {
583
+ const [usage, providerViews] = await Promise.all([collectUsage(ctx, config), accounts.providerViews()]);
584
+ attachment(res, "application/json; charset=utf-8", "dsh-usage-stats.json", JSON.stringify(jsonExport(usage, providerViews)));
585
+ } catch (error) {
586
+ exportFailure(ctx, "JSON", error, res);
587
+ }
588
+ }
589
+
386
590
  /**
387
591
  * Enumerate the harness's configured providers: the official DeepSeek route
388
592
  * (`llm-deepseek` settings namespace) plus every pi-ai provider profile
@@ -423,6 +627,105 @@ async function configuredProviders(ctx) {
423
627
  return providers;
424
628
  }
425
629
 
630
+ /**
631
+ * Resolve one live DSH session's provider/model pair. A bounded route hint from
632
+ * the formal per-session model selector wins for immediate pre-turn switches;
633
+ * the incremental event fold remains the no-hint source and history fallback.
634
+ * Both paths still pass through the shared identity resolver, remain O(new
635
+ * events), and add no stream listener, provider request, cache, or usage ledger.
636
+ */
637
+ export async function collectSessionContext(ctx, sessionId, config = { monitors: {} }, selectedRoute = null) {
638
+ await collectUsage(ctx, config);
639
+ const sessions = ctx.get("sessions");
640
+ const live = sessions?.get?.(sessionId) ?? sessions?.list?.().find((session) => session.id === sessionId);
641
+ if (live === void 0) return null;
642
+ const cache = loadedCache;
643
+ if (cache === null) return null;
644
+ const state = cache.sessions[sessionId];
645
+ if (state?.kind !== "live") return null;
646
+ const currentRoute = selectedRoute ?? state.currentRoute;
647
+ if (currentRoute === null || currentRoute === void 0) return null;
648
+ const providers = await configuredProviders(ctx);
649
+ const provider = providers.find((entry) => entry.id === currentRoute.providerId)
650
+ ?? { id: currentRoute.providerId, displayName: currentRoute.providerId };
651
+ return {
652
+ ...currentSessionContext(sessionId, { ...state, currentRoute }, provider, config),
653
+ session: renderSessionUsage(sessionId, state)
654
+ };
655
+ }
656
+
657
+ /** Resolve the current account ids for every live session without a new ledger or provider guess. */
658
+ export async function collectActiveAccountIds(ctx, config = { monitors: {} }) {
659
+ await collectUsage(ctx, config);
660
+ const sessions = ctx.get("sessions")?.list?.() ?? [];
661
+ const cache = loadedCache;
662
+ if (cache === null) return [];
663
+ const providers = await configuredProviders(ctx);
664
+ const byId = new Map(providers.map((provider) => [provider.id, provider]));
665
+ const accountIds = new Set();
666
+ for (const session of sessions) {
667
+ const state = cache.sessions[session.id];
668
+ if (state?.kind !== "live" || state.currentRoute === null || state.currentRoute === void 0) continue;
669
+ const provider = byId.get(state.currentRoute.providerId)
670
+ ?? { id: state.currentRoute.providerId, displayName: state.currentRoute.providerId };
671
+ const context = currentSessionContext(session.id, state, provider, config);
672
+ if (typeof context?.accountId === "string" && context.accountId !== "") accountIds.add(context.accountId);
673
+ }
674
+ return [...accountIds];
675
+ }
676
+
677
+ /** Session context is explicit in multi-session DSH; a single live session is unambiguous. */
678
+ async function handleSessionContext(ctx, config, accounts, req, res) {
679
+ if (rejectForeignCaller(req, res)) return;
680
+ try {
681
+ // Preserve the v0.3.0 API response for the legacy display flag even though
682
+ // the current client no longer renders any composer UI.
683
+ if (config.display?.currentSessionPill === false) {
684
+ json(res, 200, { ok: true, context: null, display: { currentSessionPill: false } });
685
+ return;
686
+ }
687
+ const url = new URL(req.url ?? "/", "http://x");
688
+ const requested = url.searchParams.get("session");
689
+ const selectedProvider = url.searchParams.get("provider");
690
+ const selectedModel = url.searchParams.get("model");
691
+ if ((selectedProvider === null) !== (selectedModel === null)
692
+ || selectedProvider !== null && (selectedProvider === "" || selectedProvider.length > 256 || selectedProvider.includes("\0"))
693
+ || selectedModel !== null && (selectedModel === "" || selectedModel.length > 512 || selectedModel.includes("\0"))) {
694
+ json(res, 400, { ok: false, error: "invalid-selection", message: "provider and model must be supplied together as bounded non-empty values" });
695
+ return;
696
+ }
697
+ const sessions = ctx.get("sessions")?.list?.() ?? [];
698
+ let sessionId = requested === null || requested === "" ? null : requested;
699
+ if (sessionId === null && sessions.length === 1) sessionId = sessions[0].id;
700
+ if (sessionId === null && sessions.length > 1) {
701
+ json(res, 400, { ok: false, error: "session-required", message: "session query parameter is required when multiple sessions are live" });
702
+ return;
703
+ }
704
+ if (sessionId === null) {
705
+ json(res, 200, { ok: true, context: null });
706
+ return;
707
+ }
708
+ if (!sessions.some((session) => session.id === sessionId)) {
709
+ json(res, 404, { ok: false, error: "unknown-session", message: `session "${sessionId}" is not live` });
710
+ return;
711
+ }
712
+ // The browser hint comes from DSH's formal per-session model directory.
713
+ // It carries route identity only; configuredProviders + the shared resolver
714
+ // below remain authoritative for family/account normalization.
715
+ const selectedRoute = selectedProvider === null ? null : {
716
+ providerId: selectedProvider,
717
+ model: selectedModel,
718
+ updatedAt: null
719
+ };
720
+ const context = await collectSessionContext(ctx, sessionId, config, selectedRoute);
721
+ if (typeof context?.accountId === "string" && context.accountId !== "") accounts.touch?.(context.accountId, "active");
722
+ json(res, 200, { ok: true, context, display: { currentSessionPill: true } });
723
+ } catch (error) {
724
+ ctx.logger.warn(`usage-stats: session context failed: ${String(error)}`);
725
+ json(res, 500, { ok: false, error: "internal", message: error instanceof Error ? error.message : String(error) });
726
+ }
727
+ }
728
+
426
729
  async function handleProviders(ctx, accounts, req, res) {
427
730
  if (rejectForeignCaller(req, res)) return;
428
731
  try {
@@ -433,6 +736,37 @@ async function handleProviders(ctx, accounts, req, res) {
433
736
  }
434
737
  }
435
738
 
739
+ /** Read secret-free preset state or perform the user's explicit path mutation. */
740
+ async function handleOrcaRouterIntegration(ctx, req, res) {
741
+ if (req.method === "GET") {
742
+ if (rejectForeignCaller(req, res)) return;
743
+ try {
744
+ json(res, 200, { ok: true, integration: orcaRouterIntegrationState(ctx.get("settings")) });
745
+ } catch (error) {
746
+ ctx.logger.warn(`usage-stats: OrcaRouter integration status failed: ${String(error)}`);
747
+ json(res, 500, { ok: false, error: "internal", message: "settings status unavailable" });
748
+ }
749
+ return;
750
+ }
751
+ if (rejectForeignMutation(req, res)) return;
752
+ try {
753
+ const integration = await addOrcaRouterPreset(ctx.get("settings"));
754
+ if (!integration.available) {
755
+ json(res, 409, { ok: false, error: "settings-unavailable", message: "DSH provider settings are not writable" });
756
+ return;
757
+ }
758
+ json(res, 200, { ok: true, integration });
759
+ } catch (error) {
760
+ const conflict = error?.code === "SETTINGS_CONFLICT";
761
+ ctx.logger.warn(`usage-stats: OrcaRouter settings mutation failed (${conflict ? "conflict" : "rejected"})`);
762
+ json(res, conflict ? 409 : 422, {
763
+ ok: false,
764
+ error: conflict ? "settings-conflict" : "settings-update-rejected",
765
+ message: conflict ? "provider settings changed; retry the action" : "DSH rejected the provider preset"
766
+ });
767
+ }
768
+ }
769
+
436
770
  async function selectedProviderId(req, accounts) {
437
771
  const url = new URL(req.url ?? "/", "http://x");
438
772
  const requested = url.searchParams.get("provider");
@@ -450,7 +784,9 @@ async function handleAccount(ctx, accounts, req, res) {
450
784
  try {
451
785
  const url = new URL(req.url ?? "/", "http://x");
452
786
  const providerId = await selectedProviderId(req, accounts);
453
- const account = providerId === null ? null : await accounts.get(providerId, { force: url.searchParams.get("refresh") === "1" });
787
+ const requestedActivity = url.searchParams.get("activity");
788
+ const activity = requestedActivity === "active" || requestedActivity === "detail" ? requestedActivity : null;
789
+ const account = providerId === null ? null : await accounts.get(providerId, { force: url.searchParams.get("refresh") === "1", activity });
454
790
  if (account === null) {
455
791
  json(res, 200, { ok: false, error: "unknown-provider", message: `provider "${providerId}" is not configured` });
456
792
  return;
@@ -526,41 +862,105 @@ async function handleSubscriptions(ctx, accounts, req, res) {
526
862
  }
527
863
  }
528
864
 
529
- /** Start an immediate refresh and repeat account + local usage refresh every 5 minutes. */
865
+ /** Existing five-minute usage fold plus an optional adaptive account scheduler. */
530
866
  export function startBackgroundRefresh(ctx, accounts, deps = {}) {
531
867
  let running = false;
532
868
  let stopped = false;
533
869
  let active = Promise.resolve();
534
- const run = async () => {
535
- if (running || stopped) return;
870
+ let timer = null;
871
+ let scheduleGeneration = 0;
872
+ let nextUsageAt = 0;
873
+ const now = deps.now ?? Date.now;
874
+ const usageIntervalMs = deps.usageIntervalMs ?? ACCOUNT_REFRESH_MS;
875
+ const setTimer = deps.setTimeout ?? setTimeout;
876
+ const clearTimer = deps.clearTimeout ?? clearTimeout;
877
+ const config = deps.config ?? { monitors: {} };
878
+ const accountRefreshEnabled = deps.accountRefreshEnabled !== false;
879
+
880
+ const clearScheduled = () => {
881
+ if (timer === null) return;
882
+ clearTimer(timer);
883
+ timer = null;
884
+ };
885
+
886
+ const schedule = async () => {
887
+ if (stopped) return;
888
+ const generation = ++scheduleGeneration;
889
+ clearScheduled();
890
+ let accountNext = null;
891
+ if (accountRefreshEnabled) {
892
+ try {
893
+ accountNext = await accounts.nextRefreshAt();
894
+ } catch (error) {
895
+ ctx.logger.warn(`usage-stats: refresh scheduling failed: ${String(error)}`);
896
+ }
897
+ }
898
+ if (stopped || generation !== scheduleGeneration) return;
899
+ const target = Math.min(accountNext ?? Infinity, nextUsageAt);
900
+ const delay = Math.max(1000, Number.isFinite(target) ? target - now() : usageIntervalMs);
901
+ timer = setTimer(() => {
902
+ timer = null;
903
+ void run();
904
+ }, delay);
905
+ timer?.unref?.();
906
+ };
907
+
908
+ const run = async (force = false) => {
909
+ if (stopped) return;
910
+ if (running) {
911
+ await active;
912
+ return force && !stopped ? run(true) : void 0;
913
+ }
914
+ clearScheduled();
536
915
  running = true;
537
916
  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)}`);
917
+ const at = now();
918
+ if (force || at >= nextUsageAt) {
919
+ try {
920
+ if (accountRefreshEnabled) accounts.setActiveProviders(await collectActiveAccountIds(ctx, config));
921
+ else await collectUsage(ctx, config);
922
+ } catch (error) {
923
+ ctx.logger.warn(`usage-stats: background usage refresh failed: ${String(error)}`);
924
+ }
925
+ nextUsageAt = now() + usageIntervalMs;
926
+ }
927
+ if (accountRefreshEnabled) {
928
+ try {
929
+ await accounts.refreshDue({ force });
930
+ } catch (error) {
931
+ ctx.logger.warn(`usage-stats: background account refresh failed: ${String(error)}`);
932
+ }
933
+ }
540
934
  })().finally(() => {
541
935
  running = false;
542
936
  });
543
- return active;
937
+ await active;
938
+ await schedule();
544
939
  };
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?.();
940
+ let unsubscribePolicyChanges = () => {};
941
+ if (accountRefreshEnabled) {
942
+ unsubscribePolicyChanges = accounts.subscribePolicyChanges?.(() => {
943
+ // Activity changes only rearm this one central timer. The service remains
944
+ // responsible for deciding whether an upstream refresh is actually due.
945
+ if (!stopped && !running) void schedule();
946
+ }) ?? unsubscribePolicyChanges;
947
+ }
948
+ const ready = run();
550
949
  const stop = async () => {
551
950
  stopped = true;
552
- clearTimer(timer);
951
+ scheduleGeneration += 1;
952
+ clearScheduled();
953
+ unsubscribePolicyChanges();
553
954
  await active;
554
955
  };
555
- stop.refreshNow = async () => {
556
- await active;
557
- return run();
558
- };
956
+ stop.ready = ready;
957
+ stop.refreshNow = () => run(true);
559
958
  return stop;
560
959
  }
561
960
 
562
961
  /**
563
- * Plugin body: register the five exact routes and start background refresh.
962
+ * Plugin body: register nine data routes plus the explicit integration route,
963
+ * then start background refresh.
564
964
  * @param ctx - plugin context carrying webServer, credentials, sessions, sessionPersistence, settings, and llm.
565
965
  */
566
966
  const Config = {
@@ -569,7 +969,7 @@ const Config = {
569
969
  vendor: "dsh-usage-stats",
570
970
  validate(value) {
571
971
  try {
572
- return { value: validateAccountConfig(value ?? {}) };
972
+ return { value: validateConfig(value ?? {}) };
573
973
  } catch (error) {
574
974
  return { issues: [{ message: error instanceof Error ? error.message : String(error) }] };
575
975
  }
@@ -577,8 +977,22 @@ const Config = {
577
977
  }
578
978
  };
579
979
 
980
+ function validateConfig(raw = {}) {
981
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) throw new Error("plugin config must be an object");
982
+ const display = raw.display ?? {};
983
+ if (display === null || typeof display !== "object" || Array.isArray(display)) throw new Error("display must be an object");
984
+ if (display.currentSessionPill !== void 0 && typeof display.currentSessionPill !== "boolean") {
985
+ throw new Error("display.currentSessionPill must be a boolean");
986
+ }
987
+ return {
988
+ ...validateAccountConfig(raw),
989
+ budgets: validateBudgetConfig(raw.budgets),
990
+ display: { currentSessionPill: display.currentSessionPill !== false }
991
+ };
992
+ }
993
+
580
994
  async function apply(ctx, rawConfig = {}, deps = {}) {
581
- const config = validateAccountConfig(rawConfig);
995
+ const config = validateConfig(rawConfig);
582
996
  const accounts = deps.accounts ?? createAccountService({
583
997
  credentials: ctx.get("credentials") ?? ctx.credentials,
584
998
  getProviders: () => configuredProviders(ctx),
@@ -591,7 +1005,7 @@ async function apply(ctx, rawConfig = {}, deps = {}) {
591
1005
  ctx.effect(() => ctx.webServer.register({
592
1006
  kind: "exact",
593
1007
  path: USAGE_PATH,
594
- handler: (req, res) => handleUsage(ctx, req, res)
1008
+ handler: (req, res) => handleUsage(ctx, config, req, res)
595
1009
  }), "usage-stats: usage route");
596
1010
  ctx.effect(() => ctx.webServer.register({
597
1011
  kind: "exact",
@@ -613,7 +1027,35 @@ async function apply(ctx, rawConfig = {}, deps = {}) {
613
1027
  path: SUBSCRIPTIONS_PATH,
614
1028
  handler: (req, res) => handleSubscriptions(ctx, accounts, req, res)
615
1029
  }), "usage-stats: subscriptions route");
616
- if (deps.disableBackgroundRefresh !== true) ctx.effect(() => startBackgroundRefresh(ctx, accounts), "usage-stats: background account refresh");
1030
+ ctx.effect(() => ctx.webServer.register({
1031
+ kind: "exact",
1032
+ path: SESSION_CONTEXT_PATH,
1033
+ handler: (req, res) => handleSessionContext(ctx, config, accounts, req, res)
1034
+ }), "usage-stats: session context route");
1035
+ ctx.effect(() => ctx.webServer.register({
1036
+ kind: "exact",
1037
+ path: ORCAROUTER_INTEGRATION_PATH,
1038
+ handler: (req, res) => handleOrcaRouterIntegration(ctx, req, res)
1039
+ }), "usage-stats: optional OrcaRouter integration route");
1040
+ ctx.effect(() => ctx.webServer.register({
1041
+ kind: "exact",
1042
+ path: DAILY_EXPORT_PATH,
1043
+ handler: (req, res) => handleDailyExport(ctx, config, req, res)
1044
+ }), "usage-stats: daily CSV export route");
1045
+ ctx.effect(() => ctx.webServer.register({
1046
+ kind: "exact",
1047
+ path: SESSIONS_EXPORT_PATH,
1048
+ handler: (req, res) => handleSessionsExport(ctx, config, req, res)
1049
+ }), "usage-stats: sessions CSV export route");
1050
+ ctx.effect(() => ctx.webServer.register({
1051
+ kind: "exact",
1052
+ path: JSON_EXPORT_PATH,
1053
+ handler: (req, res) => handleJsonExport(ctx, config, accounts, req, res)
1054
+ }), "usage-stats: JSON export route");
1055
+ if (deps.disableBackgroundRefresh !== true) ctx.effect(() => startBackgroundRefresh(ctx, accounts, {
1056
+ config,
1057
+ accountRefreshEnabled: config.refresh.enabled
1058
+ }), "usage-stats: background usage/account refresh");
617
1059
  }
618
1060
 
619
- export { apply, Config, inject, name, USAGE_PATH, PROVIDERS_PATH, BALANCE_PATH, SUBSCRIPTIONS_PATH, ACCOUNT_PATH, configuredProviders, totalTokens, zeroBuckets };
1061
+ 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, ORCAROUTER_INTEGRATION_PATH, configuredProviders, totalTokens, validateConfig, zeroBuckets };