codex-copilot-dx 0.4.42 → 0.4.43

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/README.md CHANGED
@@ -125,7 +125,7 @@ Environment variables:
125
125
  | `CCDX_MAX_INFLIGHT_BODY_BYTES` | `33554432` | Shared byte budget for admitted request bodies; a larger single request runs exclusively |
126
126
  | `CCDX_MAX_QUEUED_REQUESTS` | `16` | Maximum body requests waiting for the shared byte budget |
127
127
  | `CCDX_REQUEST_QUEUE_TIMEOUT_MS` | `120000` | Maximum wait for request-body admission before returning `503` |
128
- | `CCDX_MAX_UPSTREAM_BODY_BYTES` | `31457280` | Responses body target; larger image payloads are compressed more aggressively before forwarding |
128
+ | `CCDX_MAX_UPSTREAM_BODY_BYTES` | `31457280` | Strict maximum forwarded Responses body size; larger payloads are adapted locally or rejected with `413` |
129
129
  | `CCDX_MAX_SSE_BUFFER_BYTES` | `8388608` | Maximum buffered bytes for one unterminated upstream SSE line/event |
130
130
  | `CCDX_UPSTREAM_TIMEOUT_MS` | `120000` | Timeout for non-streaming upstream Copilot requests |
131
131
  | `CCDX_STREAM_HANDSHAKE_TIMEOUT_MS` | `120000` | Timeout while waiting for upstream streaming response headers |
@@ -137,8 +137,8 @@ Environment variables:
137
137
  | `CCDX_LOG_LEVEL` | `info` | Set to `debug` to include upstream request attempts, status codes, retry causes, and timings |
138
138
  | `CCDX_LOG_MAX_BYTES` | `16777216` | Rotate the debug log at this size, retaining one `.1` backup; set to `0` to disable rotation |
139
139
  | `CCDX_IMG_MAX_DIM` | `2048` | Max long edge in pixels for image downscaling |
140
- | `CCDX_IMG_QUALITY` | `85` | WebP quality used when re-encoding images |
141
- | `CCDX_IMG_MIN_BYTES` | `100000` | Images smaller than this are left untouched |
140
+ | `CCDX_IMG_QUALITY` | `82` | Initial WebP quality used when re-encoding images |
141
+ | `CCDX_IMG_MIN_BYTES` | `100000` | In-bounds images smaller than this are left untouched; oversized images are still downscaled |
142
142
  | `CCDX_IMG_CONCURRENCY` | `2` | Global concurrent image optimization tasks; values above `12` are capped at `12` |
143
143
  | `CCDX_IMG_MAX_INPUT_PIXELS` | `40000000` | Maximum decoded pixels accepted by `sharp` for one image |
144
144
  | `CCDX_DISABLE_IMG_OPT` | unset | Set to `1` to disable image optimization |
@@ -175,12 +175,16 @@ codex-copilot-dx usage
175
175
 
176
176
  ### Image optimization
177
177
 
178
- 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`.
178
+ 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 `408 Request Timeout` while reading the body or `413 Payload Too Large`.
179
179
 
180
- 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.
180
+ The adapter automatically downsamples embedded screenshots to model-appropriate pixel bounds (with a conservative 2048 px long-edge fallback) and initially re-encodes them as WebP quality 82 before forwarding `/v1/responses`. It never replaces an image with a larger encoding and applies one global concurrency limit across direct, function-tool, and custom-tool output images.
181
+
182
+ The final serialized UTF-8 request body is measured before forwarding. Above the configured byte limit, unique source images are processed largest-first from their originals at quality 75 / 1600 px and then quality 65 / 1280 px, stopping as soon as the body fits. If necessary, the forwarded view omits older duplicate images, historical tool images, other historical images, and finally old tool outputs while preserving current input and tool-call skeletons. A request that still cannot fit is rejected locally with a structured `413` instead of sending an oversized body upstream.
181
183
 
182
184
  When expanded Responses history exceeds Copilot's 50-image request limit, the adapter removes older duplicate image occurrences first, then keeps the 50 most recent images. This applies only to the forwarded request: local history remains complete, current images are preferred over older history, and requests at or below the limit are unchanged.
183
185
 
186
+ After a successful `/v1/responses/compact` result containing a compaction item, the compact output becomes a new local history root. Later turns continue from that snapshot without re-expanding pre-compaction history; existing older response branches remain available until normal history eviction.
187
+
184
188
  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.
185
189
 
186
190
  ## License
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-copilot-dx",
3
- "version": "0.4.42",
3
+ "version": "0.4.43",
4
4
  "description": "Use Codex Desktop, Claude Code, and Claude App with GitHub Copilot",
5
5
  "author": "Dale Xiao",
6
6
  "type": "module",
package/src/adapter.mjs CHANGED
@@ -106,6 +106,7 @@ export function createAdapterHandler(options = {}) {
106
106
  acquireRequest,
107
107
  chatCompletionsFn,
108
108
  openAIModelEnv,
109
+ responsesPayloadOptions: options.responsesPayloadOptions,
109
110
  responsesFn,
110
111
  streamHandshakeTimeoutMs,
111
112
  streamIdleTimeoutMs,
package/src/copilot.mjs CHANGED
@@ -17,7 +17,8 @@ import {
17
17
  githubTokenFingerprint,
18
18
  normalizeGithubIdentity,
19
19
  } from "./github-identity.mjs";
20
- import { prepareResponsesPayload } from "./image-optimization.mjs";
20
+ import { prepareResponsesPayload, summarizeReqBody } from "./image-optimization.mjs";
21
+ import { enforceResponsesPayloadByteBudget } from "./responses-byte-budget.mjs";
21
22
 
22
23
  export {
23
24
  optimizeImageDataUrl,
@@ -500,19 +501,26 @@ export function resetCopilotTokenForTests() {
500
501
  }
501
502
 
502
503
  // chatReq is already converted by the adapter. The caller parses the raw Response.
503
- export async function chatCompletions(chatReq, { signal, fetchImpl, retryOptions } = {}) {
504
+ export async function chatCompletions(chatReq, {
505
+ signal,
506
+ fetchImpl,
507
+ retryOptions,
508
+ bodyText,
509
+ } = {}) {
504
510
  const token = await getCopilotToken({ signal });
505
511
  const messages = chatReq.messages || [];
512
+ const serializedBody = typeof bodyText === "string" ? bodyText : JSON.stringify(chatReq);
506
513
  const headers = buildHeaders({
507
514
  token,
508
515
  version: getVSCodeVersion(),
509
516
  initiator: computeInitiator(messages),
510
517
  vision: computeVision(messages),
511
518
  });
519
+ headers["Content-Length"] = String(Buffer.byteLength(serializedBody));
512
520
  return fetchCopilotUpstream(`${getApiBase()}/chat/completions`, {
513
521
  method: "POST",
514
522
  headers,
515
- body: JSON.stringify(chatReq),
523
+ body: serializedBody,
516
524
  signal,
517
525
  }, { fetchImpl, ...retryOptions });
518
526
  }
@@ -582,10 +590,29 @@ export function resetModelListSingleflightForTests() {
582
590
  }
583
591
 
584
592
  // Responses-only models go directly to Copilot's /responses endpoint.
585
- export async function responses(reqBody, { signal, fetchImpl, retryOptions } = {}) {
593
+ export async function responses(reqBody, {
594
+ signal,
595
+ fetchImpl,
596
+ retryOptions,
597
+ currentInputStart = 0,
598
+ payloadPrepared = false,
599
+ payloadOptions = {},
600
+ } = {}) {
586
601
  const token = await getCopilotToken({ signal });
587
- const { bodyText, bodyBytes, summary } = await prepareResponsesPayload(reqBody, { signal });
602
+ const preparedPayload = await prepareResponsesPayload(reqBody, {
603
+ ...payloadOptions,
604
+ currentInputStart,
605
+ profiles: payloadPrepared ? [] : payloadOptions.profiles,
606
+ skipInitialOptimization: payloadPrepared || payloadOptions.skipInitialOptimization,
607
+ signal,
608
+ });
609
+ const finalizedPayload = enforceResponsesPayloadByteBudget(reqBody, preparedPayload);
610
+ const { bodyText, bodyBytes, stage, adapted } = finalizedPayload;
611
+ const summary = finalizedPayload.stage === preparedPayload.stage
612
+ ? preparedPayload.summary
613
+ : summarizeReqBody(reqBody);
588
614
  console.log(status("info", `responses payload bytes=${bodyBytes} input_items=${summary.items} images=${summary.images}`));
615
+ if (adapted) debugLog(`responses payload adapted stage=${stage} bytes=${bodyBytes}/${preparedPayload.targetBytes}`);
589
616
  const headers = buildHeaders({
590
617
  token,
591
618
  version: getVSCodeVersion(),
@@ -291,7 +291,7 @@ export function sendJsonError(res, err, fallbackStatus = 400) {
291
291
  return;
292
292
  }
293
293
  res.writeHead(err?.statusCode || fallbackStatus, { "Content-Type": "application/json" });
294
- res.end(JSON.stringify({ error: err?.message || "Request failed" }));
294
+ res.end(JSON.stringify(err?.jsonBody || { error: err?.message || "Request failed" }));
295
295
  }
296
296
 
297
297
  export function sendUpstreamError(res, response, text) {
@@ -1,4 +1,9 @@
1
1
  import { createHash } from "node:crypto";
2
+ import {
3
+ canonicalInlineImageIdentity,
4
+ readResponsesImagePart,
5
+ readResponsesToolOutputParts,
6
+ } from "./responses-content.mjs";
2
7
  import { status } from "./status.mjs";
3
8
 
4
9
  const DEFAULT_IMG_CONCURRENCY = 2;
@@ -7,7 +12,7 @@ const DEFAULT_IMG_MAX_INPUT_PIXELS = 40 * 1000 * 1000;
7
12
  const DEFAULT_MAX_UPSTREAM_BODY_BYTES = 30 * 1024 * 1024;
8
13
  const MAX_OPTIMIZED_IMAGE_DIGESTS = 4096;
9
14
  const IMG_MAX_DIM = positiveInt(process.env.CCDX_IMG_MAX_DIM, 2048);
10
- const IMG_QUALITY = positiveInt(process.env.CCDX_IMG_QUALITY, 85, 100);
15
+ const IMG_QUALITY = positiveInt(process.env.CCDX_IMG_QUALITY, 82, 100);
11
16
  const IMG_MIN_BYTES = nonNegativeInt(process.env.CCDX_IMG_MIN_BYTES, 100000);
12
17
  const IMG_MAX_INPUT_PIXELS = positiveInt(process.env.CCDX_IMG_MAX_INPUT_PIXELS, DEFAULT_IMG_MAX_INPUT_PIXELS);
13
18
  const MAX_UPSTREAM_BODY_BYTES = positiveInt(process.env.CCDX_MAX_UPSTREAM_BODY_BYTES, DEFAULT_MAX_UPSTREAM_BODY_BYTES);
@@ -30,6 +35,39 @@ function nonNegativeInt(value, fallback) {
30
35
  return Number.isFinite(n) && n >= 0 ? n : fallback;
31
36
  }
32
37
 
38
+ export function imageConstraintsForModel(modelId, { maxDim = IMG_MAX_DIM } = {}) {
39
+ const model = String(modelId || "").trim().toLowerCase();
40
+ const maxLongEdge = Math.min(positiveInt(maxDim, IMG_MAX_DIM), IMG_MAX_DIM, 2048);
41
+ const fullSizeGpt4 = /^(?:gpt-4o|gpt-4\.1)(?:$|-)/.test(model)
42
+ && !/(?:^|-)(?:mini|nano)(?:$|-)/.test(model);
43
+ const gpt5Mini = /^gpt-5-mini(?:$|-)/.test(model);
44
+
45
+ if (fullSizeGpt4 || gpt5Mini) {
46
+ return { maxLongEdge, maxShortEdge: Math.min(maxLongEdge, 768) };
47
+ }
48
+ if (/^gpt-5\.(?:4|5|6)(?:$|-)/.test(model)) {
49
+ return { maxLongEdge, maxArea: 2_560_000 };
50
+ }
51
+ return { maxLongEdge };
52
+ }
53
+
54
+ export function fitImageDimensions(width, height, constraints = {}) {
55
+ const sourceWidth = Number(width);
56
+ const sourceHeight = Number(height);
57
+ if (!(sourceWidth > 0) || !(sourceHeight > 0)) return null;
58
+
59
+ const longEdge = Math.max(sourceWidth, sourceHeight);
60
+ const shortEdge = Math.min(sourceWidth, sourceHeight);
61
+ let scale = 1;
62
+ if (constraints.maxLongEdge > 0) scale = Math.min(scale, constraints.maxLongEdge / longEdge);
63
+ if (constraints.maxShortEdge > 0) scale = Math.min(scale, constraints.maxShortEdge / shortEdge);
64
+ if (constraints.maxArea > 0) scale = Math.min(scale, Math.sqrt(constraints.maxArea / (sourceWidth * sourceHeight)));
65
+ return {
66
+ width: Math.max(1, Math.floor(sourceWidth * scale)),
67
+ height: Math.max(1, Math.floor(sourceHeight * scale)),
68
+ };
69
+ }
70
+
33
71
  export function parseImageConcurrency(value) {
34
72
  const n = Number.parseInt(value, 10);
35
73
  return Number.isFinite(n) && n > 0 ? Math.min(n, IMG_MAX_CONCURRENCY) : DEFAULT_IMG_CONCURRENCY;
@@ -120,6 +158,16 @@ function imageDigest(raw) {
120
158
  return createHash("sha256").update(raw).digest("base64url");
121
159
  }
122
160
 
161
+ function imageOptimizationKey(digest, constraints, quality) {
162
+ return [
163
+ digest,
164
+ constraints.maxLongEdge || "",
165
+ constraints.maxShortEdge || "",
166
+ constraints.maxArea || "",
167
+ quality,
168
+ ].join(":");
169
+ }
170
+
123
171
  function touchOptimizedImage(digest) {
124
172
  if (!optimizedImageDigests.delete(digest)) return false;
125
173
  optimizedImageDigests.add(digest);
@@ -138,6 +186,7 @@ export async function optimizeImageDataUrl(dataUrl, {
138
186
  maxDim = IMG_MAX_DIM,
139
187
  quality = IMG_QUALITY,
140
188
  force = false,
189
+ model,
141
190
  signal,
142
191
  } = {}) {
143
192
  if (IMG_OPT_DISABLED) return dataUrl;
@@ -147,34 +196,58 @@ export async function optimizeImageDataUrl(dataUrl, {
147
196
  const mime = match[1].toLowerCase();
148
197
  const encoded = match[2];
149
198
  const inputBytes = Buffer.byteLength(encoded, "base64");
150
- if (inputBytes < IMG_MIN_BYTES || mime.includes("gif")) return dataUrl;
199
+ if (mime.includes("gif")) return dataUrl;
151
200
 
152
201
  try {
153
202
  return await runGlobalImageTask(async () => {
154
203
  const raw = Buffer.from(encoded, "base64");
155
- if (raw.length < IMG_MIN_BYTES) return dataUrl;
156
204
  const digest = imageDigest(raw);
157
- if (!force && touchOptimizedImage(digest)) return dataUrl;
205
+ const constraints = imageConstraintsForModel(model, { maxDim });
206
+ const outputQuality = positiveInt(quality, IMG_QUALITY, 100);
207
+ const optimizationKey = imageOptimizationKey(digest, constraints, outputQuality);
208
+ if (!force && touchOptimizedImage(optimizationKey)) return dataUrl;
158
209
  const resize = await sharp();
159
210
  const image = resize(raw, { failOn: "none", limitInputPixels: IMG_MAX_INPUT_PIXELS });
211
+ const metadata = await image.metadata();
212
+ const orientation = Number(metadata.orientation);
213
+ const rotated = orientation >= 5 && orientation <= 8;
214
+ const sourceWidth = rotated ? metadata.height : metadata.width;
215
+ const sourceHeight = rotated ? metadata.width : metadata.height;
216
+ const target = fitImageDimensions(sourceWidth, sourceHeight, constraints);
217
+ const resizeRequired = target
218
+ && (target.width < sourceWidth || target.height < sourceHeight);
219
+ if (raw.length < IMG_MIN_BYTES && !resizeRequired) {
220
+ rememberOptimizedImage(optimizationKey);
221
+ return dataUrl;
222
+ }
223
+ const resizeOptions = target
224
+ ? { ...target, fit: "inside", withoutEnlargement: true }
225
+ : {
226
+ width: constraints.maxLongEdge,
227
+ height: constraints.maxLongEdge,
228
+ fit: "inside",
229
+ withoutEnlargement: true,
230
+ };
160
231
  const out = await image
161
232
  .rotate()
162
- .resize(positiveInt(maxDim, IMG_MAX_DIM), positiveInt(maxDim, IMG_MAX_DIM), { fit: "inside", withoutEnlargement: true })
163
- .webp({ quality: positiveInt(quality, IMG_QUALITY, 100), effort: 4 })
233
+ .resize(resizeOptions)
234
+ .webp({ quality: outputQuality, effort: 4 })
164
235
  .toBuffer();
165
236
  if (signal?.aborted) throw abortError(signal);
166
237
  if (out.length >= raw.length) {
167
- rememberOptimizedImage(digest);
238
+ rememberOptimizedImage(optimizationKey);
168
239
  return dataUrl;
169
240
  }
170
- rememberOptimizedImage(imageDigest(out));
241
+ rememberOptimizedImage(imageOptimizationKey(imageDigest(out), constraints, outputQuality));
171
242
  const ratio = ((out.length / raw.length) * 100).toFixed(1);
172
243
  console.log(status("info", `image ${(raw.length / 1024).toFixed(0)}KB ${mime} -> ${(out.length / 1024).toFixed(0)}KB webp (${ratio}%)`));
173
244
  return `data:image/webp;base64,${out.toString("base64")}`;
174
245
  }, { signal });
175
246
  } catch (e) {
176
247
  if (signal?.aborted || e?.name === "AbortError") throw e;
177
- console.warn(status("warn", `image optimize failed (${mime}, ${inputBytes}b): ${e.message}`));
248
+ if (inputBytes >= IMG_MIN_BYTES) {
249
+ console.warn(status("warn", `image optimize failed (${mime}, ${inputBytes}b): ${e.message}`));
250
+ }
178
251
  return dataUrl;
179
252
  }
180
253
  }
@@ -191,102 +264,225 @@ export async function runWithConcurrency(taskFns, concurrency) {
191
264
  }));
192
265
  }
193
266
 
194
- function visitImageParts(parts, tasks, optimizeImage) {
267
+ function visitImageParts(parts, references, commit = null) {
195
268
  if (!Array.isArray(parts)) return;
196
269
  for (const part of parts) {
197
- if (!part) continue;
198
- if (part.type === "input_image" && typeof part.image_url === "string") {
199
- tasks.push(async () => { part.image_url = await optimizeImage(part.image_url); });
200
- } else if (part.type === "image" && part.source?.type === "base64" && part.source?.data) {
201
- tasks.push(async () => {
202
- const dataUrl = `data:${part.source.media_type || "image/png"};base64,${part.source.data}`;
203
- const optimized = await optimizeImage(dataUrl);
204
- const match = /^data:([^;]+);base64,(.+)$/.exec(optimized);
205
- if (match) {
206
- part.source.media_type = match[1];
207
- part.source.data = match[2];
208
- }
209
- });
270
+ const image = readResponsesImagePart(part);
271
+ if (!image?.identity) continue;
272
+ references.push({
273
+ commit,
274
+ get: () => image.dataUrl,
275
+ set: (value) => image.setDataUrl(value),
276
+ });
277
+ }
278
+ }
279
+
280
+ function collectImageReferences(reqBody) {
281
+ const references = [];
282
+ const dirtyCommits = new Set();
283
+ if (!Array.isArray(reqBody?.input)) {
284
+ return { references, markDirty() {}, commitDirty() {} };
285
+ }
286
+
287
+ for (const item of reqBody.input) {
288
+ if (!item) continue;
289
+ visitImageParts([item], references);
290
+ if (item.type === "message" && Array.isArray(item.content)) {
291
+ visitImageParts(item.content, references);
292
+ }
293
+ const toolOutput = readResponsesToolOutputParts(item);
294
+ if (toolOutput) {
295
+ visitImageParts(toolOutput.parts, references, toolOutput.commit);
210
296
  }
211
297
  }
298
+
299
+ return {
300
+ references,
301
+ markDirty(changedReferences) {
302
+ for (const reference of changedReferences) {
303
+ if (reference.commit) dirtyCommits.add(reference.commit);
304
+ }
305
+ },
306
+ commitDirty() {
307
+ for (const apply of dirtyCommits) apply();
308
+ dirtyCommits.clear();
309
+ },
310
+ };
212
311
  }
213
312
 
214
- export async function optimizeImagesInBody(reqBody, {
215
- concurrency = IMG_CONCURRENCY,
216
- optimizeImage = optimizeImageDataUrl,
313
+ async function optimizeImageReferences(collection, {
314
+ concurrency,
315
+ model,
316
+ optimizeImage,
217
317
  signal,
218
- } = {}) {
219
- if (IMG_OPT_DISABLED || !Array.isArray(reqBody.input)) return reqBody;
220
- const tasks = [];
221
- const finalize = [];
318
+ }) {
222
319
  const optimizedByDataUrl = new Map();
223
320
  const optimizeOnce = (dataUrl) => {
224
- if (!optimizedByDataUrl.has(dataUrl)) {
225
- optimizedByDataUrl.set(dataUrl, Promise.resolve().then(() => optimizeImage(dataUrl, { signal })));
321
+ const identity = canonicalInlineImageIdentity(dataUrl) || dataUrl;
322
+ if (!optimizedByDataUrl.has(identity)) {
323
+ optimizedByDataUrl.set(identity, Promise.resolve().then(() => optimizeImage(dataUrl, { model, signal })));
226
324
  }
227
- return optimizedByDataUrl.get(dataUrl);
325
+ return optimizedByDataUrl.get(identity);
228
326
  };
327
+ const tasks = collection.references.map((reference) => async () => {
328
+ if (reference.set(await optimizeOnce(reference.get()))) collection.markDirty([reference]);
329
+ });
330
+ if (tasks.length) await runWithConcurrency(tasks, concurrency);
331
+ collection.commitDirty();
332
+ }
229
333
 
230
- for (const item of reqBody.input) {
231
- if (!item) continue;
232
- if (item.type === "message" && Array.isArray(item.content)) {
233
- visitImageParts(item.content, tasks, optimizeOnce);
234
- }
235
- if (item.type === "function_call_output") {
236
- if (Array.isArray(item.output)) {
237
- visitImageParts(item.output, tasks, optimizeOnce);
238
- } else if (typeof item.output === "string") {
239
- const trimmed = item.output.trim();
240
- if (trimmed.startsWith("[")) {
241
- try {
242
- const parsed = JSON.parse(trimmed);
243
- if (Array.isArray(parsed)) {
244
- const taskCount = tasks.length;
245
- visitImageParts(parsed, tasks, optimizeOnce);
246
- if (tasks.length > taskCount) finalize.push(() => { item.output = JSON.stringify(parsed); });
247
- }
248
- } catch {
249
- // Leave non-JSON tool output untouched.
250
- }
251
- }
252
- }
334
+ function imageInputBytes(dataUrl) {
335
+ if (typeof dataUrl !== "string") return 0;
336
+ const match = /^data:image\/[a-z+.-]+;base64,(.+)$/i.exec(dataUrl);
337
+ return match ? Buffer.byteLength(match[1], "base64") : 0;
338
+ }
339
+
340
+ function uniqueOriginalImages(references) {
341
+ const unique = new Map();
342
+ for (const reference of references) {
343
+ const dataUrl = reference.get();
344
+ const identity = canonicalInlineImageIdentity(dataUrl);
345
+ if (!identity) continue;
346
+ const inputBytes = imageInputBytes(dataUrl);
347
+ if (inputBytes <= 0) continue;
348
+ let image = unique.get(identity);
349
+ if (!image) {
350
+ image = { dataUrl, inputBytes, references: [] };
351
+ unique.set(identity, image);
253
352
  }
353
+ image.references.push(reference);
254
354
  }
355
+ return [...unique.values()];
356
+ }
255
357
 
256
- if (tasks.length) await runWithConcurrency(tasks, concurrency);
257
- for (const apply of finalize) apply();
358
+ function currentImageWireBytes(image) {
359
+ return image.references.reduce(
360
+ (total, reference) => total + Buffer.byteLength(reference.get()),
361
+ 0,
362
+ );
363
+ }
364
+
365
+ function orderImagesByCurrentWireBytes(images) {
366
+ return [...images].sort((left, right) => (
367
+ currentImageWireBytes(right) - currentImageWireBytes(left)
368
+ || right.inputBytes - left.inputBytes
369
+ ));
370
+ }
371
+
372
+ function applyImageCandidate(collection, image, candidate, bodyBytes) {
373
+ if (!canonicalInlineImageIdentity(candidate)) {
374
+ return { bodyBytes, changed: false };
375
+ }
376
+ const previous = image.references.map((reference) => reference.get());
377
+ if (previous.every((value) => value === candidate)) return { bodyBytes, changed: false };
378
+ // Base64 data URLs need no JSON escaping, so their byte delta also applies to the serialized body.
379
+ const candidateBytes = Buffer.byteLength(candidate);
380
+ const byteDelta = previous.reduce(
381
+ (delta, value) => delta + candidateBytes - Buffer.byteLength(value),
382
+ 0,
383
+ );
384
+ if (byteDelta >= 0) return { bodyBytes, changed: false };
385
+ const changedReferences = [];
386
+ for (const reference of image.references) {
387
+ if (reference.set(candidate)) changedReferences.push(reference);
388
+ }
389
+ collection.markDirty(changedReferences);
390
+ return { bodyBytes: bodyBytes + byteDelta, changed: true };
391
+ }
392
+
393
+ function normalizeCurrentInputStart(value, reqBody) {
394
+ const parsed = Number.parseInt(value, 10);
395
+ const itemCount = Array.isArray(reqBody?.input) ? reqBody.input.length : 0;
396
+ return Number.isFinite(parsed) && parsed >= 0 ? Math.min(parsed, itemCount) : 0;
397
+ }
398
+
399
+ export async function optimizeImagesInBody(reqBody, {
400
+ concurrency = IMG_CONCURRENCY,
401
+ optimizeImage = optimizeImageDataUrl,
402
+ signal,
403
+ } = {}) {
404
+ if (IMG_OPT_DISABLED || !Array.isArray(reqBody.input)) return reqBody;
405
+ const collection = collectImageReferences(reqBody);
406
+ await optimizeImageReferences(collection, {
407
+ concurrency,
408
+ model: reqBody.model,
409
+ optimizeImage,
410
+ signal,
411
+ });
258
412
  return reqBody;
259
413
  }
260
414
 
261
415
  export async function prepareResponsesPayload(reqBody, {
416
+ currentInputStart = 0,
262
417
  maxBytes = MAX_UPSTREAM_BODY_BYTES,
418
+ optimizeImage = optimizeImageDataUrl,
263
419
  profiles = OVERSIZE_IMAGE_PROFILES,
420
+ skipInitialOptimization = false,
264
421
  signal,
265
422
  } = {}) {
266
- await optimizeImagesInBody(reqBody, { signal });
423
+ const shouldOptimizeImages = !IMG_OPT_DISABLED && !skipInitialOptimization;
424
+ const collection = shouldOptimizeImages ? collectImageReferences(reqBody) : null;
425
+ const originalImages = collection ? uniqueOriginalImages(collection.references) : [];
426
+ if (collection) {
427
+ await optimizeImageReferences(collection, {
428
+ concurrency: IMG_CONCURRENCY,
429
+ model: reqBody.model,
430
+ optimizeImage: (dataUrl, options) => optimizeImage(dataUrl, { ...options, quality: IMG_QUALITY }),
431
+ signal,
432
+ });
433
+ }
267
434
  let bodyText = JSON.stringify(reqBody);
268
435
  let bodyBytes = Buffer.byteLength(bodyText);
269
436
  const summary = summarizeReqBody(reqBody);
270
437
  const targetBytes = positiveInt(maxBytes, MAX_UPSTREAM_BODY_BYTES);
271
438
  let adapted = false;
439
+ let stage = skipInitialOptimization ? "preoptimized" : `q${IMG_QUALITY}`;
272
440
 
273
- if (!IMG_OPT_DISABLED && summary.images > 0 && bodyBytes > targetBytes) {
274
- for (const profile of profiles) {
441
+ if (collection && originalImages.length > 0 && bodyBytes > targetBytes) {
442
+ const batchSize = Math.max(1, IMG_CONCURRENCY);
443
+ for (const profile of Array.isArray(profiles) ? profiles : []) {
275
444
  const beforeBytes = bodyBytes;
276
- bodyText = "";
277
- await optimizeImagesInBody(reqBody, {
278
- optimizeImage: (dataUrl) => optimizeImageDataUrl(dataUrl, { ...profile, force: true, signal }),
279
- signal,
280
- });
445
+ let processed = 0;
446
+ stage = `q${positiveInt(profile.quality, IMG_QUALITY, 100)}`;
447
+ const orderedImages = orderImagesByCurrentWireBytes(originalImages);
448
+ for (let index = 0; index < orderedImages.length && bodyBytes > targetBytes; index += batchSize) {
449
+ const batch = orderedImages.slice(index, index + batchSize);
450
+ const candidates = await Promise.all(batch.map((image) => optimizeImage(image.dataUrl, {
451
+ ...profile,
452
+ force: true,
453
+ model: reqBody.model,
454
+ signal,
455
+ })));
456
+ for (let offset = 0; offset < batch.length && bodyBytes > targetBytes; offset += 1) {
457
+ processed += 1;
458
+ const applied = applyImageCandidate(collection, batch[offset], candidates[offset], bodyBytes);
459
+ bodyBytes = applied.bodyBytes;
460
+ adapted ||= applied.changed;
461
+ if (bodyBytes <= targetBytes) {
462
+ collection.commitDirty();
463
+ bodyText = JSON.stringify(reqBody);
464
+ bodyBytes = Buffer.byteLength(bodyText);
465
+ }
466
+ }
467
+ }
468
+ collection.commitDirty();
281
469
  bodyText = JSON.stringify(reqBody);
282
470
  bodyBytes = Buffer.byteLength(bodyText);
283
- adapted ||= bodyBytes < beforeBytes;
284
- console.warn(status("warn", `responses payload ${beforeBytes}b exceeds ${targetBytes}b; image profile max_dim=${profile.maxDim} quality=${profile.quality} -> ${bodyBytes}b`));
471
+ console.warn(status("warn", `responses payload ${beforeBytes}b exceeds ${targetBytes}b; image profile max_dim=${profile.maxDim} quality=${profile.quality} processed=${processed}/${orderedImages.length} -> ${bodyBytes}b`));
285
472
  if (bodyBytes <= targetBytes) break;
286
473
  }
287
474
  }
288
475
 
289
- return { bodyText, bodyBytes, summary, adapted };
476
+ return {
477
+ bodyText,
478
+ bodyBytes,
479
+ summary,
480
+ adapted,
481
+ stage,
482
+ targetBytes,
483
+ overBudget: bodyBytes > targetBytes,
484
+ currentInputStart: normalizeCurrentInputStart(currentInputStart, reqBody),
485
+ };
290
486
  }
291
487
 
292
488
  export function summarizeReqBody(reqBody) {
@@ -297,24 +493,15 @@ export function summarizeReqBody(reqBody) {
297
493
  const countImages = (parts) => {
298
494
  if (!Array.isArray(parts)) return;
299
495
  for (const part of parts) {
300
- if (part?.type === "input_image" || part?.type === "image") images += 1;
496
+ if (["input_image", "image", "image_url"].includes(part?.type)) images += 1;
301
497
  }
302
498
  };
303
499
 
304
500
  for (const item of input) {
501
+ if (["input_image", "image", "image_url"].includes(item?.type)) countImages([item]);
305
502
  if (item?.type === "message") countImages(item.content);
306
- if (item?.type === "function_call_output") {
307
- if (Array.isArray(item.output)) {
308
- countImages(item.output);
309
- } else if (typeof item.output === "string" && item.output.trim().startsWith("[")) {
310
- try {
311
- const parsed = JSON.parse(item.output);
312
- if (Array.isArray(parsed)) countImages(parsed);
313
- } catch {
314
- // Ignore non-JSON tool output.
315
- }
316
- }
317
- }
503
+ const toolOutput = readResponsesToolOutputParts(item);
504
+ if (toolOutput) countImages(toolOutput.parts);
318
505
  }
319
506
 
320
507
  return { items: input.length, images };