codex-copilot-dx 0.4.4 → 0.4.5

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
@@ -28,7 +28,7 @@ npx codex-copilot-dx@latest
28
28
  On first run, it will:
29
29
  1. Authenticate with GitHub via device flow (if needed)
30
30
  2. Print the local package version and warn when a newer npm release is available
31
- 3. Start the adapter (port `2026`)
31
+ 3. Start the adapter on loopback (`127.0.0.1:2026`)
32
32
  4. Configure Codex (`~/.codex/config.toml`) to use the adapter, including stale shell env base URLs if present
33
33
  5. Configure Claude Code (`~/.claude/settings.json`) to use the adapter; it creates the file when missing, otherwise backs up `settings.json.bak` before updating the local API env keys
34
34
  6. Launch Codex Desktop
@@ -43,10 +43,15 @@ Environment variables:
43
43
 
44
44
  | Variable | Default | Description |
45
45
  |----------|---------|-------------|
46
+ | `ADAPTER_HOST` | `127.0.0.1` | Host for the adapter; set `0.0.0.0` only if you knowingly expose it on a trusted network |
46
47
  | `ADAPTER_PORT` | `2026` | Port for the adapter |
48
+ | `CCDX_MAX_BODY_BYTES` | `134217728` | Maximum compressed/raw request body size |
49
+ | `CCDX_MAX_DECODED_BODY_BYTES` | `268435456` | Maximum decoded request body size after decompression |
50
+ | `CCDX_UPSTREAM_TIMEOUT_MS` | `120000` | Timeout for non-streaming upstream Copilot requests |
47
51
  | `CCDX_IMG_MAX_DIM` | `2048` | Max long edge in pixels for image downscaling |
48
52
  | `CCDX_IMG_QUALITY` | `85` | WebP quality used when re-encoding images |
49
53
  | `CCDX_IMG_MIN_BYTES` | `100000` | Images smaller than this are left untouched |
54
+ | `CCDX_IMG_CONCURRENCY` | `4` | Concurrent image optimization tasks; values above `12` are capped at `12` |
50
55
  | `CCDX_DISABLE_IMG_OPT` | unset | Set to `1` to disable image optimization |
51
56
  | `CCDX_USAGE_PATH` | `~/.local/share/codex-copilot-dx/usage.jsonl` | Local JSONL token usage log |
52
57
  | `CCDX_DISABLE_USAGE` | unset | Set to `1` to disable usage logging |
package/bin/cli.mjs CHANGED
@@ -10,9 +10,14 @@ import { printUsageSummary } from "../src/usage.mjs";
10
10
  import { checkForUpdate, localPackageVersion } from "../src/version.mjs";
11
11
 
12
12
  const ADAPTER_PORT = parseInt(process.env.ADAPTER_PORT || "2026");
13
+ const ADAPTER_HOST = process.env.ADAPTER_HOST || "127.0.0.1";
13
14
  const LOCAL_VERSION = localPackageVersion();
14
15
  const command = process.argv[2];
15
16
 
17
+ function isLoopbackHost(host) {
18
+ return ["127.0.0.1", "localhost", "::1"].includes(String(host).toLowerCase());
19
+ }
20
+
16
21
  if (command === "--version" || command === "-v" || command === "version") {
17
22
  console.log(`codex-copilot-dx v${LOCAL_VERSION}`);
18
23
  process.exit(0);
@@ -41,8 +46,12 @@ try {
41
46
  // Refresh the VS Code version in the background; fallback is non-blocking.
42
47
  refreshVSCodeVersion();
43
48
 
49
+ if (!isLoopbackHost(ADAPTER_HOST)) {
50
+ console.log(status("warn", `ADAPTER_HOST=${ADAPTER_HOST} exposes the adapter beyond loopback. Use only on trusted networks.`));
51
+ }
52
+
44
53
  // Start the in-process adapter.
45
- await startAdapter(ADAPTER_PORT);
54
+ await startAdapter(ADAPTER_PORT, ADAPTER_HOST);
46
55
 
47
56
  // Point Codex and Claude Code at the adapter.
48
57
  ensureCodexConfig(ADAPTER_PORT);
@@ -54,7 +63,7 @@ try {
54
63
  console.log(`
55
64
  ${status("ok", "Ready. Codex and Claude Code are using your GitHub Copilot subscription.")}
56
65
 
57
- Adapter: http://localhost:${ADAPTER_PORT}
66
+ Adapter: http://${ADAPTER_HOST}:${ADAPTER_PORT}
58
67
 
59
68
  Press Ctrl+C to stop.
60
69
  `);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-copilot-dx",
3
- "version": "0.4.4",
3
+ "version": "0.4.5",
4
4
  "description": "Use Codex Desktop app with your GitHub Copilot subscription",
5
5
  "type": "module",
6
6
  "bin": {
package/src/adapter.mjs CHANGED
@@ -21,11 +21,23 @@ const RESPONSES_ONLY = new Set([
21
21
 
22
22
  const RESPONSE_HISTORY_LIMIT = 64;
23
23
  const responseHistories = new Map();
24
+ const DEFAULT_MAX_BODY_BYTES = 128 * 1024 * 1024;
25
+ const DEFAULT_MAX_DECODED_BODY_BYTES = 256 * 1024 * 1024;
26
+ const DEFAULT_UPSTREAM_TIMEOUT_MS = 120 * 1000;
24
27
  const gunzipAsync = promisify(zlib.gunzip);
25
28
  const inflateAsync = promisify(zlib.inflate);
26
29
  const brotliDecompressAsync = promisify(zlib.brotliDecompress);
27
30
  const zstdDecompressAsync = zlib.zstdDecompress ? promisify(zlib.zstdDecompress) : null;
28
31
 
32
+ function positiveInt(value, fallback) {
33
+ const n = Number.parseInt(value, 10);
34
+ return Number.isFinite(n) && n > 0 ? n : fallback;
35
+ }
36
+
37
+ const MAX_BODY_BYTES = positiveInt(process.env.CCDX_MAX_BODY_BYTES, DEFAULT_MAX_BODY_BYTES);
38
+ const MAX_DECODED_BODY_BYTES = positiveInt(process.env.CCDX_MAX_DECODED_BODY_BYTES, DEFAULT_MAX_DECODED_BODY_BYTES);
39
+ const UPSTREAM_TIMEOUT_MS = positiveInt(process.env.CCDX_UPSTREAM_TIMEOUT_MS, DEFAULT_UPSTREAM_TIMEOUT_MS);
40
+
29
41
  export function requestPath(reqUrl) {
30
42
  return new URL(reqUrl || "/", "http://localhost").pathname;
31
43
  }
@@ -34,15 +46,45 @@ function cloneJson(value) {
34
46
  return value === undefined ? undefined : JSON.parse(JSON.stringify(value));
35
47
  }
36
48
 
37
- async function readRequestBuffer(req) {
49
+ function httpError(message, statusCode) {
50
+ const err = new Error(message);
51
+ err.statusCode = statusCode;
52
+ return err;
53
+ }
54
+
55
+ function payloadTooLarge(kind, maxBytes) {
56
+ return httpError(`${kind} request body exceeds ${maxBytes} bytes`, 413);
57
+ }
58
+
59
+ async function readRequestBuffer(req, maxBytes) {
60
+ const contentLength = Number.parseInt(req.headers?.["content-length"] || "", 10);
61
+ if (Number.isFinite(contentLength) && contentLength > maxBytes) {
62
+ throw payloadTooLarge("Raw", maxBytes);
63
+ }
64
+
38
65
  const chunks = [];
66
+ let total = 0;
39
67
  for await (const chunk of req) {
40
- chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
68
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
69
+ total += buf.length;
70
+ if (total > maxBytes) throw payloadTooLarge("Raw", maxBytes);
71
+ chunks.push(buf);
41
72
  }
42
73
  return Buffer.concat(chunks);
43
74
  }
44
75
 
45
- async function decodeRequestBuffer(buffer, contentEncoding) {
76
+ async function decompressBody(decompress, buffer, maxBytes) {
77
+ try {
78
+ return await decompress(buffer, { maxOutputLength: maxBytes });
79
+ } catch (e) {
80
+ if (e?.code === "ERR_BUFFER_TOO_LARGE" || /maxOutputLength/i.test(e?.message || "")) {
81
+ throw payloadTooLarge("Decoded", maxBytes);
82
+ }
83
+ throw e;
84
+ }
85
+ }
86
+
87
+ async function decodeRequestBuffer(buffer, contentEncoding, maxBytes) {
46
88
  const encodings = String(contentEncoding || "identity")
47
89
  .split(",")
48
90
  .map((v) => v.trim().toLowerCase())
@@ -52,27 +94,79 @@ async function decodeRequestBuffer(buffer, contentEncoding) {
52
94
  for (const encoding of encodings.reverse()) {
53
95
  if (encoding === "identity") continue;
54
96
  if (encoding === "gzip" || encoding === "x-gzip") {
55
- decoded = await gunzipAsync(decoded);
97
+ decoded = await decompressBody(gunzipAsync, decoded, maxBytes);
56
98
  } else if (encoding === "deflate") {
57
- decoded = await inflateAsync(decoded);
99
+ decoded = await decompressBody(inflateAsync, decoded, maxBytes);
58
100
  } else if (encoding === "br") {
59
- decoded = await brotliDecompressAsync(decoded);
101
+ decoded = await decompressBody(brotliDecompressAsync, decoded, maxBytes);
60
102
  } else if (encoding === "zstd") {
61
103
  if (!zstdDecompressAsync) throw new Error("Unsupported Content-Encoding: zstd");
62
- decoded = await zstdDecompressAsync(decoded);
104
+ decoded = await decompressBody(zstdDecompressAsync, decoded, maxBytes);
63
105
  } else {
64
106
  throw new Error(`Unsupported Content-Encoding: ${encoding}`);
65
107
  }
108
+ if (decoded.length > maxBytes) throw payloadTooLarge("Decoded", maxBytes);
66
109
  }
110
+ if (decoded.length > maxBytes) throw payloadTooLarge("Decoded", maxBytes);
67
111
  return decoded;
68
112
  }
69
113
 
70
- export async function readJsonBody(req) {
71
- const buffer = await readRequestBuffer(req);
72
- const decoded = await decodeRequestBuffer(buffer, req.headers?.["content-encoding"]);
114
+ export async function readJsonBody(req, {
115
+ maxBodyBytes = MAX_BODY_BYTES,
116
+ maxDecodedBodyBytes = MAX_DECODED_BODY_BYTES,
117
+ } = {}) {
118
+ const buffer = await readRequestBuffer(req, maxBodyBytes);
119
+ const decoded = await decodeRequestBuffer(buffer, req.headers?.["content-encoding"], maxDecodedBodyBytes);
73
120
  return JSON.parse(decoded.toString("utf8"));
74
121
  }
75
122
 
123
+ function sendJsonError(res, err, fallbackStatus = 400) {
124
+ if (!res.headersSent) res.writeHead(err?.statusCode || fallbackStatus, { "Content-Type": "application/json" });
125
+ res.end(JSON.stringify({ error: err?.message || "Request failed" }));
126
+ }
127
+
128
+ export function writeOrDrain(res, chunk) {
129
+ if (res.destroyed || res.writableEnded) return Promise.resolve(false);
130
+ if (res.write(chunk)) return Promise.resolve(true);
131
+ return new Promise((resolve, reject) => {
132
+ const cleanup = () => {
133
+ res.off("drain", onDrain);
134
+ res.off("error", onError);
135
+ res.off("close", onClose);
136
+ };
137
+ const onDrain = () => { cleanup(); resolve(true); };
138
+ const onClose = () => { cleanup(); resolve(false); };
139
+ const onError = (err) => { cleanup(); reject(err); };
140
+ res.once("drain", onDrain);
141
+ res.once("close", onClose);
142
+ res.once("error", onError);
143
+ });
144
+ }
145
+
146
+ function createRequestAbort(req, res) {
147
+ const controller = new AbortController();
148
+ let timer = null;
149
+ let cleaned = false;
150
+ const abort = () => {
151
+ if (!cleaned && !controller.signal.aborted) controller.abort();
152
+ };
153
+ req.on("aborted", abort);
154
+ res.on("close", abort);
155
+ return {
156
+ signal: controller.signal,
157
+ setTimeout(ms) {
158
+ if (timer) clearTimeout(timer);
159
+ if (ms > 0) timer = setTimeout(abort, ms);
160
+ },
161
+ cleanup() {
162
+ cleaned = true;
163
+ if (timer) clearTimeout(timer);
164
+ req.off("aborted", abort);
165
+ res.off("close", abort);
166
+ },
167
+ };
168
+ }
169
+
76
170
  function responsesInputItems(input) {
77
171
  if (input === undefined || input === null) return [];
78
172
  if (typeof input === "string") {
@@ -161,8 +255,8 @@ function readSseEvents(buffer, onData) {
161
255
  }
162
256
  }
163
257
 
164
- async function proxyCopilotResponses(reqContext, req, res, upstream = copilotResponses) {
165
- const resp = await upstream(reqContext.body);
258
+ async function proxyCopilotResponses(reqContext, req, res, upstream = copilotResponses, options = {}) {
259
+ const resp = await upstream(reqContext.body, { signal: options.signal });
166
260
 
167
261
  if (reqContext.body.stream) {
168
262
  res.writeHead(resp.status, {
@@ -183,7 +277,7 @@ async function proxyCopilotResponses(reqContext, req, res, upstream = copilotRes
183
277
  }
184
278
  buffer += decoder.decode(value, { stream: true });
185
279
  buffer = readSseEvents(buffer, (data) => storeCompletedResponseFromSse(reqContext, data));
186
- res.write(value);
280
+ await writeOrDrain(res, value);
187
281
  }
188
282
  } else {
189
283
  const data = await resp.text();
@@ -260,11 +354,11 @@ function chatToResponses(chatResp, model) {
260
354
  return { id, object: "response", status: "completed", model: chatResp.model || model, output, usage: chatResp.usage ? { input_tokens: chatResp.usage.prompt_tokens || 0, output_tokens: chatResp.usage.completion_tokens || 0, total_tokens: chatResp.usage.total_tokens || 0 } : undefined };
261
355
  }
262
356
 
263
- async function forwardToChat(chatReq, emitEvent, onDone, onError) {
357
+ async function forwardToChat(chatReq, emitEvent, onDone, onError, options = {}) {
264
358
  delete chatReq.max_tokens;
265
359
  let resp;
266
360
  try {
267
- resp = await chatCompletions({ ...chatReq, stream: true });
361
+ resp = await chatCompletions({ ...chatReq, stream: true }, { signal: options.signal });
268
362
  } catch (e) {
269
363
  onError(502, e.message);
270
364
  return;
@@ -276,37 +370,37 @@ async function forwardToChat(chatReq, emitEvent, onDone, onError) {
276
370
  const respId = `resp_${uid()}`, itemId = `item_${uid()}`;
277
371
  let actualModel = "unknown", fullText = "", toolCalls = {}, hasToolCalls = false;
278
372
 
279
- emitEvent("response.created", { response: { id: respId, object: "response", status: "in_progress", model: actualModel, output: [] } });
280
- emitEvent("response.output_item.added", { output_index: 0, item: { type: "message", id: itemId, role: "assistant", status: "in_progress", content: [] } });
281
- emitEvent("response.content_part.added", { output_index: 0, content_index: 0, part: { type: "output_text", text: "" } });
373
+ await emitEvent("response.created", { response: { id: respId, object: "response", status: "in_progress", model: actualModel, output: [] } });
374
+ await emitEvent("response.output_item.added", { output_index: 0, item: { type: "message", id: itemId, role: "assistant", status: "in_progress", content: [] } });
375
+ await emitEvent("response.content_part.added", { output_index: 0, content_index: 0, part: { type: "output_text", text: "" } });
282
376
 
283
- const emitCompleted = () => {
377
+ const emitCompleted = async () => {
284
378
  if (!hasToolCalls) {
285
- emitEvent("response.output_text.done", { output_index: 0, content_index: 0, text: fullText });
286
- emitEvent("response.output_item.done", { output_index: 0, item: { type: "message", id: itemId, role: "assistant", status: "completed", content: [{ type: "output_text", text: fullText }] } });
379
+ await emitEvent("response.output_text.done", { output_index: 0, content_index: 0, text: fullText });
380
+ await emitEvent("response.output_item.done", { output_index: 0, item: { type: "message", id: itemId, role: "assistant", status: "completed", content: [{ type: "output_text", text: fullText }] } });
287
381
  }
288
382
  const output = hasToolCalls
289
383
  ? Object.entries(toolCalls).map(([id, tc]) => ({ type: "function_call", id, call_id: id, name: tc.name, arguments: tc.arguments, status: "completed" }))
290
384
  : [{ type: "message", id: itemId, role: "assistant", status: "completed", content: [{ type: "output_text", text: fullText }] }];
291
- emitEvent("response.completed", { response: { id: respId, object: "response", status: "completed", model: actualModel, output, usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 } } });
385
+ await emitEvent("response.completed", { response: { id: respId, object: "response", status: "completed", model: actualModel, output, usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 } } });
292
386
  };
293
387
 
294
388
  try {
295
389
  for await (const line of webStreamLines(resp)) {
296
390
  if (!line.startsWith("data: ")) continue;
297
391
  const data = line.slice(6).trim();
298
- if (data === "[DONE]") { emitCompleted(); onDone(); return; }
392
+ if (data === "[DONE]") { await emitCompleted(); onDone(); return; }
299
393
  let parsed;
300
394
  try { parsed = JSON.parse(data); } catch { continue; }
301
395
  if (parsed.model) actualModel = parsed.model;
302
396
  const delta = parsed.choices?.[0]?.delta;
303
397
  if (!delta) continue;
304
- if (delta.content) { fullText += delta.content; emitEvent("response.output_text.delta", { output_index: 0, content_index: 0, delta: delta.content }); }
398
+ if (delta.content) { fullText += delta.content; await emitEvent("response.output_text.delta", { output_index: 0, content_index: 0, delta: delta.content }); }
305
399
  if (delta.tool_calls) {
306
400
  hasToolCalls = true;
307
401
  for (const tc of delta.tool_calls) {
308
402
  const id = tc.id || Object.keys(toolCalls)[tc.index] || `call_${tc.index}`;
309
- if (!toolCalls[id]) { toolCalls[id] = { name: "", arguments: "" }; emitEvent("response.output_item.added", { output_index: Object.keys(toolCalls).length - 1, item: { type: "function_call", id, call_id: id, name: tc.function?.name || "", arguments: "", status: "in_progress" } }); }
403
+ if (!toolCalls[id]) { toolCalls[id] = { name: "", arguments: "" }; await emitEvent("response.output_item.added", { output_index: Object.keys(toolCalls).length - 1, item: { type: "function_call", id, call_id: id, name: tc.function?.name || "", arguments: "", status: "in_progress" } }); }
310
404
  if (tc.function?.name) toolCalls[id].name += tc.function.name;
311
405
  if (tc.function?.arguments) toolCalls[id].arguments += tc.function.arguments;
312
406
  }
@@ -316,42 +410,44 @@ async function forwardToChat(chatReq, emitEvent, onDone, onError) {
316
410
  onError(500, e?.message || "upstream stream error");
317
411
  return;
318
412
  }
319
- emitCompleted();
413
+ await emitCompleted();
320
414
  onDone();
321
415
  }
322
416
 
323
417
  // Public server entry point.
324
418
 
325
- export function startAdapter(port = 2026) {
419
+ export function startAdapter(port = 2026, host = "127.0.0.1") {
326
420
  const server = http.createServer((req, res) => {
327
421
  const pathname = requestPath(req.url);
328
422
 
329
423
  if (req.method === "POST" && pathname === "/v1/responses") {
330
424
  (async () => {
425
+ const abort = createRequestAbort(req, res);
331
426
  try {
332
427
  const parsed = await readJsonBody(req);
428
+ if (!parsed.stream) abort.setTimeout(UPSTREAM_TIMEOUT_MS);
333
429
  const prepared = prepareResponsesRequest(parsed);
334
430
  prepared.surface = "responses";
335
431
  const model = parsed.model || "unknown";
336
432
  console.log(status("info", `responses model=${model} stream=${!!parsed.stream}`));
337
433
  if (RESPONSES_ONLY.has(model)) {
338
- await proxyCopilotResponses(prepared, req, res);
434
+ await proxyCopilotResponses(prepared, req, res, copilotResponses, { signal: abort.signal });
339
435
  } else {
340
436
  const chatReq = responsesToChat(prepared.body);
341
437
  if (parsed.stream) {
342
- forwardToChat(chatReq, (event, data) => {
438
+ await forwardToChat(chatReq, async (event, data) => {
343
439
  if (!res.headersSent) res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" });
344
- res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
440
+ await writeOrDrain(res, `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
345
441
  if (event === "response.completed") {
346
442
  rememberResponseHistory(prepared, data.response);
347
443
  recordResponsesUsage({ surface: prepared.surface, mode: "stream", model, response: data.response, event: data });
348
444
  }
349
- }, () => { if (!res.writableEnded) res.end(); }, (status, errMsg) => { if (!res.headersSent) res.writeHead(status || 500); res.end(errMsg); });
445
+ }, () => { if (!res.writableEnded) res.end(); }, (status, errMsg) => { if (!res.headersSent) res.writeHead(status || 500); res.end(errMsg); }, { signal: abort.signal });
350
446
  } else {
351
447
  chatReq.stream = false;
352
448
  delete chatReq.max_tokens;
353
449
  try {
354
- const upstream = await chatCompletions({ ...chatReq, stream: false });
450
+ const upstream = await chatCompletions({ ...chatReq, stream: false }, { signal: abort.signal });
355
451
  const data = await upstream.text();
356
452
  const resp = chatToResponses(JSON.parse(data), model);
357
453
  rememberResponseHistory(prepared, resp);
@@ -366,8 +462,9 @@ export function startAdapter(port = 2026) {
366
462
  }
367
463
  } catch (e) {
368
464
  console.error(status("err", `Responses request failed: ${e.message}`));
369
- if (!res.headersSent) res.writeHead(400);
370
- res.end(JSON.stringify({ error: e.message }));
465
+ sendJsonError(res, e);
466
+ } finally {
467
+ abort.cleanup();
371
468
  }
372
469
  })();
373
470
  return;
@@ -375,38 +472,46 @@ export function startAdapter(port = 2026) {
375
472
 
376
473
  if (req.method === "POST" && pathname === "/v1/responses/compact") {
377
474
  (async () => {
475
+ const abort = createRequestAbort(req, res);
378
476
  try {
379
477
  const parsed = await readJsonBody(req);
478
+ if (!parsed.stream) abort.setTimeout(UPSTREAM_TIMEOUT_MS);
380
479
  const prepared = prepareResponsesRequest(parsed);
381
480
  prepared.surface = "responses_compact";
382
481
  const model = parsed.model || "unknown";
383
482
  console.log(status("info", `responses compact model=${model} stream=${!!parsed.stream}`));
384
- await proxyCopilotResponses(prepared, req, res, copilotResponsesCompact);
483
+ await proxyCopilotResponses(prepared, req, res, copilotResponsesCompact, { signal: abort.signal });
385
484
  } catch (e) {
386
485
  console.error(status("err", `Responses compact request failed: ${e.message}`));
387
- if (!res.headersSent) res.writeHead(400);
388
- res.end(JSON.stringify({ error: e.message }));
486
+ sendJsonError(res, e);
487
+ } finally {
488
+ abort.cleanup();
389
489
  }
390
490
  })();
391
491
  return;
392
492
  }
393
493
 
394
494
  if (req.method === "GET" && pathname === "/v1/models") {
395
- listModels()
495
+ const abort = createRequestAbort(req, res);
496
+ abort.setTimeout(UPSTREAM_TIMEOUT_MS);
497
+ listModels({ signal: abort.signal })
396
498
  .then(({ status, body }) => { res.writeHead(status, { "Content-Type": "application/json" }); res.end(body); })
397
- .catch((e) => { if (!res.headersSent) res.writeHead(502); res.end(JSON.stringify({ error: e.message })); });
499
+ .catch((e) => { if (!res.headersSent) res.writeHead(502); res.end(JSON.stringify({ error: e.message })); })
500
+ .finally(() => abort.cleanup());
398
501
  return;
399
502
  }
400
503
 
401
504
  if (req.method === "POST" && pathname === "/v1/messages/count_tokens") {
402
505
  (async () => {
506
+ const abort = createRequestAbort(req, res);
403
507
  try {
404
508
  const parsed = await readJsonBody(req);
405
509
  res.writeHead(200, { "Content-Type": "application/json" });
406
510
  res.end(JSON.stringify(countTokens(parsed)));
407
511
  } catch (e) {
408
- if (!res.headersSent) res.writeHead(400);
409
- res.end(JSON.stringify({ error: e.message }));
512
+ sendJsonError(res, e);
513
+ } finally {
514
+ abort.cleanup();
410
515
  }
411
516
  })();
412
517
  return;
@@ -414,13 +519,15 @@ export function startAdapter(port = 2026) {
414
519
 
415
520
  if (req.method === "POST" && pathname === "/v1/messages") {
416
521
  (async () => {
522
+ const abort = createRequestAbort(req, res);
417
523
  try {
418
524
  const parsed = await readJsonBody(req);
525
+ if (!parsed.stream) abort.setTimeout(UPSTREAM_TIMEOUT_MS);
419
526
  const model = parsed.model || "unknown";
420
527
  console.log(status("info", `messages model=${model} stream=${!!parsed.stream}`));
421
528
  const chatReq = anthropicToChat(parsed);
422
529
  if (parsed.stream) {
423
- const upstream = await chatCompletions({ ...chatReq, stream: true });
530
+ const upstream = await chatCompletions({ ...chatReq, stream: true }, { signal: abort.signal });
424
531
  if (!upstream.ok) {
425
532
  if (!res.headersSent) res.writeHead(upstream.status);
426
533
  res.end(await upstream.text());
@@ -431,17 +538,17 @@ export function startAdapter(port = 2026) {
431
538
  let usage;
432
539
  await streamAnthropicFromLines(
433
540
  webStreamLines(upstream),
434
- (event, data) => {
541
+ async (event, data) => {
435
542
  if (event === "message_start") messageId = data.message?.id;
436
543
  if (event === "message_delta") usage = data.usage;
437
- res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
544
+ await writeOrDrain(res, `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
438
545
  },
439
546
  model,
440
547
  );
441
548
  recordAnthropicUsage({ surface: "messages", mode: "stream", model, responseId: messageId, usage });
442
549
  if (!res.writableEnded) res.end();
443
550
  } else {
444
- const upstream = await chatCompletions({ ...chatReq, stream: false });
551
+ const upstream = await chatCompletions({ ...chatReq, stream: false }, { signal: abort.signal });
445
552
  const data = await upstream.text();
446
553
  const anthropicMsg = chatToAnthropic(JSON.parse(data), model);
447
554
  recordAnthropicUsage({ surface: "messages", mode: "json", model: anthropicMsg.model, responseId: anthropicMsg.id, usage: anthropicMsg.usage });
@@ -450,8 +557,9 @@ export function startAdapter(port = 2026) {
450
557
  }
451
558
  } catch (e) {
452
559
  console.error(status("err", `Messages request failed: ${e.message}`));
453
- if (!res.headersSent) res.writeHead(400);
454
- res.end(JSON.stringify({ error: e.message }));
560
+ sendJsonError(res, e);
561
+ } finally {
562
+ abort.cleanup();
455
563
  }
456
564
  })();
457
565
  return;
@@ -472,8 +580,9 @@ export function startAdapter(port = 2026) {
472
580
  });
473
581
 
474
582
  return new Promise((resolve) => {
475
- server.listen(port, () => {
476
- console.log(status("ok", `Adapter listening on http://localhost:${port}`));
583
+ server.listen(port, host, () => {
584
+ const actualPort = server.address()?.port || port;
585
+ console.log(status("ok", `Adapter listening on http://${host}:${actualPort}`));
477
586
  resolve(server);
478
587
  });
479
588
  });
package/src/anthropic.mjs CHANGED
@@ -163,26 +163,26 @@ export async function streamAnthropicFromLines(lineIterator, emit, model) {
163
163
  let sawToolUse = false;
164
164
  let outputTokens = 0;
165
165
 
166
- const ensureStart = () => {
166
+ const ensureStart = async () => {
167
167
  if (started) return;
168
168
  started = true;
169
- emit("message_start", { type: "message_start", message: {
169
+ await emit("message_start", { type: "message_start", message: {
170
170
  id: msgId, type: "message", role: "assistant", model: actualModel,
171
171
  content: [], stop_reason: null, stop_sequence: null,
172
172
  usage: { input_tokens: 0, output_tokens: 0 },
173
173
  } });
174
174
  };
175
175
 
176
- const openText = () => {
176
+ const openText = async () => {
177
177
  if (textOpen) return;
178
178
  blockIndex += 1;
179
179
  textOpen = true;
180
- emit("content_block_start", { type: "content_block_start", index: blockIndex,
180
+ await emit("content_block_start", { type: "content_block_start", index: blockIndex,
181
181
  content_block: { type: "text", text: "" } });
182
182
  };
183
- const closeText = () => {
183
+ const closeText = async () => {
184
184
  if (!textOpen) return;
185
- emit("content_block_stop", { type: "content_block_stop", index: blockIndex });
185
+ await emit("content_block_stop", { type: "content_block_stop", index: blockIndex });
186
186
  textOpen = false;
187
187
  };
188
188
 
@@ -197,27 +197,27 @@ export async function streamAnthropicFromLines(lineIterator, emit, model) {
197
197
  const choice = parsed.choices?.[0];
198
198
  if (!choice) continue;
199
199
  const delta = choice.delta || {};
200
- ensureStart();
200
+ await ensureStart();
201
201
 
202
202
  if (delta.content && !sawToolUse) {
203
- openText();
204
- emit("content_block_delta", { type: "content_block_delta", index: blockIndex,
203
+ await openText();
204
+ await emit("content_block_delta", { type: "content_block_delta", index: blockIndex,
205
205
  delta: { type: "text_delta", text: delta.content } });
206
206
  }
207
207
 
208
208
  if (Array.isArray(delta.tool_calls)) {
209
209
  sawToolUse = true;
210
- closeText();
210
+ await closeText();
211
211
  for (const tc of delta.tool_calls) {
212
212
  const oi = tc.index ?? 0;
213
213
  if (!toolBlocks[oi]) {
214
214
  blockIndex += 1;
215
215
  toolBlocks[oi] = { anthropicIndex: blockIndex };
216
- emit("content_block_start", { type: "content_block_start", index: blockIndex,
216
+ await emit("content_block_start", { type: "content_block_start", index: blockIndex,
217
217
  content_block: { type: "tool_use", id: tc.id || `tu_${uid()}`, name: tc.function?.name || "", input: {} } });
218
218
  }
219
219
  if (tc.function?.arguments) {
220
- emit("content_block_delta", { type: "content_block_delta", index: toolBlocks[oi].anthropicIndex,
220
+ await emit("content_block_delta", { type: "content_block_delta", index: toolBlocks[oi].anthropicIndex,
221
221
  delta: { type: "input_json_delta", partial_json: tc.function.arguments } });
222
222
  }
223
223
  }
@@ -226,16 +226,16 @@ export async function streamAnthropicFromLines(lineIterator, emit, model) {
226
226
  if (choice.finish_reason) finishReason = choice.finish_reason;
227
227
  }
228
228
 
229
- ensureStart();
230
- if (textOpen) closeText();
229
+ await ensureStart();
230
+ if (textOpen) await closeText();
231
231
  for (const oi of Object.keys(toolBlocks)) {
232
- emit("content_block_stop", { type: "content_block_stop", index: toolBlocks[oi].anthropicIndex });
232
+ await emit("content_block_stop", { type: "content_block_stop", index: toolBlocks[oi].anthropicIndex });
233
233
  }
234
234
 
235
235
  const stop_reason = finishReason ? mapStopReason(finishReason) : (sawToolUse ? "tool_use" : "end_turn");
236
- emit("message_delta", { type: "message_delta", delta: { stop_reason, stop_sequence: null },
236
+ await emit("message_delta", { type: "message_delta", delta: { stop_reason, stop_sequence: null },
237
237
  usage: { output_tokens: outputTokens } });
238
- emit("message_stop", { type: "message_stop" });
238
+ await emit("message_stop", { type: "message_stop" });
239
239
  }
240
240
 
241
241
  // Copilot has no count_tokens endpoint; estimate locally with gpt-tokenizer.
@@ -7,7 +7,7 @@ const SETTINGS_PATH = path.join(os.homedir(), ".claude", "settings.json");
7
7
 
8
8
  // Return an updated settings object without mutating the input.
9
9
  export function computeUpdatedSettings(settings, port) {
10
- const target = `http://localhost:${port}`;
10
+ const target = `http://127.0.0.1:${port}`;
11
11
  const current = settings?.env?.ANTHROPIC_BASE_URL;
12
12
  const currentToken = settings?.env?.ANTHROPIC_AUTH_TOKEN;
13
13
  const changed = current !== target || currentToken !== "dummy";
@@ -24,7 +24,7 @@ export function computeUpdatedSettings(settings, port) {
24
24
 
25
25
  // Point Claude Code at the adapter by updating ~/.claude/settings.json.
26
26
  export function ensureClaudeConfig(port = 2026) {
27
- const target = `http://localhost:${port}`;
27
+ const target = `http://127.0.0.1:${port}`;
28
28
 
29
29
  if (!fs.existsSync(SETTINGS_PATH)) {
30
30
  fs.mkdirSync(path.dirname(SETTINGS_PATH), { recursive: true });
package/src/config.mjs CHANGED
@@ -33,8 +33,8 @@ function setTomlKey(lines, sectionName, key, value) {
33
33
  }
34
34
 
35
35
  export function computeUpdatedCodexConfig(content, adapterPort = 2026) {
36
- const baseUrl = `http://localhost:${adapterPort}/v1`;
37
- const anthropicBaseUrl = `http://localhost:${adapterPort}`;
36
+ const baseUrl = `http://127.0.0.1:${adapterPort}/v1`;
37
+ const anthropicBaseUrl = `http://127.0.0.1:${adapterPort}`;
38
38
  const hadTrailingNewline = content.endsWith("\n");
39
39
  const lines = content.split("\n");
40
40
  if (hadTrailingNewline) lines.pop();
@@ -59,8 +59,8 @@ export function computeUpdatedCodexConfig(content, adapterPort = 2026) {
59
59
  }
60
60
 
61
61
  function initialCodexConfig(adapterPort) {
62
- const baseUrl = `http://localhost:${adapterPort}/v1`;
63
- const anthropicBaseUrl = `http://localhost:${adapterPort}`;
62
+ const baseUrl = `http://127.0.0.1:${adapterPort}/v1`;
63
+ const anthropicBaseUrl = `http://127.0.0.1:${adapterPort}`;
64
64
  return `openai_base_url = "${baseUrl}"
65
65
 
66
66
  [shell_environment_policy]
@@ -75,7 +75,7 @@ OPENAI_API_KEY = "dummy"
75
75
  }
76
76
 
77
77
  export function ensureCodexConfig(adapterPort = 2026) {
78
- const baseUrl = `http://localhost:${adapterPort}/v1`;
78
+ const baseUrl = `http://127.0.0.1:${adapterPort}/v1`;
79
79
 
80
80
  if (!fs.existsSync(CONFIG_PATH)) {
81
81
  // Codex config does not exist yet; create the local proxy defaults.
package/src/copilot.mjs CHANGED
@@ -10,9 +10,17 @@ let apiBase = DEFAULT_API_BASE;
10
10
  const IMG_MAX_DIM = parseInt(process.env.CCDX_IMG_MAX_DIM || "2048", 10);
11
11
  const IMG_QUALITY = parseInt(process.env.CCDX_IMG_QUALITY || "85", 10);
12
12
  const IMG_MIN_BYTES = parseInt(process.env.CCDX_IMG_MIN_BYTES || "100000", 10);
13
+ const DEFAULT_IMG_CONCURRENCY = 4;
14
+ const IMG_MAX_CONCURRENCY = 12;
13
15
  const IMG_OPT_DISABLED = process.env.CCDX_DISABLE_IMG_OPT === "1";
16
+ const IMG_CONCURRENCY = parseImageConcurrency(process.env.CCDX_IMG_CONCURRENCY);
14
17
  let sharpImport = null;
15
18
 
19
+ export function parseImageConcurrency(value) {
20
+ const n = Number.parseInt(value, 10);
21
+ return Number.isFinite(n) && n > 0 ? Math.min(n, IMG_MAX_CONCURRENCY) : DEFAULT_IMG_CONCURRENCY;
22
+ }
23
+
16
24
  export function parseApiBase(data) {
17
25
  return (data && data.endpoints && typeof data.endpoints.api === "string" && data.endpoints.api)
18
26
  ? data.endpoints.api
@@ -59,14 +67,26 @@ export async function optimizeImageDataUrl(dataUrl) {
59
67
  }
60
68
  }
61
69
 
70
+ export async function runWithConcurrency(taskFns, concurrency) {
71
+ if (!Array.isArray(taskFns) || taskFns.length === 0) return;
72
+ const limit = Math.min(parseImageConcurrency(concurrency), taskFns.length);
73
+ let next = 0;
74
+ await Promise.all(Array.from({ length: limit }, async () => {
75
+ while (next < taskFns.length) {
76
+ const task = taskFns[next++];
77
+ await task();
78
+ }
79
+ }));
80
+ }
81
+
62
82
  function visitImageParts(parts, tasks) {
63
83
  if (!Array.isArray(parts)) return;
64
84
  for (const part of parts) {
65
85
  if (!part) continue;
66
86
  if (part.type === "input_image" && typeof part.image_url === "string") {
67
- tasks.push((async () => { part.image_url = await optimizeImageDataUrl(part.image_url); })());
87
+ tasks.push(async () => { part.image_url = await optimizeImageDataUrl(part.image_url); });
68
88
  } else if (part.type === "image" && part.source?.type === "base64" && part.source?.data) {
69
- tasks.push((async () => {
89
+ tasks.push(async () => {
70
90
  const dataUrl = `data:${part.source.media_type || "image/png"};base64,${part.source.data}`;
71
91
  const opt = await optimizeImageDataUrl(dataUrl);
72
92
  const match = /^data:([^;]+);base64,(.+)$/.exec(opt);
@@ -74,7 +94,7 @@ function visitImageParts(parts, tasks) {
74
94
  part.source.media_type = match[1];
75
95
  part.source.data = match[2];
76
96
  }
77
- })());
97
+ });
78
98
  }
79
99
  }
80
100
  }
@@ -97,13 +117,13 @@ export async function optimizeImagesInBody(reqBody) {
97
117
  try {
98
118
  const parsed = JSON.parse(trimmed);
99
119
  if (Array.isArray(parsed)) {
100
- const before = tasks.length;
101
- visitImageParts(parsed, tasks);
102
- const localTasks = tasks.slice(before);
120
+ const localTasks = [];
121
+ visitImageParts(parsed, localTasks);
103
122
  if (localTasks.length) {
104
- tasks.push(Promise.all(localTasks).then(() => {
123
+ tasks.push(async () => {
124
+ await runWithConcurrency(localTasks, IMG_CONCURRENCY);
105
125
  item.output = JSON.stringify(parsed);
106
- }));
126
+ });
107
127
  }
108
128
  }
109
129
  } catch {
@@ -114,7 +134,7 @@ export async function optimizeImagesInBody(reqBody) {
114
134
  }
115
135
  }
116
136
 
117
- if (tasks.length) await Promise.all(tasks);
137
+ if (tasks.length) await runWithConcurrency(tasks, IMG_CONCURRENCY);
118
138
  return reqBody;
119
139
  }
120
140
 
@@ -235,11 +255,12 @@ function getGithubToken() {
235
255
  return fs.readFileSync(GITHUB_TOKEN_PATH, "utf-8").trim();
236
256
  }
237
257
 
238
- export async function getCopilotToken() {
258
+ export async function getCopilotToken({ signal } = {}) {
239
259
  if (copilotToken && Date.now() < copilotTokenExpiry - 60000) return copilotToken;
240
260
  const ghToken = getGithubToken();
241
261
  const resp = await fetch(`${GITHUB_API}/copilot_internal/v2/token`, {
242
262
  headers: { Authorization: `token ${ghToken}`, Accept: "application/json" },
263
+ signal,
243
264
  });
244
265
  if (!resp.ok) throw new Error(`Failed to get Copilot token: ${resp.status}`);
245
266
  const data = await resp.json();
@@ -254,8 +275,8 @@ export async function getCopilotToken() {
254
275
  }
255
276
 
256
277
  // chatReq is already converted by the adapter. The caller parses the raw Response.
257
- export async function chatCompletions(chatReq) {
258
- const token = await getCopilotToken();
278
+ export async function chatCompletions(chatReq, { signal } = {}) {
279
+ const token = await getCopilotToken({ signal });
259
280
  const messages = chatReq.messages || [];
260
281
  const headers = buildHeaders({
261
282
  token,
@@ -267,21 +288,22 @@ export async function chatCompletions(chatReq) {
267
288
  method: "POST",
268
289
  headers,
269
290
  body: JSON.stringify(chatReq),
291
+ signal,
270
292
  });
271
293
  }
272
294
 
273
- export async function listModels() {
274
- const token = await getCopilotToken();
295
+ export async function listModels({ signal } = {}) {
296
+ const token = await getCopilotToken({ signal });
275
297
  const headers = buildHeaders({
276
298
  token, version: getVSCodeVersion(), initiator: "user", vision: false,
277
299
  });
278
- const resp = await fetch(`${getApiBase()}/models`, { headers });
300
+ const resp = await fetch(`${getApiBase()}/models`, { headers, signal });
279
301
  return { status: resp.status, body: await resp.text() };
280
302
  }
281
303
 
282
304
  // Responses-only models go directly to Copilot's /responses endpoint.
283
- export async function responses(reqBody) {
284
- const token = await getCopilotToken();
305
+ export async function responses(reqBody, { signal } = {}) {
306
+ const token = await getCopilotToken({ signal });
285
307
  const beforeBytes = Buffer.byteLength(JSON.stringify(reqBody));
286
308
  await optimizeImagesInBody(reqBody);
287
309
  const bodyBuf = Buffer.from(JSON.stringify(reqBody), "utf8");
@@ -302,6 +324,7 @@ export async function responses(reqBody) {
302
324
  method: "POST",
303
325
  headers,
304
326
  body: bodyBuf,
327
+ signal,
305
328
  });
306
329
  } catch (e) {
307
330
  const cause = e?.cause;
@@ -310,7 +333,7 @@ export async function responses(reqBody) {
310
333
  }
311
334
  }
312
335
 
313
- export async function responsesCompact(reqBody) {
336
+ export async function responsesCompact(reqBody, options = {}) {
314
337
  // GitHub Copilot accepts Codex compact payloads on the regular Responses endpoint.
315
- return responses(reqBody);
338
+ return responses(reqBody, options);
316
339
  }