@wannanbigpig/dsh-usage-stats 0.1.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/LICENSE +21 -0
- package/README.md +211 -0
- package/cordis.patch.yml +5 -0
- package/lib/balance.js +87 -0
- package/lib/client.js +2428 -0
- package/lib/index.js +1390 -0
- package/lib/ledger.js +253 -0
- package/lib/pricing.js +85 -0
- package/lib/tokenizer.js +55 -0
- package/lib/usage.js +481 -0
- package/package.json +73 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,1390 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-usage-stats — server half.
|
|
3
|
+
*
|
|
4
|
+
* Registers three read-only, loopback-only endpoints on the web server:
|
|
5
|
+
* GET /api/usage-stats/usage — per-day/per-hour/per-model token usage + estimated cost
|
|
6
|
+
* GET /api/usage-stats/keys — configured DeepSeek API-key credential references
|
|
7
|
+
* GET /api/usage-stats/balance — DeepSeek official balance for one key (?key=<ref>&refresh=1)
|
|
8
|
+
*
|
|
9
|
+
* Credentials are resolved through the harness `credentials` seam at request
|
|
10
|
+
* time — this plugin never stores or logs API keys. The official DeepSeek
|
|
11
|
+
* route is queried at the account's configured base URL (default
|
|
12
|
+
* https://api.deepseek.com) using each configured credential reference.
|
|
13
|
+
*
|
|
14
|
+
* Usage aggregation is INCREMENTAL: per-session fold state (day/hour/model
|
|
15
|
+
* buckets plus the last usage sample) is cached in memory and persisted to
|
|
16
|
+
* `<DSH_HOME>/storages/usage-stats-cache.json`. On each request only the
|
|
17
|
+
* events added since the last fold are processed — live sessions fold their
|
|
18
|
+
* in-memory tail, while persisted sessions use the storage backend's opaque
|
|
19
|
+
* revision when available. Steady-state cost stays O(new events) no matter
|
|
20
|
+
* how large the logs grow.
|
|
21
|
+
*
|
|
22
|
+
* The endpoints live under the `/api` prefix as exact routes, so they win
|
|
23
|
+
* over the connection plugin's `/api` prefix handler; each handler applies
|
|
24
|
+
* its own peer-socket loopback fence (the exact routes bypass the RPC trust
|
|
25
|
+
* fence); Host is checked only as an additional defense.
|
|
26
|
+
*
|
|
27
|
+
* @module dsh-usage-stats
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { homedir } from "node:os";
|
|
31
|
+
import { randomUUID } from "node:crypto";
|
|
32
|
+
import { join, dirname } from "node:path";
|
|
33
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
34
|
+
import { createUsageState, dayKey, defaultPricing, mergeInto, providerOf, renderUsage, roundCost, zeroBuckets } from "./usage.js";
|
|
35
|
+
import { appendLedger, compactLedger, foldLedger, freezeLedgerEntry, renderLedger } from "./ledger.js";
|
|
36
|
+
import { queryDeepSeekBalance, responseStatus } from "./balance.js";
|
|
37
|
+
import { normalizePricing } from "./pricing.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"];
|
|
44
|
+
|
|
45
|
+
const USAGE_PATH = "/api/usage-stats/usage";
|
|
46
|
+
const KEYS_PATH = "/api/usage-stats/keys";
|
|
47
|
+
const BALANCE_PATH = "/api/usage-stats/balance";
|
|
48
|
+
const LIMITS_PATH = "/api/usage-stats/limits";
|
|
49
|
+
|
|
50
|
+
const DEFAULT_BASE_URL = "https://api.deepseek.com";
|
|
51
|
+
const DEFAULT_KEY_REF = "DEEPSEEK_API_KEY";
|
|
52
|
+
const UPSTREAM_TIMEOUT_MS = 15000;
|
|
53
|
+
const DEFAULT_REFRESH_MS = 300000;
|
|
54
|
+
const CACHE_VERSION = 2;
|
|
55
|
+
const LIMITS_VERSION = 2;
|
|
56
|
+
|
|
57
|
+
/** Write a JSON response. */
|
|
58
|
+
function json(res, status, value) {
|
|
59
|
+
const body = JSON.stringify(value);
|
|
60
|
+
res.writeHead(status, {
|
|
61
|
+
"content-type": "application/json; charset=utf-8",
|
|
62
|
+
"cache-control": "no-cache"
|
|
63
|
+
});
|
|
64
|
+
res.end(body);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Loopback fence, primary on the PEER SOCKET address (not the
|
|
69
|
+
* client-controllable Host header): the request must come from a loopback
|
|
70
|
+
* interface. IPv4-mapped IPv6 (`::ffff:127.0.0.1`) is normalized. The Host
|
|
71
|
+
* header is kept as an additional check, never as the deciding one.
|
|
72
|
+
*/
|
|
73
|
+
function isLoopbackAddress(address) {
|
|
74
|
+
if (typeof address !== "string") return false;
|
|
75
|
+
const a = address.toLowerCase();
|
|
76
|
+
if (a === "::1") return true;
|
|
77
|
+
const ipv4 = a.startsWith("::ffff:") ? a.slice(7) : a;
|
|
78
|
+
const octets = ipv4.split(".");
|
|
79
|
+
return octets.length === 4 && octets[0] === "127" && octets.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Parse a Host header without breaking bracketed or bare IPv6 literals. */
|
|
83
|
+
function hostNameOf(value) {
|
|
84
|
+
if (typeof value !== "string") return null;
|
|
85
|
+
const host = value.trim().toLowerCase();
|
|
86
|
+
if (host.startsWith("[")) {
|
|
87
|
+
const close = host.indexOf("]");
|
|
88
|
+
if (close <= 1) return null;
|
|
89
|
+
const suffix = host.slice(close + 1);
|
|
90
|
+
if (suffix !== "" && !/^:\d+$/.test(suffix)) return null;
|
|
91
|
+
return host.slice(1, close);
|
|
92
|
+
}
|
|
93
|
+
const firstColon = host.indexOf(":");
|
|
94
|
+
const lastColon = host.lastIndexOf(":");
|
|
95
|
+
if (firstColon !== lastColon) return host;
|
|
96
|
+
if (lastColon === -1) return host.replace(/\.$/, "");
|
|
97
|
+
if (!/^\d+$/.test(host.slice(lastColon + 1))) return null;
|
|
98
|
+
return host.slice(0, lastColon).replace(/\.$/, "");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function isLoopbackHostHeader(req) {
|
|
102
|
+
const hostName = hostNameOf(req.headers.host);
|
|
103
|
+
return hostName === "localhost" || isLoopbackAddress(hostName);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Refuse non-loopback callers and unauthorized HTTP methods before any work. */
|
|
107
|
+
function rejectForeignCaller(req, res, allowedMethods = ["GET"]) {
|
|
108
|
+
if (!allowedMethods.includes(req.method)) {
|
|
109
|
+
res.writeHead(405, { "content-type": "application/json; charset=utf-8" });
|
|
110
|
+
res.end(JSON.stringify({ ok: false, error: "method-not-allowed" }));
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
const peer = req.socket?.remoteAddress;
|
|
114
|
+
if (isLoopbackAddress(peer) && isLoopbackHostHeader(req)) return false;
|
|
115
|
+
json(res, 403, { ok: false, error: "forbidden" });
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Read JSON request body with a safety size cap. */
|
|
120
|
+
async function readJsonBody(req, limit = 65536) {
|
|
121
|
+
if (req.body !== void 0 && req.body !== null) {
|
|
122
|
+
if (typeof req.body === "object") return req.body;
|
|
123
|
+
if (typeof req.body === "string") {
|
|
124
|
+
try {
|
|
125
|
+
return JSON.parse(req.body);
|
|
126
|
+
} catch {
|
|
127
|
+
throw new Error("invalid JSON body");
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (req.readableEnded === true) {
|
|
132
|
+
return {};
|
|
133
|
+
}
|
|
134
|
+
return new Promise((resolve, reject) => {
|
|
135
|
+
let body = "";
|
|
136
|
+
req.setEncoding("utf8");
|
|
137
|
+
req.on("data", (chunk) => {
|
|
138
|
+
body += chunk;
|
|
139
|
+
if (body.length > limit) reject(new Error("payload too large"));
|
|
140
|
+
});
|
|
141
|
+
req.on("end", () => {
|
|
142
|
+
try {
|
|
143
|
+
const parsed = JSON.parse(body || "{}");
|
|
144
|
+
resolve(parsed);
|
|
145
|
+
} catch {
|
|
146
|
+
reject(new Error("invalid JSON body"));
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
req.on("error", reject);
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
//#region config
|
|
154
|
+
function nonEmptyString(value) {
|
|
155
|
+
return typeof value === "string" && value.trim() !== "" ? value.trim() : null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function finiteNumber(value, label) {
|
|
159
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
160
|
+
throw new Error(`${label} must be a finite number`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function numberOrNull(value) {
|
|
164
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
165
|
+
if (typeof value === "string" && value.trim() !== "") {
|
|
166
|
+
const parsed = Number(value);
|
|
167
|
+
if (Number.isFinite(parsed)) return parsed;
|
|
168
|
+
}
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Validate and normalize the plugin configuration. Credential references are
|
|
174
|
+
* names only — values always come from the harness credentials seam.
|
|
175
|
+
*/
|
|
176
|
+
export function validateConfig(raw = {}) {
|
|
177
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) throw new Error("config must be an object");
|
|
178
|
+
const baseURL = nonEmptyString(raw.baseURL) ?? DEFAULT_BASE_URL;
|
|
179
|
+
let url;
|
|
180
|
+
try {
|
|
181
|
+
url = new URL(baseURL);
|
|
182
|
+
} catch {
|
|
183
|
+
throw new Error("config.baseURL must be a valid URL");
|
|
184
|
+
}
|
|
185
|
+
if (url.protocol !== "https:" && raw.allowInsecure !== true) throw new Error("config.baseURL must use HTTPS unless allowInsecure is true");
|
|
186
|
+
const rawKeys = raw.keys === void 0 ? [] : raw.keys;
|
|
187
|
+
if (!Array.isArray(rawKeys)) throw new Error("config.keys must be an array of credential references");
|
|
188
|
+
const keys = [];
|
|
189
|
+
for (const key of rawKeys) {
|
|
190
|
+
const ref = nonEmptyString(key);
|
|
191
|
+
if (ref === null) throw new Error("config.keys entries must be non-empty strings");
|
|
192
|
+
if (!keys.includes(ref)) keys.push(ref);
|
|
193
|
+
}
|
|
194
|
+
const defaultKeyRef = nonEmptyString(raw.defaultKeyRef) ?? DEFAULT_KEY_REF;
|
|
195
|
+
if (!keys.includes(defaultKeyRef)) keys.unshift(defaultKeyRef);
|
|
196
|
+
const refreshMs = raw.refreshMs === void 0 ? DEFAULT_REFRESH_MS : finiteNumber(raw.refreshMs, "config.refreshMs");
|
|
197
|
+
if (refreshMs < 5000) throw new Error("config.refreshMs must be at least 5000");
|
|
198
|
+
// Pricing overrides are merged over the DeepSeek defaults; only the
|
|
199
|
+
// documented shape is accepted.
|
|
200
|
+
let pricing = null;
|
|
201
|
+
if (raw.pricing !== void 0) {
|
|
202
|
+
if (raw.pricing === null || typeof raw.pricing !== "object" || Array.isArray(raw.pricing)) throw new Error("config.pricing must be an object");
|
|
203
|
+
const merged = defaultPricing();
|
|
204
|
+
if (raw.pricing.pricing !== void 0) {
|
|
205
|
+
if (raw.pricing.pricing === null || typeof raw.pricing.pricing !== "object" || Array.isArray(raw.pricing.pricing)) throw new Error("config.pricing.pricing must be an object keyed by model id");
|
|
206
|
+
for (const [model, row] of Object.entries(raw.pricing.pricing)) {
|
|
207
|
+
if (row === null || typeof row !== "object" || Array.isArray(row)) throw new Error(`config.pricing.pricing.${model} must be an object`);
|
|
208
|
+
merged.pricing[model] = {
|
|
209
|
+
inputMiss: numberOrNull(row.inputMiss) ?? 0,
|
|
210
|
+
inputHit: numberOrNull(row.inputHit) ?? 0,
|
|
211
|
+
output: numberOrNull(row.output) ?? 0,
|
|
212
|
+
...(row.peak && typeof row.peak === "object" ? { peak: {
|
|
213
|
+
inputMiss: numberOrNull(row.peak.inputMiss) ?? 0,
|
|
214
|
+
inputHit: numberOrNull(row.peak.inputHit) ?? 0,
|
|
215
|
+
output: numberOrNull(row.peak.output) ?? 0
|
|
216
|
+
} } : {})
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
if (raw.pricing.peakMultiplier !== void 0) merged.peakMultiplier = finiteNumber(raw.pricing.peakMultiplier, "config.pricing.peakMultiplier");
|
|
221
|
+
if (raw.pricing.peakHours !== void 0) {
|
|
222
|
+
if (!Array.isArray(raw.pricing.peakHours)) throw new Error("config.pricing.peakHours must be an array of [start, end) Beijing-time hour pairs");
|
|
223
|
+
merged.peakHours = raw.pricing.peakHours.map((pair) => {
|
|
224
|
+
if (!Array.isArray(pair) || pair.length !== 2) throw new Error("config.pricing.peakHours entries must be [start, end) pairs");
|
|
225
|
+
const start = finiteNumber(pair[0], "config.pricing.peakHours start");
|
|
226
|
+
const end = finiteNumber(pair[1], "config.pricing.peakHours end");
|
|
227
|
+
if (start < 0 || start > 23 || end < 0 || end > 24 || end <= start) throw new Error("config.pricing.peakHours must satisfy 0 <= start < end <= 24");
|
|
228
|
+
return [start, end];
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
if (raw.pricing.currency !== void 0) {
|
|
232
|
+
const currency = nonEmptyString(raw.pricing.currency);
|
|
233
|
+
if (currency === null) throw new Error("config.pricing.currency must be a non-empty string");
|
|
234
|
+
merged.currency = currency;
|
|
235
|
+
}
|
|
236
|
+
pricing = normalizePricing(merged);
|
|
237
|
+
}
|
|
238
|
+
// Per-key provider mapping: keyRef → provider ids. Lets the plugin
|
|
239
|
+
// attribute today's cost (and the quota check) to the exact API key the
|
|
240
|
+
// provider route uses. Providers not listed fall back to defaultKeyRef.
|
|
241
|
+
const keyProviders = {};
|
|
242
|
+
if (raw.keyProviders !== void 0) {
|
|
243
|
+
if (raw.keyProviders === null || typeof raw.keyProviders !== "object" || Array.isArray(raw.keyProviders)) throw new Error("config.keyProviders must be an object keyed by credential reference");
|
|
244
|
+
for (const [ref, providers] of Object.entries(raw.keyProviders)) {
|
|
245
|
+
const keyRef = nonEmptyString(ref);
|
|
246
|
+
if (keyRef === null) throw new Error("config.keyProviders keys must be non-empty strings");
|
|
247
|
+
if (!Array.isArray(providers)) throw new Error(`config.keyProviders.${keyRef} must be an array of provider ids`);
|
|
248
|
+
const list = [];
|
|
249
|
+
for (const provider of providers) {
|
|
250
|
+
const id = nonEmptyString(provider);
|
|
251
|
+
if (id === null) throw new Error(`config.keyProviders.${keyRef} entries must be non-empty strings`);
|
|
252
|
+
if (!list.includes(id)) list.push(id);
|
|
253
|
+
}
|
|
254
|
+
if (list.length > 0) keyProviders[keyRef] = list;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return { keys, defaultKeyRef, baseURL, refreshMs, pricing: pricing ?? defaultPricing(), keyProviders, allowInsecure: raw.allowInsecure === true };
|
|
258
|
+
}
|
|
259
|
+
//#endregion
|
|
260
|
+
|
|
261
|
+
//#region incremental cache
|
|
262
|
+
/** Cache file location under the dsh home. */
|
|
263
|
+
function cachePath() {
|
|
264
|
+
const home = process.env.DSH_HOME ?? join(homedir(), ".dsh");
|
|
265
|
+
return join(home, "storages", "usage-stats-cache.json");
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
let loadedCache = null;
|
|
269
|
+
let loadPromise = null;
|
|
270
|
+
let inflight = null;
|
|
271
|
+
|
|
272
|
+
/** Parse one serialized day entry back into fold-state shape (lenient). */
|
|
273
|
+
function parseDayEntry(entry) {
|
|
274
|
+
const target = { totals: zeroBuckets(), models: new Map(), hours: new Map() };
|
|
275
|
+
const totals = entry.totals;
|
|
276
|
+
if (totals !== null && typeof totals === "object") {
|
|
277
|
+
target.totals.inputTokens = Number.isFinite(totals.inputTokens) ? totals.inputTokens : 0;
|
|
278
|
+
target.totals.outputTokens = Number.isFinite(totals.outputTokens) ? totals.outputTokens : 0;
|
|
279
|
+
target.totals.cacheReadTokens = Number.isFinite(totals.cacheReadTokens) ? totals.cacheReadTokens : 0;
|
|
280
|
+
target.totals.cacheWriteTokens = Number.isFinite(totals.cacheWriteTokens) ? totals.cacheWriteTokens : 0;
|
|
281
|
+
}
|
|
282
|
+
if (entry.models !== null && typeof entry.models === "object") {
|
|
283
|
+
for (const [model, buckets] of Object.entries(entry.models)) {
|
|
284
|
+
if (buckets === null || typeof buckets !== "object") continue;
|
|
285
|
+
target.models.set(model, {
|
|
286
|
+
inputTokens: Number.isFinite(buckets.inputTokens) ? buckets.inputTokens : 0,
|
|
287
|
+
outputTokens: Number.isFinite(buckets.outputTokens) ? buckets.outputTokens : 0,
|
|
288
|
+
cacheReadTokens: Number.isFinite(buckets.cacheReadTokens) ? buckets.cacheReadTokens : 0,
|
|
289
|
+
cacheWriteTokens: Number.isFinite(buckets.cacheWriteTokens) ? buckets.cacheWriteTokens : 0
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
if (entry.hours !== null && typeof entry.hours === "object") {
|
|
294
|
+
for (const [hour, byModel] of Object.entries(entry.hours)) {
|
|
295
|
+
const hourIndex = Number(hour);
|
|
296
|
+
if (!Number.isInteger(hourIndex) || hourIndex < 0 || hourIndex > 23) continue;
|
|
297
|
+
if (byModel === null || typeof byModel !== "object") continue;
|
|
298
|
+
const hourModels = new Map();
|
|
299
|
+
for (const [model, buckets] of Object.entries(byModel)) {
|
|
300
|
+
if (buckets === null || typeof buckets !== "object") continue;
|
|
301
|
+
hourModels.set(model, {
|
|
302
|
+
inputTokens: Number.isFinite(buckets.inputTokens) ? buckets.inputTokens : 0,
|
|
303
|
+
outputTokens: Number.isFinite(buckets.outputTokens) ? buckets.outputTokens : 0,
|
|
304
|
+
cacheReadTokens: Number.isFinite(buckets.cacheReadTokens) ? buckets.cacheReadTokens : 0,
|
|
305
|
+
cacheWriteTokens: Number.isFinite(buckets.cacheWriteTokens) ? buckets.cacheWriteTokens : 0
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
if (hourModels.size > 0) target.hours.set(hourIndex, hourModels);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return target;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Parse a serialized global day map (legacy snapshot) back into fold state. */
|
|
315
|
+
function parseDayMap(rawDays) {
|
|
316
|
+
const byDay = new Map();
|
|
317
|
+
if (rawDays === null || typeof rawDays !== "object") return byDay;
|
|
318
|
+
for (const [date, entry] of Object.entries(rawDays)) {
|
|
319
|
+
if (entry === null || typeof entry !== "object") continue;
|
|
320
|
+
byDay.set(date, parseDayEntry(entry));
|
|
321
|
+
}
|
|
322
|
+
return byDay;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Serialize a global day map (date → totals/models/hours). */
|
|
326
|
+
function serializeDays(byDay) {
|
|
327
|
+
const days = {};
|
|
328
|
+
for (const [date, entry] of byDay) {
|
|
329
|
+
const models = {};
|
|
330
|
+
for (const [model, buckets] of entry.models) models[model] = { ...buckets };
|
|
331
|
+
const hours = {};
|
|
332
|
+
for (const [hour, hourModels] of entry.hours) {
|
|
333
|
+
const byModel = {};
|
|
334
|
+
for (const [model, buckets] of hourModels) byModel[model] = { ...buckets };
|
|
335
|
+
hours[hour] = byModel;
|
|
336
|
+
}
|
|
337
|
+
days[date] = { totals: { ...entry.totals }, models, hours };
|
|
338
|
+
}
|
|
339
|
+
return days;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Provider routes that are WIRE-ONLY FACADES over an upstream route: their
|
|
344
|
+
* adapter delegates to llm.stream({ provider: upstream }), and the upstream
|
|
345
|
+
* call issues the real API request (and is recorded on its own). Recording
|
|
346
|
+
* both would double-count one provider bill. Skipping the facade keeps the
|
|
347
|
+
* upstream entry — matching what the provider bills.
|
|
348
|
+
* (vision-toolkit registers facade routes as 'vision-toolkit-<upstream>'.)
|
|
349
|
+
*/
|
|
350
|
+
const FACADE_PROVIDER_PREFIXES = ["vision-toolkit-"];
|
|
351
|
+
function isFacadeProvider(provider) {
|
|
352
|
+
return typeof provider === "string" && FACADE_PROVIDER_PREFIXES.some((prefix) => provider.startsWith(prefix));
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Parse a persisted ledger array (lenient, normalized). */
|
|
356
|
+
function parseLedger(raw) {
|
|
357
|
+
const ledger = [];
|
|
358
|
+
if (!Array.isArray(raw)) return ledger;
|
|
359
|
+
for (const entry of raw) {
|
|
360
|
+
if (entry === null || typeof entry !== "object") continue;
|
|
361
|
+
appendLedger(ledger, {
|
|
362
|
+
id: entry.id,
|
|
363
|
+
occurredAt: entry.occurredAt,
|
|
364
|
+
provider: entry.provider,
|
|
365
|
+
model: entry.model,
|
|
366
|
+
usage: entry.usage,
|
|
367
|
+
...(Object.hasOwn(entry, "costCny") ? { costCny: entry.costCny } : {}),
|
|
368
|
+
pricingVersion: entry.pricingVersion
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
return ledger;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Migrate a version-1 cache (session fold states) into the version-2 shape:
|
|
376
|
+
* the v1 folds are frozen as a LEGACY snapshot (event-time attribution, no
|
|
377
|
+
* request start times available) and the new call-level ledger starts empty.
|
|
378
|
+
* Statistics switch to the ledger (request-start attribution) going forward.
|
|
379
|
+
*/
|
|
380
|
+
function migrateCacheV1(v1) {
|
|
381
|
+
const byDay = new Map();
|
|
382
|
+
const sessions = v1?.sessions ?? {};
|
|
383
|
+
if (sessions !== null && typeof sessions === "object") {
|
|
384
|
+
for (const [id, entry] of Object.entries(sessions)) {
|
|
385
|
+
if (typeof id !== "string" || id === "" || entry === null || typeof entry !== "object") continue;
|
|
386
|
+
const state = parseSession(entry);
|
|
387
|
+
mergeInto(byDay, state.days);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
return {
|
|
391
|
+
legacy: { updatedAt: Date.now(), days: serializeDays(byDay) },
|
|
392
|
+
ledger: []
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** Merge the legacy snapshot and the call-level ledger into one render. */
|
|
397
|
+
function renderCombinedUsage(cache, updatedAt, pricing = defaultPricing()) {
|
|
398
|
+
const byDay = new Map();
|
|
399
|
+
const legacyDays = parseDayMap(cache?.legacy?.days ?? null);
|
|
400
|
+
mergeInto(byDay, legacyDays);
|
|
401
|
+
mergeInto(byDay, foldLedger(cache?.ledger ?? []));
|
|
402
|
+
const rendered = renderUsage(byDay, updatedAt, pricing);
|
|
403
|
+
const legacy = renderUsage(legacyDays, updatedAt, pricing);
|
|
404
|
+
const ledger = renderLedger(cache?.ledger ?? [], updatedAt, pricing);
|
|
405
|
+
const mergeCost = (left, right) => {
|
|
406
|
+
if (left === void 0) return right === void 0 ? null : right;
|
|
407
|
+
if (right === void 0) return left;
|
|
408
|
+
if (left === null || right === null) return null;
|
|
409
|
+
return roundCost(Number(left) + Number(right));
|
|
410
|
+
};
|
|
411
|
+
for (const day of rendered.days) {
|
|
412
|
+
const legacyDay = legacy.days.find((entry) => entry.date === day.date);
|
|
413
|
+
const ledgerDay = ledger.days.find((entry) => entry.date === day.date);
|
|
414
|
+
for (const model of day.models) {
|
|
415
|
+
model.cost = mergeCost(legacyDay?.models?.find((entry) => entry.model === model.model)?.cost, ledgerDay?.models?.find((entry) => entry.model === model.model)?.cost);
|
|
416
|
+
}
|
|
417
|
+
for (const hour of day.hours) {
|
|
418
|
+
const legacyHour = legacyDay?.hours?.[hour.hour];
|
|
419
|
+
const ledgerHour = ledgerDay?.hours?.[hour.hour];
|
|
420
|
+
for (const model of hour.models) {
|
|
421
|
+
model.cost = mergeCost(legacyHour?.models?.find((entry) => entry.model === model.model)?.cost, ledgerHour?.models?.find((entry) => entry.model === model.model)?.cost);
|
|
422
|
+
}
|
|
423
|
+
hour.cost = hour.models.some((model) => model.cost === null)
|
|
424
|
+
? null
|
|
425
|
+
: roundCost(hour.models.reduce((sum, model) => sum + (model.cost ?? 0), 0));
|
|
426
|
+
}
|
|
427
|
+
day.cost = day.models.some((model) => model.cost === null)
|
|
428
|
+
? null
|
|
429
|
+
: roundCost(day.models.reduce((sum, model) => sum + (model.cost ?? 0), 0));
|
|
430
|
+
}
|
|
431
|
+
rendered.total.cost = rendered.days.some((day) => day.cost === null)
|
|
432
|
+
? null
|
|
433
|
+
: roundCost(rendered.days.reduce((sum, day) => sum + (day.cost ?? 0), 0));
|
|
434
|
+
rendered.costBasis = {
|
|
435
|
+
legacy: legacy.days.length > 0 ? "legacy-estimated" : null,
|
|
436
|
+
ledger: ledger.days.length > 0 ? "frozen" : null
|
|
437
|
+
};
|
|
438
|
+
return rendered;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** Parse a serialized session entry back into fold state (lenient). */
|
|
442
|
+
function parseSession(raw) {
|
|
443
|
+
const state = createUsageState();
|
|
444
|
+
if (raw === null || typeof raw !== "object") return state;
|
|
445
|
+
state.kind = typeof raw.kind === "string" ? raw.kind : "persisted";
|
|
446
|
+
state.consumed = Number.isSafeInteger(raw.consumed) ? raw.consumed : 0;
|
|
447
|
+
if (typeof raw.revision === "string") state.revision = raw.revision;
|
|
448
|
+
if (raw.days !== null && typeof raw.days === "object") {
|
|
449
|
+
for (const [date, entry] of Object.entries(raw.days)) {
|
|
450
|
+
if (entry === null || typeof entry !== "object") continue;
|
|
451
|
+
state.days.set(date, parseDayEntry(entry));
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
if (raw.lastSample !== null && raw.lastSample !== void 0 && typeof raw.lastSample === "object" && typeof raw.lastSample.key === "string" && typeof raw.lastSample.day === "string") {
|
|
455
|
+
const buckets = raw.lastSample.buckets ?? {};
|
|
456
|
+
state.lastSample = {
|
|
457
|
+
key: raw.lastSample.key,
|
|
458
|
+
day: raw.lastSample.day,
|
|
459
|
+
hour: Number.isInteger(raw.lastSample.hour) && raw.lastSample.hour >= 0 && raw.lastSample.hour <= 23 ? raw.lastSample.hour : 0,
|
|
460
|
+
model: typeof raw.lastSample.model === "string" ? raw.lastSample.model : "unknown",
|
|
461
|
+
buckets: {
|
|
462
|
+
inputTokens: Number.isFinite(buckets.inputTokens) ? buckets.inputTokens : 0,
|
|
463
|
+
outputTokens: Number.isFinite(buckets.outputTokens) ? buckets.outputTokens : 0,
|
|
464
|
+
cacheReadTokens: Number.isFinite(buckets.cacheReadTokens) ? buckets.cacheReadTokens : 0,
|
|
465
|
+
cacheWriteTokens: Number.isFinite(buckets.cacheWriteTokens) ? buckets.cacheWriteTokens : 0
|
|
466
|
+
}
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
if (typeof raw.currentModel === "string") state.currentModel = raw.currentModel;
|
|
470
|
+
return state;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** Load the cache once per process; any corruption degrades to a fresh cache. */
|
|
474
|
+
async function loadCache() {
|
|
475
|
+
if (loadedCache !== null) return loadedCache;
|
|
476
|
+
loadPromise ??= (async () => {
|
|
477
|
+
const fresh = { version: CACHE_VERSION, legacy: null, ledger: [] };
|
|
478
|
+
try {
|
|
479
|
+
const raw = await readFile(cachePath(), "utf8");
|
|
480
|
+
const parsed = JSON.parse(raw);
|
|
481
|
+
if (parsed !== null && typeof parsed === "object") {
|
|
482
|
+
if (parsed.version === CACHE_VERSION) {
|
|
483
|
+
const legacy = parsed.legacy;
|
|
484
|
+
return {
|
|
485
|
+
version: CACHE_VERSION,
|
|
486
|
+
legacy: legacy === null || typeof legacy !== "object" ? null
|
|
487
|
+
: { updatedAt: Number(legacy.updatedAt) || 0, days: legacy.days },
|
|
488
|
+
ledger: parseLedger(parsed.ledger)
|
|
489
|
+
.filter((entry) => !isFacadeProvider(entry.provider))
|
|
490
|
+
.filter((entry) => Number.isFinite(entry.completedAt) && entry.completedAt > 0)
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
if (parsed.version === 1 && parsed.sessions !== null && typeof parsed.sessions === "object") {
|
|
494
|
+
// v1 → v2: freeze the event-time folds as a legacy snapshot
|
|
495
|
+
// (request start times were never captured) and hand the
|
|
496
|
+
// statistics over to the call-level ledger.
|
|
497
|
+
return { version: CACHE_VERSION, ...migrateCacheV1(parsed) };
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
} catch {
|
|
501
|
+
/* first run or corrupt cache */
|
|
502
|
+
}
|
|
503
|
+
return fresh;
|
|
504
|
+
})();
|
|
505
|
+
loadedCache = await loadPromise;
|
|
506
|
+
return loadedCache;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/** Persist the cache atomically (temp + rename); failures are logged, never fatal. */
|
|
510
|
+
async function saveCache(ctx, cache) {
|
|
511
|
+
try {
|
|
512
|
+
const path = cachePath();
|
|
513
|
+
await mkdir(dirname(path), { recursive: true });
|
|
514
|
+
const serialized = {
|
|
515
|
+
version: CACHE_VERSION,
|
|
516
|
+
legacy: cache.legacy,
|
|
517
|
+
ledger: cache.ledger ?? []
|
|
518
|
+
};
|
|
519
|
+
const tmp = `${path}.tmp`;
|
|
520
|
+
await writeFile(tmp, JSON.stringify(serialized), "utf8");
|
|
521
|
+
await rename(tmp, path);
|
|
522
|
+
} catch (error) {
|
|
523
|
+
ctx.logger.warn(`usage-stats: saving usage cache failed: ${String(error)}`);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/** Single-flight guard: concurrent requests share one aggregation run. */
|
|
528
|
+
function withLock(run) {
|
|
529
|
+
if (inflight !== null) return inflight;
|
|
530
|
+
inflight = run().finally(() => {
|
|
531
|
+
inflight = null;
|
|
532
|
+
});
|
|
533
|
+
return inflight;
|
|
534
|
+
}
|
|
535
|
+
//#endregion
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Collect per-day/hour/model usage. Version-2 statistics are driven by the
|
|
539
|
+
* call-level LEDGER (captured in the llm/stream interceptor with each
|
|
540
|
+
* request's START time), merged with the frozen v1 legacy snapshot (event
|
|
541
|
+
* time attribution, kept only for history that predates the ledger). Session
|
|
542
|
+
* event folding is retired: request start times are not recoverable from the
|
|
543
|
+
* event log, so only the ledger can match the provider's billing basis.
|
|
544
|
+
*/
|
|
545
|
+
export async function collectUsage(ctx, pricing = defaultPricing()) {
|
|
546
|
+
return withLock(async () => {
|
|
547
|
+
const cache = await loadCache();
|
|
548
|
+
const rendered = renderCombinedUsage(cache, Date.now(), pricing);
|
|
549
|
+
// Read-only aggregation: the cache file is written ONLY inside
|
|
550
|
+
// recordLedgerEntry's single-flight section (atomic temp+rename), so a
|
|
551
|
+
// 60s usage poll never rewrites the cache. The lock above still shares
|
|
552
|
+
// one aggregation run across concurrent renders.
|
|
553
|
+
return rendered;
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
async function handleUsage(ctx, pricing, req, res) {
|
|
557
|
+
if (rejectForeignCaller(req, res)) return;
|
|
558
|
+
try {
|
|
559
|
+
const result = await collectUsage(ctx, pricing);
|
|
560
|
+
json(res, 200, {
|
|
561
|
+
ok: true,
|
|
562
|
+
...result,
|
|
563
|
+
// Beijing YYYY-MM-DD for "today" (matches the client's day buckets).
|
|
564
|
+
today: dayKey(Date.now()),
|
|
565
|
+
pricing: {
|
|
566
|
+
currency: pricing.currency,
|
|
567
|
+
peakHours: pricing.peakHours,
|
|
568
|
+
peakMultiplier: pricing.peakMultiplier
|
|
569
|
+
}
|
|
570
|
+
});
|
|
571
|
+
} catch (error) {
|
|
572
|
+
ctx.logger.warn(`usage-stats: usage aggregation failed: ${String(error)}`);
|
|
573
|
+
json(res, 500, { ok: false, error: "internal", message: error instanceof Error ? error.message : String(error) });
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/** Resolve one credential reference to its value (empty string when absent). */
|
|
578
|
+
async function resolveCredential(credentials, ref) {
|
|
579
|
+
if (typeof ref !== "string" || ref === "" || credentials === null || credentials === void 0 || typeof credentials.resolve !== "function") return "";
|
|
580
|
+
try {
|
|
581
|
+
const hit = await credentials.resolve(ref);
|
|
582
|
+
return typeof hit?.value === "string" && hit.value.trim() !== "" ? hit.value : "";
|
|
583
|
+
} catch {
|
|
584
|
+
return "";
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/** List configured API-key credential references (names only, never values). */
|
|
589
|
+
export async function configuredKeys(ctx, config) {
|
|
590
|
+
const credentials = ctx.get("credentials") ?? ctx.credentials;
|
|
591
|
+
const entries = [];
|
|
592
|
+
for (const ref of config.keys) {
|
|
593
|
+
const configured = (await resolveCredential(credentials, ref)) !== "";
|
|
594
|
+
entries.push({ id: ref, label: ref, configured, default: ref === config.defaultKeyRef });
|
|
595
|
+
}
|
|
596
|
+
return entries;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
async function handleKeys(ctx, config, req, res) {
|
|
600
|
+
if (rejectForeignCaller(req, res)) return;
|
|
601
|
+
try {
|
|
602
|
+
json(res, 200, { ok: true, keys: await configuredKeys(ctx, config) });
|
|
603
|
+
} catch (error) {
|
|
604
|
+
ctx.logger.warn(`usage-stats: keys enumeration failed: ${String(error)}`);
|
|
605
|
+
json(res, 500, { ok: false, error: "internal", message: error instanceof Error ? error.message : String(error) });
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/** Per-key balance cache with single-flight and a configurable TTL. */
|
|
610
|
+
export function createBalanceService({ credentials, config, deps = {} }) {
|
|
611
|
+
const cache = new Map();
|
|
612
|
+
const inflight = new Map();
|
|
613
|
+
const refreshMs = config.refreshMs;
|
|
614
|
+
|
|
615
|
+
async function fetchBalance(ref, force = false) {
|
|
616
|
+
const hit = cache.get(ref);
|
|
617
|
+
const age = (deps.now ?? Date.now)() - (hit?.fetchedAt ?? 0);
|
|
618
|
+
if (!force && hit !== void 0 && age >= 0 && age < refreshMs) return hit;
|
|
619
|
+
// force only bypasses the cache-hit check — it never bypasses the
|
|
620
|
+
// single-flight, so concurrent force callers share one upstream request.
|
|
621
|
+
if (inflight.has(ref)) return inflight.get(ref);
|
|
622
|
+
const promise = (async () => {
|
|
623
|
+
const apiKey = await resolveCredential(credentials, ref);
|
|
624
|
+
if (apiKey === "") {
|
|
625
|
+
return { id: ref, status: "not-configured", fetchedAt: (deps.now ?? Date.now)() };
|
|
626
|
+
}
|
|
627
|
+
try {
|
|
628
|
+
const raw = await (deps.queryBalance ?? queryDeepSeekBalance)(config.baseURL, apiKey, deps.timeoutMs ?? UPSTREAM_TIMEOUT_MS, deps.fetch);
|
|
629
|
+
const account = {
|
|
630
|
+
id: ref,
|
|
631
|
+
status: raw.isAvailable === false ? "unavailable" : "ok",
|
|
632
|
+
fetchedAt: (deps.now ?? Date.now)(),
|
|
633
|
+
balance: {
|
|
634
|
+
currency: raw.currency ?? "CNY",
|
|
635
|
+
total: Number(raw.total),
|
|
636
|
+
...(raw.granted === void 0 ? {} : { granted: Number(raw.granted) }),
|
|
637
|
+
...(raw.toppedUp === void 0 ? {} : { toppedUp: Number(raw.toppedUp) })
|
|
638
|
+
}
|
|
639
|
+
};
|
|
640
|
+
cache.set(ref, account);
|
|
641
|
+
return account;
|
|
642
|
+
} catch (error) {
|
|
643
|
+
const account = {
|
|
644
|
+
id: ref,
|
|
645
|
+
status: error?.providerStatus ?? responseStatus(error?.httpStatus ?? 0) ?? "unavailable",
|
|
646
|
+
message: error instanceof Error ? error.message : String(error),
|
|
647
|
+
fetchedAt: (deps.now ?? Date.now)()
|
|
648
|
+
};
|
|
649
|
+
cache.set(ref, account);
|
|
650
|
+
return account;
|
|
651
|
+
}
|
|
652
|
+
})().finally(() => inflight.delete(ref));
|
|
653
|
+
inflight.set(ref, promise);
|
|
654
|
+
return promise;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
return {
|
|
658
|
+
get: fetchBalance,
|
|
659
|
+
refreshAll: () => Promise.all(config.keys.map((ref) => fetchBalance(ref, true))),
|
|
660
|
+
cached: (ref) => cache.get(ref) ?? null
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
async function handleBalance(logger, config, balanceService, req, res) {
|
|
665
|
+
if (rejectForeignCaller(req, res)) return;
|
|
666
|
+
try {
|
|
667
|
+
const url = new URL(req.url ?? "/", "http://x");
|
|
668
|
+
const requested = url.searchParams.get("key");
|
|
669
|
+
const keys = config.keys;
|
|
670
|
+
const ref = requested !== null && requested !== "" && keys.includes(requested) ? requested : config.defaultKeyRef;
|
|
671
|
+
if (ref === null || ref === void 0) {
|
|
672
|
+
json(res, 200, { ok: false, error: "no-keys", message: "no API keys configured" });
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
const account = await balanceService.get(ref, url.searchParams.get("refresh") === "1");
|
|
676
|
+
json(res, 200, { ok: true, account });
|
|
677
|
+
} catch (error) {
|
|
678
|
+
logger.warn(`usage-stats: balance fetch failed: ${String(error)}`);
|
|
679
|
+
json(res, 500, { ok: false, error: "internal", message: error instanceof Error ? error.message : String(error) });
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/** Start an immediate refresh and repeat balance + local usage refresh every N ms. */
|
|
684
|
+
export function startBackgroundRefresh(ctx, balanceService, config, deps = {}) {
|
|
685
|
+
let running = false;
|
|
686
|
+
let stopped = false;
|
|
687
|
+
let active = Promise.resolve();
|
|
688
|
+
const run = async () => {
|
|
689
|
+
if (running || stopped) return;
|
|
690
|
+
running = true;
|
|
691
|
+
active = (async () => {
|
|
692
|
+
const results = await Promise.allSettled([balanceService.refreshAll(), collectUsage(ctx)]);
|
|
693
|
+
for (const result of results) if (result.status === "rejected") ctx.logger.warn(`usage-stats: background refresh failed: ${String(result.reason)}`);
|
|
694
|
+
})().finally(() => {
|
|
695
|
+
running = false;
|
|
696
|
+
});
|
|
697
|
+
return active;
|
|
698
|
+
};
|
|
699
|
+
void run();
|
|
700
|
+
const setTimer = deps.setInterval ?? setInterval;
|
|
701
|
+
const clearTimer = deps.clearInterval ?? clearInterval;
|
|
702
|
+
const timer = setTimer(run, config.refreshMs);
|
|
703
|
+
timer?.unref?.();
|
|
704
|
+
const stop = async () => {
|
|
705
|
+
stopped = true;
|
|
706
|
+
clearTimer(timer);
|
|
707
|
+
await active;
|
|
708
|
+
};
|
|
709
|
+
stop.refreshNow = async () => {
|
|
710
|
+
await active;
|
|
711
|
+
return run();
|
|
712
|
+
};
|
|
713
|
+
return stop;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
//#region limits
|
|
717
|
+
export class UsageLimitExceededError extends Error {
|
|
718
|
+
constructor(status = {}) {
|
|
719
|
+
super(status.message || "Usage limit exceeded");
|
|
720
|
+
this.name = "UsageLimitExceededError";
|
|
721
|
+
this.code = "USAGE_LIMIT_EXCEEDED";
|
|
722
|
+
this.status = status.status ?? "blocked";
|
|
723
|
+
this.reason = status.reason ?? null;
|
|
724
|
+
this.keyRef = status.keyRef ?? null;
|
|
725
|
+
this.currentValue = status.currentValue ?? null;
|
|
726
|
+
this.threshold = status.threshold ?? null;
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
function defaultLimitRule() {
|
|
731
|
+
return {
|
|
732
|
+
enabled: false,
|
|
733
|
+
period: "daily",
|
|
734
|
+
dailyCostLimit: null,
|
|
735
|
+
monthlyCostLimit: null,
|
|
736
|
+
lowBalanceWarning: null,
|
|
737
|
+
minBalance: null,
|
|
738
|
+
alertPercent: 80,
|
|
739
|
+
criticalPercent: 90,
|
|
740
|
+
// Hard stop is opt-in; normal configurations remain advisory.
|
|
741
|
+
stopOnExceed: false,
|
|
742
|
+
notificationCooldownMs: 30 * 60 * 1000
|
|
743
|
+
};
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
function defaultLimits() {
|
|
747
|
+
return {
|
|
748
|
+
version: LIMITS_VERSION,
|
|
749
|
+
global: defaultLimitRule(),
|
|
750
|
+
keys: {}
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
const LIMIT_RULE_FIELDS = new Set([
|
|
755
|
+
"enabled", "period", "dailyCostLimit", "monthlyCostLimit", "lowBalanceWarning",
|
|
756
|
+
"minBalance", "alertPercent", "criticalPercent", "stopOnExceed", "notificationCooldownMs"
|
|
757
|
+
]);
|
|
758
|
+
|
|
759
|
+
function validateLimitRule(raw = {}, { legacy = true, strict = false } = {}) {
|
|
760
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return defaultLimitRule();
|
|
761
|
+
if (strict) {
|
|
762
|
+
for (const field of Object.keys(raw)) {
|
|
763
|
+
if (!LIMIT_RULE_FIELDS.has(field)) throw new TypeError(`unknown limit rule field: ${field}`);
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
const enabled = raw.enabled === true;
|
|
767
|
+
const period = ["daily", "monthly", "custom", "cumulative"].includes(raw.period) ? raw.period : "daily";
|
|
768
|
+
const dailyCostLimit = numberOrNull(raw.dailyCostLimit);
|
|
769
|
+
const monthlyCostLimit = numberOrNull(raw.monthlyCostLimit);
|
|
770
|
+
const lowBalanceWarning = numberOrNull(raw.lowBalanceWarning);
|
|
771
|
+
const minBalance = numberOrNull(raw.minBalance);
|
|
772
|
+
let alertPercent = 80;
|
|
773
|
+
if (raw.alertPercent !== void 0) {
|
|
774
|
+
const parsed = Number(raw.alertPercent);
|
|
775
|
+
if (Number.isFinite(parsed) && parsed >= 1 && parsed <= 100) alertPercent = Math.round(parsed);
|
|
776
|
+
}
|
|
777
|
+
let criticalPercent = 90;
|
|
778
|
+
if (raw.criticalPercent !== void 0) {
|
|
779
|
+
const parsed = Number(raw.criticalPercent);
|
|
780
|
+
if (Number.isFinite(parsed) && parsed >= 1 && parsed <= 100) criticalPercent = Math.max(alertPercent, Math.round(parsed));
|
|
781
|
+
}
|
|
782
|
+
return {
|
|
783
|
+
enabled,
|
|
784
|
+
dailyCostLimit: dailyCostLimit !== null && dailyCostLimit > 0 ? dailyCostLimit : null,
|
|
785
|
+
lowBalanceWarning: lowBalanceWarning !== null && lowBalanceWarning > 0 ? lowBalanceWarning : null,
|
|
786
|
+
alertPercent,
|
|
787
|
+
criticalPercent,
|
|
788
|
+
period,
|
|
789
|
+
monthlyCostLimit: monthlyCostLimit !== null && monthlyCostLimit > 0 ? monthlyCostLimit : null,
|
|
790
|
+
// Schema v1 exposed these fields before their safety semantics were
|
|
791
|
+
// stable. Migrate old files fail-open; only an explicit v2 save may
|
|
792
|
+
// enable a balance-based hard stop.
|
|
793
|
+
minBalance: !legacy && minBalance !== null && minBalance > 0 ? minBalance : null,
|
|
794
|
+
stopOnExceed: !legacy && raw.stopOnExceed === true,
|
|
795
|
+
notificationCooldownMs: Number.isFinite(Number(raw.notificationCooldownMs)) && Number(raw.notificationCooldownMs) >= 0 ? Math.min(7 * 86400000, Number(raw.notificationCooldownMs)) : 30 * 60 * 1000
|
|
796
|
+
};
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
function validateLimits(raw = {}) {
|
|
800
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return defaultLimits();
|
|
801
|
+
const legacy = Number(raw.version) !== LIMITS_VERSION;
|
|
802
|
+
if (!legacy) {
|
|
803
|
+
for (const field of Object.keys(raw)) {
|
|
804
|
+
if (!["version", "global", "keys"].includes(field)) throw new TypeError(`unknown limits field: ${field}`);
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
const globalRule = validateLimitRule(raw.global ?? {}, { legacy, strict: !legacy });
|
|
808
|
+
const keys = {};
|
|
809
|
+
if (raw.keys !== null && typeof raw.keys === "object" && !Array.isArray(raw.keys)) {
|
|
810
|
+
for (const [keyRef, rule] of Object.entries(raw.keys)) {
|
|
811
|
+
const ref = nonEmptyString(keyRef);
|
|
812
|
+
if (ref !== null) keys[ref] = validateLimitRule(rule, { legacy, strict: !legacy });
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
return {
|
|
816
|
+
version: LIMITS_VERSION,
|
|
817
|
+
global: globalRule,
|
|
818
|
+
keys
|
|
819
|
+
};
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
function limitsPath() {
|
|
823
|
+
const home = process.env.DSH_HOME ?? join(homedir(), ".dsh");
|
|
824
|
+
return join(home, "storages", "usage-limits.json");
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
let loadedLimits = null;
|
|
828
|
+
|
|
829
|
+
async function loadLimits() {
|
|
830
|
+
if (loadedLimits !== null) return loadedLimits;
|
|
831
|
+
try {
|
|
832
|
+
const raw = await readFile(limitsPath(), "utf8");
|
|
833
|
+
const parsed = JSON.parse(raw);
|
|
834
|
+
loadedLimits = validateLimits(parsed);
|
|
835
|
+
return loadedLimits;
|
|
836
|
+
} catch {
|
|
837
|
+
loadedLimits = defaultLimits();
|
|
838
|
+
return loadedLimits;
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
async function saveLimits(ctx, limits) {
|
|
843
|
+
const path = limitsPath();
|
|
844
|
+
await mkdir(dirname(path), { recursive: true });
|
|
845
|
+
const tmp = `${path}.tmp`;
|
|
846
|
+
await writeFile(tmp, JSON.stringify(limits, null, 2), "utf8");
|
|
847
|
+
await rename(tmp, path);
|
|
848
|
+
loadedLimits = limits;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/** Map a provider id to its API-key reference via `config.keyProviders`. */
|
|
852
|
+
export function keyForProvider(provider, config) {
|
|
853
|
+
for (const [ref, providers] of Object.entries(config.keyProviders ?? {})) {
|
|
854
|
+
if (providers.includes(provider)) return ref;
|
|
855
|
+
}
|
|
856
|
+
return null;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
/**
|
|
860
|
+
* Today's estimated cost per API key, derived from the per-model cost split.
|
|
861
|
+
* With `keyProviders` configured, each model's cost is attributed to the key
|
|
862
|
+
* owning its provider route (unmapped providers go to the default key).
|
|
863
|
+
* Without a mapping, attribution is impossible, so every key sees the global
|
|
864
|
+
* today cost (per-key daily limits still work, they just share the total).
|
|
865
|
+
* @returns Map<keyRef, cost>.
|
|
866
|
+
*/
|
|
867
|
+
export function todayCostPerKey(usageDays, today, config) {
|
|
868
|
+
const perKey = new Map();
|
|
869
|
+
const mapped = Object.keys(config.keyProviders ?? {}).length > 0;
|
|
870
|
+
for (const day of usageDays ?? []) {
|
|
871
|
+
if (day.date !== today) continue;
|
|
872
|
+
for (const model of day.models ?? []) {
|
|
873
|
+
const cost = Number(model.cost) || 0;
|
|
874
|
+
if (cost <= 0) continue;
|
|
875
|
+
const ref = keyForProvider(providerOf(model.model), config) ?? config.defaultKeyRef;
|
|
876
|
+
perKey.set(ref, (perKey.get(ref) ?? 0) + cost);
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
return { perKey, mapped };
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
/** The today-cost a quota check should use for one key. */
|
|
883
|
+
export function todayCostFor(ref, perKey, globalTodayCost, config) {
|
|
884
|
+
const mapped = Object.keys(config.keyProviders ?? {}).length > 0;
|
|
885
|
+
if (!mapped) return globalTodayCost;
|
|
886
|
+
return perKey.get(ref) ?? 0;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/** Beijing `YYYY-MM-DD` for today (matches the usage.js day buckets). */
|
|
890
|
+
function todayKeyLocal() {
|
|
891
|
+
return dayKey(Date.now());
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
/** Build the per-key status map once, shared by evaluateStatus/evaluateAll. */
|
|
895
|
+
async function evaluateStatuses({ ctx, config, balanceService, limits, usage, today, deps }) {
|
|
896
|
+
const dayEntry = (usage.days ?? []).find((d) => d.date === today);
|
|
897
|
+
const globalTodayCost = dayEntry?.cost ?? 0;
|
|
898
|
+
const { perKey } = todayCostPerKey(usage.days, today, config);
|
|
899
|
+
const keys = [...new Set([...config.keys, ...Object.keys(limits.keys)])];
|
|
900
|
+
// First pass: collect the refs whose cached balance is stale or missing.
|
|
901
|
+
// They are refreshed concurrently below — balanceService.get single-flights,
|
|
902
|
+
// so parallel callers never duplicate upstream requests.
|
|
903
|
+
const refreshTargets = [];
|
|
904
|
+
for (const ref of keys) {
|
|
905
|
+
const now = (deps.now ?? Date.now)();
|
|
906
|
+
const account = typeof balanceService?.cached === "function" ? balanceService.cached(ref) : null;
|
|
907
|
+
const cachedAt = Number(account?.fetchedAt);
|
|
908
|
+
const cacheFresh = account !== null
|
|
909
|
+
&& Number.isFinite(cachedAt)
|
|
910
|
+
&& cachedAt <= now
|
|
911
|
+
&& now - cachedAt < config.refreshMs;
|
|
912
|
+
if (!cacheFresh && typeof balanceService?.get === "function") refreshTargets.push(ref);
|
|
913
|
+
}
|
|
914
|
+
const refreshed = new Map();
|
|
915
|
+
if (refreshTargets.length > 0) {
|
|
916
|
+
const settled = await Promise.all(refreshTargets.map((ref) =>
|
|
917
|
+
balanceService.get(ref).then((account) => [ref, account]).catch(() => [ref, null])
|
|
918
|
+
));
|
|
919
|
+
for (const [ref, account] of settled) refreshed.set(ref, account);
|
|
920
|
+
}
|
|
921
|
+
const statuses = {};
|
|
922
|
+
for (const ref of keys) {
|
|
923
|
+
const now = (deps.now ?? Date.now)();
|
|
924
|
+
const account = refreshed.has(ref)
|
|
925
|
+
? refreshed.get(ref)
|
|
926
|
+
: (typeof balanceService?.cached === "function" ? balanceService.cached(ref) : null);
|
|
927
|
+
statuses[ref] = evaluateKeyQuota({
|
|
928
|
+
keyRef: ref,
|
|
929
|
+
limits,
|
|
930
|
+
todayCost: todayCostFor(ref, perKey, globalTodayCost, config),
|
|
931
|
+
balance: account?.balance ?? null,
|
|
932
|
+
balanceStatus: account?.status,
|
|
933
|
+
balanceFetchedAt: account?.fetchedAt,
|
|
934
|
+
now,
|
|
935
|
+
balanceMaxAgeMs: config.refreshMs
|
|
936
|
+
});
|
|
937
|
+
}
|
|
938
|
+
return { statuses, globalTodayCost };
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/**
|
|
942
|
+
* Effective limit rule for one key: the per-key rule when it actually
|
|
943
|
+
* carries a numeric daily cost limit, otherwise the global
|
|
944
|
+
* rule. A per-key record with no numbers (an "empty shell" left behind by a
|
|
945
|
+
* previously enabled key) must NOT shadow the global rule — otherwise the
|
|
946
|
+
* user's global limits silently stop applying to that key.
|
|
947
|
+
*/
|
|
948
|
+
function resolveLimitRule(allLimits, keyRef) {
|
|
949
|
+
const global = allLimits?.global ?? defaultLimitRule();
|
|
950
|
+
const keyRule = allLimits?.keys?.[keyRef];
|
|
951
|
+
if (keyRule === void 0) return global;
|
|
952
|
+
const hasNumbers = (keyRule.dailyCostLimit !== null && keyRule.dailyCostLimit > 0)
|
|
953
|
+
|| (keyRule.monthlyCostLimit !== null && keyRule.monthlyCostLimit > 0)
|
|
954
|
+
|| (keyRule.lowBalanceWarning !== null && keyRule.lowBalanceWarning > 0)
|
|
955
|
+
|| (keyRule.minBalance !== null && keyRule.minBalance > 0);
|
|
956
|
+
if (!hasNumbers) return global;
|
|
957
|
+
// Numbered per-key rule: override the global for its explicit fields and
|
|
958
|
+
// inherit the global for any field left unset (null), so the global stays
|
|
959
|
+
// the floor and a key can only tighten — never silently opt out.
|
|
960
|
+
return {
|
|
961
|
+
enabled: keyRule.enabled,
|
|
962
|
+
period: keyRule.period ?? global.period ?? "daily",
|
|
963
|
+
dailyCostLimit: keyRule.dailyCostLimit ?? global.dailyCostLimit ?? null,
|
|
964
|
+
monthlyCostLimit: keyRule.monthlyCostLimit ?? global.monthlyCostLimit ?? null,
|
|
965
|
+
lowBalanceWarning: keyRule.lowBalanceWarning ?? global.lowBalanceWarning ?? null,
|
|
966
|
+
alertPercent: keyRule.alertPercent ?? global.alertPercent ?? 80,
|
|
967
|
+
criticalPercent: keyRule.criticalPercent ?? global.criticalPercent ?? 90,
|
|
968
|
+
minBalance: keyRule.minBalance ?? global.minBalance ?? null,
|
|
969
|
+
stopOnExceed: keyRule.stopOnExceed === true || global.stopOnExceed === true,
|
|
970
|
+
notificationCooldownMs: keyRule.notificationCooldownMs ?? global.notificationCooldownMs ?? 30 * 60 * 1000
|
|
971
|
+
};
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
function evaluateKeyQuota({ keyRef, limits, todayCost = 0, balance = null, balanceStatus, balanceFetchedAt, now = Date.now(), balanceMaxAgeMs = Infinity }) {
|
|
975
|
+
const allLimits = limits ?? defaultLimits();
|
|
976
|
+
const rule = resolveLimitRule(allLimits, keyRef);
|
|
977
|
+
const numericCost = Number(todayCost) || 0;
|
|
978
|
+
const numericBalance = balance !== null && Number.isFinite(Number(balance.total)) ? Number(balance.total) : null;
|
|
979
|
+
const balanceFresh = balanceStatus === void 0
|
|
980
|
+
? true
|
|
981
|
+
: balanceStatus === "ok"
|
|
982
|
+
&& Number.isFinite(Number(balanceFetchedAt))
|
|
983
|
+
&& Number(balanceFetchedAt) <= now
|
|
984
|
+
&& now - Number(balanceFetchedAt) <= balanceMaxAgeMs;
|
|
985
|
+
const balanceAlertStatus = rule.enabled && rule.lowBalanceWarning !== null && numericBalance !== null && balanceFresh
|
|
986
|
+
? (numericBalance <= 0 ? "exceeded" : numericBalance <= rule.lowBalanceWarning ? "warning" : "ok")
|
|
987
|
+
: "muted";
|
|
988
|
+
const balanceExceeded = rule.enabled && rule.minBalance !== null && numericBalance !== null && balanceFresh && numericBalance <= rule.minBalance;
|
|
989
|
+
const balanceRuleEnabled = rule.enabled && (rule.lowBalanceWarning !== null || rule.minBalance !== null);
|
|
990
|
+
const unavailable = balanceRuleEnabled && balanceStatus !== void 0 && balanceStatus !== null && balanceStatus !== "ok" && balanceStatus !== "not-configured";
|
|
991
|
+
const stale = balanceRuleEnabled && balanceStatus === "ok" && numericBalance !== null && !balanceFresh;
|
|
992
|
+
|
|
993
|
+
if (!rule.enabled) {
|
|
994
|
+
return {
|
|
995
|
+
keyRef,
|
|
996
|
+
enabled: false,
|
|
997
|
+
status: "normal",
|
|
998
|
+
spendStatus: "muted",
|
|
999
|
+
balanceAlertStatus,
|
|
1000
|
+
exceeded: false,
|
|
1001
|
+
warning: false,
|
|
1002
|
+
reason: null,
|
|
1003
|
+
stopOnExceed: rule.stopOnExceed,
|
|
1004
|
+
todayCost: numericCost,
|
|
1005
|
+
dailyCostLimit: rule.dailyCostLimit,
|
|
1006
|
+
lowBalanceWarning: rule.lowBalanceWarning,
|
|
1007
|
+
minBalance: rule.minBalance,
|
|
1008
|
+
alertPercent: rule.alertPercent,
|
|
1009
|
+
currentBalance: numericBalance,
|
|
1010
|
+
balanceStatus: balanceStatus ?? null,
|
|
1011
|
+
balanceFresh,
|
|
1012
|
+
stale: false,
|
|
1013
|
+
unavailable: false,
|
|
1014
|
+
blocked: false,
|
|
1015
|
+
currentValue: numericCost,
|
|
1016
|
+
threshold: rule.dailyCostLimit,
|
|
1017
|
+
scope: { type: keyRef ? "key" : "global", id: keyRef || null },
|
|
1018
|
+
currency: "CNY",
|
|
1019
|
+
evaluatedAt: now,
|
|
1020
|
+
sourceUpdatedAt: Number.isFinite(Number(balanceFetchedAt)) ? Number(balanceFetchedAt) : null,
|
|
1021
|
+
notificationCooldownMs: rule.notificationCooldownMs,
|
|
1022
|
+
message: ""
|
|
1023
|
+
};
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
let exceeded = false;
|
|
1027
|
+
let warning = false;
|
|
1028
|
+
let reason = null;
|
|
1029
|
+
let message = "";
|
|
1030
|
+
|
|
1031
|
+
// Check daily cost limit
|
|
1032
|
+
if (rule.dailyCostLimit !== null && rule.dailyCostLimit > 0) {
|
|
1033
|
+
if (numericCost >= rule.dailyCostLimit * (rule.criticalPercent / 100)) {
|
|
1034
|
+
exceeded = true;
|
|
1035
|
+
reason = "daily_cost";
|
|
1036
|
+
message = `今日消费 (${numericCost.toFixed(2)}) 已达到严重预警线 (${rule.criticalPercent}%)`;
|
|
1037
|
+
} else if (numericCost >= (rule.dailyCostLimit * (rule.alertPercent / 100))) {
|
|
1038
|
+
warning = true;
|
|
1039
|
+
reason = "daily_cost";
|
|
1040
|
+
message = `今日消费 (${numericCost.toFixed(2)}) 已达到每日限额 (${rule.dailyCostLimit.toFixed(2)}) 的 ${rule.alertPercent}% 预警线`;
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
if (balanceExceeded && !exceeded) {
|
|
1044
|
+
exceeded = true;
|
|
1045
|
+
reason = "min_balance";
|
|
1046
|
+
message = `余额 (${numericBalance.toFixed(2)}) 已低于警戒线 (${rule.minBalance.toFixed(2)})`;
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
const spendStatus = rule.dailyCostLimit !== null && rule.dailyCostLimit > 0
|
|
1050
|
+
? (exceeded ? "exceeded" : (warning ? "warning" : "normal"))
|
|
1051
|
+
: "muted";
|
|
1052
|
+
const status = exceeded && rule.stopOnExceed
|
|
1053
|
+
? "blocked"
|
|
1054
|
+
: exceeded ? "exceeded"
|
|
1055
|
+
: warning ? "warning"
|
|
1056
|
+
: stale ? "stale"
|
|
1057
|
+
: unavailable ? "unavailable"
|
|
1058
|
+
: balanceAlertStatus === "warning" || balanceAlertStatus === "exceeded" ? balanceAlertStatus : "normal";
|
|
1059
|
+
if (reason === null && stale) {
|
|
1060
|
+
reason = "data_stale";
|
|
1061
|
+
message = "余额数据已过期,余额相关限额暂不参与硬停止";
|
|
1062
|
+
} else if (reason === null && unavailable) {
|
|
1063
|
+
reason = "query_failed";
|
|
1064
|
+
message = "余额暂不可用,余额相关限额暂不参与硬停止";
|
|
1065
|
+
}
|
|
1066
|
+
return {
|
|
1067
|
+
keyRef,
|
|
1068
|
+
enabled: true,
|
|
1069
|
+
status,
|
|
1070
|
+
spendStatus,
|
|
1071
|
+
balanceAlertStatus,
|
|
1072
|
+
exceeded,
|
|
1073
|
+
warning,
|
|
1074
|
+
reason,
|
|
1075
|
+
stopOnExceed: rule.stopOnExceed,
|
|
1076
|
+
blocked: status === "blocked",
|
|
1077
|
+
todayCost: numericCost,
|
|
1078
|
+
dailyCostLimit: rule.dailyCostLimit,
|
|
1079
|
+
lowBalanceWarning: rule.lowBalanceWarning,
|
|
1080
|
+
minBalance: rule.minBalance,
|
|
1081
|
+
alertPercent: rule.alertPercent,
|
|
1082
|
+
currentBalance: numericBalance,
|
|
1083
|
+
balanceStatus: balanceStatus ?? null,
|
|
1084
|
+
balanceFresh,
|
|
1085
|
+
stale,
|
|
1086
|
+
unavailable,
|
|
1087
|
+
currentValue: reason === "min_balance" ? numericBalance : numericCost,
|
|
1088
|
+
threshold: reason === "min_balance" ? rule.minBalance : rule.dailyCostLimit,
|
|
1089
|
+
scope: { type: keyRef ? "key" : "global", id: keyRef || null },
|
|
1090
|
+
currency: "CNY",
|
|
1091
|
+
evaluatedAt: now,
|
|
1092
|
+
sourceUpdatedAt: Number.isFinite(Number(balanceFetchedAt)) ? Number(balanceFetchedAt) : null,
|
|
1093
|
+
notificationCooldownMs: rule.notificationCooldownMs,
|
|
1094
|
+
message
|
|
1095
|
+
};
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
/** Track alert crossings without emitting on every request or poll. */
|
|
1099
|
+
function createAlertTracker({ now = Date.now } = {}) {
|
|
1100
|
+
const states = new Map();
|
|
1101
|
+
const alertStatuses = new Set(["warning", "exceeded", "blocked", "stale", "unavailable"]);
|
|
1102
|
+
return {
|
|
1103
|
+
observe(status = {}) {
|
|
1104
|
+
const scope = status.scope ?? { type: status.keyRef ? "key" : "global", id: status.keyRef ?? null };
|
|
1105
|
+
const scopeKey = `${scope.type}:${scope.id ?? ""}`;
|
|
1106
|
+
const currentStatus = status.status ?? "normal";
|
|
1107
|
+
const alerting = alertStatuses.has(currentStatus);
|
|
1108
|
+
const identity = `${currentStatus}|${status.reason ?? ""}|${status.threshold ?? ""}`;
|
|
1109
|
+
const previous = states.get(scopeKey);
|
|
1110
|
+
const at = Number(now());
|
|
1111
|
+
const cooldown = Math.max(0, Number(status.notificationCooldownMs) || 0);
|
|
1112
|
+
let shouldNotify = false;
|
|
1113
|
+
let type = null;
|
|
1114
|
+
if (alerting) {
|
|
1115
|
+
const crossed = previous === void 0 || previous.alerting !== true || previous.identity !== identity;
|
|
1116
|
+
const cooledDown = previous?.lastNotifiedAt !== null && previous?.lastNotifiedAt !== void 0 && at - previous.lastNotifiedAt >= cooldown;
|
|
1117
|
+
shouldNotify = crossed || cooledDown;
|
|
1118
|
+
type = shouldNotify ? "alert" : null;
|
|
1119
|
+
} else if (currentStatus === "normal" && previous?.alerting === true) {
|
|
1120
|
+
shouldNotify = true;
|
|
1121
|
+
type = "recovery";
|
|
1122
|
+
}
|
|
1123
|
+
states.set(scopeKey, {
|
|
1124
|
+
alerting,
|
|
1125
|
+
identity,
|
|
1126
|
+
lastNotifiedAt: shouldNotify ? at : (previous?.lastNotifiedAt ?? null)
|
|
1127
|
+
});
|
|
1128
|
+
return { shouldNotify, type, at: shouldNotify ? at : null };
|
|
1129
|
+
}
|
|
1130
|
+
};
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
|
|
1134
|
+
/** Default cap on ledger entries before the oldest overflow is folded into legacy. */
|
|
1135
|
+
const DEFAULT_MAX_LEDGER_ENTRIES = 5000;
|
|
1136
|
+
|
|
1137
|
+
/**
|
|
1138
|
+
* Append one call-level ledger entry and persist it atomically before return.
|
|
1139
|
+
* When the ledger exceeds `deps.maxLedgerEntries` (default 5000), the oldest
|
|
1140
|
+
* overflow entries are folded into the legacy snapshot (see compactLedger).
|
|
1141
|
+
* NOTE: the folded entries' frozen `costCny` is dropped — that history falls
|
|
1142
|
+
* back to the legacy estimation basis (render-time pricing), not the frozen
|
|
1143
|
+
* price, and the snapshot is stamped with a new `updatedAt`.
|
|
1144
|
+
* @param ctx - plugin context (logger).
|
|
1145
|
+
* @param entry - the normalized call-level ledger entry.
|
|
1146
|
+
* @param deps - optional { maxLedgerEntries }; the two-arg call stays valid.
|
|
1147
|
+
*/
|
|
1148
|
+
async function recordLedgerEntry(ctx, entry, deps = {}) {
|
|
1149
|
+
return withLock(async () => {
|
|
1150
|
+
const cache = await loadCache();
|
|
1151
|
+
appendLedger(cache.ledger, entry);
|
|
1152
|
+
const maxEntries = Number.isFinite(Number(deps?.maxLedgerEntries)) && Number(deps.maxLedgerEntries) > 0
|
|
1153
|
+
? Number(deps.maxLedgerEntries)
|
|
1154
|
+
: DEFAULT_MAX_LEDGER_ENTRIES;
|
|
1155
|
+
if (cache.ledger.length > maxEntries) {
|
|
1156
|
+
const removed = compactLedger(cache.ledger, maxEntries);
|
|
1157
|
+
if (removed.length > 0) {
|
|
1158
|
+
// Fold the oldest entries into the serialized legacy day map
|
|
1159
|
+
// (plain object ↔ Map via parseDayMap/serializeDays) and merge
|
|
1160
|
+
// with any pre-existing legacy snapshot.
|
|
1161
|
+
const byDay = parseDayMap(cache.legacy?.days ?? null);
|
|
1162
|
+
mergeInto(byDay, foldLedger(removed));
|
|
1163
|
+
cache.legacy = {
|
|
1164
|
+
updatedAt: Date.now(),
|
|
1165
|
+
days: serializeDays(byDay)
|
|
1166
|
+
};
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
await saveCache(ctx, cache);
|
|
1170
|
+
return entry;
|
|
1171
|
+
});
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
function createLimitsService({ ctx, config, balanceService, deps = {} }) {
|
|
1175
|
+
let memoryLimits = null;
|
|
1176
|
+
const alertTracker = deps.alertTracker ?? createAlertTracker({ now: deps.now ?? Date.now });
|
|
1177
|
+
|
|
1178
|
+
async function getLimits() {
|
|
1179
|
+
if (memoryLimits !== null) return memoryLimits;
|
|
1180
|
+
memoryLimits = validateLimits(await (deps.loadLimits ?? loadLimits)());
|
|
1181
|
+
return memoryLimits;
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
async function updateLimits(raw) {
|
|
1185
|
+
const validated = validateLimits(raw);
|
|
1186
|
+
await (deps.saveLimits ?? saveLimits)(ctx, validated);
|
|
1187
|
+
memoryLimits = validated;
|
|
1188
|
+
return validated;
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
async function evaluateStatus(keyRef) {
|
|
1192
|
+
const limits = await getLimits();
|
|
1193
|
+
const ref = keyRef || config.defaultKeyRef;
|
|
1194
|
+
const usage = await (deps.collectUsage ?? collectUsage)(ctx, config.pricing);
|
|
1195
|
+
const today = (deps.todayKey ?? todayKeyLocal)();
|
|
1196
|
+
const { statuses } = await evaluateStatuses({ ctx, config, balanceService, limits, usage, today, deps });
|
|
1197
|
+
return statuses[ref] ?? evaluateKeyQuota({ keyRef: ref, limits, todayCost: (usage.days ?? []).find((d) => d.date === today)?.cost ?? 0, balance: null });
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
async function evaluateAll() {
|
|
1201
|
+
const limits = await getLimits();
|
|
1202
|
+
const usage = await (deps.collectUsage ?? collectUsage)(ctx, config.pricing);
|
|
1203
|
+
const today = (deps.todayKey ?? todayKeyLocal)();
|
|
1204
|
+
const { statuses, globalTodayCost } = await evaluateStatuses({ ctx, config, balanceService, limits, usage, today, deps });
|
|
1205
|
+
for (const status of Object.values(statuses)) status.notification = alertTracker.observe(status);
|
|
1206
|
+
return { limits, statuses, todayCost: globalTodayCost };
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
async function check(payload = {}) {
|
|
1210
|
+
// Resolve the key by the request's provider route first (per-key
|
|
1211
|
+
// enforcement), then explicit payload hints, then the default key.
|
|
1212
|
+
const provider = payload?.config?.provider ?? payload?.provider;
|
|
1213
|
+
const byProvider = typeof provider === "string" && provider !== "" ? keyForProvider(provider, config) : null;
|
|
1214
|
+
const targetKey = byProvider
|
|
1215
|
+
?? payload?.key ?? payload?.keyRef ?? payload?.apiKeyRef ?? payload?.config?.apiKeyRef
|
|
1216
|
+
?? config.defaultKeyRef;
|
|
1217
|
+
return evaluateStatus(targetKey);
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
return {
|
|
1221
|
+
getLimits,
|
|
1222
|
+
updateLimits,
|
|
1223
|
+
evaluateStatus,
|
|
1224
|
+
evaluateAll,
|
|
1225
|
+
check
|
|
1226
|
+
};
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
async function handleLimits(ctx, config, limitsService, req, res) {
|
|
1230
|
+
if (rejectForeignCaller(req, res, ["GET", "POST"])) return;
|
|
1231
|
+
try {
|
|
1232
|
+
if (req.method === "GET") {
|
|
1233
|
+
const evaluated = await limitsService.evaluateAll();
|
|
1234
|
+
json(res, 200, { ok: true, limits: evaluated.limits, status: evaluated.statuses, defaultKeyRef: config.defaultKeyRef, todayCost: evaluated.todayCost });
|
|
1235
|
+
} else if (req.method === "POST") {
|
|
1236
|
+
const body = await readJsonBody(req);
|
|
1237
|
+
const updated = await limitsService.updateLimits(body);
|
|
1238
|
+
const evaluated = await limitsService.evaluateAll();
|
|
1239
|
+
json(res, 200, { ok: true, limits: updated, status: evaluated.statuses, defaultKeyRef: config.defaultKeyRef, todayCost: evaluated.todayCost });
|
|
1240
|
+
}
|
|
1241
|
+
} catch (error) {
|
|
1242
|
+
ctx.logger.warn(`usage-stats: limits request failed: ${String(error)}`);
|
|
1243
|
+
json(res, 500, { ok: false, error: "internal", message: error instanceof Error ? error.message : String(error) });
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
//#endregion
|
|
1247
|
+
|
|
1248
|
+
/** Standard Schema config adapter (Cordis validates + defaults via this). */
|
|
1249
|
+
const Config = {
|
|
1250
|
+
"~standard": {
|
|
1251
|
+
version: 1,
|
|
1252
|
+
vendor: "dsh-usage-stats",
|
|
1253
|
+
validate(value) {
|
|
1254
|
+
try {
|
|
1255
|
+
return { value: validateConfig(value ?? {}) };
|
|
1256
|
+
} catch (error) {
|
|
1257
|
+
return { issues: [{ message: error instanceof Error ? error.message : String(error) }] };
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
};
|
|
1262
|
+
|
|
1263
|
+
/**
|
|
1264
|
+
* Plugin body: register routes, start background refresh, and guard model calls.
|
|
1265
|
+
* @param ctx - plugin context carrying webServer, credentials, sessions, and sessionPersistence.
|
|
1266
|
+
*/
|
|
1267
|
+
function apply(ctx, rawConfig = {}, deps = {}) {
|
|
1268
|
+
const config = validateConfig(rawConfig);
|
|
1269
|
+
const credentials = ctx.get("credentials") ?? ctx.credentials;
|
|
1270
|
+
const balanceService = deps.balanceService ?? createBalanceService({ credentials, config });
|
|
1271
|
+
const limitsService = deps.limitsService ?? createLimitsService({ ctx, config, balanceService });
|
|
1272
|
+
|
|
1273
|
+
ctx.effect(() => ctx.webServer.register({
|
|
1274
|
+
kind: "exact",
|
|
1275
|
+
path: USAGE_PATH,
|
|
1276
|
+
handler: (req, res) => handleUsage(ctx, config.pricing, req, res)
|
|
1277
|
+
}), "usage-stats: usage route");
|
|
1278
|
+
ctx.effect(() => ctx.webServer.register({
|
|
1279
|
+
kind: "exact",
|
|
1280
|
+
path: KEYS_PATH,
|
|
1281
|
+
handler: (req, res) => handleKeys(ctx, config, req, res)
|
|
1282
|
+
}), "usage-stats: keys route");
|
|
1283
|
+
ctx.effect(() => ctx.webServer.register({
|
|
1284
|
+
kind: "exact",
|
|
1285
|
+
path: BALANCE_PATH,
|
|
1286
|
+
handler: (req, res) => handleBalance(ctx.logger, config, balanceService, req, res)
|
|
1287
|
+
}), "usage-stats: balance route");
|
|
1288
|
+
ctx.effect(() => ctx.webServer.register({
|
|
1289
|
+
kind: "exact",
|
|
1290
|
+
path: LIMITS_PATH,
|
|
1291
|
+
handler: (req, res) => handleLimits(ctx, config, limitsService, req, res)
|
|
1292
|
+
}), "usage-stats: limits route");
|
|
1293
|
+
|
|
1294
|
+
// Limit checks are fail-open on plugin/storage errors. A deliberate hard
|
|
1295
|
+
// stop is the only branch that changes model-call behavior.
|
|
1296
|
+
if (typeof ctx.on === "function") {
|
|
1297
|
+
ctx.effect(() => {
|
|
1298
|
+
return ctx.on("agent/request", async (payload, next) => {
|
|
1299
|
+
try {
|
|
1300
|
+
const status = await limitsService.check(payload ?? {});
|
|
1301
|
+
if (status?.status === "blocked" || status?.blocked === true) throw new UsageLimitExceededError(status);
|
|
1302
|
+
} catch (error) {
|
|
1303
|
+
if (error instanceof UsageLimitExceededError) throw error;
|
|
1304
|
+
ctx.logger?.warn?.("usage-stats: agent/request quota check failed; allowing call: " + String(error));
|
|
1305
|
+
}
|
|
1306
|
+
if (typeof next === "function") return next();
|
|
1307
|
+
});
|
|
1308
|
+
}, "usage-stats: quota limit interceptor on agent/request");
|
|
1309
|
+
|
|
1310
|
+
ctx.effect(() => {
|
|
1311
|
+
// The llm/stream waterfall must synchronously return an AsyncIterable.
|
|
1312
|
+
// Run the asynchronous quota check inside an async generator, then
|
|
1313
|
+
// delegate to the downstream stream unchanged when the call is allowed.
|
|
1314
|
+
// While streaming, capture the usage chunk and the COMPLETION time
|
|
1315
|
+
// (the moment usage is reported) so the call-level ledger attributes
|
|
1316
|
+
// cost to the hour the request COMPLETED in (the provider billing
|
|
1317
|
+
// basis), not its start hour.
|
|
1318
|
+
return ctx.on("llm/stream", async function* (payload, next) {
|
|
1319
|
+
const startedAt = Date.now();
|
|
1320
|
+
let usage = null;
|
|
1321
|
+
let completedAt = null;
|
|
1322
|
+
try {
|
|
1323
|
+
const status = await limitsService.check(payload ?? {});
|
|
1324
|
+
if (status?.status === "blocked" || status?.blocked === true) throw new UsageLimitExceededError(status);
|
|
1325
|
+
} catch (error) {
|
|
1326
|
+
if (error instanceof UsageLimitExceededError) throw error;
|
|
1327
|
+
ctx.logger?.warn?.("usage-stats: llm/stream quota check failed; allowing call: " + String(error));
|
|
1328
|
+
}
|
|
1329
|
+
try {
|
|
1330
|
+
if (typeof next === "function") {
|
|
1331
|
+
for await (const chunk of next()) {
|
|
1332
|
+
if (chunk !== null && typeof chunk === "object" && chunk.type === "usage" && chunk.usage !== void 0 && chunk.usage !== null) {
|
|
1333
|
+
usage = chunk.usage;
|
|
1334
|
+
// 官方账单按请求完成时间(usage 上报时刻)归小时。
|
|
1335
|
+
completedAt = Date.now();
|
|
1336
|
+
}
|
|
1337
|
+
yield chunk;
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
} finally {
|
|
1341
|
+
const ledgerProvider = payload?.provider ?? payload?.config?.provider;
|
|
1342
|
+
// Facade routes (vision-toolkit-*) delegate to an upstream
|
|
1343
|
+
// provider that issues the real API request; recording both
|
|
1344
|
+
// would double-count one provider bill.
|
|
1345
|
+
if (usage !== null && usage !== void 0 && !isFacadeProvider(ledgerProvider)) {
|
|
1346
|
+
try {
|
|
1347
|
+
const entry = freezeLedgerEntry({
|
|
1348
|
+
id: typeof deps.createLedgerId === "function" ? deps.createLedgerId() : randomUUID(),
|
|
1349
|
+
occurredAt: startedAt,
|
|
1350
|
+
completedAt: completedAt ?? startedAt,
|
|
1351
|
+
provider: ledgerProvider,
|
|
1352
|
+
model: payload?.model ?? payload?.config?.model,
|
|
1353
|
+
usage
|
|
1354
|
+
}, config.pricing);
|
|
1355
|
+
if (typeof deps.recordLedger === "function") await deps.recordLedger(entry);
|
|
1356
|
+
else await recordLedgerEntry(ctx, entry);
|
|
1357
|
+
} catch (error) {
|
|
1358
|
+
ctx.logger?.warn?.("usage-stats: ledger record failed: " + String(error));
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
});
|
|
1363
|
+
}, "usage-stats: quota limit interceptor on llm/stream");
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
if (deps.disableBackgroundRefresh !== true) ctx.effect(() => startBackgroundRefresh(ctx, balanceService, config), "usage-stats: background refresh");
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
export {
|
|
1370
|
+
apply,
|
|
1371
|
+
Config,
|
|
1372
|
+
inject,
|
|
1373
|
+
name,
|
|
1374
|
+
USAGE_PATH,
|
|
1375
|
+
KEYS_PATH,
|
|
1376
|
+
BALANCE_PATH,
|
|
1377
|
+
LIMITS_PATH,
|
|
1378
|
+
roundCost,
|
|
1379
|
+
defaultLimitRule,
|
|
1380
|
+
defaultLimits,
|
|
1381
|
+
validateLimitRule,
|
|
1382
|
+
validateLimits,
|
|
1383
|
+
evaluateKeyQuota,
|
|
1384
|
+
resolveLimitRule,
|
|
1385
|
+
createLimitsService,
|
|
1386
|
+
createAlertTracker,
|
|
1387
|
+
migrateCacheV1,
|
|
1388
|
+
renderCombinedUsage,
|
|
1389
|
+
recordLedgerEntry
|
|
1390
|
+
};
|