@kairyou/agent-tools 0.5.0 → 0.6.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
@@ -101,8 +101,8 @@ Here `5h` and `w` are Claude's rolling usage windows; `⟳` is the reset countdo
101
101
  When a compatible API relay is active, the statusline also appends provider usage.
102
102
 
103
103
  To choose what appears, edit `statusline.fields` in
104
- `~/.agent-tools/config.jsonc`. The installer may add new default keys on update;
105
- it preserves top-of-file comments and existing values.
104
+ `~/.agent-tools/config.jsonc`. Installer updates only add missing default keys
105
+ and never touch your edits or comments.
106
106
 
107
107
  ### Provider usage
108
108
 
@@ -148,6 +148,55 @@ endpoint and key — and tune `providerUsage` in `~/.agent-tools/config.jsonc`:
148
148
  }
149
149
  ```
150
150
 
151
+ #### Custom gateway routes
152
+
153
+ For gateways the built-in probes cannot reach (e.g. cookie-authenticated
154
+ relays), write your own route module and declare it in `providerUsage.routes`
155
+ (paths resolve against `~/.agent-tools`). Declared routes are probed first;
156
+ setting `"preset"` to a route id selects it directly.
157
+
158
+ ```jsonc
159
+ {
160
+ "providerUsage": {
161
+ "routes": [
162
+ "custom/my-gateway.mjs",
163
+ "custom/another-gateway.mjs"
164
+ ],
165
+ "myGateway": { "username": "me", "password": "..." }
166
+ }
167
+ }
168
+ ```
169
+
170
+ ```js
171
+ // ~/.agent-tools/custom/my-gateway.mjs
172
+ export const meta = { id: "my-gateway" }; // optional; id defaults to the file name
173
+
174
+ export async function run(context, { requestJson, agentConfig }) {
175
+ // context: { baseUrl, key, providerName, provider, label }
176
+ const { myGateway = {} } = await agentConfig(); // the providerUsage object; custom keys welcome
177
+
178
+ // Tip: save the token to a file (e.g. under ~/.agent-tools/cache) and reuse
179
+ // it; log in again only when a query fails with it (e.g. 401), then save the
180
+ // new token.
181
+ const login = await fetch(`${context.baseUrl}/api/user/login`, {
182
+ method: "POST",
183
+ headers: { "content-type": "application/json" },
184
+ body: JSON.stringify({ username: myGateway.username, password: myGateway.password }),
185
+ });
186
+ const session = await login.json();
187
+
188
+ // Plain fetch works too; custom headers: authorization, cookie, ...
189
+ const me = await requestJson(`${context.baseUrl}/api/user/self`, {
190
+ headers: { authorization: `Bearer ${session?.data?.accessToken}` },
191
+ });
192
+ return { text: `API | balance ¥${me?.data?.balance}` };
193
+ }
194
+ ```
195
+
196
+ `text` is a free-form string; return `{ text }` on success, throw to fall
197
+ through to the next route (with `providerUsage.debug` enabled, failures are
198
+ logged to `~/.agent-tools/logs/usage-debug.log`).
199
+
151
200
  Output examples:
152
201
 
153
202
  ```text
package/README.zh-CN.md CHANGED
@@ -96,7 +96,7 @@ npx -y @kairyou/agent-tools@latest statusline -a claude
96
96
  使用兼容的 API 中转时, statusline 还会追加 provider usage.
97
97
 
98
98
  如需控制显示项, 修改 `~/.agent-tools/config.jsonc` 里的
99
- `statusline.fields`. 安装器更新时可能补充新的默认键; 文件顶部注释和已有值会保留.
99
+ `statusline.fields`. 安装器更新时只会补充缺失的默认键, 不会动你的修改和注释.
100
100
 
101
101
  ### Provider usage
102
102
 
@@ -138,6 +138,52 @@ provider 的 `base_url` 和密钥; Claude Code: 读取 `ANTHROPIC_BASE_URL` 与
138
138
  }
139
139
  ```
140
140
 
141
+ #### 自定义网关路由
142
+
143
+ 内置探测覆盖不到的网关(比如 cookie 认证的中转), 可以自己写路由模块并在
144
+ `providerUsage.routes` 里声明(相对 `~/.agent-tools` 解析). 声明的路由优先
145
+ 探测; `"preset"` 填路由 id 可直接选中.
146
+
147
+ ```jsonc
148
+ {
149
+ "providerUsage": {
150
+ "routes": [
151
+ "custom/my-gateway.mjs",
152
+ "custom/another-gateway.mjs"
153
+ ],
154
+ "myGateway": { "username": "me", "password": "..." }
155
+ }
156
+ }
157
+ ```
158
+
159
+ ```js
160
+ // ~/.agent-tools/custom/my-gateway.mjs
161
+ export const meta = { id: "my-gateway" }; // 可选; id 缺省用文件名
162
+
163
+ export async function run(context, { requestJson, agentConfig }) {
164
+ // context: { baseUrl, key, providerName, provider, label }
165
+ const { myGateway = {} } = await agentConfig(); // providerUsage 对象, 自定义键随意加
166
+
167
+ // 建议: 把 token 存到文件里(如 ~/.agent-tools/cache 下)重复使用,
168
+ // 用它查询失败(如 401)时才重新登录, 并把新 token 写回文件.
169
+ const login = await fetch(`${context.baseUrl}/api/user/login`, {
170
+ method: "POST",
171
+ headers: { "content-type": "application/json" },
172
+ body: JSON.stringify({ username: myGateway.username, password: myGateway.password }),
173
+ });
174
+ const session = await login.json();
175
+
176
+ // 用 fetch 也行; 自定义 header: authorization, cookie 等.
177
+ const me = await requestJson(`${context.baseUrl}/api/user/self`, {
178
+ headers: { authorization: `Bearer ${session?.data?.accessToken}` },
179
+ });
180
+ return { text: `API | balance ¥${me?.data?.balance}` };
181
+ }
182
+ ```
183
+
184
+ `text` 是自由字符串; 成功返回 `{ text }`, 抛错则回落到下一条路由
185
+ (开启 `providerUsage.debug` 后, 失败会记录到 `~/.agent-tools/logs/usage-debug.log`).
186
+
141
187
  显示效果示例:
142
188
 
143
189
  ```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";
@@ -946,6 +946,10 @@ async function panelUserHeaders() {
946
946
  "neo-api-user": value
947
947
  };
948
948
  }
949
+ function snapshotTtlMs() {
950
+ const raw = Number(process.env.AGENT_TOOLS_USAGE_SNAPSHOT_TTL_MS);
951
+ return Number.isFinite(raw) && raw >= 0 ? raw : 6e4;
952
+ }
949
953
  async function newApiQuotaScale() {
950
954
  const config = await agentConfig();
951
955
  const scale = Number(config.newApiQuotaScale || DEFAULT_NEW_API_QUOTA_SCALE);
@@ -1041,6 +1045,10 @@ async function readSnapshotCache() {
1041
1045
  return { version: SNAPSHOT_VERSION, items: {} };
1042
1046
  }
1043
1047
  }
1048
+ async function readUsageSnapshot(context) {
1049
+ const cache = await readSnapshotCache();
1050
+ return cache.items[usageRouteCacheKey(context.baseUrl)] || null;
1051
+ }
1044
1052
  async function rememberUsageSnapshot(context, result) {
1045
1053
  if (!result?.text) return;
1046
1054
  try {
@@ -1089,6 +1097,11 @@ async function rememberRefreshState(context, patch) {
1089
1097
  }
1090
1098
  }
1091
1099
 
1100
+ // integrations/usage/lib/routes.mjs
1101
+ import { readdir } from "node:fs/promises";
1102
+ import { basename, extname, isAbsolute, join as join2 } from "node:path";
1103
+ import { pathToFileURL } from "node:url";
1104
+
1092
1105
  // integrations/usage/lib/http.mjs
1093
1106
  import { createContext, runInContext } from "node:vm";
1094
1107
  var REQUEST_TIMEOUT_MS = 5e3;
@@ -1191,7 +1204,8 @@ function mergeSetCookiePairs(cookieHeader, setCookieHeaders) {
1191
1204
  }
1192
1205
  return merged;
1193
1206
  }
1194
- async function requestJson(url, key, options = {}) {
1207
+ async function requestJson(url, options = {}) {
1208
+ const { key = "", headers = {}, name = "usage" } = options;
1195
1209
  let cookieHeader = "";
1196
1210
  for (let attempt = 0; attempt < 2; attempt += 1) {
1197
1211
  const controller = new AbortController();
@@ -1199,10 +1213,10 @@ async function requestJson(url, key, options = {}) {
1199
1213
  const response = await fetch(url, {
1200
1214
  headers: {
1201
1215
  accept: "application/json",
1202
- authorization: `Bearer ${options.authKey || key}`,
1216
+ ...key ? { authorization: `Bearer ${key}` } : {},
1203
1217
  "user-agent": SHIELD_USER_AGENT,
1204
1218
  ...cookieHeader ? { cookie: cookieHeader } : {},
1205
- ...options.headers || {}
1219
+ ...headers
1206
1220
  },
1207
1221
  signal: controller.signal
1208
1222
  });
@@ -1216,7 +1230,7 @@ async function requestJson(url, key, options = {}) {
1216
1230
  const contentType = response.headers.get("content-type") || "";
1217
1231
  const acwScV2 = isShieldChallenge(contentType, body) ? solveNewApiAcwScV2(body) : "";
1218
1232
  await debugLog({
1219
- source: options.name || "usage",
1233
+ source: name,
1220
1234
  url,
1221
1235
  status: response.status,
1222
1236
  contentType,
@@ -1227,25 +1241,25 @@ async function requestJson(url, key, options = {}) {
1227
1241
  cookieHeader = upsertCookie(cookieHeader, "acw_sc__v2", acwScV2);
1228
1242
  continue;
1229
1243
  }
1230
- throw new Error(`${options.name || "usage"} returned non-JSON (${response.status})`);
1244
+ throw new Error(`${name} returned non-JSON (${response.status})`);
1231
1245
  }
1232
1246
  if (!response.ok) {
1233
1247
  const message = json?.error?.message || json?.message || response.statusText;
1234
1248
  await debugLog({
1235
- source: options.name || "usage",
1249
+ source: name,
1236
1250
  url,
1237
1251
  status: response.status,
1238
1252
  message,
1239
1253
  bodyPreview: shortPreview(body)
1240
1254
  });
1241
- throw new Error(`${options.name || "usage"} failed (${response.status} ${message})`);
1255
+ throw new Error(`${name} failed (${response.status} ${message})`);
1242
1256
  }
1243
1257
  return json;
1244
1258
  } finally {
1245
1259
  clearTimeout(timeout);
1246
1260
  }
1247
1261
  }
1248
- throw new Error(`${options.name || "usage"} unavailable`);
1262
+ throw new Error(`${name} unavailable`);
1249
1263
  }
1250
1264
 
1251
1265
  // integrations/usage/lib/format.mjs
@@ -1473,14 +1487,16 @@ function usageResult(context, source, text, raw) {
1473
1487
  };
1474
1488
  }
1475
1489
  async function fetchV1Usage(context) {
1476
- const json = await requestJson(await subscriptionUrl(context.baseUrl), context.key, {
1490
+ const json = await requestJson(await subscriptionUrl(context.baseUrl), {
1491
+ key: context.key,
1477
1492
  name: "v1 usage"
1478
1493
  });
1479
1494
  if (!hasV1UsageFields(usageRoot(json))) throw new Error("v1 usage payload has no usage fields");
1480
1495
  return usageResult(context, "v1-usage", await formatQuota(context.label, json), json);
1481
1496
  }
1482
1497
  async function fetchNewApiTokenUsage(context) {
1483
- const json = await requestJson(joinUrl(serviceRoot(context.baseUrl), "/api/usage/token/"), context.key, {
1498
+ const json = await requestJson(joinUrl(serviceRoot(context.baseUrl), "/api/usage/token/"), {
1499
+ key: context.key,
1484
1500
  name: "NewAPI token usage"
1485
1501
  });
1486
1502
  const root = usageRoot(json);
@@ -1510,7 +1526,8 @@ async function fetchNewApiTokenUsage(context) {
1510
1526
  async function fetchPanelUserSelfUsage(context) {
1511
1527
  const preset = await usagePreset();
1512
1528
  const kind = preset === "auto" ? "new-api" : preset;
1513
- const json = await requestJson(joinUrl(serviceRoot(context.baseUrl), "/api/user/self"), context.key, {
1529
+ const json = await requestJson(joinUrl(serviceRoot(context.baseUrl), "/api/user/self"), {
1530
+ key: context.key,
1514
1531
  name: "panel /api/user/self",
1515
1532
  headers: await panelUserHeaders()
1516
1533
  });
@@ -1547,7 +1564,8 @@ async function fetchPanelUserSelfUsage(context) {
1547
1564
  );
1548
1565
  }
1549
1566
  async function fetchSub2ApiAuthMeUsage(context) {
1550
- const json = await requestJson(joinUrl(serviceRoot(context.baseUrl), "/api/v1/auth/me"), context.key, {
1567
+ const json = await requestJson(joinUrl(serviceRoot(context.baseUrl), "/api/v1/auth/me"), {
1568
+ key: context.key,
1551
1569
  name: "Sub2API auth/me"
1552
1570
  });
1553
1571
  const root = usageRoot(json);
@@ -1577,7 +1595,7 @@ async function fetchOpenRouterUsage(context) {
1577
1595
  let lastError;
1578
1596
  for (const endpoint of endpoints) {
1579
1597
  try {
1580
- const json = await requestJson(endpoint.url, context.key, { name: endpoint.source });
1598
+ const json = await requestJson(endpoint.url, { key: context.key, name: endpoint.source });
1581
1599
  return usageResult(context, endpoint.source, formatOpenRouterLine("OpenRouter", json), json);
1582
1600
  } catch (error) {
1583
1601
  lastError = error;
@@ -1613,6 +1631,69 @@ var USAGE_ROUTES = {
1613
1631
  run: fetchOpenRouterUsage
1614
1632
  }
1615
1633
  };
1634
+ var CUSTOM_ROUTE_HELPERS = { requestJson, agentConfig };
1635
+ var customRoutesPromise;
1636
+ function customRoutes() {
1637
+ customRoutesPromise ||= loadCustomRoutes();
1638
+ return customRoutesPromise;
1639
+ }
1640
+ async function loadRouteModule(file, spec) {
1641
+ try {
1642
+ const mod = await import(pathToFileURL(file).href);
1643
+ if (typeof mod.run !== "function") {
1644
+ throw new Error("missing `export async function run(context, helpers)`");
1645
+ }
1646
+ const id = String(mod.meta?.id || basename(file, extname(file)));
1647
+ return {
1648
+ id,
1649
+ path: spec,
1650
+ run: async (context) => {
1651
+ const result = await mod.run(context, CUSTOM_ROUTE_HELPERS);
1652
+ if (typeof result?.text !== "string" || !result.text) {
1653
+ throw new Error(`custom route ${id} returned no text`);
1654
+ }
1655
+ return {
1656
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1657
+ baseUrl: context.baseUrl,
1658
+ provider: context.providerName,
1659
+ source: id,
1660
+ ...result
1661
+ };
1662
+ }
1663
+ };
1664
+ } catch (error) {
1665
+ await debugLog({ source: "custom-route", file, error: error.message });
1666
+ return null;
1667
+ }
1668
+ }
1669
+ async function loadCustomRoutes() {
1670
+ const routes = [];
1671
+ const config = await agentConfig();
1672
+ const specs = Array.isArray(config.routes) ? config.routes : [];
1673
+ for (const spec of specs) {
1674
+ if (typeof spec !== "string" || !spec.trim()) continue;
1675
+ const file = isAbsolute(spec) ? spec : join2(AGENT_TOOLS_HOME, spec);
1676
+ const route = await loadRouteModule(file, spec);
1677
+ if (route) routes.push(route);
1678
+ }
1679
+ const packagedDir = join2(AGENT_TOOLS_HOME, "dist", "usage", "routes");
1680
+ let packaged = [];
1681
+ try {
1682
+ packaged = (await readdir(packagedDir)).filter((n) => n.endsWith(".mjs")).sort();
1683
+ } catch {
1684
+ packaged = [];
1685
+ }
1686
+ for (const name of packaged) {
1687
+ const route = await loadRouteModule(join2(packagedDir, name), `dist/usage/routes/${name}`);
1688
+ if (route && !routes.some((existing) => existing.id === route.id)) routes.push(route);
1689
+ }
1690
+ return routes;
1691
+ }
1692
+ async function routeRegistry() {
1693
+ const registry = { ...USAGE_ROUTES };
1694
+ for (const route of await customRoutes()) registry[route.id] = route;
1695
+ return registry;
1696
+ }
1616
1697
  async function usageRouteIds(context) {
1617
1698
  const preset = await usagePreset();
1618
1699
  const routes = {
@@ -1630,27 +1711,29 @@ async function usageRouteIds(context) {
1630
1711
  "openrouter": ["openrouter"]
1631
1712
  };
1632
1713
  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"];
1714
+ if (preset !== "auto") return (await routeRegistry())[preset] ? [preset] : [];
1715
+ const customIds = (await customRoutes()).map((route) => route.id);
1716
+ const builtinIds = hostIncludes(context.baseUrl, "openrouter.ai") ? ["openrouter"] : ["v1-usage", "sub2api-auth-me", "newapi-token", "panel-user-self"];
1717
+ return [.../* @__PURE__ */ new Set([...customIds, ...builtinIds])];
1636
1718
  }
1637
- async function cachedUsageRoute(context) {
1719
+ async function cachedUsageRoute(context, registry) {
1638
1720
  const cache = await readRouteCache();
1639
1721
  const key = usageRouteCacheKey(context.baseUrl);
1640
1722
  const route = cache.routes[key];
1641
- return route?.route && USAGE_ROUTES[route.route] ? route : null;
1723
+ return route?.route && registry[route.route] ? route : null;
1642
1724
  }
1643
1725
  async function orderedUsageRoutes(context) {
1726
+ const registry = await routeRegistry();
1644
1727
  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);
1728
+ const cached = await cachedUsageRoute(context, registry);
1729
+ if (!cached || !routeIds.includes(cached.route)) return routeIds.map((id) => registry[id]).filter(Boolean);
1647
1730
  await debugLog({
1648
1731
  source: "route-cache",
1649
1732
  key: usageRouteCacheKey(context.baseUrl),
1650
1733
  route: cached.route,
1651
- path: cached.path || USAGE_ROUTES[cached.route]?.path || ""
1734
+ path: cached.path || registry[cached.route]?.path || ""
1652
1735
  });
1653
- return [cached.route, ...routeIds.filter((id) => id !== cached.route)].map((id) => USAGE_ROUTES[id]).filter(Boolean);
1736
+ return [cached.route, ...routeIds.filter((id) => id !== cached.route)].map((id) => registry[id]).filter(Boolean);
1654
1737
  }
1655
1738
 
1656
1739
  // integrations/usage/lib/context.mjs
@@ -1844,11 +1927,14 @@ async function queryProviderUsage(input, options = {}) {
1844
1927
  async function refresh(agent = "codex") {
1845
1928
  return await queryAgentProviderUsage(agent);
1846
1929
  }
1847
- async function queryAgentProviderUsage(agent = "codex") {
1848
- return await queryUsageContext(await usageContext(agent), {
1849
- agent,
1850
- rememberSnapshot: agent === "claude"
1851
- });
1930
+ async function queryAgentProviderUsage(agent = "codex", { maxAgeMs = 0 } = {}) {
1931
+ const context = await usageContext(agent);
1932
+ if (maxAgeMs > 0) {
1933
+ const cached = await readUsageSnapshot(context);
1934
+ const age = cached?.updatedAt ? Date.now() - Date.parse(cached.updatedAt) : Infinity;
1935
+ if (cached?.text && age < maxAgeMs) return { ...cached, cached: true };
1936
+ }
1937
+ return await queryUsageContext(context, { agent, rememberSnapshot: true });
1852
1938
  }
1853
1939
  async function main() {
1854
1940
  try {
@@ -1858,7 +1944,7 @@ async function main() {
1858
1944
  const result = await refresh(cli.agent);
1859
1945
  textOut(result?.text || "");
1860
1946
  } else if (mode === "hook") {
1861
- const result = await refresh(cli.agent);
1947
+ const result = await queryAgentProviderUsage(cli.agent, { maxAgeMs: snapshotTtlMs() });
1862
1948
  hookOut(result?.text || "");
1863
1949
  } else {
1864
1950
  throw new Error(`unknown mode: ${mode}`);
@@ -1867,7 +1953,7 @@ async function main() {
1867
1953
  failSoft("Provider usage unavailable", error);
1868
1954
  }
1869
1955
  }
1870
- if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
1956
+ if (process.argv[1] && import.meta.url === pathToFileURL2(process.argv[1]).href) {
1871
1957
  await main();
1872
1958
  }
1873
1959
  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 {
@@ -92,6 +92,14 @@ export async function panelUserHeaders() {
92
92
  };
93
93
  }
94
94
 
95
+ // Passive callers (the codex hook fires per prompt; several sessions may run
96
+ // at once) reuse a fresh snapshot instead of hitting the gateway every time.
97
+ // Same knob the statusline uses; 0 disables.
98
+ export function snapshotTtlMs() {
99
+ const raw = Number(process.env.AGENT_TOOLS_USAGE_SNAPSHOT_TTL_MS);
100
+ return Number.isFinite(raw) && raw >= 0 ? raw : 60_000;
101
+ }
102
+
95
103
  export async function newApiQuotaScale() {
96
104
  const config = await agentConfig();
97
105
  const scale = Number(config.newApiQuotaScale || DEFAULT_NEW_API_QUOTA_SCALE);
@@ -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,7 +1,12 @@
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
11
  panelUserHeaders,
7
12
  newApiQuotaScale,
@@ -50,7 +55,8 @@ function usageResult(context, source, text, raw) {
50
55
  // OpenAI-style endpoint at /v1/usage. This is intentionally probed first for
51
56
  // generic non-OpenAI base URLs because it does not require a management token.
52
57
  async function fetchV1Usage(context) {
53
- const json = await requestJson(await subscriptionUrl(context.baseUrl), context.key, {
58
+ const json = await requestJson(await subscriptionUrl(context.baseUrl), {
59
+ key: context.key,
54
60
  name: "v1 usage",
55
61
  });
56
62
  if (!hasV1UsageFields(usageRoot(json))) throw new Error("v1 usage payload has no usage fields");
@@ -61,7 +67,8 @@ async function fetchV1Usage(context) {
61
67
  // query token usage from the service root rather than the /v1 OpenAI-compatible
62
68
  // path.
63
69
  async function fetchNewApiTokenUsage(context) {
64
- const json = await requestJson(joinUrl(serviceRoot(context.baseUrl), "/api/usage/token/"), context.key, {
70
+ const json = await requestJson(joinUrl(serviceRoot(context.baseUrl), "/api/usage/token/"), {
71
+ key: context.key,
65
72
  name: "NewAPI token usage",
66
73
  });
67
74
  const root = usageRoot(json);
@@ -95,7 +102,8 @@ async function fetchNewApiTokenUsage(context) {
95
102
  async function fetchPanelUserSelfUsage(context) {
96
103
  const preset = await usagePreset();
97
104
  const kind = preset === "auto" ? "new-api" : preset;
98
- const json = await requestJson(joinUrl(serviceRoot(context.baseUrl), "/api/user/self"), context.key, {
105
+ const json = await requestJson(joinUrl(serviceRoot(context.baseUrl), "/api/user/self"), {
106
+ key: context.key,
99
107
  name: "panel /api/user/self",
100
108
  headers: await panelUserHeaders(),
101
109
  });
@@ -140,7 +148,8 @@ async function fetchPanelUserSelfUsage(context) {
140
148
  // also expose richer subscription summaries through /v1/usage, so this route is
141
149
  // a fallback for deployments where /v1/usage is unavailable.
142
150
  async function fetchSub2ApiAuthMeUsage(context) {
143
- const json = await requestJson(joinUrl(serviceRoot(context.baseUrl), "/api/v1/auth/me"), context.key, {
151
+ const json = await requestJson(joinUrl(serviceRoot(context.baseUrl), "/api/v1/auth/me"), {
152
+ key: context.key,
144
153
  name: "Sub2API auth/me",
145
154
  });
146
155
  const root = usageRoot(json);
@@ -176,7 +185,7 @@ async function fetchOpenRouterUsage(context) {
176
185
  let lastError;
177
186
  for (const endpoint of endpoints) {
178
187
  try {
179
- const json = await requestJson(endpoint.url, context.key, { name: endpoint.source });
188
+ const json = await requestJson(endpoint.url, { key: context.key, name: endpoint.source });
180
189
  return usageResult(context, endpoint.source, formatOpenRouterLine("OpenRouter", json), json);
181
190
  } catch (error) {
182
191
  lastError = error;
@@ -214,6 +223,87 @@ const USAGE_ROUTES = {
214
223
  },
215
224
  };
216
225
 
226
+ // User-authored gateway routes, declared in config.jsonc:
227
+ // "providerUsage": { "routes": ["custom/anyrouter.mjs"] }
228
+ // Paths resolve against ~/.agent-tools. Each module exports
229
+ // `export async function run(context, helpers)` plus an optional
230
+ // `export const meta = { id }` (id defaults to the file name). Broken modules
231
+ // are logged and skipped so a bad custom route cannot break the built-ins.
232
+ const CUSTOM_ROUTE_HELPERS = { requestJson, agentConfig };
233
+
234
+ let customRoutesPromise;
235
+ function customRoutes() {
236
+ customRoutesPromise ||= loadCustomRoutes();
237
+ return customRoutesPromise;
238
+ }
239
+
240
+ async function loadRouteModule(file, spec) {
241
+ try {
242
+ const mod = await import(pathToFileURL(file).href);
243
+ if (typeof mod.run !== "function") {
244
+ throw new Error("missing `export async function run(context, helpers)`");
245
+ }
246
+ const id = String(mod.meta?.id || basename(file, extname(file)));
247
+ return {
248
+ id,
249
+ path: spec,
250
+ run: async (context) => {
251
+ const result = await mod.run(context, CUSTOM_ROUTE_HELPERS);
252
+ if (typeof result?.text !== "string" || !result.text) {
253
+ throw new Error(`custom route ${id} returned no text`);
254
+ }
255
+ return {
256
+ updatedAt: new Date().toISOString(),
257
+ baseUrl: context.baseUrl,
258
+ provider: context.providerName,
259
+ source: id,
260
+ ...result,
261
+ };
262
+ },
263
+ };
264
+ } catch (error) {
265
+ await debugLog({ source: "custom-route", file, error: error.message });
266
+ return null;
267
+ }
268
+ }
269
+
270
+ async function loadCustomRoutes() {
271
+ const routes = [];
272
+
273
+ // config-declared routes probe first, in config order.
274
+ const config = await agentConfig();
275
+ const specs = Array.isArray(config.routes) ? config.routes : [];
276
+ for (const spec of specs) {
277
+ if (typeof spec !== "string" || !spec.trim()) continue;
278
+ const file = isAbsolute(spec) ? spec : join(AGENT_TOOLS_HOME, spec);
279
+ const route = await loadRouteModule(file, spec);
280
+ if (route) routes.push(route);
281
+ }
282
+
283
+ // Repo-shipped routes: the installer replaces this directory from the
284
+ // package's dist/usage/routes on every install, so a fork can distribute
285
+ // gateways to everyone under git control. Config-declared ids win.
286
+ const packagedDir = join(AGENT_TOOLS_HOME, "dist", "usage", "routes");
287
+ let packaged = [];
288
+ try {
289
+ packaged = (await readdir(packagedDir)).filter((n) => n.endsWith(".mjs")).sort();
290
+ } catch {
291
+ packaged = [];
292
+ }
293
+ for (const name of packaged) {
294
+ const route = await loadRouteModule(join(packagedDir, name), `dist/usage/routes/${name}`);
295
+ if (route && !routes.some((existing) => existing.id === route.id)) routes.push(route);
296
+ }
297
+
298
+ return routes;
299
+ }
300
+
301
+ async function routeRegistry() {
302
+ const registry = { ...USAGE_ROUTES };
303
+ for (const route of await customRoutes()) registry[route.id] = route;
304
+ return registry;
305
+ }
306
+
217
307
  // Presets are probe-order aliases over the routes above, not separate
218
308
  // protocols (e.g. anyrouter/agentrouter just try the NewAPI panel endpoints
219
309
  // and /v1/usage in a different order).
@@ -237,29 +327,36 @@ async function usageRouteIds(context) {
237
327
  };
238
328
  if (routes[preset]) return routes[preset];
239
329
 
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"];
330
+ // A preset naming a registered route id (built-in or custom) selects it.
331
+ if (preset !== "auto") return (await routeRegistry())[preset] ? [preset] : [];
332
+
333
+ // Declared custom routes probe first, in config order.
334
+ const customIds = (await customRoutes()).map((route) => route.id);
335
+ const builtinIds = hostIncludes(context.baseUrl, "openrouter.ai")
336
+ ? ["openrouter"]
337
+ : ["v1-usage", "sub2api-auth-me", "newapi-token", "panel-user-self"];
338
+ return [...new Set([...customIds, ...builtinIds])];
243
339
  }
244
340
 
245
- async function cachedUsageRoute(context) {
341
+ async function cachedUsageRoute(context, registry) {
246
342
  const cache = await readRouteCache();
247
343
  const key = usageRouteCacheKey(context.baseUrl);
248
344
  const route = cache.routes[key];
249
- return route?.route && USAGE_ROUTES[route.route] ? route : null;
345
+ return route?.route && registry[route.route] ? route : null;
250
346
  }
251
347
 
252
348
  export async function orderedUsageRoutes(context) {
349
+ const registry = await routeRegistry();
253
350
  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);
351
+ const cached = await cachedUsageRoute(context, registry);
352
+ if (!cached || !routeIds.includes(cached.route)) return routeIds.map((id) => registry[id]).filter(Boolean);
256
353
  await debugLog({
257
354
  source: "route-cache",
258
355
  key: usageRouteCacheKey(context.baseUrl),
259
356
  route: cached.route,
260
- path: cached.path || USAGE_ROUTES[cached.route]?.path || "",
357
+ path: cached.path || registry[cached.route]?.path || "",
261
358
  });
262
359
  return [cached.route, ...routeIds.filter((id) => id !== cached.route)]
263
- .map((id) => USAGE_ROUTES[id])
360
+ .map((id) => registry[id])
264
361
  .filter(Boolean);
265
362
  }
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kairyou/agent-tools",
3
- "version": "0.5.0",
3
+ "version": "0.6.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()}`);
@@ -102,40 +102,6 @@ function nodeCmd(absScript) {
102
102
  return `node "${fwd(absScript)}"`;
103
103
  }
104
104
 
105
- function stripJsonComments(input) {
106
- let out = "";
107
- let inString = false;
108
- let escaped = false;
109
- for (let i = 0; i < input.length; i++) {
110
- const ch = input[i];
111
- const next = input[i + 1];
112
- if (inString) {
113
- out += ch;
114
- escaped = ch === "\\" ? !escaped : false;
115
- if (ch === "\"" && !escaped) inString = false;
116
- continue;
117
- }
118
- if (ch === "\"") {
119
- inString = true;
120
- out += ch;
121
- continue;
122
- }
123
- if (ch === "/" && next === "/") {
124
- while (i < input.length && input[i] !== "\n") i++;
125
- out += "\n";
126
- continue;
127
- }
128
- if (ch === "/" && next === "*") {
129
- i += 2;
130
- while (i < input.length && !(input[i] === "*" && input[i + 1] === "/")) i++;
131
- i++;
132
- continue;
133
- }
134
- out += ch;
135
- }
136
- return out;
137
- }
138
-
139
105
  function readJsonc(file) {
140
106
  if (!fs.existsSync(file)) return {};
141
107
  const raw = fs.readFileSync(file, "utf8").replace(/^\uFEFF/, "");
@@ -146,31 +112,6 @@ function readJsonc(file) {
146
112
  return parsed ?? {};
147
113
  }
148
114
 
149
- function headerComments(text) {
150
- const lines = text.replace(/^\uFEFF/, "").split(/\r?\n/);
151
- const header = [];
152
- for (const line of lines) {
153
- if (/^\s*(?:\/\/.*)?$/.test(line)) {
154
- header.push(line);
155
- continue;
156
- }
157
- break;
158
- }
159
- return header.length ? header.join("\n").replace(/\s+$/, "") + "\n" : "";
160
- }
161
-
162
- function mergeDefaults(target, defaults) {
163
- if (Array.isArray(defaults)) return target === undefined ? defaults : target;
164
- if (!defaults || typeof defaults !== "object") {
165
- return target === undefined ? defaults : target;
166
- }
167
- const out = target && typeof target === "object" && !Array.isArray(target) ? { ...target } : {};
168
- for (const [key, value] of Object.entries(defaults)) {
169
- out[key] = mergeDefaults(out[key], value);
170
- }
171
- return out;
172
- }
173
-
174
115
  function writeText(file, text, dryRun) {
175
116
  if (dryRun) {
176
117
  console.log(` [dry-run] would write ${file}:`);
@@ -215,23 +156,46 @@ function updateOpenCodeTuiConfig(file, { remove, dryRun }) {
215
156
  writeText(file, updated, dryRun);
216
157
  }
217
158
 
159
+ // Keys present in defaults but absent in current, as jsonc-parser edit paths.
160
+ function missingDefaultPaths(current, defaults, basePath = []) {
161
+ const out = [];
162
+ for (const [key, value] of Object.entries(defaults)) {
163
+ const existing = current?.[key];
164
+ if (existing === undefined) {
165
+ out.push({ path: [...basePath, key], value });
166
+ } else if (
167
+ value && typeof value === "object" && !Array.isArray(value) &&
168
+ existing && typeof existing === "object" && !Array.isArray(existing)
169
+ ) {
170
+ out.push(...missingDefaultPaths(existing, value, [...basePath, key]));
171
+ }
172
+ }
173
+ return out;
174
+ }
175
+
176
+ // Updates only add missing default keys via surgical jsonc-parser edits, so
177
+ // the user's comments, formatting, and key order survive installer updates.
218
178
  function mergeJsoncFile(src, dest, dryRun) {
219
- const defaults = readJsonc(src);
220
- const defaultHeader = headerComments(fs.readFileSync(src, "utf8"));
221
- if (!fs.existsSync(dest)) {
179
+ if (!fs.existsSync(dest) || !fs.readFileSync(dest, "utf8").trim()) {
222
180
  writeText(dest, fs.readFileSync(src, "utf8"), dryRun);
223
181
  return;
224
182
  }
183
+ const defaults = readJsonc(src);
225
184
  const currentText = fs.readFileSync(dest, "utf8");
226
- const current = readJsonc(dest);
227
- const merged = mergeDefaults(current, defaults);
228
- const currentHeader = headerComments(currentText) || defaultHeader;
229
- const mergedText = currentHeader + JSON.stringify(merged, null, 2) + "\n";
230
- if (stripJsonComments(currentText).trim() === JSON.stringify(merged, null, 2)) {
185
+ const additions = missingDefaultPaths(readJsonc(dest), defaults);
186
+ if (additions.length === 0) {
231
187
  console.log(` kept existing ${dest}`);
232
188
  return;
233
189
  }
234
- writeText(dest, mergedText, dryRun);
190
+ const eol = currentText.includes("\r\n") ? "\r\n" : "\n";
191
+ let updated = currentText;
192
+ for (const { path: keyPath, value } of additions) {
193
+ const edits = modify(updated, keyPath, value, {
194
+ formattingOptions: { insertSpaces: true, tabSize: 2, eol },
195
+ });
196
+ updated = applyEdits(updated, edits);
197
+ }
198
+ writeText(dest, updated, dryRun);
235
199
  }
236
200
 
237
201
  function copyRuntimeFile(src, dest, dryRun, options = {}) {
@@ -278,6 +242,26 @@ function installRuntimeAssets(opts) {
278
242
  }
279
243
  console.log(`runtime: ${INSTALL_ROOT}`);
280
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 });
281
265
  }
282
266
 
283
267
  function parseArgs(argv) {