codex-copilot-dx 0.4.27 → 0.4.29
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/LICENSE +21 -0
- package/README.md +9 -7
- package/package.json +12 -3
- package/src/adapter.mjs +60 -16
- package/src/http-transport.mjs +46 -8
- package/src/image-optimization.mjs +41 -20
- package/src/lock.mjs +58 -5
- package/src/models.mjs +1 -1
- package/src/stream.mjs +22 -2
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dale Xiao
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -12,12 +12,12 @@ A single in-process adapter (port `2026`) exposes both APIs over your Copilot su
|
|
|
12
12
|
|
|
13
13
|
Supports both HTTP SSE streaming and non-streaming.
|
|
14
14
|
|
|
15
|
-
Codex Auto-review requests use the hidden `codex-auto-review` model ID. The adapter maps it to Copilot's `gpt-5.
|
|
15
|
+
Codex Auto-review requests use the hidden `codex-auto-review` model ID. The adapter maps it to Copilot's `gpt-5.5` Responses model by default and logs both model IDs when the mapping is used.
|
|
16
16
|
|
|
17
17
|
## Prerequisites
|
|
18
18
|
|
|
19
19
|
- GitHub Copilot subscription (Individual, Business, or Enterprise)
|
|
20
|
-
- Node.js
|
|
20
|
+
- Node.js 22.15+ (required for built-in Zstandard request decompression)
|
|
21
21
|
- [Codex Desktop](https://openai.com/codex), [Claude Code](https://claude.com/claude-code), and/or Claude App installed
|
|
22
22
|
|
|
23
23
|
## Usage
|
|
@@ -100,21 +100,23 @@ Environment variables:
|
|
|
100
100
|
| `ADAPTER_HOST` | `127.0.0.1` | Host for the adapter; only loopback hosts are allowed by default |
|
|
101
101
|
| `ADAPTER_PORT` | `2026` | Port for the adapter |
|
|
102
102
|
| `CCDX_ALLOW_LAN` | unset | Set to `1` to allow non-loopback `ADAPTER_HOST` values such as `0.0.0.0`; exposes your Copilot-backed adapter beyond this machine |
|
|
103
|
-
| `CCDX_MAX_BODY_BYTES` | `
|
|
104
|
-
| `CCDX_MAX_DECODED_BODY_BYTES` | `
|
|
103
|
+
| `CCDX_MAX_BODY_BYTES` | `67108864` | Maximum compressed/raw request body size |
|
|
104
|
+
| `CCDX_MAX_DECODED_BODY_BYTES` | `134217728` | Maximum decoded request body size after decompression |
|
|
105
|
+
| `CCDX_MAX_SSE_BUFFER_BYTES` | `8388608` | Maximum buffered bytes for one unterminated upstream SSE line/event |
|
|
105
106
|
| `CCDX_UPSTREAM_TIMEOUT_MS` | `120000` | Timeout for non-streaming upstream Copilot requests |
|
|
106
107
|
| `CCDX_STREAM_HANDSHAKE_TIMEOUT_MS` | `120000` | Timeout while waiting for upstream streaming response headers |
|
|
107
108
|
| `CCDX_STREAM_IDLE_TIMEOUT_MS` | `120000` | Maximum idle time between upstream streaming body chunks |
|
|
108
109
|
| `CCDX_UPSTREAM_RETRIES` | `2` | Retries for transient Copilot upstream network errors; capped at `5` |
|
|
109
110
|
| `CCDX_UPSTREAM_RETRY_DELAY_MS` | `300` | Initial upstream retry backoff in milliseconds; capped at `5000` |
|
|
110
|
-
| `CCDX_AUTO_REVIEW_MODEL` | `gpt-5.
|
|
111
|
+
| `CCDX_AUTO_REVIEW_MODEL` | `gpt-5.5` | Copilot Responses model used for Codex Auto-review requests |
|
|
111
112
|
| `CCDX_LOG_PATH` | unset | Mirror terminal logs to a file; set to `1` for `~/.local/share/codex-copilot-dx/debug.log` |
|
|
112
113
|
| `CCDX_LOG_LEVEL` | `info` | Set to `debug` to include upstream request attempts, status codes, retry causes, and timings |
|
|
113
114
|
| `CCDX_LOG_MAX_BYTES` | `16777216` | Rotate the debug log at this size, retaining one `.1` backup; set to `0` to disable rotation |
|
|
114
115
|
| `CCDX_IMG_MAX_DIM` | `2048` | Max long edge in pixels for image downscaling |
|
|
115
116
|
| `CCDX_IMG_QUALITY` | `85` | WebP quality used when re-encoding images |
|
|
116
117
|
| `CCDX_IMG_MIN_BYTES` | `100000` | Images smaller than this are left untouched |
|
|
117
|
-
| `CCDX_IMG_CONCURRENCY` | `
|
|
118
|
+
| `CCDX_IMG_CONCURRENCY` | `2` | Global concurrent image optimization tasks; values above `12` are capped at `12` |
|
|
119
|
+
| `CCDX_IMG_MAX_INPUT_PIXELS` | `40000000` | Maximum decoded pixels accepted by `sharp` for one image |
|
|
118
120
|
| `CCDX_DISABLE_IMG_OPT` | unset | Set to `1` to disable image optimization |
|
|
119
121
|
| `CCDX_CONFIGURE_CLAUDE_DESKTOP` | unset | Set to `1` to write the Claude App 3P gateway profile during startup |
|
|
120
122
|
| `CCDX_CLAUDE_DESKTOP_API_KEY` | generated for opt-in setup | Bearer key written into the Claude App profile and recognized by the adapter for model discovery |
|
|
@@ -151,7 +153,7 @@ codex-copilot-dx usage
|
|
|
151
153
|
|
|
152
154
|
Long computer-use sessions can accumulate screenshots inside the conversation history. Each screenshot is shipped on later turns, and GitHub Copilot's `/responses` endpoint can reject oversized requests with `413 Payload Too Large`.
|
|
153
155
|
|
|
154
|
-
The adapter automatically downsamples embedded screenshots to long-edge <= 2048 px and re-encodes them as WebP before forwarding `/v1/responses`.
|
|
156
|
+
The adapter automatically downsamples embedded screenshots to long-edge <= 2048 px and re-encodes them as WebP before forwarding `/v1/responses`. It keeps an existing in-bounds WebP unchanged, never replaces an image with a larger encoding, and applies one global concurrency limit across direct and tool-output images.
|
|
155
157
|
|
|
156
158
|
Newer ChatGPT/Codex clients can advertise an `image_gen` namespace that already exists upstream. The adapter removes that exact conflicting client tool before forwarding and retries once only when Copilot explicitly reports an image namespace collision. Image inputs and screenshot optimization remain enabled.
|
|
157
159
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "codex-copilot-dx",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.29",
|
|
4
4
|
"description": "Use Codex Desktop, Claude Code, and Claude App with GitHub Copilot",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -25,15 +25,24 @@
|
|
|
25
25
|
"responses-api"
|
|
26
26
|
],
|
|
27
27
|
"license": "MIT",
|
|
28
|
+
"repository": {
|
|
29
|
+
"type": "git",
|
|
30
|
+
"url": "git+https://github.com/dalexiao/codex-copilot-dx.git"
|
|
31
|
+
},
|
|
32
|
+
"homepage": "https://github.com/dalexiao/codex-copilot-dx#readme",
|
|
33
|
+
"bugs": {
|
|
34
|
+
"url": "https://github.com/dalexiao/codex-copilot-dx/issues"
|
|
35
|
+
},
|
|
28
36
|
"engines": {
|
|
29
|
-
"node": ">=
|
|
37
|
+
"node": ">=22.15.0"
|
|
30
38
|
},
|
|
31
39
|
"scripts": {
|
|
32
40
|
"test": "node --test",
|
|
33
41
|
"test:smoke": "node scripts/offline-smoke.mjs",
|
|
34
42
|
"test:e2e": "bash test/e2e.sh",
|
|
35
43
|
"pack:check": "npm pack --dry-run --json",
|
|
36
|
-
"verify": "npm test && npm run test:smoke && npm run pack:check"
|
|
44
|
+
"verify": "npm test && npm run test:smoke && npm run pack:check",
|
|
45
|
+
"prepublishOnly": "npm run verify"
|
|
37
46
|
},
|
|
38
47
|
"dependencies": {
|
|
39
48
|
"gpt-tokenizer": "^3.4.0",
|
package/src/adapter.mjs
CHANGED
|
@@ -69,6 +69,7 @@ function isResponsesOnlyModel(model) {
|
|
|
69
69
|
const DEFAULT_UPSTREAM_TIMEOUT_MS = 120 * 1000;
|
|
70
70
|
const DEFAULT_STREAM_HANDSHAKE_TIMEOUT_MS = 120 * 1000;
|
|
71
71
|
const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 120 * 1000;
|
|
72
|
+
const DEFAULT_MAX_SSE_BUFFER_BYTES = 8 * 1024 * 1024;
|
|
72
73
|
|
|
73
74
|
function positiveInt(value, fallback) {
|
|
74
75
|
const n = Number.parseInt(value, 10);
|
|
@@ -78,6 +79,7 @@ function positiveInt(value, fallback) {
|
|
|
78
79
|
const UPSTREAM_TIMEOUT_MS = positiveInt(process.env.CCDX_UPSTREAM_TIMEOUT_MS, DEFAULT_UPSTREAM_TIMEOUT_MS);
|
|
79
80
|
const STREAM_HANDSHAKE_TIMEOUT_MS = positiveInt(process.env.CCDX_STREAM_HANDSHAKE_TIMEOUT_MS, DEFAULT_STREAM_HANDSHAKE_TIMEOUT_MS);
|
|
80
81
|
const STREAM_IDLE_TIMEOUT_MS = positiveInt(process.env.CCDX_STREAM_IDLE_TIMEOUT_MS, DEFAULT_STREAM_IDLE_TIMEOUT_MS);
|
|
82
|
+
const MAX_SSE_BUFFER_BYTES = positiveInt(process.env.CCDX_MAX_SSE_BUFFER_BYTES, DEFAULT_MAX_SSE_BUFFER_BYTES);
|
|
81
83
|
|
|
82
84
|
export function requestPath(reqUrl) {
|
|
83
85
|
return new URL(reqUrl || "/", "http://localhost").pathname;
|
|
@@ -324,6 +326,9 @@ function readSseEvents(buffer, onData) {
|
|
|
324
326
|
const match = buffer.match(/\r?\n\r?\n/);
|
|
325
327
|
if (!match) return buffer;
|
|
326
328
|
const chunk = buffer.slice(0, match.index);
|
|
329
|
+
if (Buffer.byteLength(chunk) > MAX_SSE_BUFFER_BYTES) {
|
|
330
|
+
throw httpError(`Upstream SSE buffer exceeds ${MAX_SSE_BUFFER_BYTES} bytes`, 502);
|
|
331
|
+
}
|
|
327
332
|
buffer = buffer.slice(match.index + match[0].length);
|
|
328
333
|
const data = chunk
|
|
329
334
|
.split(/\r?\n/)
|
|
@@ -334,6 +339,22 @@ function readSseEvents(buffer, onData) {
|
|
|
334
339
|
}
|
|
335
340
|
}
|
|
336
341
|
|
|
342
|
+
function streamErrorData(protocol, error, abort) {
|
|
343
|
+
const code = abort?.reason || "upstream_stream_error";
|
|
344
|
+
const message = `${code}: ${error?.message || "Upstream stream failed"}`;
|
|
345
|
+
if (protocol === "anthropic") {
|
|
346
|
+
return { type: "error", error: { type: "api_error", message } };
|
|
347
|
+
}
|
|
348
|
+
return { type: "error", code, message, param: null };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async function endStreamWithError(res, protocol, error, abort) {
|
|
352
|
+
if (res.destroyed || res.writableEnded) return;
|
|
353
|
+
const data = streamErrorData(protocol, error, abort);
|
|
354
|
+
await writeOrDrain(res, `event: error\ndata: ${JSON.stringify(data)}\n\n`).catch(() => false);
|
|
355
|
+
if (!res.destroyed && !res.writableEnded) res.end();
|
|
356
|
+
}
|
|
357
|
+
|
|
337
358
|
async function proxyCopilotResponses(reqContext, req, res, upstream = copilotResponses, options = {}) {
|
|
338
359
|
const opened = await openCopilotResponse(reqContext, upstream, options);
|
|
339
360
|
const { resp, errorText } = opened;
|
|
@@ -358,6 +379,9 @@ async function proxyCopilotResponses(reqContext, req, res, upstream = copilotRes
|
|
|
358
379
|
const { done, value } = await reader.read();
|
|
359
380
|
if (done) {
|
|
360
381
|
buffer += decoder.decode();
|
|
382
|
+
if (Buffer.byteLength(buffer) > MAX_SSE_BUFFER_BYTES) {
|
|
383
|
+
throw httpError(`Upstream SSE buffer exceeds ${MAX_SSE_BUFFER_BYTES} bytes`, 502);
|
|
384
|
+
}
|
|
361
385
|
readSseEvents(buffer, (data) => storeCompletedResponseFromSse(reqContext, data));
|
|
362
386
|
res.end();
|
|
363
387
|
return;
|
|
@@ -365,8 +389,14 @@ async function proxyCopilotResponses(reqContext, req, res, upstream = copilotRes
|
|
|
365
389
|
options.abort?.setTimeout(options.streamIdleTimeoutMs, "stream_idle_timeout");
|
|
366
390
|
buffer += decoder.decode(value, { stream: true });
|
|
367
391
|
buffer = readSseEvents(buffer, (data) => storeCompletedResponseFromSse(reqContext, data));
|
|
392
|
+
if (Buffer.byteLength(buffer) > MAX_SSE_BUFFER_BYTES) {
|
|
393
|
+
throw httpError(`Upstream SSE buffer exceeds ${MAX_SSE_BUFFER_BYTES} bytes`, 502);
|
|
394
|
+
}
|
|
368
395
|
if (!await writeOrDrain(res, value)) return;
|
|
369
396
|
}
|
|
397
|
+
} catch (e) {
|
|
398
|
+
logRequestFailure("Responses", e, options.abort);
|
|
399
|
+
await endStreamWithError(res, "responses", e, options.abort);
|
|
370
400
|
} finally {
|
|
371
401
|
await reader.cancel().catch(() => {});
|
|
372
402
|
reader.releaseLock();
|
|
@@ -508,11 +538,11 @@ export async function forwardToChat(chatReq, emitEvent, onDone, onError, options
|
|
|
508
538
|
}, { signal: options.signal });
|
|
509
539
|
} catch (e) {
|
|
510
540
|
const statusCode = isAbortLikeError(e) ? abortErrorStatusCode(options.abort?.reason) : 502;
|
|
511
|
-
onError(statusCode, e.message);
|
|
541
|
+
await onError(statusCode, e.message);
|
|
512
542
|
return;
|
|
513
543
|
}
|
|
514
544
|
if (!resp.ok) {
|
|
515
|
-
onError(resp.status, await resp.text());
|
|
545
|
+
await onError(resp.status, await resp.text());
|
|
516
546
|
return;
|
|
517
547
|
}
|
|
518
548
|
options.abort?.setTimeout(options.streamIdleTimeoutMs, "stream_idle_timeout");
|
|
@@ -655,7 +685,8 @@ export async function forwardToChat(chatReq, emitEvent, onDone, onError, options
|
|
|
655
685
|
}
|
|
656
686
|
}
|
|
657
687
|
} catch (e) {
|
|
658
|
-
|
|
688
|
+
const statusCode = isAbortLikeError(e) ? abortErrorStatusCode(options.abort?.reason) : 502;
|
|
689
|
+
await onError(statusCode, e?.message || "upstream stream error");
|
|
659
690
|
return;
|
|
660
691
|
}
|
|
661
692
|
await emitCompleted();
|
|
@@ -723,7 +754,14 @@ export function createAdapterHandler(options = {}) {
|
|
|
723
754
|
rememberResponseHistory(prepared, data.response);
|
|
724
755
|
recordResponsesUsage({ surface: prepared.surface, mode: "stream", model, response: data.response, event: data });
|
|
725
756
|
}
|
|
726
|
-
}, () => { if (!res.writableEnded) res.end(); }, (
|
|
757
|
+
}, () => { if (!res.writableEnded) res.end(); }, async (statusCode, errMsg) => {
|
|
758
|
+
if (!res.headersSent) {
|
|
759
|
+
res.writeHead(statusCode || 500, { "Content-Type": "text/plain; charset=utf-8" });
|
|
760
|
+
res.end(errMsg);
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
763
|
+
await endStreamWithError(res, "responses", new Error(errMsg), abort);
|
|
764
|
+
}, {
|
|
727
765
|
signal: abort.signal,
|
|
728
766
|
abort,
|
|
729
767
|
streamIdleTimeoutMs,
|
|
@@ -855,18 +893,24 @@ export function createAdapterHandler(options = {}) {
|
|
|
855
893
|
res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" });
|
|
856
894
|
let messageId;
|
|
857
895
|
let usage;
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
896
|
+
try {
|
|
897
|
+
await streamAnthropicFromLines(
|
|
898
|
+
webStreamLines(upstream, {
|
|
899
|
+
onChunk: () => abort.setTimeout(streamIdleTimeoutMs, "stream_idle_timeout"),
|
|
900
|
+
}),
|
|
901
|
+
async (event, data) => {
|
|
902
|
+
if (event === "message_start") messageId = data.message?.id;
|
|
903
|
+
if (event === "message_delta") usage = data.usage;
|
|
904
|
+
await writeOrDrain(res, `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
905
|
+
},
|
|
906
|
+
requestedModel,
|
|
907
|
+
{ forceModel: forceRequestedModel },
|
|
908
|
+
);
|
|
909
|
+
} catch (e) {
|
|
910
|
+
logRequestFailure("Messages", e, abort);
|
|
911
|
+
await endStreamWithError(res, "anthropic", e, abort);
|
|
912
|
+
return;
|
|
913
|
+
}
|
|
870
914
|
recordAnthropicUsage({ surface: "messages", mode: "stream", model: requestedModel, responseId: messageId, usage });
|
|
871
915
|
if (!res.writableEnded) res.end();
|
|
872
916
|
} else {
|
package/src/http-transport.mjs
CHANGED
|
@@ -2,8 +2,8 @@ import { promisify } from "node:util";
|
|
|
2
2
|
import * as zlib from "node:zlib";
|
|
3
3
|
import { status } from "./status.mjs";
|
|
4
4
|
|
|
5
|
-
const DEFAULT_MAX_BODY_BYTES =
|
|
6
|
-
const DEFAULT_MAX_DECODED_BODY_BYTES =
|
|
5
|
+
const DEFAULT_MAX_BODY_BYTES = 64 * 1024 * 1024;
|
|
6
|
+
const DEFAULT_MAX_DECODED_BODY_BYTES = 128 * 1024 * 1024;
|
|
7
7
|
const gunzipAsync = promisify(zlib.gunzip);
|
|
8
8
|
const inflateAsync = promisify(zlib.inflate);
|
|
9
9
|
const brotliDecompressAsync = promisify(zlib.brotliDecompress);
|
|
@@ -27,8 +27,12 @@ function payloadTooLarge(kind, maxBytes) {
|
|
|
27
27
|
return httpError(`${kind} request body exceeds ${maxBytes} bytes`, 413);
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
function requestContentLength(req) {
|
|
31
|
+
return Number.parseInt(req.headers?.["content-length"] || "", 10);
|
|
32
|
+
}
|
|
33
|
+
|
|
30
34
|
async function readRequestBuffer(req, maxBytes) {
|
|
31
|
-
const contentLength =
|
|
35
|
+
const contentLength = requestContentLength(req);
|
|
32
36
|
if (Number.isFinite(contentLength) && contentLength > maxBytes) {
|
|
33
37
|
throw payloadTooLarge("Raw", maxBytes);
|
|
34
38
|
}
|
|
@@ -44,6 +48,35 @@ async function readRequestBuffer(req, maxBytes) {
|
|
|
44
48
|
return Buffer.concat(chunks);
|
|
45
49
|
}
|
|
46
50
|
|
|
51
|
+
async function readIdentityText(req, maxBodyBytes, maxDecodedBodyBytes) {
|
|
52
|
+
const contentLength = requestContentLength(req);
|
|
53
|
+
if (Number.isFinite(contentLength) && contentLength > maxBodyBytes) {
|
|
54
|
+
throw payloadTooLarge("Raw", maxBodyBytes);
|
|
55
|
+
}
|
|
56
|
+
if (Number.isFinite(contentLength) && contentLength > maxDecodedBodyBytes) {
|
|
57
|
+
throw payloadTooLarge("Decoded", maxDecodedBodyBytes);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const decoder = new TextDecoder();
|
|
61
|
+
let text = "";
|
|
62
|
+
let total = 0;
|
|
63
|
+
for await (const chunk of req) {
|
|
64
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
65
|
+
total += buffer.length;
|
|
66
|
+
if (total > maxBodyBytes) throw payloadTooLarge("Raw", maxBodyBytes);
|
|
67
|
+
if (total > maxDecodedBodyBytes) throw payloadTooLarge("Decoded", maxDecodedBodyBytes);
|
|
68
|
+
text += decoder.decode(buffer, { stream: true });
|
|
69
|
+
}
|
|
70
|
+
return text + decoder.decode();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function contentEncodings(contentEncoding) {
|
|
74
|
+
return String(contentEncoding || "identity")
|
|
75
|
+
.split(",")
|
|
76
|
+
.map((value) => value.trim().toLowerCase())
|
|
77
|
+
.filter(Boolean);
|
|
78
|
+
}
|
|
79
|
+
|
|
47
80
|
async function decompressBody(decompress, buffer, maxBytes) {
|
|
48
81
|
try {
|
|
49
82
|
return await decompress(buffer, { maxOutputLength: maxBytes });
|
|
@@ -56,10 +89,7 @@ async function decompressBody(decompress, buffer, maxBytes) {
|
|
|
56
89
|
}
|
|
57
90
|
|
|
58
91
|
async function decodeRequestBuffer(buffer, contentEncoding, maxBytes) {
|
|
59
|
-
const encodings =
|
|
60
|
-
.split(",")
|
|
61
|
-
.map((value) => value.trim().toLowerCase())
|
|
62
|
-
.filter(Boolean);
|
|
92
|
+
const encodings = contentEncodings(contentEncoding);
|
|
63
93
|
|
|
64
94
|
let decoded = buffer;
|
|
65
95
|
for (const encoding of encodings.reverse()) {
|
|
@@ -86,6 +116,10 @@ export async function readJsonBody(req, {
|
|
|
86
116
|
maxBodyBytes = MAX_BODY_BYTES,
|
|
87
117
|
maxDecodedBodyBytes = MAX_DECODED_BODY_BYTES,
|
|
88
118
|
} = {}) {
|
|
119
|
+
const encodings = contentEncodings(req.headers?.["content-encoding"]);
|
|
120
|
+
if (encodings.every((encoding) => encoding === "identity")) {
|
|
121
|
+
return JSON.parse(await readIdentityText(req, maxBodyBytes, maxDecodedBodyBytes));
|
|
122
|
+
}
|
|
89
123
|
const buffer = await readRequestBuffer(req, maxBodyBytes);
|
|
90
124
|
const decoded = await decodeRequestBuffer(buffer, req.headers?.["content-encoding"], maxDecodedBodyBytes);
|
|
91
125
|
return JSON.parse(decoded.toString("utf8"));
|
|
@@ -93,7 +127,11 @@ export async function readJsonBody(req, {
|
|
|
93
127
|
|
|
94
128
|
export function sendJsonError(res, err, fallbackStatus = 400) {
|
|
95
129
|
if (res.destroyed || res.writableEnded) return;
|
|
96
|
-
if (
|
|
130
|
+
if (res.headersSent) {
|
|
131
|
+
res.end();
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
res.writeHead(err?.statusCode || fallbackStatus, { "Content-Type": "application/json" });
|
|
97
135
|
res.end(JSON.stringify({ error: err?.message || "Request failed" }));
|
|
98
136
|
}
|
|
99
137
|
|
|
@@ -1,14 +1,26 @@
|
|
|
1
1
|
import { status } from "./status.mjs";
|
|
2
2
|
|
|
3
|
-
const
|
|
4
|
-
const IMG_QUALITY = Number.parseInt(process.env.CCDX_IMG_QUALITY || "85", 10);
|
|
5
|
-
const IMG_MIN_BYTES = Number.parseInt(process.env.CCDX_IMG_MIN_BYTES || "100000", 10);
|
|
6
|
-
const DEFAULT_IMG_CONCURRENCY = 4;
|
|
3
|
+
const DEFAULT_IMG_CONCURRENCY = 2;
|
|
7
4
|
const IMG_MAX_CONCURRENCY = 12;
|
|
5
|
+
const DEFAULT_IMG_MAX_INPUT_PIXELS = 40 * 1000 * 1000;
|
|
6
|
+
const IMG_MAX_DIM = positiveInt(process.env.CCDX_IMG_MAX_DIM, 2048);
|
|
7
|
+
const IMG_QUALITY = positiveInt(process.env.CCDX_IMG_QUALITY, 85, 100);
|
|
8
|
+
const IMG_MIN_BYTES = nonNegativeInt(process.env.CCDX_IMG_MIN_BYTES, 100000);
|
|
9
|
+
const IMG_MAX_INPUT_PIXELS = positiveInt(process.env.CCDX_IMG_MAX_INPUT_PIXELS, DEFAULT_IMG_MAX_INPUT_PIXELS);
|
|
8
10
|
const IMG_OPT_DISABLED = process.env.CCDX_DISABLE_IMG_OPT === "1";
|
|
9
11
|
const IMG_CONCURRENCY = parseImageConcurrency(process.env.CCDX_IMG_CONCURRENCY);
|
|
10
12
|
let sharpImport = null;
|
|
11
13
|
|
|
14
|
+
function positiveInt(value, fallback, max = Number.MAX_SAFE_INTEGER) {
|
|
15
|
+
const n = Number.parseInt(value, 10);
|
|
16
|
+
return Number.isFinite(n) && n > 0 ? Math.min(n, max) : fallback;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function nonNegativeInt(value, fallback) {
|
|
20
|
+
const n = Number.parseInt(value, 10);
|
|
21
|
+
return Number.isFinite(n) && n >= 0 ? n : fallback;
|
|
22
|
+
}
|
|
23
|
+
|
|
12
24
|
export function parseImageConcurrency(value) {
|
|
13
25
|
const n = Number.parseInt(value, 10);
|
|
14
26
|
return Number.isFinite(n) && n > 0 ? Math.min(n, IMG_MAX_CONCURRENCY) : DEFAULT_IMG_CONCURRENCY;
|
|
@@ -31,11 +43,20 @@ export async function optimizeImageDataUrl(dataUrl) {
|
|
|
31
43
|
|
|
32
44
|
try {
|
|
33
45
|
const resize = await sharp();
|
|
34
|
-
const
|
|
46
|
+
const image = resize(raw, { failOn: "none", limitInputPixels: IMG_MAX_INPUT_PIXELS });
|
|
47
|
+
const metadata = await image.metadata();
|
|
48
|
+
if (mime === "image/webp"
|
|
49
|
+
&& metadata.width <= IMG_MAX_DIM
|
|
50
|
+
&& metadata.height <= IMG_MAX_DIM
|
|
51
|
+
&& (!metadata.orientation || metadata.orientation === 1)) {
|
|
52
|
+
return dataUrl;
|
|
53
|
+
}
|
|
54
|
+
const out = await image
|
|
35
55
|
.rotate()
|
|
36
56
|
.resize(IMG_MAX_DIM, IMG_MAX_DIM, { fit: "inside", withoutEnlargement: true })
|
|
37
57
|
.webp({ quality: IMG_QUALITY, effort: 4 })
|
|
38
58
|
.toBuffer();
|
|
59
|
+
if (out.length >= raw.length) return dataUrl;
|
|
39
60
|
const ratio = ((out.length / raw.length) * 100).toFixed(1);
|
|
40
61
|
console.log(status("info", `image ${(raw.length / 1024).toFixed(0)}KB ${mime} -> ${(out.length / 1024).toFixed(0)}KB webp (${ratio}%)`));
|
|
41
62
|
return `data:image/webp;base64,${out.toString("base64")}`;
|
|
@@ -57,16 +78,16 @@ export async function runWithConcurrency(taskFns, concurrency) {
|
|
|
57
78
|
}));
|
|
58
79
|
}
|
|
59
80
|
|
|
60
|
-
function visitImageParts(parts, tasks) {
|
|
81
|
+
function visitImageParts(parts, tasks, optimizeImage) {
|
|
61
82
|
if (!Array.isArray(parts)) return;
|
|
62
83
|
for (const part of parts) {
|
|
63
84
|
if (!part) continue;
|
|
64
85
|
if (part.type === "input_image" && typeof part.image_url === "string") {
|
|
65
|
-
tasks.push(async () => { part.image_url = await
|
|
86
|
+
tasks.push(async () => { part.image_url = await optimizeImage(part.image_url); });
|
|
66
87
|
} else if (part.type === "image" && part.source?.type === "base64" && part.source?.data) {
|
|
67
88
|
tasks.push(async () => {
|
|
68
89
|
const dataUrl = `data:${part.source.media_type || "image/png"};base64,${part.source.data}`;
|
|
69
|
-
const optimized = await
|
|
90
|
+
const optimized = await optimizeImage(dataUrl);
|
|
70
91
|
const match = /^data:([^;]+);base64,(.+)$/.exec(optimized);
|
|
71
92
|
if (match) {
|
|
72
93
|
part.source.media_type = match[1];
|
|
@@ -77,32 +98,31 @@ function visitImageParts(parts, tasks) {
|
|
|
77
98
|
}
|
|
78
99
|
}
|
|
79
100
|
|
|
80
|
-
export async function optimizeImagesInBody(reqBody
|
|
101
|
+
export async function optimizeImagesInBody(reqBody, {
|
|
102
|
+
concurrency = IMG_CONCURRENCY,
|
|
103
|
+
optimizeImage = optimizeImageDataUrl,
|
|
104
|
+
} = {}) {
|
|
81
105
|
if (IMG_OPT_DISABLED || !Array.isArray(reqBody.input)) return reqBody;
|
|
82
106
|
const tasks = [];
|
|
107
|
+
const finalize = [];
|
|
83
108
|
|
|
84
109
|
for (const item of reqBody.input) {
|
|
85
110
|
if (!item) continue;
|
|
86
111
|
if (item.type === "message" && Array.isArray(item.content)) {
|
|
87
|
-
visitImageParts(item.content, tasks);
|
|
112
|
+
visitImageParts(item.content, tasks, optimizeImage);
|
|
88
113
|
}
|
|
89
114
|
if (item.type === "function_call_output") {
|
|
90
115
|
if (Array.isArray(item.output)) {
|
|
91
|
-
visitImageParts(item.output, tasks);
|
|
116
|
+
visitImageParts(item.output, tasks, optimizeImage);
|
|
92
117
|
} else if (typeof item.output === "string") {
|
|
93
118
|
const trimmed = item.output.trim();
|
|
94
119
|
if (trimmed.startsWith("[")) {
|
|
95
120
|
try {
|
|
96
121
|
const parsed = JSON.parse(trimmed);
|
|
97
122
|
if (Array.isArray(parsed)) {
|
|
98
|
-
const
|
|
99
|
-
visitImageParts(parsed,
|
|
100
|
-
if (
|
|
101
|
-
tasks.push(async () => {
|
|
102
|
-
await runWithConcurrency(localTasks, IMG_CONCURRENCY);
|
|
103
|
-
item.output = JSON.stringify(parsed);
|
|
104
|
-
});
|
|
105
|
-
}
|
|
123
|
+
const taskCount = tasks.length;
|
|
124
|
+
visitImageParts(parsed, tasks, optimizeImage);
|
|
125
|
+
if (tasks.length > taskCount) finalize.push(() => { item.output = JSON.stringify(parsed); });
|
|
106
126
|
}
|
|
107
127
|
} catch {
|
|
108
128
|
// Leave non-JSON tool output untouched.
|
|
@@ -112,7 +132,8 @@ export async function optimizeImagesInBody(reqBody) {
|
|
|
112
132
|
}
|
|
113
133
|
}
|
|
114
134
|
|
|
115
|
-
if (tasks.length) await runWithConcurrency(tasks,
|
|
135
|
+
if (tasks.length) await runWithConcurrency(tasks, concurrency);
|
|
136
|
+
for (const apply of finalize) apply();
|
|
116
137
|
return reqBody;
|
|
117
138
|
}
|
|
118
139
|
|
package/src/lock.mjs
CHANGED
|
@@ -1,15 +1,52 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
3
4
|
|
|
4
5
|
function sleep(ms) {
|
|
5
6
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
6
7
|
}
|
|
7
8
|
|
|
9
|
+
function readLockSnapshot(lockPath) {
|
|
10
|
+
const stat = fs.statSync(lockPath);
|
|
11
|
+
let record = {};
|
|
12
|
+
try {
|
|
13
|
+
record = JSON.parse(fs.readFileSync(lockPath, "utf8"));
|
|
14
|
+
} catch {}
|
|
15
|
+
return {
|
|
16
|
+
dev: stat.dev,
|
|
17
|
+
ino: stat.ino,
|
|
18
|
+
mtimeMs: stat.mtimeMs,
|
|
19
|
+
size: stat.size,
|
|
20
|
+
pid: Number(record.pid),
|
|
21
|
+
owner: typeof record.owner === "string" ? record.owner : "",
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function processIsAlive(pid) {
|
|
26
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) return false;
|
|
27
|
+
try {
|
|
28
|
+
process.kill(pid, 0);
|
|
29
|
+
return true;
|
|
30
|
+
} catch (e) {
|
|
31
|
+
return e?.code === "EPERM";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function sameLock(left, right) {
|
|
36
|
+
return left.dev === right.dev
|
|
37
|
+
&& left.ino === right.ino
|
|
38
|
+
&& left.mtimeMs === right.mtimeMs
|
|
39
|
+
&& left.size === right.size
|
|
40
|
+
&& left.owner === right.owner;
|
|
41
|
+
}
|
|
42
|
+
|
|
8
43
|
function maybeRemoveStaleLock(lockPath, staleMs, nowMs) {
|
|
9
44
|
if (!Number.isFinite(staleMs) || staleMs <= 0) return false;
|
|
10
45
|
try {
|
|
11
|
-
const
|
|
12
|
-
if (nowMs -
|
|
46
|
+
const first = readLockSnapshot(lockPath);
|
|
47
|
+
if (nowMs - first.mtimeMs <= staleMs || processIsAlive(first.pid)) return false;
|
|
48
|
+
const current = readLockSnapshot(lockPath);
|
|
49
|
+
if (!sameLock(first, current)) return false;
|
|
13
50
|
fs.unlinkSync(lockPath);
|
|
14
51
|
return true;
|
|
15
52
|
} catch (e) {
|
|
@@ -26,12 +63,20 @@ export async function withFileLock(lockPath, fn, {
|
|
|
26
63
|
} = {}) {
|
|
27
64
|
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
|
|
28
65
|
const started = now();
|
|
66
|
+
const owner = randomUUID();
|
|
29
67
|
let fd = null;
|
|
30
68
|
|
|
31
69
|
while (fd === null) {
|
|
32
70
|
try {
|
|
33
|
-
|
|
34
|
-
|
|
71
|
+
const candidateFd = fs.openSync(lockPath, "wx", 0o600);
|
|
72
|
+
try {
|
|
73
|
+
fs.writeFileSync(candidateFd, JSON.stringify({ pid: process.pid, owner, created_at: new Date().toISOString() }));
|
|
74
|
+
fd = candidateFd;
|
|
75
|
+
} catch (e) {
|
|
76
|
+
fs.closeSync(candidateFd);
|
|
77
|
+
try { fs.unlinkSync(lockPath); } catch {}
|
|
78
|
+
throw e;
|
|
79
|
+
}
|
|
35
80
|
break;
|
|
36
81
|
} catch (e) {
|
|
37
82
|
if (e?.code !== "EEXIST") throw e;
|
|
@@ -50,11 +95,19 @@ export async function withFileLock(lockPath, fn, {
|
|
|
50
95
|
try {
|
|
51
96
|
if (fd !== null) fs.closeSync(fd);
|
|
52
97
|
} finally {
|
|
98
|
+
let currentOwner = "";
|
|
53
99
|
try {
|
|
54
|
-
|
|
100
|
+
currentOwner = readLockSnapshot(lockPath).owner;
|
|
55
101
|
} catch (e) {
|
|
56
102
|
if (e?.code !== "ENOENT") throw e;
|
|
57
103
|
}
|
|
104
|
+
if (currentOwner === owner) {
|
|
105
|
+
try {
|
|
106
|
+
fs.unlinkSync(lockPath);
|
|
107
|
+
} catch (e) {
|
|
108
|
+
if (e?.code !== "ENOENT") throw e;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
58
111
|
}
|
|
59
112
|
}
|
|
60
113
|
}
|
package/src/models.mjs
CHANGED
|
@@ -61,7 +61,7 @@ const DEFAULT_CLAUDE_MODEL_ALIAS_DEFS = [
|
|
|
61
61
|
];
|
|
62
62
|
|
|
63
63
|
export const CODEX_AUTO_REVIEW_MODEL = "codex-auto-review";
|
|
64
|
-
export const DEFAULT_CODEX_AUTO_REVIEW_MODEL = "gpt-5.
|
|
64
|
+
export const DEFAULT_CODEX_AUTO_REVIEW_MODEL = "gpt-5.5";
|
|
65
65
|
|
|
66
66
|
function cleanList(value) {
|
|
67
67
|
return String(value || "")
|
package/src/stream.mjs
CHANGED
|
@@ -1,6 +1,21 @@
|
|
|
1
1
|
// Yield fetch Response body lines while preserving SSE semantics for callers.
|
|
2
2
|
// Strip CRLF tails and release the reader if iteration stops early.
|
|
3
|
-
|
|
3
|
+
const DEFAULT_MAX_SSE_BUFFER_BYTES = 8 * 1024 * 1024;
|
|
4
|
+
|
|
5
|
+
function positiveInt(value, fallback) {
|
|
6
|
+
const n = Number.parseInt(value, 10);
|
|
7
|
+
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const MAX_SSE_BUFFER_BYTES = positiveInt(process.env.CCDX_MAX_SSE_BUFFER_BYTES, DEFAULT_MAX_SSE_BUFFER_BYTES);
|
|
11
|
+
|
|
12
|
+
function assertSseBufferLimit(buffer, maxBufferBytes) {
|
|
13
|
+
if (Buffer.byteLength(buffer) > maxBufferBytes) {
|
|
14
|
+
throw new Error(`Upstream SSE buffer exceeds ${maxBufferBytes} bytes`);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function* webStreamLines(response, { onChunk, maxBufferBytes = MAX_SSE_BUFFER_BYTES } = {}) {
|
|
4
19
|
const reader = response.body.getReader();
|
|
5
20
|
const decoder = new TextDecoder();
|
|
6
21
|
let buf = "";
|
|
@@ -12,9 +27,14 @@ export async function* webStreamLines(response, { onChunk } = {}) {
|
|
|
12
27
|
buf += decoder.decode(value, { stream: true });
|
|
13
28
|
const lines = buf.split("\n");
|
|
14
29
|
buf = lines.pop();
|
|
15
|
-
|
|
30
|
+
assertSseBufferLimit(buf, maxBufferBytes);
|
|
31
|
+
for (const line of lines) {
|
|
32
|
+
assertSseBufferLimit(line, maxBufferBytes);
|
|
33
|
+
yield line.replace(/\r$/, "");
|
|
34
|
+
}
|
|
16
35
|
}
|
|
17
36
|
buf += decoder.decode();
|
|
37
|
+
assertSseBufferLimit(buf, maxBufferBytes);
|
|
18
38
|
if (buf.length > 0) yield buf.replace(/\r$/, "");
|
|
19
39
|
} finally {
|
|
20
40
|
await reader.cancel().catch(() => {});
|