@autotask/atools-tool 0.1.7 → 0.1.9
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 +4 -1
- package/lib/config-openclaw.mjs +76 -12
- package/lib/install.mjs +75 -5
- package/lib/proxy-server.mjs +453 -8
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -31,6 +31,8 @@ Selection UX:
|
|
|
31
31
|
- TTY terminal: use `↑/↓` and `Enter`
|
|
32
32
|
- Non-TTY: falls back to number input
|
|
33
33
|
|
|
34
|
+
If proxy startup fails during `atools-tool openclaw`, the CLI now prints environment-aware troubleshooting hints (systemd user bus, permissions, port readiness) and does not silently fail.
|
|
35
|
+
|
|
34
36
|
Rollback to codex-compatible defaults:
|
|
35
37
|
|
|
36
38
|
```bash
|
|
@@ -64,7 +66,7 @@ atools-tool-install --help
|
|
|
64
66
|
|
|
65
67
|
`atools-tool-install` service behavior by platform:
|
|
66
68
|
|
|
67
|
-
- Linux: systemd user service
|
|
69
|
+
- Linux: systemd user service (fallback to detached process if `systemctl --user` is unavailable)
|
|
68
70
|
- macOS: launchd user agent (`~/Library/LaunchAgents`)
|
|
69
71
|
- Windows: Task Scheduler user task (ONLOGON + start now)
|
|
70
72
|
|
|
@@ -98,6 +100,7 @@ curl -fsSL https://raw.githubusercontent.com/aak1247/sub2api-openclaw-proxy/main
|
|
|
98
100
|
- patches `~/.openclaw/agents/main/agent/auth-profiles.json` (if API key is available)
|
|
99
101
|
- service install by platform:
|
|
100
102
|
- Linux: creates/enables `~/.config/systemd/user/openclaw-atools-proxy.service`
|
|
103
|
+
- if user bus/systemd is unavailable (for example container/root without login bus), installer auto-falls back to detached proxy process
|
|
101
104
|
- macOS: creates/enables `~/Library/LaunchAgents/openclaw-atools-proxy.plist`
|
|
102
105
|
- Windows: creates/updates scheduled task `openclaw-atools-proxy`
|
|
103
106
|
- restarts `openclaw-gateway.service` only on Linux (unless `--no-restart`)
|
package/lib/config-openclaw.mjs
CHANGED
|
@@ -296,9 +296,22 @@ function isLinuxProxyServiceActive() {
|
|
|
296
296
|
|
|
297
297
|
function startLinuxProxyService() {
|
|
298
298
|
const reload = runSystemctl(['daemon-reload']);
|
|
299
|
-
if (reload.status !== 0)
|
|
299
|
+
if (reload.status !== 0) {
|
|
300
|
+
return {
|
|
301
|
+
ok: false,
|
|
302
|
+
step: 'daemon-reload',
|
|
303
|
+
detail: String(reload.stderr || reload.stdout || '').trim()
|
|
304
|
+
};
|
|
305
|
+
}
|
|
300
306
|
const start = runSystemctl(['enable', '--now', PROXY_SERVICE]);
|
|
301
|
-
|
|
307
|
+
if (start.status !== 0) {
|
|
308
|
+
return {
|
|
309
|
+
ok: false,
|
|
310
|
+
step: 'enable-start',
|
|
311
|
+
detail: String(start.stderr || start.stdout || '').trim()
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
return { ok: true, step: 'enable-start', detail: '' };
|
|
302
315
|
}
|
|
303
316
|
|
|
304
317
|
function startDetachedProxy() {
|
|
@@ -350,6 +363,31 @@ async function isPortOpen(host, port, timeoutMs = 900) {
|
|
|
350
363
|
});
|
|
351
364
|
}
|
|
352
365
|
|
|
366
|
+
function buildProxyTroubleshootingHints({ linuxSystemdError } = {}) {
|
|
367
|
+
const hints = [];
|
|
368
|
+
const err = String(linuxSystemdError || '');
|
|
369
|
+
|
|
370
|
+
if (/Failed to connect to bus|No medium found/i.test(err)) {
|
|
371
|
+
hints.push('当前环境没有可用的 systemd 用户会话(常见于容器、root、无登录会话)。');
|
|
372
|
+
hints.push('建议:直接重新执行 `atools-tool-install`,新版会自动降级到非-systemd进程模式。');
|
|
373
|
+
hints.push('如果是容器环境,请确保进程管理策略允许后台常驻进程。');
|
|
374
|
+
return hints;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (/permission denied|access denied/i.test(err)) {
|
|
378
|
+
hints.push('权限不足,无法通过 systemd --user 启动服务。');
|
|
379
|
+
hints.push('建议:使用普通登录用户执行安装与配置,不要混用 root 与普通用户的 OpenClaw 目录。');
|
|
380
|
+
return hints;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (err) {
|
|
384
|
+
hints.push(`systemd 启动失败:${err}`);
|
|
385
|
+
}
|
|
386
|
+
hints.push('建议:检查 OpenClaw Home 路径与 Node 可执行路径是否存在且可访问。');
|
|
387
|
+
hints.push(`建议:确认端口 ${PROXY_PORT} 未被防火墙或策略阻断。`);
|
|
388
|
+
return hints;
|
|
389
|
+
}
|
|
390
|
+
|
|
353
391
|
async function ensureProxyReady() {
|
|
354
392
|
if (process.platform === 'linux') {
|
|
355
393
|
const service = configureLinuxProxyService();
|
|
@@ -360,13 +398,13 @@ async function ensureProxyReady() {
|
|
|
360
398
|
}
|
|
361
399
|
}
|
|
362
400
|
|
|
363
|
-
if (await isPortOpen(PROXY_HOST, PROXY_PORT)) return true;
|
|
401
|
+
if (await isPortOpen(PROXY_HOST, PROXY_PORT)) return { ok: true, mode: 'already-running' };
|
|
364
402
|
|
|
365
403
|
// Linux retry: service may exist but not running.
|
|
366
404
|
if (process.platform === 'linux') {
|
|
367
405
|
const service = configureLinuxProxyService();
|
|
368
406
|
if (service.exists && isLinuxProxyServiceActive()) {
|
|
369
|
-
if (await isPortOpen(PROXY_HOST, PROXY_PORT)) return true;
|
|
407
|
+
if (await isPortOpen(PROXY_HOST, PROXY_PORT)) return { ok: true, mode: 'already-running' };
|
|
370
408
|
}
|
|
371
409
|
}
|
|
372
410
|
|
|
@@ -388,26 +426,45 @@ async function ensureProxyReady() {
|
|
|
388
426
|
}
|
|
389
427
|
|
|
390
428
|
if (action === '取消并退出') {
|
|
391
|
-
return false;
|
|
429
|
+
return { ok: false, reason: 'user-cancelled', hints: [] };
|
|
392
430
|
}
|
|
393
431
|
|
|
394
432
|
let started = false;
|
|
433
|
+
let linuxSystemdError = '';
|
|
395
434
|
if (process.platform === 'linux') {
|
|
396
|
-
|
|
435
|
+
const systemdStart = startLinuxProxyService();
|
|
436
|
+
started = systemdStart.ok;
|
|
437
|
+
linuxSystemdError = systemdStart.detail || '';
|
|
397
438
|
}
|
|
439
|
+
let mode = 'systemd';
|
|
398
440
|
if (!started) {
|
|
399
441
|
started = startDetachedProxy();
|
|
442
|
+
mode = 'detached';
|
|
400
443
|
}
|
|
401
444
|
|
|
402
|
-
if (!started)
|
|
445
|
+
if (!started) {
|
|
446
|
+
return {
|
|
447
|
+
ok: false,
|
|
448
|
+
reason: 'start-failed',
|
|
449
|
+
hints: buildProxyTroubleshootingHints({ linuxSystemdError })
|
|
450
|
+
};
|
|
451
|
+
}
|
|
403
452
|
|
|
404
453
|
for (let i = 0; i < 5; i += 1) {
|
|
405
454
|
await sleep(500);
|
|
406
455
|
if (await isPortOpen(PROXY_HOST, PROXY_PORT)) {
|
|
407
|
-
return true;
|
|
456
|
+
return { ok: true, mode };
|
|
408
457
|
}
|
|
409
458
|
}
|
|
410
|
-
return
|
|
459
|
+
return {
|
|
460
|
+
ok: false,
|
|
461
|
+
reason: 'port-not-open',
|
|
462
|
+
hints: [
|
|
463
|
+
'代理进程启动后端口仍未就绪。',
|
|
464
|
+
`请检查日志:${PROXY_LOG_FILE}`,
|
|
465
|
+
'如为受限环境(容器/最小系统),请确认允许本地监听 127.0.0.1。'
|
|
466
|
+
]
|
|
467
|
+
};
|
|
411
468
|
}
|
|
412
469
|
|
|
413
470
|
function printRestartHint() {
|
|
@@ -442,11 +499,18 @@ export async function runConfigOpenclaw({ openclawHome } = {}) {
|
|
|
442
499
|
process.stdout.write(`固定代理目标: ${PROXY_UPSTREAM}\n`);
|
|
443
500
|
process.stdout.write(`固定 Provider Base URL: ${PROXY_BASE_URL}\n`);
|
|
444
501
|
|
|
445
|
-
const
|
|
446
|
-
if (!
|
|
447
|
-
process.stdout.write('\n
|
|
502
|
+
const proxyStatus = await ensureProxyReady();
|
|
503
|
+
if (!proxyStatus.ok) {
|
|
504
|
+
process.stdout.write('\n本地代理未就绪,未修改 OpenClaw 模型配置。\n');
|
|
505
|
+
if (Array.isArray(proxyStatus.hints) && proxyStatus.hints.length > 0) {
|
|
506
|
+
process.stdout.write('可行解决方案:\n');
|
|
507
|
+
proxyStatus.hints.forEach((hint) => process.stdout.write(`- ${hint}\n`));
|
|
508
|
+
}
|
|
448
509
|
return;
|
|
449
510
|
}
|
|
511
|
+
if (proxyStatus.mode === 'detached') {
|
|
512
|
+
process.stdout.write('\n已通过非-systemd模式启动代理(兼容无 user bus 环境)。\n');
|
|
513
|
+
}
|
|
450
514
|
rl = readlinePromises.createInterface({
|
|
451
515
|
input: process.stdin,
|
|
452
516
|
output: process.stdout
|
package/lib/install.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import os from 'node:os';
|
|
4
|
-
import { execFileSync } from 'node:child_process';
|
|
4
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
6
|
|
|
7
7
|
const DEFAULT_PROVIDER = 'sub2api';
|
|
@@ -350,7 +350,56 @@ function runSystemctl(args, { dryRun } = {}) {
|
|
|
350
350
|
execFileSync('systemctl', ['--user', ...args], { stdio: 'inherit' });
|
|
351
351
|
}
|
|
352
352
|
|
|
353
|
-
function
|
|
353
|
+
function printSystemdFallbackHints(detail) {
|
|
354
|
+
const text = String(detail || '');
|
|
355
|
+
if (/Failed to connect to bus|No medium found/i.test(text)) {
|
|
356
|
+
info('detected: no systemd user bus in current environment');
|
|
357
|
+
info('hint: this is common in containers/root/non-login sessions');
|
|
358
|
+
info('hint: installer will continue with detached process mode');
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
if (/permission denied|access denied/i.test(text)) {
|
|
362
|
+
info('detected: permission issue for systemd --user');
|
|
363
|
+
info('hint: run install under the same normal user as OpenClaw');
|
|
364
|
+
info('hint: installer will continue with detached process mode');
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
info('detected: systemd --user is not usable in current environment');
|
|
368
|
+
info('hint: installer will continue with detached process mode');
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function startDetachedProxyProcess({ nodeBin, cliPath, serveArgs, dryRun }) {
|
|
372
|
+
if (dryRun) {
|
|
373
|
+
info(`dry-run: detached start ${[nodeBin, cliPath, ...serveArgs].join(' ')}`);
|
|
374
|
+
return true;
|
|
375
|
+
}
|
|
376
|
+
try {
|
|
377
|
+
const child = spawn(nodeBin, [cliPath, ...serveArgs], {
|
|
378
|
+
detached: true,
|
|
379
|
+
stdio: 'ignore',
|
|
380
|
+
cwd: os.homedir(),
|
|
381
|
+
env: {
|
|
382
|
+
...process.env,
|
|
383
|
+
HOME: process.env.HOME || os.homedir(),
|
|
384
|
+
TMPDIR: process.env.TMPDIR || '/tmp',
|
|
385
|
+
SUB2API_COMPAT_USER_AGENT: 'codex_cli_rs/0.101.0 (Ubuntu 24.4.0; x86_64) WindowsTerminal'
|
|
386
|
+
}
|
|
387
|
+
});
|
|
388
|
+
child.unref();
|
|
389
|
+
return true;
|
|
390
|
+
} catch {
|
|
391
|
+
return false;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function installLinuxService({
|
|
396
|
+
serviceName,
|
|
397
|
+
content,
|
|
398
|
+
nodeBin,
|
|
399
|
+
cliPath,
|
|
400
|
+
serveArgs,
|
|
401
|
+
dryRun
|
|
402
|
+
}) {
|
|
354
403
|
const serviceDir = path.join(os.homedir(), '.config/systemd/user');
|
|
355
404
|
const servicePath = path.join(serviceDir, serviceName);
|
|
356
405
|
ensureDir(serviceDir);
|
|
@@ -366,8 +415,22 @@ function installLinuxService({ serviceName, content, dryRun }) {
|
|
|
366
415
|
info(`wrote: ${servicePath}`);
|
|
367
416
|
}
|
|
368
417
|
|
|
369
|
-
|
|
370
|
-
|
|
418
|
+
try {
|
|
419
|
+
runSystemctl(['daemon-reload'], { dryRun });
|
|
420
|
+
runSystemctl(['enable', '--now', serviceName], { dryRun });
|
|
421
|
+
info(`service started with systemd --user: ${serviceName}`);
|
|
422
|
+
return;
|
|
423
|
+
} catch (err) {
|
|
424
|
+
const detail = String(err?.message || err);
|
|
425
|
+
info(`systemd --user unavailable, fallback to detached process (${detail})`);
|
|
426
|
+
printSystemdFallbackHints(detail);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const started = startDetachedProxyProcess({ nodeBin, cliPath, serveArgs, dryRun });
|
|
430
|
+
if (!started) {
|
|
431
|
+
throw new Error(`failed to start proxy with both systemd --user and detached mode`);
|
|
432
|
+
}
|
|
433
|
+
info('proxy started in detached mode (non-systemd fallback)');
|
|
371
434
|
}
|
|
372
435
|
|
|
373
436
|
function runLaunchctl(args, { dryRun, allowFailure = false } = {}) {
|
|
@@ -444,7 +507,14 @@ function installService({
|
|
|
444
507
|
dryRun
|
|
445
508
|
}) {
|
|
446
509
|
if (process.platform === 'linux') {
|
|
447
|
-
installLinuxService({
|
|
510
|
+
installLinuxService({
|
|
511
|
+
serviceName,
|
|
512
|
+
content: linuxServiceContent,
|
|
513
|
+
nodeBin,
|
|
514
|
+
cliPath,
|
|
515
|
+
serveArgs,
|
|
516
|
+
dryRun
|
|
517
|
+
});
|
|
448
518
|
return;
|
|
449
519
|
}
|
|
450
520
|
if (process.platform === 'darwin') {
|
package/lib/proxy-server.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import http from 'node:http';
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import crypto from 'node:crypto';
|
|
4
5
|
|
|
5
6
|
const DEFAULT_PORT = 18888;
|
|
6
7
|
const DEFAULT_UPSTREAM = 'https://sub2api.atools.live';
|
|
@@ -10,6 +11,9 @@ const DEFAULT_MAX_REQ_BYTES = 68000;
|
|
|
10
11
|
const DEFAULT_DROP_TOOLS_ON_COMPACT = false;
|
|
11
12
|
const DEFAULT_STRIP_PREVIOUS_RESPONSE_ID = false;
|
|
12
13
|
const DEFAULT_FORCE_MODEL = '';
|
|
14
|
+
const DEFAULT_DEBUG_DUMP = false;
|
|
15
|
+
const DEFAULT_DEBUG_MAX_BODY = 800000;
|
|
16
|
+
const DEFAULT_DEBUG_INCLUDE_AUTH = false;
|
|
13
17
|
const SUPPORTED_MODEL_IDS = new Set(['gpt-5.2', 'gpt-5.3-codex', 'gpt-5.4']);
|
|
14
18
|
const FORWARD_HEADER_ALLOWLIST = new Set([
|
|
15
19
|
'authorization',
|
|
@@ -111,6 +115,15 @@ function normalizeResponsesPayload(payload, {
|
|
|
111
115
|
});
|
|
112
116
|
}
|
|
113
117
|
|
|
118
|
+
const hasFunctionCallOutput = extractFunctionCallOutputIds(out).length > 0;
|
|
119
|
+
if ((Array.isArray(out.tools) && out.tools.length > 0) || hasFunctionCallOutput) {
|
|
120
|
+
out.store = true;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (Array.isArray(out.tools) && out.tools.length > 0 && (out.tool_choice == null || out.tool_choice === '')) {
|
|
124
|
+
out.tool_choice = 'auto';
|
|
125
|
+
}
|
|
126
|
+
|
|
114
127
|
return pruneResponsesPayload(out);
|
|
115
128
|
}
|
|
116
129
|
|
|
@@ -200,6 +213,236 @@ function sleep(ms) {
|
|
|
200
213
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
201
214
|
}
|
|
202
215
|
|
|
216
|
+
function parseBool(value, fallback = false) {
|
|
217
|
+
if (typeof value === 'boolean') return value;
|
|
218
|
+
if (value == null) return fallback;
|
|
219
|
+
const normalized = String(value).trim().toLowerCase();
|
|
220
|
+
if (!normalized) return fallback;
|
|
221
|
+
return ['1', 'true', 'yes', 'on'].includes(normalized);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function maskSecret(value) {
|
|
225
|
+
const text = String(value ?? '');
|
|
226
|
+
if (text.length <= 10) return '***';
|
|
227
|
+
return `${text.slice(0, 6)}...${text.slice(-4)}`;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function toHeaderObject(headers, { includeAuth = DEFAULT_DEBUG_INCLUDE_AUTH } = {}) {
|
|
231
|
+
const out = {};
|
|
232
|
+
if (!headers) return out;
|
|
233
|
+
|
|
234
|
+
if (typeof headers.forEach === 'function') {
|
|
235
|
+
headers.forEach((value, key) => {
|
|
236
|
+
const low = String(key || '').toLowerCase();
|
|
237
|
+
if (!includeAuth && (low === 'authorization' || low === 'api-key' || low === 'x-api-key')) {
|
|
238
|
+
out[key] = maskSecret(value);
|
|
239
|
+
} else {
|
|
240
|
+
out[key] = String(value);
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
return out;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
for (const [key, rawValue] of Object.entries(headers)) {
|
|
247
|
+
const value = normalizeHeaderValue(rawValue);
|
|
248
|
+
const low = String(key || '').toLowerCase();
|
|
249
|
+
if (!includeAuth && (low === 'authorization' || low === 'api-key' || low === 'x-api-key')) {
|
|
250
|
+
out[key] = maskSecret(value);
|
|
251
|
+
} else {
|
|
252
|
+
out[key] = value;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return out;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function bufferToDebugText(buf, maxChars = DEFAULT_DEBUG_MAX_BODY) {
|
|
259
|
+
const text = Buffer.isBuffer(buf) ? buf.toString('utf8') : String(buf ?? '');
|
|
260
|
+
if (!Number.isFinite(maxChars) || maxChars <= 0) return text;
|
|
261
|
+
if (text.length <= maxChars) return text;
|
|
262
|
+
return `${text.slice(0, maxChars)}\n...[truncated ${text.length - maxChars} chars]`;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function hashKey(text) {
|
|
266
|
+
return crypto.createHash('sha1').update(String(text)).digest('hex').slice(0, 16);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function extractAuthIdentity(headers = {}) {
|
|
270
|
+
const auth = normalizeHeaderValue(headers.authorization || headers.Authorization || '');
|
|
271
|
+
if (auth) return auth;
|
|
272
|
+
const apiKey = normalizeHeaderValue(headers['x-api-key'] || headers['api-key'] || '');
|
|
273
|
+
return apiKey;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function buildConversationKey(headers = {}, payload = {}) {
|
|
277
|
+
const authIdentity = extractAuthIdentity(headers);
|
|
278
|
+
if (!authIdentity) return '';
|
|
279
|
+
const modelRaw = typeof payload?.model === 'string' ? payload.model : '*';
|
|
280
|
+
const model = normalizeModelId(modelRaw) || '*';
|
|
281
|
+
return hashKey(`${authIdentity}|${model}`);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function buildPromptContextKey(headers = {}, payload = {}) {
|
|
285
|
+
const authIdentity = extractAuthIdentity(headers);
|
|
286
|
+
if (!authIdentity) return '';
|
|
287
|
+
const promptCacheKey = typeof payload?.prompt_cache_key === 'string'
|
|
288
|
+
? payload.prompt_cache_key.trim()
|
|
289
|
+
: '';
|
|
290
|
+
if (!promptCacheKey) return '';
|
|
291
|
+
const modelRaw = typeof payload?.model === 'string' ? payload.model : '*';
|
|
292
|
+
const model = normalizeModelId(modelRaw) || '*';
|
|
293
|
+
return hashKey(`${authIdentity}|${model}|${promptCacheKey}`);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function extractFunctionCallOutputIds(payload = {}) {
|
|
297
|
+
const ids = [];
|
|
298
|
+
const input = Array.isArray(payload.input) ? payload.input : [];
|
|
299
|
+
for (const item of input) {
|
|
300
|
+
if (!item || typeof item !== 'object') continue;
|
|
301
|
+
|
|
302
|
+
if (item.type === 'function_call_output' && typeof item.call_id === 'string' && item.call_id) {
|
|
303
|
+
ids.push(item.call_id);
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (Array.isArray(item.content)) {
|
|
308
|
+
for (const part of item.content) {
|
|
309
|
+
if (!part || typeof part !== 'object') continue;
|
|
310
|
+
if (part.type === 'function_call_output' && typeof part.call_id === 'string' && part.call_id) {
|
|
311
|
+
ids.push(part.call_id);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return ids;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function extractItemReferenceIds(payload = {}) {
|
|
320
|
+
const ids = [];
|
|
321
|
+
const input = Array.isArray(payload.input) ? payload.input : [];
|
|
322
|
+
for (const item of input) {
|
|
323
|
+
if (!item || typeof item !== 'object') continue;
|
|
324
|
+
if (item.type === 'item_reference' && typeof item.id === 'string' && item.id) {
|
|
325
|
+
ids.push(item.id);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return ids;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function extractResponseId(contentType, buffer) {
|
|
332
|
+
const body = bufferToDebugText(buffer, 3_000_000);
|
|
333
|
+
const ct = String(contentType || '').toLowerCase();
|
|
334
|
+
|
|
335
|
+
if (ct.includes('application/json')) {
|
|
336
|
+
try {
|
|
337
|
+
const parsed = JSON.parse(body);
|
|
338
|
+
if (parsed && typeof parsed.id === 'string' && parsed.id) return parsed.id;
|
|
339
|
+
if (parsed?.response && typeof parsed.response.id === 'string' && parsed.response.id) return parsed.response.id;
|
|
340
|
+
} catch {
|
|
341
|
+
return '';
|
|
342
|
+
}
|
|
343
|
+
return '';
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (ct.includes('text/event-stream')) {
|
|
347
|
+
const chunks = body.split('\n\n');
|
|
348
|
+
for (const chunk of chunks) {
|
|
349
|
+
const dataLines = chunk.split('\n').filter((line) => line.startsWith('data: '));
|
|
350
|
+
if (!dataLines.length) continue;
|
|
351
|
+
const dataText = dataLines.map((line) => line.slice(6)).join('\n');
|
|
352
|
+
if (!dataText || dataText === '[DONE]') continue;
|
|
353
|
+
try {
|
|
354
|
+
const parsed = JSON.parse(dataText);
|
|
355
|
+
if (parsed?.type === 'response.created' && typeof parsed?.response?.id === 'string') {
|
|
356
|
+
return parsed.response.id;
|
|
357
|
+
}
|
|
358
|
+
if (parsed?.type === 'response.completed' && typeof parsed?.response?.id === 'string') {
|
|
359
|
+
return parsed.response.id;
|
|
360
|
+
}
|
|
361
|
+
} catch {
|
|
362
|
+
// ignore malformed SSE chunk
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return '';
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function setCachedResponseId(cache, key, responseId) {
|
|
371
|
+
if (!key || !responseId) return;
|
|
372
|
+
cache.set(key, responseId);
|
|
373
|
+
if (cache.size > 300) {
|
|
374
|
+
const first = cache.keys().next();
|
|
375
|
+
if (!first.done) cache.delete(first.value);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function setCachedCallIdContext(cache, callId, responseId) {
|
|
380
|
+
if (!callId || !responseId) return;
|
|
381
|
+
cache.set(callId, responseId);
|
|
382
|
+
if (cache.size > 1200) {
|
|
383
|
+
const first = cache.keys().next();
|
|
384
|
+
if (!first.done) cache.delete(first.value);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function extractFunctionCallIdsFromResponse(contentType, buffer) {
|
|
389
|
+
const ids = [];
|
|
390
|
+
const body = bufferToDebugText(buffer, 3_000_000);
|
|
391
|
+
const ct = String(contentType || '').toLowerCase();
|
|
392
|
+
|
|
393
|
+
if (ct.includes('application/json')) {
|
|
394
|
+
try {
|
|
395
|
+
const parsed = JSON.parse(body);
|
|
396
|
+
const output = Array.isArray(parsed?.output) ? parsed.output : [];
|
|
397
|
+
for (const item of output) {
|
|
398
|
+
if (item?.type === 'function_call' && typeof item.call_id === 'string' && item.call_id) {
|
|
399
|
+
ids.push(item.call_id);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
} catch {
|
|
403
|
+
return ids;
|
|
404
|
+
}
|
|
405
|
+
return ids;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if (ct.includes('text/event-stream')) {
|
|
409
|
+
const chunks = body.split('\n\n');
|
|
410
|
+
for (const chunk of chunks) {
|
|
411
|
+
const dataLines = chunk.split('\n').filter((line) => line.startsWith('data: '));
|
|
412
|
+
if (!dataLines.length) continue;
|
|
413
|
+
const dataText = dataLines.map((line) => line.slice(6)).join('\n');
|
|
414
|
+
if (!dataText || dataText === '[DONE]') continue;
|
|
415
|
+
try {
|
|
416
|
+
const parsed = JSON.parse(dataText);
|
|
417
|
+
if (parsed?.type === 'response.output_item.added') {
|
|
418
|
+
const item = parsed?.item;
|
|
419
|
+
if (item?.type === 'function_call' && typeof item.call_id === 'string' && item.call_id) {
|
|
420
|
+
ids.push(item.call_id);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
} catch {
|
|
424
|
+
// ignore malformed SSE chunk
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
return ids;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function parseSseEventDataText(eventBlock = '') {
|
|
433
|
+
const dataLines = String(eventBlock)
|
|
434
|
+
.split('\n')
|
|
435
|
+
.filter((line) => line.startsWith('data: '));
|
|
436
|
+
if (!dataLines.length) return null;
|
|
437
|
+
const dataText = dataLines.map((line) => line.slice(6)).join('\n');
|
|
438
|
+
if (!dataText || dataText === '[DONE]') return null;
|
|
439
|
+
try {
|
|
440
|
+
return JSON.parse(dataText);
|
|
441
|
+
} catch {
|
|
442
|
+
return null;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
203
446
|
async function forward(url, req, headers, body) {
|
|
204
447
|
return fetch(url, {
|
|
205
448
|
method: req.method,
|
|
@@ -252,6 +495,15 @@ export async function createProxyServer(options = {}) {
|
|
|
252
495
|
const stripPreviousResponseId = options.stripPreviousResponseId
|
|
253
496
|
?? ['1', 'true', 'yes', 'on'].includes(String(process.env.SUB2API_COMPAT_STRIP_PREVIOUS_RESPONSE_ID || '').toLowerCase());
|
|
254
497
|
const forceModel = String(options.forceModel ?? process.env.SUB2API_COMPAT_FORCE_MODEL ?? '').trim();
|
|
498
|
+
const debugDump = parseBool(options.debugDump ?? process.env.SUB2API_COMPAT_DEBUG_DUMP, DEFAULT_DEBUG_DUMP);
|
|
499
|
+
const debugMaxBody = Number(options.debugMaxBody ?? process.env.SUB2API_COMPAT_DEBUG_MAX_BODY ?? DEFAULT_DEBUG_MAX_BODY);
|
|
500
|
+
const debugIncludeAuth = parseBool(
|
|
501
|
+
options.debugIncludeAuth ?? process.env.SUB2API_COMPAT_DEBUG_INCLUDE_AUTH,
|
|
502
|
+
DEFAULT_DEBUG_INCLUDE_AUTH
|
|
503
|
+
);
|
|
504
|
+
const responseContextCache = new Map();
|
|
505
|
+
const promptContextCache = new Map();
|
|
506
|
+
const callIdContextCache = new Map();
|
|
255
507
|
|
|
256
508
|
const server = http.createServer(async (req, res) => {
|
|
257
509
|
const startAt = Date.now();
|
|
@@ -262,21 +514,83 @@ export async function createProxyServer(options = {}) {
|
|
|
262
514
|
const rawBody = Buffer.concat(chunks);
|
|
263
515
|
|
|
264
516
|
const baseHeaders = buildCodexLikeHeaders(req.headers, userAgent);
|
|
517
|
+
const incomingHeaders = toHeaderObject(req.headers, { includeAuth: debugIncludeAuth });
|
|
265
518
|
|
|
266
519
|
const isResponsesPath = req.method === 'POST' && url.pathname.endsWith('/responses');
|
|
267
520
|
let body = rawBody;
|
|
268
521
|
let compactBody = null;
|
|
269
522
|
let aggressiveBody = null;
|
|
270
523
|
let minimalBody = null;
|
|
524
|
+
let requestedStream = false;
|
|
525
|
+
let rawBodyText = '';
|
|
526
|
+
let normalizedBodyText = '';
|
|
527
|
+
let conversationKey = '';
|
|
528
|
+
let promptContextKey = '';
|
|
529
|
+
let functionCallOutputIds = [];
|
|
271
530
|
|
|
272
531
|
if (isResponsesPath && rawBody.length > 0) {
|
|
273
532
|
try {
|
|
274
533
|
const parsed = JSON.parse(rawBody.toString('utf8'));
|
|
534
|
+
rawBodyText = JSON.stringify(parsed);
|
|
275
535
|
const inputModel = typeof parsed.model === 'string' ? parsed.model : '';
|
|
276
536
|
const normalized = normalizeResponsesPayload(parsed, {
|
|
277
537
|
stripPreviousResponseId,
|
|
278
538
|
forceModel
|
|
279
539
|
});
|
|
540
|
+
requestedStream = normalized?.stream === true;
|
|
541
|
+
conversationKey = buildConversationKey(req.headers, normalized);
|
|
542
|
+
promptContextKey = buildPromptContextKey(req.headers, normalized);
|
|
543
|
+
functionCallOutputIds = extractFunctionCallOutputIds(normalized);
|
|
544
|
+
if (functionCallOutputIds.length > 0 && !normalized.previous_response_id) {
|
|
545
|
+
let cachedResponseId = '';
|
|
546
|
+
let source = '';
|
|
547
|
+
const matched = new Set();
|
|
548
|
+
for (const callId of functionCallOutputIds) {
|
|
549
|
+
const responseId = callIdContextCache.get(callId);
|
|
550
|
+
if (responseId) matched.add(responseId);
|
|
551
|
+
}
|
|
552
|
+
if (matched.size === 1) {
|
|
553
|
+
cachedResponseId = [...matched][0];
|
|
554
|
+
source = 'call_id';
|
|
555
|
+
} else if (promptContextKey) {
|
|
556
|
+
cachedResponseId = promptContextCache.get(promptContextKey) || '';
|
|
557
|
+
if (cachedResponseId) source = 'prompt_cache_key';
|
|
558
|
+
}
|
|
559
|
+
if (!cachedResponseId && conversationKey) {
|
|
560
|
+
cachedResponseId = responseContextCache.get(conversationKey) || '';
|
|
561
|
+
if (cachedResponseId) source = 'conversation';
|
|
562
|
+
}
|
|
563
|
+
if (cachedResponseId) {
|
|
564
|
+
normalized.previous_response_id = cachedResponseId;
|
|
565
|
+
normalized.store = true;
|
|
566
|
+
appendLog(logFile, {
|
|
567
|
+
method: req.method,
|
|
568
|
+
path: url.pathname,
|
|
569
|
+
contextRepair: {
|
|
570
|
+
source,
|
|
571
|
+
previous_response_id: cachedResponseId,
|
|
572
|
+
function_call_output_count: functionCallOutputIds.length
|
|
573
|
+
}
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
if (!cachedResponseId && Array.isArray(normalized.input)) {
|
|
577
|
+
const existingRefs = new Set(extractItemReferenceIds(normalized));
|
|
578
|
+
const missingCallIds = [...new Set(functionCallOutputIds)].filter((id) => !existingRefs.has(id));
|
|
579
|
+
if (missingCallIds.length > 0) {
|
|
580
|
+
const refs = missingCallIds.map((id) => ({ type: 'item_reference', id }));
|
|
581
|
+
normalized.input = [...refs, ...normalized.input];
|
|
582
|
+
appendLog(logFile, {
|
|
583
|
+
method: req.method,
|
|
584
|
+
path: url.pathname,
|
|
585
|
+
contextRepair: {
|
|
586
|
+
item_reference_injected: missingCallIds.length
|
|
587
|
+
}
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
requestedStream = normalized?.stream === true;
|
|
593
|
+
normalizedBodyText = JSON.stringify(normalized);
|
|
280
594
|
const outputModel = typeof normalized.model === 'string' ? normalized.model : '';
|
|
281
595
|
body = Buffer.from(JSON.stringify(normalized));
|
|
282
596
|
if (body.length > maxReqBytes) {
|
|
@@ -310,8 +624,11 @@ export async function createProxyServer(options = {}) {
|
|
|
310
624
|
});
|
|
311
625
|
}
|
|
312
626
|
} catch {
|
|
627
|
+
rawBodyText = bufferToDebugText(rawBody, debugMaxBody);
|
|
313
628
|
// Keep original body if parse fails.
|
|
314
629
|
}
|
|
630
|
+
} else if (rawBody.length > 0) {
|
|
631
|
+
rawBodyText = bufferToDebugText(rawBody, debugMaxBody);
|
|
315
632
|
}
|
|
316
633
|
|
|
317
634
|
const requestBodies = [body];
|
|
@@ -330,11 +647,17 @@ export async function createProxyServer(options = {}) {
|
|
|
330
647
|
requestBodies.push(minimalBody);
|
|
331
648
|
}
|
|
332
649
|
|
|
333
|
-
let resp;
|
|
334
650
|
let attempts = 0;
|
|
335
651
|
let primaryStatus = null;
|
|
336
652
|
let compacted = false;
|
|
337
653
|
let strategy = 'full';
|
|
654
|
+
let finalStatus = 502;
|
|
655
|
+
let finalHeaders = {};
|
|
656
|
+
let outBuffer = Buffer.alloc(0);
|
|
657
|
+
let streamResp = null;
|
|
658
|
+
let streamObservedResponseId = '';
|
|
659
|
+
const streamObservedCallIds = new Set();
|
|
660
|
+
const debugAttempts = [];
|
|
338
661
|
const maxAttempts = isResponsesPath ? retryMax : 1;
|
|
339
662
|
while (attempts < maxAttempts) {
|
|
340
663
|
attempts += 1;
|
|
@@ -347,8 +670,42 @@ export async function createProxyServer(options = {}) {
|
|
|
347
670
|
const primaryHeaders = isResponsesPath ? buildHeaders(baseHeaders, requestBody.length) : baseHeaders;
|
|
348
671
|
|
|
349
672
|
const primaryResp = await forward(url, req, primaryHeaders, requestBody);
|
|
673
|
+
const attemptHeaders = toHeaderObject(primaryResp.headers, { includeAuth: debugIncludeAuth });
|
|
350
674
|
primaryStatus = primaryResp.status;
|
|
351
|
-
|
|
675
|
+
finalStatus = primaryResp.status;
|
|
676
|
+
finalHeaders = attemptHeaders;
|
|
677
|
+
|
|
678
|
+
if (requestedStream && primaryResp.status < 500) {
|
|
679
|
+
streamResp = primaryResp;
|
|
680
|
+
if (debugDump) {
|
|
681
|
+
debugAttempts.push({
|
|
682
|
+
attempt: attempts,
|
|
683
|
+
strategy,
|
|
684
|
+
requestHeaders: toHeaderObject(primaryHeaders, { includeAuth: debugIncludeAuth }),
|
|
685
|
+
requestBody: bufferToDebugText(requestBody, debugMaxBody),
|
|
686
|
+
responseStatus: primaryResp.status,
|
|
687
|
+
responseHeaders: attemptHeaders,
|
|
688
|
+
responseBody: '[stream passthrough]'
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
break;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
const attemptBuffer = Buffer.from(await primaryResp.arrayBuffer());
|
|
695
|
+
outBuffer = attemptBuffer;
|
|
696
|
+
|
|
697
|
+
if (debugDump) {
|
|
698
|
+
debugAttempts.push({
|
|
699
|
+
attempt: attempts,
|
|
700
|
+
strategy,
|
|
701
|
+
requestHeaders: toHeaderObject(primaryHeaders, { includeAuth: debugIncludeAuth }),
|
|
702
|
+
requestBody: bufferToDebugText(requestBody, debugMaxBody),
|
|
703
|
+
responseStatus: primaryResp.status,
|
|
704
|
+
responseHeaders: attemptHeaders,
|
|
705
|
+
responseBody: bufferToDebugText(attemptBuffer, debugMaxBody)
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
|
|
352
709
|
if (primaryResp.status < 500) break;
|
|
353
710
|
|
|
354
711
|
if (attempts < maxAttempts) {
|
|
@@ -356,20 +713,91 @@ export async function createProxyServer(options = {}) {
|
|
|
356
713
|
}
|
|
357
714
|
}
|
|
358
715
|
|
|
359
|
-
res.statusCode =
|
|
360
|
-
|
|
716
|
+
res.statusCode = finalStatus;
|
|
717
|
+
Object.entries(finalHeaders).forEach(([key, value]) => {
|
|
361
718
|
if (key.toLowerCase() === 'transfer-encoding') return;
|
|
362
719
|
res.setHeader(key, value);
|
|
363
720
|
});
|
|
364
721
|
|
|
365
|
-
|
|
366
|
-
|
|
722
|
+
if (streamResp?.body) {
|
|
723
|
+
const streamed = [];
|
|
724
|
+
let sseCarry = '';
|
|
725
|
+
for await (const chunk of streamResp.body) {
|
|
726
|
+
const buf = Buffer.from(chunk);
|
|
727
|
+
streamed.push(buf);
|
|
728
|
+
if (isResponsesPath) {
|
|
729
|
+
sseCarry += buf.toString('utf8').replace(/\r\n/g, '\n');
|
|
730
|
+
let splitIndex = sseCarry.indexOf('\n\n');
|
|
731
|
+
while (splitIndex >= 0) {
|
|
732
|
+
const eventBlock = sseCarry.slice(0, splitIndex);
|
|
733
|
+
sseCarry = sseCarry.slice(splitIndex + 2);
|
|
734
|
+
const parsedEvent = parseSseEventDataText(eventBlock);
|
|
735
|
+
if (parsedEvent) {
|
|
736
|
+
const eventResponseId = typeof parsedEvent?.response?.id === 'string'
|
|
737
|
+
? parsedEvent.response.id
|
|
738
|
+
: '';
|
|
739
|
+
if (!streamObservedResponseId && eventResponseId) {
|
|
740
|
+
streamObservedResponseId = eventResponseId;
|
|
741
|
+
setCachedResponseId(responseContextCache, conversationKey, streamObservedResponseId);
|
|
742
|
+
setCachedResponseId(promptContextCache, promptContextKey, streamObservedResponseId);
|
|
743
|
+
}
|
|
744
|
+
if (
|
|
745
|
+
parsedEvent?.type === 'response.output_item.added'
|
|
746
|
+
|| parsedEvent?.type === 'response.output_item.done'
|
|
747
|
+
) {
|
|
748
|
+
const item = parsedEvent?.item;
|
|
749
|
+
const callId = (item?.type === 'function_call' && typeof item?.call_id === 'string')
|
|
750
|
+
? item.call_id
|
|
751
|
+
: '';
|
|
752
|
+
if (callId) {
|
|
753
|
+
streamObservedCallIds.add(callId);
|
|
754
|
+
if (streamObservedResponseId) {
|
|
755
|
+
setCachedCallIdContext(callIdContextCache, callId, streamObservedResponseId);
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
splitIndex = sseCarry.indexOf('\n\n');
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
res.write(buf);
|
|
764
|
+
}
|
|
765
|
+
outBuffer = Buffer.concat(streamed);
|
|
766
|
+
res.end();
|
|
767
|
+
} else {
|
|
768
|
+
res.end(outBuffer);
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
const upstreamError = finalStatus >= 500
|
|
367
772
|
? outBuffer.toString('utf8').replace(/\s+/g, ' ').slice(0, 240)
|
|
368
773
|
: '';
|
|
774
|
+
const finalContentType = finalHeaders['content-type'] || '';
|
|
775
|
+
const responseId = streamObservedResponseId || extractResponseId(finalContentType, outBuffer);
|
|
776
|
+
if (responseId) {
|
|
777
|
+
setCachedResponseId(responseContextCache, conversationKey, responseId);
|
|
778
|
+
setCachedResponseId(promptContextCache, promptContextKey, responseId);
|
|
779
|
+
const callIds = extractFunctionCallIdsFromResponse(finalContentType, outBuffer);
|
|
780
|
+
for (const callId of streamObservedCallIds) {
|
|
781
|
+
setCachedCallIdContext(callIdContextCache, callId, responseId);
|
|
782
|
+
}
|
|
783
|
+
for (const callId of callIds) {
|
|
784
|
+
setCachedCallIdContext(callIdContextCache, callId, responseId);
|
|
785
|
+
}
|
|
786
|
+
if (callIds.length > 0 || streamObservedCallIds.size > 0) {
|
|
787
|
+
appendLog(logFile, {
|
|
788
|
+
method: req.method,
|
|
789
|
+
path: url.pathname,
|
|
790
|
+
contextCacheUpdate: {
|
|
791
|
+
response_id: responseId,
|
|
792
|
+
call_ids: Math.max(callIds.length, streamObservedCallIds.size)
|
|
793
|
+
}
|
|
794
|
+
});
|
|
795
|
+
}
|
|
796
|
+
}
|
|
369
797
|
appendLog(logFile, {
|
|
370
798
|
method: req.method,
|
|
371
799
|
path: url.pathname,
|
|
372
|
-
status:
|
|
800
|
+
status: finalStatus,
|
|
373
801
|
attempts,
|
|
374
802
|
primaryStatus,
|
|
375
803
|
compacted,
|
|
@@ -380,7 +808,24 @@ export async function createProxyServer(options = {}) {
|
|
|
380
808
|
...(upstreamError ? { upstreamError } : {}),
|
|
381
809
|
durationMs: Date.now() - startAt
|
|
382
810
|
});
|
|
383
|
-
|
|
811
|
+
if (debugDump) {
|
|
812
|
+
appendLog(logFile, {
|
|
813
|
+
kind: 'debug_dump',
|
|
814
|
+
method: req.method,
|
|
815
|
+
path: url.pathname,
|
|
816
|
+
upstream: url.toString(),
|
|
817
|
+
incomingHeaders,
|
|
818
|
+
normalizedHeaders: toHeaderObject(baseHeaders, { includeAuth: debugIncludeAuth }),
|
|
819
|
+
rawRequestBody: bufferToDebugText(rawBodyText || rawBody, debugMaxBody),
|
|
820
|
+
normalizedRequestBody: bufferToDebugText(normalizedBodyText || body, debugMaxBody),
|
|
821
|
+
attempts: debugAttempts,
|
|
822
|
+
finalResponse: {
|
|
823
|
+
status: finalStatus,
|
|
824
|
+
headers: finalHeaders,
|
|
825
|
+
body: bufferToDebugText(outBuffer, debugMaxBody)
|
|
826
|
+
}
|
|
827
|
+
});
|
|
828
|
+
}
|
|
384
829
|
} catch (err) {
|
|
385
830
|
const message = String(err?.message || err);
|
|
386
831
|
appendLog(logFile, {
|