codex-copilot-dx 0.4.4 → 0.4.6

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.6",
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,86 @@ 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
+ function sendUpstreamError(res, resp, text) {
129
+ if (!res.headersSent) {
130
+ res.writeHead(resp.status || 502, { "Content-Type": resp.headers?.get("content-type") || "application/json" });
131
+ }
132
+ res.end(text || JSON.stringify({ error: "Upstream request failed" }));
133
+ }
134
+
135
+ export function writeOrDrain(res, chunk) {
136
+ if (res.destroyed || res.writableEnded) return Promise.resolve(false);
137
+ if (res.write(chunk)) return Promise.resolve(true);
138
+ return new Promise((resolve, reject) => {
139
+ const cleanup = () => {
140
+ res.off("drain", onDrain);
141
+ res.off("error", onError);
142
+ res.off("close", onClose);
143
+ };
144
+ const onDrain = () => { cleanup(); resolve(true); };
145
+ const onClose = () => { cleanup(); resolve(false); };
146
+ const onError = (err) => { cleanup(); reject(err); };
147
+ res.once("drain", onDrain);
148
+ res.once("close", onClose);
149
+ res.once("error", onError);
150
+ });
151
+ }
152
+
153
+ function createRequestAbort(req, res) {
154
+ const controller = new AbortController();
155
+ let timer = null;
156
+ let cleaned = false;
157
+ const abort = () => {
158
+ if (!cleaned && !controller.signal.aborted) controller.abort();
159
+ };
160
+ req.on("aborted", abort);
161
+ res.on("close", abort);
162
+ return {
163
+ signal: controller.signal,
164
+ setTimeout(ms) {
165
+ if (timer) clearTimeout(timer);
166
+ if (ms > 0) timer = setTimeout(abort, ms);
167
+ },
168
+ cleanup() {
169
+ cleaned = true;
170
+ if (timer) clearTimeout(timer);
171
+ req.off("aborted", abort);
172
+ res.off("close", abort);
173
+ },
174
+ };
175
+ }
176
+
76
177
  function responsesInputItems(input) {
77
178
  if (input === undefined || input === null) return [];
78
179
  if (typeof input === "string") {
@@ -86,6 +187,81 @@ function responsesOutputItems(output) {
86
187
  return cloneJson(output.filter((item) => item?.type === "message" || item?.type === "function_call"));
87
188
  }
88
189
 
190
+ function stripEncryptedReasoningValue(value, state) {
191
+ if (Array.isArray(value)) {
192
+ return value.map((item) => stripEncryptedReasoningValue(item, state));
193
+ }
194
+
195
+ if (value && typeof value === "object") {
196
+ const out = {};
197
+ for (const [key, child] of Object.entries(value)) {
198
+ if (key === "encrypted_content") {
199
+ state.changed = true;
200
+ continue;
201
+ }
202
+ out[key] = stripEncryptedReasoningValue(child, state);
203
+ }
204
+ return out;
205
+ }
206
+
207
+ return value;
208
+ }
209
+
210
+ function isEncryptedReasoningInputItem(item) {
211
+ return item && typeof item === "object"
212
+ && (item.type === "reasoning" || Object.prototype.hasOwnProperty.call(item, "encrypted_content"));
213
+ }
214
+
215
+ export function sanitizeEncryptedReasoningRequest(reqContext) {
216
+ const state = { changed: false };
217
+ let body = cloneJson(reqContext.body);
218
+ if (Array.isArray(body.input)) {
219
+ const input = [];
220
+ for (const item of body.input) {
221
+ if (isEncryptedReasoningInputItem(item)) {
222
+ state.changed = true;
223
+ continue;
224
+ }
225
+ input.push(stripEncryptedReasoningValue(item, state));
226
+ }
227
+ body.input = input;
228
+ } else {
229
+ body = stripEncryptedReasoningValue(body, state);
230
+ }
231
+ if (!state.changed) return null;
232
+ return {
233
+ ...reqContext,
234
+ body,
235
+ inputItems: Array.isArray(body.input) ? body.input : reqContext.inputItems,
236
+ };
237
+ }
238
+
239
+ export function isEncryptedContentVerificationError(statusCode, text) {
240
+ if (statusCode < 400 || !text) return false;
241
+ const lower = String(text).toLowerCase();
242
+ return lower.includes("encrypted content")
243
+ && lower.includes("could not be verified")
244
+ && (lower.includes("could not be decrypted") || lower.includes("could not be parsed"));
245
+ }
246
+
247
+ export async function openCopilotResponse(reqContext, upstream = copilotResponses, options = {}) {
248
+ let resp = await upstream(reqContext.body, { signal: options.signal });
249
+ if (resp.ok) return { resp, reqContext };
250
+
251
+ const errorText = await resp.text();
252
+ if (!isEncryptedContentVerificationError(resp.status, errorText)) {
253
+ return { resp, reqContext, errorText };
254
+ }
255
+
256
+ const retryContext = sanitizeEncryptedReasoningRequest(reqContext);
257
+ if (!retryContext) return { resp, reqContext, errorText };
258
+
259
+ console.warn(status("warn", "encrypted reasoning rejected by upstream; retrying without encrypted reasoning"));
260
+ resp = await upstream(retryContext.body, { signal: options.signal });
261
+ if (resp.ok) return { resp, reqContext: retryContext };
262
+ return { resp, reqContext: retryContext, errorText: await resp.text() };
263
+ }
264
+
89
265
  export function clearResponseHistoryForTests() {
90
266
  responseHistories.clear();
91
267
  }
@@ -161,8 +337,14 @@ function readSseEvents(buffer, onData) {
161
337
  }
162
338
  }
163
339
 
164
- async function proxyCopilotResponses(reqContext, req, res, upstream = copilotResponses) {
165
- const resp = await upstream(reqContext.body);
340
+ async function proxyCopilotResponses(reqContext, req, res, upstream = copilotResponses, options = {}) {
341
+ const opened = await openCopilotResponse(reqContext, upstream, options);
342
+ const { resp, errorText } = opened;
343
+ reqContext = opened.reqContext;
344
+ if (errorText !== undefined) {
345
+ sendUpstreamError(res, resp, errorText);
346
+ return;
347
+ }
166
348
 
167
349
  if (reqContext.body.stream) {
168
350
  res.writeHead(resp.status, {
@@ -183,7 +365,7 @@ async function proxyCopilotResponses(reqContext, req, res, upstream = copilotRes
183
365
  }
184
366
  buffer += decoder.decode(value, { stream: true });
185
367
  buffer = readSseEvents(buffer, (data) => storeCompletedResponseFromSse(reqContext, data));
186
- res.write(value);
368
+ await writeOrDrain(res, value);
187
369
  }
188
370
  } else {
189
371
  const data = await resp.text();
@@ -260,11 +442,11 @@ function chatToResponses(chatResp, model) {
260
442
  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
443
  }
262
444
 
263
- async function forwardToChat(chatReq, emitEvent, onDone, onError) {
445
+ async function forwardToChat(chatReq, emitEvent, onDone, onError, options = {}) {
264
446
  delete chatReq.max_tokens;
265
447
  let resp;
266
448
  try {
267
- resp = await chatCompletions({ ...chatReq, stream: true });
449
+ resp = await chatCompletions({ ...chatReq, stream: true }, { signal: options.signal });
268
450
  } catch (e) {
269
451
  onError(502, e.message);
270
452
  return;
@@ -276,37 +458,37 @@ async function forwardToChat(chatReq, emitEvent, onDone, onError) {
276
458
  const respId = `resp_${uid()}`, itemId = `item_${uid()}`;
277
459
  let actualModel = "unknown", fullText = "", toolCalls = {}, hasToolCalls = false;
278
460
 
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: "" } });
461
+ await emitEvent("response.created", { response: { id: respId, object: "response", status: "in_progress", model: actualModel, output: [] } });
462
+ await emitEvent("response.output_item.added", { output_index: 0, item: { type: "message", id: itemId, role: "assistant", status: "in_progress", content: [] } });
463
+ await emitEvent("response.content_part.added", { output_index: 0, content_index: 0, part: { type: "output_text", text: "" } });
282
464
 
283
- const emitCompleted = () => {
465
+ const emitCompleted = async () => {
284
466
  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 }] } });
467
+ await emitEvent("response.output_text.done", { output_index: 0, content_index: 0, text: fullText });
468
+ 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
469
  }
288
470
  const output = hasToolCalls
289
471
  ? Object.entries(toolCalls).map(([id, tc]) => ({ type: "function_call", id, call_id: id, name: tc.name, arguments: tc.arguments, status: "completed" }))
290
472
  : [{ 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 } } });
473
+ 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
474
  };
293
475
 
294
476
  try {
295
477
  for await (const line of webStreamLines(resp)) {
296
478
  if (!line.startsWith("data: ")) continue;
297
479
  const data = line.slice(6).trim();
298
- if (data === "[DONE]") { emitCompleted(); onDone(); return; }
480
+ if (data === "[DONE]") { await emitCompleted(); onDone(); return; }
299
481
  let parsed;
300
482
  try { parsed = JSON.parse(data); } catch { continue; }
301
483
  if (parsed.model) actualModel = parsed.model;
302
484
  const delta = parsed.choices?.[0]?.delta;
303
485
  if (!delta) continue;
304
- if (delta.content) { fullText += delta.content; emitEvent("response.output_text.delta", { output_index: 0, content_index: 0, delta: delta.content }); }
486
+ if (delta.content) { fullText += delta.content; await emitEvent("response.output_text.delta", { output_index: 0, content_index: 0, delta: delta.content }); }
305
487
  if (delta.tool_calls) {
306
488
  hasToolCalls = true;
307
489
  for (const tc of delta.tool_calls) {
308
490
  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" } }); }
491
+ 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
492
  if (tc.function?.name) toolCalls[id].name += tc.function.name;
311
493
  if (tc.function?.arguments) toolCalls[id].arguments += tc.function.arguments;
312
494
  }
@@ -316,42 +498,44 @@ async function forwardToChat(chatReq, emitEvent, onDone, onError) {
316
498
  onError(500, e?.message || "upstream stream error");
317
499
  return;
318
500
  }
319
- emitCompleted();
501
+ await emitCompleted();
320
502
  onDone();
321
503
  }
322
504
 
323
505
  // Public server entry point.
324
506
 
325
- export function startAdapter(port = 2026) {
507
+ export function startAdapter(port = 2026, host = "127.0.0.1") {
326
508
  const server = http.createServer((req, res) => {
327
509
  const pathname = requestPath(req.url);
328
510
 
329
511
  if (req.method === "POST" && pathname === "/v1/responses") {
330
512
  (async () => {
513
+ const abort = createRequestAbort(req, res);
331
514
  try {
332
515
  const parsed = await readJsonBody(req);
516
+ if (!parsed.stream) abort.setTimeout(UPSTREAM_TIMEOUT_MS);
333
517
  const prepared = prepareResponsesRequest(parsed);
334
518
  prepared.surface = "responses";
335
519
  const model = parsed.model || "unknown";
336
520
  console.log(status("info", `responses model=${model} stream=${!!parsed.stream}`));
337
521
  if (RESPONSES_ONLY.has(model)) {
338
- await proxyCopilotResponses(prepared, req, res);
522
+ await proxyCopilotResponses(prepared, req, res, copilotResponses, { signal: abort.signal });
339
523
  } else {
340
524
  const chatReq = responsesToChat(prepared.body);
341
525
  if (parsed.stream) {
342
- forwardToChat(chatReq, (event, data) => {
526
+ await forwardToChat(chatReq, async (event, data) => {
343
527
  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`);
528
+ await writeOrDrain(res, `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
345
529
  if (event === "response.completed") {
346
530
  rememberResponseHistory(prepared, data.response);
347
531
  recordResponsesUsage({ surface: prepared.surface, mode: "stream", model, response: data.response, event: data });
348
532
  }
349
- }, () => { if (!res.writableEnded) res.end(); }, (status, errMsg) => { if (!res.headersSent) res.writeHead(status || 500); res.end(errMsg); });
533
+ }, () => { if (!res.writableEnded) res.end(); }, (status, errMsg) => { if (!res.headersSent) res.writeHead(status || 500); res.end(errMsg); }, { signal: abort.signal });
350
534
  } else {
351
535
  chatReq.stream = false;
352
536
  delete chatReq.max_tokens;
353
537
  try {
354
- const upstream = await chatCompletions({ ...chatReq, stream: false });
538
+ const upstream = await chatCompletions({ ...chatReq, stream: false }, { signal: abort.signal });
355
539
  const data = await upstream.text();
356
540
  const resp = chatToResponses(JSON.parse(data), model);
357
541
  rememberResponseHistory(prepared, resp);
@@ -366,8 +550,9 @@ export function startAdapter(port = 2026) {
366
550
  }
367
551
  } catch (e) {
368
552
  console.error(status("err", `Responses request failed: ${e.message}`));
369
- if (!res.headersSent) res.writeHead(400);
370
- res.end(JSON.stringify({ error: e.message }));
553
+ sendJsonError(res, e);
554
+ } finally {
555
+ abort.cleanup();
371
556
  }
372
557
  })();
373
558
  return;
@@ -375,38 +560,46 @@ export function startAdapter(port = 2026) {
375
560
 
376
561
  if (req.method === "POST" && pathname === "/v1/responses/compact") {
377
562
  (async () => {
563
+ const abort = createRequestAbort(req, res);
378
564
  try {
379
565
  const parsed = await readJsonBody(req);
566
+ if (!parsed.stream) abort.setTimeout(UPSTREAM_TIMEOUT_MS);
380
567
  const prepared = prepareResponsesRequest(parsed);
381
568
  prepared.surface = "responses_compact";
382
569
  const model = parsed.model || "unknown";
383
570
  console.log(status("info", `responses compact model=${model} stream=${!!parsed.stream}`));
384
- await proxyCopilotResponses(prepared, req, res, copilotResponsesCompact);
571
+ await proxyCopilotResponses(prepared, req, res, copilotResponsesCompact, { signal: abort.signal });
385
572
  } catch (e) {
386
573
  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 }));
574
+ sendJsonError(res, e);
575
+ } finally {
576
+ abort.cleanup();
389
577
  }
390
578
  })();
391
579
  return;
392
580
  }
393
581
 
394
582
  if (req.method === "GET" && pathname === "/v1/models") {
395
- listModels()
583
+ const abort = createRequestAbort(req, res);
584
+ abort.setTimeout(UPSTREAM_TIMEOUT_MS);
585
+ listModels({ signal: abort.signal })
396
586
  .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 })); });
587
+ .catch((e) => { if (!res.headersSent) res.writeHead(502); res.end(JSON.stringify({ error: e.message })); })
588
+ .finally(() => abort.cleanup());
398
589
  return;
399
590
  }
400
591
 
401
592
  if (req.method === "POST" && pathname === "/v1/messages/count_tokens") {
402
593
  (async () => {
594
+ const abort = createRequestAbort(req, res);
403
595
  try {
404
596
  const parsed = await readJsonBody(req);
405
597
  res.writeHead(200, { "Content-Type": "application/json" });
406
598
  res.end(JSON.stringify(countTokens(parsed)));
407
599
  } catch (e) {
408
- if (!res.headersSent) res.writeHead(400);
409
- res.end(JSON.stringify({ error: e.message }));
600
+ sendJsonError(res, e);
601
+ } finally {
602
+ abort.cleanup();
410
603
  }
411
604
  })();
412
605
  return;
@@ -414,13 +607,15 @@ export function startAdapter(port = 2026) {
414
607
 
415
608
  if (req.method === "POST" && pathname === "/v1/messages") {
416
609
  (async () => {
610
+ const abort = createRequestAbort(req, res);
417
611
  try {
418
612
  const parsed = await readJsonBody(req);
613
+ if (!parsed.stream) abort.setTimeout(UPSTREAM_TIMEOUT_MS);
419
614
  const model = parsed.model || "unknown";
420
615
  console.log(status("info", `messages model=${model} stream=${!!parsed.stream}`));
421
616
  const chatReq = anthropicToChat(parsed);
422
617
  if (parsed.stream) {
423
- const upstream = await chatCompletions({ ...chatReq, stream: true });
618
+ const upstream = await chatCompletions({ ...chatReq, stream: true }, { signal: abort.signal });
424
619
  if (!upstream.ok) {
425
620
  if (!res.headersSent) res.writeHead(upstream.status);
426
621
  res.end(await upstream.text());
@@ -431,17 +626,17 @@ export function startAdapter(port = 2026) {
431
626
  let usage;
432
627
  await streamAnthropicFromLines(
433
628
  webStreamLines(upstream),
434
- (event, data) => {
629
+ async (event, data) => {
435
630
  if (event === "message_start") messageId = data.message?.id;
436
631
  if (event === "message_delta") usage = data.usage;
437
- res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
632
+ await writeOrDrain(res, `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
438
633
  },
439
634
  model,
440
635
  );
441
636
  recordAnthropicUsage({ surface: "messages", mode: "stream", model, responseId: messageId, usage });
442
637
  if (!res.writableEnded) res.end();
443
638
  } else {
444
- const upstream = await chatCompletions({ ...chatReq, stream: false });
639
+ const upstream = await chatCompletions({ ...chatReq, stream: false }, { signal: abort.signal });
445
640
  const data = await upstream.text();
446
641
  const anthropicMsg = chatToAnthropic(JSON.parse(data), model);
447
642
  recordAnthropicUsage({ surface: "messages", mode: "json", model: anthropicMsg.model, responseId: anthropicMsg.id, usage: anthropicMsg.usage });
@@ -450,8 +645,9 @@ export function startAdapter(port = 2026) {
450
645
  }
451
646
  } catch (e) {
452
647
  console.error(status("err", `Messages request failed: ${e.message}`));
453
- if (!res.headersSent) res.writeHead(400);
454
- res.end(JSON.stringify({ error: e.message }));
648
+ sendJsonError(res, e);
649
+ } finally {
650
+ abort.cleanup();
455
651
  }
456
652
  })();
457
653
  return;
@@ -472,8 +668,9 @@ export function startAdapter(port = 2026) {
472
668
  });
473
669
 
474
670
  return new Promise((resolve) => {
475
- server.listen(port, () => {
476
- console.log(status("ok", `Adapter listening on http://localhost:${port}`));
671
+ server.listen(port, host, () => {
672
+ const actualPort = server.address()?.port || port;
673
+ console.log(status("ok", `Adapter listening on http://${host}:${actualPort}`));
477
674
  resolve(server);
478
675
  });
479
676
  });
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
  }