@autotask/atools-tool 0.1.5 → 0.1.7

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
@@ -68,6 +68,9 @@ atools-tool-install --help
68
68
  - macOS: launchd user agent (`~/Library/LaunchAgents`)
69
69
  - Windows: Task Scheduler user task (ONLOGON + start now)
70
70
 
71
+ By default, installer does not hard-force a single outbound model.
72
+ Proxy normalizes `sub2api/<model>` to plain model id for codex-compatible upstreams.
73
+
71
74
  ## Proxy Command
72
75
 
73
76
  ```bash
package/bin/cli.mjs CHANGED
@@ -8,6 +8,7 @@ function parseArgs(argv) {
8
8
  openclawHome: undefined,
9
9
  port: undefined,
10
10
  upstream: undefined,
11
+ forceModel: undefined,
11
12
  maxReqBytes: undefined,
12
13
  logFile: undefined,
13
14
  retryMax: undefined,
@@ -25,6 +26,7 @@ function parseArgs(argv) {
25
26
  if (a === '--port') out.port = Number(next());
26
27
  else if (a === '--openclaw-home') out.openclawHome = next();
27
28
  else if (a === '--upstream') out.upstream = next();
29
+ else if (a === '--force-model') out.forceModel = next();
28
30
  else if (a === '--max-req-bytes') out.maxReqBytes = Number(next());
29
31
  else if (a === '--log-file') out.logFile = next();
30
32
  else if (a === '--retry-max') out.retryMax = Number(next());
@@ -50,6 +52,7 @@ Options:
50
52
  --openclaw-home <path> OpenClaw home dir (default: ~/.openclaw)
51
53
  --port <number> Listen port (default: 18888)
52
54
  --upstream <url> Upstream base URL (default: https://sub2api.atools.live)
55
+ --force-model <id> Force outbound model id (e.g. gpt-5.3-codex)
53
56
  --max-req-bytes <number> Compact requests larger than this threshold (default: 68000)
54
57
  --log-file <path> Log file (default: /tmp/openclaw/atools-compat-proxy.log)
55
58
  --retry-max <number> Retry attempts on 5xx for /responses (default: 6)
@@ -258,6 +258,11 @@ function upsertEnvLine(content, key, value) {
258
258
  return `${content.slice(0, afterServiceHeader + 1)}${line}\n${content.slice(afterServiceHeader + 1)}`;
259
259
  }
260
260
 
261
+ function removeEnvLine(content, key) {
262
+ const re = new RegExp(`^Environment=${escapeRegExp(key)}=.*\\n?`, 'gm');
263
+ return content.replace(re, '');
264
+ }
265
+
261
266
  function configureLinuxProxyService() {
262
267
  const servicePath = path.join(os.homedir(), '.config/systemd/user', PROXY_SERVICE);
263
268
  if (!fs.existsSync(servicePath)) {
@@ -266,6 +271,7 @@ function configureLinuxProxyService() {
266
271
  const original = fs.readFileSync(servicePath, 'utf8');
267
272
  let next = original;
268
273
  next = upsertEnvLine(next, 'SUB2API_UPSTREAM', PROXY_UPSTREAM);
274
+ next = removeEnvLine(next, 'SUB2API_COMPAT_FORCE_MODEL');
269
275
  next = upsertEnvLine(next, 'SUB2API_COMPAT_DROP_TOOLS_ON_COMPACT', '0');
270
276
  next = upsertEnvLine(next, 'SUB2API_COMPAT_PORT', String(PROXY_PORT));
271
277
  next = upsertEnvLine(next, 'SUB2API_COMPAT_LOG', PROXY_LOG_FILE);
package/lib/install.mjs CHANGED
@@ -6,6 +6,7 @@ import { fileURLToPath } from 'node:url';
6
6
 
7
7
  const DEFAULT_PROVIDER = 'sub2api';
8
8
  const DEFAULT_MODEL = 'gpt-5.3-codex';
9
+ const DEFAULT_FORCE_MODEL = '';
9
10
  const DEFAULT_PORT = 18888;
10
11
  const DEFAULT_UPSTREAM = 'https://sub2api.atools.live';
11
12
  const DEFAULT_LOG_FILE = path.join(os.tmpdir(), 'openclaw', 'atools-compat-proxy.log');
@@ -50,7 +51,7 @@ function defaultServiceId(name = DEFAULT_SERVICE_NAME) {
50
51
  return String(name || DEFAULT_SERVICE_NAME).replace(/\.service$/i, '');
51
52
  }
52
53
 
53
- function buildProxyServeArgs({ port, upstream, maxReqBytes, logFile }) {
54
+ function buildProxyServeArgs({ port, upstream, forceModel, maxReqBytes, logFile }) {
54
55
  const args = [
55
56
  'serve',
56
57
  '--port',
@@ -62,6 +63,9 @@ function buildProxyServeArgs({ port, upstream, maxReqBytes, logFile }) {
62
63
  '--max-req-bytes',
63
64
  String(maxReqBytes)
64
65
  ];
66
+ if (forceModel) {
67
+ args.push('--force-model', String(forceModel));
68
+ }
65
69
  return args;
66
70
  }
67
71
 
@@ -85,6 +89,7 @@ function parseArgs(argv) {
85
89
  openclawHome: process.env.OPENCLAW_HOME || path.join(os.homedir(), '.openclaw'),
86
90
  provider: DEFAULT_PROVIDER,
87
91
  model: DEFAULT_MODEL,
92
+ forceModel: DEFAULT_FORCE_MODEL,
88
93
  port: DEFAULT_PORT,
89
94
  upstream: DEFAULT_UPSTREAM,
90
95
  maxReqBytes: 68000,
@@ -102,6 +107,7 @@ function parseArgs(argv) {
102
107
  if (a === '--openclaw-home') out.openclawHome = next();
103
108
  else if (a === '--provider') out.provider = next();
104
109
  else if (a === '--model') out.model = next();
110
+ else if (a === '--force-model') out.forceModel = next();
105
111
  else if (a === '--port') out.port = Number(next());
106
112
  else if (a === '--upstream') out.upstream = next();
107
113
  else if (a === '--max-req-bytes') out.maxReqBytes = Number(next());
@@ -480,6 +486,7 @@ Options:
480
486
  --openclaw-home <path> Default: ~/.openclaw
481
487
  --provider <name> Default: sub2api
482
488
  --model <id> Default: gpt-5.3-codex
489
+ --force-model <id> Default: disabled (optional hard rewrite)
483
490
  --port <number> Default: 18888
484
491
  --upstream <url> Default: https://sub2api.atools.live
485
492
  --max-req-bytes <number> Default: 68000
@@ -506,6 +513,7 @@ export async function runInstall(rawArgs = process.argv.slice(2)) {
506
513
  const serveArgs = buildProxyServeArgs({
507
514
  port: args.port,
508
515
  upstream: args.upstream,
516
+ forceModel: args.forceModel,
509
517
  maxReqBytes: args.maxReqBytes,
510
518
  logFile: args.logFile
511
519
  });
@@ -513,6 +521,7 @@ export async function runInstall(rawArgs = process.argv.slice(2)) {
513
521
  info(`openclawHome=${args.openclawHome}`);
514
522
  info(`platform=${process.platform}`);
515
523
  info(`provider/model=${args.provider}/${args.model}`);
524
+ info(`proxy forceModel=${args.forceModel || '(disabled)'}`);
516
525
  info(`proxy=http://127.0.0.1:${args.port}/v1 -> ${args.upstream}`);
517
526
 
518
527
  patchOpenclawConfig(args.openclawHome, args.provider, args.model, args.port, args.dryRun);
@@ -9,6 +9,35 @@ const DEFAULT_USER_AGENT = 'codex_cli_rs/0.101.0 (Ubuntu 24.4.0; x86_64) Windows
9
9
  const DEFAULT_MAX_REQ_BYTES = 68000;
10
10
  const DEFAULT_DROP_TOOLS_ON_COMPACT = false;
11
11
  const DEFAULT_STRIP_PREVIOUS_RESPONSE_ID = false;
12
+ const DEFAULT_FORCE_MODEL = '';
13
+ const SUPPORTED_MODEL_IDS = new Set(['gpt-5.2', 'gpt-5.3-codex', 'gpt-5.4']);
14
+ const FORWARD_HEADER_ALLOWLIST = new Set([
15
+ 'authorization',
16
+ 'api-key',
17
+ 'x-api-key',
18
+ 'openai-organization',
19
+ 'openai-project'
20
+ ]);
21
+ const RESPONSES_KEY_ALLOWLIST = new Set([
22
+ 'model',
23
+ 'input',
24
+ 'instructions',
25
+ 'reasoning',
26
+ 'metadata',
27
+ 'temperature',
28
+ 'max_output_tokens',
29
+ 'top_p',
30
+ 'stream',
31
+ 'store',
32
+ 'previous_response_id',
33
+ 'tools',
34
+ 'tool_choice',
35
+ 'parallel_tool_calls',
36
+ 'text',
37
+ 'truncation',
38
+ 'max_tool_calls',
39
+ 'prompt_cache_key'
40
+ ]);
12
41
 
13
42
  function ensureParentDir(filePath) {
14
43
  const dir = path.dirname(filePath);
@@ -54,13 +83,20 @@ function normalizeContent(content) {
54
83
  return [toInputText('')];
55
84
  }
56
85
 
57
- function normalizeResponsesPayload(payload, { stripPreviousResponseId = DEFAULT_STRIP_PREVIOUS_RESPONSE_ID } = {}) {
86
+ function normalizeResponsesPayload(payload, {
87
+ stripPreviousResponseId = DEFAULT_STRIP_PREVIOUS_RESPONSE_ID,
88
+ forceModel = DEFAULT_FORCE_MODEL
89
+ } = {}) {
58
90
  if (!payload || typeof payload !== 'object') return payload;
59
91
  const out = { ...payload };
60
92
 
61
93
  if (stripPreviousResponseId && 'previous_response_id' in out) {
62
94
  delete out.previous_response_id;
63
95
  }
96
+ const normalizedModel = normalizeModelId(out.model, forceModel);
97
+ if (normalizedModel) {
98
+ out.model = normalizedModel;
99
+ }
64
100
 
65
101
  if (typeof out.input === 'string') {
66
102
  out.input = [{ role: 'user', content: normalizeContent(out.input) }];
@@ -75,6 +111,32 @@ function normalizeResponsesPayload(payload, { stripPreviousResponseId = DEFAULT_
75
111
  });
76
112
  }
77
113
 
114
+ return pruneResponsesPayload(out);
115
+ }
116
+
117
+ function normalizeModelId(modelId, forceModel = DEFAULT_FORCE_MODEL) {
118
+ const forced = String(forceModel || '').trim();
119
+ if (forced) return forced;
120
+ if (typeof modelId !== 'string') return '';
121
+
122
+ const raw = modelId.trim();
123
+ if (!raw) return raw;
124
+ if (SUPPORTED_MODEL_IDS.has(raw)) return raw;
125
+
126
+ const slashIndex = raw.lastIndexOf('/');
127
+ if (slashIndex < 0) return raw;
128
+ const tail = raw.slice(slashIndex + 1).trim();
129
+ if (SUPPORTED_MODEL_IDS.has(tail)) return tail;
130
+ return raw;
131
+ }
132
+
133
+ function pruneResponsesPayload(payload) {
134
+ const out = {};
135
+ for (const [key, value] of Object.entries(payload || {})) {
136
+ if (RESPONSES_KEY_ALLOWLIST.has(key)) {
137
+ out[key] = value;
138
+ }
139
+ }
78
140
  return out;
79
141
  }
80
142
 
@@ -154,6 +216,29 @@ function buildHeaders(baseHeaders, bodyLen) {
154
216
  return headers;
155
217
  }
156
218
 
219
+ function normalizeHeaderValue(value) {
220
+ if (Array.isArray(value)) return value.join(', ');
221
+ if (value == null) return '';
222
+ return String(value);
223
+ }
224
+
225
+ function buildCodexLikeHeaders(incomingHeaders, userAgent) {
226
+ const headers = {};
227
+ for (const [rawKey, rawValue] of Object.entries(incomingHeaders || {})) {
228
+ const key = String(rawKey || '').toLowerCase();
229
+ if (!FORWARD_HEADER_ALLOWLIST.has(key)) continue;
230
+ const value = normalizeHeaderValue(rawValue);
231
+ if (value) headers[key] = value;
232
+ }
233
+ headers.accept = 'application/json';
234
+ headers['content-type'] = 'application/json';
235
+ headers['user-agent'] = userAgent;
236
+ headers['x-stainless-lang'] = 'rust';
237
+ headers['x-stainless-package-name'] = 'codex-cli';
238
+ headers['x-stainless-package-version'] = '0.101.0';
239
+ return headers;
240
+ }
241
+
157
242
  export async function createProxyServer(options = {}) {
158
243
  const port = Number(options.port ?? process.env.SUB2API_COMPAT_PORT ?? DEFAULT_PORT);
159
244
  const upstream = options.upstream ?? process.env.SUB2API_UPSTREAM ?? DEFAULT_UPSTREAM;
@@ -166,6 +251,7 @@ export async function createProxyServer(options = {}) {
166
251
  ?? ['1', 'true', 'yes', 'on'].includes(String(process.env.SUB2API_COMPAT_DROP_TOOLS_ON_COMPACT || '').toLowerCase());
167
252
  const stripPreviousResponseId = options.stripPreviousResponseId
168
253
  ?? ['1', 'true', 'yes', 'on'].includes(String(process.env.SUB2API_COMPAT_STRIP_PREVIOUS_RESPONSE_ID || '').toLowerCase());
254
+ const forceModel = String(options.forceModel ?? process.env.SUB2API_COMPAT_FORCE_MODEL ?? '').trim();
169
255
 
170
256
  const server = http.createServer(async (req, res) => {
171
257
  const startAt = Date.now();
@@ -175,10 +261,7 @@ export async function createProxyServer(options = {}) {
175
261
  for await (const c of req) chunks.push(c);
176
262
  const rawBody = Buffer.concat(chunks);
177
263
 
178
- const baseHeaders = { ...req.headers };
179
- delete baseHeaders.host;
180
- delete baseHeaders.connection;
181
- baseHeaders['user-agent'] = userAgent;
264
+ const baseHeaders = buildCodexLikeHeaders(req.headers, userAgent);
182
265
 
183
266
  const isResponsesPath = req.method === 'POST' && url.pathname.endsWith('/responses');
184
267
  let body = rawBody;
@@ -189,7 +272,12 @@ export async function createProxyServer(options = {}) {
189
272
  if (isResponsesPath && rawBody.length > 0) {
190
273
  try {
191
274
  const parsed = JSON.parse(rawBody.toString('utf8'));
192
- const normalized = normalizeResponsesPayload(parsed, { stripPreviousResponseId });
275
+ const inputModel = typeof parsed.model === 'string' ? parsed.model : '';
276
+ const normalized = normalizeResponsesPayload(parsed, {
277
+ stripPreviousResponseId,
278
+ forceModel
279
+ });
280
+ const outputModel = typeof normalized.model === 'string' ? normalized.model : '';
193
281
  body = Buffer.from(JSON.stringify(normalized));
194
282
  if (body.length > maxReqBytes) {
195
283
  const compacted = compactResponsesPayload(normalized, { dropToolsOnCompact });
@@ -214,6 +302,13 @@ export async function createProxyServer(options = {}) {
214
302
  minimalBody = minimalBuf;
215
303
  }
216
304
  }
305
+ if (inputModel && outputModel && inputModel !== outputModel) {
306
+ appendLog(logFile, {
307
+ method: req.method,
308
+ path: url.pathname,
309
+ modelRewrite: { from: inputModel, to: outputModel }
310
+ });
311
+ }
217
312
  } catch {
218
313
  // Keep original body if parse fails.
219
314
  }
@@ -268,6 +363,9 @@ export async function createProxyServer(options = {}) {
268
363
  });
269
364
 
270
365
  const outBuffer = Buffer.from(await resp.arrayBuffer());
366
+ const upstreamError = resp.status >= 500
367
+ ? outBuffer.toString('utf8').replace(/\s+/g, ' ').slice(0, 240)
368
+ : '';
271
369
  appendLog(logFile, {
272
370
  method: req.method,
273
371
  path: url.pathname,
@@ -279,6 +377,7 @@ export async function createProxyServer(options = {}) {
279
377
  reqBytes: rawBody.length,
280
378
  upstreamReqBytes: requestBodies[Math.min(requestBodies.length - 1, attempts - 1)].length,
281
379
  respBytes: outBuffer.length,
380
+ ...(upstreamError ? { upstreamError } : {}),
282
381
  durationMs: Date.now() - startAt
283
382
  });
284
383
  res.end(outBuffer);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@autotask/atools-tool",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "ATools CLI for OpenClaw proxy compatibility and interactive model configuration",
5
5
  "type": "module",
6
6
  "license": "MIT",