autowonder 0.2.138 → 0.2.140
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 +11 -9
- package/bin/cli.js +53 -4
- package/bin/qoder-bootstrap.js +135 -2
- package/bin/shared.js +17 -5
- package/bin/token-proxy.js +469 -0
- package/package.json +1 -1
- package/vendor/autowonder-daemon-darwin-amd64 +0 -0
- package/vendor/autowonder-daemon-darwin-arm64 +0 -0
- package/vendor/autowonder-daemon-linux-amd64 +0 -0
- package/vendor/autowonder-daemon-linux-arm64 +0 -0
- package/vendor/autowonder-daemon-win32-amd64.exe +0 -0
- package/vendor/autowonder-daemon-win32-arm64.exe +0 -0
package/README.md
CHANGED
|
@@ -6,16 +6,16 @@ AutoWonder 本地 agent runtime。安装后启动 daemon,持续轮询本地 as
|
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
8
|
# Qoder CLI(模型使用 provider model ID;Context Window 使用固定档位)
|
|
9
|
-
npx -y autowonder@0.2.
|
|
9
|
+
npx -y autowonder@0.2.140 connect --ws-url <wss-endpoint> --token <executor-token> --executor-id <executor-id> --provider qoder --model qmodel_latest --reasoning-effort medium --context-window 260000
|
|
10
10
|
|
|
11
11
|
# Qoder CLI CN
|
|
12
|
-
npx -y autowonder@0.2.
|
|
12
|
+
npx -y autowonder@0.2.140 connect --ws-url <wss-endpoint> --token <executor-token> --executor-id <executor-id> --provider qodercn
|
|
13
13
|
|
|
14
14
|
# Claude Code
|
|
15
|
-
npx -y autowonder@0.2.
|
|
15
|
+
npx -y autowonder@0.2.140 connect --ws-url <wss-endpoint> --token <executor-token> --executor-id <executor-id> --provider claude
|
|
16
16
|
|
|
17
17
|
# Codex CLI
|
|
18
|
-
npx -y autowonder@0.2.
|
|
18
|
+
npx -y autowonder@0.2.140 connect --ws-url <wss-endpoint> --token <executor-token> --executor-id <executor-id> --provider codex --model gpt-5.5 --reasoning-effort medium
|
|
19
19
|
```
|
|
20
20
|
|
|
21
21
|
`connect` 会安装当前 npm 包内置的 daemon,并使用页面生成的 WebSocket endpoint、Token 和执行器 ID 建立连接。选择 Qoder 时需要 Node.js 20+;runtime 会在缺少 `qodercli` 时自动通过 npm 安装,并在尚未登录时打开 Qoder 浏览器登录。Claude Code 和 Codex CLI 仍需提前安装并登录。runtime 会继承当前用户的 HOME、环境变量和 CLI 登录状态。
|
|
@@ -24,6 +24,8 @@ npx -y autowonder@0.2.138 connect --ws-url <wss-endpoint> --token <executor-toke
|
|
|
24
24
|
|
|
25
25
|
Qoder 的 Context Window 只能使用 `1000000`、`400000` 或 `260000`,Reasoning Effort 只能使用 `none`、`low`、`medium`、`high`、`xhigh` 或 `max`。建议直接复制 AutoWonder 执行器页面生成的命令,避免把模型展示名称误当成 provider model ID。
|
|
26
26
|
|
|
27
|
+
给 `connect` 追加 `--token-aware-enable` 可开启 qoder / qodercn 的 token 用量采集:CLI 会以 sidecar 方式启动本地 HTTPS 转发代理(`bin/token-proxy.js`),从 Qoder API 的 SSE 响应流中提取 usage 数据并按 dispatch ID 归属上报。该功能旁路设计、不阻塞任务执行,代理启动失败时仅告警并继续。代理端口可用 `TOKEN_PROXY_PORT` 环境变量调整(默认 19876);CA 证书位于 `~/.autowonder/token-tap/ca-cert.pem`。daemon 退出时 sidecar 一并停止。
|
|
28
|
+
|
|
27
29
|
## 自动更新
|
|
28
30
|
|
|
29
31
|
安装首个支持自动更新的正式版本后,daemon 会直接查询 npm 的 `autowonder/latest`。首次检查会随机分散在启动后的 30~90 秒,之后约每 5 分钟检查一次并加入随机抖动;npm 临时错误会指数退避,HTTP 429/503 的 `Retry-After` 会被遵循。发现更高的稳定版本时,它会在后台下载 npm tarball,校验 SHA-512 SRI、包版本和平台二进制;只有在没有运行/恢复任务且结果 outbox 已收到服务端确认时,才会暂停接单、原子切换二进制并自动重启。启动失败会恢复上一个二进制。
|
|
@@ -62,7 +64,7 @@ mv "$queue/.assignment.tmp" "$queue/assignment.json"
|
|
|
62
64
|
也可以直接提交到本地 API:
|
|
63
65
|
|
|
64
66
|
```bash
|
|
65
|
-
npx -y autowonder@0.2.
|
|
67
|
+
npx -y autowonder@0.2.140 dispatch ./assignment.json
|
|
66
68
|
```
|
|
67
69
|
|
|
68
70
|
## 上传工单附件
|
|
@@ -70,7 +72,7 @@ npx -y autowonder@0.2.138 dispatch ./assignment.json
|
|
|
70
72
|
把本地需求/设计文档直接上传到工单,避免在会话里传递大文件:
|
|
71
73
|
|
|
72
74
|
```bash
|
|
73
|
-
npx -y autowonder@0.2.
|
|
75
|
+
npx -y autowonder@0.2.140 workitem upload --server-url <autowonder-server-url> --workitem-id <id> --file <filepath-1> --file <filepath-2> --json
|
|
74
76
|
```
|
|
75
77
|
|
|
76
78
|
上传令牌通过 MCP 工具 `autowonder.workitem_cli_upload_token` 签发,经 `--token` 或 `AUTOWONDER_UPLOAD_TOKEN` 环境变量传入。`--file` 可重复指定多个文件;`--json` 输出机器可读结果。失败时按错误类型返回不同退出码(401→3、403→4、404→5、409→6、413→7、其他 4xx→8、网络/重定向→9)。
|
|
@@ -78,8 +80,8 @@ npx -y autowonder@0.2.138 workitem upload --server-url <autowonder-server-url> -
|
|
|
78
80
|
## 管理 daemon
|
|
79
81
|
|
|
80
82
|
```bash
|
|
81
|
-
npx -y autowonder@0.2.
|
|
82
|
-
npx -y autowonder@0.2.
|
|
83
|
+
npx -y autowonder@0.2.140 status
|
|
84
|
+
npx -y autowonder@0.2.140 stop
|
|
83
85
|
```
|
|
84
86
|
|
|
85
87
|
默认 API 是 `http://127.0.0.1:34989`,日志位于 `~/.autowonder/daemon.log`。npm 包不包含任何 agent、MCP 或服务端凭证。
|
|
@@ -87,7 +89,7 @@ npx -y autowonder@0.2.138 stop
|
|
|
87
89
|
## 调试
|
|
88
90
|
|
|
89
91
|
```bash
|
|
90
|
-
npx -y autowonder@0.2.
|
|
92
|
+
npx -y autowonder@0.2.140 connect ... --debug
|
|
91
93
|
```
|
|
92
94
|
|
|
93
95
|
`--debug` 会让 CLI 以 `AUTOWONDER_DEBUG=1` 启动 daemon,把完整的 daemon 与 agent 活动(runtime 事件流、provider 事件解码、重试退避与会话门等待、空闲时的阻塞点)同时输出到控制台和会话调试日志。调试日志不做脱敏,仅用于本机排障;不传 `--debug` 时该环境变量不会注入。
|
package/bin/cli.js
CHANGED
|
@@ -331,7 +331,7 @@ function buildFromSource() {
|
|
|
331
331
|
// ─── Commands ────────────────────────────────────────────────────────
|
|
332
332
|
|
|
333
333
|
async function cmdConnect(args) {
|
|
334
|
-
const flags = parseFlags(args, ["ws-url", "token", "executor-id", "provider", "name", "max-tasks", "model", "reasoning-effort", "context-window", "memory-mode", "settings"]);
|
|
334
|
+
const flags = parseFlags(args, ["ws-url", "token", "executor-id", "provider", "name", "max-tasks", "model", "reasoning-effort", "context-window", "memory-mode", "settings", "token-aware-enable"]);
|
|
335
335
|
|
|
336
336
|
if (!flags["ws-url"] || !flags.token || !flags["executor-id"]) {
|
|
337
337
|
error("Usage: autowonder connect --ws-url <url> --token <token> --executor-id <id> [--provider qoder|qodercn]");
|
|
@@ -427,10 +427,59 @@ async function cmdConnect(args) {
|
|
|
427
427
|
if (config.claudeSettingsPath) env.AUTOWONDER_CLAUDE_SETTINGS = config.claudeSettingsPath;
|
|
428
428
|
if (flags.debug) env.AUTOWONDER_DEBUG = "1";
|
|
429
429
|
|
|
430
|
+
// --token-aware-enable: start token-proxy as a sidecar (bypass, non-blocking)
|
|
431
|
+
let tokenProxyChild = null;
|
|
432
|
+
if (flags["token-aware-enable"]) {
|
|
433
|
+
const tokenProxyScript = path.join(__dirname, "token-proxy.js");
|
|
434
|
+
if (fs.existsSync(tokenProxyScript)) {
|
|
435
|
+
const proxyPort = parseInt(process.env.TOKEN_PROXY_PORT || "19876", 10);
|
|
436
|
+
const caCertPath = path.join(AUTOWONDER_HOME, "token-tap", "ca-cert.pem");
|
|
437
|
+
try {
|
|
438
|
+
tokenProxyChild = spawn(process.execPath, [tokenProxyScript, "start", "--port", String(proxyPort)], {
|
|
439
|
+
detached: true,
|
|
440
|
+
stdio: ["ignore", "ignore", "ignore"],
|
|
441
|
+
});
|
|
442
|
+
tokenProxyChild.unref();
|
|
443
|
+
|
|
444
|
+
// Wait up to 3s for CA cert to appear (proxy generates it on first start)
|
|
445
|
+
const deadline = Date.now() + 3000;
|
|
446
|
+
while (Date.now() < deadline && !fs.existsSync(caCertPath)) {
|
|
447
|
+
spawnSync(process.execPath, ["-e", "setTimeout(()=>{},200)"], { stdio: "ignore" }); // cross-platform sleep
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
if (fs.existsSync(caCertPath)) {
|
|
451
|
+
// Pass proxy config to daemon via env. The daemon will set per-dispatch
|
|
452
|
+
// HTTPS_PROXY=http://d-{dispatchId}:x@host:port for each qodercli process,
|
|
453
|
+
// so the proxy can tag token records with the exact dispatch ID.
|
|
454
|
+
env.AUTOWONDER_TOKEN_PROXY_PORT = String(proxyPort);
|
|
455
|
+
env.AUTOWONDER_TOKEN_PROXY_CA = caCertPath;
|
|
456
|
+
env.NODE_EXTRA_CA_CERTS = caCertPath;
|
|
457
|
+
log(`Token tracking enabled (proxy pid=${tokenProxyChild.pid}, port=${proxyPort})`);
|
|
458
|
+
} else {
|
|
459
|
+
log("WARN: token-proxy CA cert not ready, continuing without token tracking");
|
|
460
|
+
}
|
|
461
|
+
} catch (e) {
|
|
462
|
+
log(`WARN: token-proxy start failed (${e.message}), continuing without token tracking`);
|
|
463
|
+
}
|
|
464
|
+
} else {
|
|
465
|
+
log(`WARN: token-proxy.js not found at ${tokenProxyScript}, --token-aware-enable ignored`);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
430
469
|
const child = spawn(DAEMON_BIN, [], { env, stdio: "inherit" });
|
|
431
|
-
child.on("exit", (code) =>
|
|
432
|
-
|
|
433
|
-
|
|
470
|
+
child.on("exit", (code) => {
|
|
471
|
+
// Stop token-proxy when daemon exits
|
|
472
|
+
if (tokenProxyChild) { try { process.kill(tokenProxyChild.pid); } catch (_) {} }
|
|
473
|
+
process.exit(code || 0);
|
|
474
|
+
});
|
|
475
|
+
process.on("SIGINT", () => {
|
|
476
|
+
child.kill("SIGINT");
|
|
477
|
+
if (tokenProxyChild) { try { process.kill(tokenProxyChild.pid); } catch (_) {} }
|
|
478
|
+
});
|
|
479
|
+
process.on("SIGTERM", () => {
|
|
480
|
+
child.kill("SIGTERM");
|
|
481
|
+
if (tokenProxyChild) { try { process.kill(tokenProxyChild.pid); } catch (_) {} }
|
|
482
|
+
});
|
|
434
483
|
}
|
|
435
484
|
|
|
436
485
|
async function cmdStart(args) {
|
package/bin/qoder-bootstrap.js
CHANGED
|
@@ -25,6 +25,7 @@ const QODER_FAMILY_PROFILES = {
|
|
|
25
25
|
defaultConfigRootName: ".qoder",
|
|
26
26
|
managedBinName: "qodercli",
|
|
27
27
|
windowsShimName: "qodercli.cmd",
|
|
28
|
+
windowsInstallDirNames: ["Qoder CLI", "QoderCLI"],
|
|
28
29
|
unixCandidateRelPaths: [
|
|
29
30
|
[".local", "bin", "qodercli"],
|
|
30
31
|
[".qoder", "bin", "qodercli", "qodercli"],
|
|
@@ -41,6 +42,7 @@ const QODER_FAMILY_PROFILES = {
|
|
|
41
42
|
defaultConfigRootName: ".qoder-cn",
|
|
42
43
|
managedBinName: "qodercn",
|
|
43
44
|
windowsShimName: "qodercn.cmd",
|
|
45
|
+
windowsInstallDirNames: ["Qoder CN CLI", "QoderCNCLI"],
|
|
44
46
|
unixCandidateRelPaths: [[".local", "bin", "qodercn"]],
|
|
45
47
|
},
|
|
46
48
|
};
|
|
@@ -63,10 +65,127 @@ function isQoderFamilyProvider(provider) {
|
|
|
63
65
|
return provider === "qoder" || provider === "qodercn";
|
|
64
66
|
}
|
|
65
67
|
|
|
68
|
+
// Quotes one argument for cmd.exe. Paths containing spaces must stay a single
|
|
69
|
+
// token when a .cmd shim is launched through ComSpec.
|
|
70
|
+
function quoteWindowsArg(arg) {
|
|
71
|
+
if (arg && !/[\s"]/.test(arg)) return arg;
|
|
72
|
+
return `"${String(arg).replace(/"/g, '\\"')}"`;
|
|
73
|
+
}
|
|
74
|
+
|
|
66
75
|
function spawnQoderSync(executable, args, options = {}) {
|
|
67
76
|
// npm exposes package binaries as .cmd shims on Windows. CreateProcess
|
|
68
|
-
// cannot execute those shims directly,
|
|
69
|
-
|
|
77
|
+
// cannot execute those shims directly, but shell:true would join the
|
|
78
|
+
// executable and its arguments with bare spaces and shred any path that
|
|
79
|
+
// contains a space (e.g. "...\Qoder CN CLI\..."). Launch ComSpec ourselves
|
|
80
|
+
// with an explicitly quoted command line instead; everything else runs as a
|
|
81
|
+
// plain argument array with no shell involved.
|
|
82
|
+
if (IS_WIN32 && /\.(cmd|bat)$/i.test(executable)) {
|
|
83
|
+
const comspec = process.env.ComSpec || "cmd.exe";
|
|
84
|
+
const command = [quoteWindowsArg(executable), ...args.map(quoteWindowsArg)].join(" ");
|
|
85
|
+
return spawnSync(comspec, ["/d", "/s", "/c", command], options);
|
|
86
|
+
}
|
|
87
|
+
return spawnSync(executable, args, { ...options, shell: false });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Roots that hold user-level installers on Windows. Kept env-driven so the
|
|
91
|
+
// probe logic stays testable off Windows.
|
|
92
|
+
function windowsInstallRoots(env) {
|
|
93
|
+
const roots = [];
|
|
94
|
+
if (env.LOCALAPPDATA) roots.push(path.join(env.LOCALAPPDATA, "Programs"));
|
|
95
|
+
if (env.PROGRAMFILES) roots.push(env.PROGRAMFILES);
|
|
96
|
+
if (env["PROGRAMFILES(X86)"]) roots.push(env["PROGRAMFILES(X86)"]);
|
|
97
|
+
if (env.USERPROFILE) roots.push(env.USERPROFILE);
|
|
98
|
+
return roots;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Matches a directory inside `parent` against the wanted names
|
|
102
|
+
// case-insensitively, also accepting the space-free spelling, so casing or
|
|
103
|
+
// spacing drift cannot hide an existing install.
|
|
104
|
+
function matchInstallDir(parent, wantedNames, probed) {
|
|
105
|
+
let entries;
|
|
106
|
+
try { entries = fs.readdirSync(parent); } catch { return ""; }
|
|
107
|
+
const wanted = new Set(wantedNames.map((name) => name.toLowerCase()));
|
|
108
|
+
for (const entry of entries) {
|
|
109
|
+
const lower = entry.toLowerCase();
|
|
110
|
+
if (!wanted.has(lower) && !wanted.has(lower.replace(/\s+/g, ""))) continue;
|
|
111
|
+
const candidate = path.join(parent, entry);
|
|
112
|
+
probed.push(candidate);
|
|
113
|
+
try {
|
|
114
|
+
if (fs.statSync(candidate).isDirectory()) return candidate;
|
|
115
|
+
} catch {}
|
|
116
|
+
}
|
|
117
|
+
return "";
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Relative locations of the CLI executable inside an install directory.
|
|
121
|
+
function windowsExeRelPaths(profile) {
|
|
122
|
+
const exe = profile.executable;
|
|
123
|
+
return [
|
|
124
|
+
`${exe}.exe`,
|
|
125
|
+
exe,
|
|
126
|
+
`${exe}.cmd`,
|
|
127
|
+
path.join("bin", `${exe}.exe`),
|
|
128
|
+
path.join("bin", exe),
|
|
129
|
+
path.join("bin", `${exe}.cmd`),
|
|
130
|
+
];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Probes the known install directories with the filesystem only; PATH entries
|
|
134
|
+
// and install paths are always treated as whole strings, never split on
|
|
135
|
+
// spaces. Returns the executable plus every location that was probed so a
|
|
136
|
+
// miss can be explained to the operator.
|
|
137
|
+
function findInInstallDirs(provider, env = process.env) {
|
|
138
|
+
const profile = QODER_FAMILY_PROFILES[provider];
|
|
139
|
+
const probed = [];
|
|
140
|
+
const roots = windowsInstallRoots(env);
|
|
141
|
+
roots.push(path.join(os.homedir(), profile.defaultConfigRootName));
|
|
142
|
+
for (const root of roots) {
|
|
143
|
+
const dir = matchInstallDir(root, profile.windowsInstallDirNames, probed);
|
|
144
|
+
if (!dir) {
|
|
145
|
+
for (const name of profile.windowsInstallDirNames) probed.push(path.join(root, name));
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
for (const rel of windowsExeRelPaths(profile)) {
|
|
149
|
+
const candidate = path.join(dir, rel);
|
|
150
|
+
probed.push(candidate);
|
|
151
|
+
try {
|
|
152
|
+
fs.accessSync(candidate, fs.constants.F_OK);
|
|
153
|
+
if (!fs.statSync(candidate).isDirectory()) return { found: candidate, probed };
|
|
154
|
+
} catch {}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return { found: "", probed };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Everything a "not installed" report must show: the install candidates, the
|
|
161
|
+
// managed shim and any PATH entry that mentions the CLI family.
|
|
162
|
+
function qoderSearchSummary(provider) {
|
|
163
|
+
const profile = QODER_FAMILY_PROFILES[provider];
|
|
164
|
+
const paths = qoderPaths(profile);
|
|
165
|
+
const probed = [];
|
|
166
|
+
for (const root of windowsInstallRoots(process.env)) {
|
|
167
|
+
for (const name of profile.windowsInstallDirNames) probed.push(path.join(root, name));
|
|
168
|
+
}
|
|
169
|
+
probed.push(path.join(os.homedir(), profile.defaultConfigRootName));
|
|
170
|
+
probed.push(path.join(paths.managedDir, "node_modules", ".bin", profile.windowsShimName));
|
|
171
|
+
for (const rel of profile.unixCandidateRelPaths) probed.push(path.join(os.homedir(), ...rel));
|
|
172
|
+
const needle = "qoder";
|
|
173
|
+
const pathHints = (process.env.PATH || "").split(path.delimiter)
|
|
174
|
+
.filter((entry) => entry && entry.toLowerCase().includes(needle));
|
|
175
|
+
return { probed, pathHints };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function reportQoderNotFound(provider) {
|
|
179
|
+
const profile = QODER_FAMILY_PROFILES[provider];
|
|
180
|
+
const { probed, pathHints } = qoderSearchSummary(provider);
|
|
181
|
+
error(`${profile.displayName} was not found. Probed locations (PATH entries were scanned as whole strings):`);
|
|
182
|
+
probed.forEach((location) => error(` - ${location}`));
|
|
183
|
+
if (pathHints.length) {
|
|
184
|
+
error("PATH entries mentioning qoder:");
|
|
185
|
+
pathHints.forEach((entry) => error(` - ${entry}`));
|
|
186
|
+
} else {
|
|
187
|
+
error(`no PATH entry mentions "${profile.executable}".`);
|
|
188
|
+
}
|
|
70
189
|
}
|
|
71
190
|
|
|
72
191
|
function findQoderExecutable(provider) {
|
|
@@ -85,6 +204,8 @@ function findQoderExecutable(provider) {
|
|
|
85
204
|
const onPath = findOnPath(profile.executable);
|
|
86
205
|
if (onPath) return onPath;
|
|
87
206
|
if (IS_WIN32) {
|
|
207
|
+
const { found } = findInInstallDirs(provider);
|
|
208
|
+
if (found) return found;
|
|
88
209
|
const shim = path.join(paths.managedDir, "node_modules", ".bin", profile.windowsShimName);
|
|
89
210
|
try { fs.accessSync(shim, fs.constants.F_OK); return shim; } catch {}
|
|
90
211
|
return "";
|
|
@@ -137,6 +258,10 @@ function qoderBinaryVersion(executable) {
|
|
|
137
258
|
stdio: ["ignore", "pipe", "pipe"],
|
|
138
259
|
timeout: 20000,
|
|
139
260
|
});
|
|
261
|
+
// "missing" means the file itself is absent; "unknown" means the file is
|
|
262
|
+
// there but produced no version, so the two failure classes never collapse
|
|
263
|
+
// into one misleading answer.
|
|
264
|
+
if (result.error && result.error.code === "ENOENT") return "missing";
|
|
140
265
|
// stdout wins so a warning on stderr cannot masquerade as the version and
|
|
141
266
|
// make the identity comparison see a change that did not happen.
|
|
142
267
|
const firstLine = (stream) => (stream || "").split(/\r?\n/).map((line) => line.trim()).find(Boolean);
|
|
@@ -385,6 +510,9 @@ function reportQoderDiagnostics(profile, info) {
|
|
|
385
510
|
const { executable, version, configRoot, rootSource, verdict, identityChanged, previous } = info;
|
|
386
511
|
error(`--- ${profile.displayName} bootstrap diagnostics ---`);
|
|
387
512
|
error(`executable : ${executable}`);
|
|
513
|
+
if (/\s/.test(executable)) {
|
|
514
|
+
error("note : the executable path contains spaces; it is launched as an argument array / quoted command line");
|
|
515
|
+
}
|
|
388
516
|
error(`version : ${version}`);
|
|
389
517
|
error(`config root : ${configRoot} (${rootSource})`);
|
|
390
518
|
error(`verdict : ${verdict.state} - ${verdict.reason || "no further detail"}`);
|
|
@@ -456,6 +584,7 @@ async function ensureQoderReady(provider, options = {}) {
|
|
|
456
584
|
// reinstall the package that just landed.
|
|
457
585
|
let refreshed = false;
|
|
458
586
|
if (!executable) {
|
|
587
|
+
reportQoderNotFound(provider);
|
|
459
588
|
executable = installQoderCLI(provider);
|
|
460
589
|
refreshed = true;
|
|
461
590
|
}
|
|
@@ -564,13 +693,17 @@ async function ensureQoderReady(provider, options = {}) {
|
|
|
564
693
|
module.exports = {
|
|
565
694
|
QODER_FAMILY_PROFILES,
|
|
566
695
|
ensureQoderReady,
|
|
696
|
+
findInInstallDirs,
|
|
567
697
|
findQoderExecutable,
|
|
568
698
|
inspectQoderHealth,
|
|
569
699
|
isQoderFamilyProvider,
|
|
570
700
|
qoderBinaryVersion,
|
|
571
701
|
qoderPaths,
|
|
702
|
+
qoderSearchSummary,
|
|
703
|
+
quoteWindowsArg,
|
|
572
704
|
readQoderState,
|
|
573
705
|
reportQoderDiagnostics,
|
|
706
|
+
reportQoderNotFound,
|
|
574
707
|
selectQoderConfigRoot,
|
|
575
708
|
spawnQoderSync,
|
|
576
709
|
waitForQoderLogin,
|
package/bin/shared.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
|
-
const { spawnSync } = require("child_process");
|
|
4
3
|
const fs = require("fs");
|
|
5
4
|
const os = require("os");
|
|
6
5
|
const path = require("path");
|
|
@@ -25,11 +24,24 @@ function resolveUserPath(value) {
|
|
|
25
24
|
return path.resolve(value);
|
|
26
25
|
}
|
|
27
26
|
|
|
27
|
+
// Scans PATH entry by entry with the filesystem instead of shelling out to
|
|
28
|
+
// where/which: each entry stays a single string, so directories containing
|
|
29
|
+
// spaces can never be split or misquoted by a shell.
|
|
28
30
|
function findOnPath(name) {
|
|
29
|
-
const
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
const isWin32 = process.platform === "win32";
|
|
32
|
+
const suffixes = isWin32 ? ["", ".exe", ".cmd", ".bat", ".com"] : [""];
|
|
33
|
+
const entries = (process.env.PATH || process.env.Path || "").split(path.delimiter);
|
|
34
|
+
for (const entry of entries) {
|
|
35
|
+
if (!entry) continue;
|
|
36
|
+
for (const suffix of suffixes) {
|
|
37
|
+
const candidate = path.join(entry, name + suffix);
|
|
38
|
+
try {
|
|
39
|
+
fs.accessSync(candidate, isWin32 ? fs.constants.F_OK : fs.constants.X_OK);
|
|
40
|
+
if (!fs.statSync(candidate).isDirectory()) return candidate;
|
|
41
|
+
} catch {}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return "";
|
|
33
45
|
}
|
|
34
46
|
|
|
35
47
|
module.exports = { ensureDir, error, findOnPath, log, resolveUserPath };
|
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* token-proxy.js — Qoder/QoderCN token usage 旁路采集 proxy
|
|
4
|
+
*
|
|
5
|
+
* 单文件,零 npm 依赖,仅用 Node 内置模块。
|
|
6
|
+
* 作为 HTTPS forward proxy 运行,透传所有流量,从 Qoder API 的 SSE 响应中
|
|
7
|
+
* 提取 token usage 数据写入 JSONL 文件。
|
|
8
|
+
*
|
|
9
|
+
* 用法:
|
|
10
|
+
* node token-proxy.js start [--port 19876] 启动 proxy
|
|
11
|
+
* node token-proxy.js query [--since 1h] 查询 token 汇总
|
|
12
|
+
* node token-proxy.js query --json JSON 格式输出
|
|
13
|
+
*
|
|
14
|
+
* 降级策略:
|
|
15
|
+
* - 所有解析逻辑被 try-catch 包裹,解析失败只丢 token 数据,不断流量
|
|
16
|
+
* - proxy 启动失败时,wrapper 不注入 HTTPS_PROXY,daemon 正常直连
|
|
17
|
+
* - proxy 运行中崩溃时,Node HTTPS 模块遇到 ECONNREFUSED 会忽略 proxy 直连
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
'use strict';
|
|
21
|
+
|
|
22
|
+
const http = require('http');
|
|
23
|
+
const net = require('net');
|
|
24
|
+
const tls = require('tls');
|
|
25
|
+
const crypto = require('crypto');
|
|
26
|
+
const fs = require('fs');
|
|
27
|
+
const path = require('path');
|
|
28
|
+
const os = require('os');
|
|
29
|
+
|
|
30
|
+
// ─── Config ──────────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
const DEFAULT_PORT = 19876;
|
|
33
|
+
const MAX_LOG_SIZE = 10 * 1024 * 1024; // 10MB, then rotate
|
|
34
|
+
const DATA_DIR = path.join(os.homedir(), '.autowonder', 'token-tap');
|
|
35
|
+
const CA_KEY_PATH = path.join(DATA_DIR, 'ca-key.pem');
|
|
36
|
+
const CA_CERT_PATH = path.join(DATA_DIR, 'ca-cert.pem');
|
|
37
|
+
const USAGE_LOG_PATH = path.join(DATA_DIR, 'token-usage.jsonl');
|
|
38
|
+
const USAGE_LOG_OLD = path.join(DATA_DIR, 'token-usage.old.jsonl');
|
|
39
|
+
|
|
40
|
+
// Qoder/QoderCN LLM API hosts to sniff (others are tunneled without inspection).
|
|
41
|
+
// Only LLM endpoints are listed — non-LLM APIs (openapi.*) are excluded to avoid
|
|
42
|
+
// unnecessary MITM overhead on high-frequency config/auth calls.
|
|
43
|
+
const SNIFF_HOSTS = new Set([
|
|
44
|
+
// Qoder (international)
|
|
45
|
+
'api3.qoder.sh', 'api2.qoder.sh', 'api.qoder.sh',
|
|
46
|
+
// QoderCN (China)
|
|
47
|
+
'gateway.qoder.com.cn',
|
|
48
|
+
'api3.qodercn.com', 'api2.qodercn.com', 'api.qodercn.com',
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
// ─── CA Certificate (auto-generated, only for this proxy) ────────────────────
|
|
52
|
+
|
|
53
|
+
function ensureCA() {
|
|
54
|
+
fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
55
|
+
if (fs.existsSync(CA_KEY_PATH) && fs.existsSync(CA_CERT_PATH)) {
|
|
56
|
+
return { key: fs.readFileSync(CA_KEY_PATH), cert: fs.readFileSync(CA_CERT_PATH) };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const { privateKey } = crypto.generateKeyPairSync('rsa', {
|
|
60
|
+
modulusLength: 2048,
|
|
61
|
+
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
|
62
|
+
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// Write key first — openssl needs it on disk
|
|
66
|
+
fs.writeFileSync(CA_KEY_PATH, privateKey, { mode: 0o600 });
|
|
67
|
+
|
|
68
|
+
// Generate self-signed CA cert
|
|
69
|
+
const cert = generateCACert(privateKey);
|
|
70
|
+
fs.writeFileSync(CA_CERT_PATH, cert, { mode: 0o644 });
|
|
71
|
+
return { key: privateKey, cert };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function generateCACert(privateKey) {
|
|
75
|
+
const { execFileSync } = require('child_process');
|
|
76
|
+
const subject = '/CN=AutoWonder Token Tap CA';
|
|
77
|
+
|
|
78
|
+
// Try openssl at various paths (macOS/Linux/Windows-Git)
|
|
79
|
+
const candidates = ['openssl', '/usr/bin/openssl', '/opt/homebrew/bin/openssl',
|
|
80
|
+
'C:\\Program Files\\Git\\usr\\bin\\openssl.exe', 'C:\\Program Files\\OpenSSL-Win64\\bin\\openssl.exe'];
|
|
81
|
+
for (const cmd of candidates) {
|
|
82
|
+
try {
|
|
83
|
+
execFileSync(cmd, [
|
|
84
|
+
'req', '-new', '-x509', '-key', CA_KEY_PATH,
|
|
85
|
+
'-out', CA_CERT_PATH, '-days', '3650', '-subj', subject,
|
|
86
|
+
], { stdio: 'pipe' });
|
|
87
|
+
return fs.readFileSync(CA_CERT_PATH, 'utf8');
|
|
88
|
+
} catch (_) { continue; }
|
|
89
|
+
}
|
|
90
|
+
throw new Error('Cannot generate CA certificate: openssl not found. Install openssl or use Node 22+.');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ─── Per-host TLS certificate (signed by our CA) ─────────────────────────────
|
|
94
|
+
|
|
95
|
+
const hostCertCache = new Map();
|
|
96
|
+
|
|
97
|
+
function isCertExpired(certPath) {
|
|
98
|
+
try {
|
|
99
|
+
const stat = fs.statSync(certPath);
|
|
100
|
+
// Cert is valid for 365 days; renew when older than 300 days
|
|
101
|
+
const ageMs = Date.now() - stat.mtimeMs;
|
|
102
|
+
return ageMs > 300 * 86400000;
|
|
103
|
+
} catch (_) { return true; }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function getHostCert(hostname, ca) {
|
|
107
|
+
if (hostCertCache.has(hostname)) return hostCertCache.get(hostname);
|
|
108
|
+
|
|
109
|
+
const certPath = path.join(DATA_DIR, `${hostname}.pem`);
|
|
110
|
+
const keyPath = path.join(DATA_DIR, `${hostname}-key.pem`);
|
|
111
|
+
|
|
112
|
+
// Return cached cert from disk if still valid
|
|
113
|
+
if (fs.existsSync(certPath) && fs.existsSync(keyPath) && !isCertExpired(certPath)) {
|
|
114
|
+
const result = { key: fs.readFileSync(keyPath), cert: fs.readFileSync(certPath) };
|
|
115
|
+
hostCertCache.set(hostname, result);
|
|
116
|
+
return result;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const { privateKey } = crypto.generateKeyPairSync('rsa', {
|
|
120
|
+
modulusLength: 2048,
|
|
121
|
+
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
|
122
|
+
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const { execFileSync } = require('child_process');
|
|
126
|
+
const csrPath = path.join(DATA_DIR, `${hostname}.csr`);
|
|
127
|
+
fs.writeFileSync(keyPath, privateKey, { mode: 0o600 });
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
execFileSync('openssl', ['req', '-new', '-key', keyPath, '-out', csrPath, '-subj', `/CN=${hostname}`], { stdio: 'pipe' });
|
|
131
|
+
execFileSync('openssl', [
|
|
132
|
+
'x509', '-req', '-in', csrPath, '-CA', CA_CERT_PATH, '-CAkey', CA_KEY_PATH,
|
|
133
|
+
'-CAcreateserial', '-out', certPath, '-days', '365',
|
|
134
|
+
'-extfile', '/dev/stdin', '-extensions', 'v3_ext',
|
|
135
|
+
], { input: `[v3_ext]\nsubjectAltName=DNS:${hostname}\n`, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
136
|
+
} catch (_) {
|
|
137
|
+
// Windows fallback: without SAN extension
|
|
138
|
+
try {
|
|
139
|
+
execFileSync('openssl', ['req', '-new', '-key', keyPath, '-out', csrPath, '-subj', `/CN=${hostname}`], { stdio: 'pipe' });
|
|
140
|
+
execFileSync('openssl', [
|
|
141
|
+
'x509', '-req', '-in', csrPath, '-CA', CA_CERT_PATH, '-CAkey', CA_KEY_PATH,
|
|
142
|
+
'-CAcreateserial', '-out', certPath, '-days', '365',
|
|
143
|
+
], { stdio: 'pipe' });
|
|
144
|
+
} catch (e2) {
|
|
145
|
+
// Cannot generate host cert — fall back to blind tunnel (no sniffing)
|
|
146
|
+
hostCertCache.set(hostname, null);
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const result = { key: fs.readFileSync(keyPath), cert: fs.readFileSync(certPath) };
|
|
152
|
+
hostCertCache.set(hostname, result);
|
|
153
|
+
// Cleanup temp files
|
|
154
|
+
try { fs.unlinkSync(csrPath); } catch (_) {}
|
|
155
|
+
try { fs.unlinkSync(path.join(DATA_DIR, 'ca-cert.srl')); } catch (_) {}
|
|
156
|
+
return result;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ─── Usage extraction from Qoder SSE ────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
function extractUsageFromResponse(buffer) {
|
|
162
|
+
// Supports multiple response formats:
|
|
163
|
+
// 1. Qoder SSE (current): data:{"headers":{...},"body":"{...\"usage\":{...}}"}
|
|
164
|
+
// 2. Standard OpenAI SSE: data: {"choices":[...],"usage":{...}}
|
|
165
|
+
// 3. NDJSON streaming: {"choices":[...],"usage":{...}}\n
|
|
166
|
+
// 4. Single JSON response: {"choices":[...],"usage":{...}}
|
|
167
|
+
//
|
|
168
|
+
// Strategy: scan lines backwards for "prompt_tokens", try multiple parse strategies.
|
|
169
|
+
try {
|
|
170
|
+
const text = buffer.toString('utf8');
|
|
171
|
+
const lines = text.split('\n');
|
|
172
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
173
|
+
const line = lines[i].trim();
|
|
174
|
+
if (!line.includes('prompt_tokens')) continue;
|
|
175
|
+
|
|
176
|
+
// Strategy 1: Qoder nested SSE — data:{"headers":...,"body":"<escaped json>"}
|
|
177
|
+
if (line.startsWith('data:')) {
|
|
178
|
+
const usage = tryQoderNestedSSE(line.slice(5));
|
|
179
|
+
if (usage) return usage;
|
|
180
|
+
// Strategy 2: Standard OpenAI SSE — data: {"choices":...,"usage":{...}}
|
|
181
|
+
const usage2 = tryDirectJSON(line.slice(5).trim());
|
|
182
|
+
if (usage2) return usage2;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Strategy 3: Plain JSON line (NDJSON or single response)
|
|
186
|
+
const usage3 = tryDirectJSON(line);
|
|
187
|
+
if (usage3) return usage3;
|
|
188
|
+
}
|
|
189
|
+
} catch (_) {
|
|
190
|
+
// Silent: parsing failure only loses token data, never breaks traffic
|
|
191
|
+
}
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function tryQoderNestedSSE(data) {
|
|
196
|
+
try {
|
|
197
|
+
const outer = JSON.parse(data);
|
|
198
|
+
if (!outer || !outer.body || outer.body === '[DONE]') return null;
|
|
199
|
+
const inner = JSON.parse(outer.body);
|
|
200
|
+
if (inner && inner.usage) return buildUsageRecord(inner.usage, inner.model);
|
|
201
|
+
} catch (_) {}
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function tryDirectJSON(data) {
|
|
206
|
+
try {
|
|
207
|
+
const obj = JSON.parse(data);
|
|
208
|
+
if (obj && obj.usage) return buildUsageRecord(obj.usage, obj.model);
|
|
209
|
+
} catch (_) {}
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function buildUsageRecord(u, model) {
|
|
214
|
+
return {
|
|
215
|
+
ts: new Date().toISOString(),
|
|
216
|
+
input_tokens: u.prompt_tokens || u.input_tokens || 0,
|
|
217
|
+
output_tokens: u.completion_tokens || u.output_tokens || 0,
|
|
218
|
+
cached_tokens: (u.prompt_tokens_details || {}).cached_tokens || u.cache_read_input_tokens || 0,
|
|
219
|
+
reasoning_tokens: (u.completion_tokens_details || {}).reasoning_tokens || 0,
|
|
220
|
+
credits: u.credits || 0,
|
|
221
|
+
model: model || 'unknown',
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function appendUsage(usage) {
|
|
226
|
+
try {
|
|
227
|
+
// Rotate if over max size
|
|
228
|
+
try {
|
|
229
|
+
const stat = fs.statSync(USAGE_LOG_PATH);
|
|
230
|
+
if (stat.size > MAX_LOG_SIZE) {
|
|
231
|
+
try { fs.unlinkSync(USAGE_LOG_OLD); } catch (_) {}
|
|
232
|
+
fs.renameSync(USAGE_LOG_PATH, USAGE_LOG_OLD);
|
|
233
|
+
}
|
|
234
|
+
} catch (_) {} // file doesn't exist yet, fine
|
|
235
|
+
|
|
236
|
+
fs.appendFileSync(USAGE_LOG_PATH, JSON.stringify(usage) + '\n');
|
|
237
|
+
} catch (_) {
|
|
238
|
+
// Silent: write failure only loses token data
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ─── Dispatch ID extraction from Proxy-Authorization ─────────────────────────
|
|
243
|
+
|
|
244
|
+
function extractDispatchId(req) {
|
|
245
|
+
// daemon sets HTTPS_PROXY=http://d-{dispatchId}:x@host:port
|
|
246
|
+
// → client sends Proxy-Authorization: Basic <base64("d-{id}:x")>
|
|
247
|
+
const auth = req.headers['proxy-authorization'] || '';
|
|
248
|
+
if (!auth.startsWith('Basic ')) return null;
|
|
249
|
+
try {
|
|
250
|
+
const decoded = Buffer.from(auth.slice(6), 'base64').toString('utf8');
|
|
251
|
+
const [user] = decoded.split(':');
|
|
252
|
+
if (user && user.startsWith('d-')) return user.slice(2);
|
|
253
|
+
} catch (_) {}
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ─── Forward Proxy (CONNECT method) ─────────────────────────────────────────
|
|
258
|
+
|
|
259
|
+
function startProxy(port) {
|
|
260
|
+
const ca = ensureCA();
|
|
261
|
+
|
|
262
|
+
const server = http.createServer((req, res) => {
|
|
263
|
+
// Non-CONNECT requests: reject (we only handle CONNECT tunnels)
|
|
264
|
+
res.writeHead(405);
|
|
265
|
+
res.end('Only CONNECT supported');
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
server.on('connect', (req, clientSocket, head) => {
|
|
269
|
+
const [hostname, portStr] = req.url.split(':');
|
|
270
|
+
const remotePort = parseInt(portStr) || 443;
|
|
271
|
+
const shouldSniff = SNIFF_HOSTS.has(hostname);
|
|
272
|
+
const dispatchId = extractDispatchId(req);
|
|
273
|
+
|
|
274
|
+
if (!shouldSniff) {
|
|
275
|
+
// Blind tunnel: don't MITM, just pipe through
|
|
276
|
+
const upstream = net.connect(remotePort, hostname, () => {
|
|
277
|
+
clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
|
|
278
|
+
upstream.write(head);
|
|
279
|
+
upstream.pipe(clientSocket);
|
|
280
|
+
clientSocket.pipe(upstream);
|
|
281
|
+
});
|
|
282
|
+
upstream.on('error', () => { try { clientSocket.end(); } catch (_) {} });
|
|
283
|
+
clientSocket.on('error', () => { try { upstream.end(); } catch (_) {} });
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// MITM for Qoder API hosts: decrypt, sniff usage, re-encrypt
|
|
288
|
+
const hostCert = getHostCert(hostname, ca);
|
|
289
|
+
if (!hostCert) {
|
|
290
|
+
// Cannot generate cert — fall back to blind tunnel
|
|
291
|
+
const upstream = net.connect(remotePort, hostname, () => {
|
|
292
|
+
clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
|
|
293
|
+
upstream.write(head);
|
|
294
|
+
upstream.pipe(clientSocket);
|
|
295
|
+
clientSocket.pipe(upstream);
|
|
296
|
+
});
|
|
297
|
+
upstream.on('error', () => { try { clientSocket.end(); } catch (_) {} });
|
|
298
|
+
clientSocket.on('error', () => { try { upstream.end(); } catch (_) {} });
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Accept TLS from client (presenting our per-host cert signed by our CA)
|
|
303
|
+
clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
|
|
304
|
+
|
|
305
|
+
const tlsClient = new tls.TLSSocket(clientSocket, {
|
|
306
|
+
isServer: true,
|
|
307
|
+
key: hostCert.key,
|
|
308
|
+
cert: hostCert.cert,
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
// Connect to real upstream
|
|
312
|
+
const tlsUpstream = tls.connect({ host: hostname, port: remotePort, servername: hostname });
|
|
313
|
+
|
|
314
|
+
let responseBuffer = Buffer.alloc(0);
|
|
315
|
+
let usageExtracted = false;
|
|
316
|
+
|
|
317
|
+
tlsUpstream.on('data', (chunk) => {
|
|
318
|
+
try { tlsClient.write(chunk); } catch (_) {}
|
|
319
|
+
try {
|
|
320
|
+
// Keep last 8KB of response for usage extraction
|
|
321
|
+
responseBuffer = Buffer.concat([responseBuffer.slice(-8192), chunk]);
|
|
322
|
+
// Try to extract usage eagerly (the usage chunk arrives before connection close)
|
|
323
|
+
if (!usageExtracted && chunk.toString('utf8').includes('prompt_tokens')) {
|
|
324
|
+
const usage = extractUsageFromResponse(responseBuffer);
|
|
325
|
+
if (usage) {
|
|
326
|
+
if (dispatchId) usage.dispatch_id = dispatchId;
|
|
327
|
+
appendUsage(usage);
|
|
328
|
+
usageExtracted = true;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
} catch (_) {}
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
tlsClient.on('data', (chunk) => {
|
|
335
|
+
try { tlsUpstream.write(chunk); } catch (_) {}
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
const cleanup = () => {
|
|
339
|
+
// Final attempt to extract usage if not already done
|
|
340
|
+
if (!usageExtracted) {
|
|
341
|
+
try {
|
|
342
|
+
const usage = extractUsageFromResponse(responseBuffer);
|
|
343
|
+
if (usage) {
|
|
344
|
+
if (dispatchId) usage.dispatch_id = dispatchId;
|
|
345
|
+
appendUsage(usage);
|
|
346
|
+
}
|
|
347
|
+
} catch (_) {}
|
|
348
|
+
}
|
|
349
|
+
responseBuffer = Buffer.alloc(0);
|
|
350
|
+
try { tlsClient.end(); } catch (_) {}
|
|
351
|
+
try { tlsUpstream.end(); } catch (_) {}
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
tlsClient.on('end', cleanup);
|
|
355
|
+
tlsClient.on('error', () => { cleanup(); });
|
|
356
|
+
tlsUpstream.on('end', cleanup);
|
|
357
|
+
tlsUpstream.on('error', () => { cleanup(); });
|
|
358
|
+
|
|
359
|
+
if (head.length > 0) tlsClient.emit('data', head);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
server.on('error', (err) => {
|
|
363
|
+
console.error(`[token-proxy] server error: ${err.message}`);
|
|
364
|
+
process.exit(1);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
server.listen(port, '127.0.0.1', () => {
|
|
368
|
+
console.log(`[token-proxy] listening on 127.0.0.1:${port}`);
|
|
369
|
+
console.log(`[token-proxy] CA cert: ${CA_CERT_PATH}`);
|
|
370
|
+
console.log(`[token-proxy] Usage log: ${USAGE_LOG_PATH}`);
|
|
371
|
+
console.log(`[token-proxy] Sniffing hosts: ${[...SNIFF_HOSTS].join(', ')}`);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
// Graceful shutdown
|
|
375
|
+
process.on('SIGINT', () => { server.close(); process.exit(0); });
|
|
376
|
+
process.on('SIGTERM', () => { server.close(); process.exit(0); });
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// ─── Query command ──────────────────────────────────────────────────────────
|
|
380
|
+
|
|
381
|
+
function queryUsage(opts) {
|
|
382
|
+
if (!fs.existsSync(USAGE_LOG_PATH)) {
|
|
383
|
+
console.log('No token usage data found.');
|
|
384
|
+
console.log(`(Expected at: ${USAGE_LOG_PATH})`);
|
|
385
|
+
process.exit(0);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const lines = fs.readFileSync(USAGE_LOG_PATH, 'utf8').trim().split('\n').filter(Boolean);
|
|
389
|
+
let records = lines.map(l => { try { return JSON.parse(l); } catch (_) { return null; } }).filter(Boolean);
|
|
390
|
+
|
|
391
|
+
// Filter by time
|
|
392
|
+
if (opts.since) {
|
|
393
|
+
const ms = parseDuration(opts.since);
|
|
394
|
+
const cutoff = new Date(Date.now() - ms).toISOString();
|
|
395
|
+
records = records.filter(r => r.ts >= cutoff);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if (records.length === 0) {
|
|
399
|
+
console.log('No token usage data in the specified time range.');
|
|
400
|
+
process.exit(0);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if (opts.json) {
|
|
404
|
+
console.log(JSON.stringify(records, null, 2));
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Pretty print
|
|
409
|
+
const total = { input: 0, output: 0, cached: 0, reasoning: 0, credits: 0, calls: 0 };
|
|
410
|
+
console.log(`${'Time'.padEnd(22)} ${'Input'.padStart(10)} ${'Output'.padStart(8)} ${'Cached'.padStart(10)} ${'Credits'.padStart(8)} Model`);
|
|
411
|
+
console.log('-'.repeat(72));
|
|
412
|
+
for (const r of records) {
|
|
413
|
+
total.input += r.input_tokens || 0;
|
|
414
|
+
total.output += r.output_tokens || 0;
|
|
415
|
+
total.cached += r.cached_tokens || 0;
|
|
416
|
+
total.reasoning += r.reasoning_tokens || 0;
|
|
417
|
+
total.credits += r.credits || 0;
|
|
418
|
+
total.calls++;
|
|
419
|
+
const ts = (r.ts || '').slice(0, 19);
|
|
420
|
+
console.log(`${ts.padEnd(22)} ${fmt(r.input_tokens).padStart(10)} ${fmt(r.output_tokens).padStart(8)} ${fmt(r.cached_tokens).padStart(10)} ${(r.credits || 0).toFixed(2).padStart(8)} ${r.model || ''}`);
|
|
421
|
+
}
|
|
422
|
+
console.log('-'.repeat(72));
|
|
423
|
+
console.log(`${'TOTAL (' + total.calls + ' calls)'.padEnd(22)} ${fmt(total.input).padStart(10)} ${fmt(total.output).padStart(8)} ${fmt(total.cached).padStart(10)} ${total.credits.toFixed(2).padStart(8)}`);
|
|
424
|
+
console.log(`\n Non-cached input: ${fmt(total.input - total.cached)} tokens`);
|
|
425
|
+
console.log(` Cache hit rate: ${total.input > 0 ? (total.cached / total.input * 100).toFixed(1) : 0}%`);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function fmt(n) { return (n || 0).toLocaleString(); }
|
|
429
|
+
|
|
430
|
+
function parseDuration(s) {
|
|
431
|
+
const m = s.match(/^(\d+)(h|m|d|s)?$/);
|
|
432
|
+
if (!m) return 3600000; // default 1h
|
|
433
|
+
const val = parseInt(m[1]);
|
|
434
|
+
switch (m[2]) {
|
|
435
|
+
case 'd': return val * 86400000;
|
|
436
|
+
case 'h': return val * 3600000;
|
|
437
|
+
case 'm': return val * 60000;
|
|
438
|
+
case 's': return val * 1000;
|
|
439
|
+
default: return val * 3600000;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// ─── CLI ─────────────────────────────────────────────────────────────────────
|
|
444
|
+
|
|
445
|
+
const args = process.argv.slice(2);
|
|
446
|
+
const command = args[0];
|
|
447
|
+
|
|
448
|
+
if (command === 'start') {
|
|
449
|
+
const portIdx = args.indexOf('--port');
|
|
450
|
+
const port = portIdx >= 0 ? parseInt(args[portIdx + 1]) : DEFAULT_PORT;
|
|
451
|
+
startProxy(port);
|
|
452
|
+
} else if (command === 'query') {
|
|
453
|
+
const jsonFlag = args.includes('--json');
|
|
454
|
+
const sinceIdx = args.indexOf('--since');
|
|
455
|
+
const since = sinceIdx >= 0 ? args[sinceIdx + 1] : '24h';
|
|
456
|
+
queryUsage({ json: jsonFlag, since });
|
|
457
|
+
} else {
|
|
458
|
+
console.log(`Usage:
|
|
459
|
+
node token-proxy.js start [--port ${DEFAULT_PORT}] Start the proxy
|
|
460
|
+
node token-proxy.js query [--since 1h] [--json] Query token usage
|
|
461
|
+
|
|
462
|
+
Environment (set before starting daemon):
|
|
463
|
+
HTTPS_PROXY=http://127.0.0.1:${DEFAULT_PORT}
|
|
464
|
+
NODE_EXTRA_CA_CERTS=${CA_CERT_PATH}
|
|
465
|
+
|
|
466
|
+
Files:
|
|
467
|
+
CA cert: ${CA_CERT_PATH}
|
|
468
|
+
Usage log: ${USAGE_LOG_PATH}`);
|
|
469
|
+
}
|
package/package.json
CHANGED
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|