@kairyou/agent-tools 0.8.0 → 0.10.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 +57 -113
- package/README.zh-CN.md +55 -103
- package/dist/usage/cli.mjs +5 -3
- package/dist/usage/codex-hook.mjs +18 -10
- package/dist/usage/core.mjs +275 -114
- 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 +79 -5
- package/integrations/usage/lib/http.mjs +5 -2
- package/integrations/usage/lib/routes.mjs +42 -15
- package/package.json +2 -1
- package/scripts/install.mjs +13 -4
- package/scripts/publish.mjs +6 -2
- package/scripts/release.mjs +8 -6
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# 自定义网关路由
|
|
2
|
+
|
|
3
|
+
[Provider usage](../../README.zh-CN.md#provider-usage) 的高级指南: 为内置 preset 覆盖不到的中转(比如 cookie 认证的网关)编写自己的用量探测, 无需修改包内代码.
|
|
4
|
+
|
|
5
|
+
## 声明路由
|
|
6
|
+
|
|
7
|
+
编写路由模块, 并在 `providerUsage.routes` 里声明(相对 `~/.agent-tools` 解析). 声明的路由优先探测; `"preset"` 填路由 id 可直接选中.
|
|
8
|
+
|
|
9
|
+
```jsonc
|
|
10
|
+
{
|
|
11
|
+
"providerUsage": {
|
|
12
|
+
"routes": [
|
|
13
|
+
"custom/my-gateway.mjs",
|
|
14
|
+
"custom/another-gateway.mjs"
|
|
15
|
+
],
|
|
16
|
+
"myGateway": { "username": "me", "password": "..." }
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## 路由模块 API
|
|
22
|
+
|
|
23
|
+
```js
|
|
24
|
+
// ~/.agent-tools/custom/my-gateway.mjs
|
|
25
|
+
export const meta = { id: "my-gateway" }; // 可选; id 默认取文件名
|
|
26
|
+
|
|
27
|
+
export async function run(context, { requestJson, agentConfig }) {
|
|
28
|
+
// context: { baseUrl, key, providerName, provider, label }
|
|
29
|
+
const { myGateway = {} } = await agentConfig(); // providerUsage 对象, 自定义键随意加
|
|
30
|
+
|
|
31
|
+
const login = await fetch(`${context.baseUrl}/api/user/login`, {
|
|
32
|
+
method: "POST",
|
|
33
|
+
headers: { "content-type": "application/json" },
|
|
34
|
+
body: JSON.stringify({ username: myGateway.username, password: myGateway.password }),
|
|
35
|
+
});
|
|
36
|
+
const session = await login.json();
|
|
37
|
+
|
|
38
|
+
// requestJson 会解析 JSON, 并在非 2xx 响应时抛错; 需要时可在这里传入
|
|
39
|
+
// authorization、cookie 等自定义认证 header.
|
|
40
|
+
const me = await requestJson(`${context.baseUrl}/api/user/self`, {
|
|
41
|
+
headers: { authorization: `Bearer ${session?.data?.accessToken}` },
|
|
42
|
+
});
|
|
43
|
+
return { text: `balance ¥${me?.data?.balance}` };
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`text` 是自由字符串; 成功返回 `{ text }`, 抛错则回落到下一条路由. 开启 `providerUsage.debug` 后, 探测失败会记录到 `~/.agent-tools/logs/usage-debug.log`.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# 仓库结构
|
|
2
|
+
|
|
3
|
+
```text
|
|
4
|
+
agent-tools/
|
|
5
|
+
├── .claude-plugin/ # Claude Code/plugin 生态的 manifest.
|
|
6
|
+
├── .codex-plugin/ # Codex plugin manifest.
|
|
7
|
+
├── integrations/ # 可安装的 capability, 一个一目录.
|
|
8
|
+
│ ├── statusline/ # Agent 状态栏: 分支, 模型, 用量.
|
|
9
|
+
│ ├── usage/ # Provider 余额/额度显示.
|
|
10
|
+
│ └── vision/ # 跨模型识图.
|
|
11
|
+
├── skills/ # 可复用的 Agent Skills.
|
|
12
|
+
│ ├── workflow/ # 工作流类 skills.
|
|
13
|
+
│ │ ├── at-commit/ # 生成 Conventional Commits message.
|
|
14
|
+
│ │ ├── at-review/ # 审查改动中的 bug 与回归风险.
|
|
15
|
+
│ │ └── at-simplify/ # 减少改动中的冗余和复杂度.
|
|
16
|
+
│ └── integrations/ # 对接外部系统的 skills.
|
|
17
|
+
│ └── at-zentao/ # 禅道 bug/task 修复工作流.
|
|
18
|
+
├── docs/ # 高级指南和贡献者参考.
|
|
19
|
+
└── scripts/ # 安装, 同步, 校验和仓库维护脚本.
|
|
20
|
+
```
|
|
@@ -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();
|