@ychris12138/dsh-usage-stats 0.3.0 → 0.3.2

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,7 +1,8 @@
1
1
  /**
2
2
  * dsh-usage-stats — server half.
3
3
  *
4
- * Registers nine 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>)
@@ -11,6 +12,7 @@
11
12
  * GET /api/usage-stats/export/daily.csv — daily provider/model usage export
12
13
  * GET /api/usage-stats/export/sessions.csv — per-session usage export
13
14
  * GET /api/usage-stats/export.json — versioned usage/account-safe export
15
+ * GET|POST /api/usage-stats/integrations/orcarouter — status / explicit add
14
16
  *
15
17
  * Provider configuration is read straight from the harness settings
16
18
  * (`llm-deepseek` for the official DeepSeek route, `llm-pi-ai` for every
@@ -41,6 +43,7 @@ import { applyUsageDelta, createUsageState, currentSessionContext, mergeBillingI
41
43
  import { ACCOUNT_REFRESH_MS, createAccountService, validateAccountConfig } from "./accounts.js";
42
44
  import { changedProviderPricingRoutes, createUsageCostEstimator, parseCostAccumulator, pricingFingerprint, renderBudgetSummary, serializeCostAccumulator, validateBudgetConfig } from "./billing.js";
43
45
  import { dailyCsv, jsonExport, sessionsCsv } from "./export.js";
46
+ import { addOrcaRouterPreset, orcaRouterIntegrationState } from "./orcarouter.js";
44
47
 
45
48
  /** Stable Cordis plugin name. */
46
49
  const name = "usage-stats";
@@ -57,6 +60,7 @@ const SESSION_CONTEXT_PATH = "/api/usage-stats/session-context";
57
60
  const DAILY_EXPORT_PATH = "/api/usage-stats/export/daily.csv";
58
61
  const SESSIONS_EXPORT_PATH = "/api/usage-stats/export/sessions.csv";
59
62
  const JSON_EXPORT_PATH = "/api/usage-stats/export.json";
63
+ const ORCAROUTER_INTEGRATION_PATH = "/api/usage-stats/integrations/orcarouter";
60
64
  const UPSTREAM_TIMEOUT_MS = 15000;
61
65
  const CACHE_VERSION = 5;
62
66
 
@@ -138,6 +142,29 @@ function rejectForeignCaller(req, res) {
138
142
  return true;
139
143
  }
140
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
+
141
168
  //#region incremental cache
142
169
  /** Cache file location under the dsh home. */
143
170
  function cachePath() {
@@ -149,6 +176,37 @@ let loadedCache = null;
149
176
  let loadPromise = null;
150
177
  let inflight = null;
151
178
 
179
+ // Runtime-only cache bookkeeping. Nothing in this WeakMap is serialized to
180
+ // disk: the on-disk schema remains CACHE_VERSION=5. Keeping the aggregate and
181
+ // dirty flags out of the persisted shape also means old cache files remain
182
+ // valid while hot UI reads can skip redundant work.
183
+ const cacheRuntime = new WeakMap();
184
+
185
+ function runtimeOf(cache) {
186
+ let runtime = cacheRuntime.get(cache);
187
+ if (runtime === void 0) {
188
+ runtime = {
189
+ dirty: false,
190
+ aggregate: null,
191
+ aggregateDirty: true
192
+ };
193
+ cacheRuntime.set(cache, runtime);
194
+ }
195
+ return runtime;
196
+ }
197
+
198
+ function markCacheChanged(cache) {
199
+ const runtime = runtimeOf(cache);
200
+ runtime.dirty = true;
201
+ runtime.aggregateDirty = true;
202
+ }
203
+
204
+ function diagnostic(ctx, name) {
205
+ const stats = ctx?.__usageStatsDiagnostics;
206
+ if (stats === null || typeof stats !== "object") return;
207
+ stats[name] = (stats[name] ?? 0) + 1;
208
+ }
209
+
152
210
  /** Serialize one session's fold state (Maps → plain objects). */
153
211
  function serializeSession(state) {
154
212
  const days = {};
@@ -282,6 +340,7 @@ function parsePricingIdentityCutoffs(raw) {
282
340
  }
283
341
 
284
342
  function freshCache(pricingFingerprintValue, previous = null, transitionAt = Date.now()) {
343
+ const fingerprintChanged = previous !== null && previous.pricingFingerprint !== pricingFingerprintValue;
285
344
  const pricingIdentityCutoffs = parsePricingIdentityCutoffs(previous?.pricingIdentityCutoffs);
286
345
  let pricingIdentityCutoffAll = Number.isFinite(previous?.pricingIdentityCutoffAll) && previous.pricingIdentityCutoffAll >= 0
287
346
  ? previous.pricingIdentityCutoffAll
@@ -291,13 +350,19 @@ function freshCache(pricingFingerprintValue, previous = null, transitionAt = Dat
291
350
  if (changedRoutes === null) pricingIdentityCutoffAll = Math.max(pricingIdentityCutoffAll ?? 0, transitionAt);
292
351
  else for (const routeId of changedRoutes) pricingIdentityCutoffs[routeId] = Math.max(pricingIdentityCutoffs[routeId] ?? 0, transitionAt);
293
352
  }
294
- return {
353
+ const cache = {
295
354
  version: CACHE_VERSION,
296
355
  pricingFingerprint: pricingFingerprintValue,
297
356
  pricingIdentityCutoffAll,
298
357
  pricingIdentityCutoffs,
299
358
  sessions: {}
300
359
  };
360
+ const runtime = runtimeOf(cache);
361
+ // A pricing identity/catalog change invalidates all derived billing. Mark
362
+ // the replacement dirty even when the next collection deliberately avoids
363
+ // a persisted scan, so the stale fingerprint cannot survive a restart.
364
+ if (fingerprintChanged) runtime.dirty = true;
365
+ return cache;
301
366
  }
302
367
 
303
368
  function restoredCache(parsed, pricingFingerprintValue) {
@@ -305,7 +370,7 @@ function restoredCache(parsed, pricingFingerprintValue) {
305
370
  for (const [id, entry] of Object.entries(parsed.sessions)) {
306
371
  if (typeof id === "string" && id.length > 0) sessions[id] = parseSession(entry);
307
372
  }
308
- return {
373
+ const cache = {
309
374
  version: CACHE_VERSION,
310
375
  pricingFingerprint: pricingFingerprintValue,
311
376
  pricingIdentityCutoffAll: Number.isFinite(parsed.pricingIdentityCutoffAll) && parsed.pricingIdentityCutoffAll >= 0
@@ -314,6 +379,8 @@ function restoredCache(parsed, pricingFingerprintValue) {
314
379
  pricingIdentityCutoffs: parsePricingIdentityCutoffs(parsed.pricingIdentityCutoffs),
315
380
  sessions
316
381
  };
382
+ runtimeOf(cache);
383
+ return cache;
317
384
  }
318
385
 
319
386
  /** Load against the current runtime provider/pricing fingerprint. */
@@ -347,6 +414,8 @@ async function loadCache(pricingFingerprintValue) {
347
414
 
348
415
  /** Persist the cache atomically (temp + rename); failures are logged, never fatal. */
349
416
  async function saveCache(ctx, cache) {
417
+ const runtime = runtimeOf(cache);
418
+ if (!runtime.dirty) return false;
350
419
  try {
351
420
  const path = cachePath();
352
421
  await mkdir(dirname(path), { recursive: true });
@@ -361,18 +430,32 @@ async function saveCache(ctx, cache) {
361
430
  const tmp = `${path}.tmp`;
362
431
  await writeFile(tmp, JSON.stringify(serialized), "utf8");
363
432
  await rename(tmp, path);
433
+ runtime.dirty = false;
434
+ diagnostic(ctx, "cacheWrites");
435
+ return true;
364
436
  } catch (error) {
365
437
  ctx.logger.warn(`usage-stats: saving usage cache failed: ${String(error)}`);
438
+ return false;
366
439
  }
367
440
  }
368
441
 
369
- /** Single-flight guard: concurrent requests share one aggregation run. */
370
- function withLock(run) {
371
- if (inflight !== null) return inflight;
372
- inflight = run().finally(() => {
373
- inflight = null;
442
+ /**
443
+ * Single-flight guard: concurrent requests share compatible aggregation work.
444
+ * A full persisted scan cannot be satisfied by an already-running live-only
445
+ * collection; it waits for that run and then performs its own full pass.
446
+ */
447
+ function withCollectionLock(scanPersisted, run) {
448
+ if (inflight !== null) {
449
+ if (!scanPersisted || inflight.scanPersisted) return inflight.promise;
450
+ const waitForLive = () => withCollectionLock(true, run);
451
+ return inflight.promise.then(waitForLive, waitForLive);
452
+ }
453
+ const entry = { scanPersisted, promise: null };
454
+ entry.promise = run().finally(() => {
455
+ if (inflight === entry) inflight = null;
374
456
  });
375
- return inflight;
457
+ inflight = entry;
458
+ return entry.promise;
376
459
  }
377
460
  //#endregion
378
461
 
@@ -389,10 +472,47 @@ function withLock(run) {
389
472
  * to be contiguous with the last folded seq — a gap or an empty delta means
390
473
  * the log was truncated/rewritten, so the session is refolded from scratch.
391
474
  * Sessions that vanished are dropped, and a session switching between
392
- * live/persisted is refolded from scratch to stay exact.
475
+ * live/persisted is refolded from scratch to stay exact. High-frequency UI
476
+ * reads pass `{ scanPersisted: false }`: cached persisted folds remain in the
477
+ * aggregate, but the persistence backend is not enumerated or read. The
478
+ * five-minute background fold and exports use the default full scan.
393
479
  */
394
- export async function collectUsage(ctx, config = { monitors: {}, budgets: validateBudgetConfig() }) {
395
- return withLock(async () => {
480
+ function liveSessionAdapter(session) {
481
+ if (session === null || typeof session !== "object") throw new TypeError("live session must be an object");
482
+ if (typeof session.snapshotEvents === "function") {
483
+ if (!("seq" in session)) throw new TypeError("live session exposes an incomplete snapshot API");
484
+ return {
485
+ count() {
486
+ const count = session.seq;
487
+ if (!Number.isSafeInteger(count) || count < 0) throw new TypeError("live session seq must be a non-negative safe integer");
488
+ return count;
489
+ },
490
+ tail(fromSeq) {
491
+ const events = session.snapshotEvents(fromSeq);
492
+ if (!Array.isArray(events)) throw new TypeError("live session snapshotEvents() must return an array");
493
+ return events;
494
+ }
495
+ };
496
+ }
497
+ const legacyEvents = session.events;
498
+ if (!Array.isArray(legacyEvents)) throw new TypeError("live session does not expose a supported snapshot API");
499
+ return {
500
+ count() {
501
+ const events = session.events;
502
+ if (!Array.isArray(events)) throw new TypeError("legacy live session events must be an array");
503
+ return events.length;
504
+ },
505
+ tail(fromSeq) {
506
+ const events = session.events;
507
+ if (!Array.isArray(events)) throw new TypeError("legacy live session events must be an array");
508
+ return events.slice(fromSeq);
509
+ }
510
+ };
511
+ }
512
+
513
+ export async function collectUsage(ctx, config = { monitors: {}, budgets: validateBudgetConfig() }, options = {}) {
514
+ const scanPersisted = options?.scanPersisted !== false;
515
+ return withCollectionLock(scanPersisted, async () => {
396
516
  const providers = await configuredProviders(ctx);
397
517
  const runtimePricingFingerprint = pricingFingerprint({ providers, config });
398
518
  const cache = await loadCache(runtimePricingFingerprint);
@@ -410,13 +530,23 @@ export async function collectUsage(ctx, config = { monitors: {}, budgets: valida
410
530
  if (live !== void 0) {
411
531
  for (const session of live.list()) {
412
532
  attached.add(session.id);
413
- const state = cache.sessions[session.id] ?? createUsageState();
414
- if (typeof session.title === "string") state.title = session.title;
533
+ const liveAdapter = liveSessionAdapter(session);
534
+ let state = cache.sessions[session.id];
535
+ let stateChanged = false;
536
+ if (state === void 0) {
537
+ state = createUsageState();
538
+ stateChanged = true;
539
+ }
540
+ if (typeof session.title === "string" && state.title !== session.title) {
541
+ state.title = session.title;
542
+ stateChanged = true;
543
+ }
415
544
  if (state.kind !== "live") {
416
545
  // Live/persisted transition: refold the whole in-memory log.
417
546
  resetUsageState(state);
547
+ stateChanged = true;
418
548
  }
419
- const count = session.events.length;
549
+ const count = liveAdapter.count();
420
550
  if (count < (state.consumed ?? 0)) {
421
551
  // The in-memory log shrank below the folded cursor — DSH
422
552
  // restores sessions from disk as compressed summaries after
@@ -425,76 +555,127 @@ export async function collectUsage(ctx, config = { monitors: {}, budgets: valida
425
555
  // forever (#23). The cursor is meaningless against the
426
556
  // rebuilt log: refold it from scratch.
427
557
  resetUsageState(state);
558
+ stateChanged = true;
428
559
  }
429
560
  if ((state.consumed ?? 0) < count) {
430
- applyUsageDelta(state, session.events.slice(state.consumed ?? 0), { estimateCost });
561
+ applyUsageDelta(state, liveAdapter.tail(state.consumed ?? 0), { estimateCost });
431
562
  state.consumed = count;
563
+ stateChanged = true;
564
+ }
565
+ if (state.kind !== "live") {
566
+ state.kind = "live";
567
+ stateChanged = true;
432
568
  }
433
- state.kind = "live";
434
569
  cache.sessions[session.id] = state;
570
+ if (stateChanged) markCacheChanged(cache);
435
571
  }
436
572
  }
437
573
  const persistence = ctx.get("sessionPersistence");
438
574
  const persistedIds = new Set();
439
- if (persistence !== void 0) {
575
+ if (scanPersisted && persistence !== void 0) {
440
576
  // Prefer the backend's opaque per-log revisions (no file I/O in the
441
577
  // plugin, works for any backend that exposes listSnapshots).
442
578
  let snapshots = null;
443
579
  if (typeof persistence.listSnapshots === "function") {
444
580
  try {
581
+ diagnostic(ctx, "listSnapshots");
445
582
  snapshots = await persistence.listSnapshots();
446
583
  } catch (error) {
447
584
  ctx.logger.warn(`usage-stats: listSnapshots failed, falling back to list(): ${String(error)}`);
448
585
  }
449
586
  }
450
- const metas = snapshots !== null ? snapshots.map((entry) => entry.header) : await persistence.list();
587
+ let metas;
588
+ if (snapshots !== null) metas = snapshots.map((entry) => entry.header);
589
+ else {
590
+ diagnostic(ctx, "list");
591
+ metas = typeof persistence.list === "function" ? await persistence.list() : [];
592
+ }
451
593
  const revisionOf = new Map();
452
594
  if (snapshots !== null) for (const entry of snapshots) revisionOf.set(entry.header.id, entry.revision);
453
595
  for (const meta of metas) {
454
596
  persistedIds.add(meta.id);
455
597
  if (attached.has(meta.id)) continue;
456
- const state = cache.sessions[meta.id] ?? createUsageState();
457
- if (typeof meta.title === "string") state.title = meta.title;
598
+ let state = cache.sessions[meta.id];
599
+ let stateChanged = false;
600
+ if (state === void 0) {
601
+ state = createUsageState();
602
+ stateChanged = true;
603
+ }
604
+ if (typeof meta.title === "string" && state.title !== meta.title) {
605
+ state.title = meta.title;
606
+ stateChanged = true;
607
+ }
458
608
  const revision = revisionOf.get(meta.id);
459
- const changed = state.kind !== "persisted" || (revision !== void 0 && revision !== state.revision) || revision === void 0;
609
+ const revisionChanged = revision !== void 0 && revision !== state.revision;
610
+ const changed = state.kind !== "persisted" || revisionChanged || revision === void 0;
611
+ if (revisionChanged) stateChanged = true;
460
612
  if (changed) {
461
613
  try {
462
614
  const wasPersisted = state.kind === "persisted";
463
615
  const fromSeq = wasPersisted ? state.consumed : 0;
616
+ diagnostic(ctx, "readFrom");
464
617
  const { events } = await persistence.readFrom(meta.id, fromSeq);
465
618
  if (!wasPersisted) {
466
619
  resetUsageState(state);
620
+ stateChanged = true;
467
621
  }
468
622
  const fresh = wasPersisted ? events.filter((event) => event.seq > (state.consumed ?? 0)) : events;
469
- const contiguous = fresh.length === 0 ? state.consumed === 0 : fresh[0].seq === state.consumed + 1;
623
+ // readFrom is inclusive: an unchanged log returns exactly the
624
+ // already-folded cursor event (seq === consumed). An empty
625
+ // fresh slice with the cursor still present is therefore NOT a
626
+ // rewrite — only a log that no longer contains the cursor
627
+ // (truncated/rewritten) must refold from scratch.
628
+ const contiguous = fresh.length === 0
629
+ ? wasPersisted && events.some((event) => event.seq === (state.consumed ?? 0))
630
+ : fresh[0].seq === state.consumed + 1;
470
631
  if (!contiguous && state.consumed > 0) {
471
632
  // Log truncated or rewritten: refold the whole log.
472
633
  resetUsageState(state);
634
+ stateChanged = true;
635
+ diagnostic(ctx, "readFrom");
473
636
  const { events: allEvents } = await persistence.readFrom(meta.id, 0);
474
637
  applyUsageDelta(state, allEvents, { estimateCost });
475
638
  state.consumed = allEvents.length > 0 ? allEvents[allEvents.length - 1].seq : 0;
476
639
  } else if (fresh.length > 0) {
477
640
  applyUsageDelta(state, fresh, { estimateCost });
478
641
  state.consumed = fresh[fresh.length - 1].seq;
642
+ stateChanged = true;
643
+ }
644
+ if (state.kind !== "persisted") {
645
+ state.kind = "persisted";
646
+ stateChanged = true;
647
+ }
648
+ if (revision !== void 0 && state.revision !== revision) {
649
+ state.revision = revision;
650
+ stateChanged = true;
479
651
  }
480
- state.kind = "persisted";
481
- if (revision !== void 0) state.revision = revision;
482
652
  } catch (error) {
483
653
  ctx.logger.warn(`usage-stats: reading persisted session "${meta.id}" failed: ${String(error)}`);
484
654
  }
485
655
  }
486
656
  cache.sessions[meta.id] = state;
657
+ if (stateChanged) markCacheChanged(cache);
658
+ }
659
+ for (const id of Object.keys(cache.sessions)) {
660
+ if (!attached.has(id) && !persistedIds.has(id)) {
661
+ delete cache.sessions[id];
662
+ markCacheChanged(cache);
663
+ }
487
664
  }
488
665
  }
489
- for (const id of Object.keys(cache.sessions)) {
490
- if (!attached.has(id) && !persistedIds.has(id)) delete cache.sessions[id];
491
- }
492
- const byDay = new Map();
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);
666
+ const runtime = runtimeOf(cache);
667
+ if (runtime.aggregate === null || runtime.aggregateDirty) {
668
+ diagnostic(ctx, "aggregateRebuilds");
669
+ const byDay = new Map();
670
+ const billingByDay = new Map();
671
+ for (const state of Object.values(cache.sessions)) {
672
+ mergeInto(byDay, state.days);
673
+ mergeBillingInto(billingByDay, state.billing.days);
674
+ }
675
+ runtime.aggregate = { byDay, billingByDay };
676
+ runtime.aggregateDirty = false;
497
677
  }
678
+ const { byDay, billingByDay } = runtime.aggregate;
498
679
  // Keep the atomic cache write inside the single-flight section. Otherwise
499
680
  // overlapping saves can race on the same temporary file.
500
681
  await saveCache(ctx, cache);
@@ -511,7 +692,7 @@ export async function collectUsage(ctx, config = { monitors: {}, budgets: valida
511
692
  async function handleUsage(ctx, config, req, res) {
512
693
  if (rejectForeignCaller(req, res)) return;
513
694
  try {
514
- const result = await collectUsage(ctx, config);
695
+ const result = await collectUsage(ctx, config, { scanPersisted: false });
515
696
  json(res, 200, { ok: true, ...result });
516
697
  } catch (error) {
517
698
  ctx.logger.warn(`usage-stats: usage aggregation failed: ${String(error)}`);
@@ -528,7 +709,7 @@ function exportFailure(ctx, kind, error, res) {
528
709
  async function handleDailyExport(ctx, config, req, res) {
529
710
  if (rejectForeignCaller(req, res)) return;
530
711
  try {
531
- attachment(res, "text/csv; charset=utf-8", "dsh-usage-daily.csv", dailyCsv(await collectUsage(ctx, config)));
712
+ attachment(res, "text/csv; charset=utf-8", "dsh-usage-daily.csv", dailyCsv(await collectUsage(ctx, config, { scanPersisted: true })));
532
713
  } catch (error) {
533
714
  exportFailure(ctx, "daily CSV", error, res);
534
715
  }
@@ -537,7 +718,7 @@ async function handleDailyExport(ctx, config, req, res) {
537
718
  async function handleSessionsExport(ctx, config, req, res) {
538
719
  if (rejectForeignCaller(req, res)) return;
539
720
  try {
540
- attachment(res, "text/csv; charset=utf-8", "dsh-usage-sessions.csv", sessionsCsv(await collectUsage(ctx, config)));
721
+ attachment(res, "text/csv; charset=utf-8", "dsh-usage-sessions.csv", sessionsCsv(await collectUsage(ctx, config, { scanPersisted: true })));
541
722
  } catch (error) {
542
723
  exportFailure(ctx, "session CSV", error, res);
543
724
  }
@@ -546,7 +727,10 @@ async function handleSessionsExport(ctx, config, req, res) {
546
727
  async function handleJsonExport(ctx, config, accounts, req, res) {
547
728
  if (rejectForeignCaller(req, res)) return;
548
729
  try {
549
- const [usage, providerViews] = await Promise.all([collectUsage(ctx, config), accounts.providerViews()]);
730
+ const [usage, providerViews] = await Promise.all([
731
+ collectUsage(ctx, config, { scanPersisted: true }),
732
+ accounts.providerViews()
733
+ ]);
550
734
  attachment(res, "application/json; charset=utf-8", "dsh-usage-stats.json", JSON.stringify(jsonExport(usage, providerViews)));
551
735
  } catch (error) {
552
736
  exportFailure(ctx, "JSON", error, res);
@@ -601,7 +785,7 @@ async function configuredProviders(ctx) {
601
785
  * events), and add no stream listener, provider request, cache, or usage ledger.
602
786
  */
603
787
  export async function collectSessionContext(ctx, sessionId, config = { monitors: {} }, selectedRoute = null) {
604
- await collectUsage(ctx, config);
788
+ await collectUsage(ctx, config, { scanPersisted: false });
605
789
  const sessions = ctx.get("sessions");
606
790
  const live = sessions?.get?.(sessionId) ?? sessions?.list?.().find((session) => session.id === sessionId);
607
791
  if (live === void 0) return null;
@@ -621,8 +805,8 @@ export async function collectSessionContext(ctx, sessionId, config = { monitors:
621
805
  }
622
806
 
623
807
  /** 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);
808
+ export async function collectActiveAccountIds(ctx, config = { monitors: {} }, options = { scanPersisted: false }) {
809
+ await collectUsage(ctx, config, options);
626
810
  const sessions = ctx.get("sessions")?.list?.() ?? [];
627
811
  const cache = loadedCache;
628
812
  if (cache === null) return [];
@@ -644,9 +828,8 @@ export async function collectActiveAccountIds(ctx, config = { monitors: {} }) {
644
828
  async function handleSessionContext(ctx, config, accounts, req, res) {
645
829
  if (rejectForeignCaller(req, res)) return;
646
830
  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.
831
+ // Preserve the v0.3.0 API response for the legacy display flag even though
832
+ // the current client no longer renders any composer UI.
650
833
  if (config.display?.currentSessionPill === false) {
651
834
  json(res, 200, { ok: true, context: null, display: { currentSessionPill: false } });
652
835
  return;
@@ -703,6 +886,37 @@ async function handleProviders(ctx, accounts, req, res) {
703
886
  }
704
887
  }
705
888
 
889
+ /** Read secret-free preset state or perform the user's explicit path mutation. */
890
+ async function handleOrcaRouterIntegration(ctx, req, res) {
891
+ if (req.method === "GET") {
892
+ if (rejectForeignCaller(req, res)) return;
893
+ try {
894
+ json(res, 200, { ok: true, integration: orcaRouterIntegrationState(ctx.get("settings")) });
895
+ } catch (error) {
896
+ ctx.logger.warn(`usage-stats: OrcaRouter integration status failed: ${String(error)}`);
897
+ json(res, 500, { ok: false, error: "internal", message: "settings status unavailable" });
898
+ }
899
+ return;
900
+ }
901
+ if (rejectForeignMutation(req, res)) return;
902
+ try {
903
+ const integration = await addOrcaRouterPreset(ctx.get("settings"));
904
+ if (!integration.available) {
905
+ json(res, 409, { ok: false, error: "settings-unavailable", message: "DSH provider settings are not writable" });
906
+ return;
907
+ }
908
+ json(res, 200, { ok: true, integration });
909
+ } catch (error) {
910
+ const conflict = error?.code === "SETTINGS_CONFLICT";
911
+ ctx.logger.warn(`usage-stats: OrcaRouter settings mutation failed (${conflict ? "conflict" : "rejected"})`);
912
+ json(res, conflict ? 409 : 422, {
913
+ ok: false,
914
+ error: conflict ? "settings-conflict" : "settings-update-rejected",
915
+ message: conflict ? "provider settings changed; retry the action" : "DSH rejected the provider preset"
916
+ });
917
+ }
918
+ }
919
+
706
920
  async function selectedProviderId(req, accounts) {
707
921
  const url = new URL(req.url ?? "/", "http://x");
708
922
  const requested = url.searchParams.get("provider");
@@ -853,8 +1067,8 @@ export function startBackgroundRefresh(ctx, accounts, deps = {}) {
853
1067
  const at = now();
854
1068
  if (force || at >= nextUsageAt) {
855
1069
  try {
856
- if (accountRefreshEnabled) accounts.setActiveProviders(await collectActiveAccountIds(ctx, config));
857
- else await collectUsage(ctx, config);
1070
+ if (accountRefreshEnabled) accounts.setActiveProviders(await collectActiveAccountIds(ctx, config, { scanPersisted: true }));
1071
+ else await collectUsage(ctx, config, { scanPersisted: true });
858
1072
  } catch (error) {
859
1073
  ctx.logger.warn(`usage-stats: background usage refresh failed: ${String(error)}`);
860
1074
  }
@@ -895,7 +1109,8 @@ export function startBackgroundRefresh(ctx, accounts, deps = {}) {
895
1109
  }
896
1110
 
897
1111
  /**
898
- * Plugin body: register the nine exact routes and start background refresh.
1112
+ * Plugin body: register nine data routes plus the explicit integration route,
1113
+ * then start background refresh.
899
1114
  * @param ctx - plugin context carrying webServer, credentials, sessions, sessionPersistence, settings, and llm.
900
1115
  */
901
1116
  const Config = {
@@ -967,6 +1182,11 @@ async function apply(ctx, rawConfig = {}, deps = {}) {
967
1182
  path: SESSION_CONTEXT_PATH,
968
1183
  handler: (req, res) => handleSessionContext(ctx, config, accounts, req, res)
969
1184
  }), "usage-stats: session context route");
1185
+ ctx.effect(() => ctx.webServer.register({
1186
+ kind: "exact",
1187
+ path: ORCAROUTER_INTEGRATION_PATH,
1188
+ handler: (req, res) => handleOrcaRouterIntegration(ctx, req, res)
1189
+ }), "usage-stats: optional OrcaRouter integration route");
970
1190
  ctx.effect(() => ctx.webServer.register({
971
1191
  kind: "exact",
972
1192
  path: DAILY_EXPORT_PATH,
@@ -988,4 +1208,4 @@ async function apply(ctx, rawConfig = {}, deps = {}) {
988
1208
  }), "usage-stats: background usage/account refresh");
989
1209
  }
990
1210
 
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 };
1211
+ 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 };
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Optional OrcaRouter provider preset and its narrow DSH settings mutation.
3
+ *
4
+ * The plugin never edits settings.yaml directly and never installs this route
5
+ * during startup. A caller must explicitly request the single path mutation;
6
+ * an existing `orcarouter` profile always wins unchanged.
7
+ *
8
+ * @module dsh-usage-stats/orcarouter
9
+ */
10
+
11
+ const SETTINGS_NAMESPACE = "llm-pi-ai";
12
+ const PROVIDER_ID = "orcarouter";
13
+
14
+ export const ORCAROUTER_PROFILE = Object.freeze({
15
+ displayName: "OrcaRouter",
16
+ apiKeyEnv: "ORCAROUTER_API_KEY",
17
+ api: "openai-completions",
18
+ baseURL: "https://api.orcarouter.ai/v1",
19
+ models: Object.freeze([Object.freeze({ id: "orcarouter/auto", name: "OrcaRouter Auto" })])
20
+ });
21
+
22
+ function descriptorOf(settings) {
23
+ if (typeof settings?.describe !== "function") return null;
24
+ const descriptors = settings.describe({ redactSecrets: true });
25
+ if (!Array.isArray(descriptors)) return null;
26
+ return descriptors.find((entry) => entry?.ns === SETTINGS_NAMESPACE) ?? null;
27
+ }
28
+ function hasOrcaRouter(descriptor) {
29
+ const providers = descriptor?.value?.providers;
30
+ return providers !== null && typeof providers === "object" && !Array.isArray(providers)
31
+ && Object.hasOwn(providers, PROVIDER_ID);
32
+ }
33
+
34
+ /** Secret-free availability state suitable for the browser integration card. */
35
+ export function orcaRouterIntegrationState(settings) {
36
+ const descriptor = descriptorOf(settings);
37
+ const available = descriptor !== null && typeof settings?.mutate === "function" && settings.writable !== false;
38
+ return {
39
+ available,
40
+ installed: descriptor !== null && hasOrcaRouter(descriptor)
41
+ };
42
+ }
43
+
44
+ function detachedProfile() {
45
+ return {
46
+ displayName: ORCAROUTER_PROFILE.displayName,
47
+ apiKeyEnv: ORCAROUTER_PROFILE.apiKeyEnv,
48
+ api: ORCAROUTER_PROFILE.api,
49
+ baseURL: ORCAROUTER_PROFILE.baseURL,
50
+ models: ORCAROUTER_PROFILE.models.map((model) => ({ ...model }))
51
+ };
52
+ }
53
+
54
+ /**
55
+ * Add the preset with one revision-guarded path mutation. This preserves all
56
+ * unrelated providers and converges cleanly if another writer adds the same
57
+ * route between our read and write.
58
+ */
59
+ export async function addOrcaRouterPreset(settings) {
60
+ const before = descriptorOf(settings);
61
+ const available = before !== null && typeof settings?.mutate === "function" && settings.writable !== false;
62
+ if (!available) return { available: false, installed: hasOrcaRouter(before), added: false };
63
+ if (hasOrcaRouter(before)) return { available: true, installed: true, added: false };
64
+
65
+ try {
66
+ await settings.mutate(SETTINGS_NAMESPACE, [{
67
+ op: "set",
68
+ path: ["providers", PROVIDER_ID],
69
+ value: detachedProfile()
70
+ }], before.revision);
71
+ return { available: true, installed: true, added: true };
72
+ } catch (error) {
73
+ if (error?.code === "SETTINGS_CONFLICT") {
74
+ const after = descriptorOf(settings);
75
+ if (hasOrcaRouter(after)) return { available: true, installed: true, added: false };
76
+ }
77
+ throw error;
78
+ }
79
+ }
@@ -20,6 +20,7 @@ const ADAPTER_IDENTITIES = Object.freeze({
20
20
  "openrouter-balance": { providerFamily: "openrouter", pricingFamily: "openrouter" },
21
21
  "moonshot-balance": { providerFamily: "moonshot", pricingFamily: "moonshot" },
22
22
  "zai-balance": { providerFamily: "zai", pricingFamily: "zai" },
23
+ "orcarouter-balance": { providerFamily: "orcarouter", pricingFamily: "unknown" },
23
24
  general: { providerFamily: "unknown", pricingFamily: "unknown" },
24
25
  "new-api": { providerFamily: "new-api", pricingFamily: "unknown" },
25
26
  sub2api: { providerFamily: "sub2api", pricingFamily: "unknown" },
@@ -48,6 +49,7 @@ const CANONICAL_ROUTES = Object.freeze({
48
49
  minimaxi: { providerFamily: "minimax", accountAdapter: "minimax-token-plan", balanceScheme: null },
49
50
  "minimax-cn": { providerFamily: "minimax", accountAdapter: "minimax-token-plan", balanceScheme: null },
50
51
  "minimax-coding": { providerFamily: "minimax", accountAdapter: "minimax-token-plan", balanceScheme: null },
52
+ orcarouter: { providerFamily: "orcarouter", accountAdapter: "orcarouter-balance", pricingFamily: "unknown", balanceScheme: "orcarouter" },
51
53
  passion: { providerFamily: "sub2api", accountAdapter: "sub2api", pricingFamily: "unknown", balanceScheme: null }
52
54
  });
53
55
 
@@ -66,6 +68,7 @@ function hostnameOf(baseURL) {
66
68
 
67
69
  function hostRule(hostname) {
68
70
  if (hostname === "api.deepseek.com") return { providerFamily: "deepseek", accountAdapter: "deepseek-balance" };
71
+ if (hostname === "api.orcarouter.ai") return { providerFamily: "orcarouter", accountAdapter: "orcarouter-balance", pricingFamily: "unknown" };
69
72
  if (hostname === "passionapi.com" || hostname.endsWith(".passionapi.com")) return { providerFamily: "sub2api", accountAdapter: "sub2api", pricingFamily: "unknown" };
70
73
  if (hostname === "ollama.com" || hostname.endsWith(".ollama.com")) return { providerFamily: "ollama", accountAdapter: "ollama" };
71
74
  return null;