@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 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 the usage APIs of Sub2API, NewAPI/OneAPI-family panels (OneHub,
113
- DoneHub, Veloera, ...), and OpenRouter; compatibility can vary with a
114
- deployment's version and auth scheme.
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 | new-api | veloera | openrouter | ...
144
- "userId": "", // some NewAPI/Veloera panels require your panel user id
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, NewAPI/OneAPI 系面板 (OneHub, DoneHub, Veloera 等)
106
- 与 OpenRouter 的用量接口; 同类网关的部署版本和鉴权方式不同, 兼容性可能有差异.
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 | new-api | veloera | openrouter | ...
134
- "userId": "", // 部分 NewAPI/Veloera 面板需要填面板用户 id
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
@@ -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 || value > MAX_USAGE_DAYS) return DEFAULT_USAGE_DAYS;
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
- async function panelUserId() {
930
- const config = await agentConfig();
931
- const raw = process.env.PROVIDER_USAGE_USER_ID || config.userId || "";
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, key, options = {}) {
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 ${options.authKey || key}`,
1196
+ ...key ? { authorization: `Bearer ${key}` } : {},
1203
1197
  "user-agent": SHIELD_USER_AGENT,
1204
1198
  ...cookieHeader ? { cookie: cookieHeader } : {},
1205
- ...options.headers || {}
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: options.name || "usage",
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(`${options.name || "usage"} returned non-JSON (${response.status})`);
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: options.name || "usage",
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(`${options.name || "usage"} failed (${response.status} ${message})`);
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(`${options.name || "usage"} unavailable`);
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 panelQuotaScale(kind) {
1378
- return kind === "veloera" ? 1e6 : DEFAULT_NEW_API_QUOTA_SCALE;
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), context.key, {
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/"), context.key, {
1484
- name: "NewAPI token usage"
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 fetchPanelUserSelfUsage(context) {
1511
- const preset = await usagePreset();
1512
- const kind = preset === "auto" ? "new-api" : preset;
1513
- const json = await requestJson(joinUrl(serviceRoot(context.baseUrl), "/api/user/self"), context.key, {
1514
- name: "panel /api/user/self",
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 root = usageRoot(json);
1518
- if (pickNumber(root, ["quota"]) === void 0 && pickNumber(root, ["used_quota", "usedQuota"]) === void 0) {
1519
- await debugLog({
1520
- source: "panel /api/user/self",
1521
- payloadKeys: Object.keys(root || {}).slice(0, 20),
1522
- success: root?.success,
1523
- message: root?.message || root?.error?.message || ""
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 scale = panelQuotaScale(kind);
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: total === void 0 ? void 0 : total / scale,
1535
- used: used === void 0 ? void 0 : used / scale,
1536
- remaining: remaining === void 0 ? void 0 : remaining / scale
1497
+ limit,
1498
+ used,
1499
+ remaining: Math.max(0, limit - used)
1537
1500
  },
1538
1501
  unit: "USD",
1539
- source: "panel-user-self",
1540
- raw: json
1502
+ source: "oneapi-billing",
1503
+ raw: { subscription, usage }
1541
1504
  };
1542
1505
  return usageResult(
1543
1506
  context,
1544
- "panel-user-self",
1545
- await formatPanelUserSelfLine(context.label, json, kind),
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, { name: endpoint.source });
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
- "panel-user-self": {
1606
- id: "panel-user-self",
1607
- path: "/api/user/self",
1608
- run: fetchPanelUserSelfUsage
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", "sub2api-auth-me"],
1618
+ "sub2api": ["v1-usage"],
1620
1619
  "openai-compatible": ["v1-usage"],
1621
- "new-api": ["newapi-token", "panel-user-self"],
1622
- "one-api": ["newapi-token", "panel-user-self"],
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
- if (hostIncludes(context.baseUrl, "openrouter.ai")) return ["openrouter"];
1635
- return ["v1-usage", "sub2api-auth-me", "newapi-token", "panel-user-self"];
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 && USAGE_ROUTES[route.route] ? route : null;
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) => USAGE_ROUTES[id]).filter(Boolean);
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 || USAGE_ROUTES[cached.route]?.path || ""
1645
+ path: cached.path || registry[cached.route]?.path || ""
1652
1646
  });
1653
- return [cached.route, ...routeIds.filter((id) => id !== cached.route)].map((id) => USAGE_ROUTES[id]).filter(Boolean);
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
- return await queryUsageContext(await usageContext(agent), {
1849
- agent,
1850
- rememberSnapshot: agent === "claude"
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 refresh(cli.agent);
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 === pathToFileURL(process.argv[1]).href) {
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
- return await queryUsageContext(await usageContext(agent), {
118
- agent,
119
- rememberSnapshot: agent === "claude",
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
- const result = await refresh(cli.agent);
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 || value > MAX_USAGE_DAYS) return DEFAULT_USAGE_DAYS;
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
- export async function panelUserId() {
74
- const config = await agentConfig();
75
- const raw = process.env.PROVIDER_USAGE_USER_ID || config.userId || "";
76
- const parsed = Number.parseInt(String(raw), 10);
77
- return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
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, DEFAULT_NEW_API_QUOTA_SCALE } from "./config.mjs";
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 panelQuotaScale(kind) {
163
- return kind === "veloera" ? 1000000 : DEFAULT_NEW_API_QUOTA_SCALE;
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, key, options = {}) {
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 ${options.authKey || key}`,
137
+ ...(key ? { authorization: `Bearer ${key}` } : {}),
137
138
  "user-agent": SHIELD_USER_AGENT,
138
139
  ...(cookieHeader ? { cookie: cookieHeader } : {}),
139
- ...(options.headers || {}),
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: options.name || "usage",
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(`${options.name || "usage"} returned non-JSON (${response.status})`);
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: options.name || "usage",
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(`${options.name || "usage"} failed (${response.status} ${message})`);
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(`${options.name || "usage"} unavailable`);
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), context.key, {
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
- // NewAPI / OneAPI family panels: use the current API key as Bearer auth and
61
- // query token usage from the service root rather than the /v1 OpenAI-compatible
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/"), context.key, {
65
- name: "NewAPI token usage",
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
- // NewAPI / OneAPI / OneHub / DoneHub / Veloera panel session endpoint, based on
93
- // Metapi's platform handling. This works when PROVIDER_USAGE_API_KEY is a panel
94
- // access/session token, or when the site accepts the API key for /api/user/self.
95
- async function fetchPanelUserSelfUsage(context) {
96
- const preset = await usagePreset();
97
- const kind = preset === "auto" ? "new-api" : preset;
98
- const json = await requestJson(joinUrl(serviceRoot(context.baseUrl), "/api/user/self"), context.key, {
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 root = usageRoot(json);
103
- if (pickNumber(root, ["quota"]) === undefined && pickNumber(root, ["used_quota", "usedQuota"]) === undefined) {
104
- await debugLog({
105
- source: "panel /api/user/self",
106
- payloadKeys: Object.keys(root || {}).slice(0, 20),
107
- success: root?.success,
108
- message: root?.message || root?.error?.message || "",
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 scale = panelQuotaScale(kind);
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: total === undefined ? undefined : total / scale,
124
- used: used === undefined ? undefined : used / scale,
125
- remaining: remaining === undefined ? undefined : remaining / scale,
115
+ limit,
116
+ used,
117
+ remaining: Math.max(0, limit - used),
126
118
  },
127
119
  unit: "USD",
128
- source: "panel-user-self",
129
- raw: json,
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
- "sub2api-auth-me",
160
- `API | balance ${formatMoney(balance)}`,
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 NewAPI/OneAPI.
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, { name: endpoint.source });
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
- "panel-user-self": {
206
- id: "panel-user-self",
207
- path: "/api/user/self",
208
- run: fetchPanelUserSelfUsage,
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
- // Presets are probe-order aliases over the routes above, not separate
218
- // protocols (e.g. anyrouter/agentrouter just try the NewAPI panel endpoints
219
- // and /v1/usage in a different order).
220
- // They do not provide panel session-cookie authentication; only endpoints that
221
- // accept the configured Bearer key can succeed.
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", "sub2api-auth-me"],
263
+ "sub2api": ["v1-usage"],
226
264
  "openai-compatible": ["v1-usage"],
227
- "new-api": ["newapi-token", "panel-user-self"],
228
- "one-api": ["newapi-token", "panel-user-self"],
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
- if (preset !== "auto") return [];
241
- if (hostIncludes(context.baseUrl, "openrouter.ai")) return ["openrouter"];
242
- return ["v1-usage", "sub2api-auth-me", "newapi-token", "panel-user-self"];
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 && USAGE_ROUTES[route.route] ? route : null;
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) => USAGE_ROUTES[id]).filter(Boolean);
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 || USAGE_ROUTES[cached.route]?.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) => USAGE_ROUTES[id])
301
+ .map((id) => registry[id])
264
302
  .filter(Boolean);
265
303
  }
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kairyou/agent-tools",
3
- "version": "0.5.2",
3
+ "version": "0.7.0",
4
4
  "description": "Reusable Agent Skills and installable integrations (statusline, provider usage, vision) for Codex, Claude Code, and opencode.",
5
5
  "license": "MIT",
6
6
  "engines": {
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()}`);
@@ -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) {