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 +19 -0
- package/README.md +11 -0
- package/package.json +5 -3
- package/src/adapter.mjs +238 -22
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;
|
|
@@ -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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
if (
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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", () =>
|
|
221
|
-
upstreamRes.on("error", () =>
|
|
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
|
-
|
|
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
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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) => {
|