claude-code-cache-fix 3.2.1 → 3.4.0
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.ko.md +32 -0
- package/README.md +108 -1
- package/package.json +7 -2
- package/proxy/extensions/identity-normalization.mjs +1 -1
- package/proxy/extensions/image-strip.mjs +566 -39
- package/proxy/extensions/messages-cache-breakpoint.mjs +314 -0
- package/proxy/extensions/microcompact-stability.mjs +428 -0
- package/proxy/extensions/ttl-management.mjs +2 -1
- package/proxy/extensions/ttl-tier-detect.mjs +33 -0
- package/proxy/extensions.json +4 -0
- package/proxy/image-resize.mjs +133 -0
|
@@ -1,5 +1,26 @@
|
|
|
1
|
+
// image-strip extension — back-compat for v3.2.1 KEEP_LAST/MAX_DIM behavior
|
|
2
|
+
// PLUS the v3.3.0 image-guard pipeline.
|
|
3
|
+
//
|
|
4
|
+
// Activation pattern: `enabled: true` in extensions.json (the extension is
|
|
5
|
+
// always loaded), runtime gates per-feature via env vars. Three independent
|
|
6
|
+
// trigger surfaces:
|
|
7
|
+
//
|
|
8
|
+
// - CACHE_FIX_IMAGE_KEEP_LAST=N → legacy Pass 0 (count cap on
|
|
9
|
+
// tool_result images in user msgs)
|
|
10
|
+
// - CACHE_FIX_IMAGE_MAX_DIM=N → legacy Pass 1 strip-only (back-compat)
|
|
11
|
+
// - CACHE_FIX_IMAGE_GUARD=1 → v3.3.0 pipeline (Pass 1 + 2 + count cap)
|
|
12
|
+
// - CACHE_FIX_IMAGE_GUARD=1 +
|
|
13
|
+
// CACHE_FIX_IMAGE_PRESERVE_DETAIL=1 → adds Pass 3 (Lanczos resize via sharp)
|
|
14
|
+
//
|
|
15
|
+
// Execution order: Pass 0 → Pass 3 → Pass 1 → Pass 2 → image-count cap.
|
|
16
|
+
//
|
|
17
|
+
// See `docs/directives/proxy-image-guard-pipeline.md` for full spec, including
|
|
18
|
+
// the precedence matrix and per-pass trigger/action contracts.
|
|
19
|
+
|
|
1
20
|
import { parseImageDimensions } from "../image-dimensions.mjs";
|
|
21
|
+
import { resizeImageToCap } from "../image-resize.mjs";
|
|
2
22
|
|
|
23
|
+
// --- Legacy v3.2.1 constants (back-compat) ---
|
|
3
24
|
const KEEP_LAST = parseInt(process.env.CACHE_FIX_IMAGE_KEEP_LAST || "0", 10);
|
|
4
25
|
const MAX_DIM = parseInt(process.env.CACHE_FIX_IMAGE_MAX_DIM || "0", 10);
|
|
5
26
|
const PLACEHOLDER = "[image stripped from history — file may still be on disk]";
|
|
@@ -8,6 +29,37 @@ function oversizedPlaceholder(maxDim, w, h) {
|
|
|
8
29
|
return `[image stripped — exceeded ${maxDim}px max dimension (was ${w}x${h}px)]`;
|
|
9
30
|
}
|
|
10
31
|
|
|
32
|
+
// --- v3.3.0 pipeline env helpers (read at call time, not at module load,
|
|
33
|
+
// so per-test isolation works without re-importing the module) ---
|
|
34
|
+
function isImageGuardEnabled() {
|
|
35
|
+
return process.env.CACHE_FIX_IMAGE_GUARD === "1";
|
|
36
|
+
}
|
|
37
|
+
function isPreserveDetailEnabled() {
|
|
38
|
+
return process.env.CACHE_FIX_IMAGE_PRESERVE_DETAIL === "1";
|
|
39
|
+
}
|
|
40
|
+
function getMaxDim() {
|
|
41
|
+
return parseInt(process.env.CACHE_FIX_IMAGE_MAX_DIM || "0", 10);
|
|
42
|
+
}
|
|
43
|
+
function getKeepLast() {
|
|
44
|
+
return parseInt(process.env.CACHE_FIX_IMAGE_KEEP_LAST || "0", 10);
|
|
45
|
+
}
|
|
46
|
+
function getRequestSizeMax() {
|
|
47
|
+
// Default 30 MB (31457280 bytes). 2 MB headroom from Anthropic's 32 MB body limit.
|
|
48
|
+
const v = parseInt(process.env.CACHE_FIX_IMAGE_REQUEST_SIZE_MAX || "31457280", 10);
|
|
49
|
+
return v > 0 ? v : 31457280;
|
|
50
|
+
}
|
|
51
|
+
function isDebug() {
|
|
52
|
+
return process.env.CACHE_FIX_DEBUG === "1";
|
|
53
|
+
}
|
|
54
|
+
function getImageCountMax() {
|
|
55
|
+
// Default 100 — single cap covering the only model family in active CC use.
|
|
56
|
+
// Users on legacy Claude 1/2.x/Instant who genuinely need 600 can override.
|
|
57
|
+
const v = parseInt(process.env.CACHE_FIX_IMAGE_COUNT_MAX || "100", 10);
|
|
58
|
+
return v > 0 ? v : 100;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// --- Legacy Pass 0: KEEP_LAST tool_result image strip ---
|
|
62
|
+
// (Unchanged from v3.2.1 — only formatting tightened.)
|
|
11
63
|
function stripOldToolResultImages(messages, keepLast) {
|
|
12
64
|
if (!keepLast || keepLast <= 0 || !Array.isArray(messages)) {
|
|
13
65
|
return { messages, stats: null };
|
|
@@ -65,15 +117,9 @@ function stripOldToolResultImages(messages, keepLast) {
|
|
|
65
117
|
return { messages: strippedCount > 0 ? result : messages, stats };
|
|
66
118
|
}
|
|
67
119
|
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
// exceeds maxDim. Fail-open: images we can't measure (unsupported format,
|
|
72
|
-
// truncated header) are kept rather than stripped.
|
|
73
|
-
//
|
|
74
|
-
// Stripping by oversize prevents the Anthropic API error:
|
|
75
|
-
// "An image in the conversation exceeds the dimension limit for many-image
|
|
76
|
-
// requests (2000px). Start a new session with fewer images."
|
|
120
|
+
// --- Legacy Pass 1 (strip-only by max dim) ---
|
|
121
|
+
// (Unchanged from v3.2.1.) The new pipeline's Pass 1 is a separate function
|
|
122
|
+
// (`runPass1RejectionCapStrip`) that uses the conditional 2000/8000 logic.
|
|
77
123
|
function stripOversizedImages(messages, maxDim) {
|
|
78
124
|
if (!maxDim || maxDim <= 0 || !Array.isArray(messages)) {
|
|
79
125
|
return { messages, stats: null };
|
|
@@ -87,7 +133,7 @@ function stripOversizedImages(messages, maxDim) {
|
|
|
87
133
|
const src = item.source;
|
|
88
134
|
if (!src || !src.data || !src.media_type) return item;
|
|
89
135
|
const dims = parseImageDimensions(src.media_type, src.data);
|
|
90
|
-
if (!dims) return item;
|
|
136
|
+
if (!dims) return item;
|
|
91
137
|
if (dims.width <= maxDim && dims.height <= maxDim) return item;
|
|
92
138
|
strippedCount++;
|
|
93
139
|
strippedBytes += src.data.length;
|
|
@@ -98,7 +144,6 @@ function stripOversizedImages(messages, maxDim) {
|
|
|
98
144
|
if (!Array.isArray(msg.content)) return msg;
|
|
99
145
|
let mutated = false;
|
|
100
146
|
const newContent = msg.content.map((block) => {
|
|
101
|
-
// Direct image block on a user message
|
|
102
147
|
if (block && block.type === "image") {
|
|
103
148
|
const replaced = maybeStrip(block);
|
|
104
149
|
if (replaced !== block) {
|
|
@@ -107,7 +152,6 @@ function stripOversizedImages(messages, maxDim) {
|
|
|
107
152
|
}
|
|
108
153
|
return block;
|
|
109
154
|
}
|
|
110
|
-
// Image nested inside a tool_result.content array
|
|
111
155
|
if (block && block.type === "tool_result" && Array.isArray(block.content)) {
|
|
112
156
|
let toolMutated = false;
|
|
113
157
|
const newToolContent = block.content.map((item) => {
|
|
@@ -132,49 +176,532 @@ function stripOversizedImages(messages, maxDim) {
|
|
|
132
176
|
return { messages: strippedCount > 0 ? result : messages, stats };
|
|
133
177
|
}
|
|
134
178
|
|
|
135
|
-
|
|
179
|
+
// =============================================================================
|
|
180
|
+
// v3.3.0 pipeline
|
|
181
|
+
// =============================================================================
|
|
182
|
+
|
|
183
|
+
// --- Pure helpers (exported for tests) ---
|
|
184
|
+
|
|
185
|
+
// Pass 1 cap selector. `maxDimOverride > 0` always wins over the conditional
|
|
186
|
+
// rejection cap. Otherwise: count > 20 → 2000 px, else 8000 px.
|
|
187
|
+
function pickPass1Cap(imageCount, maxDimOverride) {
|
|
188
|
+
if (maxDimOverride && maxDimOverride > 0) return maxDimOverride;
|
|
189
|
+
return imageCount > 20 ? 2000 : 8000;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Pass 3 native-cap selector. Only Opus 4.7 has the higher cap.
|
|
193
|
+
function pickPass3NativeCap(modelString) {
|
|
194
|
+
if (typeof modelString === "string" && modelString.startsWith("claude-opus-4-7")) {
|
|
195
|
+
return 2576;
|
|
196
|
+
}
|
|
197
|
+
return 1568;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Anthropic's documented per-image token formula: `width * height / 750`,
|
|
201
|
+
// capped at the model's native token cap. Diagnostic only (no enforcement).
|
|
202
|
+
function estimateImageTokens(width, height, modelTokenCap) {
|
|
203
|
+
if (!width || !height) return 0;
|
|
204
|
+
const raw = Math.ceil((width * height) / 750);
|
|
205
|
+
if (modelTokenCap && raw > modelTokenCap) return modelTokenCap;
|
|
206
|
+
return raw;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function nativeTokenCap(modelString) {
|
|
210
|
+
if (typeof modelString === "string" && modelString.startsWith("claude-opus-4-7")) {
|
|
211
|
+
return 4784;
|
|
212
|
+
}
|
|
213
|
+
return 1568;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Walker: enumerate every image in `body.messages`, both user-msg direct content
|
|
217
|
+
// and tool_result.content. Returns a list of `{ msgIdx, blockIdx, itemIdx | null,
|
|
218
|
+
// item }` references. `itemIdx === null` means the image is a direct user-msg
|
|
219
|
+
// content block (not nested in tool_result).
|
|
220
|
+
function walkImages(messages) {
|
|
221
|
+
const out = [];
|
|
222
|
+
if (!Array.isArray(messages)) return out;
|
|
223
|
+
for (let m = 0; m < messages.length; m++) {
|
|
224
|
+
const msg = messages[m];
|
|
225
|
+
if (!Array.isArray(msg.content)) continue;
|
|
226
|
+
for (let b = 0; b < msg.content.length; b++) {
|
|
227
|
+
const block = msg.content[b];
|
|
228
|
+
if (!block) continue;
|
|
229
|
+
if (block.type === "image") {
|
|
230
|
+
out.push({ msgIdx: m, blockIdx: b, itemIdx: null, item: block });
|
|
231
|
+
} else if (block.type === "tool_result" && Array.isArray(block.content)) {
|
|
232
|
+
for (let i = 0; i < block.content.length; i++) {
|
|
233
|
+
const item = block.content[i];
|
|
234
|
+
if (item && item.type === "image") {
|
|
235
|
+
out.push({ msgIdx: m, blockIdx: b, itemIdx: i, item });
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return out;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Mutate a single image at the given (msgIdx, blockIdx, itemIdx) reference,
|
|
245
|
+
// either replacing it with a placeholder text block (strip) or updating its
|
|
246
|
+
// source.data + dims (resize).
|
|
247
|
+
function replaceImageInPlace(messages, ref, replacement) {
|
|
248
|
+
const msg = messages[ref.msgIdx];
|
|
249
|
+
if (!msg || !Array.isArray(msg.content)) return;
|
|
250
|
+
const block = msg.content[ref.blockIdx];
|
|
251
|
+
if (!block) return;
|
|
252
|
+
if (ref.itemIdx === null) {
|
|
253
|
+
msg.content[ref.blockIdx] = replacement;
|
|
254
|
+
} else {
|
|
255
|
+
if (!Array.isArray(block.content)) return;
|
|
256
|
+
block.content[ref.itemIdx] = replacement;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Pure walker for Pass 1: returns the list of image refs whose long edge
|
|
261
|
+
// exceeds `capPx`, alongside their measured dims. Unparseable images are not
|
|
262
|
+
// included (fail-open). Exposed for tests.
|
|
263
|
+
function walkImagesForPass1(messages, capPx) {
|
|
264
|
+
const refs = walkImages(messages);
|
|
265
|
+
const plan = [];
|
|
266
|
+
for (const ref of refs) {
|
|
267
|
+
const src = ref.item.source;
|
|
268
|
+
if (!src || !src.data || !src.media_type) continue;
|
|
269
|
+
const dims = parseImageDimensions(src.media_type, src.data);
|
|
270
|
+
if (!dims) {
|
|
271
|
+
plan.push({ ref, dims: null, action: "skip_unmeasurable" });
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
const longEdge = Math.max(dims.width, dims.height);
|
|
275
|
+
if (longEdge > capPx) {
|
|
276
|
+
plan.push({ ref, dims, action: "strip" });
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return plan;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Pure walker for Pass 3: returns refs whose long edge exceeds `nativeCapPx`,
|
|
283
|
+
// plus the dims/cap so the caller can do the actual resize.
|
|
284
|
+
function walkImagesForPass3(messages, nativeCapPx) {
|
|
285
|
+
const refs = walkImages(messages);
|
|
286
|
+
const plan = [];
|
|
287
|
+
for (const ref of refs) {
|
|
288
|
+
const src = ref.item.source;
|
|
289
|
+
if (!src || !src.data || !src.media_type) continue;
|
|
290
|
+
const dims = parseImageDimensions(src.media_type, src.data);
|
|
291
|
+
if (!dims) {
|
|
292
|
+
plan.push({ ref, dims: null, action: "skip_unmeasurable" });
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
const longEdge = Math.max(dims.width, dims.height);
|
|
296
|
+
if (longEdge > nativeCapPx) {
|
|
297
|
+
plan.push({ ref, dims, action: "resize", capPx: nativeCapPx });
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
return plan;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Eviction order for Pass 2 / count cap: oldest first (low msgIdx wins),
|
|
304
|
+
// within a message tool_result images are preferred over direct images at the
|
|
305
|
+
// same age. Returns an ordered list of refs to drop.
|
|
306
|
+
function pickEvictionTargets(messages) {
|
|
307
|
+
const refs = walkImages(messages);
|
|
308
|
+
// Stable sort: by msgIdx ascending, then prefer tool_result (itemIdx !== null)
|
|
309
|
+
// over direct (itemIdx === null) at the same msgIdx.
|
|
310
|
+
refs.sort((a, b) => {
|
|
311
|
+
if (a.msgIdx !== b.msgIdx) return a.msgIdx - b.msgIdx;
|
|
312
|
+
const aTool = a.itemIdx !== null ? 0 : 1;
|
|
313
|
+
const bTool = b.itemIdx !== null ? 0 : 1;
|
|
314
|
+
if (aTool !== bTool) return aTool - bTool;
|
|
315
|
+
if (a.blockIdx !== b.blockIdx) return a.blockIdx - b.blockIdx;
|
|
316
|
+
return (a.itemIdx ?? 0) - (b.itemIdx ?? 0);
|
|
317
|
+
});
|
|
318
|
+
return refs;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// --- Stats initializer (matches the directive's telemetry surface verbatim) ---
|
|
322
|
+
function initStats() {
|
|
323
|
+
return {
|
|
324
|
+
total_images: 0,
|
|
325
|
+
count_axis_path: "few",
|
|
326
|
+
unsupported_format_count: 0,
|
|
327
|
+
dimension_probe_fail_count: 0,
|
|
328
|
+
resize_attempted: 0,
|
|
329
|
+
resize_succeeded: 0,
|
|
330
|
+
resize_failed: 0,
|
|
331
|
+
library_missing: false,
|
|
332
|
+
images_stripped_pass1: 0,
|
|
333
|
+
images_dropped_for_size: 0,
|
|
334
|
+
images_dropped_for_count_cap: 0,
|
|
335
|
+
|
|
336
|
+
request_bytes_before: 0,
|
|
337
|
+
request_bytes_after: 0,
|
|
338
|
+
request_bytes_headroom: 0,
|
|
339
|
+
image_bytes_total: 0,
|
|
340
|
+
image_bytes_dropped: 0,
|
|
341
|
+
|
|
342
|
+
estimated_image_tokens_total: 0,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// --- Pass 3 runtime: native-cap resize via sharp ---
|
|
347
|
+
async function runPass3NativeCapResize(reqCtx, stats) {
|
|
348
|
+
if (stats.library_missing) return;
|
|
349
|
+
|
|
350
|
+
const messages = reqCtx.body.messages;
|
|
351
|
+
const model = reqCtx.body.model;
|
|
352
|
+
const nativeCap = pickPass3NativeCap(model);
|
|
353
|
+
const tokenCap = nativeTokenCap(model);
|
|
354
|
+
|
|
355
|
+
const plan = walkImagesForPass3(messages, nativeCap);
|
|
356
|
+
for (const step of plan) {
|
|
357
|
+
if (step.action === "skip_unmeasurable") {
|
|
358
|
+
// Tracked separately via the unsupported/probe counters at the top-level
|
|
359
|
+
// walker (we double-count if we touch them here too — leave it to the
|
|
360
|
+
// central counter pass).
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
if (step.action !== "resize") continue;
|
|
364
|
+
|
|
365
|
+
const src = step.ref.item.source;
|
|
366
|
+
stats.resize_attempted++;
|
|
367
|
+
const result = await resizeImageToCap(src.data, src.media_type, step.capPx);
|
|
368
|
+
if (result.ok) {
|
|
369
|
+
stats.resize_succeeded++;
|
|
370
|
+
// Mutate in place: keep the same content block shape, swap data + record dims.
|
|
371
|
+
const newImage = {
|
|
372
|
+
...step.ref.item,
|
|
373
|
+
source: { ...src, data: result.base64 },
|
|
374
|
+
};
|
|
375
|
+
replaceImageInPlace(messages, step.ref, newImage);
|
|
376
|
+
const tokensBefore = estimateImageTokens(step.dims.width, step.dims.height, tokenCap);
|
|
377
|
+
const tokensAfter = estimateImageTokens(result.dims.width, result.dims.height, tokenCap);
|
|
378
|
+
stats.estimated_image_tokens_total += tokensAfter - tokensBefore;
|
|
379
|
+
} else if (result.reason === "library_missing") {
|
|
380
|
+
stats.library_missing = true;
|
|
381
|
+
// Sticky: stop attempting Pass 3 for the remainder of this request.
|
|
382
|
+
// (loadSharp() inside image-resize.mjs is also sticky for the process.)
|
|
383
|
+
return;
|
|
384
|
+
} else {
|
|
385
|
+
stats.resize_failed++;
|
|
386
|
+
// Leave image untouched; Pass 1 will evaluate it against its own cap.
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// --- Pass 1 runtime: conditional rejection-cap strip ---
|
|
392
|
+
function runPass1RejectionCapStrip(reqCtx, stats, opts) {
|
|
393
|
+
const { maxDimOverride } = opts || {};
|
|
394
|
+
const messages = reqCtx.body.messages;
|
|
395
|
+
|
|
396
|
+
const refs = walkImages(messages);
|
|
397
|
+
const imageCount = refs.length;
|
|
398
|
+
stats.total_images = Math.max(stats.total_images, imageCount);
|
|
399
|
+
stats.count_axis_path = imageCount > 20 ? "many" : "few";
|
|
400
|
+
const cap = pickPass1Cap(imageCount, maxDimOverride);
|
|
401
|
+
|
|
402
|
+
for (const ref of refs) {
|
|
403
|
+
const src = ref.item.source;
|
|
404
|
+
if (!src || !src.data || !src.media_type) continue;
|
|
405
|
+
const dims = parseImageDimensions(src.media_type, src.data);
|
|
406
|
+
if (!dims) {
|
|
407
|
+
// Distinguish unsupported format from probe failure on a known type.
|
|
408
|
+
const mt = (src.media_type || "").toLowerCase();
|
|
409
|
+
if (mt === "image/png" || mt === "image/jpeg" || mt === "image/jpg") {
|
|
410
|
+
stats.dimension_probe_fail_count++;
|
|
411
|
+
} else {
|
|
412
|
+
stats.unsupported_format_count++;
|
|
413
|
+
}
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
const longEdge = Math.max(dims.width, dims.height);
|
|
417
|
+
if (longEdge > cap) {
|
|
418
|
+
replaceImageInPlace(messages, ref, {
|
|
419
|
+
type: "text",
|
|
420
|
+
text: oversizedPlaceholder(cap, dims.width, dims.height),
|
|
421
|
+
});
|
|
422
|
+
stats.images_stripped_pass1++;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// --- Pass 2 runtime: request-size guard ---
|
|
428
|
+
function runPass2RequestSizeGuard(reqCtx, stats) {
|
|
429
|
+
const budget = getRequestSizeMax();
|
|
430
|
+
const before = Buffer.byteLength(JSON.stringify(reqCtx.body));
|
|
431
|
+
if (stats.request_bytes_before === 0) stats.request_bytes_before = before;
|
|
432
|
+
|
|
433
|
+
if (before <= budget) {
|
|
434
|
+
stats.request_bytes_after = before;
|
|
435
|
+
stats.request_bytes_headroom = budget - before;
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// Build eviction queue once; drop one at a time, re-measuring after each drop.
|
|
440
|
+
const queue = pickEvictionTargets(reqCtx.body.messages);
|
|
441
|
+
let bytes = before;
|
|
442
|
+
for (const ref of queue) {
|
|
443
|
+
if (bytes <= budget) break;
|
|
444
|
+
const src = ref.item.source;
|
|
445
|
+
const droppedBytes = src && src.data ? src.data.length : 0;
|
|
446
|
+
replaceImageInPlace(reqCtx.body.messages, ref, {
|
|
447
|
+
type: "text",
|
|
448
|
+
text: "[image dropped to fit request-size budget]",
|
|
449
|
+
});
|
|
450
|
+
stats.images_dropped_for_size++;
|
|
451
|
+
stats.image_bytes_dropped += droppedBytes;
|
|
452
|
+
bytes = Buffer.byteLength(JSON.stringify(reqCtx.body));
|
|
453
|
+
}
|
|
454
|
+
stats.request_bytes_after = bytes;
|
|
455
|
+
stats.request_bytes_headroom = budget - bytes;
|
|
456
|
+
// If we exhausted the queue and bytes still exceed budget, the body is
|
|
457
|
+
// over-budget for non-image reasons; the request will fail upstream and we
|
|
458
|
+
// don't address that here. Telemetry already records the final bytes.
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// --- Hard image-count cap ---
|
|
462
|
+
function runImageCountCap(reqCtx, stats) {
|
|
463
|
+
const cap = getImageCountMax();
|
|
464
|
+
const queue = pickEvictionTargets(reqCtx.body.messages);
|
|
465
|
+
if (queue.length <= cap) return;
|
|
466
|
+
const toDrop = queue.length - cap;
|
|
467
|
+
for (let i = 0; i < toDrop; i++) {
|
|
468
|
+
const ref = queue[i];
|
|
469
|
+
const src = ref.item.source;
|
|
470
|
+
const droppedBytes = src && src.data ? src.data.length : 0;
|
|
471
|
+
replaceImageInPlace(reqCtx.body.messages, ref, {
|
|
472
|
+
type: "text",
|
|
473
|
+
text: "[image dropped — exceeded image-count cap]",
|
|
474
|
+
});
|
|
475
|
+
stats.images_dropped_for_count_cap++;
|
|
476
|
+
stats.image_bytes_dropped += droppedBytes;
|
|
477
|
+
}
|
|
478
|
+
// Recompute request_bytes_after after count-cap evictions so the final
|
|
479
|
+
// telemetry reflects the post-pipeline body. Without this, count-cap-only
|
|
480
|
+
// requests would report unchanged byte totals (Codex review note).
|
|
481
|
+
if (toDrop > 0) {
|
|
482
|
+
const budget = getRequestSizeMax();
|
|
483
|
+
const after = Buffer.byteLength(JSON.stringify(reqCtx.body));
|
|
484
|
+
stats.request_bytes_after = after;
|
|
485
|
+
stats.request_bytes_headroom = budget - after;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// --- Telemetry: walk surviving images for byte/token totals ---
|
|
490
|
+
function finalizeTelemetry(reqCtx, stats) {
|
|
491
|
+
const refs = walkImages(reqCtx.body.messages);
|
|
492
|
+
let totalBytes = 0;
|
|
493
|
+
let totalTokens = 0;
|
|
494
|
+
const tokenCap = nativeTokenCap(reqCtx.body.model);
|
|
495
|
+
for (const ref of refs) {
|
|
496
|
+
const src = ref.item.source;
|
|
497
|
+
if (!src || !src.data) continue;
|
|
498
|
+
totalBytes += src.data.length;
|
|
499
|
+
const dims = parseImageDimensions(src.media_type, src.data);
|
|
500
|
+
if (dims) {
|
|
501
|
+
totalTokens += estimateImageTokens(dims.width, dims.height, tokenCap);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
stats.image_bytes_total = totalBytes;
|
|
505
|
+
// estimated_image_tokens_total was decremented by Pass 3 deltas; for the
|
|
506
|
+
// baseline (Pass 3 disabled) recompute from surviving images.
|
|
507
|
+
if (stats.resize_attempted === 0) {
|
|
508
|
+
stats.estimated_image_tokens_total = totalTokens;
|
|
509
|
+
} else {
|
|
510
|
+
// Pass 3 mutated in place; re-measure surviving population to ground-truth.
|
|
511
|
+
stats.estimated_image_tokens_total = totalTokens;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// --- Top-level pipeline orchestrator ---
|
|
516
|
+
async function runImageGuard(reqCtx) {
|
|
517
|
+
const stats = initStats();
|
|
518
|
+
const messages = reqCtx.body.messages;
|
|
519
|
+
if (!Array.isArray(messages)) return stats;
|
|
520
|
+
|
|
521
|
+
// Capture initial population count for the summary line.
|
|
522
|
+
stats.total_images = walkImages(messages).length;
|
|
523
|
+
stats.count_axis_path = stats.total_images > 20 ? "many" : "few";
|
|
524
|
+
|
|
525
|
+
const guardOn = isImageGuardEnabled();
|
|
526
|
+
const preserveOn = isPreserveDetailEnabled();
|
|
527
|
+
const maxDimOverride = getMaxDim();
|
|
528
|
+
|
|
529
|
+
// Warn if PRESERVE_DETAIL is set without IMAGE_GUARD (one-time per process).
|
|
530
|
+
if (!guardOn && preserveOn && !_preserveDetailWarned) {
|
|
531
|
+
process.stderr.write(
|
|
532
|
+
"[image-guard] CACHE_FIX_IMAGE_PRESERVE_DETAIL=1 has no effect without CACHE_FIX_IMAGE_GUARD=1\n"
|
|
533
|
+
);
|
|
534
|
+
_preserveDetailWarned = true;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// Pass 3: native-cap resize (only when both gates are on)
|
|
538
|
+
if (guardOn && preserveOn) {
|
|
539
|
+
await runPass3NativeCapResize(reqCtx, stats);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// Pass 1: rejection-cap strip — runs if IMAGE_GUARD=1 OR legacy MAX_DIM > 0
|
|
543
|
+
if (guardOn || maxDimOverride > 0) {
|
|
544
|
+
runPass1RejectionCapStrip(reqCtx, stats, { maxDimOverride });
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// Pass 2: request-size guard — IMAGE_GUARD only
|
|
548
|
+
if (guardOn) {
|
|
549
|
+
runPass2RequestSizeGuard(reqCtx, stats);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// Hard image-count cap — IMAGE_GUARD only
|
|
553
|
+
if (guardOn) {
|
|
554
|
+
runImageCountCap(reqCtx, stats);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// Final telemetry sweep over surviving images
|
|
558
|
+
finalizeTelemetry(reqCtx, stats);
|
|
559
|
+
|
|
560
|
+
return stats;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// One-time warning state for PRESERVE_DETAIL-without-GUARD.
|
|
564
|
+
let _preserveDetailWarned = false;
|
|
565
|
+
|
|
566
|
+
// Test hook to reset warning flag.
|
|
567
|
+
function _resetWarningStateForTests() {
|
|
568
|
+
_preserveDetailWarned = false;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
export {
|
|
572
|
+
// Legacy v3.2.1 exports (kept stable for back-compat tests)
|
|
573
|
+
stripOldToolResultImages,
|
|
574
|
+
stripOversizedImages,
|
|
575
|
+
PLACEHOLDER,
|
|
576
|
+
oversizedPlaceholder,
|
|
577
|
+
// v3.3.0 pipeline pure functions (test seams)
|
|
578
|
+
pickPass1Cap,
|
|
579
|
+
pickPass3NativeCap,
|
|
580
|
+
estimateImageTokens,
|
|
581
|
+
walkImagesForPass1,
|
|
582
|
+
walkImagesForPass3,
|
|
583
|
+
pickEvictionTargets,
|
|
584
|
+
// Orchestrator (used by the extension default export and direct test calls)
|
|
585
|
+
runImageGuard,
|
|
586
|
+
// Test utilities
|
|
587
|
+
_resetWarningStateForTests,
|
|
588
|
+
};
|
|
136
589
|
|
|
137
590
|
export default {
|
|
138
591
|
name: "image-strip",
|
|
139
592
|
description:
|
|
140
|
-
"
|
|
141
|
-
|
|
593
|
+
"v3.2.1 KEEP_LAST/MAX_DIM legacy paths PLUS v3.3.0 image-guard pipeline " +
|
|
594
|
+
"(conditional rejection cap + request-size guard + optional Lanczos resize)",
|
|
595
|
+
enabled: false, // overridden by extensions.json
|
|
142
596
|
order: 150,
|
|
143
597
|
|
|
144
598
|
async onRequest(ctx) {
|
|
145
|
-
const
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
599
|
+
const guardOn = isImageGuardEnabled();
|
|
600
|
+
const preserveOn = isPreserveDetailEnabled();
|
|
601
|
+
// ctx.meta overrides allow tests to drive the legacy paths without env vars.
|
|
602
|
+
// Pipeline gates (IMAGE_GUARD, PRESERVE_DETAIL) remain env-only — tests that
|
|
603
|
+
// need to flip them set process.env directly.
|
|
604
|
+
const keepLast = parseInt(ctx.meta?.imageKeepLast ?? getKeepLast(), 10);
|
|
605
|
+
const maxDim = parseInt(ctx.meta?.imageMaxDim ?? getMaxDim(), 10);
|
|
149
606
|
|
|
150
|
-
|
|
151
|
-
|
|
607
|
+
// Short-circuit: nothing to do.
|
|
608
|
+
if (!guardOn && keepLast <= 0 && maxDim <= 0) {
|
|
609
|
+
// Surface the PRESERVE_DETAIL-without-GUARD warning even when the
|
|
610
|
+
// pipeline doesn't run otherwise.
|
|
611
|
+
if (preserveOn && !_preserveDetailWarned) {
|
|
612
|
+
process.stderr.write(
|
|
613
|
+
"[image-guard] CACHE_FIX_IMAGE_PRESERVE_DETAIL=1 has no effect without CACHE_FIX_IMAGE_GUARD=1\n"
|
|
614
|
+
);
|
|
615
|
+
_preserveDetailWarned = true;
|
|
616
|
+
}
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
152
619
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
620
|
+
if (!ctx.body || !ctx.body.messages) return;
|
|
621
|
+
|
|
622
|
+
// ========== Legacy path (v3.2.1 back-compat) ==========
|
|
623
|
+
// When IMAGE_GUARD=1 is OFF but legacy env vars are set, run the v3.2.1
|
|
624
|
+
// pipeline exactly as before — preserves bug-for-bug compatibility for
|
|
625
|
+
// existing users.
|
|
626
|
+
if (!guardOn) {
|
|
627
|
+
let messages = ctx.body.messages;
|
|
628
|
+
const logParts = [];
|
|
629
|
+
|
|
630
|
+
if (keepLast > 0) {
|
|
631
|
+
const r = stripOldToolResultImages(messages, keepLast);
|
|
632
|
+
if (r.stats) {
|
|
633
|
+
messages = r.messages;
|
|
634
|
+
ctx.meta.imageStripStats = r.stats;
|
|
635
|
+
logParts.push(
|
|
636
|
+
`keep_last: ${r.stats.strippedCount} stripped (~${r.stats.estimatedTokens} tokens saved)`
|
|
637
|
+
);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
if (maxDim > 0) {
|
|
642
|
+
const r = stripOversizedImages(messages, maxDim);
|
|
643
|
+
if (r.stats) {
|
|
644
|
+
messages = r.messages;
|
|
645
|
+
ctx.meta.imageStripOversizedStats = r.stats;
|
|
646
|
+
logParts.push(
|
|
647
|
+
`max_dim: ${r.stats.strippedCount} oversized stripped ` +
|
|
648
|
+
`(~${r.stats.estimatedTokens} tokens saved)`
|
|
649
|
+
);
|
|
650
|
+
}
|
|
161
651
|
}
|
|
652
|
+
|
|
653
|
+
if (logParts.length > 0) {
|
|
654
|
+
ctx.body.messages = messages;
|
|
655
|
+
if (isDebug()) {
|
|
656
|
+
process.stderr.write(`[image-strip] ${logParts.join("; ")}\n`);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
return;
|
|
162
660
|
}
|
|
163
661
|
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
if (
|
|
167
|
-
const r =
|
|
662
|
+
// ========== v3.3.0 pipeline path ==========
|
|
663
|
+
// KEEP_LAST runs first as Pass 0 (back-compat behavior preserved).
|
|
664
|
+
if (keepLast > 0) {
|
|
665
|
+
const r = stripOldToolResultImages(ctx.body.messages, keepLast);
|
|
168
666
|
if (r.stats) {
|
|
169
|
-
messages = r.messages;
|
|
170
|
-
ctx.meta.
|
|
171
|
-
logParts.push(`max_dim: ${r.stats.strippedCount} oversized stripped (~${r.stats.estimatedTokens} tokens saved)`);
|
|
667
|
+
ctx.body.messages = r.messages;
|
|
668
|
+
ctx.meta.imageStripStats = r.stats;
|
|
172
669
|
}
|
|
173
670
|
}
|
|
174
671
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
672
|
+
const stats = await runImageGuard(ctx);
|
|
673
|
+
ctx.meta.imageGuardStats = stats;
|
|
674
|
+
|
|
675
|
+
// Emit summary only if the pipeline actually did anything observable.
|
|
676
|
+
const didSomething =
|
|
677
|
+
stats.images_stripped_pass1 > 0 ||
|
|
678
|
+
stats.images_dropped_for_size > 0 ||
|
|
679
|
+
stats.images_dropped_for_count_cap > 0 ||
|
|
680
|
+
stats.resize_attempted > 0 ||
|
|
681
|
+
stats.resize_succeeded > 0 ||
|
|
682
|
+
stats.unsupported_format_count > 0 ||
|
|
683
|
+
stats.dimension_probe_fail_count > 0;
|
|
684
|
+
if (didSomething && isDebug()) {
|
|
685
|
+
const parts = [];
|
|
686
|
+
if (stats.resize_succeeded > 0) parts.push(`resized=${stats.resize_succeeded}`);
|
|
687
|
+
if (stats.resize_failed > 0) parts.push(`resize_failed=${stats.resize_failed}`);
|
|
688
|
+
if (stats.library_missing) parts.push("sharp=missing");
|
|
689
|
+
if (stats.images_stripped_pass1 > 0) parts.push(`stripped=${stats.images_stripped_pass1}`);
|
|
690
|
+
if (stats.images_dropped_for_size > 0) parts.push(`evicted=${stats.images_dropped_for_size}`);
|
|
691
|
+
if (stats.images_dropped_for_count_cap > 0) {
|
|
692
|
+
parts.push(`count_capped=${stats.images_dropped_for_count_cap}`);
|
|
693
|
+
}
|
|
694
|
+
if (stats.unsupported_format_count > 0) parts.push(`unsupported=${stats.unsupported_format_count}`);
|
|
695
|
+
const summary = parts.join(" ") || "ran";
|
|
696
|
+
const finalImages = stats.total_images
|
|
697
|
+
- stats.images_stripped_pass1
|
|
698
|
+
- stats.images_dropped_for_size
|
|
699
|
+
- stats.images_dropped_for_count_cap;
|
|
700
|
+
process.stderr.write(
|
|
701
|
+
`[image-guard] ${summary} req_bytes=${stats.request_bytes_before}->${stats.request_bytes_after} ` +
|
|
702
|
+
`(headroom=${stats.request_bytes_headroom}) images=${stats.total_images}->${finalImages}\n`
|
|
703
|
+
);
|
|
178
704
|
}
|
|
179
705
|
},
|
|
180
706
|
};
|
|
707
|
+
|