@kairyou/agent-tools 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 +156 -0
- package/README.zh-CN.md +149 -0
- package/config.default.jsonc +23 -0
- package/hooks/claude/.gitkeep +1 -0
- package/hooks/codex/.gitkeep +1 -0
- package/hooks/codex/usage-hook.mjs +157 -0
- package/hooks/common/.gitkeep +1 -0
- package/hooks/opencode/.gitkeep +1 -0
- package/lib/usage.mjs +1200 -0
- package/package.json +31 -0
- package/plugins/opencode/usage-plugin.mjs +95 -0
- package/plugins/opencode/usage-tui.mjs +49 -0
- package/scripts/install.mjs +551 -0
- package/skills/workflow/at-commit/SKILL.md +83 -0
- package/skills/workflow/at-review/SKILL.md +89 -0
- package/skills/workflow/at-simplify/SKILL.md +67 -0
- package/statusline/.gitkeep +1 -0
- package/statusline/claude/statusline.mjs +399 -0
- package/statusline/codex/.gitkeep +1 -0
package/lib/usage.mjs
ADDED
|
@@ -0,0 +1,1200 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Agent usage runtime (agent-tools).
|
|
3
|
+
// Reads the active provider, probes known gateway usage endpoints, and prints a
|
|
4
|
+
// compact balance/quota message. Fails open when provider usage cannot be fetched.
|
|
5
|
+
|
|
6
|
+
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
7
|
+
import { existsSync } from "node:fs";
|
|
8
|
+
import { dirname, join } from "node:path";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { createContext, runInContext } from "node:vm";
|
|
11
|
+
import { pathToFileURL } from "node:url";
|
|
12
|
+
|
|
13
|
+
const CODEX_HOME = process.env.CODEX_HOME || join(homedir(), ".codex");
|
|
14
|
+
const AGENT_TOOLS_HOME = process.env.AGENT_TOOLS_HOME || join(homedir(), ".agent-tools");
|
|
15
|
+
const AUTH_PATH = join(CODEX_HOME, "auth.json");
|
|
16
|
+
const CODEX_CONFIG_PATH = join(CODEX_HOME, "config.toml");
|
|
17
|
+
const AGENT_CONFIG_PATH = process.env.AGENT_TOOLS_CONFIG || join(AGENT_TOOLS_HOME, "config.jsonc");
|
|
18
|
+
const DEBUG_PATH = join(AGENT_TOOLS_HOME, "logs", "usage-debug.log");
|
|
19
|
+
const ROUTE_CACHE_PATH = join(AGENT_TOOLS_HOME, "cache", "usage-routes.json");
|
|
20
|
+
const SNAPSHOT_PATH = join(AGENT_TOOLS_HOME, "cache", "usage-snapshot.json");
|
|
21
|
+
const REFRESH_STATE_PATH = join(AGENT_TOOLS_HOME, "cache", "usage-refresh-state.json");
|
|
22
|
+
const REQUEST_TIMEOUT_MS = 5000;
|
|
23
|
+
const DEFAULT_USAGE_DAYS = 30;
|
|
24
|
+
const MAX_USAGE_DAYS = 90;
|
|
25
|
+
const DEFAULT_NEW_API_QUOTA_SCALE = 500000;
|
|
26
|
+
const ROUTE_CACHE_VERSION = 1;
|
|
27
|
+
const SNAPSHOT_VERSION = 1;
|
|
28
|
+
const REFRESH_STATE_VERSION = 1;
|
|
29
|
+
const SHIELD_USER_AGENT =
|
|
30
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
|
|
31
|
+
"(KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36";
|
|
32
|
+
|
|
33
|
+
function parseArgs(argv) {
|
|
34
|
+
const opts = { mode: "hook", agent: "codex" };
|
|
35
|
+
let modeSet = false;
|
|
36
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
37
|
+
const arg = argv[i];
|
|
38
|
+
if (arg === "--agent" && argv[i + 1]) {
|
|
39
|
+
opts.agent = argv[++i];
|
|
40
|
+
} else if (arg.startsWith("--agent=")) {
|
|
41
|
+
opts.agent = arg.slice("--agent=".length);
|
|
42
|
+
} else if (!arg.startsWith("-") && !modeSet) {
|
|
43
|
+
opts.mode = arg;
|
|
44
|
+
modeSet = true;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return opts;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const cli = parseArgs(process.argv.slice(2));
|
|
51
|
+
const mode = cli.mode;
|
|
52
|
+
|
|
53
|
+
async function debugLog(event) {
|
|
54
|
+
const config = await agentConfig();
|
|
55
|
+
if (process.env.PROVIDER_USAGE_DEBUG !== "1" && config.debug !== true) return;
|
|
56
|
+
await mkdir(dirname(DEBUG_PATH), { recursive: true });
|
|
57
|
+
const line = JSON.stringify({
|
|
58
|
+
at: new Date().toISOString(),
|
|
59
|
+
...event,
|
|
60
|
+
});
|
|
61
|
+
await writeFile(DEBUG_PATH, `${line}\n`, { flag: "a" });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function hookOut(message) {
|
|
65
|
+
const payload = { continue: true };
|
|
66
|
+
if (message) payload.systemMessage = message;
|
|
67
|
+
process.stdout.write(`${JSON.stringify(payload)}\n`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function textOut(message) {
|
|
71
|
+
if (message) process.stdout.write(`${message}\n`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function failSoft(message, error) {
|
|
75
|
+
const detail = error?.message ? `: ${error.message}` : "";
|
|
76
|
+
if (mode === "hook") hookOut();
|
|
77
|
+
else if (mode !== "refresh") textOut(`${message}${detail}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function shortPreview(text) {
|
|
81
|
+
return String(text || "")
|
|
82
|
+
.replace(/\s+/g, " ")
|
|
83
|
+
.trim()
|
|
84
|
+
.slice(0, 220);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function isShieldChallenge(contentType, text) {
|
|
88
|
+
const normalizedType = String(contentType || "").toLowerCase();
|
|
89
|
+
return (
|
|
90
|
+
(normalizedType.includes("text/html") && /var\s+arg1\s*=|acw_sc__v2|cdn_sec_tc|<script/i.test(text)) ||
|
|
91
|
+
/var\s+arg1\s*=/.test(text)
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function parseChallengeArg1(html) {
|
|
96
|
+
const match = String(html).match(/var\s+arg1\s*=\s*['"]([0-9a-fA-F]+)['"]/);
|
|
97
|
+
return match?.[1]?.toUpperCase() || "";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function parseChallengeMapping(html) {
|
|
101
|
+
const match = String(html).match(/for\(var m=\[([^\]]+)\],p=L\(0x115\)/);
|
|
102
|
+
if (!match?.[1]) return null;
|
|
103
|
+
const values = match[1].split(",").map((raw) => {
|
|
104
|
+
const value = raw.trim().toLowerCase();
|
|
105
|
+
if (!value) return Number.NaN;
|
|
106
|
+
return value.startsWith("0x") ? Number.parseInt(value.slice(2), 16) : Number.parseInt(value, 10);
|
|
107
|
+
});
|
|
108
|
+
return values.some((value) => Number.isNaN(value)) ? null : values;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function parseChallengeXorSeed(html) {
|
|
112
|
+
const text = String(html);
|
|
113
|
+
const fnStart = text.indexOf("function a0i()");
|
|
114
|
+
const bStart = text.indexOf("function b(");
|
|
115
|
+
const rotateStart = text.indexOf("(function(a,c){");
|
|
116
|
+
const rotateEnd = text.indexOf("),!(function", rotateStart);
|
|
117
|
+
if (fnStart < 0 || bStart < 0 || bStart <= fnStart || rotateStart < 0 || rotateEnd < 0) {
|
|
118
|
+
return "";
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const helperCode = text.slice(fnStart, bStart);
|
|
122
|
+
const rotateCode = `${text.slice(rotateStart, rotateEnd + 1)})`;
|
|
123
|
+
try {
|
|
124
|
+
const sandbox = { decodeURIComponent };
|
|
125
|
+
createContext(sandbox);
|
|
126
|
+
runInContext(helperCode, sandbox, { timeout: 100 });
|
|
127
|
+
runInContext(rotateCode, sandbox, { timeout: 100 });
|
|
128
|
+
const decoder = sandbox.a0j;
|
|
129
|
+
if (typeof decoder !== "function") return "";
|
|
130
|
+
const seed = decoder(0x115);
|
|
131
|
+
return typeof seed === "string" && /^[0-9a-f]+$/i.test(seed) ? seed : "";
|
|
132
|
+
} catch {
|
|
133
|
+
return "";
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function solveNewApiAcwScV2(html) {
|
|
138
|
+
const arg1 = parseChallengeArg1(html);
|
|
139
|
+
const mapping = parseChallengeMapping(html);
|
|
140
|
+
const xorSeed = parseChallengeXorSeed(html);
|
|
141
|
+
if (!arg1 || !mapping || !xorSeed) return "";
|
|
142
|
+
|
|
143
|
+
const reordered = [];
|
|
144
|
+
for (let i = 0; i < arg1.length; i += 1) {
|
|
145
|
+
const ch = arg1[i];
|
|
146
|
+
for (let j = 0; j < mapping.length; j += 1) {
|
|
147
|
+
if (mapping[j] === i + 1) reordered[j] = ch;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const source = reordered.join("");
|
|
152
|
+
let out = "";
|
|
153
|
+
for (let i = 0; i < source.length && i < xorSeed.length; i += 2) {
|
|
154
|
+
const left = Number.parseInt(source.slice(i, i + 2), 16);
|
|
155
|
+
const right = Number.parseInt(xorSeed.slice(i, i + 2), 16);
|
|
156
|
+
if (Number.isNaN(left) || Number.isNaN(right)) return "";
|
|
157
|
+
out += (left ^ right).toString(16).padStart(2, "0");
|
|
158
|
+
}
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function upsertCookie(cookieHeader, name, value) {
|
|
163
|
+
const parts = String(cookieHeader || "").split(";").map((part) => part.trim()).filter(Boolean);
|
|
164
|
+
let replaced = false;
|
|
165
|
+
const next = parts.map((part) => {
|
|
166
|
+
const eq = part.indexOf("=");
|
|
167
|
+
if (eq < 0) return part;
|
|
168
|
+
const key = part.slice(0, eq).trim();
|
|
169
|
+
if (key !== name) return part;
|
|
170
|
+
replaced = true;
|
|
171
|
+
return `${name}=${value}`;
|
|
172
|
+
});
|
|
173
|
+
if (!replaced) next.push(`${name}=${value}`);
|
|
174
|
+
return next.join("; ");
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function collectSetCookieHeaders(headers) {
|
|
178
|
+
const getSetCookie = headers?.getSetCookie;
|
|
179
|
+
if (typeof getSetCookie === "function") return getSetCookie.call(headers) || [];
|
|
180
|
+
const single = headers?.get?.("set-cookie");
|
|
181
|
+
return single ? [single] : [];
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function mergeSetCookiePairs(cookieHeader, setCookieHeaders) {
|
|
185
|
+
let merged = cookieHeader || "";
|
|
186
|
+
for (const raw of setCookieHeaders || []) {
|
|
187
|
+
const firstPair = String(raw || "").split(";")[0]?.trim();
|
|
188
|
+
if (!firstPair) continue;
|
|
189
|
+
const eq = firstPair.indexOf("=");
|
|
190
|
+
if (eq <= 0) continue;
|
|
191
|
+
merged = upsertCookie(merged, firstPair.slice(0, eq).trim(), firstPair.slice(eq + 1));
|
|
192
|
+
}
|
|
193
|
+
return merged;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function readJson(path) {
|
|
197
|
+
return JSON.parse(await readFile(path, "utf8"));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function readTextIfExists(path) {
|
|
201
|
+
if (!existsSync(path)) return "";
|
|
202
|
+
return readFile(path, "utf8");
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function stripJsonComments(input) {
|
|
206
|
+
let out = "";
|
|
207
|
+
let inString = false;
|
|
208
|
+
let escaped = false;
|
|
209
|
+
for (let i = 0; i < input.length; i += 1) {
|
|
210
|
+
const ch = input[i];
|
|
211
|
+
const next = input[i + 1];
|
|
212
|
+
if (inString) {
|
|
213
|
+
out += ch;
|
|
214
|
+
escaped = ch === "\\" ? !escaped : false;
|
|
215
|
+
if (ch === "\"" && !escaped) inString = false;
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
if (ch === "\"") {
|
|
219
|
+
inString = true;
|
|
220
|
+
out += ch;
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
if (ch === "/" && next === "/") {
|
|
224
|
+
while (i < input.length && input[i] !== "\n") i += 1;
|
|
225
|
+
out += "\n";
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
if (ch === "/" && next === "*") {
|
|
229
|
+
i += 2;
|
|
230
|
+
while (i < input.length && !(input[i] === "*" && input[i + 1] === "/")) i += 1;
|
|
231
|
+
i += 1;
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
out += ch;
|
|
235
|
+
}
|
|
236
|
+
return out;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
let agentConfigCache;
|
|
240
|
+
async function agentConfig() {
|
|
241
|
+
if (agentConfigCache) return agentConfigCache;
|
|
242
|
+
try {
|
|
243
|
+
const raw = await readTextIfExists(AGENT_CONFIG_PATH);
|
|
244
|
+
if (!raw.trim()) {
|
|
245
|
+
agentConfigCache = {};
|
|
246
|
+
return agentConfigCache;
|
|
247
|
+
}
|
|
248
|
+
const parsed = JSON.parse(stripJsonComments(raw.replace(/^\uFEFF/, "")));
|
|
249
|
+
agentConfigCache = parsed.providerUsage || {};
|
|
250
|
+
} catch {
|
|
251
|
+
agentConfigCache = {};
|
|
252
|
+
}
|
|
253
|
+
return agentConfigCache;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function stripInlineComment(value) {
|
|
257
|
+
let inSingle = false;
|
|
258
|
+
let inDouble = false;
|
|
259
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
260
|
+
const char = value[i];
|
|
261
|
+
const prev = value[i - 1];
|
|
262
|
+
if (char === "'" && !inDouble) inSingle = !inSingle;
|
|
263
|
+
if (char === '"' && !inSingle && prev !== "\\") inDouble = !inDouble;
|
|
264
|
+
if (char === "#" && !inSingle && !inDouble) return value.slice(0, i).trim();
|
|
265
|
+
}
|
|
266
|
+
return value.trim();
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function parseTomlLite(source) {
|
|
270
|
+
const root = {};
|
|
271
|
+
let current = root;
|
|
272
|
+
for (const rawLine of source.split(/\r?\n/)) {
|
|
273
|
+
const line = rawLine.trim();
|
|
274
|
+
if (!line || line.startsWith("#")) continue;
|
|
275
|
+
|
|
276
|
+
const table = line.match(/^\[([^\]]+)\]$/);
|
|
277
|
+
if (table) {
|
|
278
|
+
current = root;
|
|
279
|
+
for (const part of table[1].split(".")) {
|
|
280
|
+
const key = part.replace(/^['"]|['"]$/g, "");
|
|
281
|
+
current[key] ||= {};
|
|
282
|
+
current = current[key];
|
|
283
|
+
}
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const eq = line.indexOf("=");
|
|
288
|
+
if (eq === -1) continue;
|
|
289
|
+
const key = line.slice(0, eq).trim();
|
|
290
|
+
const rawValue = stripInlineComment(line.slice(eq + 1));
|
|
291
|
+
current[key] = parseTomlValue(rawValue);
|
|
292
|
+
}
|
|
293
|
+
return root;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function parseTomlValue(value) {
|
|
297
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
298
|
+
return value.slice(1, -1);
|
|
299
|
+
}
|
|
300
|
+
if (value === "true") return true;
|
|
301
|
+
if (value === "false") return false;
|
|
302
|
+
return value;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function activeProvider(config) {
|
|
306
|
+
const providerName = config.model_provider || "openai";
|
|
307
|
+
const provider = config.model_providers?.[providerName] || {};
|
|
308
|
+
return { providerName, provider };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function isOfficialBaseUrl(baseUrl) {
|
|
312
|
+
if (!baseUrl) return true;
|
|
313
|
+
const clean = baseUrl.replace(/\/+$/, "");
|
|
314
|
+
return [
|
|
315
|
+
"https://api.openai.com",
|
|
316
|
+
"https://api.openai.com/v1",
|
|
317
|
+
"https://api.anthropic.com",
|
|
318
|
+
"https://api.anthropic.com/v1",
|
|
319
|
+
].includes(clean);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function cleanBaseUrl(baseUrl) {
|
|
323
|
+
return String(baseUrl || "").replace(/\/+$/, "");
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function serviceRoot(baseUrl) {
|
|
327
|
+
const clean = cleanBaseUrl(baseUrl);
|
|
328
|
+
return clean.endsWith("/v1") ? clean.slice(0, -3) : clean;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function usageRouteCacheKey(baseUrl) {
|
|
332
|
+
try {
|
|
333
|
+
const url = new URL(cleanBaseUrl(baseUrl));
|
|
334
|
+
url.hash = "";
|
|
335
|
+
url.search = "";
|
|
336
|
+
url.pathname = url.pathname
|
|
337
|
+
.replace(/\/+$/, "")
|
|
338
|
+
.replace(/\/api\/v1$/i, "")
|
|
339
|
+
.replace(/\/v1$/i, "");
|
|
340
|
+
return url.toString().replace(/\/$/, "");
|
|
341
|
+
} catch {
|
|
342
|
+
return serviceRoot(baseUrl);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function joinUrl(baseUrl, path) {
|
|
347
|
+
return `${cleanBaseUrl(baseUrl)}${path.startsWith("/") ? path : `/${path}`}`;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function hostIncludes(baseUrl, value) {
|
|
351
|
+
try {
|
|
352
|
+
return new URL(baseUrl).hostname.toLowerCase().includes(value);
|
|
353
|
+
} catch {
|
|
354
|
+
return false;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
async function providerUsageDays() {
|
|
359
|
+
const config = await agentConfig();
|
|
360
|
+
const value = Number(process.env.PROVIDER_USAGE_DAYS || config.days || DEFAULT_USAGE_DAYS);
|
|
361
|
+
if (!Number.isInteger(value) || value <= 0 || value > MAX_USAGE_DAYS) return DEFAULT_USAGE_DAYS;
|
|
362
|
+
return value;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
async function subscriptionUrl(baseUrl) {
|
|
366
|
+
const clean = baseUrl.replace(/\/+$/, "");
|
|
367
|
+
const url = clean.endsWith("/v1") ? `${clean}/usage` : `${clean}/v1/usage`;
|
|
368
|
+
return `${url}?days=${await providerUsageDays()}`;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
async function usagePreset() {
|
|
372
|
+
const config = await agentConfig();
|
|
373
|
+
return String(process.env.PROVIDER_USAGE_PRESET || config.preset || "auto").toLowerCase();
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async function panelUserId() {
|
|
377
|
+
const config = await agentConfig();
|
|
378
|
+
const raw = process.env.PROVIDER_USAGE_USER_ID || config.userId || "";
|
|
379
|
+
const parsed = Number.parseInt(String(raw), 10);
|
|
380
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
async function panelUserHeaders() {
|
|
384
|
+
const userId = await panelUserId();
|
|
385
|
+
if (!userId) return {};
|
|
386
|
+
const value = String(userId);
|
|
387
|
+
return {
|
|
388
|
+
"New-API-User": value,
|
|
389
|
+
"Veloera-User": value,
|
|
390
|
+
"voapi-user": value,
|
|
391
|
+
"User-id": value,
|
|
392
|
+
"X-User-Id": value,
|
|
393
|
+
"Rix-Api-User": value,
|
|
394
|
+
"neo-api-user": value,
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
async function requestJson(url, key, options = {}) {
|
|
399
|
+
let cookieHeader = "";
|
|
400
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
401
|
+
const controller = new AbortController();
|
|
402
|
+
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
403
|
+
const response = await fetch(url, {
|
|
404
|
+
headers: {
|
|
405
|
+
accept: "application/json",
|
|
406
|
+
authorization: `Bearer ${options.authKey || key}`,
|
|
407
|
+
"user-agent": SHIELD_USER_AGENT,
|
|
408
|
+
...(cookieHeader ? { cookie: cookieHeader } : {}),
|
|
409
|
+
...(options.headers || {}),
|
|
410
|
+
},
|
|
411
|
+
signal: controller.signal,
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
try {
|
|
415
|
+
const body = await response.text();
|
|
416
|
+
cookieHeader = mergeSetCookiePairs(cookieHeader, collectSetCookieHeaders(response.headers));
|
|
417
|
+
let json = {};
|
|
418
|
+
try {
|
|
419
|
+
json = body ? JSON.parse(body) : {};
|
|
420
|
+
} catch {
|
|
421
|
+
const contentType = response.headers.get("content-type") || "";
|
|
422
|
+
const acwScV2 = isShieldChallenge(contentType, body) ? solveNewApiAcwScV2(body) : "";
|
|
423
|
+
await debugLog({
|
|
424
|
+
source: options.name || "usage",
|
|
425
|
+
url,
|
|
426
|
+
status: response.status,
|
|
427
|
+
contentType,
|
|
428
|
+
shieldRetry: Boolean(acwScV2 && attempt === 0),
|
|
429
|
+
bodyPreview: shortPreview(body),
|
|
430
|
+
});
|
|
431
|
+
if (acwScV2 && attempt === 0) {
|
|
432
|
+
cookieHeader = upsertCookie(cookieHeader, "acw_sc__v2", acwScV2);
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
throw new Error(`${options.name || "usage"} returned non-JSON (${response.status})`);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
if (!response.ok) {
|
|
439
|
+
const message = json?.error?.message || json?.message || response.statusText;
|
|
440
|
+
await debugLog({
|
|
441
|
+
source: options.name || "usage",
|
|
442
|
+
url,
|
|
443
|
+
status: response.status,
|
|
444
|
+
message,
|
|
445
|
+
bodyPreview: shortPreview(body),
|
|
446
|
+
});
|
|
447
|
+
throw new Error(`${options.name || "usage"} failed (${response.status} ${message})`);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
return json;
|
|
451
|
+
} finally {
|
|
452
|
+
clearTimeout(timeout);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
throw new Error(`${options.name || "usage"} unavailable`);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
async function readRouteCache() {
|
|
459
|
+
try {
|
|
460
|
+
const raw = await readTextIfExists(ROUTE_CACHE_PATH);
|
|
461
|
+
if (!raw.trim()) return { version: ROUTE_CACHE_VERSION, routes: {} };
|
|
462
|
+
const parsed = JSON.parse(raw);
|
|
463
|
+
return {
|
|
464
|
+
version: ROUTE_CACHE_VERSION,
|
|
465
|
+
routes: parsed?.routes && typeof parsed.routes === "object" ? parsed.routes : {},
|
|
466
|
+
};
|
|
467
|
+
} catch {
|
|
468
|
+
return { version: ROUTE_CACHE_VERSION, routes: {} };
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
async function cachedUsageRoute(context) {
|
|
473
|
+
const cache = await readRouteCache();
|
|
474
|
+
const key = usageRouteCacheKey(context.baseUrl);
|
|
475
|
+
const route = cache.routes[key];
|
|
476
|
+
return route?.route && USAGE_ROUTES[route.route] ? route : null;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
async function rememberUsageRoute(context, route, result) {
|
|
480
|
+
try {
|
|
481
|
+
const cache = await readRouteCache();
|
|
482
|
+
const key = usageRouteCacheKey(context.baseUrl);
|
|
483
|
+
cache.routes[key] = {
|
|
484
|
+
route: route.id,
|
|
485
|
+
path: route.path,
|
|
486
|
+
source: result.source,
|
|
487
|
+
updatedAt: new Date().toISOString(),
|
|
488
|
+
};
|
|
489
|
+
await mkdir(dirname(ROUTE_CACHE_PATH), { recursive: true });
|
|
490
|
+
await writeFile(ROUTE_CACHE_PATH, `${JSON.stringify(cache, null, 2)}\n`);
|
|
491
|
+
} catch (error) {
|
|
492
|
+
await debugLog({ source: "route-cache", error: error.message });
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
async function readSnapshotCache() {
|
|
497
|
+
try {
|
|
498
|
+
const raw = await readTextIfExists(SNAPSHOT_PATH);
|
|
499
|
+
if (!raw.trim()) return { version: SNAPSHOT_VERSION, items: {} };
|
|
500
|
+
const parsed = JSON.parse(raw);
|
|
501
|
+
return {
|
|
502
|
+
version: SNAPSHOT_VERSION,
|
|
503
|
+
items: parsed?.items && typeof parsed.items === "object" ? parsed.items : {},
|
|
504
|
+
};
|
|
505
|
+
} catch {
|
|
506
|
+
return { version: SNAPSHOT_VERSION, items: {} };
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
async function rememberUsageSnapshot(context, result) {
|
|
511
|
+
if (!result?.text) return;
|
|
512
|
+
try {
|
|
513
|
+
const cache = await readSnapshotCache();
|
|
514
|
+
const key = usageRouteCacheKey(context.baseUrl);
|
|
515
|
+
cache.items[key] = {
|
|
516
|
+
text: result.text,
|
|
517
|
+
source: result.source,
|
|
518
|
+
baseUrl: context.baseUrl,
|
|
519
|
+
updatedAt: new Date().toISOString(),
|
|
520
|
+
};
|
|
521
|
+
await mkdir(dirname(SNAPSHOT_PATH), { recursive: true });
|
|
522
|
+
await writeFile(SNAPSHOT_PATH, `${JSON.stringify(cache, null, 2)}\n`);
|
|
523
|
+
} catch (error) {
|
|
524
|
+
await debugLog({ source: "snapshot-cache", error: error.message });
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
async function readRefreshState() {
|
|
529
|
+
try {
|
|
530
|
+
const raw = await readTextIfExists(REFRESH_STATE_PATH);
|
|
531
|
+
if (!raw.trim()) return { version: REFRESH_STATE_VERSION, items: {} };
|
|
532
|
+
const parsed = JSON.parse(raw);
|
|
533
|
+
return {
|
|
534
|
+
version: REFRESH_STATE_VERSION,
|
|
535
|
+
items: parsed?.items && typeof parsed.items === "object" ? parsed.items : {},
|
|
536
|
+
};
|
|
537
|
+
} catch {
|
|
538
|
+
return { version: REFRESH_STATE_VERSION, items: {} };
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
async function rememberRefreshState(context, patch) {
|
|
543
|
+
try {
|
|
544
|
+
const state = await readRefreshState();
|
|
545
|
+
const key = usageRouteCacheKey(context.baseUrl);
|
|
546
|
+
state.items[key] = {
|
|
547
|
+
...(state.items[key] || {}),
|
|
548
|
+
...patch,
|
|
549
|
+
baseUrl: context.baseUrl,
|
|
550
|
+
};
|
|
551
|
+
await mkdir(dirname(REFRESH_STATE_PATH), { recursive: true });
|
|
552
|
+
await writeFile(REFRESH_STATE_PATH, `${JSON.stringify(state, null, 2)}\n`);
|
|
553
|
+
} catch (error) {
|
|
554
|
+
await debugLog({ source: "refresh-state", error: error.message });
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function apiKeyFor(auth, provider) {
|
|
559
|
+
if (process.env.PROVIDER_USAGE_API_KEY) return process.env.PROVIDER_USAGE_API_KEY;
|
|
560
|
+
if (process.env.SUB2API_API_KEY) return process.env.SUB2API_API_KEY;
|
|
561
|
+
if (provider.env_key && process.env[provider.env_key]) return process.env[provider.env_key];
|
|
562
|
+
if (auth.OPENAI_API_KEY) return auth.OPENAI_API_KEY;
|
|
563
|
+
if (process.env.OPENAI_API_KEY) return process.env.OPENAI_API_KEY;
|
|
564
|
+
return "";
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function apiKeyForClaude() {
|
|
568
|
+
return (
|
|
569
|
+
process.env.PROVIDER_USAGE_API_KEY ||
|
|
570
|
+
process.env.ANTHROPIC_AUTH_TOKEN ||
|
|
571
|
+
process.env.ANTHROPIC_API_KEY ||
|
|
572
|
+
""
|
|
573
|
+
);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function providerLabel(providerName, provider) {
|
|
577
|
+
return String(provider.name || providerName || "API").toUpperCase();
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function pickNumber(obj, keys) {
|
|
581
|
+
for (const key of keys) {
|
|
582
|
+
const value = obj?.[key];
|
|
583
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
584
|
+
if (typeof value === "string" && value.trim() && Number.isFinite(Number(value))) return Number(value);
|
|
585
|
+
}
|
|
586
|
+
return undefined;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function formatMoney(value) {
|
|
590
|
+
return `$${value.toFixed(value >= 100 ? 0 : 1)}`;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function formatMaybeMoney(value, unit = "USD") {
|
|
594
|
+
if (unit === "USD" || unit === "$") return formatMoney(value);
|
|
595
|
+
return `${value.toLocaleString("en-US", { maximumFractionDigits: 1 })} ${unit}`;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function usageParts() {
|
|
599
|
+
return ["API"];
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
async function formatNewApiQuota(value) {
|
|
603
|
+
const scale = await newApiQuotaScale();
|
|
604
|
+
if (Number.isFinite(scale) && scale > 0) return formatMoney(value / scale);
|
|
605
|
+
return value.toLocaleString("en-US", { maximumFractionDigits: 0 });
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
async function newApiQuotaScale() {
|
|
609
|
+
const config = await agentConfig();
|
|
610
|
+
const scale = Number(config.newApiQuotaScale || DEFAULT_NEW_API_QUOTA_SCALE);
|
|
611
|
+
return Number.isFinite(scale) && scale > 0 ? scale : 0;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function usageRoot(data) {
|
|
615
|
+
return data?.data && typeof data.data === "object" ? data.data : data;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function shortDate(value) {
|
|
619
|
+
if (!value) return "";
|
|
620
|
+
const match = String(value).match(/^(\d{4})-(\d{2})-(\d{2})/);
|
|
621
|
+
return match ? `${match[2]}-${match[3]}` : "";
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function hasSubscriptionLimits(root) {
|
|
625
|
+
const sub = root?.subscription || {};
|
|
626
|
+
return [
|
|
627
|
+
"daily_limit_usd",
|
|
628
|
+
"weekly_limit_usd",
|
|
629
|
+
"monthly_limit_usd",
|
|
630
|
+
"daily_usage_usd",
|
|
631
|
+
"weekly_usage_usd",
|
|
632
|
+
"monthly_usage_usd",
|
|
633
|
+
].some((key) => pickNumber(sub, [key]) !== undefined);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
function isQuotaLimitedUsage(root) {
|
|
637
|
+
return root?.mode === "quota_limited" || root?.quota;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function isSubscriptionUsage(root) {
|
|
641
|
+
return hasSubscriptionLimits(root) || (root?.mode === "unrestricted" && root?.subscription);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function isWalletUsage(root) {
|
|
645
|
+
const planName = String(root?.planName || "");
|
|
646
|
+
return (
|
|
647
|
+
(root?.mode === "unrestricted" && !isSubscriptionUsage(root)) ||
|
|
648
|
+
planName.includes("钱包") ||
|
|
649
|
+
planName.toLowerCase().includes("wallet") ||
|
|
650
|
+
(pickNumber(root, ["balance"]) !== undefined && !hasSubscriptionLimits(root))
|
|
651
|
+
);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function hasV1UsageFields(root) {
|
|
655
|
+
return (
|
|
656
|
+
hasSubscriptionLimits(root) ||
|
|
657
|
+
pickNumber(root, [
|
|
658
|
+
"balance",
|
|
659
|
+
"remaining",
|
|
660
|
+
"remain",
|
|
661
|
+
"available",
|
|
662
|
+
"hard_limit_usd",
|
|
663
|
+
"hard_limit",
|
|
664
|
+
"total_granted",
|
|
665
|
+
"quota",
|
|
666
|
+
"total_usage",
|
|
667
|
+
"used",
|
|
668
|
+
"usage",
|
|
669
|
+
]) !== undefined ||
|
|
670
|
+
pickNumber(root?.quota, ["limit", "quota", "used", "quota_used", "remaining"]) !== undefined ||
|
|
671
|
+
pickNumber(root?.usage?.today, ["actual_cost", "cost"]) !== undefined ||
|
|
672
|
+
(Array.isArray(root?.daily_usage) && root.daily_usage.length > 0)
|
|
673
|
+
);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
async function formatQuota(label, data) {
|
|
677
|
+
const root = usageRoot(data);
|
|
678
|
+
const unit = root?.unit || "USD";
|
|
679
|
+
const remaining = pickNumber(root, ["remaining"]);
|
|
680
|
+
const hardLimit = pickNumber(root, ["hard_limit_usd", "hard_limit", "total_granted", "quota"]);
|
|
681
|
+
const used = pickNumber(root, ["total_usage", "used", "usage"]);
|
|
682
|
+
const balance = pickNumber(root, ["balance", "remaining", "remain", "available"]);
|
|
683
|
+
|
|
684
|
+
if (isQuotaLimitedUsage(root)) return formatQuotaLimitedLine(label, root);
|
|
685
|
+
if (isSubscriptionUsage(root)) return formatUsageLine(label, root);
|
|
686
|
+
if (isWalletUsage(root)) return await formatWalletLine(label, root);
|
|
687
|
+
if (remaining !== undefined) return formatUsageLine(label, root);
|
|
688
|
+
if (balance !== undefined) return `API | balance ${formatMaybeMoney(balance, unit)}`;
|
|
689
|
+
if (hardLimit !== undefined && used !== undefined) {
|
|
690
|
+
return `API | remaining ${formatMaybeMoney(Math.max(0, hardLimit - used), unit)}`;
|
|
691
|
+
}
|
|
692
|
+
if (hardLimit !== undefined) return `API | total ${formatMaybeMoney(hardLimit, unit)}`;
|
|
693
|
+
|
|
694
|
+
const keys = Object.keys(root || {}).slice(0, 4).join(", ");
|
|
695
|
+
return keys ? `API | received (${keys})` : `API | checked ${unit}`;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
async function formatNewApiTokenLine(label, data) {
|
|
699
|
+
const root = usageRoot(data);
|
|
700
|
+
const unlimited = root?.unlimited_quota === true || root?.unlimitedQuota === true;
|
|
701
|
+
const quota = pickNumber(root, ["quota", "limit", "total_quota", "totalQuota"]);
|
|
702
|
+
const used = pickNumber(root, ["used_quota", "usedQuota", "used"]);
|
|
703
|
+
let remaining = pickNumber(root, ["remain_quota", "remainQuota", "remaining", "balance"]);
|
|
704
|
+
if (remaining === undefined && quota !== undefined && used !== undefined) {
|
|
705
|
+
remaining = Math.max(0, quota - used);
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
if (!unlimited && quota === undefined && used === undefined && remaining === undefined) {
|
|
709
|
+
throw new Error("NewAPI token usage payload has no quota fields");
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
const parts = usageParts();
|
|
713
|
+
if (unlimited) parts.push("unlimited");
|
|
714
|
+
if (remaining !== undefined) parts.push(`balance ${await formatNewApiQuota(remaining)}`);
|
|
715
|
+
if (used !== undefined && quota !== undefined) {
|
|
716
|
+
parts.push(`used ${await formatNewApiQuota(used)}/${await formatNewApiQuota(quota)}`);
|
|
717
|
+
} else if (used !== undefined) {
|
|
718
|
+
parts.push(`used ${await formatNewApiQuota(used)}`);
|
|
719
|
+
}
|
|
720
|
+
return parts.join(" | ");
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
function formatOpenRouterLine(label, data) {
|
|
724
|
+
const root = usageRoot(data);
|
|
725
|
+
const limit = pickNumber(root, ["limit", "limit_remaining", "total_credits"]);
|
|
726
|
+
const remaining = pickNumber(root, ["limit_remaining", "remaining_credits"]);
|
|
727
|
+
const used = pickNumber(root, ["usage", "total_usage", "spend"]);
|
|
728
|
+
const reset = root?.limit_reset || root?.reset_at ? shortDate(root.limit_reset || root.reset_at) : "";
|
|
729
|
+
const parts = usageParts();
|
|
730
|
+
|
|
731
|
+
if (remaining !== undefined) parts.push(`balance ${formatMoney(remaining)}`);
|
|
732
|
+
if (used !== undefined && limit !== undefined && limit !== remaining) {
|
|
733
|
+
parts.push(`used ${formatMoney(used)}/${formatMoney(limit)}`);
|
|
734
|
+
} else if (used !== undefined) {
|
|
735
|
+
parts.push(`used ${formatMoney(used)}`);
|
|
736
|
+
}
|
|
737
|
+
if (reset) parts.push(`Reset ${reset}`);
|
|
738
|
+
|
|
739
|
+
if (parts.length === 1) throw new Error("OpenRouter payload has no usage fields");
|
|
740
|
+
return parts.join(" | ");
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
function panelQuotaScale(kind) {
|
|
744
|
+
return kind === "veloera" ? 1000000 : DEFAULT_NEW_API_QUOTA_SCALE;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function panelQuotaLooksRemaining(kind) {
|
|
748
|
+
return ["new-api", "anyrouter", "agentrouter", "done-hub", "donehub"].includes(kind);
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
async function formatPanelUserSelfLine(label, data, kind) {
|
|
752
|
+
const root = usageRoot(data);
|
|
753
|
+
const scale = panelQuotaScale(kind);
|
|
754
|
+
const quota = pickNumber(root, ["quota"]);
|
|
755
|
+
const used = pickNumber(root, ["used_quota", "usedQuota"]);
|
|
756
|
+
const todayIncome = pickNumber(root, ["today_income", "todayIncome"]);
|
|
757
|
+
const todayUsed = pickNumber(root, ["today_quota_consumption", "todayQuotaConsumption"]);
|
|
758
|
+
|
|
759
|
+
if (quota === undefined && used === undefined) {
|
|
760
|
+
throw new Error("panel /api/user/self payload has no quota fields");
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
const quotaUsd = quota === undefined ? undefined : quota / scale;
|
|
764
|
+
const usedUsd = used === undefined ? undefined : used / scale;
|
|
765
|
+
const remainingUsd = panelQuotaLooksRemaining(kind)
|
|
766
|
+
? quotaUsd
|
|
767
|
+
: (quotaUsd === undefined || usedUsd === undefined ? quotaUsd : Math.max(0, quotaUsd - usedUsd));
|
|
768
|
+
const totalUsd = panelQuotaLooksRemaining(kind)
|
|
769
|
+
? (quotaUsd === undefined || usedUsd === undefined ? quotaUsd : quotaUsd + usedUsd)
|
|
770
|
+
: quotaUsd;
|
|
771
|
+
|
|
772
|
+
const parts = usageParts();
|
|
773
|
+
if (remainingUsd !== undefined) parts.push(`balance ${formatMoney(remainingUsd)}`);
|
|
774
|
+
if (usedUsd !== undefined && totalUsd !== undefined) {
|
|
775
|
+
parts.push(`used ${formatMoney(usedUsd)}/${formatMoney(totalUsd)}`);
|
|
776
|
+
} else if (usedUsd !== undefined) {
|
|
777
|
+
parts.push(`used ${formatMoney(usedUsd)}`);
|
|
778
|
+
}
|
|
779
|
+
if (todayUsed !== undefined) parts.push(`today ${formatMoney(todayUsed / scale)}`);
|
|
780
|
+
if (todayIncome !== undefined) parts.push(`income ${formatMoney(todayIncome / scale)}`);
|
|
781
|
+
return parts.join(" | ");
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
function formatQuotaLimitedLine(label, root) {
|
|
785
|
+
const quota = root?.quota || {};
|
|
786
|
+
const limit = pickNumber(quota, ["limit", "quota"]);
|
|
787
|
+
const used = pickNumber(quota, ["used", "quota_used"]);
|
|
788
|
+
const remaining = pickNumber(quota, ["remaining"]) ?? pickNumber(root, ["remaining"]);
|
|
789
|
+
const parts = usageParts();
|
|
790
|
+
|
|
791
|
+
if (limit !== undefined && used !== undefined) {
|
|
792
|
+
parts.push(`Q ${formatMoney(used)}/${formatMoney(limit)}`);
|
|
793
|
+
} else if (remaining !== undefined) {
|
|
794
|
+
parts.push(`remaining ${formatMoney(remaining)}`);
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
if (Array.isArray(root?.rate_limits) && root.rate_limits.length > 0) {
|
|
798
|
+
const rateParts = root.rate_limits
|
|
799
|
+
.map((entry) => {
|
|
800
|
+
const window = entry?.window;
|
|
801
|
+
const rateLimit = pickNumber(entry, ["limit"]);
|
|
802
|
+
const rateUsed = pickNumber(entry, ["used"]);
|
|
803
|
+
return window && rateLimit !== undefined && rateUsed !== undefined
|
|
804
|
+
? `${window} ${formatMoney(rateUsed)}/${formatMoney(rateLimit)}`
|
|
805
|
+
: "";
|
|
806
|
+
})
|
|
807
|
+
.filter(Boolean);
|
|
808
|
+
if (rateParts.length > 0) parts.push(rateParts.join(", "));
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
return parts.join(" | ");
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
async function formatWalletLine(label, root) {
|
|
815
|
+
const balance = pickNumber(root, ["balance", "remaining", "remain", "available"]);
|
|
816
|
+
const todayCost = pickNumber(root?.usage?.today, ["actual_cost", "cost"]);
|
|
817
|
+
const recentUsage = Array.isArray(root?.daily_usage)
|
|
818
|
+
? root.daily_usage.reduce((sum, day) => sum + (pickNumber(day, ["actual_cost", "cost"]) || 0), 0)
|
|
819
|
+
: undefined;
|
|
820
|
+
|
|
821
|
+
const parts = usageParts();
|
|
822
|
+
if (balance !== undefined) parts.push(`balance ${formatMoney(balance)}`);
|
|
823
|
+
if (todayCost !== undefined) parts.push(`today ${formatMoney(todayCost)}`);
|
|
824
|
+
if (recentUsage !== undefined && root.daily_usage.length > 0) {
|
|
825
|
+
parts.push(`${await providerUsageDays()}d ${formatMoney(recentUsage)}`);
|
|
826
|
+
}
|
|
827
|
+
return parts.join(" | ");
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
function formatUsageLine(label, root) {
|
|
831
|
+
const sub = root?.subscription || {};
|
|
832
|
+
const dailyLimit = pickNumber(sub, ["daily_limit_usd"]);
|
|
833
|
+
const dailyUsage = pickNumber(sub, ["daily_usage_usd"]);
|
|
834
|
+
const weeklyLimit = pickNumber(sub, ["weekly_limit_usd"]);
|
|
835
|
+
const weeklyUsage = pickNumber(sub, ["weekly_usage_usd"]);
|
|
836
|
+
const monthlyLimit = pickNumber(sub, ["monthly_limit_usd"]);
|
|
837
|
+
const monthlyUsage = pickNumber(sub, ["monthly_usage_usd"]);
|
|
838
|
+
const expires = shortDate(sub.expires_at);
|
|
839
|
+
|
|
840
|
+
const parts = usageParts();
|
|
841
|
+
if (dailyLimit > 0 && dailyUsage !== undefined) parts.push(`D ${formatMoney(dailyUsage)}/${formatMoney(dailyLimit)}`);
|
|
842
|
+
if (weeklyLimit > 0 && weeklyUsage !== undefined) parts.push(`W ${formatMoney(weeklyUsage)}/${formatMoney(weeklyLimit)}`);
|
|
843
|
+
if (monthlyLimit > 0 && monthlyUsage !== undefined) parts.push(`M ${formatMoney(monthlyUsage)}/${formatMoney(monthlyLimit)}`);
|
|
844
|
+
if (expires) parts.push(`Exp ${expires}`);
|
|
845
|
+
return parts.join(" | ");
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// Sub2API and several private OpenAI-compatible gateways expose a lightweight
|
|
849
|
+
// OpenAI-style endpoint at /v1/usage. This is intentionally probed first for
|
|
850
|
+
// generic non-OpenAI base URLs because it does not require a management token.
|
|
851
|
+
async function fetchV1Usage(context) {
|
|
852
|
+
const json = await requestJson(await subscriptionUrl(context.baseUrl), context.key, {
|
|
853
|
+
name: "v1 usage",
|
|
854
|
+
});
|
|
855
|
+
if (!hasV1UsageFields(usageRoot(json))) throw new Error("v1 usage payload has no usage fields");
|
|
856
|
+
return usageResult(context, "v1-usage", await formatQuota(context.label, json), json);
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
// NewAPI / OneAPI family, including AnyRouter/AgentRouter-style deployments:
|
|
860
|
+
// use the current API key as Bearer auth and query token usage from the service
|
|
861
|
+
// root rather than the /v1 OpenAI-compatible path.
|
|
862
|
+
async function fetchNewApiTokenUsage(context) {
|
|
863
|
+
const json = await requestJson(joinUrl(serviceRoot(context.baseUrl), "/api/usage/token/"), context.key, {
|
|
864
|
+
name: "NewAPI token usage",
|
|
865
|
+
});
|
|
866
|
+
const root = usageRoot(json);
|
|
867
|
+
const quota = pickNumber(root, ["quota", "limit", "total_quota", "totalQuota"]);
|
|
868
|
+
const used = pickNumber(root, ["used_quota", "usedQuota", "used"]);
|
|
869
|
+
let remaining = pickNumber(root, ["remain_quota", "remainQuota", "remaining", "balance"]);
|
|
870
|
+
if (remaining === undefined && quota !== undefined && used !== undefined) {
|
|
871
|
+
remaining = Math.max(0, quota - used);
|
|
872
|
+
}
|
|
873
|
+
const scale = await newApiQuotaScale();
|
|
874
|
+
const quotaForWarning = scale ? quota / scale : quota;
|
|
875
|
+
const usedForWarning = scale ? used / scale : used;
|
|
876
|
+
const remainingForWarning = scale ? remaining / scale : remaining;
|
|
877
|
+
const normalized = {
|
|
878
|
+
mode: "quota_limited",
|
|
879
|
+
quota: {
|
|
880
|
+
limit: quotaForWarning,
|
|
881
|
+
used: usedForWarning,
|
|
882
|
+
remaining: remainingForWarning,
|
|
883
|
+
},
|
|
884
|
+
unit: scale ? "USD" : "quota",
|
|
885
|
+
source: "newapi-token",
|
|
886
|
+
raw: json,
|
|
887
|
+
};
|
|
888
|
+
return usageResult(context, "newapi-token", await formatNewApiTokenLine(context.label, json), normalized);
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
// NewAPI / OneAPI / OneHub / DoneHub / Veloera panel session endpoint, based on
|
|
892
|
+
// Metapi's platform handling. This works when PROVIDER_USAGE_API_KEY is a panel
|
|
893
|
+
// access/session token, or when the site accepts the API key for /api/user/self.
|
|
894
|
+
async function fetchPanelUserSelfUsage(context) {
|
|
895
|
+
const preset = await usagePreset();
|
|
896
|
+
const kind = preset === "auto" ? "new-api" : preset;
|
|
897
|
+
const json = await requestJson(joinUrl(serviceRoot(context.baseUrl), "/api/user/self"), context.key, {
|
|
898
|
+
name: "panel /api/user/self",
|
|
899
|
+
headers: await panelUserHeaders(),
|
|
900
|
+
});
|
|
901
|
+
const root = usageRoot(json);
|
|
902
|
+
if (pickNumber(root, ["quota"]) === undefined && pickNumber(root, ["used_quota", "usedQuota"]) === undefined) {
|
|
903
|
+
await debugLog({
|
|
904
|
+
source: "panel /api/user/self",
|
|
905
|
+
payloadKeys: Object.keys(root || {}).slice(0, 20),
|
|
906
|
+
success: root?.success,
|
|
907
|
+
message: root?.message || root?.error?.message || "",
|
|
908
|
+
});
|
|
909
|
+
}
|
|
910
|
+
const scale = panelQuotaScale(kind);
|
|
911
|
+
const quota = pickNumber(root, ["quota"]);
|
|
912
|
+
const used = pickNumber(root, ["used_quota", "usedQuota"]);
|
|
913
|
+
const remaining = panelQuotaLooksRemaining(kind)
|
|
914
|
+
? quota
|
|
915
|
+
: (quota === undefined || used === undefined ? quota : Math.max(0, quota - used));
|
|
916
|
+
const total = panelQuotaLooksRemaining(kind)
|
|
917
|
+
? (quota === undefined || used === undefined ? quota : quota + used)
|
|
918
|
+
: quota;
|
|
919
|
+
const normalized = {
|
|
920
|
+
mode: "quota_limited",
|
|
921
|
+
quota: {
|
|
922
|
+
limit: total === undefined ? undefined : total / scale,
|
|
923
|
+
used: used === undefined ? undefined : used / scale,
|
|
924
|
+
remaining: remaining === undefined ? undefined : remaining / scale,
|
|
925
|
+
},
|
|
926
|
+
unit: "USD",
|
|
927
|
+
source: "panel-user-self",
|
|
928
|
+
raw: json,
|
|
929
|
+
};
|
|
930
|
+
return usageResult(
|
|
931
|
+
context,
|
|
932
|
+
"panel-user-self",
|
|
933
|
+
await formatPanelUserSelfLine(context.label, json, kind),
|
|
934
|
+
normalized
|
|
935
|
+
);
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
// Sub2API exposes user balance as USD at /api/v1/auth/me. Newer deployments may
|
|
939
|
+
// also expose richer subscription summaries through /v1/usage, so this route is
|
|
940
|
+
// a fallback for deployments where /v1/usage is unavailable.
|
|
941
|
+
async function fetchSub2ApiAuthMeUsage(context) {
|
|
942
|
+
const json = await requestJson(joinUrl(serviceRoot(context.baseUrl), "/api/v1/auth/me"), context.key, {
|
|
943
|
+
name: "Sub2API auth/me",
|
|
944
|
+
});
|
|
945
|
+
const root = usageRoot(json);
|
|
946
|
+
const balance = pickNumber(root, ["balance"]);
|
|
947
|
+
if (balance === undefined) throw new Error("Sub2API auth/me payload has no balance field");
|
|
948
|
+
const normalized = {
|
|
949
|
+
mode: "unrestricted",
|
|
950
|
+
planName: root?.username || root?.email || context.label || "Sub2API",
|
|
951
|
+
balance,
|
|
952
|
+
unit: "USD",
|
|
953
|
+
source: "sub2api-auth-me",
|
|
954
|
+
raw: json,
|
|
955
|
+
};
|
|
956
|
+
return usageResult(
|
|
957
|
+
context,
|
|
958
|
+
"sub2api-auth-me",
|
|
959
|
+
`API | balance ${formatMoney(balance)}`,
|
|
960
|
+
normalized
|
|
961
|
+
);
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
// OpenRouter exposes normal API-key usage at /api/v1/key. Some accounts also
|
|
965
|
+
// expose credits at /api/v1/credits; keep this route isolated because
|
|
966
|
+
// OpenRouter's base URL already includes /api/v1, unlike NewAPI/OneAPI.
|
|
967
|
+
async function fetchOpenRouterUsage(context) {
|
|
968
|
+
const base = cleanBaseUrl(context.baseUrl).includes("/api/v1")
|
|
969
|
+
? cleanBaseUrl(context.baseUrl)
|
|
970
|
+
: joinUrl(serviceRoot(context.baseUrl), "/api/v1");
|
|
971
|
+
const endpoints = [
|
|
972
|
+
{ source: "openrouter-key", url: joinUrl(base, "/key") },
|
|
973
|
+
{ source: "openrouter-credits", url: joinUrl(base, "/credits") },
|
|
974
|
+
];
|
|
975
|
+
let lastError;
|
|
976
|
+
for (const endpoint of endpoints) {
|
|
977
|
+
try {
|
|
978
|
+
const json = await requestJson(endpoint.url, context.key, { name: endpoint.source });
|
|
979
|
+
return usageResult(context, endpoint.source, formatOpenRouterLine("OpenRouter", json), json);
|
|
980
|
+
} catch (error) {
|
|
981
|
+
lastError = error;
|
|
982
|
+
await debugLog({ source: endpoint.source, error: error.message });
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
throw lastError || new Error("OpenRouter usage unavailable");
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
function usageResult(context, source, text, raw) {
|
|
989
|
+
return {
|
|
990
|
+
updatedAt: new Date().toISOString(),
|
|
991
|
+
baseUrl: context.baseUrl,
|
|
992
|
+
provider: context.providerName,
|
|
993
|
+
source,
|
|
994
|
+
text,
|
|
995
|
+
raw,
|
|
996
|
+
};
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
const USAGE_ROUTES = {
|
|
1000
|
+
"v1-usage": {
|
|
1001
|
+
id: "v1-usage",
|
|
1002
|
+
path: "/v1/usage",
|
|
1003
|
+
run: fetchV1Usage,
|
|
1004
|
+
},
|
|
1005
|
+
"sub2api-auth-me": {
|
|
1006
|
+
id: "sub2api-auth-me",
|
|
1007
|
+
path: "/api/v1/auth/me",
|
|
1008
|
+
run: fetchSub2ApiAuthMeUsage,
|
|
1009
|
+
},
|
|
1010
|
+
"newapi-token": {
|
|
1011
|
+
id: "newapi-token",
|
|
1012
|
+
path: "/api/usage/token/",
|
|
1013
|
+
run: fetchNewApiTokenUsage,
|
|
1014
|
+
},
|
|
1015
|
+
"panel-user-self": {
|
|
1016
|
+
id: "panel-user-self",
|
|
1017
|
+
path: "/api/user/self",
|
|
1018
|
+
run: fetchPanelUserSelfUsage,
|
|
1019
|
+
},
|
|
1020
|
+
"openrouter": {
|
|
1021
|
+
id: "openrouter",
|
|
1022
|
+
path: "/api/v1/key",
|
|
1023
|
+
run: fetchOpenRouterUsage,
|
|
1024
|
+
},
|
|
1025
|
+
};
|
|
1026
|
+
|
|
1027
|
+
async function usageRouteIds(context) {
|
|
1028
|
+
const preset = await usagePreset();
|
|
1029
|
+
const routes = {
|
|
1030
|
+
"sub2api": ["v1-usage", "sub2api-auth-me"],
|
|
1031
|
+
"openai-compatible": ["v1-usage"],
|
|
1032
|
+
"new-api": ["newapi-token", "panel-user-self"],
|
|
1033
|
+
"one-api": ["newapi-token", "panel-user-self"],
|
|
1034
|
+
"onehub": ["newapi-token", "panel-user-self"],
|
|
1035
|
+
"one-hub": ["newapi-token", "panel-user-self"],
|
|
1036
|
+
"donehub": ["newapi-token", "panel-user-self"],
|
|
1037
|
+
"done-hub": ["newapi-token", "panel-user-self"],
|
|
1038
|
+
"veloera": ["panel-user-self", "newapi-token"],
|
|
1039
|
+
"anyrouter": ["newapi-token", "panel-user-self", "v1-usage"],
|
|
1040
|
+
"agentrouter": ["newapi-token", "panel-user-self", "v1-usage"],
|
|
1041
|
+
"openrouter": ["openrouter"],
|
|
1042
|
+
};
|
|
1043
|
+
if (routes[preset]) return routes[preset];
|
|
1044
|
+
|
|
1045
|
+
if (preset !== "auto") return [];
|
|
1046
|
+
if (hostIncludes(context.baseUrl, "openrouter.ai")) return ["openrouter"];
|
|
1047
|
+
return ["v1-usage", "sub2api-auth-me", "newapi-token", "panel-user-self"];
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
async function orderedUsageRoutes(context) {
|
|
1051
|
+
const routeIds = await usageRouteIds(context);
|
|
1052
|
+
const cached = await cachedUsageRoute(context);
|
|
1053
|
+
if (!cached || !routeIds.includes(cached.route)) return routeIds.map((id) => USAGE_ROUTES[id]).filter(Boolean);
|
|
1054
|
+
await debugLog({
|
|
1055
|
+
source: "route-cache",
|
|
1056
|
+
key: usageRouteCacheKey(context.baseUrl),
|
|
1057
|
+
route: cached.route,
|
|
1058
|
+
path: cached.path || USAGE_ROUTES[cached.route]?.path || "",
|
|
1059
|
+
});
|
|
1060
|
+
return [cached.route, ...routeIds.filter((id) => id !== cached.route)]
|
|
1061
|
+
.map((id) => USAGE_ROUTES[id])
|
|
1062
|
+
.filter(Boolean);
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
async function contextForCodex() {
|
|
1066
|
+
const auth = existsSync(AUTH_PATH) ? await readJson(AUTH_PATH) : {};
|
|
1067
|
+
const codexConfig = parseTomlLite(await readTextIfExists(CODEX_CONFIG_PATH));
|
|
1068
|
+
const { providerName, provider } = activeProvider(codexConfig);
|
|
1069
|
+
const baseUrl =
|
|
1070
|
+
process.env.PROVIDER_USAGE_BASE_URL ||
|
|
1071
|
+
process.env.SUB2API_BASE_URL ||
|
|
1072
|
+
process.env.OPENAI_BASE_URL ||
|
|
1073
|
+
provider.base_url ||
|
|
1074
|
+
"";
|
|
1075
|
+
const key = apiKeyFor(auth, provider);
|
|
1076
|
+
return {
|
|
1077
|
+
providerName,
|
|
1078
|
+
provider,
|
|
1079
|
+
baseUrl,
|
|
1080
|
+
key,
|
|
1081
|
+
label: providerLabel(providerName, provider),
|
|
1082
|
+
};
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
function contextForClaude() {
|
|
1086
|
+
const baseUrl =
|
|
1087
|
+
process.env.PROVIDER_USAGE_BASE_URL ||
|
|
1088
|
+
process.env.ANTHROPIC_BASE_URL ||
|
|
1089
|
+
"";
|
|
1090
|
+
return {
|
|
1091
|
+
providerName: "claude",
|
|
1092
|
+
provider: { name: "Claude" },
|
|
1093
|
+
baseUrl,
|
|
1094
|
+
key: apiKeyForClaude(),
|
|
1095
|
+
label: "Claude",
|
|
1096
|
+
};
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
async function usageContext(agent) {
|
|
1100
|
+
return agent === "claude" ? contextForClaude() : await contextForCodex();
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
function normalizeUsageContext(input) {
|
|
1104
|
+
const providerName = String(input?.providerName || "provider");
|
|
1105
|
+
const provider = input?.provider && typeof input.provider === "object"
|
|
1106
|
+
? input.provider
|
|
1107
|
+
: { name: providerName };
|
|
1108
|
+
return {
|
|
1109
|
+
providerName,
|
|
1110
|
+
provider,
|
|
1111
|
+
baseUrl: String(input?.baseUrl || ""),
|
|
1112
|
+
key: String(input?.key || ""),
|
|
1113
|
+
label: String(input?.label || provider.name || providerName),
|
|
1114
|
+
};
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
async function queryUsageContext(context, { agent = "external", rememberSnapshot = false } = {}) {
|
|
1118
|
+
|
|
1119
|
+
await debugLog({
|
|
1120
|
+
mode,
|
|
1121
|
+
agent,
|
|
1122
|
+
providerName: context.providerName,
|
|
1123
|
+
baseUrl: context.baseUrl,
|
|
1124
|
+
preset: await usagePreset(),
|
|
1125
|
+
providerEnvKey: context.provider?.env_key || "",
|
|
1126
|
+
hasProviderUsageKey: Boolean(process.env.PROVIDER_USAGE_API_KEY),
|
|
1127
|
+
hasSub2apiKey: Boolean(process.env.SUB2API_API_KEY),
|
|
1128
|
+
hasProviderEnvKey: Boolean(context.provider?.env_key && process.env[context.provider.env_key]),
|
|
1129
|
+
hasAnthropicAuthToken: Boolean(process.env.ANTHROPIC_AUTH_TOKEN),
|
|
1130
|
+
hasAnthropicApiKey: Boolean(process.env.ANTHROPIC_API_KEY),
|
|
1131
|
+
hasEnvOpenaiKey: Boolean(process.env.OPENAI_API_KEY),
|
|
1132
|
+
});
|
|
1133
|
+
|
|
1134
|
+
if (!context.key || isOfficialBaseUrl(context.baseUrl)) {
|
|
1135
|
+
return { skipped: true, text: "" };
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
let lastError;
|
|
1139
|
+
for (const route of await orderedUsageRoutes(context)) {
|
|
1140
|
+
try {
|
|
1141
|
+
const result = await route.run(context);
|
|
1142
|
+
await rememberUsageRoute(context, route, result);
|
|
1143
|
+
if (rememberSnapshot) {
|
|
1144
|
+
await rememberUsageSnapshot(context, result);
|
|
1145
|
+
await rememberRefreshState(context, {
|
|
1146
|
+
lastSuccessAt: new Date().toISOString(),
|
|
1147
|
+
lastError: "",
|
|
1148
|
+
});
|
|
1149
|
+
}
|
|
1150
|
+
return result;
|
|
1151
|
+
} catch (error) {
|
|
1152
|
+
lastError = error;
|
|
1153
|
+
await debugLog({ route: route.id, path: route.path, error: error.message });
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
if (lastError) {
|
|
1158
|
+
if (rememberSnapshot) {
|
|
1159
|
+
await rememberRefreshState(context, {
|
|
1160
|
+
lastFailureAt: new Date().toISOString(),
|
|
1161
|
+
lastError: lastError.message,
|
|
1162
|
+
});
|
|
1163
|
+
}
|
|
1164
|
+
throw lastError;
|
|
1165
|
+
}
|
|
1166
|
+
return { skipped: true, text: "" };
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
export async function queryProviderUsage(input, options = {}) {
|
|
1170
|
+
return await queryUsageContext(normalizeUsageContext(input), options);
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
async function refresh(agent = "codex") {
|
|
1174
|
+
return await queryUsageContext(await usageContext(agent), {
|
|
1175
|
+
agent,
|
|
1176
|
+
rememberSnapshot: agent === "claude",
|
|
1177
|
+
});
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
async function main() {
|
|
1181
|
+
try {
|
|
1182
|
+
if (mode === "refresh") {
|
|
1183
|
+
await refresh(cli.agent);
|
|
1184
|
+
} else if (mode === "print" || mode === "print-or-refresh") {
|
|
1185
|
+
const result = await refresh(cli.agent);
|
|
1186
|
+
textOut(result?.text || "");
|
|
1187
|
+
} else if (mode === "hook") {
|
|
1188
|
+
const result = await refresh(cli.agent);
|
|
1189
|
+
hookOut(result?.text || "");
|
|
1190
|
+
} else {
|
|
1191
|
+
throw new Error(`unknown mode: ${mode}`);
|
|
1192
|
+
}
|
|
1193
|
+
} catch (error) {
|
|
1194
|
+
failSoft("Provider usage unavailable", error);
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
1199
|
+
await main();
|
|
1200
|
+
}
|