codex-copilot-dx 0.4.3 → 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.3",
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
@@ -1,5 +1,7 @@
1
1
  import http from "node:http";
2
2
  import { randomUUID } from "node:crypto";
3
+ import { promisify } from "node:util";
4
+ import * as zlib from "node:zlib";
3
5
  import { chatCompletions, listModels, responses as copilotResponses, responsesCompact as copilotResponsesCompact } from "./copilot.mjs";
4
6
  import { webStreamLines } from "./stream.mjs";
5
7
  import { anthropicToChat, chatToAnthropic, streamAnthropicFromLines, countTokens } from "./anthropic.mjs";
@@ -19,6 +21,22 @@ const RESPONSES_ONLY = new Set([
19
21
 
20
22
  const RESPONSE_HISTORY_LIMIT = 64;
21
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;
27
+ const gunzipAsync = promisify(zlib.gunzip);
28
+ const inflateAsync = promisify(zlib.inflate);
29
+ const brotliDecompressAsync = promisify(zlib.brotliDecompress);
30
+ const zstdDecompressAsync = zlib.zstdDecompress ? promisify(zlib.zstdDecompress) : null;
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);
22
40
 
23
41
  export function requestPath(reqUrl) {
24
42
  return new URL(reqUrl || "/", "http://localhost").pathname;
@@ -28,6 +46,127 @@ function cloneJson(value) {
28
46
  return value === undefined ? undefined : JSON.parse(JSON.stringify(value));
29
47
  }
30
48
 
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
+
65
+ const chunks = [];
66
+ let total = 0;
67
+ for await (const chunk of req) {
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);
72
+ }
73
+ return Buffer.concat(chunks);
74
+ }
75
+
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) {
88
+ const encodings = String(contentEncoding || "identity")
89
+ .split(",")
90
+ .map((v) => v.trim().toLowerCase())
91
+ .filter(Boolean);
92
+
93
+ let decoded = buffer;
94
+ for (const encoding of encodings.reverse()) {
95
+ if (encoding === "identity") continue;
96
+ if (encoding === "gzip" || encoding === "x-gzip") {
97
+ decoded = await decompressBody(gunzipAsync, decoded, maxBytes);
98
+ } else if (encoding === "deflate") {
99
+ decoded = await decompressBody(inflateAsync, decoded, maxBytes);
100
+ } else if (encoding === "br") {
101
+ decoded = await decompressBody(brotliDecompressAsync, decoded, maxBytes);
102
+ } else if (encoding === "zstd") {
103
+ if (!zstdDecompressAsync) throw new Error("Unsupported Content-Encoding: zstd");
104
+ decoded = await decompressBody(zstdDecompressAsync, decoded, maxBytes);
105
+ } else {
106
+ throw new Error(`Unsupported Content-Encoding: ${encoding}`);
107
+ }
108
+ if (decoded.length > maxBytes) throw payloadTooLarge("Decoded", maxBytes);
109
+ }
110
+ if (decoded.length > maxBytes) throw payloadTooLarge("Decoded", maxBytes);
111
+ return decoded;
112
+ }
113
+
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);
120
+ return JSON.parse(decoded.toString("utf8"));
121
+ }
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
+
31
170
  function responsesInputItems(input) {
32
171
  if (input === undefined || input === null) return [];
33
172
  if (typeof input === "string") {
@@ -64,6 +203,10 @@ export function prepareResponsesRequest(reqBody) {
64
203
 
65
204
  delete body.previous_response_id;
66
205
  delete body.store;
206
+ if (Array.isArray(body.tools)) {
207
+ body.tools = body.tools.filter((tool) => tool?.type !== "image_generation");
208
+ if (!body.tools.length) delete body.tools;
209
+ }
67
210
 
68
211
  return { body, inputItems: body.input };
69
212
  }
@@ -112,8 +255,8 @@ function readSseEvents(buffer, onData) {
112
255
  }
113
256
  }
114
257
 
115
- async function proxyCopilotResponses(reqContext, req, res, upstream = copilotResponses) {
116
- 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 });
117
260
 
118
261
  if (reqContext.body.stream) {
119
262
  res.writeHead(resp.status, {
@@ -134,7 +277,7 @@ async function proxyCopilotResponses(reqContext, req, res, upstream = copilotRes
134
277
  }
135
278
  buffer += decoder.decode(value, { stream: true });
136
279
  buffer = readSseEvents(buffer, (data) => storeCompletedResponseFromSse(reqContext, data));
137
- res.write(value);
280
+ await writeOrDrain(res, value);
138
281
  }
139
282
  } else {
140
283
  const data = await resp.text();
@@ -211,11 +354,11 @@ function chatToResponses(chatResp, model) {
211
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 };
212
355
  }
213
356
 
214
- async function forwardToChat(chatReq, emitEvent, onDone, onError) {
357
+ async function forwardToChat(chatReq, emitEvent, onDone, onError, options = {}) {
215
358
  delete chatReq.max_tokens;
216
359
  let resp;
217
360
  try {
218
- resp = await chatCompletions({ ...chatReq, stream: true });
361
+ resp = await chatCompletions({ ...chatReq, stream: true }, { signal: options.signal });
219
362
  } catch (e) {
220
363
  onError(502, e.message);
221
364
  return;
@@ -227,37 +370,37 @@ async function forwardToChat(chatReq, emitEvent, onDone, onError) {
227
370
  const respId = `resp_${uid()}`, itemId = `item_${uid()}`;
228
371
  let actualModel = "unknown", fullText = "", toolCalls = {}, hasToolCalls = false;
229
372
 
230
- emitEvent("response.created", { response: { id: respId, object: "response", status: "in_progress", model: actualModel, output: [] } });
231
- emitEvent("response.output_item.added", { output_index: 0, item: { type: "message", id: itemId, role: "assistant", status: "in_progress", content: [] } });
232
- 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: "" } });
233
376
 
234
- const emitCompleted = () => {
377
+ const emitCompleted = async () => {
235
378
  if (!hasToolCalls) {
236
- emitEvent("response.output_text.done", { output_index: 0, content_index: 0, text: fullText });
237
- 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 }] } });
238
381
  }
239
382
  const output = hasToolCalls
240
383
  ? Object.entries(toolCalls).map(([id, tc]) => ({ type: "function_call", id, call_id: id, name: tc.name, arguments: tc.arguments, status: "completed" }))
241
384
  : [{ type: "message", id: itemId, role: "assistant", status: "completed", content: [{ type: "output_text", text: fullText }] }];
242
- 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 } } });
243
386
  };
244
387
 
245
388
  try {
246
389
  for await (const line of webStreamLines(resp)) {
247
390
  if (!line.startsWith("data: ")) continue;
248
391
  const data = line.slice(6).trim();
249
- if (data === "[DONE]") { emitCompleted(); onDone(); return; }
392
+ if (data === "[DONE]") { await emitCompleted(); onDone(); return; }
250
393
  let parsed;
251
394
  try { parsed = JSON.parse(data); } catch { continue; }
252
395
  if (parsed.model) actualModel = parsed.model;
253
396
  const delta = parsed.choices?.[0]?.delta;
254
397
  if (!delta) continue;
255
- 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 }); }
256
399
  if (delta.tool_calls) {
257
400
  hasToolCalls = true;
258
401
  for (const tc of delta.tool_calls) {
259
402
  const id = tc.id || Object.keys(toolCalls)[tc.index] || `call_${tc.index}`;
260
- 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" } }); }
261
404
  if (tc.function?.name) toolCalls[id].name += tc.function.name;
262
405
  if (tc.function?.arguments) toolCalls[id].arguments += tc.function.arguments;
263
406
  }
@@ -267,44 +410,44 @@ async function forwardToChat(chatReq, emitEvent, onDone, onError) {
267
410
  onError(500, e?.message || "upstream stream error");
268
411
  return;
269
412
  }
270
- emitCompleted();
413
+ await emitCompleted();
271
414
  onDone();
272
415
  }
273
416
 
274
417
  // Public server entry point.
275
418
 
276
- export function startAdapter(port = 2026) {
419
+ export function startAdapter(port = 2026, host = "127.0.0.1") {
277
420
  const server = http.createServer((req, res) => {
278
421
  const pathname = requestPath(req.url);
279
422
 
280
423
  if (req.method === "POST" && pathname === "/v1/responses") {
281
- let body = "";
282
- req.on("data", (c) => (body += c));
283
- req.on("end", async () => {
424
+ (async () => {
425
+ const abort = createRequestAbort(req, res);
284
426
  try {
285
- const parsed = JSON.parse(body);
427
+ const parsed = await readJsonBody(req);
428
+ if (!parsed.stream) abort.setTimeout(UPSTREAM_TIMEOUT_MS);
286
429
  const prepared = prepareResponsesRequest(parsed);
287
430
  prepared.surface = "responses";
288
431
  const model = parsed.model || "unknown";
289
432
  console.log(status("info", `responses model=${model} stream=${!!parsed.stream}`));
290
433
  if (RESPONSES_ONLY.has(model)) {
291
- await proxyCopilotResponses(prepared, req, res);
434
+ await proxyCopilotResponses(prepared, req, res, copilotResponses, { signal: abort.signal });
292
435
  } else {
293
436
  const chatReq = responsesToChat(prepared.body);
294
437
  if (parsed.stream) {
295
- forwardToChat(chatReq, (event, data) => {
438
+ await forwardToChat(chatReq, async (event, data) => {
296
439
  if (!res.headersSent) res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" });
297
- res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
440
+ await writeOrDrain(res, `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
298
441
  if (event === "response.completed") {
299
442
  rememberResponseHistory(prepared, data.response);
300
443
  recordResponsesUsage({ surface: prepared.surface, mode: "stream", model, response: data.response, event: data });
301
444
  }
302
- }, () => { 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 });
303
446
  } else {
304
447
  chatReq.stream = false;
305
448
  delete chatReq.max_tokens;
306
449
  try {
307
- const upstream = await chatCompletions({ ...chatReq, stream: false });
450
+ const upstream = await chatCompletions({ ...chatReq, stream: false }, { signal: abort.signal });
308
451
  const data = await upstream.text();
309
452
  const resp = chatToResponses(JSON.parse(data), model);
310
453
  rememberResponseHistory(prepared, resp);
@@ -319,67 +462,72 @@ export function startAdapter(port = 2026) {
319
462
  }
320
463
  } catch (e) {
321
464
  console.error(status("err", `Responses request failed: ${e.message}`));
322
- if (!res.headersSent) res.writeHead(400);
323
- res.end(JSON.stringify({ error: e.message }));
465
+ sendJsonError(res, e);
466
+ } finally {
467
+ abort.cleanup();
324
468
  }
325
- });
469
+ })();
326
470
  return;
327
471
  }
328
472
 
329
473
  if (req.method === "POST" && pathname === "/v1/responses/compact") {
330
- let body = "";
331
- req.on("data", (c) => (body += c));
332
- req.on("end", async () => {
474
+ (async () => {
475
+ const abort = createRequestAbort(req, res);
333
476
  try {
334
- const parsed = JSON.parse(body);
477
+ const parsed = await readJsonBody(req);
478
+ if (!parsed.stream) abort.setTimeout(UPSTREAM_TIMEOUT_MS);
335
479
  const prepared = prepareResponsesRequest(parsed);
336
480
  prepared.surface = "responses_compact";
337
481
  const model = parsed.model || "unknown";
338
482
  console.log(status("info", `responses compact model=${model} stream=${!!parsed.stream}`));
339
- await proxyCopilotResponses(prepared, req, res, copilotResponsesCompact);
483
+ await proxyCopilotResponses(prepared, req, res, copilotResponsesCompact, { signal: abort.signal });
340
484
  } catch (e) {
341
485
  console.error(status("err", `Responses compact request failed: ${e.message}`));
342
- if (!res.headersSent) res.writeHead(400);
343
- res.end(JSON.stringify({ error: e.message }));
486
+ sendJsonError(res, e);
487
+ } finally {
488
+ abort.cleanup();
344
489
  }
345
- });
490
+ })();
346
491
  return;
347
492
  }
348
493
 
349
494
  if (req.method === "GET" && pathname === "/v1/models") {
350
- listModels()
495
+ const abort = createRequestAbort(req, res);
496
+ abort.setTimeout(UPSTREAM_TIMEOUT_MS);
497
+ listModels({ signal: abort.signal })
351
498
  .then(({ status, body }) => { res.writeHead(status, { "Content-Type": "application/json" }); res.end(body); })
352
- .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());
353
501
  return;
354
502
  }
355
503
 
356
504
  if (req.method === "POST" && pathname === "/v1/messages/count_tokens") {
357
- let body = "";
358
- req.on("data", (c) => (body += c));
359
- req.on("end", () => {
505
+ (async () => {
506
+ const abort = createRequestAbort(req, res);
360
507
  try {
361
- const parsed = JSON.parse(body);
508
+ const parsed = await readJsonBody(req);
362
509
  res.writeHead(200, { "Content-Type": "application/json" });
363
510
  res.end(JSON.stringify(countTokens(parsed)));
364
511
  } catch (e) {
365
- if (!res.headersSent) res.writeHead(400);
366
- res.end(JSON.stringify({ error: e.message }));
512
+ sendJsonError(res, e);
513
+ } finally {
514
+ abort.cleanup();
367
515
  }
368
- });
516
+ })();
369
517
  return;
370
518
  }
371
519
 
372
520
  if (req.method === "POST" && pathname === "/v1/messages") {
373
- let body = "";
374
- req.on("data", (c) => (body += c));
375
- req.on("end", async () => {
521
+ (async () => {
522
+ const abort = createRequestAbort(req, res);
376
523
  try {
377
- const parsed = JSON.parse(body);
524
+ const parsed = await readJsonBody(req);
525
+ if (!parsed.stream) abort.setTimeout(UPSTREAM_TIMEOUT_MS);
378
526
  const model = parsed.model || "unknown";
379
527
  console.log(status("info", `messages model=${model} stream=${!!parsed.stream}`));
380
528
  const chatReq = anthropicToChat(parsed);
381
529
  if (parsed.stream) {
382
- const upstream = await chatCompletions({ ...chatReq, stream: true });
530
+ const upstream = await chatCompletions({ ...chatReq, stream: true }, { signal: abort.signal });
383
531
  if (!upstream.ok) {
384
532
  if (!res.headersSent) res.writeHead(upstream.status);
385
533
  res.end(await upstream.text());
@@ -390,17 +538,17 @@ export function startAdapter(port = 2026) {
390
538
  let usage;
391
539
  await streamAnthropicFromLines(
392
540
  webStreamLines(upstream),
393
- (event, data) => {
541
+ async (event, data) => {
394
542
  if (event === "message_start") messageId = data.message?.id;
395
543
  if (event === "message_delta") usage = data.usage;
396
- res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
544
+ await writeOrDrain(res, `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
397
545
  },
398
546
  model,
399
547
  );
400
548
  recordAnthropicUsage({ surface: "messages", mode: "stream", model, responseId: messageId, usage });
401
549
  if (!res.writableEnded) res.end();
402
550
  } else {
403
- const upstream = await chatCompletions({ ...chatReq, stream: false });
551
+ const upstream = await chatCompletions({ ...chatReq, stream: false }, { signal: abort.signal });
404
552
  const data = await upstream.text();
405
553
  const anthropicMsg = chatToAnthropic(JSON.parse(data), model);
406
554
  recordAnthropicUsage({ surface: "messages", mode: "json", model: anthropicMsg.model, responseId: anthropicMsg.id, usage: anthropicMsg.usage });
@@ -409,10 +557,11 @@ export function startAdapter(port = 2026) {
409
557
  }
410
558
  } catch (e) {
411
559
  console.error(status("err", `Messages request failed: ${e.message}`));
412
- if (!res.headersSent) res.writeHead(400);
413
- res.end(JSON.stringify({ error: e.message }));
560
+ sendJsonError(res, e);
561
+ } finally {
562
+ abort.cleanup();
414
563
  }
415
- });
564
+ })();
416
565
  return;
417
566
  }
418
567
 
@@ -431,8 +580,9 @@ export function startAdapter(port = 2026) {
431
580
  });
432
581
 
433
582
  return new Promise((resolve) => {
434
- server.listen(port, () => {
435
- 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}`));
436
586
  resolve(server);
437
587
  });
438
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
  }