@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 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`)
@@ -296,9 +296,22 @@ function isLinuxProxyServiceActive() {
296
296
 
297
297
  function startLinuxProxyService() {
298
298
  const reload = runSystemctl(['daemon-reload']);
299
- if (reload.status !== 0) return false;
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
- return start.status === 0;
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
- started = startLinuxProxyService();
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) return false;
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 false;
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 proxyReady = await ensureProxyReady();
446
- if (!proxyReady) {
447
- process.stdout.write('\n已取消:本地代理未就绪,未修改 OpenClaw 模型配置。\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 installLinuxService({ serviceName, content, dryRun }) {
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
- runSystemctl(['daemon-reload'], { dryRun });
370
- runSystemctl(['enable', '--now', serviceName], { dryRun });
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({ serviceName, content: linuxServiceContent, dryRun });
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') {
@@ -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
- resp = primaryResp;
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 = resp.status;
360
- resp.headers.forEach((value, key) => {
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
- const outBuffer = Buffer.from(await resp.arrayBuffer());
366
- const upstreamError = resp.status >= 500
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: resp.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
- res.end(outBuffer);
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, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@autotask/atools-tool",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "ATools CLI for OpenClaw proxy compatibility and interactive model configuration",
5
5
  "type": "module",
6
6
  "license": "MIT",