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 +19 -0
- package/README.md +11 -0
- package/package.json +5 -3
- package/src/adapter.mjs +166 -4
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.
|
|
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:
|
|
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:
|
|
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;
|