@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/README.md +97 -20
- package/SECURITY.md +3 -1
- package/docs/release-checklist.md +111 -0
- package/docs/release-notes-v0.3.0.md +25 -0
- package/docs/release-notes-v0.3.1.md +40 -0
- package/lib/accounts.js +421 -172
- package/lib/balance.js +116 -15
- package/lib/billing.js +319 -0
- package/lib/client.js +481 -86
- package/lib/export.js +227 -0
- package/lib/index.js +506 -64
- package/lib/network.js +65 -0
- package/lib/orcarouter.js +79 -0
- package/lib/pricing.js +391 -0
- package/lib/provider-identity.js +129 -0
- package/lib/usage.js +190 -13
- package/package.json +17 -5
package/lib/index.js
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* dsh-usage-stats — server half.
|
|
3
3
|
*
|
|
4
|
-
* Registers
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
if (
|
|
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 =
|
|
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
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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 = {
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
535
|
-
|
|
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
|
|
539
|
-
|
|
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
|
-
|
|
937
|
+
await active;
|
|
938
|
+
await schedule();
|
|
544
939
|
};
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
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
|
-
|
|
951
|
+
scheduleGeneration += 1;
|
|
952
|
+
clearScheduled();
|
|
953
|
+
unsubscribePolicyChanges();
|
|
553
954
|
await active;
|
|
554
955
|
};
|
|
555
|
-
stop.
|
|
556
|
-
|
|
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
|
|
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:
|
|
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 =
|
|
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
|
-
|
|
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 };
|