@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/README.md +21 -23
- package/SECURITY.md +1 -1
- package/docs/release-checklist.md +23 -11
- package/docs/release-notes-v0.3.1.md +40 -0
- package/docs/release-notes-v0.3.2.md +36 -0
- package/lib/accounts.js +3 -1
- package/lib/balance.js +115 -12
- package/lib/client.js +159 -317
- package/lib/index.js +267 -47
- package/lib/orcarouter.js +79 -0
- package/lib/provider-identity.js +3 -0
- package/package.json +6 -3
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
-
|
|
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
|
-
|
|
395
|
-
|
|
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
|
|
414
|
-
|
|
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 =
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
457
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
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([
|
|
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
|
-
//
|
|
648
|
-
//
|
|
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
|
|
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
|
+
}
|
package/lib/provider-identity.js
CHANGED
|
@@ -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;
|