@kairyou/agent-tools 0.5.2 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +58 -6
- package/README.zh-CN.md +53 -5
- package/dist/usage/core.mjs +144 -147
- package/integrations/usage/core.mjs +12 -7
- package/integrations/usage/lib/cache.mjs +5 -0
- package/integrations/usage/lib/config.mjs +8 -22
- package/integrations/usage/lib/format.mjs +3 -40
- package/integrations/usage/lib/http.mjs +9 -8
- package/integrations/usage/lib/routes.mjs +143 -105
- package/integrations/usage/routes/.gitkeep +0 -0
- package/package.json +1 -1
- package/scripts/build.mjs +9 -0
- package/scripts/install.mjs +20 -0
package/README.md
CHANGED
|
@@ -109,9 +109,9 @@ and never touch your edits or comments.
|
|
|
109
109
|
For API relay / gateway setups: shows the relay's balance / quota inside the
|
|
110
110
|
agent, so when you pay per use or have plan limits you always know how much you
|
|
111
111
|
have spent and how much is left — without opening the gateway console.
|
|
112
|
-
Works with
|
|
113
|
-
|
|
114
|
-
|
|
112
|
+
Works with API-key usage endpoints exposed by Sub2API, One API, New API, and
|
|
113
|
+
OpenRouter. Compatibility depends on the gateway version and whether the
|
|
114
|
+
corresponding usage endpoint is enabled.
|
|
115
115
|
|
|
116
116
|
```bash
|
|
117
117
|
npx -y @kairyou/agent-tools@latest usage -a claude
|
|
@@ -140,14 +140,66 @@ endpoint and key — and tune `providerUsage` in `~/.agent-tools/config.jsonc`:
|
|
|
140
140
|
```jsonc
|
|
141
141
|
{
|
|
142
142
|
"providerUsage": {
|
|
143
|
-
"preset": "auto", // sub2api |
|
|
144
|
-
"
|
|
145
|
-
"days": 30, // spend window for the "30d" field (max 90)
|
|
143
|
+
"preset": "auto", // auto | sub2api | one-api | new-api | openrouter | <custom-route-id>
|
|
144
|
+
"days": 30, // how many recent days of spend to count
|
|
146
145
|
"debug": false // true: log probes to ~/.agent-tools/logs/usage-debug.log
|
|
147
146
|
}
|
|
148
147
|
}
|
|
149
148
|
```
|
|
150
149
|
|
|
150
|
+
Keep `preset` set to `auto` for automatic detection. Select a specific protocol
|
|
151
|
+
only when you know which usage endpoint the gateway exposes; a configured
|
|
152
|
+
custom route id is also accepted.
|
|
153
|
+
|
|
154
|
+
#### Custom gateway routes
|
|
155
|
+
|
|
156
|
+
For gateways the built-in probes cannot reach (e.g. cookie-authenticated
|
|
157
|
+
relays), write your own route module and declare it in `providerUsage.routes`
|
|
158
|
+
(paths resolve against `~/.agent-tools`). Declared routes are probed first;
|
|
159
|
+
setting `"preset"` to a route id selects it directly.
|
|
160
|
+
|
|
161
|
+
```jsonc
|
|
162
|
+
{
|
|
163
|
+
"providerUsage": {
|
|
164
|
+
"routes": [
|
|
165
|
+
"custom/my-gateway.mjs",
|
|
166
|
+
"custom/another-gateway.mjs"
|
|
167
|
+
],
|
|
168
|
+
"myGateway": { "username": "me", "password": "..." }
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
```js
|
|
174
|
+
// ~/.agent-tools/custom/my-gateway.mjs
|
|
175
|
+
export const meta = { id: "my-gateway" }; // optional; id defaults to the file name
|
|
176
|
+
|
|
177
|
+
export async function run(context, { requestJson, agentConfig }) {
|
|
178
|
+
// context: { baseUrl, key, providerName, provider, label }
|
|
179
|
+
const { myGateway = {} } = await agentConfig(); // the providerUsage object; custom keys welcome
|
|
180
|
+
|
|
181
|
+
// Tip: save the token to a file (e.g. under ~/.agent-tools/cache) and reuse
|
|
182
|
+
// it; log in again only when a query fails with it (e.g. 401), then save the
|
|
183
|
+
// new token.
|
|
184
|
+
const login = await fetch(`${context.baseUrl}/api/user/login`, {
|
|
185
|
+
method: "POST",
|
|
186
|
+
headers: { "content-type": "application/json" },
|
|
187
|
+
body: JSON.stringify({ username: myGateway.username, password: myGateway.password }),
|
|
188
|
+
});
|
|
189
|
+
const session = await login.json();
|
|
190
|
+
|
|
191
|
+
// Plain fetch works too; custom headers: authorization, cookie, ...
|
|
192
|
+
const me = await requestJson(`${context.baseUrl}/api/user/self`, {
|
|
193
|
+
headers: { authorization: `Bearer ${session?.data?.accessToken}` },
|
|
194
|
+
});
|
|
195
|
+
return { text: `API | balance ¥${me?.data?.balance}` };
|
|
196
|
+
}
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
`text` is a free-form string; return `{ text }` on success, throw to fall
|
|
200
|
+
through to the next route (with `providerUsage.debug` enabled, failures are
|
|
201
|
+
logged to `~/.agent-tools/logs/usage-debug.log`).
|
|
202
|
+
|
|
151
203
|
Output examples:
|
|
152
204
|
|
|
153
205
|
```text
|
package/README.zh-CN.md
CHANGED
|
@@ -102,8 +102,8 @@ npx -y @kairyou/agent-tools@latest statusline -a claude
|
|
|
102
102
|
|
|
103
103
|
面向使用 API 中转的场景: 在 agent 内直接显示中转网关的余额/额度, 按量付费或
|
|
104
104
|
有套餐限额时, 随时知道花了多少, 还剩多少, 不用切出去登录网关后台.
|
|
105
|
-
支持 Sub2API,
|
|
106
|
-
|
|
105
|
+
支持 Sub2API, One API, New API 与 OpenRouter 提供的 API Key 用量接口.
|
|
106
|
+
具体兼容性取决于网关版本及其是否开放相应接口.
|
|
107
107
|
|
|
108
108
|
```bash
|
|
109
109
|
npx -y @kairyou/agent-tools@latest usage -a claude
|
|
@@ -130,14 +130,62 @@ provider 的 `base_url` 和密钥; Claude Code: 读取 `ANTHROPIC_BASE_URL` 与
|
|
|
130
130
|
```jsonc
|
|
131
131
|
{
|
|
132
132
|
"providerUsage": {
|
|
133
|
-
"preset": "auto", // sub2api |
|
|
134
|
-
"
|
|
135
|
-
"days": 30, // "30d" 字段的统计窗口(最大 90)
|
|
133
|
+
"preset": "auto", // auto | sub2api | one-api | new-api | openrouter | <自定义 route id>
|
|
134
|
+
"days": 30, // 统计最近多少天的消耗
|
|
136
135
|
"debug": false // true: 探测过程写入 ~/.agent-tools/logs/usage-debug.log
|
|
137
136
|
}
|
|
138
137
|
}
|
|
139
138
|
```
|
|
140
139
|
|
|
140
|
+
保持 `preset: "auto"` 即可自动探测. 只有明确知道网关开放的是哪种用量协议时,
|
|
141
|
+
才指定相应的内置 preset 或已配置的自定义 route id.
|
|
142
|
+
|
|
143
|
+
#### 自定义网关路由
|
|
144
|
+
|
|
145
|
+
内置探测覆盖不到的网关(比如 cookie 认证的中转), 可以自己写路由模块并在
|
|
146
|
+
`providerUsage.routes` 里声明(相对 `~/.agent-tools` 解析). 声明的路由优先
|
|
147
|
+
探测; `"preset"` 填路由 id 可直接选中.
|
|
148
|
+
|
|
149
|
+
```jsonc
|
|
150
|
+
{
|
|
151
|
+
"providerUsage": {
|
|
152
|
+
"routes": [
|
|
153
|
+
"custom/my-gateway.mjs",
|
|
154
|
+
"custom/another-gateway.mjs"
|
|
155
|
+
],
|
|
156
|
+
"myGateway": { "username": "me", "password": "..." }
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
```js
|
|
162
|
+
// ~/.agent-tools/custom/my-gateway.mjs
|
|
163
|
+
export const meta = { id: "my-gateway" }; // 可选; id 缺省用文件名
|
|
164
|
+
|
|
165
|
+
export async function run(context, { requestJson, agentConfig }) {
|
|
166
|
+
// context: { baseUrl, key, providerName, provider, label }
|
|
167
|
+
const { myGateway = {} } = await agentConfig(); // providerUsage 对象, 自定义键随意加
|
|
168
|
+
|
|
169
|
+
// 建议: 把 token 存到文件里(如 ~/.agent-tools/cache 下)重复使用,
|
|
170
|
+
// 用它查询失败(如 401)时才重新登录, 并把新 token 写回文件.
|
|
171
|
+
const login = await fetch(`${context.baseUrl}/api/user/login`, {
|
|
172
|
+
method: "POST",
|
|
173
|
+
headers: { "content-type": "application/json" },
|
|
174
|
+
body: JSON.stringify({ username: myGateway.username, password: myGateway.password }),
|
|
175
|
+
});
|
|
176
|
+
const session = await login.json();
|
|
177
|
+
|
|
178
|
+
// 用 fetch 也行; 自定义 header: authorization, cookie 等.
|
|
179
|
+
const me = await requestJson(`${context.baseUrl}/api/user/self`, {
|
|
180
|
+
headers: { authorization: `Bearer ${session?.data?.accessToken}` },
|
|
181
|
+
});
|
|
182
|
+
return { text: `API | balance ¥${me?.data?.balance}` };
|
|
183
|
+
}
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
`text` 是自由字符串; 成功返回 `{ text }`, 抛错则回落到下一条路由
|
|
187
|
+
(开启 `providerUsage.debug` 后, 失败会记录到 `~/.agent-tools/logs/usage-debug.log`).
|
|
188
|
+
|
|
141
189
|
显示效果示例:
|
|
142
190
|
|
|
143
191
|
```text
|
package/dist/usage/core.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// integrations/usage/core.mjs
|
|
4
|
-
import { pathToFileURL } from "node:url";
|
|
4
|
+
import { pathToFileURL as pathToFileURL2 } from "node:url";
|
|
5
5
|
|
|
6
6
|
// integrations/usage/lib/config.mjs
|
|
7
7
|
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
@@ -919,32 +919,16 @@ async function debugLog(event) {
|
|
|
919
919
|
async function providerUsageDays() {
|
|
920
920
|
const config = await agentConfig();
|
|
921
921
|
const value = Number(process.env.PROVIDER_USAGE_DAYS || config.days || DEFAULT_USAGE_DAYS);
|
|
922
|
-
if (!Number.isInteger(value) || value <= 0
|
|
923
|
-
return value;
|
|
922
|
+
if (!Number.isInteger(value) || value <= 0) return DEFAULT_USAGE_DAYS;
|
|
923
|
+
return Math.min(value, MAX_USAGE_DAYS);
|
|
924
924
|
}
|
|
925
925
|
async function usagePreset() {
|
|
926
926
|
const config = await agentConfig();
|
|
927
927
|
return String(process.env.PROVIDER_USAGE_PRESET || config.preset || "auto").toLowerCase();
|
|
928
928
|
}
|
|
929
|
-
|
|
930
|
-
const
|
|
931
|
-
|
|
932
|
-
const parsed = Number.parseInt(String(raw), 10);
|
|
933
|
-
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
|
934
|
-
}
|
|
935
|
-
async function panelUserHeaders() {
|
|
936
|
-
const userId = await panelUserId();
|
|
937
|
-
if (!userId) return {};
|
|
938
|
-
const value = String(userId);
|
|
939
|
-
return {
|
|
940
|
-
"New-API-User": value,
|
|
941
|
-
"Veloera-User": value,
|
|
942
|
-
"voapi-user": value,
|
|
943
|
-
"User-id": value,
|
|
944
|
-
"X-User-Id": value,
|
|
945
|
-
"Rix-Api-User": value,
|
|
946
|
-
"neo-api-user": value
|
|
947
|
-
};
|
|
929
|
+
function snapshotTtlMs() {
|
|
930
|
+
const raw = Number(process.env.AGENT_TOOLS_USAGE_SNAPSHOT_TTL_MS);
|
|
931
|
+
return Number.isFinite(raw) && raw >= 0 ? raw : 6e4;
|
|
948
932
|
}
|
|
949
933
|
async function newApiQuotaScale() {
|
|
950
934
|
const config = await agentConfig();
|
|
@@ -1041,6 +1025,10 @@ async function readSnapshotCache() {
|
|
|
1041
1025
|
return { version: SNAPSHOT_VERSION, items: {} };
|
|
1042
1026
|
}
|
|
1043
1027
|
}
|
|
1028
|
+
async function readUsageSnapshot(context) {
|
|
1029
|
+
const cache = await readSnapshotCache();
|
|
1030
|
+
return cache.items[usageRouteCacheKey(context.baseUrl)] || null;
|
|
1031
|
+
}
|
|
1044
1032
|
async function rememberUsageSnapshot(context, result) {
|
|
1045
1033
|
if (!result?.text) return;
|
|
1046
1034
|
try {
|
|
@@ -1089,6 +1077,11 @@ async function rememberRefreshState(context, patch) {
|
|
|
1089
1077
|
}
|
|
1090
1078
|
}
|
|
1091
1079
|
|
|
1080
|
+
// integrations/usage/lib/routes.mjs
|
|
1081
|
+
import { readdir } from "node:fs/promises";
|
|
1082
|
+
import { basename, extname, isAbsolute, join as join2 } from "node:path";
|
|
1083
|
+
import { pathToFileURL } from "node:url";
|
|
1084
|
+
|
|
1092
1085
|
// integrations/usage/lib/http.mjs
|
|
1093
1086
|
import { createContext, runInContext } from "node:vm";
|
|
1094
1087
|
var REQUEST_TIMEOUT_MS = 5e3;
|
|
@@ -1191,7 +1184,8 @@ function mergeSetCookiePairs(cookieHeader, setCookieHeaders) {
|
|
|
1191
1184
|
}
|
|
1192
1185
|
return merged;
|
|
1193
1186
|
}
|
|
1194
|
-
async function requestJson(url,
|
|
1187
|
+
async function requestJson(url, options = {}) {
|
|
1188
|
+
const { key = "", headers = {}, name = "usage" } = options;
|
|
1195
1189
|
let cookieHeader = "";
|
|
1196
1190
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
1197
1191
|
const controller = new AbortController();
|
|
@@ -1199,10 +1193,10 @@ async function requestJson(url, key, options = {}) {
|
|
|
1199
1193
|
const response = await fetch(url, {
|
|
1200
1194
|
headers: {
|
|
1201
1195
|
accept: "application/json",
|
|
1202
|
-
authorization: `Bearer ${
|
|
1196
|
+
...key ? { authorization: `Bearer ${key}` } : {},
|
|
1203
1197
|
"user-agent": SHIELD_USER_AGENT,
|
|
1204
1198
|
...cookieHeader ? { cookie: cookieHeader } : {},
|
|
1205
|
-
...
|
|
1199
|
+
...headers
|
|
1206
1200
|
},
|
|
1207
1201
|
signal: controller.signal
|
|
1208
1202
|
});
|
|
@@ -1216,7 +1210,7 @@ async function requestJson(url, key, options = {}) {
|
|
|
1216
1210
|
const contentType = response.headers.get("content-type") || "";
|
|
1217
1211
|
const acwScV2 = isShieldChallenge(contentType, body) ? solveNewApiAcwScV2(body) : "";
|
|
1218
1212
|
await debugLog({
|
|
1219
|
-
source:
|
|
1213
|
+
source: name,
|
|
1220
1214
|
url,
|
|
1221
1215
|
status: response.status,
|
|
1222
1216
|
contentType,
|
|
@@ -1227,25 +1221,25 @@ async function requestJson(url, key, options = {}) {
|
|
|
1227
1221
|
cookieHeader = upsertCookie(cookieHeader, "acw_sc__v2", acwScV2);
|
|
1228
1222
|
continue;
|
|
1229
1223
|
}
|
|
1230
|
-
throw new Error(`${
|
|
1224
|
+
throw new Error(`${name} returned non-JSON (${response.status})`);
|
|
1231
1225
|
}
|
|
1232
1226
|
if (!response.ok) {
|
|
1233
1227
|
const message = json?.error?.message || json?.message || response.statusText;
|
|
1234
1228
|
await debugLog({
|
|
1235
|
-
source:
|
|
1229
|
+
source: name,
|
|
1236
1230
|
url,
|
|
1237
1231
|
status: response.status,
|
|
1238
1232
|
message,
|
|
1239
1233
|
bodyPreview: shortPreview(body)
|
|
1240
1234
|
});
|
|
1241
|
-
throw new Error(`${
|
|
1235
|
+
throw new Error(`${name} failed (${response.status} ${message})`);
|
|
1242
1236
|
}
|
|
1243
1237
|
return json;
|
|
1244
1238
|
} finally {
|
|
1245
1239
|
clearTimeout(timeout);
|
|
1246
1240
|
}
|
|
1247
1241
|
}
|
|
1248
|
-
throw new Error(`${
|
|
1242
|
+
throw new Error(`${name} unavailable`);
|
|
1249
1243
|
}
|
|
1250
1244
|
|
|
1251
1245
|
// integrations/usage/lib/format.mjs
|
|
@@ -1374,36 +1368,8 @@ function formatOpenRouterLine(label, data) {
|
|
|
1374
1368
|
if (parts.length === 1) throw new Error("OpenRouter payload has no usage fields");
|
|
1375
1369
|
return parts.join(" | ");
|
|
1376
1370
|
}
|
|
1377
|
-
function
|
|
1378
|
-
return
|
|
1379
|
-
}
|
|
1380
|
-
function panelQuotaLooksRemaining(kind) {
|
|
1381
|
-
return ["new-api", "anyrouter", "agentrouter", "done-hub", "donehub"].includes(kind);
|
|
1382
|
-
}
|
|
1383
|
-
async function formatPanelUserSelfLine(label, data, kind) {
|
|
1384
|
-
const root = usageRoot(data);
|
|
1385
|
-
const scale = panelQuotaScale(kind);
|
|
1386
|
-
const quota = pickNumber(root, ["quota"]);
|
|
1387
|
-
const used = pickNumber(root, ["used_quota", "usedQuota"]);
|
|
1388
|
-
const todayIncome = pickNumber(root, ["today_income", "todayIncome"]);
|
|
1389
|
-
const todayUsed = pickNumber(root, ["today_quota_consumption", "todayQuotaConsumption"]);
|
|
1390
|
-
if (quota === void 0 && used === void 0) {
|
|
1391
|
-
throw new Error("panel /api/user/self payload has no quota fields");
|
|
1392
|
-
}
|
|
1393
|
-
const quotaUsd = quota === void 0 ? void 0 : quota / scale;
|
|
1394
|
-
const usedUsd = used === void 0 ? void 0 : used / scale;
|
|
1395
|
-
const remainingUsd = panelQuotaLooksRemaining(kind) ? quotaUsd : quotaUsd === void 0 || usedUsd === void 0 ? quotaUsd : Math.max(0, quotaUsd - usedUsd);
|
|
1396
|
-
const totalUsd = panelQuotaLooksRemaining(kind) ? quotaUsd === void 0 || usedUsd === void 0 ? quotaUsd : quotaUsd + usedUsd : quotaUsd;
|
|
1397
|
-
const parts = usageParts();
|
|
1398
|
-
if (remainingUsd !== void 0) parts.push(`balance ${formatMoney(remainingUsd)}`);
|
|
1399
|
-
if (usedUsd !== void 0 && totalUsd !== void 0) {
|
|
1400
|
-
parts.push(`used ${formatMoney(usedUsd)}/${formatMoney(totalUsd)}`);
|
|
1401
|
-
} else if (usedUsd !== void 0) {
|
|
1402
|
-
parts.push(`used ${formatMoney(usedUsd)}`);
|
|
1403
|
-
}
|
|
1404
|
-
if (todayUsed !== void 0) parts.push(`today ${formatMoney(todayUsed / scale)}`);
|
|
1405
|
-
if (todayIncome !== void 0) parts.push(`income ${formatMoney(todayIncome / scale)}`);
|
|
1406
|
-
return parts.join(" | ");
|
|
1371
|
+
function formatOneApiBillingLine(limit, used) {
|
|
1372
|
+
return `API | balance ${formatMoney(Math.max(0, limit - used))} | used ${formatMoney(used)}/${formatMoney(limit)}`;
|
|
1407
1373
|
}
|
|
1408
1374
|
function formatQuotaLimitedLine(label, root) {
|
|
1409
1375
|
const quota = root?.quota || {};
|
|
@@ -1473,15 +1439,17 @@ function usageResult(context, source, text, raw) {
|
|
|
1473
1439
|
};
|
|
1474
1440
|
}
|
|
1475
1441
|
async function fetchV1Usage(context) {
|
|
1476
|
-
const json = await requestJson(await subscriptionUrl(context.baseUrl),
|
|
1442
|
+
const json = await requestJson(await subscriptionUrl(context.baseUrl), {
|
|
1443
|
+
key: context.key,
|
|
1477
1444
|
name: "v1 usage"
|
|
1478
1445
|
});
|
|
1479
1446
|
if (!hasV1UsageFields(usageRoot(json))) throw new Error("v1 usage payload has no usage fields");
|
|
1480
1447
|
return usageResult(context, "v1-usage", await formatQuota(context.label, json), json);
|
|
1481
1448
|
}
|
|
1482
1449
|
async function fetchNewApiTokenUsage(context) {
|
|
1483
|
-
const json = await requestJson(joinUrl(serviceRoot(context.baseUrl), "/api/usage/token/"),
|
|
1484
|
-
|
|
1450
|
+
const json = await requestJson(joinUrl(serviceRoot(context.baseUrl), "/api/usage/token/"), {
|
|
1451
|
+
key: context.key,
|
|
1452
|
+
name: "New API token usage"
|
|
1485
1453
|
});
|
|
1486
1454
|
const root = usageRoot(json);
|
|
1487
1455
|
const quota = pickNumber(root, ["quota", "limit", "total_quota", "totalQuota"]);
|
|
@@ -1507,64 +1475,37 @@ async function fetchNewApiTokenUsage(context) {
|
|
|
1507
1475
|
};
|
|
1508
1476
|
return usageResult(context, "newapi-token", await formatNewApiTokenLine(context.label, json), normalized);
|
|
1509
1477
|
}
|
|
1510
|
-
async function
|
|
1511
|
-
const
|
|
1512
|
-
const
|
|
1513
|
-
|
|
1514
|
-
name: "
|
|
1515
|
-
headers: await panelUserHeaders()
|
|
1478
|
+
async function fetchOneApiBillingUsage(context) {
|
|
1479
|
+
const base = serviceRoot(context.baseUrl);
|
|
1480
|
+
const subscription = await requestJson(joinUrl(base, "/v1/dashboard/billing/subscription"), {
|
|
1481
|
+
key: context.key,
|
|
1482
|
+
name: "One API billing subscription"
|
|
1516
1483
|
});
|
|
1517
|
-
const
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1484
|
+
const usage = await requestJson(joinUrl(base, "/v1/dashboard/billing/usage"), {
|
|
1485
|
+
key: context.key,
|
|
1486
|
+
name: "One API billing usage"
|
|
1487
|
+
});
|
|
1488
|
+
const limit = pickNumber(subscription, ["hard_limit_usd", "hardLimitUsd"]);
|
|
1489
|
+
const usageCents = pickNumber(usage, ["total_usage", "totalUsage"]);
|
|
1490
|
+
if (limit === void 0 || usageCents === void 0) {
|
|
1491
|
+
throw new Error("One API billing payload has no quota fields");
|
|
1525
1492
|
}
|
|
1526
|
-
const
|
|
1527
|
-
const quota = pickNumber(root, ["quota"]);
|
|
1528
|
-
const used = pickNumber(root, ["used_quota", "usedQuota"]);
|
|
1529
|
-
const remaining = panelQuotaLooksRemaining(kind) ? quota : quota === void 0 || used === void 0 ? quota : Math.max(0, quota - used);
|
|
1530
|
-
const total = panelQuotaLooksRemaining(kind) ? quota === void 0 || used === void 0 ? quota : quota + used : quota;
|
|
1493
|
+
const used = usageCents / 100;
|
|
1531
1494
|
const normalized = {
|
|
1532
1495
|
mode: "quota_limited",
|
|
1533
1496
|
quota: {
|
|
1534
|
-
limit
|
|
1535
|
-
used
|
|
1536
|
-
remaining:
|
|
1497
|
+
limit,
|
|
1498
|
+
used,
|
|
1499
|
+
remaining: Math.max(0, limit - used)
|
|
1537
1500
|
},
|
|
1538
1501
|
unit: "USD",
|
|
1539
|
-
source: "
|
|
1540
|
-
raw:
|
|
1502
|
+
source: "oneapi-billing",
|
|
1503
|
+
raw: { subscription, usage }
|
|
1541
1504
|
};
|
|
1542
1505
|
return usageResult(
|
|
1543
1506
|
context,
|
|
1544
|
-
"
|
|
1545
|
-
|
|
1546
|
-
normalized
|
|
1547
|
-
);
|
|
1548
|
-
}
|
|
1549
|
-
async function fetchSub2ApiAuthMeUsage(context) {
|
|
1550
|
-
const json = await requestJson(joinUrl(serviceRoot(context.baseUrl), "/api/v1/auth/me"), context.key, {
|
|
1551
|
-
name: "Sub2API auth/me"
|
|
1552
|
-
});
|
|
1553
|
-
const root = usageRoot(json);
|
|
1554
|
-
const balance = pickNumber(root, ["balance"]);
|
|
1555
|
-
if (balance === void 0) throw new Error("Sub2API auth/me payload has no balance field");
|
|
1556
|
-
const normalized = {
|
|
1557
|
-
mode: "unrestricted",
|
|
1558
|
-
planName: root?.username || root?.email || context.label || "Sub2API",
|
|
1559
|
-
balance,
|
|
1560
|
-
unit: "USD",
|
|
1561
|
-
source: "sub2api-auth-me",
|
|
1562
|
-
raw: json
|
|
1563
|
-
};
|
|
1564
|
-
return usageResult(
|
|
1565
|
-
context,
|
|
1566
|
-
"sub2api-auth-me",
|
|
1567
|
-
`API | balance ${formatMoney(balance)}`,
|
|
1507
|
+
"oneapi-billing",
|
|
1508
|
+
formatOneApiBillingLine(limit, used),
|
|
1568
1509
|
normalized
|
|
1569
1510
|
);
|
|
1570
1511
|
}
|
|
@@ -1577,7 +1518,7 @@ async function fetchOpenRouterUsage(context) {
|
|
|
1577
1518
|
let lastError;
|
|
1578
1519
|
for (const endpoint of endpoints) {
|
|
1579
1520
|
try {
|
|
1580
|
-
const json = await requestJson(endpoint.url, context.key,
|
|
1521
|
+
const json = await requestJson(endpoint.url, { key: context.key, name: endpoint.source });
|
|
1581
1522
|
return usageResult(context, endpoint.source, formatOpenRouterLine("OpenRouter", json), json);
|
|
1582
1523
|
} catch (error) {
|
|
1583
1524
|
lastError = error;
|
|
@@ -1592,20 +1533,15 @@ var USAGE_ROUTES = {
|
|
|
1592
1533
|
path: "/v1/usage",
|
|
1593
1534
|
run: fetchV1Usage
|
|
1594
1535
|
},
|
|
1595
|
-
"sub2api-auth-me": {
|
|
1596
|
-
id: "sub2api-auth-me",
|
|
1597
|
-
path: "/api/v1/auth/me",
|
|
1598
|
-
run: fetchSub2ApiAuthMeUsage
|
|
1599
|
-
},
|
|
1600
1536
|
"newapi-token": {
|
|
1601
1537
|
id: "newapi-token",
|
|
1602
1538
|
path: "/api/usage/token/",
|
|
1603
1539
|
run: fetchNewApiTokenUsage
|
|
1604
1540
|
},
|
|
1605
|
-
"
|
|
1606
|
-
id: "
|
|
1607
|
-
path: "/
|
|
1608
|
-
run:
|
|
1541
|
+
"oneapi-billing": {
|
|
1542
|
+
id: "oneapi-billing",
|
|
1543
|
+
path: "/v1/dashboard/billing/subscription",
|
|
1544
|
+
run: fetchOneApiBillingUsage
|
|
1609
1545
|
},
|
|
1610
1546
|
"openrouter": {
|
|
1611
1547
|
id: "openrouter",
|
|
@@ -1613,44 +1549,102 @@ var USAGE_ROUTES = {
|
|
|
1613
1549
|
run: fetchOpenRouterUsage
|
|
1614
1550
|
}
|
|
1615
1551
|
};
|
|
1552
|
+
var CUSTOM_ROUTE_HELPERS = { requestJson, agentConfig };
|
|
1553
|
+
var customRoutesPromise;
|
|
1554
|
+
function customRoutes() {
|
|
1555
|
+
customRoutesPromise ||= loadCustomRoutes();
|
|
1556
|
+
return customRoutesPromise;
|
|
1557
|
+
}
|
|
1558
|
+
async function loadRouteModule(file, spec) {
|
|
1559
|
+
try {
|
|
1560
|
+
const mod = await import(pathToFileURL(file).href);
|
|
1561
|
+
if (typeof mod.run !== "function") {
|
|
1562
|
+
throw new Error("missing `export async function run(context, helpers)`");
|
|
1563
|
+
}
|
|
1564
|
+
const id = String(mod.meta?.id || basename(file, extname(file)));
|
|
1565
|
+
return {
|
|
1566
|
+
id,
|
|
1567
|
+
path: spec,
|
|
1568
|
+
run: async (context) => {
|
|
1569
|
+
const result = await mod.run(context, CUSTOM_ROUTE_HELPERS);
|
|
1570
|
+
if (typeof result?.text !== "string" || !result.text) {
|
|
1571
|
+
throw new Error(`custom route ${id} returned no text`);
|
|
1572
|
+
}
|
|
1573
|
+
return {
|
|
1574
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1575
|
+
baseUrl: context.baseUrl,
|
|
1576
|
+
provider: context.providerName,
|
|
1577
|
+
source: id,
|
|
1578
|
+
...result
|
|
1579
|
+
};
|
|
1580
|
+
}
|
|
1581
|
+
};
|
|
1582
|
+
} catch (error) {
|
|
1583
|
+
await debugLog({ source: "custom-route", file, error: error.message });
|
|
1584
|
+
return null;
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
async function loadCustomRoutes() {
|
|
1588
|
+
const routes = [];
|
|
1589
|
+
const config = await agentConfig();
|
|
1590
|
+
const specs = Array.isArray(config.routes) ? config.routes : [];
|
|
1591
|
+
for (const spec of specs) {
|
|
1592
|
+
if (typeof spec !== "string" || !spec.trim()) continue;
|
|
1593
|
+
const file = isAbsolute(spec) ? spec : join2(AGENT_TOOLS_HOME, spec);
|
|
1594
|
+
const route = await loadRouteModule(file, spec);
|
|
1595
|
+
if (route) routes.push(route);
|
|
1596
|
+
}
|
|
1597
|
+
const packagedDir = join2(AGENT_TOOLS_HOME, "dist", "usage", "routes");
|
|
1598
|
+
let packaged = [];
|
|
1599
|
+
try {
|
|
1600
|
+
packaged = (await readdir(packagedDir)).filter((n) => n.endsWith(".mjs")).sort();
|
|
1601
|
+
} catch {
|
|
1602
|
+
packaged = [];
|
|
1603
|
+
}
|
|
1604
|
+
for (const name of packaged) {
|
|
1605
|
+
const route = await loadRouteModule(join2(packagedDir, name), `dist/usage/routes/${name}`);
|
|
1606
|
+
if (route && !routes.some((existing) => existing.id === route.id)) routes.push(route);
|
|
1607
|
+
}
|
|
1608
|
+
return routes;
|
|
1609
|
+
}
|
|
1610
|
+
async function routeRegistry() {
|
|
1611
|
+
const registry = { ...USAGE_ROUTES };
|
|
1612
|
+
for (const route of await customRoutes()) registry[route.id] = route;
|
|
1613
|
+
return registry;
|
|
1614
|
+
}
|
|
1616
1615
|
async function usageRouteIds(context) {
|
|
1617
1616
|
const preset = await usagePreset();
|
|
1618
1617
|
const routes = {
|
|
1619
|
-
"sub2api": ["v1-usage"
|
|
1618
|
+
"sub2api": ["v1-usage"],
|
|
1620
1619
|
"openai-compatible": ["v1-usage"],
|
|
1621
|
-
"new-api": ["newapi-token"
|
|
1622
|
-
"one-api": ["
|
|
1623
|
-
"onehub": ["newapi-token", "panel-user-self"],
|
|
1624
|
-
"one-hub": ["newapi-token", "panel-user-self"],
|
|
1625
|
-
"donehub": ["newapi-token", "panel-user-self"],
|
|
1626
|
-
"done-hub": ["newapi-token", "panel-user-self"],
|
|
1627
|
-
"veloera": ["panel-user-self", "newapi-token"],
|
|
1628
|
-
"anyrouter": ["newapi-token", "panel-user-self", "v1-usage"],
|
|
1629
|
-
"agentrouter": ["newapi-token", "panel-user-self", "v1-usage"],
|
|
1620
|
+
"new-api": ["newapi-token"],
|
|
1621
|
+
"one-api": ["oneapi-billing"],
|
|
1630
1622
|
"openrouter": ["openrouter"]
|
|
1631
1623
|
};
|
|
1632
1624
|
if (routes[preset]) return routes[preset];
|
|
1633
|
-
if (preset !== "auto") return [];
|
|
1634
|
-
|
|
1635
|
-
|
|
1625
|
+
if (preset !== "auto") return (await routeRegistry())[preset] ? [preset] : [];
|
|
1626
|
+
const customIds = (await customRoutes()).map((route) => route.id);
|
|
1627
|
+
const builtinIds = hostIncludes(context.baseUrl, "openrouter.ai") ? ["openrouter"] : ["v1-usage", "newapi-token", "oneapi-billing"];
|
|
1628
|
+
return [.../* @__PURE__ */ new Set([...customIds, ...builtinIds])];
|
|
1636
1629
|
}
|
|
1637
|
-
async function cachedUsageRoute(context) {
|
|
1630
|
+
async function cachedUsageRoute(context, registry) {
|
|
1638
1631
|
const cache = await readRouteCache();
|
|
1639
1632
|
const key = usageRouteCacheKey(context.baseUrl);
|
|
1640
1633
|
const route = cache.routes[key];
|
|
1641
|
-
return route?.route &&
|
|
1634
|
+
return route?.route && registry[route.route] ? route : null;
|
|
1642
1635
|
}
|
|
1643
1636
|
async function orderedUsageRoutes(context) {
|
|
1637
|
+
const registry = await routeRegistry();
|
|
1644
1638
|
const routeIds = await usageRouteIds(context);
|
|
1645
|
-
const cached = await cachedUsageRoute(context);
|
|
1646
|
-
if (!cached || !routeIds.includes(cached.route)) return routeIds.map((id) =>
|
|
1639
|
+
const cached = await cachedUsageRoute(context, registry);
|
|
1640
|
+
if (!cached || !routeIds.includes(cached.route)) return routeIds.map((id) => registry[id]).filter(Boolean);
|
|
1647
1641
|
await debugLog({
|
|
1648
1642
|
source: "route-cache",
|
|
1649
1643
|
key: usageRouteCacheKey(context.baseUrl),
|
|
1650
1644
|
route: cached.route,
|
|
1651
|
-
path: cached.path ||
|
|
1645
|
+
path: cached.path || registry[cached.route]?.path || ""
|
|
1652
1646
|
});
|
|
1653
|
-
return [cached.route, ...routeIds.filter((id) => id !== cached.route)].map((id) =>
|
|
1647
|
+
return [cached.route, ...routeIds.filter((id) => id !== cached.route)].map((id) => registry[id]).filter(Boolean);
|
|
1654
1648
|
}
|
|
1655
1649
|
|
|
1656
1650
|
// integrations/usage/lib/context.mjs
|
|
@@ -1844,11 +1838,14 @@ async function queryProviderUsage(input, options = {}) {
|
|
|
1844
1838
|
async function refresh(agent = "codex") {
|
|
1845
1839
|
return await queryAgentProviderUsage(agent);
|
|
1846
1840
|
}
|
|
1847
|
-
async function queryAgentProviderUsage(agent = "codex") {
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1841
|
+
async function queryAgentProviderUsage(agent = "codex", { maxAgeMs = 0 } = {}) {
|
|
1842
|
+
const context = await usageContext(agent);
|
|
1843
|
+
if (maxAgeMs > 0) {
|
|
1844
|
+
const cached = await readUsageSnapshot(context);
|
|
1845
|
+
const age = cached?.updatedAt ? Date.now() - Date.parse(cached.updatedAt) : Infinity;
|
|
1846
|
+
if (cached?.text && age < maxAgeMs) return { ...cached, cached: true };
|
|
1847
|
+
}
|
|
1848
|
+
return await queryUsageContext(context, { agent, rememberSnapshot: true });
|
|
1852
1849
|
}
|
|
1853
1850
|
async function main() {
|
|
1854
1851
|
try {
|
|
@@ -1858,7 +1855,7 @@ async function main() {
|
|
|
1858
1855
|
const result = await refresh(cli.agent);
|
|
1859
1856
|
textOut(result?.text || "");
|
|
1860
1857
|
} else if (mode === "hook") {
|
|
1861
|
-
const result = await
|
|
1858
|
+
const result = await queryAgentProviderUsage(cli.agent, { maxAgeMs: snapshotTtlMs() });
|
|
1862
1859
|
hookOut(result?.text || "");
|
|
1863
1860
|
} else {
|
|
1864
1861
|
throw new Error(`unknown mode: ${mode}`);
|
|
@@ -1867,7 +1864,7 @@ async function main() {
|
|
|
1867
1864
|
failSoft("Provider usage unavailable", error);
|
|
1868
1865
|
}
|
|
1869
1866
|
}
|
|
1870
|
-
if (process.argv[1] && import.meta.url ===
|
|
1867
|
+
if (process.argv[1] && import.meta.url === pathToFileURL2(process.argv[1]).href) {
|
|
1871
1868
|
await main();
|
|
1872
1869
|
}
|
|
1873
1870
|
export {
|
|
@@ -8,9 +8,10 @@
|
|
|
8
8
|
// routes) and is bundled into dist/usage/core.mjs at build time.
|
|
9
9
|
|
|
10
10
|
import { pathToFileURL } from "node:url";
|
|
11
|
-
import { debugLog, usagePreset } from "./lib/config.mjs";
|
|
11
|
+
import { debugLog, usagePreset, snapshotTtlMs } from "./lib/config.mjs";
|
|
12
12
|
import { isOfficialBaseUrl } from "./lib/urls.mjs";
|
|
13
13
|
import {
|
|
14
|
+
readUsageSnapshot,
|
|
14
15
|
rememberUsageRoute,
|
|
15
16
|
rememberUsageSnapshot,
|
|
16
17
|
rememberRefreshState,
|
|
@@ -113,11 +114,14 @@ async function refresh(agent = "codex") {
|
|
|
113
114
|
return await queryAgentProviderUsage(agent);
|
|
114
115
|
}
|
|
115
116
|
|
|
116
|
-
export async function queryAgentProviderUsage(agent = "codex") {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
117
|
+
export async function queryAgentProviderUsage(agent = "codex", { maxAgeMs = 0 } = {}) {
|
|
118
|
+
const context = await usageContext(agent);
|
|
119
|
+
if (maxAgeMs > 0) {
|
|
120
|
+
const cached = await readUsageSnapshot(context);
|
|
121
|
+
const age = cached?.updatedAt ? Date.now() - Date.parse(cached.updatedAt) : Infinity;
|
|
122
|
+
if (cached?.text && age < maxAgeMs) return { ...cached, cached: true };
|
|
123
|
+
}
|
|
124
|
+
return await queryUsageContext(context, { agent, rememberSnapshot: true });
|
|
121
125
|
}
|
|
122
126
|
|
|
123
127
|
async function main() {
|
|
@@ -128,7 +132,8 @@ async function main() {
|
|
|
128
132
|
const result = await refresh(cli.agent);
|
|
129
133
|
textOut(result?.text || "");
|
|
130
134
|
} else if (mode === "hook") {
|
|
131
|
-
|
|
135
|
+
// Hook mode fires on every prompt; serve a fresh snapshot when possible.
|
|
136
|
+
const result = await queryAgentProviderUsage(cli.agent, { maxAgeMs: snapshotTtlMs() });
|
|
132
137
|
hookOut(result?.text || "");
|
|
133
138
|
} else {
|
|
134
139
|
throw new Error(`unknown mode: ${mode}`);
|
|
@@ -61,6 +61,11 @@ async function readSnapshotCache() {
|
|
|
61
61
|
}
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
export async function readUsageSnapshot(context) {
|
|
65
|
+
const cache = await readSnapshotCache();
|
|
66
|
+
return cache.items[usageRouteCacheKey(context.baseUrl)] || null;
|
|
67
|
+
}
|
|
68
|
+
|
|
64
69
|
export async function rememberUsageSnapshot(context, result) {
|
|
65
70
|
if (!result?.text) return;
|
|
66
71
|
try {
|
|
@@ -61,8 +61,8 @@ export async function debugLog(event) {
|
|
|
61
61
|
export async function providerUsageDays() {
|
|
62
62
|
const config = await agentConfig();
|
|
63
63
|
const value = Number(process.env.PROVIDER_USAGE_DAYS || config.days || DEFAULT_USAGE_DAYS);
|
|
64
|
-
if (!Number.isInteger(value) || value <= 0
|
|
65
|
-
return value;
|
|
64
|
+
if (!Number.isInteger(value) || value <= 0) return DEFAULT_USAGE_DAYS;
|
|
65
|
+
return Math.min(value, MAX_USAGE_DAYS);
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
export async function usagePreset() {
|
|
@@ -70,26 +70,12 @@ export async function usagePreset() {
|
|
|
70
70
|
return String(process.env.PROVIDER_USAGE_PRESET || config.preset || "auto").toLowerCase();
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
export async function panelUserHeaders() {
|
|
81
|
-
const userId = await panelUserId();
|
|
82
|
-
if (!userId) return {};
|
|
83
|
-
const value = String(userId);
|
|
84
|
-
return {
|
|
85
|
-
"New-API-User": value,
|
|
86
|
-
"Veloera-User": value,
|
|
87
|
-
"voapi-user": value,
|
|
88
|
-
"User-id": value,
|
|
89
|
-
"X-User-Id": value,
|
|
90
|
-
"Rix-Api-User": value,
|
|
91
|
-
"neo-api-user": value,
|
|
92
|
-
};
|
|
73
|
+
// Passive callers (the codex hook fires per prompt; several sessions may run
|
|
74
|
+
// at once) reuse a fresh snapshot instead of hitting the gateway every time.
|
|
75
|
+
// Same knob the statusline uses; 0 disables.
|
|
76
|
+
export function snapshotTtlMs() {
|
|
77
|
+
const raw = Number(process.env.AGENT_TOOLS_USAGE_SNAPSHOT_TTL_MS);
|
|
78
|
+
return Number.isFinite(raw) && raw >= 0 ? raw : 60_000;
|
|
93
79
|
}
|
|
94
80
|
|
|
95
81
|
export async function newApiQuotaScale() {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Turns gateway payloads into the compact one-line usage message.
|
|
2
2
|
|
|
3
|
-
import { newApiQuotaScale, providerUsageDays
|
|
3
|
+
import { newApiQuotaScale, providerUsageDays } from "./config.mjs";
|
|
4
4
|
|
|
5
5
|
export function pickNumber(obj, keys) {
|
|
6
6
|
for (const key of keys) {
|
|
@@ -159,45 +159,8 @@ export function formatOpenRouterLine(label, data) {
|
|
|
159
159
|
return parts.join(" | ");
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
-
export function
|
|
163
|
-
return
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
export function panelQuotaLooksRemaining(kind) {
|
|
167
|
-
return ["new-api", "anyrouter", "agentrouter", "done-hub", "donehub"].includes(kind);
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
export async function formatPanelUserSelfLine(label, data, kind) {
|
|
171
|
-
const root = usageRoot(data);
|
|
172
|
-
const scale = panelQuotaScale(kind);
|
|
173
|
-
const quota = pickNumber(root, ["quota"]);
|
|
174
|
-
const used = pickNumber(root, ["used_quota", "usedQuota"]);
|
|
175
|
-
const todayIncome = pickNumber(root, ["today_income", "todayIncome"]);
|
|
176
|
-
const todayUsed = pickNumber(root, ["today_quota_consumption", "todayQuotaConsumption"]);
|
|
177
|
-
|
|
178
|
-
if (quota === undefined && used === undefined) {
|
|
179
|
-
throw new Error("panel /api/user/self payload has no quota fields");
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
const quotaUsd = quota === undefined ? undefined : quota / scale;
|
|
183
|
-
const usedUsd = used === undefined ? undefined : used / scale;
|
|
184
|
-
const remainingUsd = panelQuotaLooksRemaining(kind)
|
|
185
|
-
? quotaUsd
|
|
186
|
-
: (quotaUsd === undefined || usedUsd === undefined ? quotaUsd : Math.max(0, quotaUsd - usedUsd));
|
|
187
|
-
const totalUsd = panelQuotaLooksRemaining(kind)
|
|
188
|
-
? (quotaUsd === undefined || usedUsd === undefined ? quotaUsd : quotaUsd + usedUsd)
|
|
189
|
-
: quotaUsd;
|
|
190
|
-
|
|
191
|
-
const parts = usageParts();
|
|
192
|
-
if (remainingUsd !== undefined) parts.push(`balance ${formatMoney(remainingUsd)}`);
|
|
193
|
-
if (usedUsd !== undefined && totalUsd !== undefined) {
|
|
194
|
-
parts.push(`used ${formatMoney(usedUsd)}/${formatMoney(totalUsd)}`);
|
|
195
|
-
} else if (usedUsd !== undefined) {
|
|
196
|
-
parts.push(`used ${formatMoney(usedUsd)}`);
|
|
197
|
-
}
|
|
198
|
-
if (todayUsed !== undefined) parts.push(`today ${formatMoney(todayUsed / scale)}`);
|
|
199
|
-
if (todayIncome !== undefined) parts.push(`income ${formatMoney(todayIncome / scale)}`);
|
|
200
|
-
return parts.join(" | ");
|
|
162
|
+
export function formatOneApiBillingLine(limit, used) {
|
|
163
|
+
return `API | balance ${formatMoney(Math.max(0, limit - used))} | used ${formatMoney(used)}/${formatMoney(limit)}`;
|
|
201
164
|
}
|
|
202
165
|
|
|
203
166
|
function formatQuotaLimitedLine(label, root) {
|
|
@@ -125,7 +125,8 @@ function mergeSetCookiePairs(cookieHeader, setCookieHeaders) {
|
|
|
125
125
|
return merged;
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
-
export async function requestJson(url,
|
|
128
|
+
export async function requestJson(url, options = {}) {
|
|
129
|
+
const { key = "", headers = {}, name = "usage" } = options;
|
|
129
130
|
let cookieHeader = "";
|
|
130
131
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
131
132
|
const controller = new AbortController();
|
|
@@ -133,10 +134,10 @@ export async function requestJson(url, key, options = {}) {
|
|
|
133
134
|
const response = await fetch(url, {
|
|
134
135
|
headers: {
|
|
135
136
|
accept: "application/json",
|
|
136
|
-
authorization: `Bearer ${
|
|
137
|
+
...(key ? { authorization: `Bearer ${key}` } : {}),
|
|
137
138
|
"user-agent": SHIELD_USER_AGENT,
|
|
138
139
|
...(cookieHeader ? { cookie: cookieHeader } : {}),
|
|
139
|
-
...
|
|
140
|
+
...headers,
|
|
140
141
|
},
|
|
141
142
|
signal: controller.signal,
|
|
142
143
|
});
|
|
@@ -151,7 +152,7 @@ export async function requestJson(url, key, options = {}) {
|
|
|
151
152
|
const contentType = response.headers.get("content-type") || "";
|
|
152
153
|
const acwScV2 = isShieldChallenge(contentType, body) ? solveNewApiAcwScV2(body) : "";
|
|
153
154
|
await debugLog({
|
|
154
|
-
source:
|
|
155
|
+
source: name,
|
|
155
156
|
url,
|
|
156
157
|
status: response.status,
|
|
157
158
|
contentType,
|
|
@@ -162,19 +163,19 @@ export async function requestJson(url, key, options = {}) {
|
|
|
162
163
|
cookieHeader = upsertCookie(cookieHeader, "acw_sc__v2", acwScV2);
|
|
163
164
|
continue;
|
|
164
165
|
}
|
|
165
|
-
throw new Error(`${
|
|
166
|
+
throw new Error(`${name} returned non-JSON (${response.status})`);
|
|
166
167
|
}
|
|
167
168
|
|
|
168
169
|
if (!response.ok) {
|
|
169
170
|
const message = json?.error?.message || json?.message || response.statusText;
|
|
170
171
|
await debugLog({
|
|
171
|
-
source:
|
|
172
|
+
source: name,
|
|
172
173
|
url,
|
|
173
174
|
status: response.status,
|
|
174
175
|
message,
|
|
175
176
|
bodyPreview: shortPreview(body),
|
|
176
177
|
});
|
|
177
|
-
throw new Error(`${
|
|
178
|
+
throw new Error(`${name} failed (${response.status} ${message})`);
|
|
178
179
|
}
|
|
179
180
|
|
|
180
181
|
return json;
|
|
@@ -182,5 +183,5 @@ export async function requestJson(url, key, options = {}) {
|
|
|
182
183
|
clearTimeout(timeout);
|
|
183
184
|
}
|
|
184
185
|
}
|
|
185
|
-
throw new Error(`${
|
|
186
|
+
throw new Error(`${name} unavailable`);
|
|
186
187
|
}
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
// Known gateway usage endpoints and the probing order for a given context.
|
|
2
2
|
|
|
3
|
+
import { readdir } from "node:fs/promises";
|
|
4
|
+
import { basename, extname, isAbsolute, join } from "node:path";
|
|
5
|
+
import { pathToFileURL } from "node:url";
|
|
3
6
|
import { requestJson } from "./http.mjs";
|
|
4
7
|
import {
|
|
8
|
+
AGENT_TOOLS_HOME,
|
|
9
|
+
agentConfig,
|
|
5
10
|
usagePreset,
|
|
6
|
-
panelUserHeaders,
|
|
7
11
|
newApiQuotaScale,
|
|
8
12
|
providerUsageDays,
|
|
9
13
|
debugLog,
|
|
@@ -17,15 +21,12 @@ import {
|
|
|
17
21
|
} from "./urls.mjs";
|
|
18
22
|
import {
|
|
19
23
|
pickNumber,
|
|
20
|
-
formatMoney,
|
|
21
24
|
usageRoot,
|
|
22
25
|
hasV1UsageFields,
|
|
23
26
|
formatQuota,
|
|
24
27
|
formatNewApiTokenLine,
|
|
28
|
+
formatOneApiBillingLine,
|
|
25
29
|
formatOpenRouterLine,
|
|
26
|
-
formatPanelUserSelfLine,
|
|
27
|
-
panelQuotaScale,
|
|
28
|
-
panelQuotaLooksRemaining,
|
|
29
30
|
} from "./format.mjs";
|
|
30
31
|
import { readRouteCache } from "./cache.mjs";
|
|
31
32
|
|
|
@@ -50,19 +51,20 @@ function usageResult(context, source, text, raw) {
|
|
|
50
51
|
// OpenAI-style endpoint at /v1/usage. This is intentionally probed first for
|
|
51
52
|
// generic non-OpenAI base URLs because it does not require a management token.
|
|
52
53
|
async function fetchV1Usage(context) {
|
|
53
|
-
const json = await requestJson(await subscriptionUrl(context.baseUrl),
|
|
54
|
+
const json = await requestJson(await subscriptionUrl(context.baseUrl), {
|
|
55
|
+
key: context.key,
|
|
54
56
|
name: "v1 usage",
|
|
55
57
|
});
|
|
56
58
|
if (!hasV1UsageFields(usageRoot(json))) throw new Error("v1 usage payload has no usage fields");
|
|
57
59
|
return usageResult(context, "v1-usage", await formatQuota(context.label, json), json);
|
|
58
60
|
}
|
|
59
61
|
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
// path.
|
|
62
|
+
// New API exposes a read-only usage endpoint authenticated by the same relay
|
|
63
|
+
// API key used for model requests.
|
|
63
64
|
async function fetchNewApiTokenUsage(context) {
|
|
64
|
-
const json = await requestJson(joinUrl(serviceRoot(context.baseUrl), "/api/usage/token/"),
|
|
65
|
-
|
|
65
|
+
const json = await requestJson(joinUrl(serviceRoot(context.baseUrl), "/api/usage/token/"), {
|
|
66
|
+
key: context.key,
|
|
67
|
+
name: "New API token usage",
|
|
66
68
|
});
|
|
67
69
|
const root = usageRoot(json);
|
|
68
70
|
const quota = pickNumber(root, ["quota", "limit", "total_quota", "totalQuota"]);
|
|
@@ -89,82 +91,46 @@ async function fetchNewApiTokenUsage(context) {
|
|
|
89
91
|
return usageResult(context, "newapi-token", await formatNewApiTokenLine(context.label, json), normalized);
|
|
90
92
|
}
|
|
91
93
|
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
name: "panel /api/user/self",
|
|
100
|
-
headers: await panelUserHeaders(),
|
|
94
|
+
// One API's legacy OpenAI billing endpoints use the same relay API key as
|
|
95
|
+
// model requests. Subscription reports the total quota; usage reports cents.
|
|
96
|
+
async function fetchOneApiBillingUsage(context) {
|
|
97
|
+
const base = serviceRoot(context.baseUrl);
|
|
98
|
+
const subscription = await requestJson(joinUrl(base, "/v1/dashboard/billing/subscription"), {
|
|
99
|
+
key: context.key,
|
|
100
|
+
name: "One API billing subscription",
|
|
101
101
|
});
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
102
|
+
const usage = await requestJson(joinUrl(base, "/v1/dashboard/billing/usage"), {
|
|
103
|
+
key: context.key,
|
|
104
|
+
name: "One API billing usage",
|
|
105
|
+
});
|
|
106
|
+
const limit = pickNumber(subscription, ["hard_limit_usd", "hardLimitUsd"]);
|
|
107
|
+
const usageCents = pickNumber(usage, ["total_usage", "totalUsage"]);
|
|
108
|
+
if (limit === undefined || usageCents === undefined) {
|
|
109
|
+
throw new Error("One API billing payload has no quota fields");
|
|
110
110
|
}
|
|
111
|
-
const
|
|
112
|
-
const quota = pickNumber(root, ["quota"]);
|
|
113
|
-
const used = pickNumber(root, ["used_quota", "usedQuota"]);
|
|
114
|
-
const remaining = panelQuotaLooksRemaining(kind)
|
|
115
|
-
? quota
|
|
116
|
-
: (quota === undefined || used === undefined ? quota : Math.max(0, quota - used));
|
|
117
|
-
const total = panelQuotaLooksRemaining(kind)
|
|
118
|
-
? (quota === undefined || used === undefined ? quota : quota + used)
|
|
119
|
-
: quota;
|
|
111
|
+
const used = usageCents / 100;
|
|
120
112
|
const normalized = {
|
|
121
113
|
mode: "quota_limited",
|
|
122
114
|
quota: {
|
|
123
|
-
limit
|
|
124
|
-
used
|
|
125
|
-
remaining:
|
|
115
|
+
limit,
|
|
116
|
+
used,
|
|
117
|
+
remaining: Math.max(0, limit - used),
|
|
126
118
|
},
|
|
127
119
|
unit: "USD",
|
|
128
|
-
source: "
|
|
129
|
-
raw:
|
|
130
|
-
};
|
|
131
|
-
return usageResult(
|
|
132
|
-
context,
|
|
133
|
-
"panel-user-self",
|
|
134
|
-
await formatPanelUserSelfLine(context.label, json, kind),
|
|
135
|
-
normalized
|
|
136
|
-
);
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
// Sub2API exposes user balance as USD at /api/v1/auth/me. Newer deployments may
|
|
140
|
-
// also expose richer subscription summaries through /v1/usage, so this route is
|
|
141
|
-
// a fallback for deployments where /v1/usage is unavailable.
|
|
142
|
-
async function fetchSub2ApiAuthMeUsage(context) {
|
|
143
|
-
const json = await requestJson(joinUrl(serviceRoot(context.baseUrl), "/api/v1/auth/me"), context.key, {
|
|
144
|
-
name: "Sub2API auth/me",
|
|
145
|
-
});
|
|
146
|
-
const root = usageRoot(json);
|
|
147
|
-
const balance = pickNumber(root, ["balance"]);
|
|
148
|
-
if (balance === undefined) throw new Error("Sub2API auth/me payload has no balance field");
|
|
149
|
-
const normalized = {
|
|
150
|
-
mode: "unrestricted",
|
|
151
|
-
planName: root?.username || root?.email || context.label || "Sub2API",
|
|
152
|
-
balance,
|
|
153
|
-
unit: "USD",
|
|
154
|
-
source: "sub2api-auth-me",
|
|
155
|
-
raw: json,
|
|
120
|
+
source: "oneapi-billing",
|
|
121
|
+
raw: { subscription, usage },
|
|
156
122
|
};
|
|
157
123
|
return usageResult(
|
|
158
124
|
context,
|
|
159
|
-
"
|
|
160
|
-
|
|
125
|
+
"oneapi-billing",
|
|
126
|
+
formatOneApiBillingLine(limit, used),
|
|
161
127
|
normalized
|
|
162
128
|
);
|
|
163
129
|
}
|
|
164
130
|
|
|
165
131
|
// OpenRouter exposes normal API-key usage at /api/v1/key. Some accounts also
|
|
166
132
|
// expose credits at /api/v1/credits; keep this route isolated because
|
|
167
|
-
// OpenRouter's base URL already includes /api/v1, unlike
|
|
133
|
+
// OpenRouter's base URL already includes /api/v1, unlike New API.
|
|
168
134
|
async function fetchOpenRouterUsage(context) {
|
|
169
135
|
const base = cleanBaseUrl(context.baseUrl).includes("/api/v1")
|
|
170
136
|
? cleanBaseUrl(context.baseUrl)
|
|
@@ -176,7 +142,7 @@ async function fetchOpenRouterUsage(context) {
|
|
|
176
142
|
let lastError;
|
|
177
143
|
for (const endpoint of endpoints) {
|
|
178
144
|
try {
|
|
179
|
-
const json = await requestJson(endpoint.url, context.key,
|
|
145
|
+
const json = await requestJson(endpoint.url, { key: context.key, name: endpoint.source });
|
|
180
146
|
return usageResult(context, endpoint.source, formatOpenRouterLine("OpenRouter", json), json);
|
|
181
147
|
} catch (error) {
|
|
182
148
|
lastError = error;
|
|
@@ -192,20 +158,15 @@ const USAGE_ROUTES = {
|
|
|
192
158
|
path: "/v1/usage",
|
|
193
159
|
run: fetchV1Usage,
|
|
194
160
|
},
|
|
195
|
-
"sub2api-auth-me": {
|
|
196
|
-
id: "sub2api-auth-me",
|
|
197
|
-
path: "/api/v1/auth/me",
|
|
198
|
-
run: fetchSub2ApiAuthMeUsage,
|
|
199
|
-
},
|
|
200
161
|
"newapi-token": {
|
|
201
162
|
id: "newapi-token",
|
|
202
163
|
path: "/api/usage/token/",
|
|
203
164
|
run: fetchNewApiTokenUsage,
|
|
204
165
|
},
|
|
205
|
-
"
|
|
206
|
-
id: "
|
|
207
|
-
path: "/
|
|
208
|
-
run:
|
|
166
|
+
"oneapi-billing": {
|
|
167
|
+
id: "oneapi-billing",
|
|
168
|
+
path: "/v1/dashboard/billing/subscription",
|
|
169
|
+
run: fetchOneApiBillingUsage,
|
|
209
170
|
},
|
|
210
171
|
"openrouter": {
|
|
211
172
|
id: "openrouter",
|
|
@@ -214,52 +175,129 @@ const USAGE_ROUTES = {
|
|
|
214
175
|
},
|
|
215
176
|
};
|
|
216
177
|
|
|
217
|
-
//
|
|
218
|
-
//
|
|
219
|
-
//
|
|
220
|
-
//
|
|
221
|
-
//
|
|
178
|
+
// User-authored gateway routes, declared in config.jsonc:
|
|
179
|
+
// "providerUsage": { "routes": ["custom/my-gateway.mjs"] }
|
|
180
|
+
// Paths resolve against ~/.agent-tools. Each module exports
|
|
181
|
+
// `export async function run(context, helpers)` plus an optional
|
|
182
|
+
// `export const meta = { id }` (id defaults to the file name). Broken modules
|
|
183
|
+
// are logged and skipped so a bad custom route cannot break the built-ins.
|
|
184
|
+
const CUSTOM_ROUTE_HELPERS = { requestJson, agentConfig };
|
|
185
|
+
|
|
186
|
+
let customRoutesPromise;
|
|
187
|
+
function customRoutes() {
|
|
188
|
+
customRoutesPromise ||= loadCustomRoutes();
|
|
189
|
+
return customRoutesPromise;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function loadRouteModule(file, spec) {
|
|
193
|
+
try {
|
|
194
|
+
const mod = await import(pathToFileURL(file).href);
|
|
195
|
+
if (typeof mod.run !== "function") {
|
|
196
|
+
throw new Error("missing `export async function run(context, helpers)`");
|
|
197
|
+
}
|
|
198
|
+
const id = String(mod.meta?.id || basename(file, extname(file)));
|
|
199
|
+
return {
|
|
200
|
+
id,
|
|
201
|
+
path: spec,
|
|
202
|
+
run: async (context) => {
|
|
203
|
+
const result = await mod.run(context, CUSTOM_ROUTE_HELPERS);
|
|
204
|
+
if (typeof result?.text !== "string" || !result.text) {
|
|
205
|
+
throw new Error(`custom route ${id} returned no text`);
|
|
206
|
+
}
|
|
207
|
+
return {
|
|
208
|
+
updatedAt: new Date().toISOString(),
|
|
209
|
+
baseUrl: context.baseUrl,
|
|
210
|
+
provider: context.providerName,
|
|
211
|
+
source: id,
|
|
212
|
+
...result,
|
|
213
|
+
};
|
|
214
|
+
},
|
|
215
|
+
};
|
|
216
|
+
} catch (error) {
|
|
217
|
+
await debugLog({ source: "custom-route", file, error: error.message });
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function loadCustomRoutes() {
|
|
223
|
+
const routes = [];
|
|
224
|
+
|
|
225
|
+
// config-declared routes probe first, in config order.
|
|
226
|
+
const config = await agentConfig();
|
|
227
|
+
const specs = Array.isArray(config.routes) ? config.routes : [];
|
|
228
|
+
for (const spec of specs) {
|
|
229
|
+
if (typeof spec !== "string" || !spec.trim()) continue;
|
|
230
|
+
const file = isAbsolute(spec) ? spec : join(AGENT_TOOLS_HOME, spec);
|
|
231
|
+
const route = await loadRouteModule(file, spec);
|
|
232
|
+
if (route) routes.push(route);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Repo-shipped routes: the installer replaces this directory from the
|
|
236
|
+
// package's dist/usage/routes on every install, so a fork can distribute
|
|
237
|
+
// gateways to everyone under git control. Config-declared ids win.
|
|
238
|
+
const packagedDir = join(AGENT_TOOLS_HOME, "dist", "usage", "routes");
|
|
239
|
+
let packaged = [];
|
|
240
|
+
try {
|
|
241
|
+
packaged = (await readdir(packagedDir)).filter((n) => n.endsWith(".mjs")).sort();
|
|
242
|
+
} catch {
|
|
243
|
+
packaged = [];
|
|
244
|
+
}
|
|
245
|
+
for (const name of packaged) {
|
|
246
|
+
const route = await loadRouteModule(join(packagedDir, name), `dist/usage/routes/${name}`);
|
|
247
|
+
if (route && !routes.some((existing) => existing.id === route.id)) routes.push(route);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return routes;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function routeRegistry() {
|
|
254
|
+
const registry = { ...USAGE_ROUTES };
|
|
255
|
+
for (const route of await customRoutes()) registry[route.id] = route;
|
|
256
|
+
return registry;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Presets select API-key usage protocols, not hosted gateway brands.
|
|
222
260
|
async function usageRouteIds(context) {
|
|
223
261
|
const preset = await usagePreset();
|
|
224
262
|
const routes = {
|
|
225
|
-
"sub2api": ["v1-usage"
|
|
263
|
+
"sub2api": ["v1-usage"],
|
|
226
264
|
"openai-compatible": ["v1-usage"],
|
|
227
|
-
"new-api": ["newapi-token"
|
|
228
|
-
"one-api": ["
|
|
229
|
-
"onehub": ["newapi-token", "panel-user-self"],
|
|
230
|
-
"one-hub": ["newapi-token", "panel-user-self"],
|
|
231
|
-
"donehub": ["newapi-token", "panel-user-self"],
|
|
232
|
-
"done-hub": ["newapi-token", "panel-user-self"],
|
|
233
|
-
"veloera": ["panel-user-self", "newapi-token"],
|
|
234
|
-
"anyrouter": ["newapi-token", "panel-user-self", "v1-usage"],
|
|
235
|
-
"agentrouter": ["newapi-token", "panel-user-self", "v1-usage"],
|
|
265
|
+
"new-api": ["newapi-token"],
|
|
266
|
+
"one-api": ["oneapi-billing"],
|
|
236
267
|
"openrouter": ["openrouter"],
|
|
237
268
|
};
|
|
238
269
|
if (routes[preset]) return routes[preset];
|
|
239
270
|
|
|
240
|
-
|
|
241
|
-
if (
|
|
242
|
-
|
|
271
|
+
// A preset naming a registered route id (built-in or custom) selects it.
|
|
272
|
+
if (preset !== "auto") return (await routeRegistry())[preset] ? [preset] : [];
|
|
273
|
+
|
|
274
|
+
// Declared custom routes probe first, in config order.
|
|
275
|
+
const customIds = (await customRoutes()).map((route) => route.id);
|
|
276
|
+
const builtinIds = hostIncludes(context.baseUrl, "openrouter.ai")
|
|
277
|
+
? ["openrouter"]
|
|
278
|
+
: ["v1-usage", "newapi-token", "oneapi-billing"];
|
|
279
|
+
return [...new Set([...customIds, ...builtinIds])];
|
|
243
280
|
}
|
|
244
281
|
|
|
245
|
-
async function cachedUsageRoute(context) {
|
|
282
|
+
async function cachedUsageRoute(context, registry) {
|
|
246
283
|
const cache = await readRouteCache();
|
|
247
284
|
const key = usageRouteCacheKey(context.baseUrl);
|
|
248
285
|
const route = cache.routes[key];
|
|
249
|
-
return route?.route &&
|
|
286
|
+
return route?.route && registry[route.route] ? route : null;
|
|
250
287
|
}
|
|
251
288
|
|
|
252
289
|
export async function orderedUsageRoutes(context) {
|
|
290
|
+
const registry = await routeRegistry();
|
|
253
291
|
const routeIds = await usageRouteIds(context);
|
|
254
|
-
const cached = await cachedUsageRoute(context);
|
|
255
|
-
if (!cached || !routeIds.includes(cached.route)) return routeIds.map((id) =>
|
|
292
|
+
const cached = await cachedUsageRoute(context, registry);
|
|
293
|
+
if (!cached || !routeIds.includes(cached.route)) return routeIds.map((id) => registry[id]).filter(Boolean);
|
|
256
294
|
await debugLog({
|
|
257
295
|
source: "route-cache",
|
|
258
296
|
key: usageRouteCacheKey(context.baseUrl),
|
|
259
297
|
route: cached.route,
|
|
260
|
-
path: cached.path ||
|
|
298
|
+
path: cached.path || registry[cached.route]?.path || "",
|
|
261
299
|
});
|
|
262
300
|
return [cached.route, ...routeIds.filter((id) => id !== cached.route)]
|
|
263
|
-
.map((id) =>
|
|
301
|
+
.map((id) => registry[id])
|
|
264
302
|
.filter(Boolean);
|
|
265
303
|
}
|
|
File without changes
|
package/package.json
CHANGED
package/scripts/build.mjs
CHANGED
|
@@ -37,6 +37,15 @@ const TARGETS = {
|
|
|
37
37
|
},
|
|
38
38
|
};
|
|
39
39
|
|
|
40
|
+
// Repo-shipped usage routes (a fork can commit integrations/usage/routes/*.mjs
|
|
41
|
+
// to distribute custom gateways to everyone who installs). Absent upstream.
|
|
42
|
+
const USAGE_ROUTES_DIR = path.join(ROOT, "integrations", "usage", "routes");
|
|
43
|
+
if (fs.existsSync(USAGE_ROUTES_DIR)) {
|
|
44
|
+
for (const file of fs.readdirSync(USAGE_ROUTES_DIR).filter((n) => n.endsWith(".mjs"))) {
|
|
45
|
+
TARGETS.usage.entryPoints[`routes/${file.slice(0, -4)}`] = path.join(USAGE_ROUTES_DIR, file);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
40
49
|
for (const [name, { entryPoints, external = [] }] of Object.entries(TARGETS)) {
|
|
41
50
|
const outDir = path.join(DIST, name);
|
|
42
51
|
const stage = path.join(DIST, `.${name}-build-${process.pid}-${Date.now()}`);
|
package/scripts/install.mjs
CHANGED
|
@@ -242,6 +242,26 @@ function installRuntimeAssets(opts) {
|
|
|
242
242
|
}
|
|
243
243
|
console.log(`runtime: ${INSTALL_ROOT}`);
|
|
244
244
|
for (const [src, dest, options] of files) copyRuntimeFile(src, dest, opts.dryRun, options);
|
|
245
|
+
if (wants(opts, "usage")) syncUsageRoutesDir(opts.dryRun);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Repo-shipped usage routes are replaced wholesale so routes removed from the
|
|
249
|
+
// repo do not linger (a stale file would still be loaded).
|
|
250
|
+
const SOURCE_USAGE_ROUTES_DIR = path.join(REPO_ROOT, "dist", "usage", "routes");
|
|
251
|
+
const RUNTIME_USAGE_ROUTES_DIR = path.join(INSTALL_ROOT, "dist", "usage", "routes");
|
|
252
|
+
|
|
253
|
+
function syncUsageRoutesDir(dryRun) {
|
|
254
|
+
const hasSource = fs.existsSync(SOURCE_USAGE_ROUTES_DIR);
|
|
255
|
+
if (dryRun) {
|
|
256
|
+
if (hasSource) {
|
|
257
|
+
console.log(` [dry-run] would copy ${SOURCE_USAGE_ROUTES_DIR} -> ${RUNTIME_USAGE_ROUTES_DIR}`);
|
|
258
|
+
} else if (fs.existsSync(RUNTIME_USAGE_ROUTES_DIR)) {
|
|
259
|
+
console.log(` [dry-run] would remove ${RUNTIME_USAGE_ROUTES_DIR}`);
|
|
260
|
+
}
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
fs.rmSync(RUNTIME_USAGE_ROUTES_DIR, { recursive: true, force: true });
|
|
264
|
+
if (hasSource) fs.cpSync(SOURCE_USAGE_ROUTES_DIR, RUNTIME_USAGE_ROUTES_DIR, { recursive: true });
|
|
245
265
|
}
|
|
246
266
|
|
|
247
267
|
function parseArgs(argv) {
|