@autotask/atools-tool 0.1.6 → 0.1.8

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,14 +68,13 @@ 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 enables proxy model rewrite:
72
-
73
- - force outbound model to `gpt-5.3-codex` (for codex-only upstream compatibility)
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.
74
73
 
75
74
  ## Proxy Command
76
75
 
77
76
  ```bash
78
- atools-tool serve --port 18888 --upstream https://sub2api.atools.live --force-model gpt-5.3-codex
77
+ atools-tool serve --port 18888 --upstream https://sub2api.atools.live
79
78
  ```
80
79
 
81
80
  ## Repo Install
@@ -16,7 +16,6 @@ const PROXY_HOST = '127.0.0.1';
16
16
  const PROXY_PORT = 18888;
17
17
  const PROXY_BASE_URL = `http://${PROXY_HOST}:${PROXY_PORT}/v1`;
18
18
  const PROXY_UPSTREAM = 'https://sub2api.atools.live';
19
- const PROXY_FORCE_MODEL = 'gpt-5.3-codex';
20
19
  const PROXY_LOG_FILE = path.join(os.tmpdir(), 'openclaw', 'atools-compat-proxy.log');
21
20
  const PROXY_SERVICE = 'openclaw-atools-proxy.service';
22
21
 
@@ -259,6 +258,11 @@ function upsertEnvLine(content, key, value) {
259
258
  return `${content.slice(0, afterServiceHeader + 1)}${line}\n${content.slice(afterServiceHeader + 1)}`;
260
259
  }
261
260
 
261
+ function removeEnvLine(content, key) {
262
+ const re = new RegExp(`^Environment=${escapeRegExp(key)}=.*\\n?`, 'gm');
263
+ return content.replace(re, '');
264
+ }
265
+
262
266
  function configureLinuxProxyService() {
263
267
  const servicePath = path.join(os.homedir(), '.config/systemd/user', PROXY_SERVICE);
264
268
  if (!fs.existsSync(servicePath)) {
@@ -267,7 +271,7 @@ function configureLinuxProxyService() {
267
271
  const original = fs.readFileSync(servicePath, 'utf8');
268
272
  let next = original;
269
273
  next = upsertEnvLine(next, 'SUB2API_UPSTREAM', PROXY_UPSTREAM);
270
- next = upsertEnvLine(next, 'SUB2API_COMPAT_FORCE_MODEL', PROXY_FORCE_MODEL);
274
+ next = removeEnvLine(next, 'SUB2API_COMPAT_FORCE_MODEL');
271
275
  next = upsertEnvLine(next, 'SUB2API_COMPAT_DROP_TOOLS_ON_COMPACT', '0');
272
276
  next = upsertEnvLine(next, 'SUB2API_COMPAT_PORT', String(PROXY_PORT));
273
277
  next = upsertEnvLine(next, 'SUB2API_COMPAT_LOG', PROXY_LOG_FILE);
@@ -317,8 +321,7 @@ function startDetachedProxy() {
317
321
  stdio: 'ignore',
318
322
  env: {
319
323
  ...process.env,
320
- SUB2API_UPSTREAM: PROXY_UPSTREAM,
321
- SUB2API_COMPAT_FORCE_MODEL: PROXY_FORCE_MODEL
324
+ SUB2API_UPSTREAM: PROXY_UPSTREAM
322
325
  }
323
326
  }
324
327
  );
package/lib/install.mjs CHANGED
@@ -6,7 +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 = 'gpt-5.3-codex';
9
+ const DEFAULT_FORCE_MODEL = '';
10
10
  const DEFAULT_PORT = 18888;
11
11
  const DEFAULT_UPSTREAM = 'https://sub2api.atools.live';
12
12
  const DEFAULT_LOG_FILE = path.join(os.tmpdir(), 'openclaw', 'atools-compat-proxy.log');
@@ -486,7 +486,7 @@ Options:
486
486
  --openclaw-home <path> Default: ~/.openclaw
487
487
  --provider <name> Default: sub2api
488
488
  --model <id> Default: gpt-5.3-codex
489
- --force-model <id> Default: gpt-5.3-codex (proxy rewrites outbound model)
489
+ --force-model <id> Default: disabled (optional hard rewrite)
490
490
  --port <number> Default: 18888
491
491
  --upstream <url> Default: https://sub2api.atools.live
492
492
  --max-req-bytes <number> Default: 68000
@@ -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,37 @@ 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;
17
+ const SUPPORTED_MODEL_IDS = new Set(['gpt-5.2', 'gpt-5.3-codex', 'gpt-5.4']);
18
+ const FORWARD_HEADER_ALLOWLIST = new Set([
19
+ 'authorization',
20
+ 'api-key',
21
+ 'x-api-key',
22
+ 'openai-organization',
23
+ 'openai-project'
24
+ ]);
25
+ const RESPONSES_KEY_ALLOWLIST = new Set([
26
+ 'model',
27
+ 'input',
28
+ 'instructions',
29
+ 'reasoning',
30
+ 'metadata',
31
+ 'temperature',
32
+ 'max_output_tokens',
33
+ 'top_p',
34
+ 'stream',
35
+ 'store',
36
+ 'previous_response_id',
37
+ 'tools',
38
+ 'tool_choice',
39
+ 'parallel_tool_calls',
40
+ 'text',
41
+ 'truncation',
42
+ 'max_tool_calls',
43
+ 'prompt_cache_key'
44
+ ]);
13
45
 
14
46
  function ensureParentDir(filePath) {
15
47
  const dir = path.dirname(filePath);
@@ -65,8 +97,9 @@ function normalizeResponsesPayload(payload, {
65
97
  if (stripPreviousResponseId && 'previous_response_id' in out) {
66
98
  delete out.previous_response_id;
67
99
  }
68
- if (forceModel) {
69
- out.model = forceModel;
100
+ const normalizedModel = normalizeModelId(out.model, forceModel);
101
+ if (normalizedModel) {
102
+ out.model = normalizedModel;
70
103
  }
71
104
 
72
105
  if (typeof out.input === 'string') {
@@ -82,6 +115,41 @@ function normalizeResponsesPayload(payload, {
82
115
  });
83
116
  }
84
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
+
127
+ return pruneResponsesPayload(out);
128
+ }
129
+
130
+ function normalizeModelId(modelId, forceModel = DEFAULT_FORCE_MODEL) {
131
+ const forced = String(forceModel || '').trim();
132
+ if (forced) return forced;
133
+ if (typeof modelId !== 'string') return '';
134
+
135
+ const raw = modelId.trim();
136
+ if (!raw) return raw;
137
+ if (SUPPORTED_MODEL_IDS.has(raw)) return raw;
138
+
139
+ const slashIndex = raw.lastIndexOf('/');
140
+ if (slashIndex < 0) return raw;
141
+ const tail = raw.slice(slashIndex + 1).trim();
142
+ if (SUPPORTED_MODEL_IDS.has(tail)) return tail;
143
+ return raw;
144
+ }
145
+
146
+ function pruneResponsesPayload(payload) {
147
+ const out = {};
148
+ for (const [key, value] of Object.entries(payload || {})) {
149
+ if (RESPONSES_KEY_ALLOWLIST.has(key)) {
150
+ out[key] = value;
151
+ }
152
+ }
85
153
  return out;
86
154
  }
87
155
 
@@ -145,6 +213,236 @@ function sleep(ms) {
145
213
  return new Promise((resolve) => setTimeout(resolve, ms));
146
214
  }
147
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
+
148
446
  async function forward(url, req, headers, body) {
149
447
  return fetch(url, {
150
448
  method: req.method,
@@ -161,6 +459,29 @@ function buildHeaders(baseHeaders, bodyLen) {
161
459
  return headers;
162
460
  }
163
461
 
462
+ function normalizeHeaderValue(value) {
463
+ if (Array.isArray(value)) return value.join(', ');
464
+ if (value == null) return '';
465
+ return String(value);
466
+ }
467
+
468
+ function buildCodexLikeHeaders(incomingHeaders, userAgent) {
469
+ const headers = {};
470
+ for (const [rawKey, rawValue] of Object.entries(incomingHeaders || {})) {
471
+ const key = String(rawKey || '').toLowerCase();
472
+ if (!FORWARD_HEADER_ALLOWLIST.has(key)) continue;
473
+ const value = normalizeHeaderValue(rawValue);
474
+ if (value) headers[key] = value;
475
+ }
476
+ headers.accept = 'application/json';
477
+ headers['content-type'] = 'application/json';
478
+ headers['user-agent'] = userAgent;
479
+ headers['x-stainless-lang'] = 'rust';
480
+ headers['x-stainless-package-name'] = 'codex-cli';
481
+ headers['x-stainless-package-version'] = '0.101.0';
482
+ return headers;
483
+ }
484
+
164
485
  export async function createProxyServer(options = {}) {
165
486
  const port = Number(options.port ?? process.env.SUB2API_COMPAT_PORT ?? DEFAULT_PORT);
166
487
  const upstream = options.upstream ?? process.env.SUB2API_UPSTREAM ?? DEFAULT_UPSTREAM;
@@ -174,6 +495,15 @@ export async function createProxyServer(options = {}) {
174
495
  const stripPreviousResponseId = options.stripPreviousResponseId
175
496
  ?? ['1', 'true', 'yes', 'on'].includes(String(process.env.SUB2API_COMPAT_STRIP_PREVIOUS_RESPONSE_ID || '').toLowerCase());
176
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();
177
507
 
178
508
  const server = http.createServer(async (req, res) => {
179
509
  const startAt = Date.now();
@@ -183,25 +513,84 @@ export async function createProxyServer(options = {}) {
183
513
  for await (const c of req) chunks.push(c);
184
514
  const rawBody = Buffer.concat(chunks);
185
515
 
186
- const baseHeaders = { ...req.headers };
187
- delete baseHeaders.host;
188
- delete baseHeaders.connection;
189
- baseHeaders['user-agent'] = userAgent;
516
+ const baseHeaders = buildCodexLikeHeaders(req.headers, userAgent);
517
+ const incomingHeaders = toHeaderObject(req.headers, { includeAuth: debugIncludeAuth });
190
518
 
191
519
  const isResponsesPath = req.method === 'POST' && url.pathname.endsWith('/responses');
192
520
  let body = rawBody;
193
521
  let compactBody = null;
194
522
  let aggressiveBody = null;
195
523
  let minimalBody = null;
524
+ let requestedStream = false;
525
+ let rawBodyText = '';
526
+ let normalizedBodyText = '';
527
+ let conversationKey = '';
528
+ let promptContextKey = '';
529
+ let functionCallOutputIds = [];
196
530
 
197
531
  if (isResponsesPath && rawBody.length > 0) {
198
532
  try {
199
533
  const parsed = JSON.parse(rawBody.toString('utf8'));
534
+ rawBodyText = JSON.stringify(parsed);
200
535
  const inputModel = typeof parsed.model === 'string' ? parsed.model : '';
201
536
  const normalized = normalizeResponsesPayload(parsed, {
202
537
  stripPreviousResponseId,
203
538
  forceModel
204
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);
205
594
  const outputModel = typeof normalized.model === 'string' ? normalized.model : '';
206
595
  body = Buffer.from(JSON.stringify(normalized));
207
596
  if (body.length > maxReqBytes) {
@@ -227,7 +616,7 @@ export async function createProxyServer(options = {}) {
227
616
  minimalBody = minimalBuf;
228
617
  }
229
618
  }
230
- if (forceModel && inputModel && inputModel !== outputModel) {
619
+ if (inputModel && outputModel && inputModel !== outputModel) {
231
620
  appendLog(logFile, {
232
621
  method: req.method,
233
622
  path: url.pathname,
@@ -235,8 +624,11 @@ export async function createProxyServer(options = {}) {
235
624
  });
236
625
  }
237
626
  } catch {
627
+ rawBodyText = bufferToDebugText(rawBody, debugMaxBody);
238
628
  // Keep original body if parse fails.
239
629
  }
630
+ } else if (rawBody.length > 0) {
631
+ rawBodyText = bufferToDebugText(rawBody, debugMaxBody);
240
632
  }
241
633
 
242
634
  const requestBodies = [body];
@@ -255,11 +647,17 @@ export async function createProxyServer(options = {}) {
255
647
  requestBodies.push(minimalBody);
256
648
  }
257
649
 
258
- let resp;
259
650
  let attempts = 0;
260
651
  let primaryStatus = null;
261
652
  let compacted = false;
262
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 = [];
263
661
  const maxAttempts = isResponsesPath ? retryMax : 1;
264
662
  while (attempts < maxAttempts) {
265
663
  attempts += 1;
@@ -272,8 +670,42 @@ export async function createProxyServer(options = {}) {
272
670
  const primaryHeaders = isResponsesPath ? buildHeaders(baseHeaders, requestBody.length) : baseHeaders;
273
671
 
274
672
  const primaryResp = await forward(url, req, primaryHeaders, requestBody);
673
+ const attemptHeaders = toHeaderObject(primaryResp.headers, { includeAuth: debugIncludeAuth });
275
674
  primaryStatus = primaryResp.status;
276
- 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
+
277
709
  if (primaryResp.status < 500) break;
278
710
 
279
711
  if (attempts < maxAttempts) {
@@ -281,17 +713,91 @@ export async function createProxyServer(options = {}) {
281
713
  }
282
714
  }
283
715
 
284
- res.statusCode = resp.status;
285
- resp.headers.forEach((value, key) => {
716
+ res.statusCode = finalStatus;
717
+ Object.entries(finalHeaders).forEach(([key, value]) => {
286
718
  if (key.toLowerCase() === 'transfer-encoding') return;
287
719
  res.setHeader(key, value);
288
720
  });
289
721
 
290
- const outBuffer = Buffer.from(await resp.arrayBuffer());
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
772
+ ? outBuffer.toString('utf8').replace(/\s+/g, ' ').slice(0, 240)
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
+ }
291
797
  appendLog(logFile, {
292
798
  method: req.method,
293
799
  path: url.pathname,
294
- status: resp.status,
800
+ status: finalStatus,
295
801
  attempts,
296
802
  primaryStatus,
297
803
  compacted,
@@ -299,9 +805,27 @@ export async function createProxyServer(options = {}) {
299
805
  reqBytes: rawBody.length,
300
806
  upstreamReqBytes: requestBodies[Math.min(requestBodies.length - 1, attempts - 1)].length,
301
807
  respBytes: outBuffer.length,
808
+ ...(upstreamError ? { upstreamError } : {}),
302
809
  durationMs: Date.now() - startAt
303
810
  });
304
- 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
+ }
305
829
  } catch (err) {
306
830
  const message = String(err?.message || err);
307
831
  appendLog(logFile, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@autotask/atools-tool",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "ATools CLI for OpenClaw proxy compatibility and interactive model configuration",
5
5
  "type": "module",
6
6
  "license": "MIT",