@autotask/atools-tool 0.1.2 → 0.1.4

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 CHANGED
@@ -15,20 +15,45 @@ atools-tool <command>
15
15
  Available command:
16
16
 
17
17
  ```bash
18
- atools-tool config-openclaw
18
+ atools-tool openclaw
19
19
  ```
20
20
 
21
21
  Interactive flow:
22
22
 
23
- 1. Select default model: `gpt-5.2` / `gpt-5.3-codex` / `gpt-5.4`
24
- 2. Configure reasoning: `off` / `on`
25
- 3. Configure thinking strength: `off|minimal|low|medium|high|xhigh`
26
- 4. Input API key (leave empty to keep current key)
23
+ 1. Check local proxy status (`127.0.0.1:18888`); if not running, ask whether to start it first
24
+ 2. Select default model: `gpt-5.2` / `gpt-5.3-codex` / `gpt-5.4`
25
+ 3. Configure reasoning: `off` / `on`
26
+ 4. Configure thinking strength: `off|minimal|low|medium|high|xhigh`
27
+ 5. Input API key (leave empty to keep current key)
28
+
29
+ Selection UX:
30
+
31
+ - TTY terminal: use `↑/↓` and `Enter`
32
+ - Non-TTY: falls back to number input
33
+
34
+ Rollback to codex-compatible defaults:
35
+
36
+ ```bash
37
+ atools-tool openclaw
38
+ ```
39
+
40
+ Recommended selections:
41
+
42
+ 1. `gpt-5.3-codex`
43
+ 2. `off`
44
+ 3. `low`
45
+ 4. Input your sub2api-compatible key
27
46
 
28
47
  ## Install (npm)
29
48
 
30
49
  ```bash
31
- npm i -g atools-tool
50
+ npm i -g @autotask/atools-tool
51
+ ```
52
+
53
+ Default full install (recommended):
54
+
55
+ ```bash
56
+ npm i -g @autotask/atools-tool && atools-tool-install && atools-tool openclaw
32
57
  ```
33
58
 
34
59
  Optional installer command:
@@ -37,6 +62,12 @@ Optional installer command:
37
62
  atools-tool-install --help
38
63
  ```
39
64
 
65
+ `atools-tool-install` service behavior by platform:
66
+
67
+ - Linux: systemd user service
68
+ - macOS: launchd user agent (`~/Library/LaunchAgents`)
69
+ - Windows: Task Scheduler user task (ONLOGON + start now)
70
+
40
71
  ## Proxy Command
41
72
 
42
73
  ```bash
@@ -62,8 +93,11 @@ curl -fsSL https://raw.githubusercontent.com/aak1247/sub2api-openclaw-proxy/main
62
93
  - patches `~/.openclaw/openclaw.json`
63
94
  - patches `~/.openclaw/agents/main/agent/models.json`
64
95
  - patches `~/.openclaw/agents/main/agent/auth-profiles.json` (if API key is available)
65
- - creates/enables `~/.config/systemd/user/openclaw-atools-proxy.service`
66
- - restarts `openclaw-gateway.service` (unless `--no-restart`)
96
+ - service install by platform:
97
+ - Linux: creates/enables `~/.config/systemd/user/openclaw-atools-proxy.service`
98
+ - macOS: creates/enables `~/Library/LaunchAgents/openclaw-atools-proxy.plist`
99
+ - Windows: creates/updates scheduled task `openclaw-atools-proxy`
100
+ - restarts `openclaw-gateway.service` only on Linux (unless `--no-restart`)
67
101
 
68
102
  Default proxy log:
69
103
 
package/bin/cli.mjs CHANGED
@@ -8,8 +8,6 @@ function parseArgs(argv) {
8
8
  openclawHome: undefined,
9
9
  port: undefined,
10
10
  upstream: undefined,
11
- fallbackUpstream: undefined,
12
- fallbackApiKey: undefined,
13
11
  maxReqBytes: undefined,
14
12
  logFile: undefined,
15
13
  retryMax: undefined,
@@ -27,8 +25,6 @@ function parseArgs(argv) {
27
25
  if (a === '--port') out.port = Number(next());
28
26
  else if (a === '--openclaw-home') out.openclawHome = next();
29
27
  else if (a === '--upstream') out.upstream = next();
30
- else if (a === '--fallback-upstream') out.fallbackUpstream = next();
31
- else if (a === '--fallback-api-key') out.fallbackApiKey = next();
32
28
  else if (a === '--max-req-bytes') out.maxReqBytes = Number(next());
33
29
  else if (a === '--log-file') out.logFile = next();
34
30
  else if (a === '--retry-max') out.retryMax = Number(next());
@@ -47,14 +43,13 @@ Usage:
47
43
 
48
44
  Commands:
49
45
  serve Run proxy service
50
- config-openclaw Interactive OpenClaw model/reasoning/thinking/key setup
46
+ openclaw Interactive OpenClaw setup (sub2api proxy check + model/reasoning/thinking/key)
47
+ config-openclaw Alias of 'openclaw'
51
48
 
52
49
  Options:
53
50
  --openclaw-home <path> OpenClaw home dir (default: ~/.openclaw)
54
51
  --port <number> Listen port (default: 18888)
55
52
  --upstream <url> Upstream base URL (default: https://sub2api.atools.live)
56
- --fallback-upstream <url> Fallback base URL on primary 5xx
57
- --fallback-api-key <key> Override Bearer key for fallback upstream
58
53
  --max-req-bytes <number> Compact requests larger than this threshold (default: 68000)
59
54
  --log-file <path> Log file (default: /tmp/openclaw/atools-compat-proxy.log)
60
55
  --retry-max <number> Retry attempts on 5xx for /responses (default: 6)
@@ -71,7 +66,7 @@ async function main() {
71
66
  return;
72
67
  }
73
68
 
74
- if (args.cmd === 'config-openclaw') {
69
+ if (args.cmd === 'config-openclaw' || args.cmd === 'openclaw') {
75
70
  await runConfigOpenclaw({ openclawHome: args.openclawHome });
76
71
  return;
77
72
  }
@@ -80,9 +75,8 @@ async function main() {
80
75
  throw new Error(`unsupported command: ${args.cmd}`);
81
76
  }
82
77
 
83
- const { port, upstream, fallbackUpstream, logFile } = await createProxyServer(args);
84
- const fallbackNote = fallbackUpstream ? `, fallback=${fallbackUpstream}` : '';
85
- process.stdout.write(`atools-tool listening on http://127.0.0.1:${port}, upstream=${upstream}${fallbackNote}, log=${logFile}\n`);
78
+ const { port, upstream, logFile } = await createProxyServer(args);
79
+ process.stdout.write(`atools-tool listening on http://127.0.0.1:${port}, upstream=${upstream}, log=${logFile}\n`);
86
80
  }
87
81
 
88
82
  main().catch((err) => {
@@ -2,12 +2,23 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import os from 'node:os';
4
4
  import process from 'node:process';
5
- import readline from 'node:readline/promises';
5
+ import net from 'node:net';
6
+ import { spawn, spawnSync } from 'node:child_process';
7
+ import { fileURLToPath } from 'node:url';
8
+ import readlinePromises from 'node:readline/promises';
9
+ import readline from 'node:readline';
6
10
 
7
11
  const PROVIDER = 'sub2api';
8
12
  const MODEL_OPTIONS = ['gpt-5.2', 'gpt-5.3-codex', 'gpt-5.4'];
9
13
  const THINKING_OPTIONS = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh'];
10
14
 
15
+ const PROXY_HOST = '127.0.0.1';
16
+ const PROXY_PORT = 18888;
17
+ const PROXY_BASE_URL = `http://${PROXY_HOST}:${PROXY_PORT}/v1`;
18
+ const PROXY_UPSTREAM = 'https://sub2api.atools.live';
19
+ const PROXY_LOG_FILE = path.join(os.tmpdir(), 'openclaw', 'atools-compat-proxy.log');
20
+ const PROXY_SERVICE = 'openclaw-atools-proxy.service';
21
+
11
22
  function readJson(filePath) {
12
23
  if (!fs.existsSync(filePath)) {
13
24
  throw new Error(`missing file: ${filePath}`);
@@ -28,7 +39,19 @@ function backup(filePath) {
28
39
  fs.copyFileSync(filePath, `${filePath}.bak.${stamp}`);
29
40
  }
30
41
 
31
- async function askChoice(rl, question, options, defaultIndex = 0) {
42
+ function sleep(ms) {
43
+ return new Promise((resolve) => setTimeout(resolve, ms));
44
+ }
45
+
46
+ function canUseArrowMenu() {
47
+ return Boolean(
48
+ process.stdin.isTTY
49
+ && process.stdout.isTTY
50
+ && typeof process.stdin.setRawMode === 'function'
51
+ );
52
+ }
53
+
54
+ async function askChoiceNumber(rl, question, options, defaultIndex = 0) {
32
55
  while (true) {
33
56
  process.stdout.write(`\n${question}\n`);
34
57
  options.forEach((item, idx) => {
@@ -45,14 +68,153 @@ async function askChoice(rl, question, options, defaultIndex = 0) {
45
68
  }
46
69
  }
47
70
 
71
+ async function askChoiceArrow(question, options, defaultIndex = 0) {
72
+ return new Promise((resolve, reject) => {
73
+ let index = defaultIndex;
74
+ process.stdout.write(`\n${question}\n`);
75
+
76
+ const draw = () => {
77
+ readline.clearLine(process.stdout, 0);
78
+ readline.cursorTo(process.stdout, 0);
79
+ process.stdout.write(`> ${options[index]} (↑/↓, Enter)`);
80
+ };
81
+
82
+ const cleanup = () => {
83
+ process.stdin.off('keypress', onKeyPress);
84
+ if (process.stdin.isTTY && typeof process.stdin.setRawMode === 'function') {
85
+ process.stdin.setRawMode(false);
86
+ }
87
+ };
88
+
89
+ const onKeyPress = (str, key = {}) => {
90
+ if (key.ctrl && key.name === 'c') {
91
+ cleanup();
92
+ reject(new Error('aborted by user'));
93
+ return;
94
+ }
95
+
96
+ if (key.name === 'up') {
97
+ index = (index - 1 + options.length) % options.length;
98
+ draw();
99
+ return;
100
+ }
101
+ if (key.name === 'down') {
102
+ index = (index + 1) % options.length;
103
+ draw();
104
+ return;
105
+ }
106
+ if (key.name === 'return' || key.name === 'enter') {
107
+ cleanup();
108
+ readline.clearLine(process.stdout, 0);
109
+ readline.cursorTo(process.stdout, 0);
110
+ process.stdout.write(`已选择: ${options[index]}\n`);
111
+ resolve(options[index]);
112
+ return;
113
+ }
114
+
115
+ const n = Number(str);
116
+ if (Number.isInteger(n) && n >= 1 && n <= options.length) {
117
+ index = n - 1;
118
+ draw();
119
+ }
120
+ };
121
+
122
+ try {
123
+ readline.emitKeypressEvents(process.stdin);
124
+ process.stdin.setRawMode(true);
125
+ process.stdin.resume();
126
+ process.stdin.on('keypress', onKeyPress);
127
+ draw();
128
+ } catch (err) {
129
+ cleanup();
130
+ reject(err);
131
+ }
132
+ });
133
+ }
134
+
135
+ async function askChoice(rl, question, options, defaultIndex = 0) {
136
+ if (!canUseArrowMenu()) {
137
+ return askChoiceNumber(rl, question, options, defaultIndex);
138
+ }
139
+ return askChoiceArrow(question, options, defaultIndex);
140
+ }
141
+
142
+ async function askInputArrow(defaultValue = '') {
143
+ return new Promise((resolve, reject) => {
144
+ let value = '';
145
+ process.stdout.write('请输入 key: ');
146
+
147
+ const draw = () => {
148
+ readline.clearLine(process.stdout, 1);
149
+ readline.cursorTo(process.stdout, 10);
150
+ process.stdout.write(value);
151
+ };
152
+
153
+ const cleanup = () => {
154
+ process.stdin.off('keypress', onKeyPress);
155
+ if (process.stdin.isTTY && typeof process.stdin.setRawMode === 'function') {
156
+ process.stdin.setRawMode(false);
157
+ }
158
+ };
159
+
160
+ const onKeyPress = (str, key = {}) => {
161
+ if (key.ctrl && key.name === 'c') {
162
+ cleanup();
163
+ reject(new Error('aborted by user'));
164
+ return;
165
+ }
166
+ if (key.name === 'return' || key.name === 'enter') {
167
+ cleanup();
168
+ process.stdout.write('\n');
169
+ resolve(value.trim() || defaultValue || '');
170
+ return;
171
+ }
172
+ if (key.name === 'backspace') {
173
+ value = value.slice(0, -1);
174
+ draw();
175
+ return;
176
+ }
177
+ if (!key.ctrl && !key.meta && typeof str === 'string' && str.length > 0) {
178
+ value += str;
179
+ draw();
180
+ }
181
+ };
182
+
183
+ try {
184
+ readline.emitKeypressEvents(process.stdin);
185
+ process.stdin.setRawMode(true);
186
+ process.stdin.resume();
187
+ process.stdin.on('keypress', onKeyPress);
188
+ } catch (err) {
189
+ cleanup();
190
+ reject(err);
191
+ }
192
+ });
193
+ }
194
+
48
195
  async function askKey(rl, currentKey) {
49
196
  const masked = currentKey ? `${currentKey.slice(0, 6)}...${currentKey.slice(-4)}` : '未设置';
50
- process.stdout.write(`\n4) 输入 API Key(当前: ${masked})\n`);
197
+ const question = `4) 输入 API Key(当前: ${masked})`;
198
+ process.stdout.write(`\n${question}\n`);
51
199
  process.stdout.write('留空表示保持当前 key\n');
200
+ if (canUseArrowMenu()) {
201
+ return askInputArrow(currentKey);
202
+ }
52
203
  const input = (await rl.question('请输入 key: ')).trim();
53
204
  return input || currentKey || '';
54
205
  }
55
206
 
207
+ async function askKeyWithGuard(rl, currentKey) {
208
+ let selectedKey = await askKey(rl, currentKey);
209
+ while (/^sk-or-v1-/i.test(selectedKey)) {
210
+ process.stdout.write('\n检测到你输入的是 OpenRouter key(sk-or-v1- 开头),这不适用于 sub2api /v1 代理模式。\n');
211
+ const choice = await askChoice(rl, '是否重新输入 key?', ['重新输入 key(推荐)', '继续使用当前 key'], 0);
212
+ if (choice === '继续使用当前 key') break;
213
+ selectedKey = await askKey(rl, selectedKey);
214
+ }
215
+ return selectedKey;
216
+ }
217
+
56
218
  function ensureModel(providerObj, modelId, reasoning) {
57
219
  providerObj.models = providerObj.models || [];
58
220
  let model = providerObj.models.find((m) => m && m.id === modelId);
@@ -75,6 +237,186 @@ function ensureModel(providerObj, modelId, reasoning) {
75
237
  model.api = model.api || 'openai-responses';
76
238
  }
77
239
 
240
+ function escapeRegExp(text) {
241
+ return String(text).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
242
+ }
243
+
244
+ function upsertEnvLine(content, key, value) {
245
+ const line = `Environment=${key}=${value}`;
246
+ const re = new RegExp(`^Environment=${escapeRegExp(key)}=.*$`, 'm');
247
+ if (re.test(content)) {
248
+ return content.replace(re, line);
249
+ }
250
+ const serviceIdx = content.indexOf('[Service]');
251
+ if (serviceIdx === -1) {
252
+ return `${content.trimEnd()}\n${line}\n`;
253
+ }
254
+ const afterServiceHeader = content.indexOf('\n', serviceIdx);
255
+ if (afterServiceHeader < 0) {
256
+ return `${content}\n${line}\n`;
257
+ }
258
+ return `${content.slice(0, afterServiceHeader + 1)}${line}\n${content.slice(afterServiceHeader + 1)}`;
259
+ }
260
+
261
+ function configureLinuxProxyService() {
262
+ const servicePath = path.join(os.homedir(), '.config/systemd/user', PROXY_SERVICE);
263
+ if (!fs.existsSync(servicePath)) {
264
+ return { exists: false, changed: false, servicePath };
265
+ }
266
+ const original = fs.readFileSync(servicePath, 'utf8');
267
+ let next = original;
268
+ next = upsertEnvLine(next, 'SUB2API_UPSTREAM', PROXY_UPSTREAM);
269
+ next = upsertEnvLine(next, 'SUB2API_COMPAT_DROP_TOOLS_ON_COMPACT', '0');
270
+ next = upsertEnvLine(next, 'SUB2API_COMPAT_PORT', String(PROXY_PORT));
271
+ next = upsertEnvLine(next, 'SUB2API_COMPAT_LOG', PROXY_LOG_FILE);
272
+
273
+ if (next === original) {
274
+ return { exists: true, changed: false, servicePath };
275
+ }
276
+ backup(servicePath);
277
+ fs.writeFileSync(servicePath, next, 'utf8');
278
+ return { exists: true, changed: true, servicePath };
279
+ }
280
+
281
+ function runSystemctl(args) {
282
+ const out = spawnSync('systemctl', ['--user', ...args], { encoding: 'utf8' });
283
+ return out;
284
+ }
285
+
286
+ function isLinuxProxyServiceActive() {
287
+ const out = runSystemctl(['is-active', PROXY_SERVICE]);
288
+ return out.status === 0 && String(out.stdout || '').trim() === 'active';
289
+ }
290
+
291
+ function startLinuxProxyService() {
292
+ const reload = runSystemctl(['daemon-reload']);
293
+ if (reload.status !== 0) return false;
294
+ const start = runSystemctl(['enable', '--now', PROXY_SERVICE]);
295
+ return start.status === 0;
296
+ }
297
+
298
+ function startDetachedProxy() {
299
+ try {
300
+ const cliPath = fileURLToPath(new URL('../bin/cli.mjs', import.meta.url));
301
+ const child = spawn(
302
+ process.execPath,
303
+ [
304
+ cliPath,
305
+ 'serve',
306
+ '--port',
307
+ String(PROXY_PORT),
308
+ '--upstream',
309
+ PROXY_UPSTREAM,
310
+ '--log-file',
311
+ PROXY_LOG_FILE
312
+ ],
313
+ {
314
+ detached: true,
315
+ stdio: 'ignore',
316
+ env: {
317
+ ...process.env,
318
+ SUB2API_UPSTREAM: PROXY_UPSTREAM
319
+ }
320
+ }
321
+ );
322
+ child.unref();
323
+ return true;
324
+ } catch {
325
+ return false;
326
+ }
327
+ }
328
+
329
+ async function isPortOpen(host, port, timeoutMs = 900) {
330
+ return new Promise((resolve) => {
331
+ const socket = net.createConnection({ host, port });
332
+ let done = false;
333
+
334
+ const finish = (ok) => {
335
+ if (done) return;
336
+ done = true;
337
+ socket.destroy();
338
+ resolve(ok);
339
+ };
340
+
341
+ socket.on('connect', () => finish(true));
342
+ socket.on('error', () => finish(false));
343
+ socket.setTimeout(timeoutMs, () => finish(false));
344
+ });
345
+ }
346
+
347
+ async function ensureProxyReady() {
348
+ if (process.platform === 'linux') {
349
+ const service = configureLinuxProxyService();
350
+ if (service.exists && service.changed) {
351
+ // Service file changed: reload + restart so it points back to sub2api.atools.live.
352
+ startLinuxProxyService();
353
+ await sleep(400);
354
+ }
355
+ }
356
+
357
+ if (await isPortOpen(PROXY_HOST, PROXY_PORT)) return true;
358
+
359
+ // Linux retry: service may exist but not running.
360
+ if (process.platform === 'linux') {
361
+ const service = configureLinuxProxyService();
362
+ if (service.exists && isLinuxProxyServiceActive()) {
363
+ if (await isPortOpen(PROXY_HOST, PROXY_PORT)) return true;
364
+ }
365
+ }
366
+
367
+ process.stdout.write('\n检测到本地代理未运行(127.0.0.1:18888)。\n');
368
+ const promptRl = readlinePromises.createInterface({
369
+ input: process.stdin,
370
+ output: process.stdout
371
+ });
372
+ let action = '';
373
+ try {
374
+ action = await askChoice(
375
+ promptRl,
376
+ 'atools 模型依赖本地代理,是否现在启动代理?',
377
+ ['启动代理(推荐)', '取消并退出'],
378
+ 0
379
+ );
380
+ } finally {
381
+ promptRl.close();
382
+ }
383
+
384
+ if (action === '取消并退出') {
385
+ return false;
386
+ }
387
+
388
+ let started = false;
389
+ if (process.platform === 'linux') {
390
+ started = startLinuxProxyService();
391
+ }
392
+ if (!started) {
393
+ started = startDetachedProxy();
394
+ }
395
+
396
+ if (!started) return false;
397
+
398
+ for (let i = 0; i < 5; i += 1) {
399
+ await sleep(500);
400
+ if (await isPortOpen(PROXY_HOST, PROXY_PORT)) {
401
+ return true;
402
+ }
403
+ }
404
+ return false;
405
+ }
406
+
407
+ function printRestartHint() {
408
+ process.stdout.write('\n后续建议:\n');
409
+ if (process.platform === 'linux') {
410
+ process.stdout.write('- Linux(systemd): systemctl --user restart openclaw-gateway.service\n');
411
+ } else if (process.platform === 'darwin') {
412
+ process.stdout.write('- macOS: 重启 OpenClaw 进程,或重新执行你本地的 openclaw 启动命令\n');
413
+ } else if (process.platform === 'win32') {
414
+ process.stdout.write('- Windows: 重启 OpenClaw 进程或服务(若以服务方式运行)\n');
415
+ } else {
416
+ process.stdout.write('- 请重启 OpenClaw 进程,使新配置生效\n');
417
+ }
418
+ }
419
+
78
420
  export async function runConfigOpenclaw({ openclawHome } = {}) {
79
421
  const home = openclawHome || process.env.OPENCLAW_HOME || path.join(os.homedir(), '.openclaw');
80
422
  const openclawJsonPath = path.join(home, 'openclaw.json');
@@ -83,17 +425,26 @@ export async function runConfigOpenclaw({ openclawHome } = {}) {
83
425
 
84
426
  const openclawCfg = readJson(openclawJsonPath);
85
427
  const modelsCfg = readJson(modelsJsonPath);
86
- let authCfg = fs.existsSync(authProfilesPath)
428
+ const authCfg = fs.existsSync(authProfilesPath)
87
429
  ? readJson(authProfilesPath)
88
430
  : { version: 1, profiles: {}, lastGood: {}, usageStats: {} };
89
431
 
90
- const rl = readline.createInterface({
91
- input: process.stdin,
92
- output: process.stdout
93
- });
432
+ let rl = null;
94
433
 
95
434
  try {
96
435
  process.stdout.write(`OpenClaw Home: ${home}\n`);
436
+ process.stdout.write(`固定代理目标: ${PROXY_UPSTREAM}\n`);
437
+ process.stdout.write(`固定 Provider Base URL: ${PROXY_BASE_URL}\n`);
438
+
439
+ const proxyReady = await ensureProxyReady();
440
+ if (!proxyReady) {
441
+ process.stdout.write('\n已取消:本地代理未就绪,未修改 OpenClaw 模型配置。\n');
442
+ return;
443
+ }
444
+ rl = readlinePromises.createInterface({
445
+ input: process.stdin,
446
+ output: process.stdout
447
+ });
97
448
 
98
449
  const selectedModel = await askChoice(rl, '1) 选择默认模型', MODEL_OPTIONS, 1);
99
450
  const reasoningSwitch = await askChoice(rl, '2) 配置推理开关', ['off', 'on'], 0);
@@ -104,13 +455,17 @@ export async function runConfigOpenclaw({ openclawHome } = {}) {
104
455
  modelsCfg?.providers?.[PROVIDER]?.apiKey ||
105
456
  authCfg?.profiles?.[`${PROVIDER}:default`]?.key ||
106
457
  '';
107
- const selectedKey = await askKey(rl, currentKey);
458
+ const selectedKey = await askKeyWithGuard(rl, currentKey);
108
459
 
109
460
  openclawCfg.models = openclawCfg.models || {};
110
461
  openclawCfg.models.providers = openclawCfg.models.providers || {};
111
462
  openclawCfg.models.providers[PROVIDER] = openclawCfg.models.providers[PROVIDER] || {};
463
+ openclawCfg.models.providers[PROVIDER].baseUrl = PROXY_BASE_URL;
112
464
  openclawCfg.models.providers[PROVIDER].api = 'openai-responses';
113
465
  openclawCfg.models.providers[PROVIDER].authHeader = true;
466
+ openclawCfg.models.providers[PROVIDER].headers = openclawCfg.models.providers[PROVIDER].headers || {};
467
+ openclawCfg.models.providers[PROVIDER].headers['User-Agent'] = 'codex_cli_rs/0.101.0 (Ubuntu 24.4.0; x86_64) WindowsTerminal';
468
+ openclawCfg.models.providers[PROVIDER].headers.Accept = 'application/json';
114
469
  if (selectedKey) {
115
470
  openclawCfg.models.providers[PROVIDER].apiKey = selectedKey;
116
471
  openclawCfg.models.providers[PROVIDER].auth = 'api-key';
@@ -125,11 +480,24 @@ export async function runConfigOpenclaw({ openclawHome } = {}) {
125
480
  openclawCfg.agents.defaults.models = openclawCfg.agents.defaults.models || {};
126
481
  openclawCfg.agents.defaults.models[`${PROVIDER}/${selectedModel}`] =
127
482
  openclawCfg.agents.defaults.models[`${PROVIDER}/${selectedModel}`] || { alias: `ATools ${selectedModel}` };
483
+ if (Array.isArray(openclawCfg.agents.list)) {
484
+ openclawCfg.agents.list = openclawCfg.agents.list.map((agent) => {
485
+ if (!agent || typeof agent !== 'object') return agent;
486
+ if (typeof agent.model === 'string' && agent.model.startsWith(`${PROVIDER}/`)) {
487
+ return { ...agent, model: `${PROVIDER}/${selectedModel}` };
488
+ }
489
+ return agent;
490
+ });
491
+ }
128
492
 
129
493
  modelsCfg.providers = modelsCfg.providers || {};
130
494
  modelsCfg.providers[PROVIDER] = modelsCfg.providers[PROVIDER] || {};
495
+ modelsCfg.providers[PROVIDER].baseUrl = PROXY_BASE_URL;
131
496
  modelsCfg.providers[PROVIDER].api = 'openai-responses';
132
497
  modelsCfg.providers[PROVIDER].authHeader = true;
498
+ modelsCfg.providers[PROVIDER].headers = modelsCfg.providers[PROVIDER].headers || {};
499
+ modelsCfg.providers[PROVIDER].headers['User-Agent'] = 'codex_cli_rs/0.101.0 (Ubuntu 24.4.0; x86_64) WindowsTerminal';
500
+ modelsCfg.providers[PROVIDER].headers.Accept = 'application/json';
133
501
  if (selectedKey) {
134
502
  modelsCfg.providers[PROVIDER].apiKey = selectedKey;
135
503
  modelsCfg.providers[PROVIDER].auth = 'api-key';
@@ -161,6 +529,8 @@ export async function runConfigOpenclaw({ openclawHome } = {}) {
161
529
  writeJson(authProfilesPath, authCfg);
162
530
 
163
531
  process.stdout.write('\n配置已更新:\n');
532
+ process.stdout.write(`- Proxy Upstream: ${PROXY_UPSTREAM}\n`);
533
+ process.stdout.write(`- Provider Base URL: ${PROXY_BASE_URL}\n`);
164
534
  process.stdout.write(`- 默认模型: ${PROVIDER}/${selectedModel}\n`);
165
535
  process.stdout.write(`- 推理开关: ${reasoning ? 'on' : 'off'}\n`);
166
536
  process.stdout.write(`- 思考强度: ${selectedThinking}\n`);
@@ -168,7 +538,8 @@ export async function runConfigOpenclaw({ openclawHome } = {}) {
168
538
  process.stdout.write(`- 已写入: ${openclawJsonPath}\n`);
169
539
  process.stdout.write(`- 已写入: ${modelsJsonPath}\n`);
170
540
  process.stdout.write(`- 已写入: ${authProfilesPath}\n`);
541
+ printRestartHint();
171
542
  } finally {
172
- rl.close();
543
+ if (rl) rl.close();
173
544
  }
174
545
  }
package/lib/install.mjs CHANGED
@@ -8,8 +8,7 @@ const DEFAULT_PROVIDER = 'sub2api';
8
8
  const DEFAULT_MODEL = 'gpt-5.3-codex';
9
9
  const DEFAULT_PORT = 18888;
10
10
  const DEFAULT_UPSTREAM = 'https://sub2api.atools.live';
11
- const DEFAULT_FALLBACK_UPSTREAM = 'https://gmn.chuangzuoli.com/v1';
12
- const DEFAULT_LOG_FILE = '/tmp/openclaw/atools-compat-proxy.log';
11
+ const DEFAULT_LOG_FILE = path.join(os.tmpdir(), 'openclaw', 'atools-compat-proxy.log');
13
12
  const DEFAULT_SERVICE_NAME = 'openclaw-atools-proxy.service';
14
13
  const OPENCLAW_GATEWAY_SERVICE = 'openclaw-gateway.service';
15
14
 
@@ -47,6 +46,40 @@ function ensureDir(dir) {
47
46
  fs.mkdirSync(dir, { recursive: true });
48
47
  }
49
48
 
49
+ function defaultServiceId(name = DEFAULT_SERVICE_NAME) {
50
+ return String(name || DEFAULT_SERVICE_NAME).replace(/\.service$/i, '');
51
+ }
52
+
53
+ function buildProxyServeArgs({ port, upstream, maxReqBytes, logFile }) {
54
+ const args = [
55
+ 'serve',
56
+ '--port',
57
+ String(port),
58
+ '--upstream',
59
+ String(upstream),
60
+ '--log-file',
61
+ String(logFile),
62
+ '--max-req-bytes',
63
+ String(maxReqBytes)
64
+ ];
65
+ return args;
66
+ }
67
+
68
+ function escapeXml(text) {
69
+ return String(text)
70
+ .replace(/&/g, '&amp;')
71
+ .replace(/</g, '&lt;')
72
+ .replace(/>/g, '&gt;')
73
+ .replace(/"/g, '&quot;')
74
+ .replace(/'/g, '&apos;');
75
+ }
76
+
77
+ function quoteWindowsArg(arg) {
78
+ const s = String(arg ?? '');
79
+ if (!/[\s"]/g.test(s)) return s;
80
+ return `"${s.replace(/"/g, '\\"')}"`;
81
+ }
82
+
50
83
  function parseArgs(argv) {
51
84
  const out = {
52
85
  openclawHome: process.env.OPENCLAW_HOME || path.join(os.homedir(), '.openclaw'),
@@ -54,12 +87,10 @@ function parseArgs(argv) {
54
87
  model: DEFAULT_MODEL,
55
88
  port: DEFAULT_PORT,
56
89
  upstream: DEFAULT_UPSTREAM,
57
- fallbackUpstream: DEFAULT_FALLBACK_UPSTREAM,
58
90
  maxReqBytes: 68000,
59
91
  logFile: DEFAULT_LOG_FILE,
60
92
  serviceName: DEFAULT_SERVICE_NAME,
61
93
  apiKey: process.env.SUB_OAI_KEY || '',
62
- fallbackApiKey: process.env.FALLBACK_OAI_KEY || '',
63
94
  dryRun: false,
64
95
  noRestart: false
65
96
  };
@@ -73,12 +104,10 @@ function parseArgs(argv) {
73
104
  else if (a === '--model') out.model = next();
74
105
  else if (a === '--port') out.port = Number(next());
75
106
  else if (a === '--upstream') out.upstream = next();
76
- else if (a === '--fallback-upstream') out.fallbackUpstream = next();
77
107
  else if (a === '--max-req-bytes') out.maxReqBytes = Number(next());
78
108
  else if (a === '--log-file') out.logFile = next();
79
109
  else if (a === '--service-name') out.serviceName = next();
80
110
  else if (a === '--api-key') out.apiKey = next();
81
- else if (a === '--fallback-api-key') out.fallbackApiKey = next();
82
111
  else if (a === '--dry-run') out.dryRun = true;
83
112
  else if (a === '--no-restart') out.noRestart = true;
84
113
  else if (a === '--help' || a === '-h') {
@@ -95,42 +124,65 @@ function parseArgs(argv) {
95
124
  }
96
125
 
97
126
  function renderSystemdService({
98
- serviceName,
99
127
  nodeBin,
100
128
  cliPath,
101
- port,
102
- upstream,
103
- fallbackUpstream,
104
- maxReqBytes,
105
- fallbackApiKey,
106
- logFile
129
+ serveArgs
107
130
  }) {
108
- const fallbackEnv = fallbackUpstream
109
- ? `Environment=SUB2API_FALLBACK_UPSTREAM=${fallbackUpstream}\nEnvironment=SUB2API_FALLBACK_API_KEY=${fallbackApiKey || ''}\n`
110
- : '';
131
+ const execArgs = [nodeBin, cliPath, ...serveArgs].join(' ');
111
132
  return `[Unit]
112
133
  Description=OpenClaw ATools Compatibility Proxy
113
134
  After=network-online.target
114
135
  Wants=network-online.target
115
136
 
116
137
  [Service]
117
- ExecStart=${nodeBin} ${cliPath} serve --port ${port} --upstream ${upstream} --log-file ${logFile}
138
+ ExecStart=${execArgs}
118
139
  Restart=always
119
140
  RestartSec=2
120
141
  Environment=HOME=${os.homedir()}
121
142
  Environment=TMPDIR=/tmp
122
- Environment=SUB2API_COMPAT_PORT=${port}
123
- Environment=SUB2API_UPSTREAM=${upstream}
124
- Environment=SUB2API_COMPAT_LOG=${logFile}
125
- Environment=SUB2API_COMPAT_MAX_REQ_BYTES=${maxReqBytes}
126
143
  Environment="SUB2API_COMPAT_USER_AGENT=codex_cli_rs/0.101.0 (Ubuntu 24.4.0; x86_64) WindowsTerminal"
127
- ${fallbackEnv}
128
144
 
129
145
  [Install]
130
146
  WantedBy=default.target
131
147
  `;
132
148
  }
133
149
 
150
+ function renderLaunchdPlist({ label, nodeBin, cliPath, serveArgs, logFile }) {
151
+ const args = [nodeBin, cliPath, ...serveArgs];
152
+ const argsXml = args.map((arg) => ` <string>${escapeXml(arg)}</string>`).join('\n');
153
+ const launchLog = `${logFile}.launchd.log`;
154
+ return `<?xml version="1.0" encoding="UTF-8"?>
155
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
156
+ <plist version="1.0">
157
+ <dict>
158
+ <key>Label</key>
159
+ <string>${escapeXml(label)}</string>
160
+ <key>ProgramArguments</key>
161
+ <array>
162
+ ${argsXml}
163
+ </array>
164
+ <key>RunAtLoad</key>
165
+ <true/>
166
+ <key>KeepAlive</key>
167
+ <true/>
168
+ <key>WorkingDirectory</key>
169
+ <string>${escapeXml(os.homedir())}</string>
170
+ <key>EnvironmentVariables</key>
171
+ <dict>
172
+ <key>HOME</key>
173
+ <string>${escapeXml(os.homedir())}</string>
174
+ <key>SUB2API_COMPAT_USER_AGENT</key>
175
+ <string>codex_cli_rs/0.101.0 (Ubuntu 24.4.0; x86_64) WindowsTerminal</string>
176
+ </dict>
177
+ <key>StandardOutPath</key>
178
+ <string>${escapeXml(launchLog)}</string>
179
+ <key>StandardErrorPath</key>
180
+ <string>${escapeXml(launchLog)}</string>
181
+ </dict>
182
+ </plist>
183
+ `;
184
+ }
185
+
134
186
  function detectApiKey(openclawHome, explicitKey) {
135
187
  if (explicitKey) return explicitKey;
136
188
  const authPath = path.join(openclawHome, 'agents/main/agent/auth-profiles.json');
@@ -138,13 +190,6 @@ function detectApiKey(openclawHome, explicitKey) {
138
190
  return auth?.profiles?.['sub2api:default']?.key || '';
139
191
  }
140
192
 
141
- function detectFallbackApiKey(openclawHome, explicitKey) {
142
- if (explicitKey) return explicitKey;
143
- const cfgPath = path.join(openclawHome, 'openclaw.json');
144
- const cfg = readJsonIfExists(cfgPath);
145
- return cfg?.models?.providers?.gmn?.apiKey || '';
146
- }
147
-
148
193
  function patchOpenclawConfig(openclawHome, provider, model, port, dryRun) {
149
194
  const cfgPath = path.join(openclawHome, 'openclaw.json');
150
195
  const cfg = readJsonIfExists(cfgPath);
@@ -297,7 +342,7 @@ function runSystemctl(args, { dryRun } = {}) {
297
342
  execFileSync('systemctl', ['--user', ...args], { stdio: 'inherit' });
298
343
  }
299
344
 
300
- function installService({ serviceName, content, dryRun }) {
345
+ function installLinuxService({ serviceName, content, dryRun }) {
301
346
  const serviceDir = path.join(os.homedir(), '.config/systemd/user');
302
347
  const servicePath = path.join(serviceDir, serviceName);
303
348
  ensureDir(serviceDir);
@@ -317,12 +362,105 @@ function installService({ serviceName, content, dryRun }) {
317
362
  runSystemctl(['enable', '--now', serviceName], { dryRun });
318
363
  }
319
364
 
365
+ function runLaunchctl(args, { dryRun, allowFailure = false } = {}) {
366
+ if (dryRun) {
367
+ info(`dry-run: launchctl ${args.join(' ')}`);
368
+ return;
369
+ }
370
+ try {
371
+ execFileSync('launchctl', args, { stdio: 'inherit' });
372
+ } catch (err) {
373
+ if (allowFailure) return;
374
+ throw err;
375
+ }
376
+ }
377
+
378
+ function installMacService({ serviceName, content, dryRun }) {
379
+ const serviceId = defaultServiceId(serviceName);
380
+ const launchAgentDir = path.join(os.homedir(), 'Library', 'LaunchAgents');
381
+ const plistPath = path.join(launchAgentDir, `${serviceId}.plist`);
382
+ const uid = typeof process.getuid === 'function' ? process.getuid() : null;
383
+ if (uid == null) {
384
+ throw new Error('failed to resolve current uid for launchctl');
385
+ }
386
+
387
+ ensureDir(launchAgentDir);
388
+ if (dryRun) {
389
+ info(`dry-run: would write ${plistPath}`);
390
+ } else {
391
+ if (fs.existsSync(plistPath)) {
392
+ const bak = backup(plistPath);
393
+ if (bak) info(`backup created: ${bak}`);
394
+ }
395
+ fs.writeFileSync(plistPath, content, 'utf8');
396
+ info(`wrote: ${plistPath}`);
397
+ }
398
+
399
+ const target = `gui/${uid}/${serviceId}`;
400
+ runLaunchctl(['bootout', target], { dryRun, allowFailure: true });
401
+ runLaunchctl(['bootstrap', `gui/${uid}`, plistPath], { dryRun });
402
+ runLaunchctl(['enable', target], { dryRun, allowFailure: true });
403
+ runLaunchctl(['kickstart', '-k', target], { dryRun, allowFailure: true });
404
+ }
405
+
406
+ function runSchtasks(args, { dryRun, allowFailure = false } = {}) {
407
+ if (dryRun) {
408
+ info(`dry-run: schtasks ${args.join(' ')}`);
409
+ return;
410
+ }
411
+ try {
412
+ execFileSync('schtasks', args, { stdio: 'inherit' });
413
+ } catch (err) {
414
+ if (allowFailure) return;
415
+ throw err;
416
+ }
417
+ }
418
+
419
+ function installWindowsService({ serviceName, nodeBin, cliPath, serveArgs, dryRun }) {
420
+ const taskName = defaultServiceId(serviceName);
421
+ const command = [nodeBin, cliPath, ...serveArgs].map(quoteWindowsArg).join(' ');
422
+ runSchtasks(
423
+ ['/Create', '/F', '/SC', 'ONLOGON', '/RL', 'LIMITED', '/TN', taskName, '/TR', command],
424
+ { dryRun }
425
+ );
426
+ runSchtasks(['/Run', '/TN', taskName], { dryRun, allowFailure: true });
427
+ }
428
+
429
+ function installService({
430
+ serviceName,
431
+ linuxServiceContent,
432
+ macPlistContent,
433
+ nodeBin,
434
+ cliPath,
435
+ serveArgs,
436
+ dryRun
437
+ }) {
438
+ if (process.platform === 'linux') {
439
+ installLinuxService({ serviceName, content: linuxServiceContent, dryRun });
440
+ return;
441
+ }
442
+ if (process.platform === 'darwin') {
443
+ installMacService({ serviceName, content: macPlistContent, dryRun });
444
+ return;
445
+ }
446
+ if (process.platform === 'win32') {
447
+ installWindowsService({ serviceName, nodeBin, cliPath, serveArgs, dryRun });
448
+ return;
449
+ }
450
+ throw new Error(`unsupported platform for service install: ${process.platform}`);
451
+ }
452
+
320
453
  function restartOpenclawGateway({ noRestart, dryRun }) {
321
454
  if (noRestart) {
322
455
  info('skip gateway restart (--no-restart)');
323
456
  return;
324
457
  }
325
458
 
459
+ if (process.platform !== 'linux') {
460
+ info(`skip gateway restart on ${process.platform} (please restart OpenClaw manually)`);
461
+ return;
462
+ }
463
+
326
464
  try {
327
465
  runSystemctl(['restart', OPENCLAW_GATEWAY_SERVICE], { dryRun });
328
466
  } catch {
@@ -342,12 +480,10 @@ Options:
342
480
  --model <id> Default: gpt-5.3-codex
343
481
  --port <number> Default: 18888
344
482
  --upstream <url> Default: https://sub2api.atools.live
345
- --fallback-upstream <url> Default: https://gmn.chuangzuoli.com/v1
346
483
  --max-req-bytes <number> Default: 68000
347
- --log-file <path> Default: /tmp/openclaw/atools-compat-proxy.log
348
- --service-name <name> Default: openclaw-atools-proxy.service
349
- --api-key <key> Prefer explicit key; fallback env SUB_OAI_KEY then auth-profiles
350
- --fallback-api-key <key> Prefer explicit key; fallback env FALLBACK_OAI_KEY then openclaw gmn key
484
+ --log-file <path> Default: <tmp>/openclaw/atools-compat-proxy.log
485
+ --service-name <name> Linux: *.service; macOS/Windows: service id/task name
486
+ --api-key <key> Prefer explicit key; otherwise use SUB_OAI_KEY or auth-profiles
351
487
  --no-restart Do not restart openclaw-gateway.service
352
488
  --dry-run Print actions without changing files
353
489
  -h, --help Show help
@@ -365,32 +501,44 @@ export async function runInstall(rawArgs = process.argv.slice(2)) {
365
501
  const nodeBin = process.execPath;
366
502
  const cliPath = fileURLToPath(new URL('../bin/cli.mjs', import.meta.url));
367
503
  const apiKey = detectApiKey(args.openclawHome, args.apiKey);
368
- const fallbackApiKey = detectFallbackApiKey(args.openclawHome, args.fallbackApiKey);
504
+ const serveArgs = buildProxyServeArgs({
505
+ port: args.port,
506
+ upstream: args.upstream,
507
+ maxReqBytes: args.maxReqBytes,
508
+ logFile: args.logFile
509
+ });
369
510
 
370
511
  info(`openclawHome=${args.openclawHome}`);
512
+ info(`platform=${process.platform}`);
371
513
  info(`provider/model=${args.provider}/${args.model}`);
372
514
  info(`proxy=http://127.0.0.1:${args.port}/v1 -> ${args.upstream}`);
373
- if (args.fallbackUpstream) {
374
- info(`fallback=${args.fallbackUpstream}`);
375
- }
376
515
 
377
516
  patchOpenclawConfig(args.openclawHome, args.provider, args.model, args.port, args.dryRun);
378
517
  patchAgentModels(args.openclawHome, args.provider, args.model, args.port, apiKey, args.dryRun);
379
518
  patchAuthProfiles(args.openclawHome, args.provider, apiKey, args.dryRun);
380
519
 
381
- const serviceContent = renderSystemdService({
382
- serviceName: args.serviceName,
520
+ const linuxServiceContent = renderSystemdService({
383
521
  nodeBin,
384
522
  cliPath,
385
- port: args.port,
386
- upstream: args.upstream,
387
- fallbackUpstream: args.fallbackUpstream,
388
- maxReqBytes: args.maxReqBytes,
389
- fallbackApiKey,
523
+ serveArgs
524
+ });
525
+ const macPlistContent = renderLaunchdPlist({
526
+ label: defaultServiceId(args.serviceName),
527
+ nodeBin,
528
+ cliPath,
529
+ serveArgs,
390
530
  logFile: args.logFile
391
531
  });
392
532
 
393
- installService({ serviceName: args.serviceName, content: serviceContent, dryRun: args.dryRun });
533
+ installService({
534
+ serviceName: args.serviceName,
535
+ linuxServiceContent,
536
+ macPlistContent,
537
+ nodeBin,
538
+ cliPath,
539
+ serveArgs,
540
+ dryRun: args.dryRun
541
+ });
394
542
  restartOpenclawGateway({ noRestart: args.noRestart, dryRun: args.dryRun });
395
543
 
396
544
  info('done');
@@ -1,17 +1,17 @@
1
1
  import http from 'node:http';
2
2
  import fs from 'node:fs';
3
+ import path from 'node:path';
3
4
 
4
5
  const DEFAULT_PORT = 18888;
5
6
  const DEFAULT_UPSTREAM = 'https://sub2api.atools.live';
6
7
  const DEFAULT_LOG = '/tmp/openclaw/atools-compat-proxy.log';
7
8
  const DEFAULT_USER_AGENT = 'codex_cli_rs/0.101.0 (Ubuntu 24.4.0; x86_64) WindowsTerminal';
8
- const DEFAULT_FALLBACK_UPSTREAM = '';
9
- const DEFAULT_FALLBACK_API_KEY = '';
10
9
  const DEFAULT_MAX_REQ_BYTES = 68000;
11
10
  const DEFAULT_DROP_TOOLS_ON_COMPACT = false;
11
+ const DEFAULT_STRIP_PREVIOUS_RESPONSE_ID = false;
12
12
 
13
13
  function ensureParentDir(filePath) {
14
- const dir = filePath.replace(/\/[^/]*$/, '');
14
+ const dir = path.dirname(filePath);
15
15
  if (dir) {
16
16
  fs.mkdirSync(dir, { recursive: true });
17
17
  }
@@ -54,12 +54,13 @@ function normalizeContent(content) {
54
54
  return [toInputText('')];
55
55
  }
56
56
 
57
- function normalizeResponsesPayload(payload) {
57
+ function normalizeResponsesPayload(payload, { stripPreviousResponseId = DEFAULT_STRIP_PREVIOUS_RESPONSE_ID } = {}) {
58
58
  if (!payload || typeof payload !== 'object') return payload;
59
59
  const out = { ...payload };
60
60
 
61
- // Some upstream gateways reject/unstable with this field.
62
- if ('previous_response_id' in out) delete out.previous_response_id;
61
+ if (stripPreviousResponseId && 'previous_response_id' in out) {
62
+ delete out.previous_response_id;
63
+ }
63
64
 
64
65
  if (typeof out.input === 'string') {
65
66
  out.input = [{ role: 'user', content: normalizeContent(out.input) }];
@@ -84,7 +85,28 @@ function trimTextPart(part, maxChars) {
84
85
  return { ...part, text: text.length > maxChars ? text.slice(0, maxChars) : text };
85
86
  }
86
87
 
87
- function compactResponsesPayload(payload, { dropToolsOnCompact = DEFAULT_DROP_TOOLS_ON_COMPACT } = {}) {
88
+ function compactInputMessages(messages, maxRecentMessages = 8) {
89
+ if (!Array.isArray(messages)) return messages;
90
+ const recent = messages.slice(-Math.max(1, maxRecentMessages));
91
+ let latestSystem = null;
92
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
93
+ const msg = messages[i];
94
+ if (!msg || typeof msg !== 'object') continue;
95
+ if (msg.role === 'system' || msg.role === 'developer') {
96
+ latestSystem = msg;
97
+ break;
98
+ }
99
+ }
100
+ if (latestSystem && !recent.includes(latestSystem)) {
101
+ return [latestSystem, ...recent];
102
+ }
103
+ return recent;
104
+ }
105
+
106
+ function compactResponsesPayload(payload, {
107
+ dropToolsOnCompact = DEFAULT_DROP_TOOLS_ON_COMPACT,
108
+ maxRecentMessages
109
+ } = {}) {
88
110
  if (!payload || typeof payload !== 'object') return payload;
89
111
  const out = { ...payload };
90
112
 
@@ -104,13 +126,11 @@ function compactResponsesPayload(payload, { dropToolsOnCompact = DEFAULT_DROP_TO
104
126
  }
105
127
  return item;
106
128
  });
107
- const systemMsgs = normalized.filter((m) => m && typeof m === 'object' && (m.role === 'system' || m.role === 'developer')).slice(-1);
108
- const userMsgs = normalized.filter((m) => m && typeof m === 'object' && m.role === 'user').slice(-2);
109
- const merged = [...systemMsgs, ...userMsgs];
110
- out.input = merged.length > 0 ? merged : normalized.slice(-1);
129
+ const resolvedMaxRecentMessages = Number.isInteger(maxRecentMessages)
130
+ ? Math.max(1, maxRecentMessages)
131
+ : (('previous_response_id' in out) ? 14 : 8);
132
+ out.input = compactInputMessages(normalized, resolvedMaxRecentMessages);
111
133
  }
112
-
113
- if ('previous_response_id' in out) delete out.previous_response_id;
114
134
  return out;
115
135
  }
116
136
 
@@ -127,13 +147,10 @@ async function forward(url, req, headers, body) {
127
147
  });
128
148
  }
129
149
 
130
- function buildHeaders(baseHeaders, bodyLen, apiKeyOverride = '') {
150
+ function buildHeaders(baseHeaders, bodyLen) {
131
151
  const headers = { ...baseHeaders };
132
152
  headers['content-type'] = 'application/json';
133
153
  headers['content-length'] = String(bodyLen);
134
- if (apiKeyOverride) {
135
- headers.authorization = `Bearer ${apiKeyOverride}`;
136
- }
137
154
  return headers;
138
155
  }
139
156
 
@@ -144,17 +161,16 @@ export async function createProxyServer(options = {}) {
144
161
  const retryMax = Number(options.retryMax ?? process.env.SUB2API_COMPAT_RETRY_MAX ?? 6);
145
162
  const retryBaseMs = Number(options.retryBaseMs ?? process.env.SUB2API_COMPAT_RETRY_BASE_MS ?? 300);
146
163
  const userAgent = options.userAgent ?? process.env.SUB2API_COMPAT_USER_AGENT ?? DEFAULT_USER_AGENT;
147
- const fallbackUpstream = options.fallbackUpstream ?? process.env.SUB2API_FALLBACK_UPSTREAM ?? DEFAULT_FALLBACK_UPSTREAM;
148
- const fallbackApiKey = options.fallbackApiKey ?? process.env.SUB2API_FALLBACK_API_KEY ?? DEFAULT_FALLBACK_API_KEY;
149
164
  const maxReqBytes = Number(options.maxReqBytes ?? process.env.SUB2API_COMPAT_MAX_REQ_BYTES ?? DEFAULT_MAX_REQ_BYTES);
150
165
  const dropToolsOnCompact = options.dropToolsOnCompact
151
166
  ?? ['1', 'true', 'yes', 'on'].includes(String(process.env.SUB2API_COMPAT_DROP_TOOLS_ON_COMPACT || '').toLowerCase());
167
+ const stripPreviousResponseId = options.stripPreviousResponseId
168
+ ?? ['1', 'true', 'yes', 'on'].includes(String(process.env.SUB2API_COMPAT_STRIP_PREVIOUS_RESPONSE_ID || '').toLowerCase());
152
169
 
153
170
  const server = http.createServer(async (req, res) => {
154
171
  const startAt = Date.now();
155
172
  try {
156
173
  const url = new URL(req.url || '/', upstream);
157
- const fallbackUrl = fallbackUpstream ? new URL(req.url || '/', fallbackUpstream) : null;
158
174
  const chunks = [];
159
175
  for await (const c of req) chunks.push(c);
160
176
  const rawBody = Buffer.concat(chunks);
@@ -167,61 +183,78 @@ export async function createProxyServer(options = {}) {
167
183
  const isResponsesPath = req.method === 'POST' && url.pathname.endsWith('/responses');
168
184
  let body = rawBody;
169
185
  let compactBody = null;
186
+ let aggressiveBody = null;
187
+ let minimalBody = null;
170
188
 
171
189
  if (isResponsesPath && rawBody.length > 0) {
172
190
  try {
173
191
  const parsed = JSON.parse(rawBody.toString('utf8'));
174
- const normalized = normalizeResponsesPayload(parsed);
192
+ const normalized = normalizeResponsesPayload(parsed, { stripPreviousResponseId });
175
193
  body = Buffer.from(JSON.stringify(normalized));
176
- const compacted = compactResponsesPayload(normalized, { dropToolsOnCompact });
177
- const compactBuf = Buffer.from(JSON.stringify(compacted));
178
- if (compactBuf.length + 512 < body.length) {
179
- compactBody = compactBuf;
194
+ if (body.length > maxReqBytes) {
195
+ const compacted = compactResponsesPayload(normalized, { dropToolsOnCompact });
196
+ const compactBuf = Buffer.from(JSON.stringify(compacted));
197
+ if (compactBuf.length + 512 < body.length) {
198
+ compactBody = compactBuf;
199
+ }
200
+ const aggressive = compactResponsesPayload(normalized, {
201
+ dropToolsOnCompact,
202
+ maxRecentMessages: 3
203
+ });
204
+ const aggressiveBuf = Buffer.from(JSON.stringify(aggressive));
205
+ if (aggressiveBuf.length + 256 < body.length) {
206
+ aggressiveBody = aggressiveBuf;
207
+ }
208
+ const minimal = compactResponsesPayload(normalized, {
209
+ dropToolsOnCompact,
210
+ maxRecentMessages: 1
211
+ });
212
+ const minimalBuf = Buffer.from(JSON.stringify(minimal));
213
+ if (minimalBuf.length + 128 < body.length) {
214
+ minimalBody = minimalBuf;
215
+ }
180
216
  }
181
217
  } catch {
182
218
  // Keep original body if parse fails.
183
219
  }
184
220
  }
185
221
 
222
+ const requestBodies = [body];
223
+ if (compactBody && !compactBody.equals(body)) {
224
+ requestBodies.push(compactBody);
225
+ }
226
+ if (aggressiveBody && !aggressiveBody.equals(body) && !(compactBody && aggressiveBody.equals(compactBody))) {
227
+ requestBodies.push(aggressiveBody);
228
+ }
229
+ if (
230
+ minimalBody
231
+ && !minimalBody.equals(body)
232
+ && !(compactBody && minimalBody.equals(compactBody))
233
+ && !(aggressiveBody && minimalBody.equals(aggressiveBody))
234
+ ) {
235
+ requestBodies.push(minimalBody);
236
+ }
237
+
186
238
  let resp;
187
239
  let attempts = 0;
188
- let via = 'primary';
189
240
  let primaryStatus = null;
190
- let fallbackStatus = null;
191
241
  let compacted = false;
242
+ let strategy = 'full';
192
243
  const maxAttempts = isResponsesPath ? retryMax : 1;
193
244
  while (attempts < maxAttempts) {
194
245
  attempts += 1;
195
- const shouldCompactNow = Boolean(compactBody && (attempts > 1 || body.length > maxReqBytes));
196
- const requestBody = shouldCompactNow ? compactBody : body;
197
- compacted = shouldCompactNow;
246
+ const strategyIndex = Math.min(requestBodies.length - 1, attempts - 1);
247
+ const requestBody = requestBodies[strategyIndex];
248
+ compacted = strategyIndex > 0;
249
+ strategy = strategyIndex === 0
250
+ ? 'full'
251
+ : (strategyIndex === 1 ? 'compact' : (strategyIndex === 2 ? 'aggressive' : 'minimal'));
198
252
  const primaryHeaders = isResponsesPath ? buildHeaders(baseHeaders, requestBody.length) : baseHeaders;
199
- const fallbackHeaders = isResponsesPath
200
- ? buildHeaders(baseHeaders, requestBody.length, fallbackApiKey)
201
- : (fallbackApiKey ? { ...baseHeaders, authorization: `Bearer ${fallbackApiKey}` } : baseHeaders);
202
253
 
203
254
  const primaryResp = await forward(url, req, primaryHeaders, requestBody);
204
255
  primaryStatus = primaryResp.status;
205
- if (primaryResp.status < 500) {
206
- resp = primaryResp;
207
- via = 'primary';
208
- break;
209
- }
210
-
211
- if (fallbackUrl) {
212
- const fbResp = await forward(fallbackUrl, req, fallbackHeaders, requestBody);
213
- fallbackStatus = fbResp.status;
214
- if (fbResp.status < 500) {
215
- resp = fbResp;
216
- via = 'fallback';
217
- break;
218
- }
219
- resp = fbResp;
220
- via = 'fallback';
221
- } else {
222
- resp = primaryResp;
223
- via = 'primary';
224
- }
256
+ resp = primaryResp;
257
+ if (primaryResp.status < 500) break;
225
258
 
226
259
  if (attempts < maxAttempts) {
227
260
  await sleep(retryBaseMs * attempts);
@@ -240,12 +273,11 @@ export async function createProxyServer(options = {}) {
240
273
  path: url.pathname,
241
274
  status: resp.status,
242
275
  attempts,
243
- via,
244
276
  primaryStatus,
245
- fallbackStatus,
246
277
  compacted,
278
+ strategy,
247
279
  reqBytes: rawBody.length,
248
- upstreamReqBytes: compactBody && compacted ? compactBody.length : body.length,
280
+ upstreamReqBytes: requestBodies[Math.min(requestBodies.length - 1, attempts - 1)].length,
249
281
  respBytes: outBuffer.length,
250
282
  durationMs: Date.now() - startAt
251
283
  });
@@ -274,7 +306,6 @@ export async function createProxyServer(options = {}) {
274
306
  server,
275
307
  port,
276
308
  upstream,
277
- fallbackUpstream,
278
309
  logFile,
279
310
  close: () => new Promise((resolve, reject) => server.close((err) => (err ? reject(err) : resolve())))
280
311
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@autotask/atools-tool",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "ATools CLI for OpenClaw proxy compatibility and interactive model configuration",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -33,7 +33,7 @@
33
33
  },
34
34
  "scripts": {
35
35
  "start": "node ./bin/cli.mjs serve",
36
- "config:openclaw": "node ./bin/cli.mjs config-openclaw",
36
+ "config:openclaw": "node ./bin/cli.mjs openclaw",
37
37
  "install:local": "node ./bin/install.mjs",
38
38
  "pack:check": "npm pack"
39
39
  },