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 +9 -5
- package/package.json +1 -1
- package/src/adapter.mjs +1 -0
- package/src/copilot.mjs +32 -5
- package/src/http-transport.mjs +1 -1
- package/src/image-optimization.mjs +269 -82
- package/src/responses-bridge.mjs +31 -11
- package/src/responses-byte-budget.mjs +314 -0
- package/src/responses-chat-payload.mjs +128 -0
- package/src/responses-content.mjs +86 -0
- package/src/responses-handler.mjs +39 -6
- package/src/responses-image-limit.mjs +15 -26
- package/src/responses-request.mjs +43 -6
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
|
|
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` | `
|
|
141
|
-
| `CCDX_IMG_MIN_BYTES` | `100000` |
|
|
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
|
|
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
package/src/adapter.mjs
CHANGED
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, {
|
|
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:
|
|
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, {
|
|
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
|
|
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(),
|
package/src/http-transport.mjs
CHANGED
|
@@ -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,
|
|
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 (
|
|
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
|
-
|
|
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(
|
|
163
|
-
.webp({ quality:
|
|
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(
|
|
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
|
-
|
|
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,
|
|
267
|
+
function visitImageParts(parts, references, commit = null) {
|
|
195
268
|
if (!Array.isArray(parts)) return;
|
|
196
269
|
for (const part of parts) {
|
|
197
|
-
|
|
198
|
-
if (
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
215
|
-
concurrency
|
|
216
|
-
|
|
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
|
-
|
|
225
|
-
|
|
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(
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
257
|
-
|
|
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
|
-
|
|
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 (
|
|
274
|
-
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
|
|
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 {
|
|
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 (
|
|
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
|
-
|
|
307
|
-
|
|
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 };
|