@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/LICENSE +21 -0
- package/README.md +362 -0
- package/SECURITY.md +17 -0
- package/cordis.patch.yml +5 -0
- package/docs/images/usage-panel.svg +176 -0
- package/lib/accounts.js +1272 -0
- package/lib/balance.js +126 -0
- package/lib/client.js +1531 -0
- package/lib/index.js +619 -0
- package/lib/subscriptions.js +610 -0
- package/lib/usage.js +276 -0
- package/package.json +72 -0
- package/scripts/install.mjs +142 -0
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 };
|