@kairyou/agent-tools 0.5.2 → 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 +49 -0
- package/README.zh-CN.md +46 -0
- package/dist/usage/core.mjs +116 -30
- package/integrations/usage/core.mjs +12 -7
- package/integrations/usage/lib/cache.mjs +5 -0
- package/integrations/usage/lib/config.mjs +8 -0
- package/integrations/usage/lib/http.mjs +9 -8
- package/integrations/usage/lib/routes.mjs +111 -14
- package/integrations/usage/routes/.gitkeep +0 -0
- package/package.json +1 -1
- package/scripts/build.mjs +9 -0
- package/scripts/install.mjs +20 -0
package/README.md
CHANGED
|
@@ -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
|
@@ -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
|
package/dist/usage/core.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// integrations/usage/core.mjs
|
|
4
|
-
import { pathToFileURL } from "node:url";
|
|
4
|
+
import { pathToFileURL as pathToFileURL2 } from "node:url";
|
|
5
5
|
|
|
6
6
|
// integrations/usage/lib/config.mjs
|
|
7
7
|
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
@@ -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,
|
|
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 ${
|
|
1216
|
+
...key ? { authorization: `Bearer ${key}` } : {},
|
|
1203
1217
|
"user-agent": SHIELD_USER_AGENT,
|
|
1204
1218
|
...cookieHeader ? { cookie: cookieHeader } : {},
|
|
1205
|
-
...
|
|
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:
|
|
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(`${
|
|
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:
|
|
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(`${
|
|
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(`${
|
|
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),
|
|
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/"),
|
|
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"),
|
|
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"),
|
|
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,
|
|
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
|
-
|
|
1635
|
-
|
|
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 &&
|
|
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) =>
|
|
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 ||
|
|
1734
|
+
path: cached.path || registry[cached.route]?.path || ""
|
|
1652
1735
|
});
|
|
1653
|
-
return [cached.route, ...routeIds.filter((id) => id !== cached.route)].map((id) =>
|
|
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
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
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
|
|
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 ===
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
117
|
+
export async function queryAgentProviderUsage(agent = "codex", { maxAgeMs = 0 } = {}) {
|
|
118
|
+
const context = await usageContext(agent);
|
|
119
|
+
if (maxAgeMs > 0) {
|
|
120
|
+
const cached = await readUsageSnapshot(context);
|
|
121
|
+
const age = cached?.updatedAt ? Date.now() - Date.parse(cached.updatedAt) : Infinity;
|
|
122
|
+
if (cached?.text && age < maxAgeMs) return { ...cached, cached: true };
|
|
123
|
+
}
|
|
124
|
+
return await queryUsageContext(context, { agent, rememberSnapshot: true });
|
|
121
125
|
}
|
|
122
126
|
|
|
123
127
|
async function main() {
|
|
@@ -128,7 +132,8 @@ async function main() {
|
|
|
128
132
|
const result = await refresh(cli.agent);
|
|
129
133
|
textOut(result?.text || "");
|
|
130
134
|
} else if (mode === "hook") {
|
|
131
|
-
|
|
135
|
+
// Hook mode fires on every prompt; serve a fresh snapshot when possible.
|
|
136
|
+
const result = await queryAgentProviderUsage(cli.agent, { maxAgeMs: snapshotTtlMs() });
|
|
132
137
|
hookOut(result?.text || "");
|
|
133
138
|
} else {
|
|
134
139
|
throw new Error(`unknown mode: ${mode}`);
|
|
@@ -61,6 +61,11 @@ async function readSnapshotCache() {
|
|
|
61
61
|
}
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
export async function readUsageSnapshot(context) {
|
|
65
|
+
const cache = await readSnapshotCache();
|
|
66
|
+
return cache.items[usageRouteCacheKey(context.baseUrl)] || null;
|
|
67
|
+
}
|
|
68
|
+
|
|
64
69
|
export async function rememberUsageSnapshot(context, result) {
|
|
65
70
|
if (!result?.text) return;
|
|
66
71
|
try {
|
|
@@ -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,
|
|
128
|
+
export async function requestJson(url, options = {}) {
|
|
129
|
+
const { key = "", headers = {}, name = "usage" } = options;
|
|
129
130
|
let cookieHeader = "";
|
|
130
131
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
131
132
|
const controller = new AbortController();
|
|
@@ -133,10 +134,10 @@ export async function requestJson(url, key, options = {}) {
|
|
|
133
134
|
const response = await fetch(url, {
|
|
134
135
|
headers: {
|
|
135
136
|
accept: "application/json",
|
|
136
|
-
authorization: `Bearer ${
|
|
137
|
+
...(key ? { authorization: `Bearer ${key}` } : {}),
|
|
137
138
|
"user-agent": SHIELD_USER_AGENT,
|
|
138
139
|
...(cookieHeader ? { cookie: cookieHeader } : {}),
|
|
139
|
-
...
|
|
140
|
+
...headers,
|
|
140
141
|
},
|
|
141
142
|
signal: controller.signal,
|
|
142
143
|
});
|
|
@@ -151,7 +152,7 @@ export async function requestJson(url, key, options = {}) {
|
|
|
151
152
|
const contentType = response.headers.get("content-type") || "";
|
|
152
153
|
const acwScV2 = isShieldChallenge(contentType, body) ? solveNewApiAcwScV2(body) : "";
|
|
153
154
|
await debugLog({
|
|
154
|
-
source:
|
|
155
|
+
source: name,
|
|
155
156
|
url,
|
|
156
157
|
status: response.status,
|
|
157
158
|
contentType,
|
|
@@ -162,19 +163,19 @@ export async function requestJson(url, key, options = {}) {
|
|
|
162
163
|
cookieHeader = upsertCookie(cookieHeader, "acw_sc__v2", acwScV2);
|
|
163
164
|
continue;
|
|
164
165
|
}
|
|
165
|
-
throw new Error(`${
|
|
166
|
+
throw new Error(`${name} returned non-JSON (${response.status})`);
|
|
166
167
|
}
|
|
167
168
|
|
|
168
169
|
if (!response.ok) {
|
|
169
170
|
const message = json?.error?.message || json?.message || response.statusText;
|
|
170
171
|
await debugLog({
|
|
171
|
-
source:
|
|
172
|
+
source: name,
|
|
172
173
|
url,
|
|
173
174
|
status: response.status,
|
|
174
175
|
message,
|
|
175
176
|
bodyPreview: shortPreview(body),
|
|
176
177
|
});
|
|
177
|
-
throw new Error(`${
|
|
178
|
+
throw new Error(`${name} failed (${response.status} ${message})`);
|
|
178
179
|
}
|
|
179
180
|
|
|
180
181
|
return json;
|
|
@@ -182,5 +183,5 @@ export async function requestJson(url, key, options = {}) {
|
|
|
182
183
|
clearTimeout(timeout);
|
|
183
184
|
}
|
|
184
185
|
}
|
|
185
|
-
throw new Error(`${
|
|
186
|
+
throw new Error(`${name} unavailable`);
|
|
186
187
|
}
|
|
@@ -1,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),
|
|
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/"),
|
|
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"),
|
|
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"),
|
|
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,
|
|
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
|
-
|
|
241
|
-
if (
|
|
242
|
-
|
|
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 &&
|
|
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) =>
|
|
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 ||
|
|
357
|
+
path: cached.path || registry[cached.route]?.path || "",
|
|
261
358
|
});
|
|
262
359
|
return [cached.route, ...routeIds.filter((id) => id !== cached.route)]
|
|
263
|
-
.map((id) =>
|
|
360
|
+
.map((id) => registry[id])
|
|
264
361
|
.filter(Boolean);
|
|
265
362
|
}
|
|
File without changes
|
package/package.json
CHANGED
package/scripts/build.mjs
CHANGED
|
@@ -37,6 +37,15 @@ const TARGETS = {
|
|
|
37
37
|
},
|
|
38
38
|
};
|
|
39
39
|
|
|
40
|
+
// Repo-shipped usage routes (a fork can commit integrations/usage/routes/*.mjs
|
|
41
|
+
// to distribute custom gateways to everyone who installs). Absent upstream.
|
|
42
|
+
const USAGE_ROUTES_DIR = path.join(ROOT, "integrations", "usage", "routes");
|
|
43
|
+
if (fs.existsSync(USAGE_ROUTES_DIR)) {
|
|
44
|
+
for (const file of fs.readdirSync(USAGE_ROUTES_DIR).filter((n) => n.endsWith(".mjs"))) {
|
|
45
|
+
TARGETS.usage.entryPoints[`routes/${file.slice(0, -4)}`] = path.join(USAGE_ROUTES_DIR, file);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
40
49
|
for (const [name, { entryPoints, external = [] }] of Object.entries(TARGETS)) {
|
|
41
50
|
const outDir = path.join(DIST, name);
|
|
42
51
|
const stage = path.join(DIST, `.${name}-build-${process.pid}-${Date.now()}`);
|
package/scripts/install.mjs
CHANGED
|
@@ -242,6 +242,26 @@ function installRuntimeAssets(opts) {
|
|
|
242
242
|
}
|
|
243
243
|
console.log(`runtime: ${INSTALL_ROOT}`);
|
|
244
244
|
for (const [src, dest, options] of files) copyRuntimeFile(src, dest, opts.dryRun, options);
|
|
245
|
+
if (wants(opts, "usage")) syncUsageRoutesDir(opts.dryRun);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Repo-shipped usage routes are replaced wholesale so routes removed from the
|
|
249
|
+
// repo do not linger (a stale file would still be loaded).
|
|
250
|
+
const SOURCE_USAGE_ROUTES_DIR = path.join(REPO_ROOT, "dist", "usage", "routes");
|
|
251
|
+
const RUNTIME_USAGE_ROUTES_DIR = path.join(INSTALL_ROOT, "dist", "usage", "routes");
|
|
252
|
+
|
|
253
|
+
function syncUsageRoutesDir(dryRun) {
|
|
254
|
+
const hasSource = fs.existsSync(SOURCE_USAGE_ROUTES_DIR);
|
|
255
|
+
if (dryRun) {
|
|
256
|
+
if (hasSource) {
|
|
257
|
+
console.log(` [dry-run] would copy ${SOURCE_USAGE_ROUTES_DIR} -> ${RUNTIME_USAGE_ROUTES_DIR}`);
|
|
258
|
+
} else if (fs.existsSync(RUNTIME_USAGE_ROUTES_DIR)) {
|
|
259
|
+
console.log(` [dry-run] would remove ${RUNTIME_USAGE_ROUTES_DIR}`);
|
|
260
|
+
}
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
fs.rmSync(RUNTIME_USAGE_ROUTES_DIR, { recursive: true, force: true });
|
|
264
|
+
if (hasSource) fs.cpSync(SOURCE_USAGE_ROUTES_DIR, RUNTIME_USAGE_ROUTES_DIR, { recursive: true });
|
|
245
265
|
}
|
|
246
266
|
|
|
247
267
|
function parseArgs(argv) {
|