@ychris12138/dsh-usage-stats 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js ADDED
@@ -0,0 +1,619 @@
1
+ /**
2
+ * dsh-usage-stats — server half.
3
+ *
4
+ * Registers five read-only, loopback-only endpoints on the web server:
5
+ * GET /api/usage-stats/usage — per-day token usage across every session
6
+ * GET /api/usage-stats/providers — configured providers + balance schemes
7
+ * GET /api/usage-stats/balance — balance for one provider (?provider=<id>)
8
+ * GET /api/usage-stats/subscriptions — OpenCode Go + Z.ai quota windows
9
+ * GET /api/usage-stats/account — unified account snapshot for one provider
10
+ *
11
+ * Provider configuration is read straight from the harness settings
12
+ * (`llm-deepseek` for the official DeepSeek route, `llm-pi-ai` for every
13
+ * configured pi-ai provider profile), and each provider's API key is resolved
14
+ * through the credentials seam at request time — nothing is stored by this
15
+ * plugin.
16
+ *
17
+ * The endpoints live under the `/api` prefix as exact routes, so they win
18
+ * over the connection plugin's `/api` prefix handler; each handler applies
19
+ * its own peer-socket loopback fence (the exact routes bypass the RPC trust
20
+ * fence); Host is checked only as an additional defense.
21
+ *
22
+ * Usage aggregation is INCREMENTAL: per-session fold state (day/model
23
+ * buckets plus the last usage sample) is cached in memory and persisted to
24
+ * `<DSH_HOME>/storages/usage-stats-cache.json`. On each request only the
25
+ * events added since the last fold are processed — live sessions fold their
26
+ * in-memory tail, while persisted sessions use the storage backend's opaque
27
+ * revision when available. Steady-state cost stays O(new events) no matter
28
+ * how large the logs grow.
29
+ *
30
+ * @module dsh-usage-stats
31
+ */
32
+
33
+ import { homedir } from "node:os";
34
+ import { join, dirname } from "node:path";
35
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
36
+ import { applyUsageDelta, createUsageState, mergeInto, renderUsage, totalTokens, zeroBuckets } from "./usage.js";
37
+ import { ACCOUNT_REFRESH_MS, createAccountService, validateAccountConfig } from "./accounts.js";
38
+
39
+ /** Stable Cordis plugin name. */
40
+ const name = "usage-stats";
41
+
42
+ /** Services required before this plugin activates. */
43
+ const inject = ["webServer", "credentials", "sessions", "sessionPersistence", "settings", "llm"];
44
+
45
+ const USAGE_PATH = "/api/usage-stats/usage";
46
+ const PROVIDERS_PATH = "/api/usage-stats/providers";
47
+ const BALANCE_PATH = "/api/usage-stats/balance";
48
+ const SUBSCRIPTIONS_PATH = "/api/usage-stats/subscriptions";
49
+ const ACCOUNT_PATH = "/api/usage-stats/account";
50
+ const UPSTREAM_TIMEOUT_MS = 15000;
51
+ const CACHE_VERSION = 3;
52
+
53
+ /** Default DeepSeek connection facts when the settings namespace is absent. */
54
+ const DEEPSEEK_DEFAULTS = {
55
+ apiKeyEnv: "DEEPSEEK_API_KEY",
56
+ baseURL: "https://api.deepseek.com"
57
+ };
58
+
59
+ /** Write a JSON response. */
60
+ function json(res, status, value) {
61
+ const body = JSON.stringify(value);
62
+ res.writeHead(status, {
63
+ "content-type": "application/json; charset=utf-8",
64
+ "cache-control": "no-cache"
65
+ });
66
+ res.end(body);
67
+ }
68
+
69
+ /**
70
+ * Loopback fence, primary on the PEER SOCKET address (not the
71
+ * client-controllable Host header): the request must come from a loopback
72
+ * interface. IPv4-mapped IPv6 (`::ffff:127.0.0.1`) is normalized. The Host
73
+ * header is kept as an additional check, never as the deciding one.
74
+ */
75
+ function isLoopbackAddress(address) {
76
+ if (typeof address !== "string") return false;
77
+ const a = address.toLowerCase();
78
+ if (a === "::1") return true;
79
+ const ipv4 = a.startsWith("::ffff:") ? a.slice(7) : a;
80
+ const octets = ipv4.split(".");
81
+ return octets.length === 4 && octets[0] === "127" && octets.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255);
82
+ }
83
+
84
+ /** Parse a Host header without breaking bracketed or bare IPv6 literals. */
85
+ function hostNameOf(value) {
86
+ if (typeof value !== "string") return null;
87
+ const host = value.trim().toLowerCase();
88
+ if (host.startsWith("[")) {
89
+ const close = host.indexOf("]");
90
+ if (close <= 1) return null;
91
+ const suffix = host.slice(close + 1);
92
+ if (suffix !== "" && !/^:\d+$/.test(suffix)) return null;
93
+ return host.slice(1, close);
94
+ }
95
+ const firstColon = host.indexOf(":");
96
+ const lastColon = host.lastIndexOf(":");
97
+ if (firstColon !== lastColon) return host;
98
+ if (lastColon === -1) return host.replace(/\.$/, "");
99
+ if (!/^\d+$/.test(host.slice(lastColon + 1))) return null;
100
+ return host.slice(0, lastColon).replace(/\.$/, "");
101
+ }
102
+
103
+ function isLoopbackHostHeader(req) {
104
+ const name = hostNameOf(req.headers.host);
105
+ return name === "localhost" || isLoopbackAddress(name);
106
+ }
107
+
108
+ /** Refuse non-loopback callers and non-GET methods before any work. */
109
+ function rejectForeignCaller(req, res) {
110
+ if (req.method !== "GET") {
111
+ res.writeHead(405, { "content-type": "application/json; charset=utf-8" });
112
+ res.end(JSON.stringify({ ok: false, error: "method-not-allowed" }));
113
+ return true;
114
+ }
115
+ const peer = req.socket?.remoteAddress;
116
+ if (isLoopbackAddress(peer) && isLoopbackHostHeader(req)) return false;
117
+ json(res, 403, { ok: false, error: "forbidden" });
118
+ return true;
119
+ }
120
+
121
+ //#region incremental cache
122
+ /** Cache file location under the dsh home. */
123
+ function cachePath() {
124
+ const home = process.env.DSH_HOME ?? join(homedir(), ".dsh");
125
+ return join(home, "storages", "usage-stats-cache.json");
126
+ }
127
+
128
+ let loadedCache = null;
129
+ let loadPromise = null;
130
+ let inflight = null;
131
+
132
+ /** Serialize one session's fold state (Maps → plain objects). */
133
+ function serializeSession(state) {
134
+ const days = {};
135
+ for (const [date, entry] of state.days) {
136
+ const models = {};
137
+ for (const [model, buckets] of entry.models) models[model] = { ...buckets };
138
+ days[date] = { totals: { ...entry.totals }, models };
139
+ }
140
+ return {
141
+ kind: state.kind ?? "persisted",
142
+ consumed: state.consumed ?? 0,
143
+ ...(state.revision === void 0 ? {} : { revision: state.revision }),
144
+ days,
145
+ lastSample: state.lastSample === null ? null : {
146
+ key: state.lastSample.key,
147
+ day: state.lastSample.day,
148
+ model: state.lastSample.model,
149
+ buckets: { ...state.lastSample.buckets }
150
+ },
151
+ currentModel: state.currentModel
152
+ };
153
+ }
154
+
155
+ /** Parse a serialized session entry back into fold state (lenient). */
156
+ function parseSession(raw) {
157
+ const state = createUsageState();
158
+ if (raw === null || typeof raw !== "object") return state;
159
+ state.kind = typeof raw.kind === "string" ? raw.kind : "persisted";
160
+ state.consumed = Number.isSafeInteger(raw.consumed) ? raw.consumed : 0;
161
+ if (typeof raw.revision === "string") state.revision = raw.revision;
162
+ if (raw.days !== null && typeof raw.days === "object") {
163
+ for (const [date, entry] of Object.entries(raw.days)) {
164
+ if (entry === null || typeof entry !== "object") continue;
165
+ const target = { totals: zeroBuckets(), models: new Map() };
166
+ const totals = entry.totals;
167
+ if (totals !== null && typeof totals === "object") {
168
+ target.totals.inputTokens = Number.isFinite(totals.inputTokens) ? totals.inputTokens : 0;
169
+ target.totals.outputTokens = Number.isFinite(totals.outputTokens) ? totals.outputTokens : 0;
170
+ target.totals.cacheReadTokens = Number.isFinite(totals.cacheReadTokens) ? totals.cacheReadTokens : 0;
171
+ target.totals.cacheWriteTokens = Number.isFinite(totals.cacheWriteTokens) ? totals.cacheWriteTokens : 0;
172
+ }
173
+ if (entry.models !== null && typeof entry.models === "object") {
174
+ for (const [model, buckets] of Object.entries(entry.models)) {
175
+ if (buckets === null || typeof buckets !== "object") continue;
176
+ target.models.set(model, {
177
+ inputTokens: Number.isFinite(buckets.inputTokens) ? buckets.inputTokens : 0,
178
+ outputTokens: Number.isFinite(buckets.outputTokens) ? buckets.outputTokens : 0,
179
+ cacheReadTokens: Number.isFinite(buckets.cacheReadTokens) ? buckets.cacheReadTokens : 0,
180
+ cacheWriteTokens: Number.isFinite(buckets.cacheWriteTokens) ? buckets.cacheWriteTokens : 0
181
+ });
182
+ }
183
+ }
184
+ state.days.set(date, target);
185
+ }
186
+ }
187
+ if (raw.lastSample !== null && raw.lastSample !== void 0 && typeof raw.lastSample === "object" && typeof raw.lastSample.key === "string" && typeof raw.lastSample.day === "string") {
188
+ const buckets = raw.lastSample.buckets ?? {};
189
+ state.lastSample = {
190
+ key: raw.lastSample.key,
191
+ day: raw.lastSample.day,
192
+ model: typeof raw.lastSample.model === "string" ? raw.lastSample.model : "unknown",
193
+ buckets: {
194
+ inputTokens: Number.isFinite(buckets.inputTokens) ? buckets.inputTokens : 0,
195
+ outputTokens: Number.isFinite(buckets.outputTokens) ? buckets.outputTokens : 0,
196
+ cacheReadTokens: Number.isFinite(buckets.cacheReadTokens) ? buckets.cacheReadTokens : 0,
197
+ cacheWriteTokens: Number.isFinite(buckets.cacheWriteTokens) ? buckets.cacheWriteTokens : 0
198
+ }
199
+ };
200
+ }
201
+ if (typeof raw.currentModel === "string") state.currentModel = raw.currentModel;
202
+ return state;
203
+ }
204
+
205
+ /** Load the cache once per process; any corruption degrades to a fresh cache. */
206
+ async function loadCache() {
207
+ if (loadedCache !== null) return loadedCache;
208
+ loadPromise ??= (async () => {
209
+ const fresh = { version: CACHE_VERSION, sessions: {} };
210
+ try {
211
+ const raw = await readFile(cachePath(), "utf8");
212
+ const parsed = JSON.parse(raw);
213
+ if (parsed !== null && typeof parsed === "object" && parsed.version === CACHE_VERSION && parsed.sessions !== null && typeof parsed.sessions === "object") {
214
+ const sessions = {};
215
+ for (const [id, entry] of Object.entries(parsed.sessions)) {
216
+ if (typeof id === "string" && id.length > 0) sessions[id] = parseSession(entry);
217
+ }
218
+ return { version: CACHE_VERSION, sessions };
219
+ }
220
+ } catch {
221
+ /* first run or corrupt cache */
222
+ }
223
+ return fresh;
224
+ })();
225
+ loadedCache = await loadPromise;
226
+ return loadedCache;
227
+ }
228
+
229
+ /** Persist the cache atomically (temp + rename); failures are logged, never fatal. */
230
+ async function saveCache(ctx, cache) {
231
+ try {
232
+ const path = cachePath();
233
+ await mkdir(dirname(path), { recursive: true });
234
+ const serialized = { version: CACHE_VERSION, sessions: {} };
235
+ for (const [id, state] of Object.entries(cache.sessions)) serialized.sessions[id] = serializeSession(state);
236
+ const tmp = `${path}.tmp`;
237
+ await writeFile(tmp, JSON.stringify(serialized), "utf8");
238
+ await rename(tmp, path);
239
+ } catch (error) {
240
+ ctx.logger.warn(`usage-stats: saving usage cache failed: ${String(error)}`);
241
+ }
242
+ }
243
+
244
+ /** Single-flight guard: concurrent requests share one aggregation run. */
245
+ function withLock(run) {
246
+ if (inflight !== null) return inflight;
247
+ inflight = run().finally(() => {
248
+ inflight = null;
249
+ });
250
+ return inflight;
251
+ }
252
+ //#endregion
253
+
254
+ /**
255
+ * Collect per-day usage across live and persisted sessions, incrementally.
256
+ *
257
+ * Live sessions: fold only the in-memory events added since the last fold;
258
+ * an in-memory log that SHRANK below the folded cursor was rebuilt (DSH
259
+ * restores compressed summaries after a restart), so the session is refolded
260
+ * from scratch instead of freezing its stats (#23).
261
+ * Persisted sessions: skipped when the backend's opaque revision is
262
+ * unchanged (`sessionPersistence.listSnapshots`, falling back to always
263
+ * reading the delta); when the revision changes, the new events are verified
264
+ * to be contiguous with the last folded seq — a gap or an empty delta means
265
+ * the log was truncated/rewritten, so the session is refolded from scratch.
266
+ * Sessions that vanished are dropped, and a session switching between
267
+ * live/persisted is refolded from scratch to stay exact.
268
+ */
269
+ export async function collectUsage(ctx) {
270
+ return withLock(async () => {
271
+ const cache = await loadCache();
272
+ const live = ctx.get("sessions");
273
+ const attached = new Set();
274
+ if (live !== void 0) {
275
+ for (const session of live.list()) {
276
+ attached.add(session.id);
277
+ const state = cache.sessions[session.id] ?? createUsageState();
278
+ if (state.kind !== "live") {
279
+ // Live/persisted transition: refold the whole in-memory log.
280
+ state.days = new Map();
281
+ state.lastSample = null;
282
+ state.currentModel = null;
283
+ state.consumed = 0;
284
+ }
285
+ const count = session.events.length;
286
+ if (count < (state.consumed ?? 0)) {
287
+ // The in-memory log shrank below the folded cursor — DSH
288
+ // restores sessions from disk as compressed summaries after
289
+ // a restart, so a positional cursor from the pre-restart
290
+ // full log would silently freeze this session's stats
291
+ // forever (#23). The cursor is meaningless against the
292
+ // rebuilt log: refold it from scratch.
293
+ state.days = new Map();
294
+ state.lastSample = null;
295
+ state.currentModel = null;
296
+ state.consumed = 0;
297
+ }
298
+ if ((state.consumed ?? 0) < count) {
299
+ applyUsageDelta(state, session.events.slice(state.consumed ?? 0));
300
+ state.consumed = count;
301
+ }
302
+ state.kind = "live";
303
+ cache.sessions[session.id] = state;
304
+ }
305
+ }
306
+ const persistence = ctx.get("sessionPersistence");
307
+ const persistedIds = new Set();
308
+ if (persistence !== void 0) {
309
+ // Prefer the backend's opaque per-log revisions (no file I/O in the
310
+ // plugin, works for any backend that exposes listSnapshots).
311
+ let snapshots = null;
312
+ if (typeof persistence.listSnapshots === "function") {
313
+ try {
314
+ snapshots = await persistence.listSnapshots();
315
+ } catch (error) {
316
+ ctx.logger.warn(`usage-stats: listSnapshots failed, falling back to list(): ${String(error)}`);
317
+ }
318
+ }
319
+ const metas = snapshots !== null ? snapshots.map((entry) => entry.header) : await persistence.list();
320
+ const revisionOf = new Map();
321
+ if (snapshots !== null) for (const entry of snapshots) revisionOf.set(entry.header.id, entry.revision);
322
+ for (const meta of metas) {
323
+ persistedIds.add(meta.id);
324
+ if (attached.has(meta.id)) continue;
325
+ const state = cache.sessions[meta.id] ?? createUsageState();
326
+ const revision = revisionOf.get(meta.id);
327
+ const changed = state.kind !== "persisted" || (revision !== void 0 && revision !== state.revision) || revision === void 0;
328
+ if (changed) {
329
+ try {
330
+ const wasPersisted = state.kind === "persisted";
331
+ const fromSeq = wasPersisted ? state.consumed : 0;
332
+ const { events } = await persistence.readFrom(meta.id, fromSeq);
333
+ if (!wasPersisted) {
334
+ state.days = new Map();
335
+ state.lastSample = null;
336
+ state.currentModel = null;
337
+ state.consumed = 0;
338
+ }
339
+ const fresh = wasPersisted ? events.filter((event) => event.seq > (state.consumed ?? 0)) : events;
340
+ const contiguous = fresh.length === 0 ? state.consumed === 0 : fresh[0].seq === state.consumed + 1;
341
+ if (!contiguous && state.consumed > 0) {
342
+ // Log truncated or rewritten: refold the whole log.
343
+ state.days = new Map();
344
+ state.lastSample = null;
345
+ state.currentModel = null;
346
+ state.consumed = 0;
347
+ const { events: allEvents } = await persistence.readFrom(meta.id, 0);
348
+ applyUsageDelta(state, allEvents);
349
+ state.consumed = allEvents.length > 0 ? allEvents[allEvents.length - 1].seq : 0;
350
+ } else if (fresh.length > 0) {
351
+ applyUsageDelta(state, fresh);
352
+ state.consumed = fresh[fresh.length - 1].seq;
353
+ }
354
+ state.kind = "persisted";
355
+ if (revision !== void 0) state.revision = revision;
356
+ } catch (error) {
357
+ ctx.logger.warn(`usage-stats: reading persisted session "${meta.id}" failed: ${String(error)}`);
358
+ }
359
+ }
360
+ cache.sessions[meta.id] = state;
361
+ }
362
+ }
363
+ for (const id of Object.keys(cache.sessions)) {
364
+ if (!attached.has(id) && !persistedIds.has(id)) delete cache.sessions[id];
365
+ }
366
+ const byDay = new Map();
367
+ for (const state of Object.values(cache.sessions)) mergeInto(byDay, state.days);
368
+ // Keep the atomic cache write inside the single-flight section. Otherwise
369
+ // overlapping saves can race on the same temporary file.
370
+ await saveCache(ctx, cache);
371
+ return renderUsage(byDay, Date.now());
372
+ });
373
+ }
374
+
375
+ async function handleUsage(ctx, req, res) {
376
+ if (rejectForeignCaller(req, res)) return;
377
+ try {
378
+ const result = await collectUsage(ctx);
379
+ json(res, 200, { ok: true, ...result });
380
+ } catch (error) {
381
+ ctx.logger.warn(`usage-stats: usage aggregation failed: ${String(error)}`);
382
+ json(res, 500, { ok: false, error: "internal", message: error instanceof Error ? error.message : String(error) });
383
+ }
384
+ }
385
+
386
+ /**
387
+ * Enumerate the harness's configured providers: the official DeepSeek route
388
+ * (`llm-deepseek` settings namespace) plus every pi-ai provider profile
389
+ * (`llm-pi-ai` settings namespace). Each entry carries the connection facts
390
+ * (credential ref + base URL) needed to query a balance — no keys here.
391
+ */
392
+ async function configuredProviders(ctx) {
393
+ const settings = ctx.get("settings");
394
+ const providers = [];
395
+ const deepseek = settings?.get?.("llm-deepseek");
396
+ if (deepseek !== void 0 && deepseek !== null && typeof deepseek === "object") {
397
+ providers.push({
398
+ id: "deepseek-official",
399
+ displayName: "DeepSeek",
400
+ apiKeyEnv: typeof deepseek.apiKeyEnv === "string" ? deepseek.apiKeyEnv : DEEPSEEK_DEFAULTS.apiKeyEnv,
401
+ baseURL: typeof deepseek.baseURL === "string" ? deepseek.baseURL : DEEPSEEK_DEFAULTS.baseURL
402
+ });
403
+ } else {
404
+ providers.push({
405
+ id: "deepseek-official",
406
+ displayName: "DeepSeek",
407
+ apiKeyEnv: DEEPSEEK_DEFAULTS.apiKeyEnv,
408
+ baseURL: DEEPSEEK_DEFAULTS.baseURL
409
+ });
410
+ }
411
+ const pi = settings?.get?.("llm-pi-ai");
412
+ if (pi !== void 0 && pi !== null && typeof pi === "object" && pi.providers !== void 0 && typeof pi.providers === "object") {
413
+ for (const [route, profile] of Object.entries(pi.providers)) {
414
+ if (profile === null || typeof profile !== "object") continue;
415
+ providers.push({
416
+ id: route,
417
+ displayName: typeof profile.displayName === "string" && profile.displayName.length > 0 ? profile.displayName : route,
418
+ apiKeyEnv: typeof profile.apiKeyEnv === "string" ? profile.apiKeyEnv : void 0,
419
+ baseURL: typeof profile.baseURL === "string" ? profile.baseURL : void 0
420
+ });
421
+ }
422
+ }
423
+ return providers;
424
+ }
425
+
426
+ async function handleProviders(ctx, accounts, req, res) {
427
+ if (rejectForeignCaller(req, res)) return;
428
+ try {
429
+ json(res, 200, { ok: true, providers: await accounts.providerViews() });
430
+ } catch (error) {
431
+ ctx.logger.warn(`usage-stats: providers enumeration failed: ${String(error)}`);
432
+ json(res, 500, { ok: false, error: "internal", message: error instanceof Error ? error.message : String(error) });
433
+ }
434
+ }
435
+
436
+ async function selectedProviderId(req, accounts) {
437
+ const url = new URL(req.url ?? "/", "http://x");
438
+ const requested = url.searchParams.get("provider");
439
+ if (requested !== null && requested !== "") return requested;
440
+ const providers = await accounts.providerViews();
441
+ return providers.find((entry) => entry.id === "deepseek-official")?.id
442
+ ?? providers.find((entry) => entry.configured)?.id
443
+ ?? providers[0]?.id
444
+ ?? null;
445
+ }
446
+
447
+ /** Unified account endpoint; cached by default, `refresh=1` forces upstream. */
448
+ async function handleAccount(ctx, accounts, req, res) {
449
+ if (rejectForeignCaller(req, res)) return;
450
+ try {
451
+ const url = new URL(req.url ?? "/", "http://x");
452
+ const providerId = await selectedProviderId(req, accounts);
453
+ const account = providerId === null ? null : await accounts.get(providerId, { force: url.searchParams.get("refresh") === "1" });
454
+ if (account === null) {
455
+ json(res, 200, { ok: false, error: "unknown-provider", message: `provider "${providerId}" is not configured` });
456
+ return;
457
+ }
458
+ json(res, 200, { ok: true, account });
459
+ } catch (error) {
460
+ ctx.logger.warn(`usage-stats: account fetch failed: ${String(error)}`);
461
+ json(res, 500, { ok: false, error: "internal", message: error instanceof Error ? error.message : String(error) });
462
+ }
463
+ }
464
+
465
+ /** Backward-compatible balance route delegated to the account registry. */
466
+ async function handleBalance(ctx, accounts, req, res) {
467
+ if (rejectForeignCaller(req, res)) return;
468
+ try {
469
+ const providerId = await selectedProviderId(req, accounts);
470
+ const account = providerId === null ? null : await accounts.get(providerId);
471
+ if (account === null) {
472
+ json(res, 200, { ok: false, error: "unknown-provider", message: `provider "${providerId}" is not configured` });
473
+ return;
474
+ }
475
+ if (account.mode !== "balance" || account.status === "unsupported") {
476
+ json(res, 200, {
477
+ ok: false,
478
+ error: "unsupported",
479
+ message: `${account.displayName} has no public balance interface`,
480
+ provider: account.id
481
+ });
482
+ return;
483
+ }
484
+ if (account.status === "not-configured") {
485
+ json(res, 200, {
486
+ ok: false,
487
+ error: "no-credential",
488
+ message: account.missingCredentials?.[0] ?? "api key",
489
+ provider: account.id
490
+ });
491
+ return;
492
+ }
493
+ if (account.balance === null || account.balance === void 0) {
494
+ json(res, 502, { ok: false, error: "failed", message: account.status });
495
+ return;
496
+ }
497
+ json(res, 200, {
498
+ ok: true,
499
+ provider: account.id,
500
+ balance: {
501
+ isAvailable: account.status === "ok" || account.stale === true,
502
+ currency: account.balance.currency,
503
+ total: account.balance.remaining,
504
+ granted: account.balance.breakdown?.granted,
505
+ toppedUp: account.balance.breakdown?.toppedUp
506
+ },
507
+ fetchedAt: account.fetchedAt
508
+ });
509
+ } catch (error) {
510
+ ctx.logger.warn(`usage-stats: balance fetch failed: ${String(error)}`);
511
+ json(res, 502, { ok: false, error: "failed", message: error instanceof Error ? error.message : String(error) });
512
+ }
513
+ }
514
+
515
+ /** Query normalized percentage windows for subscription-style providers. */
516
+ async function handleSubscriptions(ctx, accounts, req, res) {
517
+ if (rejectForeignCaller(req, res)) return;
518
+ try {
519
+ const subscriptions = (await accounts.subscriptionAccounts()).filter(Boolean).map((account) => (
520
+ account.adapter === "zai-token-plan" ? { ...account, id: "zai" } : account
521
+ ));
522
+ json(res, 200, { ok: true, subscriptions, fetchedAt: Date.now() });
523
+ } catch (error) {
524
+ ctx.logger.warn(`usage-stats: subscription usage failed: ${String(error)}`);
525
+ json(res, 500, { ok: false, error: "internal", message: error instanceof Error ? error.message : String(error) });
526
+ }
527
+ }
528
+
529
+ /** Start an immediate refresh and repeat account + local usage refresh every 5 minutes. */
530
+ export function startBackgroundRefresh(ctx, accounts, deps = {}) {
531
+ let running = false;
532
+ let stopped = false;
533
+ let active = Promise.resolve();
534
+ const run = async () => {
535
+ if (running || stopped) return;
536
+ running = true;
537
+ active = (async () => {
538
+ const results = await Promise.allSettled([accounts.refreshAll(), collectUsage(ctx)]);
539
+ for (const result of results) if (result.status === "rejected") ctx.logger.warn(`usage-stats: background refresh failed: ${String(result.reason)}`);
540
+ })().finally(() => {
541
+ running = false;
542
+ });
543
+ return active;
544
+ };
545
+ void run();
546
+ const setTimer = deps.setInterval ?? setInterval;
547
+ const clearTimer = deps.clearInterval ?? clearInterval;
548
+ const timer = setTimer(run, deps.intervalMs ?? ACCOUNT_REFRESH_MS);
549
+ timer?.unref?.();
550
+ const stop = async () => {
551
+ stopped = true;
552
+ clearTimer(timer);
553
+ await active;
554
+ };
555
+ stop.refreshNow = async () => {
556
+ await active;
557
+ return run();
558
+ };
559
+ return stop;
560
+ }
561
+
562
+ /**
563
+ * Plugin body: register the five exact routes and start background refresh.
564
+ * @param ctx - plugin context carrying webServer, credentials, sessions, sessionPersistence, settings, and llm.
565
+ */
566
+ const Config = {
567
+ "~standard": {
568
+ version: 1,
569
+ vendor: "dsh-usage-stats",
570
+ validate(value) {
571
+ try {
572
+ return { value: validateAccountConfig(value ?? {}) };
573
+ } catch (error) {
574
+ return { issues: [{ message: error instanceof Error ? error.message : String(error) }] };
575
+ }
576
+ }
577
+ }
578
+ };
579
+
580
+ async function apply(ctx, rawConfig = {}, deps = {}) {
581
+ const config = validateAccountConfig(rawConfig);
582
+ const accounts = deps.accounts ?? createAccountService({
583
+ credentials: ctx.get("credentials") ?? ctx.credentials,
584
+ getProviders: () => configuredProviders(ctx),
585
+ config,
586
+ deps: { timeoutMs: UPSTREAM_TIMEOUT_MS }
587
+ });
588
+ // Provider ids come from the async Harness settings service, so this dynamic
589
+ // part of config validation must finish before any routes or timers start.
590
+ await accounts.validate();
591
+ ctx.effect(() => ctx.webServer.register({
592
+ kind: "exact",
593
+ path: USAGE_PATH,
594
+ handler: (req, res) => handleUsage(ctx, req, res)
595
+ }), "usage-stats: usage route");
596
+ ctx.effect(() => ctx.webServer.register({
597
+ kind: "exact",
598
+ path: PROVIDERS_PATH,
599
+ handler: (req, res) => handleProviders(ctx, accounts, req, res)
600
+ }), "usage-stats: providers route");
601
+ ctx.effect(() => ctx.webServer.register({
602
+ kind: "exact",
603
+ path: ACCOUNT_PATH,
604
+ handler: (req, res) => handleAccount(ctx, accounts, req, res)
605
+ }), "usage-stats: account route");
606
+ ctx.effect(() => ctx.webServer.register({
607
+ kind: "exact",
608
+ path: BALANCE_PATH,
609
+ handler: (req, res) => handleBalance(ctx, accounts, req, res)
610
+ }), "usage-stats: balance route");
611
+ ctx.effect(() => ctx.webServer.register({
612
+ kind: "exact",
613
+ path: SUBSCRIPTIONS_PATH,
614
+ handler: (req, res) => handleSubscriptions(ctx, accounts, req, res)
615
+ }), "usage-stats: subscriptions route");
616
+ if (deps.disableBackgroundRefresh !== true) ctx.effect(() => startBackgroundRefresh(ctx, accounts), "usage-stats: background account refresh");
617
+ }
618
+
619
+ export { apply, Config, inject, name, USAGE_PATH, PROVIDERS_PATH, BALANCE_PATH, SUBSCRIPTIONS_PATH, ACCOUNT_PATH, configuredProviders, totalTokens, zeroBuckets };