codex-copilot-dx 0.4.28 → 0.4.30

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 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
@@ -17,7 +17,7 @@ Codex Auto-review requests use the hidden `codex-auto-review` model ID. The adap
17
17
  ## Prerequisites
18
18
 
19
19
  - GitHub Copilot subscription (Individual, Business, or Enterprise)
20
- - Node.js 18.17+
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,8 +100,10 @@ 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` | `134217728` | Maximum compressed/raw request body size |
104
- | `CCDX_MAX_DECODED_BODY_BYTES` | `268435456` | Maximum decoded request body size after decompression |
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_UPSTREAM_BODY_BYTES` | `31457280` | Responses body target; larger image payloads are compressed more aggressively before forwarding |
106
+ | `CCDX_MAX_SSE_BUFFER_BYTES` | `8388608` | Maximum buffered bytes for one unterminated upstream SSE line/event |
105
107
  | `CCDX_UPSTREAM_TIMEOUT_MS` | `120000` | Timeout for non-streaming upstream Copilot requests |
106
108
  | `CCDX_STREAM_HANDSHAKE_TIMEOUT_MS` | `120000` | Timeout while waiting for upstream streaming response headers |
107
109
  | `CCDX_STREAM_IDLE_TIMEOUT_MS` | `120000` | Maximum idle time between upstream streaming body chunks |
@@ -114,7 +116,8 @@ Environment variables:
114
116
  | `CCDX_IMG_MAX_DIM` | `2048` | Max long edge in pixels for image downscaling |
115
117
  | `CCDX_IMG_QUALITY` | `85` | WebP quality used when re-encoding images |
116
118
  | `CCDX_IMG_MIN_BYTES` | `100000` | Images smaller than this are left untouched |
117
- | `CCDX_IMG_CONCURRENCY` | `4` | Concurrent image optimization tasks; values above `12` are capped at `12` |
119
+ | `CCDX_IMG_CONCURRENCY` | `2` | Global concurrent image optimization tasks; values above `12` are capped at `12` |
120
+ | `CCDX_IMG_MAX_INPUT_PIXELS` | `40000000` | Maximum decoded pixels accepted by `sharp` for one image |
118
121
  | `CCDX_DISABLE_IMG_OPT` | unset | Set to `1` to disable image optimization |
119
122
  | `CCDX_CONFIGURE_CLAUDE_DESKTOP` | unset | Set to `1` to write the Claude App 3P gateway profile during startup |
120
123
  | `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 +154,7 @@ codex-copilot-dx usage
151
154
 
152
155
  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
156
 
154
- The adapter automatically downsamples embedded screenshots to long-edge <= 2048 px and re-encodes them as WebP before forwarding `/v1/responses`.
157
+ 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
158
 
156
159
  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
160
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-copilot-dx",
3
- "version": "0.4.28",
3
+ "version": "0.4.30",
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": ">=18.17"
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
- onError(500, e?.message || "upstream stream error");
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(); }, (status, errMsg) => { if (!res.headersSent) res.writeHead(status || 500); res.end(errMsg); }, {
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
- await streamAnthropicFromLines(
859
- webStreamLines(upstream, {
860
- onChunk: () => abort.setTimeout(streamIdleTimeoutMs, "stream_idle_timeout"),
861
- }),
862
- async (event, data) => {
863
- if (event === "message_start") messageId = data.message?.id;
864
- if (event === "message_delta") usage = data.usage;
865
- await writeOrDrain(res, `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
866
- },
867
- requestedModel,
868
- { forceModel: forceRequestedModel },
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/copilot.mjs CHANGED
@@ -4,18 +4,13 @@ import { randomUUID } from "node:crypto";
4
4
  import { status } from "./status.mjs";
5
5
  import { debugLog } from "./log.mjs";
6
6
  import { ensureGithubTokenMetadata, githubReauthMessage, githubTokenPath, importDiscoveredGithubToken } from "./auth.mjs";
7
- import {
8
- optimizeImageDataUrl,
9
- optimizeImagesInBody,
10
- parseImageConcurrency,
11
- runWithConcurrency,
12
- summarizeReqBody,
13
- } from "./image-optimization.mjs";
7
+ import { prepareResponsesPayload } from "./image-optimization.mjs";
14
8
 
15
9
  export {
16
10
  optimizeImageDataUrl,
17
11
  optimizeImagesInBody,
18
12
  parseImageConcurrency,
13
+ prepareResponsesPayload,
19
14
  runWithConcurrency,
20
15
  summarizeReqBody,
21
16
  } from "./image-optimization.mjs";
@@ -384,10 +379,7 @@ export async function listModels({ signal, fetchImpl, retryOptions } = {}) {
384
379
  // Responses-only models go directly to Copilot's /responses endpoint.
385
380
  export async function responses(reqBody, { signal, fetchImpl, retryOptions } = {}) {
386
381
  const token = await getCopilotToken({ signal });
387
- await optimizeImagesInBody(reqBody);
388
- const bodyText = JSON.stringify(reqBody);
389
- const bodyBytes = Buffer.byteLength(bodyText);
390
- const summary = summarizeReqBody(reqBody);
382
+ const { bodyText, bodyBytes, summary } = await prepareResponsesPayload(reqBody);
391
383
  console.log(status("info", `responses payload bytes=${bodyBytes} input_items=${summary.items} images=${summary.images}`));
392
384
  const headers = buildHeaders({
393
385
  token,
@@ -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 = 128 * 1024 * 1024;
6
- const DEFAULT_MAX_DECODED_BODY_BYTES = 256 * 1024 * 1024;
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 = Number.parseInt(req.headers?.["content-length"] || "", 10);
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 = String(contentEncoding || "identity")
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 (!res.headersSent) res.writeHead(err?.statusCode || fallbackStatus, { "Content-Type": "application/json" });
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,35 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { status } from "./status.mjs";
2
3
 
3
- const IMG_MAX_DIM = Number.parseInt(process.env.CCDX_IMG_MAX_DIM || "2048", 10);
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;
4
+ const DEFAULT_IMG_CONCURRENCY = 2;
7
5
  const IMG_MAX_CONCURRENCY = 12;
6
+ const DEFAULT_IMG_MAX_INPUT_PIXELS = 40 * 1000 * 1000;
7
+ const DEFAULT_MAX_UPSTREAM_BODY_BYTES = 30 * 1024 * 1024;
8
+ const MAX_OPTIMIZED_IMAGE_DIGESTS = 4096;
9
+ const IMG_MAX_DIM = positiveInt(process.env.CCDX_IMG_MAX_DIM, 2048);
10
+ const IMG_QUALITY = positiveInt(process.env.CCDX_IMG_QUALITY, 85, 100);
11
+ const IMG_MIN_BYTES = nonNegativeInt(process.env.CCDX_IMG_MIN_BYTES, 100000);
12
+ const IMG_MAX_INPUT_PIXELS = positiveInt(process.env.CCDX_IMG_MAX_INPUT_PIXELS, DEFAULT_IMG_MAX_INPUT_PIXELS);
13
+ const MAX_UPSTREAM_BODY_BYTES = positiveInt(process.env.CCDX_MAX_UPSTREAM_BODY_BYTES, DEFAULT_MAX_UPSTREAM_BODY_BYTES);
8
14
  const IMG_OPT_DISABLED = process.env.CCDX_DISABLE_IMG_OPT === "1";
9
15
  const IMG_CONCURRENCY = parseImageConcurrency(process.env.CCDX_IMG_CONCURRENCY);
16
+ const OVERSIZE_IMAGE_PROFILES = [
17
+ { maxDim: Math.min(IMG_MAX_DIM, 1600), quality: Math.min(IMG_QUALITY, 75) },
18
+ { maxDim: Math.min(IMG_MAX_DIM, 1280), quality: Math.min(IMG_QUALITY, 65) },
19
+ ];
20
+ const optimizedImageDigests = new Set();
10
21
  let sharpImport = null;
11
22
 
23
+ function positiveInt(value, fallback, max = Number.MAX_SAFE_INTEGER) {
24
+ const n = Number.parseInt(value, 10);
25
+ return Number.isFinite(n) && n > 0 ? Math.min(n, max) : fallback;
26
+ }
27
+
28
+ function nonNegativeInt(value, fallback) {
29
+ const n = Number.parseInt(value, 10);
30
+ return Number.isFinite(n) && n >= 0 ? n : fallback;
31
+ }
32
+
12
33
  export function parseImageConcurrency(value) {
13
34
  const n = Number.parseInt(value, 10);
14
35
  return Number.isFinite(n) && n > 0 ? Math.min(n, IMG_MAX_CONCURRENCY) : DEFAULT_IMG_CONCURRENCY;
@@ -20,7 +41,29 @@ async function sharp() {
20
41
  return mod.default || mod;
21
42
  }
22
43
 
23
- export async function optimizeImageDataUrl(dataUrl) {
44
+ function imageDigest(raw) {
45
+ return createHash("sha256").update(raw).digest("base64url");
46
+ }
47
+
48
+ function touchOptimizedImage(digest) {
49
+ if (!optimizedImageDigests.delete(digest)) return false;
50
+ optimizedImageDigests.add(digest);
51
+ return true;
52
+ }
53
+
54
+ function rememberOptimizedImage(digest) {
55
+ optimizedImageDigests.delete(digest);
56
+ optimizedImageDigests.add(digest);
57
+ if (optimizedImageDigests.size > MAX_OPTIMIZED_IMAGE_DIGESTS) {
58
+ optimizedImageDigests.delete(optimizedImageDigests.values().next().value);
59
+ }
60
+ }
61
+
62
+ export async function optimizeImageDataUrl(dataUrl, {
63
+ maxDim = IMG_MAX_DIM,
64
+ quality = IMG_QUALITY,
65
+ force = false,
66
+ } = {}) {
24
67
  if (IMG_OPT_DISABLED) return dataUrl;
25
68
  if (typeof dataUrl !== "string" || !dataUrl.startsWith("data:")) return dataUrl;
26
69
  const match = /^data:(image\/[a-z+.-]+);base64,(.+)$/i.exec(dataUrl);
@@ -28,14 +71,22 @@ export async function optimizeImageDataUrl(dataUrl) {
28
71
  const mime = match[1].toLowerCase();
29
72
  const raw = Buffer.from(match[2], "base64");
30
73
  if (raw.length < IMG_MIN_BYTES || mime.includes("gif")) return dataUrl;
74
+ const digest = imageDigest(raw);
75
+ if (!force && touchOptimizedImage(digest)) return dataUrl;
31
76
 
32
77
  try {
33
78
  const resize = await sharp();
34
- const out = await resize(raw, { failOn: "none" })
79
+ const image = resize(raw, { failOn: "none", limitInputPixels: IMG_MAX_INPUT_PIXELS });
80
+ const out = await image
35
81
  .rotate()
36
- .resize(IMG_MAX_DIM, IMG_MAX_DIM, { fit: "inside", withoutEnlargement: true })
37
- .webp({ quality: IMG_QUALITY, effort: 4 })
82
+ .resize(positiveInt(maxDim, IMG_MAX_DIM), positiveInt(maxDim, IMG_MAX_DIM), { fit: "inside", withoutEnlargement: true })
83
+ .webp({ quality: positiveInt(quality, IMG_QUALITY, 100), effort: 4 })
38
84
  .toBuffer();
85
+ if (out.length >= raw.length) {
86
+ rememberOptimizedImage(digest);
87
+ return dataUrl;
88
+ }
89
+ rememberOptimizedImage(imageDigest(out));
39
90
  const ratio = ((out.length / raw.length) * 100).toFixed(1);
40
91
  console.log(status("info", `image ${(raw.length / 1024).toFixed(0)}KB ${mime} -> ${(out.length / 1024).toFixed(0)}KB webp (${ratio}%)`));
41
92
  return `data:image/webp;base64,${out.toString("base64")}`;
@@ -57,16 +108,16 @@ export async function runWithConcurrency(taskFns, concurrency) {
57
108
  }));
58
109
  }
59
110
 
60
- function visitImageParts(parts, tasks) {
111
+ function visitImageParts(parts, tasks, optimizeImage) {
61
112
  if (!Array.isArray(parts)) return;
62
113
  for (const part of parts) {
63
114
  if (!part) continue;
64
115
  if (part.type === "input_image" && typeof part.image_url === "string") {
65
- tasks.push(async () => { part.image_url = await optimizeImageDataUrl(part.image_url); });
116
+ tasks.push(async () => { part.image_url = await optimizeImage(part.image_url); });
66
117
  } else if (part.type === "image" && part.source?.type === "base64" && part.source?.data) {
67
118
  tasks.push(async () => {
68
119
  const dataUrl = `data:${part.source.media_type || "image/png"};base64,${part.source.data}`;
69
- const optimized = await optimizeImageDataUrl(dataUrl);
120
+ const optimized = await optimizeImage(dataUrl);
70
121
  const match = /^data:([^;]+);base64,(.+)$/.exec(optimized);
71
122
  if (match) {
72
123
  part.source.media_type = match[1];
@@ -77,32 +128,31 @@ function visitImageParts(parts, tasks) {
77
128
  }
78
129
  }
79
130
 
80
- export async function optimizeImagesInBody(reqBody) {
131
+ export async function optimizeImagesInBody(reqBody, {
132
+ concurrency = IMG_CONCURRENCY,
133
+ optimizeImage = optimizeImageDataUrl,
134
+ } = {}) {
81
135
  if (IMG_OPT_DISABLED || !Array.isArray(reqBody.input)) return reqBody;
82
136
  const tasks = [];
137
+ const finalize = [];
83
138
 
84
139
  for (const item of reqBody.input) {
85
140
  if (!item) continue;
86
141
  if (item.type === "message" && Array.isArray(item.content)) {
87
- visitImageParts(item.content, tasks);
142
+ visitImageParts(item.content, tasks, optimizeImage);
88
143
  }
89
144
  if (item.type === "function_call_output") {
90
145
  if (Array.isArray(item.output)) {
91
- visitImageParts(item.output, tasks);
146
+ visitImageParts(item.output, tasks, optimizeImage);
92
147
  } else if (typeof item.output === "string") {
93
148
  const trimmed = item.output.trim();
94
149
  if (trimmed.startsWith("[")) {
95
150
  try {
96
151
  const parsed = JSON.parse(trimmed);
97
152
  if (Array.isArray(parsed)) {
98
- const localTasks = [];
99
- visitImageParts(parsed, localTasks);
100
- if (localTasks.length) {
101
- tasks.push(async () => {
102
- await runWithConcurrency(localTasks, IMG_CONCURRENCY);
103
- item.output = JSON.stringify(parsed);
104
- });
105
- }
153
+ const taskCount = tasks.length;
154
+ visitImageParts(parsed, tasks, optimizeImage);
155
+ if (tasks.length > taskCount) finalize.push(() => { item.output = JSON.stringify(parsed); });
106
156
  }
107
157
  } catch {
108
158
  // Leave non-JSON tool output untouched.
@@ -112,10 +162,40 @@ export async function optimizeImagesInBody(reqBody) {
112
162
  }
113
163
  }
114
164
 
115
- if (tasks.length) await runWithConcurrency(tasks, IMG_CONCURRENCY);
165
+ if (tasks.length) await runWithConcurrency(tasks, concurrency);
166
+ for (const apply of finalize) apply();
116
167
  return reqBody;
117
168
  }
118
169
 
170
+ export async function prepareResponsesPayload(reqBody, {
171
+ maxBytes = MAX_UPSTREAM_BODY_BYTES,
172
+ profiles = OVERSIZE_IMAGE_PROFILES,
173
+ } = {}) {
174
+ await optimizeImagesInBody(reqBody);
175
+ let bodyText = JSON.stringify(reqBody);
176
+ let bodyBytes = Buffer.byteLength(bodyText);
177
+ const summary = summarizeReqBody(reqBody);
178
+ const targetBytes = positiveInt(maxBytes, MAX_UPSTREAM_BODY_BYTES);
179
+ let adapted = false;
180
+
181
+ if (!IMG_OPT_DISABLED && summary.images > 0 && bodyBytes > targetBytes) {
182
+ for (const profile of profiles) {
183
+ const beforeBytes = bodyBytes;
184
+ bodyText = "";
185
+ await optimizeImagesInBody(reqBody, {
186
+ optimizeImage: (dataUrl) => optimizeImageDataUrl(dataUrl, { ...profile, force: true }),
187
+ });
188
+ bodyText = JSON.stringify(reqBody);
189
+ bodyBytes = Buffer.byteLength(bodyText);
190
+ adapted ||= bodyBytes < beforeBytes;
191
+ console.warn(status("warn", `responses payload ${beforeBytes}b exceeds ${targetBytes}b; image profile max_dim=${profile.maxDim} quality=${profile.quality} -> ${bodyBytes}b`));
192
+ if (bodyBytes <= targetBytes) break;
193
+ }
194
+ }
195
+
196
+ return { bodyText, bodyBytes, summary, adapted };
197
+ }
198
+
119
199
  export function summarizeReqBody(reqBody) {
120
200
  try {
121
201
  const input = reqBody.input;
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 stat = fs.statSync(lockPath);
12
- if (nowMs - stat.mtimeMs <= staleMs) return false;
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
- fd = fs.openSync(lockPath, "wx", 0o600);
34
- fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, created_at: new Date().toISOString() }));
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
- fs.unlinkSync(lockPath);
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/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
- export async function* webStreamLines(response, { onChunk } = {}) {
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
- for (const line of lines) yield line.replace(/\r$/, "");
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(() => {});