codex-copilot-dx 0.1.4 → 0.1.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/CHANGELOG.md ADDED
@@ -0,0 +1,19 @@
1
+ # Changelog
2
+
3
+ ## 0.1.6
4
+
5
+ ### Fixed
6
+ - **413 Payload Too Large** on `/v1/responses` for long computer-use sessions. The Codex Desktop computer-use tool embeds screenshots as `input_image` parts inside `function_call_output.output` (sometimes as a stringified JSON array). After dozens of tool turns these accumulate to >5MB and hit GitHub Copilot's request size limit. The adapter now walks tool outputs, decodes embedded base64 images, and re-encodes them as WebP at long-edge ≤ 2048px before forwarding to the upstream.
7
+
8
+ ### Added
9
+ - Automatic image optimization with `sharp`. Tunable via env vars:
10
+ - `CCDX_IMG_MAX_DIM` (default `2048`) – max long edge in pixels
11
+ - `CCDX_IMG_QUALITY` (default `85`) – WebP quality
12
+ - `CCDX_IMG_MIN_BYTES` (default `100000`) – skip images smaller than this
13
+ - `CCDX_DISABLE_IMG_OPT=1` – disable optimization entirely
14
+ - `CCDX_DEBUG=1` – verbose diagnostics
15
+ - Per-request log line with byte counts before/after optimization, image count, and biggest item size.
16
+ - Upstream error responses (status, headers, body) are now logged when non-2xx, making 413/auth issues much easier to diagnose.
17
+
18
+ ### Internal
19
+ - Request headers to Copilot now set explicit `Content-Length`, `Content-Type: application/json; charset=utf-8`, and `Accept` to avoid undici picking chunked transfer for large bodies.
package/README.md CHANGED
@@ -40,6 +40,17 @@ Environment variables:
40
40
  |----------|---------|-------------|
41
41
  | `COPILOT_API_PORT` | `4141` | Port for copilot-api |
42
42
  | `ADAPTER_PORT` | `4142` | Port for the adapter |
43
+ | `CCDX_IMG_MAX_DIM` | `2048` | Max long-edge (px) for image downscaling |
44
+ | `CCDX_IMG_QUALITY` | `85` | WebP quality used when re-encoding images |
45
+ | `CCDX_IMG_MIN_BYTES` | `100000` | Images smaller than this are left untouched |
46
+ | `CCDX_DISABLE_IMG_OPT` | unset | Set to `1` to disable image optimization |
47
+ | `CCDX_DEBUG` | unset | Set to `1` for verbose request diagnostics |
48
+
49
+ ### Image optimization
50
+
51
+ Long computer-use sessions can accumulate dozens of screenshots inside the conversation history. Each screenshot is shipped on every subsequent turn, and GitHub Copilot's `/v1/responses` endpoint rejects requests larger than ~5 MB with `413 Payload Too Large`.
52
+
53
+ To avoid that, the adapter automatically downsamples embedded screenshots to long-edge ≤ 2048 px and re-encodes them as WebP before forwarding the request. Typical UI screenshots shrink to 10–15% of their original size with minimal perceptual loss. Disable with `CCDX_DISABLE_IMG_OPT=1` if you need pixel-perfect originals.
43
54
 
44
55
  ## License
45
56
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-copilot-dx",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Use Codex Desktop app with your GitHub Copilot subscription",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,7 +9,8 @@
9
9
  "files": [
10
10
  "bin/",
11
11
  "src/",
12
- "README.md"
12
+ "README.md",
13
+ "CHANGELOG.md"
13
14
  ],
14
15
  "keywords": [
15
16
  "codex",
@@ -21,6 +22,7 @@
21
22
  ],
22
23
  "license": "MIT",
23
24
  "dependencies": {
24
- "copilot-api": "^0.7.0"
25
+ "copilot-api": "^0.7.0",
26
+ "sharp": "^0.33.5"
25
27
  }
26
28
  }
package/src/adapter.mjs CHANGED
@@ -4,11 +4,140 @@ import fs from "node:fs";
4
4
  import path from "node:path";
5
5
  import os from "node:os";
6
6
  import { randomUUID, createHash } from "node:crypto";
7
+ import sharp from "sharp";
7
8
 
8
9
  const COPILOT_API_PORT = parseInt(process.env.COPILOT_API_PORT || "4141");
9
10
  const COPILOT_API = "https://api.githubcopilot.com";
10
11
  const GITHUB_API = "https://api.github.com";
11
12
 
13
+ // Image optimization knobs
14
+ const IMG_MAX_DIM = parseInt(process.env.CCDX_IMG_MAX_DIM || "2048");
15
+ const IMG_QUALITY = parseInt(process.env.CCDX_IMG_QUALITY || "85");
16
+ const IMG_MIN_BYTES = parseInt(process.env.CCDX_IMG_MIN_BYTES || "100000");
17
+ const IMG_OPT_DISABLED = process.env.CCDX_DISABLE_IMG_OPT === "1";
18
+ const CCDX_DEBUG = process.env.CCDX_DEBUG === "1";
19
+
20
+ async function optimizeImageDataUrl(dataUrl) {
21
+ if (IMG_OPT_DISABLED) return dataUrl;
22
+ if (typeof dataUrl !== "string" || !dataUrl.startsWith("data:")) return dataUrl;
23
+ const m = /^data:(image\/[a-z+.-]+);base64,(.+)$/i.exec(dataUrl);
24
+ if (!m) return dataUrl;
25
+ const mime = m[1].toLowerCase();
26
+ const b64 = m[2];
27
+ const raw = Buffer.from(b64, "base64");
28
+ if (raw.length < IMG_MIN_BYTES) return dataUrl;
29
+ // Skip animated formats — sharp will pick first frame and lose animation; better to leave alone
30
+ if (mime.includes("gif")) return dataUrl;
31
+ try {
32
+ const out = await sharp(raw, { failOn: "none" })
33
+ .rotate() // honor EXIF orientation
34
+ .resize(IMG_MAX_DIM, IMG_MAX_DIM, { fit: "inside", withoutEnlargement: true })
35
+ .webp({ quality: IMG_QUALITY, effort: 4 })
36
+ .toBuffer();
37
+ const ratio = ((out.length / raw.length) * 100).toFixed(1);
38
+ console.log(`[codex-copilot-dx] image: ${(raw.length/1024).toFixed(0)}KB ${mime} → ${(out.length/1024).toFixed(0)}KB webp (${ratio}%)`);
39
+ return `data:image/webp;base64,${out.toString("base64")}`;
40
+ } catch (e) {
41
+ console.warn(`[codex-copilot-dx] image optimize failed (${mime}, ${raw.length}b): ${e.message}`);
42
+ return dataUrl;
43
+ }
44
+ }
45
+
46
+ async function optimizeImagesInBody(reqBody) {
47
+ if (IMG_OPT_DISABLED) return reqBody;
48
+ if (!Array.isArray(reqBody.input)) return reqBody;
49
+ const tasks = [];
50
+
51
+ const visitParts = (parts) => {
52
+ if (!Array.isArray(parts)) return;
53
+ for (const part of parts) {
54
+ if (!part) continue;
55
+ // Responses API image part shape: { type: "input_image", image_url: "data:..." }
56
+ if (part.type === "input_image" && typeof part.image_url === "string") {
57
+ tasks.push((async () => { part.image_url = await optimizeImageDataUrl(part.image_url); })());
58
+ } else if (part.type === "image" && part.source?.type === "base64" && part.source?.data) {
59
+ // Anthropic style
60
+ tasks.push((async () => {
61
+ const dataUrl = `data:${part.source.media_type || "image/png"};base64,${part.source.data}`;
62
+ const opt = await optimizeImageDataUrl(dataUrl);
63
+ const m = /^data:([^;]+);base64,(.+)$/.exec(opt);
64
+ if (m) { part.source.media_type = m[1]; part.source.data = m[2]; }
65
+ })());
66
+ }
67
+ }
68
+ };
69
+
70
+ for (const item of reqBody.input) {
71
+ if (!item) continue;
72
+ // Plain user/assistant message
73
+ if (item.type === "message" && Array.isArray(item.content)) {
74
+ visitParts(item.content);
75
+ }
76
+ // computer-use / tool results: function_call_output.output can be a
77
+ // structured array of parts (input_text, input_image, ...) carrying screenshots.
78
+ if (item.type === "function_call_output") {
79
+ if (Array.isArray(item.output)) {
80
+ visitParts(item.output);
81
+ } else if (typeof item.output === "string") {
82
+ // Some tools stringify a JSON array of parts. Parse, optimize images
83
+ // inside, then re-serialize AFTER all image tasks for this array finish.
84
+ const trimmed = item.output.trim();
85
+ if (trimmed.startsWith("[")) {
86
+ try {
87
+ const parsed = JSON.parse(trimmed);
88
+ if (Array.isArray(parsed)) {
89
+ const before = tasks.length;
90
+ visitParts(parsed);
91
+ const localTasks = tasks.slice(before); // tasks added by visitParts
92
+ if (localTasks.length) {
93
+ tasks.push(
94
+ Promise.all(localTasks).then(() => {
95
+ item.output = JSON.stringify(parsed);
96
+ })
97
+ );
98
+ }
99
+ }
100
+ } catch { /* not JSON, leave it */ }
101
+ }
102
+ }
103
+ }
104
+ }
105
+ if (tasks.length) await Promise.all(tasks);
106
+ return reqBody;
107
+ }
108
+
109
+ function summarizeReqBody(reqBody) {
110
+ try {
111
+ const inp = reqBody.input;
112
+ if (!Array.isArray(inp)) return { items: 0, images: 0, biggest: 0, biggestKind: "n/a" };
113
+ let biggest = 0, biggestKind = "", biggestItem = null, images = 0;
114
+ const countImages = (parts) => {
115
+ if (!Array.isArray(parts)) return;
116
+ for (const p of parts) {
117
+ if (p?.type === "input_image" || p?.type === "image") images++;
118
+ }
119
+ };
120
+ for (const it of inp) {
121
+ const s = JSON.stringify(it);
122
+ if (s.length > biggest) { biggest = s.length; biggestKind = it.type || "?"; biggestItem = it; }
123
+ if (it?.type === "message") countImages(it.content);
124
+ if (it?.type === "function_call_output") {
125
+ if (Array.isArray(it.output)) countImages(it.output);
126
+ else if (typeof it.output === "string" && it.output.trim().startsWith("[")) {
127
+ try { const p = JSON.parse(it.output); if (Array.isArray(p)) countImages(p); } catch {}
128
+ }
129
+ }
130
+ }
131
+ // Diagnostic: peek at the biggest function_call_output to see if it embeds images
132
+ let biggestPeek = "";
133
+ if (CCDX_DEBUG && biggestItem?.type === "function_call_output") {
134
+ const out = typeof biggestItem.output === "string" ? biggestItem.output : JSON.stringify(biggestItem.output);
135
+ biggestPeek = out.slice(0, 200);
136
+ }
137
+ return { items: inp.length, images, biggest, biggestKind, biggestPeek };
138
+ } catch { return { items: -1, images: -1, biggest: -1, biggestKind: "err", biggestPeek: "" }; }
139
+ }
140
+
12
141
  // Models that only support Responses API (not chat/completions)
13
142
  const RESPONSES_ONLY = new Set([
14
143
  "gpt-5.5",
@@ -46,18 +175,38 @@ async function getCopilotToken() {
46
175
  // ── Direct Copilot Responses API proxy ──
47
176
 
48
177
  async function proxyCopilotResponses(reqBody, req, res) {
178
+ const beforeBytes = Buffer.byteLength(JSON.stringify(reqBody));
179
+ await optimizeImagesInBody(reqBody);
49
180
  const token = await getCopilotToken();
181
+ const bodyStr = JSON.stringify(reqBody);
182
+ const bodyBuf = Buffer.from(bodyStr, "utf-8");
183
+ const summary = summarizeReqBody(reqBody);
184
+ console.log(`[codex-copilot-dx] → responses model=${reqBody.model} stream=${!!reqBody.stream} bytes=${bodyBuf.length} (was ${beforeBytes}) input_items=${summary.items} images=${summary.images} biggest_item=${summary.biggest}b (${summary.biggestKind})`);
185
+ if (summary.biggestPeek) console.log(`[codex-copilot-dx] peek: ${JSON.stringify(summary.biggestPeek)}`);
186
+
50
187
  const resp = await fetch(`${COPILOT_API}/v1/responses`, {
51
188
  method: "POST",
52
189
  headers: {
53
190
  Authorization: `Bearer ${token}`,
54
- "Content-Type": "application/json",
191
+ "Content-Type": "application/json; charset=utf-8",
192
+ "Content-Length": String(bodyBuf.length),
55
193
  "Editor-Version": "vscode/1.90.0",
56
194
  "Copilot-Integration-Id": "vscode-chat",
195
+ "Accept": reqBody.stream ? "text/event-stream" : "application/json",
57
196
  },
58
- body: JSON.stringify(reqBody),
197
+ body: bodyBuf,
59
198
  });
60
199
 
200
+ if (!resp.ok) {
201
+ const errText = await resp.text();
202
+ console.error(`[codex-copilot-dx] ✗ upstream ${resp.status} ${resp.statusText}`);
203
+ console.error(`[codex-copilot-dx] headers:`, Object.fromEntries(resp.headers));
204
+ console.error(`[codex-copilot-dx] body:`, errText.slice(0, 2000));
205
+ res.writeHead(resp.status, { "Content-Type": resp.headers.get("content-type") || "application/json" });
206
+ res.end(errText);
207
+ return;
208
+ }
209
+
61
210
  if (reqBody.stream) {
62
211
  res.writeHead(resp.status, {
63
212
  "Content-Type": resp.headers.get("content-type") || "text/event-stream",
@@ -79,19 +228,32 @@ async function proxyCopilotResponses(reqBody, req, res) {
79
228
 
80
229
  async function proxyCopilotResponsesWS(reqBody, wsSend, onDone) {
81
230
  const token = await getCopilotToken();
231
+ const payload = { ...reqBody, stream: true };
232
+ const beforeBytes = Buffer.byteLength(JSON.stringify(payload));
233
+ await optimizeImagesInBody(payload);
234
+ const bodyStr = JSON.stringify(payload);
235
+ const bodyBuf = Buffer.from(bodyStr, "utf-8");
236
+ const summary = summarizeReqBody(payload);
237
+ console.log(`[codex-copilot-dx] → responses (ws) model=${payload.model} bytes=${bodyBuf.length} (was ${beforeBytes}) input_items=${summary.items} images=${summary.images} biggest_item=${summary.biggest}b (${summary.biggestKind})`);
238
+
82
239
  const resp = await fetch(`${COPILOT_API}/v1/responses`, {
83
240
  method: "POST",
84
241
  headers: {
85
242
  Authorization: `Bearer ${token}`,
86
- "Content-Type": "application/json",
243
+ "Content-Type": "application/json; charset=utf-8",
244
+ "Content-Length": String(bodyBuf.length),
87
245
  "Editor-Version": "vscode/1.90.0",
88
246
  "Copilot-Integration-Id": "vscode-chat",
247
+ "Accept": "text/event-stream",
89
248
  },
90
- body: JSON.stringify({ ...reqBody, stream: true }),
249
+ body: bodyBuf,
91
250
  });
92
251
 
93
252
  if (!resp.ok) {
94
253
  const err = await resp.text();
254
+ console.error(`[codex-copilot-dx] ✗ upstream(ws) ${resp.status} ${resp.statusText}`);
255
+ console.error(`[codex-copilot-dx] headers:`, Object.fromEntries(resp.headers));
256
+ console.error(`[codex-copilot-dx] body:`, err.slice(0, 2000));
95
257
  wsSend(JSON.stringify({ type: "error", error: { message: err, code: resp.status } }));
96
258
  onDone();
97
259
  return;
@@ -100,18 +262,42 @@ async function proxyCopilotResponsesWS(reqBody, wsSend, onDone) {
100
262
  const reader = resp.body.getReader();
101
263
  const decoder = new TextDecoder();
102
264
  let buf = "";
103
- while (true) {
104
- const { done, value } = await reader.read();
105
- if (done) break;
106
- buf += decoder.decode(value, { stream: true });
107
- const lines = buf.split("\n");
108
- buf = lines.pop();
109
- for (const line of lines) {
110
- if (!line.startsWith("data: ")) continue;
111
- const data = line.slice(6).trim();
112
- if (data === "[DONE]") continue;
113
- try { wsSend(JSON.stringify(JSON.parse(data))); } catch {}
265
+ let currentEvent = null;
266
+ let sawCompleted = false;
267
+ let lastResponseId = `resp_${uid()}`;
268
+ let lastModel = reqBody.model || "unknown";
269
+ try {
270
+ while (true) {
271
+ const { done, value } = await reader.read();
272
+ if (done) break;
273
+ buf += decoder.decode(value, { stream: true });
274
+ const lines = buf.split("\n");
275
+ buf = lines.pop();
276
+ for (const line of lines) {
277
+ if (line.startsWith("event: ")) { currentEvent = line.slice(7).trim(); continue; }
278
+ if (!line.startsWith("data: ")) { if (line === "") currentEvent = null; continue; }
279
+ const data = line.slice(6).trim();
280
+ if (data === "[DONE]") continue;
281
+ let parsed;
282
+ try { parsed = JSON.parse(data); } catch { continue; }
283
+ if (!parsed.type && currentEvent) parsed.type = currentEvent;
284
+ if (!parsed.type) continue;
285
+ if (parsed.response?.id) lastResponseId = parsed.response.id;
286
+ if (parsed.response?.model) lastModel = parsed.response.model;
287
+ if (parsed.type === "response.completed") sawCompleted = true;
288
+ wsSend(JSON.stringify(parsed));
289
+ }
114
290
  }
291
+ } catch (e) {
292
+ wsSend(JSON.stringify({ type: "error", error: { message: e.message || String(e), code: 500 } }));
293
+ onDone();
294
+ return;
295
+ }
296
+ if (!sawCompleted) {
297
+ wsSend(JSON.stringify({
298
+ type: "response.completed",
299
+ response: { id: lastResponseId, object: "response", status: "completed", model: lastModel, output: [], usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 } },
300
+ }));
115
301
  }
116
302
  onDone();
117
303
  }
@@ -179,7 +365,23 @@ function processStream(upstreamRes, emitEvent, onDone) {
179
365
  emitEvent("response.output_item.added", { output_index: 0, item: { type: "message", id: itemId, role: "assistant", status: "in_progress", content: [] } });
180
366
  emitEvent("response.content_part.added", { output_index: 0, content_index: 0, part: { type: "output_text", text: "" } });
181
367
 
182
- let buf = "", fullText = "", toolCalls = {}, hasToolCalls = false;
368
+ let buf = "", fullText = "", toolCalls = {}, hasToolCalls = false, finished = false;
369
+
370
+ const finish = (errMsg) => {
371
+ if (finished) return;
372
+ finished = true;
373
+ if (errMsg) {
374
+ emitEvent("error", { error: { message: errMsg, code: 500 } });
375
+ } else {
376
+ emitEvent("response.output_text.done", { output_index: 0, content_index: 0, text: fullText });
377
+ emitEvent("response.output_item.done", { output_index: 0, item: { type: "message", id: itemId, role: "assistant", status: "completed", content: [{ type: "output_text", text: fullText }] } });
378
+ const output = hasToolCalls
379
+ ? Object.entries(toolCalls).map(([id, tc]) => ({ type: "function_call", id, call_id: id, name: tc.name, arguments: tc.arguments, status: "completed" }))
380
+ : [{ type: "message", id: itemId, role: "assistant", status: "completed", content: [{ type: "output_text", text: fullText }] }];
381
+ emitEvent("response.completed", { response: { id: respId, object: "response", status: "completed", model: actualModel, output, usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 } } });
382
+ }
383
+ onDone();
384
+ };
183
385
 
184
386
  upstreamRes.on("data", (chunk) => {
185
387
  buf += chunk.toString();
@@ -197,6 +399,7 @@ function processStream(upstreamRes, emitEvent, onDone) {
197
399
  ? Object.entries(toolCalls).map(([id, tc]) => ({ type: "function_call", id, call_id: id, name: tc.name, arguments: tc.arguments, status: "completed" }))
198
400
  : [{ type: "message", id: itemId, role: "assistant", status: "completed", content: [{ type: "output_text", text: fullText }] }];
199
401
  emitEvent("response.completed", { response: { id: respId, object: "response", status: "completed", model: actualModel, output, usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 } } });
402
+ finished = true;
200
403
  onDone();
201
404
  return;
202
405
  }
@@ -217,8 +420,8 @@ function processStream(upstreamRes, emitEvent, onDone) {
217
420
  }
218
421
  }
219
422
  });
220
- upstreamRes.on("end", () => onDone());
221
- upstreamRes.on("error", () => onDone());
423
+ upstreamRes.on("end", () => finish());
424
+ upstreamRes.on("error", (e) => finish(e?.message || "upstream stream error"));
222
425
  }
223
426
 
224
427
  function forwardToChat(chatReq, emitEvent, onDone, onError) {
@@ -274,7 +477,16 @@ function wsSend(socket, data, opcode = 0x1) {
274
477
  }
275
478
 
276
479
  async function handleWebSocket(req, socket) {
277
- const key = req.headers["sec-websocket-key"];
480
+ let rawKey = req.headers["sec-websocket-key"];
481
+ if (Array.isArray(rawKey)) rawKey = rawKey[0];
482
+ const key = (rawKey || "").trim();
483
+ const upgrade = String(req.headers["upgrade"] || "").toLowerCase();
484
+ const connection = String(req.headers["connection"] || "").toLowerCase();
485
+ if (!key || upgrade !== "websocket" || !connection.includes("upgrade")) {
486
+ socket.write("HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n");
487
+ socket.destroy();
488
+ return;
489
+ }
278
490
  const accept = createHash("sha1").update(key + "258EAFA5-E914-47DA-95CA-5AB9FC6AB199").digest("base64");
279
491
  socket.write("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: " + accept + "\r\n\r\n");
280
492
 
@@ -357,9 +569,13 @@ export function startAdapter(port = 4142) {
357
569
  });
358
570
 
359
571
  server.on("upgrade", (req, socket) => {
360
- if (req.url?.startsWith("/v1/responses")) {
361
- handleWebSocket(req, socket).catch((e) => { console.error("[codex-copilot-dx] WS error:", e.message); socket.destroy(); });
362
- } else socket.destroy();
572
+ // Codex Desktop 0.130+ negotiates a "responses_websockets" server-push
573
+ // protocol on WS upgrade. We don't implement that protocol; accepting the
574
+ // upgrade and waiting for a client request just hangs and triggers a
575
+ // 5-attempt reconnect storm. Refuse the upgrade so Codex falls back to
576
+ // plain HTTP SSE, which this adapter handles correctly.
577
+ socket.write("HTTP/1.1 426 Upgrade Required\r\nConnection: close\r\nContent-Length: 0\r\n\r\n");
578
+ socket.destroy();
363
579
  });
364
580
 
365
581
  return new Promise((resolve) => {