@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 +3 -4
- package/lib/config-openclaw.mjs +7 -4
- package/lib/install.mjs +2 -2
- package/lib/proxy-server.mjs +538 -14
- package/package.json +1 -1
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
|
|
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
|
|
77
|
+
atools-tool serve --port 18888 --upstream https://sub2api.atools.live
|
|
79
78
|
```
|
|
80
79
|
|
|
81
80
|
## Repo Install
|
package/lib/config-openclaw.mjs
CHANGED
|
@@ -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 =
|
|
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 = '
|
|
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:
|
|
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
|
package/lib/proxy-server.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import http from 'node:http';
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import crypto from 'node:crypto';
|
|
4
5
|
|
|
5
6
|
const DEFAULT_PORT = 18888;
|
|
6
7
|
const DEFAULT_UPSTREAM = 'https://sub2api.atools.live';
|
|
@@ -10,6 +11,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
|
-
|
|
69
|
-
|
|
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 =
|
|
187
|
-
|
|
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 (
|
|
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
|
-
|
|
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 =
|
|
285
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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, {
|