codex-copilot-dx 0.1.5 → 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.5",
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;