@kairyou/agent-tools 0.9.0 → 0.10.1
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 +40 -110
- package/README.zh-CN.md +39 -100
- package/dist/usage/cli.mjs +5 -3
- package/dist/usage/codex-hook.mjs +18 -10
- package/dist/usage/core.mjs +224 -98
- package/docs/en/custom-gateway-routes.md +53 -0
- package/docs/en/repository-structure.md +20 -0
- package/docs/zh-CN/custom-gateway-routes.md +47 -0
- package/docs/zh-CN/repository-structure.md +20 -0
- package/integrations/usage/codex-hook.mjs +19 -10
- package/integrations/usage/core.mjs +60 -12
- package/integrations/usage/lib/cache.mjs +152 -70
- package/integrations/usage/lib/config.mjs +11 -10
- package/integrations/usage/lib/format.mjs +56 -4
- package/integrations/usage/lib/http.mjs +5 -2
- package/package.json +2 -1
- package/scripts/install.mjs +13 -4
- package/scripts/publish.mjs +6 -2
- package/scripts/release.mjs +8 -6
|
@@ -16,6 +16,7 @@ const LOG_PATH = path.join(AGENT_TOOLS_HOME, "logs", "usage-hook.log");
|
|
|
16
16
|
const TIMEOUT_MS = Number(process.env.AGENT_TOOLS_USAGE_HOOK_TIMEOUT_MS || 4500);
|
|
17
17
|
const MAX_LOG_BYTES = Number(process.env.AGENT_TOOLS_USAGE_HOOK_LOG_BYTES || 256 * 1024);
|
|
18
18
|
const KEEP_LOG_BYTES = 128 * 1024;
|
|
19
|
+
const SILENT = process.argv.includes("--silent");
|
|
19
20
|
|
|
20
21
|
function hookOut(message) {
|
|
21
22
|
const payload = { continue: true };
|
|
@@ -64,6 +65,10 @@ function failureMessage() {
|
|
|
64
65
|
return `API usage hook failed; see ${LOG_PATH.replace(/\\/g, "/")}`;
|
|
65
66
|
}
|
|
66
67
|
|
|
68
|
+
function hookFailureOut() {
|
|
69
|
+
hookOut(SILENT ? "" : failureMessage());
|
|
70
|
+
}
|
|
71
|
+
|
|
67
72
|
function parseHookJson(stdout) {
|
|
68
73
|
const text = stdout.trim();
|
|
69
74
|
if (!text) return { continue: true };
|
|
@@ -73,17 +78,21 @@ function parseHookJson(stdout) {
|
|
|
73
78
|
async function runUsageScript() {
|
|
74
79
|
if (!fs.existsSync(USAGE_SCRIPT)) {
|
|
75
80
|
logFailure({ reason: "missing usage script", usageScript: USAGE_SCRIPT });
|
|
76
|
-
|
|
81
|
+
hookFailureOut();
|
|
77
82
|
return;
|
|
78
83
|
}
|
|
79
84
|
|
|
80
85
|
const result = await new Promise((resolve) => {
|
|
81
|
-
const child = spawn(
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
86
|
+
const child = spawn(
|
|
87
|
+
process.execPath,
|
|
88
|
+
[USAGE_SCRIPT, "hook", "--agent", "codex", ...(SILENT ? ["--silent"] : [])],
|
|
89
|
+
{
|
|
90
|
+
cwd: process.cwd(),
|
|
91
|
+
env: process.env,
|
|
92
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
93
|
+
windowsHide: true,
|
|
94
|
+
}
|
|
95
|
+
);
|
|
87
96
|
let stdout = "";
|
|
88
97
|
let stderr = "";
|
|
89
98
|
let settled = false;
|
|
@@ -126,7 +135,7 @@ async function runUsageScript() {
|
|
|
126
135
|
node: process.version,
|
|
127
136
|
platform: `${process.platform} ${os.release()}`,
|
|
128
137
|
});
|
|
129
|
-
|
|
138
|
+
hookFailureOut();
|
|
130
139
|
return;
|
|
131
140
|
}
|
|
132
141
|
|
|
@@ -141,7 +150,7 @@ async function runUsageScript() {
|
|
|
141
150
|
stderr: preview(result.stderr),
|
|
142
151
|
usageScript: USAGE_SCRIPT,
|
|
143
152
|
});
|
|
144
|
-
|
|
153
|
+
hookFailureOut();
|
|
145
154
|
}
|
|
146
155
|
}
|
|
147
156
|
|
|
@@ -153,5 +162,5 @@ try {
|
|
|
153
162
|
error: error?.stack || error?.message || String(error),
|
|
154
163
|
usageScript: USAGE_SCRIPT,
|
|
155
164
|
});
|
|
156
|
-
|
|
165
|
+
hookFailureOut();
|
|
157
166
|
}
|
|
@@ -7,11 +7,19 @@
|
|
|
7
7
|
// Everything else lives in ./lib (config, urls, http, cache, format, context,
|
|
8
8
|
// routes) and is bundled into dist/usage/core.mjs at build time.
|
|
9
9
|
|
|
10
|
-
import { pathToFileURL } from "node:url";
|
|
11
|
-
import {
|
|
10
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
11
|
+
import { spawn } from "node:child_process";
|
|
12
|
+
import {
|
|
13
|
+
debugLog,
|
|
14
|
+
usagePreset,
|
|
15
|
+
HOOK_SNAPSHOT_MAX_AGE_MS,
|
|
16
|
+
REFRESH_INTERVAL_MS,
|
|
17
|
+
} from "./lib/config.mjs";
|
|
12
18
|
import { isOfficialBaseUrl } from "./lib/urls.mjs";
|
|
13
19
|
import {
|
|
14
20
|
readUsageSnapshot,
|
|
21
|
+
acquireUsageRefreshLease,
|
|
22
|
+
canRefreshUsage,
|
|
15
23
|
rememberUsageRoute,
|
|
16
24
|
rememberUsageSnapshot,
|
|
17
25
|
rememberRefreshState,
|
|
@@ -20,7 +28,7 @@ import { orderedUsageRoutes } from "./lib/routes.mjs";
|
|
|
20
28
|
import { usageContext, normalizeUsageContext } from "./lib/context.mjs";
|
|
21
29
|
|
|
22
30
|
function parseArgs(argv) {
|
|
23
|
-
const opts = { mode: "hook", agent: "codex" };
|
|
31
|
+
const opts = { mode: "hook", agent: "codex", silent: false };
|
|
24
32
|
let modeSet = false;
|
|
25
33
|
for (let i = 0; i < argv.length; i += 1) {
|
|
26
34
|
const arg = argv[i];
|
|
@@ -28,6 +36,8 @@ function parseArgs(argv) {
|
|
|
28
36
|
opts.agent = argv[++i];
|
|
29
37
|
} else if (arg.startsWith("--agent=")) {
|
|
30
38
|
opts.agent = arg.slice("--agent=".length);
|
|
39
|
+
} else if (arg === "--silent") {
|
|
40
|
+
opts.silent = true;
|
|
31
41
|
} else if (!arg.startsWith("-") && !modeSet) {
|
|
32
42
|
opts.mode = arg;
|
|
33
43
|
modeSet = true;
|
|
@@ -111,30 +121,68 @@ export async function queryProviderUsage(input, options = {}) {
|
|
|
111
121
|
}
|
|
112
122
|
|
|
113
123
|
async function refresh(agent = "codex") {
|
|
114
|
-
|
|
124
|
+
const context = await usageContext(agent);
|
|
125
|
+
const release = await acquireUsageRefreshLease(context);
|
|
126
|
+
if (!release) return { skipped: true, text: "" };
|
|
127
|
+
try {
|
|
128
|
+
if (!(await canRefreshUsage(context, REFRESH_INTERVAL_MS))) return { skipped: true, text: "" };
|
|
129
|
+
await rememberRefreshState(context, { lastStartedAt: new Date().toISOString() });
|
|
130
|
+
return await queryUsageContext(context, { agent, rememberSnapshot: true });
|
|
131
|
+
} finally {
|
|
132
|
+
await release();
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function cachedUsage(context) {
|
|
137
|
+
const cached = await readUsageSnapshot(context);
|
|
138
|
+
const ageMs = cached?.updatedAt ? Date.now() - Date.parse(cached.updatedAt) : Infinity;
|
|
139
|
+
return cached?.text && Number.isFinite(ageMs) ? { ...cached, ageMs } : null;
|
|
115
140
|
}
|
|
116
141
|
|
|
117
142
|
export async function queryAgentProviderUsage(agent = "codex", { maxAgeMs = 0 } = {}) {
|
|
118
143
|
const context = await usageContext(agent);
|
|
119
144
|
if (maxAgeMs > 0) {
|
|
120
|
-
const cached = await
|
|
121
|
-
|
|
122
|
-
if (cached?.text && age < maxAgeMs) return { ...cached, cached: true };
|
|
145
|
+
const cached = await cachedUsage(context);
|
|
146
|
+
if (cached && cached.ageMs < maxAgeMs) return { ...cached, cached: true };
|
|
123
147
|
}
|
|
124
148
|
return await queryUsageContext(context, { agent, rememberSnapshot: true });
|
|
125
149
|
}
|
|
126
150
|
|
|
151
|
+
function scheduleRefresh(agent) {
|
|
152
|
+
const child = spawn(process.execPath, [fileURLToPath(import.meta.url), "refresh", "--agent", agent], {
|
|
153
|
+
cwd: process.cwd(),
|
|
154
|
+
env: process.env,
|
|
155
|
+
detached: true,
|
|
156
|
+
stdio: "ignore",
|
|
157
|
+
windowsHide: true,
|
|
158
|
+
});
|
|
159
|
+
child.on("error", () => {});
|
|
160
|
+
child.unref();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function hook(agent, { silent = false } = {}) {
|
|
164
|
+
const context = await usageContext(agent);
|
|
165
|
+
const cached = await cachedUsage(context);
|
|
166
|
+
if (
|
|
167
|
+
(!cached || cached.ageMs >= REFRESH_INTERVAL_MS) &&
|
|
168
|
+
(await canRefreshUsage(context, REFRESH_INTERVAL_MS))
|
|
169
|
+
) {
|
|
170
|
+
scheduleRefresh(agent);
|
|
171
|
+
}
|
|
172
|
+
hookOut(silent ? "" : cached && cached.ageMs < HOOK_SNAPSHOT_MAX_AGE_MS ? cached.text : "");
|
|
173
|
+
}
|
|
174
|
+
|
|
127
175
|
async function main() {
|
|
128
176
|
try {
|
|
129
177
|
if (mode === "refresh") {
|
|
130
178
|
await refresh(cli.agent);
|
|
131
|
-
} else if (mode === "print"
|
|
132
|
-
|
|
179
|
+
} else if (mode === "print") {
|
|
180
|
+
// Explicit request: query directly instead of going through the
|
|
181
|
+
// background refresh throttle, which would print nothing.
|
|
182
|
+
const result = await queryAgentProviderUsage(cli.agent);
|
|
133
183
|
textOut(result?.text || "");
|
|
134
184
|
} else if (mode === "hook") {
|
|
135
|
-
|
|
136
|
-
const result = await queryAgentProviderUsage(cli.agent, { maxAgeMs: snapshotTtlMs() });
|
|
137
|
-
hookOut(result?.text || "");
|
|
185
|
+
await hook(cli.agent, { silent: cli.silent });
|
|
138
186
|
} else {
|
|
139
187
|
throw new Error(`unknown mode: ${mode}`);
|
|
140
188
|
}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
// On-disk state under ~/.agent-tools/cache: the last working route per
|
|
2
2
|
// gateway, the latest usage snapshot, and refresh bookkeeping.
|
|
3
3
|
|
|
4
|
-
import { writeFile, mkdir } from "node:fs/promises";
|
|
5
|
-
import {
|
|
4
|
+
import { writeFile, mkdir, open, unlink, rename, stat, utimes } from "node:fs/promises";
|
|
5
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
6
|
+
import { dirname, join } from "node:path";
|
|
6
7
|
import {
|
|
8
|
+
CACHE_DIR,
|
|
7
9
|
ROUTE_CACHE_PATH,
|
|
8
10
|
SNAPSHOT_PATH,
|
|
9
11
|
REFRESH_STATE_PATH,
|
|
@@ -12,104 +14,184 @@ import {
|
|
|
12
14
|
} from "./config.mjs";
|
|
13
15
|
import { usageRouteCacheKey } from "./urls.mjs";
|
|
14
16
|
|
|
15
|
-
const
|
|
16
|
-
const
|
|
17
|
-
const REFRESH_STATE_VERSION = 1;
|
|
17
|
+
const CACHE_VERSION = 1;
|
|
18
|
+
const WRITE_LOCK_PATH = join(CACHE_DIR, "usage-cache-write.lock");
|
|
18
19
|
|
|
19
|
-
|
|
20
|
+
// Every cache file is { version, <field>: { [gatewayKey]: entry } }.
|
|
21
|
+
async function readJsonCache(path, field) {
|
|
20
22
|
try {
|
|
21
|
-
const raw = await readTextIfExists(
|
|
22
|
-
|
|
23
|
-
const parsed = JSON.parse(raw);
|
|
23
|
+
const raw = await readTextIfExists(path);
|
|
24
|
+
const entries = raw.trim() ? JSON.parse(raw)?.[field] : null;
|
|
24
25
|
return {
|
|
25
|
-
version:
|
|
26
|
-
|
|
26
|
+
version: CACHE_VERSION,
|
|
27
|
+
[field]: entries && typeof entries === "object" ? entries : {},
|
|
27
28
|
};
|
|
28
29
|
} catch {
|
|
29
|
-
return { version:
|
|
30
|
+
return { version: CACHE_VERSION, [field]: {} };
|
|
30
31
|
}
|
|
31
32
|
}
|
|
32
33
|
|
|
33
|
-
|
|
34
|
+
// Read-modify-write serialized across processes: the gateway refresh lock
|
|
35
|
+
// covers only one relay, and skill/CLI queries hold no lock at all, so
|
|
36
|
+
// concurrent writers would otherwise drop each other's entries. The temp file
|
|
37
|
+
// plus rename keeps readers from ever seeing a half-written file.
|
|
38
|
+
async function updateJsonCache(path, field, source, mutate) {
|
|
39
|
+
const release = await acquireWriteLock();
|
|
40
|
+
if (!release) {
|
|
41
|
+
// Losing one update is safer than an unlocked read-modify-write, which
|
|
42
|
+
// would drop the other gateways' entries. Caches refill on the next run.
|
|
43
|
+
await debugLog({ source, skipped: "cache write lock unavailable" });
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
34
46
|
try {
|
|
35
|
-
const cache = await
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
updatedAt: new Date().toISOString(),
|
|
42
|
-
};
|
|
43
|
-
await mkdir(dirname(ROUTE_CACHE_PATH), { recursive: true });
|
|
44
|
-
await writeFile(ROUTE_CACHE_PATH, `${JSON.stringify(cache, null, 2)}\n`);
|
|
47
|
+
const cache = await readJsonCache(path, field);
|
|
48
|
+
mutate(cache[field]);
|
|
49
|
+
await mkdir(dirname(path), { recursive: true });
|
|
50
|
+
const temp = `${path}.${process.pid}.tmp`;
|
|
51
|
+
await writeFile(temp, `${JSON.stringify(cache, null, 2)}\n`);
|
|
52
|
+
await rename(temp, path);
|
|
45
53
|
} catch (error) {
|
|
46
|
-
await debugLog({ source
|
|
54
|
+
await debugLog({ source, error: error.message });
|
|
55
|
+
} finally {
|
|
56
|
+
await release();
|
|
47
57
|
}
|
|
48
58
|
}
|
|
49
59
|
|
|
50
|
-
async function
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
60
|
+
export async function readRouteCache() {
|
|
61
|
+
return await readJsonCache(ROUTE_CACHE_PATH, "routes");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function rememberUsageRoute(context, route, result) {
|
|
65
|
+
await updateJsonCache(ROUTE_CACHE_PATH, "routes", "route-cache", (routes) => {
|
|
66
|
+
routes[usageRouteCacheKey(context.baseUrl)] = {
|
|
67
|
+
route: route.id,
|
|
68
|
+
path: route.path,
|
|
69
|
+
source: result.source,
|
|
70
|
+
updatedAt: new Date().toISOString(),
|
|
58
71
|
};
|
|
59
|
-
}
|
|
60
|
-
return { version: SNAPSHOT_VERSION, items: {} };
|
|
61
|
-
}
|
|
72
|
+
});
|
|
62
73
|
}
|
|
63
74
|
|
|
64
75
|
export async function readUsageSnapshot(context) {
|
|
65
|
-
const cache = await
|
|
76
|
+
const cache = await readJsonCache(SNAPSHOT_PATH, "items");
|
|
66
77
|
return cache.items[usageRouteCacheKey(context.baseUrl)] || null;
|
|
67
78
|
}
|
|
68
79
|
|
|
69
80
|
export async function rememberUsageSnapshot(context, result) {
|
|
70
81
|
if (!result?.text) return;
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
const key = usageRouteCacheKey(context.baseUrl);
|
|
74
|
-
cache.items[key] = {
|
|
82
|
+
await updateJsonCache(SNAPSHOT_PATH, "items", "snapshot-cache", (items) => {
|
|
83
|
+
items[usageRouteCacheKey(context.baseUrl)] = {
|
|
75
84
|
text: result.text,
|
|
76
85
|
source: result.source,
|
|
77
86
|
baseUrl: context.baseUrl,
|
|
78
87
|
updatedAt: new Date().toISOString(),
|
|
79
88
|
};
|
|
80
|
-
|
|
81
|
-
await writeFile(SNAPSHOT_PATH, `${JSON.stringify(cache, null, 2)}\n`);
|
|
82
|
-
} catch (error) {
|
|
83
|
-
await debugLog({ source: "snapshot-cache", error: error.message });
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
async function readRefreshState() {
|
|
88
|
-
try {
|
|
89
|
-
const raw = await readTextIfExists(REFRESH_STATE_PATH);
|
|
90
|
-
if (!raw.trim()) return { version: REFRESH_STATE_VERSION, items: {} };
|
|
91
|
-
const parsed = JSON.parse(raw);
|
|
92
|
-
return {
|
|
93
|
-
version: REFRESH_STATE_VERSION,
|
|
94
|
-
items: parsed?.items && typeof parsed.items === "object" ? parsed.items : {},
|
|
95
|
-
};
|
|
96
|
-
} catch {
|
|
97
|
-
return { version: REFRESH_STATE_VERSION, items: {} };
|
|
98
|
-
}
|
|
89
|
+
});
|
|
99
90
|
}
|
|
100
91
|
|
|
101
92
|
export async function rememberRefreshState(context, patch) {
|
|
102
|
-
|
|
103
|
-
const state = await readRefreshState();
|
|
93
|
+
await updateJsonCache(REFRESH_STATE_PATH, "items", "refresh-state", (items) => {
|
|
104
94
|
const key = usageRouteCacheKey(context.baseUrl);
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
95
|
+
items[key] = { ...(items[key] || {}), ...patch, baseUrl: context.baseUrl };
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function canRefreshUsage(context, minIntervalMs) {
|
|
100
|
+
if (minIntervalMs <= 0) return true;
|
|
101
|
+
const state = await readJsonCache(REFRESH_STATE_PATH, "items");
|
|
102
|
+
const item = state.items[usageRouteCacheKey(context.baseUrl)] || {};
|
|
103
|
+
const latest = Math.max(
|
|
104
|
+
...[item.lastStartedAt, item.lastSuccessAt, item.lastFailureAt]
|
|
105
|
+
.map((value) => Date.parse(value || ""))
|
|
106
|
+
.filter(Number.isFinite),
|
|
107
|
+
0
|
|
108
|
+
);
|
|
109
|
+
return latest === 0 || Date.now() - latest >= minIntervalMs;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// `wx` fails when the file exists, which makes creating it an atomic
|
|
113
|
+
// cross-process lock. Returns a release function, or null when the lock is held.
|
|
114
|
+
//
|
|
115
|
+
// Best-effort, not a guarantee: the holder keeps touching the file so a live
|
|
116
|
+
// holder is unlikely to be declared stale, but a suspended machine, a stalled
|
|
117
|
+
// event loop, or a failing utimes can still make one miss its heartbeat. The
|
|
118
|
+
// owner token on release covers most of what slips through — a takeover that
|
|
119
|
+
// happens between reading the token and unlinking still isn't excluded, and a
|
|
120
|
+
// stale holder's heartbeat can even touch the new owner's file, since utimes
|
|
121
|
+
// works on the path rather than a handle. Good enough for a background usage
|
|
122
|
+
// cache, where the worst case is one duplicated refresh that self-heals.
|
|
123
|
+
async function tryLock(lockPath, staleMs, details = {}) {
|
|
124
|
+
await mkdir(dirname(lockPath), { recursive: true });
|
|
125
|
+
const token = randomUUID();
|
|
126
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
127
|
+
try {
|
|
128
|
+
const handle = await open(lockPath, "wx");
|
|
129
|
+
try {
|
|
130
|
+
await handle.writeFile(
|
|
131
|
+
`${JSON.stringify({ token, pid: process.pid, ...details, startedAt: new Date().toISOString() })}\n`
|
|
132
|
+
);
|
|
133
|
+
} finally {
|
|
134
|
+
await handle.close();
|
|
135
|
+
}
|
|
136
|
+
const heartbeat = setInterval(() => {
|
|
137
|
+
const now = new Date();
|
|
138
|
+
utimes(lockPath, now, now).catch(() => {});
|
|
139
|
+
}, Math.max(50, Math.floor(staleMs / 3)));
|
|
140
|
+
heartbeat.unref(); // must never keep the process alive
|
|
141
|
+
return async () => {
|
|
142
|
+
clearInterval(heartbeat);
|
|
143
|
+
// If we were taken over anyway, the file belongs to someone else and
|
|
144
|
+
// must survive; this check is why a missed heartbeat is recoverable.
|
|
145
|
+
try {
|
|
146
|
+
const held = JSON.parse(await readTextIfExists(lockPath));
|
|
147
|
+
if (held?.token === token) await unlink(lockPath);
|
|
148
|
+
} catch {
|
|
149
|
+
// Unreadable or already gone: nothing of ours left to release.
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
} catch (error) {
|
|
153
|
+
if (error?.code !== "EEXIST" || attempt > 0) return null;
|
|
154
|
+
const age = await stat(lockPath).then(
|
|
155
|
+
({ mtimeMs }) => Date.now() - mtimeMs,
|
|
156
|
+
() => Infinity // vanished under us: retry immediately
|
|
157
|
+
);
|
|
158
|
+
if (age <= staleMs) return null;
|
|
159
|
+
// Claim the stale lock by renaming it: rename is atomic, so only one
|
|
160
|
+
// process wins and nobody deletes a lock another just created.
|
|
161
|
+
try {
|
|
162
|
+
const claimed = `${lockPath}.${token}`;
|
|
163
|
+
await rename(lockPath, claimed);
|
|
164
|
+
await unlink(claimed).catch(() => {});
|
|
165
|
+
} catch {
|
|
166
|
+
return null; // another process claimed it first
|
|
167
|
+
}
|
|
168
|
+
}
|
|
114
169
|
}
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// One lock per gateway, keyed like the route and snapshot caches, so
|
|
174
|
+
// refreshing one relay never blocks another.
|
|
175
|
+
function refreshLockPath(context) {
|
|
176
|
+
const key = usageRouteCacheKey(context.baseUrl);
|
|
177
|
+
const hash = createHash("sha256").update(key).digest("hex").slice(0, 16);
|
|
178
|
+
return join(CACHE_DIR, `usage-refresh-${hash}.lock`);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// The holder keeps its lock warm, so this window mostly decides how soon a
|
|
182
|
+
// crashed refresh is reclaimed rather than how long a slow one may run.
|
|
183
|
+
export async function acquireUsageRefreshLease(context, leaseMs = 60_000) {
|
|
184
|
+
return await tryLock(refreshLockPath(context), leaseMs, { baseUrl: context.baseUrl });
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Writers wait briefly for each other; null means "skip this write" so a cache
|
|
188
|
+
// update never blocks a background refresh.
|
|
189
|
+
async function acquireWriteLock({ timeoutMs = 500, staleMs = 5_000 } = {}) {
|
|
190
|
+
const deadline = Date.now() + timeoutMs;
|
|
191
|
+
do {
|
|
192
|
+
const release = await tryLock(WRITE_LOCK_PATH, staleMs);
|
|
193
|
+
if (release) return release;
|
|
194
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
195
|
+
} while (Date.now() < deadline);
|
|
196
|
+
return null;
|
|
115
197
|
}
|
|
@@ -13,9 +13,10 @@ export const AUTH_PATH = join(CODEX_HOME, "auth.json");
|
|
|
13
13
|
export const CODEX_CONFIG_PATH = join(CODEX_HOME, "config.toml");
|
|
14
14
|
export const AGENT_CONFIG_PATH = join(AGENT_TOOLS_HOME, "config.jsonc");
|
|
15
15
|
export const DEBUG_PATH = join(AGENT_TOOLS_HOME, "logs", "usage-debug.log");
|
|
16
|
-
export const
|
|
17
|
-
export const
|
|
18
|
-
export const
|
|
16
|
+
export const CACHE_DIR = join(AGENT_TOOLS_HOME, "cache");
|
|
17
|
+
export const ROUTE_CACHE_PATH = join(CACHE_DIR, "usage-routes.json");
|
|
18
|
+
export const SNAPSHOT_PATH = join(CACHE_DIR, "usage-snapshot.json");
|
|
19
|
+
export const REFRESH_STATE_PATH = join(CACHE_DIR, "usage-refresh-state.json");
|
|
19
20
|
export const DEFAULT_USAGE_DAYS = 30;
|
|
20
21
|
export const MAX_USAGE_DAYS = 90;
|
|
21
22
|
export const DEFAULT_NEW_API_QUOTA_SCALE = 500000;
|
|
@@ -91,13 +92,13 @@ export async function usagePreset() {
|
|
|
91
92
|
return String(process.env.PROVIDER_USAGE_PRESET || config.preset || "auto").toLowerCase();
|
|
92
93
|
}
|
|
93
94
|
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
95
|
+
// Timing follows the caller's shape, so it is fixed rather than configurable:
|
|
96
|
+
// the hook never waits on the network (snapshot + background refresh), and
|
|
97
|
+
// every other caller either runs detached or is an explicit user request.
|
|
98
|
+
// Past this age a snapshot is too stale to show instead of nothing.
|
|
99
|
+
export const HOOK_SNAPSHOT_MAX_AGE_MS = 10 * 60_000;
|
|
100
|
+
// Minimum gap between gateway queries, however many hooks fire.
|
|
101
|
+
export const REFRESH_INTERVAL_MS = 60_000;
|
|
101
102
|
|
|
102
103
|
export async function newApiQuotaScale() {
|
|
103
104
|
const config = await agentConfig();
|
|
@@ -6,6 +6,15 @@ import { newApiQuotaScale, providerUsageDays } from "./config.mjs";
|
|
|
6
6
|
|
|
7
7
|
const ONE_API_HARD_LIMIT_SENTINEL_USD = 1_000_000;
|
|
8
8
|
|
|
9
|
+
const RESET_TIME_FIELDS = Object.freeze({
|
|
10
|
+
// Sub2API rate-limit entries returned alongside quota usage.
|
|
11
|
+
SUB2API_RATE_LIMIT: "reset_at",
|
|
12
|
+
// Sub2API /v1/usage response: subscription weekly-window anchor.
|
|
13
|
+
SUB2API_SUBSCRIPTION_WEEKLY_START: "weekly_window_start",
|
|
14
|
+
// OpenRouter /api/v1/key response: absolute key-limit reset.
|
|
15
|
+
OPENROUTER_KEY_LIMIT: "limit_reset",
|
|
16
|
+
});
|
|
17
|
+
|
|
9
18
|
export function pickNumber(obj, keys) {
|
|
10
19
|
for (const key of keys) {
|
|
11
20
|
const value = obj?.[key];
|
|
@@ -40,6 +49,45 @@ function shortDate(value) {
|
|
|
40
49
|
return match ? `${match[2]}-${match[3]}` : "";
|
|
41
50
|
}
|
|
42
51
|
|
|
52
|
+
function timestampMs(value) {
|
|
53
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
54
|
+
return value < 1e12 ? value * 1000 : value;
|
|
55
|
+
}
|
|
56
|
+
const parsed = Date.parse(value);
|
|
57
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function compactDurationUntil(value) {
|
|
61
|
+
const resetMs = timestampMs(value);
|
|
62
|
+
if (resetMs === undefined) return "";
|
|
63
|
+
|
|
64
|
+
const remainingMs = resetMs - Date.now();
|
|
65
|
+
if (remainingMs <= 0) return "";
|
|
66
|
+
|
|
67
|
+
let seconds = Math.floor(remainingMs / 1000);
|
|
68
|
+
if (seconds <= 0) return "0m";
|
|
69
|
+
const days = Math.floor(seconds / 86400);
|
|
70
|
+
seconds %= 86400;
|
|
71
|
+
const hours = Math.floor(seconds / 3600);
|
|
72
|
+
seconds %= 3600;
|
|
73
|
+
const minutes = Math.floor(seconds / 60);
|
|
74
|
+
if (days) return `${days}d${hours}h`;
|
|
75
|
+
if (hours) return `${hours}h${minutes}m`;
|
|
76
|
+
return `${minutes}m`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function rateLimitResetAt(entry) {
|
|
80
|
+
const value = entry?.[RESET_TIME_FIELDS.SUB2API_RATE_LIMIT];
|
|
81
|
+
return timestampMs(value) === undefined ? undefined : value;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function weeklyResetAt(subscription) {
|
|
85
|
+
const startMs = timestampMs(
|
|
86
|
+
subscription?.[RESET_TIME_FIELDS.SUB2API_SUBSCRIPTION_WEEKLY_START]
|
|
87
|
+
);
|
|
88
|
+
return startMs === undefined ? undefined : startMs + 7 * 86400 * 1000;
|
|
89
|
+
}
|
|
90
|
+
|
|
43
91
|
function hasSubscriptionLimits(root) {
|
|
44
92
|
const sub = root?.subscription || {};
|
|
45
93
|
return [
|
|
@@ -163,7 +211,7 @@ export function formatOpenRouterLine(data) {
|
|
|
163
211
|
const limit = pickNumber(root, ["limit", "limit_remaining", "total_credits"]);
|
|
164
212
|
const remaining = pickNumber(root, ["limit_remaining", "remaining_credits"]);
|
|
165
213
|
const used = pickNumber(root, ["usage", "total_usage", "spend"]);
|
|
166
|
-
const reset = root?.
|
|
214
|
+
const reset = compactDurationUntil(root?.[RESET_TIME_FIELDS.OPENROUTER_KEY_LIMIT]);
|
|
167
215
|
const parts = [];
|
|
168
216
|
|
|
169
217
|
if (remaining !== undefined) parts.push(`balance ${formatMoney(remaining)}`);
|
|
@@ -172,7 +220,7 @@ export function formatOpenRouterLine(data) {
|
|
|
172
220
|
} else if (used !== undefined) {
|
|
173
221
|
parts.push(`used ${formatMoney(used)}`);
|
|
174
222
|
}
|
|
175
|
-
if (reset) parts.push(
|
|
223
|
+
if (reset) parts.push(`⟳${reset}`);
|
|
176
224
|
|
|
177
225
|
if (parts.length === 0) throw new Error("OpenRouter payload has no usage fields");
|
|
178
226
|
return parts.join(" | ");
|
|
@@ -254,8 +302,9 @@ function formatQuotaLimitedLine(root) {
|
|
|
254
302
|
const window = entry?.window;
|
|
255
303
|
const rateLimit = pickNumber(entry, ["limit"]);
|
|
256
304
|
const rateUsed = pickNumber(entry, ["used"]);
|
|
305
|
+
const reset = compactDurationUntil(rateLimitResetAt(entry));
|
|
257
306
|
return window && rateLimit !== undefined && rateUsed !== undefined
|
|
258
|
-
? `${window} ${formatMoney(rateUsed)}/${formatMoney(rateLimit)}`
|
|
307
|
+
? `${window} ${formatMoney(rateUsed)}/${formatMoney(rateLimit)}${reset ? ` ⟳${reset}` : ""}`
|
|
259
308
|
: "";
|
|
260
309
|
})
|
|
261
310
|
.filter(Boolean);
|
|
@@ -290,10 +339,13 @@ function formatUsageLine(root) {
|
|
|
290
339
|
const monthlyLimit = pickNumber(sub, ["monthly_limit_usd"]);
|
|
291
340
|
const monthlyUsage = pickNumber(sub, ["monthly_usage_usd"]);
|
|
292
341
|
const expires = shortDate(sub.expires_at);
|
|
342
|
+
const weeklyReset = compactDurationUntil(weeklyResetAt(sub));
|
|
293
343
|
|
|
294
344
|
const parts = [];
|
|
295
345
|
if (dailyLimit > 0 && dailyUsage !== undefined) parts.push(`D ${formatMoney(dailyUsage)}/${formatMoney(dailyLimit)}`);
|
|
296
|
-
if (weeklyLimit > 0 && weeklyUsage !== undefined)
|
|
346
|
+
if (weeklyLimit > 0 && weeklyUsage !== undefined) {
|
|
347
|
+
parts.push(`W ${formatMoney(weeklyUsage)}/${formatMoney(weeklyLimit)}${weeklyReset ? ` ⟳${weeklyReset}` : ""}`);
|
|
348
|
+
}
|
|
297
349
|
if (monthlyLimit > 0 && monthlyUsage !== undefined) parts.push(`M ${formatMoney(monthlyUsage)}/${formatMoney(monthlyLimit)}`);
|
|
298
350
|
if (expires) parts.push(`Exp ${expires}`);
|
|
299
351
|
return parts.join(" | ");
|
|
@@ -4,7 +4,10 @@
|
|
|
4
4
|
import { createContext, runInContext } from "node:vm";
|
|
5
5
|
import { debugLog } from "./config.mjs";
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
// No user waits on this request: the hook reads a snapshot and refreshes in a
|
|
8
|
+
// detached process, and skill/CLI queries are explicit. Custom routes can pass
|
|
9
|
+
// their own timeoutMs.
|
|
10
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 10_000;
|
|
8
11
|
const SHIELD_USER_AGENT =
|
|
9
12
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
|
|
10
13
|
"(KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36";
|
|
@@ -126,7 +129,7 @@ function mergeSetCookiePairs(cookieHeader, setCookieHeaders) {
|
|
|
126
129
|
}
|
|
127
130
|
|
|
128
131
|
export async function requestJson(url, options = {}) {
|
|
129
|
-
const { key = "", headers = {}, name = "usage", timeoutMs =
|
|
132
|
+
const { key = "", headers = {}, name = "usage", timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS } = options;
|
|
130
133
|
let cookieHeader = "";
|
|
131
134
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
132
135
|
const controller = new AbortController();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kairyou/agent-tools",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.1",
|
|
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
|
"repository": {
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
},
|
|
16
16
|
"files": [
|
|
17
17
|
"config.default.jsonc",
|
|
18
|
+
"docs/",
|
|
18
19
|
"dist/",
|
|
19
20
|
"integrations/",
|
|
20
21
|
"scripts/",
|