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