claude-code-cache-fix 3.2.0 → 3.2.1

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
@@ -112,7 +112,7 @@ docker run -d --name cache-fix-proxy --restart=always -p 9801:9801 \
112
112
  ghcr.io/cnighswonger/claude-code-cache-fix:latest
113
113
  ```
114
114
 
115
- Image tags: `latest`, `3`, `3.2`, `3.2.0` (semver-ladder, so `3` always points to the newest 3.x). `latest` always tracks the newest tagged release.
115
+ Image tags: `latest`, `3`, `3.2`, `3.2.1` (semver-ladder, so `3` always points to the newest 3.x). `latest` always tracks the newest tagged release.
116
116
 
117
117
  **Linux note:** the chained-upstream `host.docker.internal` example below is automatic on Docker Desktop (macOS / Windows). On plain Linux Docker Engine you usually need `--add-host=host.docker.internal:host-gateway` so the name resolves to the host bridge. Without it, the container's name lookup fails and the proxy can't reach the upstream service running on the host. Example chaining cache-fix proxy through `llm-relay` running on the host:
118
118
 
@@ -334,6 +334,27 @@ export CACHE_FIX_IMAGE_KEEP_LAST=3
334
334
 
335
335
  Keeps images in the last 3 user messages, replaces older ones with a text placeholder. Only targets `tool_result` blocks — user-pasted images are never touched.
336
336
 
337
+ ### Oversized-image guard
338
+
339
+ ```bash
340
+ export CACHE_FIX_IMAGE_MAX_DIM=2000
341
+ ```
342
+
343
+ The Anthropic API enforces TWO image-related limits on multi-image requests, and the same error message can fire for either:
344
+
345
+ > `"An image in the conversation exceeds the dimension limit for many-image requests (2000px). Start a new session with fewer images."`
346
+
347
+ Two pressure axes to address them:
348
+
349
+ | Pressure | Variable | What it does |
350
+ |---|---|---|
351
+ | **Too many images in conversation** | `CACHE_FIX_IMAGE_KEEP_LAST=N` | Strips images from old user messages, keeps only the last N. |
352
+ | **Any single image too large** | `CACHE_FIX_IMAGE_MAX_DIM=2000` | Replaces images exceeding the dimension limit with a forensic placeholder noting the original dimensions. Covers both user-message direct images and tool_result-nested images. |
353
+
354
+ The two compose: with both set, `KEEP_LAST` runs first (drops the count), then `MAX_DIM` runs on what remains (caps the size of the kept ones). Common triggers for the dimension axis: hi-res manuscript scans, retina screenshots, photos at full resolution.
355
+
356
+ Pure-JS PNG and JPEG header parsing — no native deps. Other formats (GIF, WebP, AVIF, BMP) pass through unchanged regardless of dimension. Fail-open: images whose dimensions can't be parsed (truncated header, unsupported format) are kept rather than stripped — better to send a request that might error than to strip a valid image we just couldn't measure.
357
+
337
358
  ## System prompt rewrite (preload mode, optional)
338
359
 
339
360
  The interceptor can rewrite Claude Code's `# Output efficiency` system-prompt section. Disabled by default. Enable with `CACHE_FIX_OUTPUT_EFFICIENCY_REPLACEMENT`. See [docs/output-efficiency-prompts.md](docs/output-efficiency-prompts.md) for the three known prompt variants and usage instructions.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-cache-fix",
3
- "version": "3.2.0",
3
+ "version": "3.2.1",
4
4
  "description": "Cache optimization proxy and interceptor for Claude Code. Fixes prompt cache bugs, stabilizes prefix, reduces quota burn.",
5
5
  "type": "module",
6
6
  "exports": "./preload.mjs",
@@ -1,6 +1,13 @@
1
+ import { parseImageDimensions } from "../image-dimensions.mjs";
2
+
1
3
  const KEEP_LAST = parseInt(process.env.CACHE_FIX_IMAGE_KEEP_LAST || "0", 10);
4
+ const MAX_DIM = parseInt(process.env.CACHE_FIX_IMAGE_MAX_DIM || "0", 10);
2
5
  const PLACEHOLDER = "[image stripped from history — file may still be on disk]";
3
6
 
7
+ function oversizedPlaceholder(maxDim, w, h) {
8
+ return `[image stripped — exceeded ${maxDim}px max dimension (was ${w}x${h}px)]`;
9
+ }
10
+
4
11
  function stripOldToolResultImages(messages, keepLast) {
5
12
  if (!keepLast || keepLast <= 0 || !Array.isArray(messages)) {
6
13
  return { messages, stats: null };
@@ -58,26 +65,116 @@ function stripOldToolResultImages(messages, keepLast) {
58
65
  return { messages: strippedCount > 0 ? result : messages, stats };
59
66
  }
60
67
 
61
- export { stripOldToolResultImages, PLACEHOLDER };
68
+ // Strip oversized images from BOTH user-message direct content and
69
+ // tool_result-nested content. Orthogonal to KEEP_LAST: scans every image
70
+ // remaining in the message list and replaces any whose width or height
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."
77
+ function stripOversizedImages(messages, maxDim) {
78
+ if (!maxDim || maxDim <= 0 || !Array.isArray(messages)) {
79
+ return { messages, stats: null };
80
+ }
81
+
82
+ let strippedCount = 0;
83
+ let strippedBytes = 0;
84
+
85
+ function maybeStrip(item) {
86
+ if (!item || item.type !== "image") return item;
87
+ const src = item.source;
88
+ if (!src || !src.data || !src.media_type) return item;
89
+ const dims = parseImageDimensions(src.media_type, src.data);
90
+ if (!dims) return item; // can't measure → keep
91
+ if (dims.width <= maxDim && dims.height <= maxDim) return item;
92
+ strippedCount++;
93
+ strippedBytes += src.data.length;
94
+ return { type: "text", text: oversizedPlaceholder(maxDim, dims.width, dims.height) };
95
+ }
96
+
97
+ const result = messages.map((msg) => {
98
+ if (!Array.isArray(msg.content)) return msg;
99
+ let mutated = false;
100
+ const newContent = msg.content.map((block) => {
101
+ // Direct image block on a user message
102
+ if (block && block.type === "image") {
103
+ const replaced = maybeStrip(block);
104
+ if (replaced !== block) {
105
+ mutated = true;
106
+ return replaced;
107
+ }
108
+ return block;
109
+ }
110
+ // Image nested inside a tool_result.content array
111
+ if (block && block.type === "tool_result" && Array.isArray(block.content)) {
112
+ let toolMutated = false;
113
+ const newToolContent = block.content.map((item) => {
114
+ const replaced = maybeStrip(item);
115
+ if (replaced !== item) toolMutated = true;
116
+ return replaced;
117
+ });
118
+ if (toolMutated) {
119
+ mutated = true;
120
+ return { ...block, content: newToolContent };
121
+ }
122
+ }
123
+ return block;
124
+ });
125
+ return mutated ? { ...msg, content: newContent } : msg;
126
+ });
127
+
128
+ const stats = strippedCount > 0
129
+ ? { strippedCount, strippedBytes, estimatedTokens: Math.ceil(strippedBytes * 0.125) }
130
+ : null;
131
+
132
+ return { messages: strippedCount > 0 ? result : messages, stats };
133
+ }
134
+
135
+ export { stripOldToolResultImages, stripOversizedImages, PLACEHOLDER, oversizedPlaceholder };
62
136
 
63
137
  export default {
64
138
  name: "image-strip",
65
- description: "Strip base64 images from old tool results to reduce token waste",
139
+ description:
140
+ "Strip base64 images from old tool results AND optionally strip oversized images that would trigger Anthropic's many-image dimension limit",
66
141
  enabled: false,
67
142
  order: 150,
68
143
 
69
144
  async onRequest(ctx) {
70
145
  const keepLast = parseInt(ctx.meta.imageKeepLast ?? KEEP_LAST, 10);
71
- if (!keepLast || keepLast <= 0) return;
146
+ const maxDim = parseInt(ctx.meta.imageMaxDim ?? MAX_DIM, 10);
147
+ if ((!keepLast || keepLast <= 0) && (!maxDim || maxDim <= 0)) return;
72
148
  if (!ctx.body.messages) return;
73
149
 
74
- const { messages, stats } = stripOldToolResultImages(ctx.body.messages, keepLast);
75
- if (stats) {
150
+ let messages = ctx.body.messages;
151
+ const logParts = [];
152
+
153
+ // Pass 1: existing keep_last behavior. Sets ctx.meta.imageStripStats with
154
+ // the same shape as before this PR — back-compat preserved.
155
+ if (keepLast > 0) {
156
+ const r = stripOldToolResultImages(messages, keepLast);
157
+ if (r.stats) {
158
+ messages = r.messages;
159
+ ctx.meta.imageStripStats = r.stats;
160
+ logParts.push(`keep_last: ${r.stats.strippedCount} stripped (~${r.stats.estimatedTokens} tokens saved)`);
161
+ }
162
+ }
163
+
164
+ // Pass 2: new max_dim behavior. Stats land on a new field so consumers
165
+ // already reading imageStripStats don't see a shape change.
166
+ if (maxDim > 0) {
167
+ const r = stripOversizedImages(messages, maxDim);
168
+ if (r.stats) {
169
+ messages = r.messages;
170
+ ctx.meta.imageStripOversizedStats = r.stats;
171
+ logParts.push(`max_dim: ${r.stats.strippedCount} oversized stripped (~${r.stats.estimatedTokens} tokens saved)`);
172
+ }
173
+ }
174
+
175
+ if (logParts.length > 0) {
76
176
  ctx.body.messages = messages;
77
- ctx.meta.imageStripStats = stats;
78
- process.stderr.write(
79
- `[image-strip] stripped ${stats.strippedCount} images (~${stats.estimatedTokens} tokens saved)\n`
80
- );
177
+ process.stderr.write(`[image-strip] ${logParts.join("; ")}\n`);
81
178
  }
82
179
  },
83
180
  };
@@ -0,0 +1,120 @@
1
+ // Pure-JS image header dimension parsing for PNG and JPEG.
2
+ //
3
+ // Used by the image-strip extension to detect images exceeding a configurable
4
+ // max dimension. Stays in a separate module so it can be unit-tested without
5
+ // the rest of the proxy machinery.
6
+ //
7
+ // No native deps. Decode-only — never modifies the image data.
8
+
9
+ const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
10
+
11
+ // PNG: after the 8-byte magic, the IHDR chunk begins. IHDR layout:
12
+ // [4 bytes length][4 bytes "IHDR"][4 bytes width BE][4 bytes height BE]...
13
+ // Width starts at byte 16, height at byte 20, both 32-bit big-endian.
14
+ export function parsePngDimensions(buffer) {
15
+ if (!buffer || buffer.length < 24) return null;
16
+ for (let i = 0; i < PNG_MAGIC.length; i++) {
17
+ if (buffer[i] !== PNG_MAGIC[i]) return null;
18
+ }
19
+ // Verify the IHDR chunk type (offset 12-15 should be ASCII "IHDR")
20
+ if (
21
+ buffer[12] !== 0x49 || buffer[13] !== 0x48 ||
22
+ buffer[14] !== 0x44 || buffer[15] !== 0x52
23
+ ) {
24
+ return null;
25
+ }
26
+ const width = buffer.readUInt32BE(16);
27
+ const height = buffer.readUInt32BE(20);
28
+ if (width <= 0 || height <= 0) return null;
29
+ return { width, height };
30
+ }
31
+
32
+ // JPEG SOF (Start Of Frame) markers we care about. We don't differentiate
33
+ // between SOF0 (baseline), SOF1 (extended sequential), SOF2 (progressive),
34
+ // SOF3 (lossless) etc — all carry dimensions in the same layout.
35
+ const JPEG_SOF_MARKERS = new Set([
36
+ 0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf,
37
+ ]);
38
+
39
+ // JPEG: starts with FF D8 (SOI). Each segment after is FF <marker> [length BE]
40
+ // [data...]. We scan for an SOF marker and read width/height from its segment.
41
+ // SOF segment layout after the marker: [length 2B][precision 1B][height 2B][width 2B]...
42
+ export function parseJpegDimensions(buffer) {
43
+ if (!buffer || buffer.length < 4) return null;
44
+ if (buffer[0] !== 0xff || buffer[1] !== 0xd8) return null;
45
+
46
+ let i = 2;
47
+ const max = buffer.length;
48
+ // Bound iterations to keep malformed inputs from looping. JPEG headers we
49
+ // care about are well under 1KB; cap at the buffer size we got.
50
+ let iterations = 0;
51
+ while (i < max - 8 && iterations++ < 1000) {
52
+ // Each marker segment starts with 0xFF followed by the marker byte.
53
+ if (buffer[i] !== 0xff) {
54
+ // Skip over fill bytes / pad bytes (not valid in standard JPEG but tolerated)
55
+ i++;
56
+ continue;
57
+ }
58
+ // Skip multiple 0xFF prefixes (pad fill — valid per spec)
59
+ while (i < max - 1 && buffer[i] === 0xff) i++;
60
+ const marker = buffer[i];
61
+ i++;
62
+
63
+ // Markers without a length-prefixed segment: SOI (D8), EOI (D9), RST0-7 (D0-D7).
64
+ if (marker === 0xd8 || marker === 0xd9 || (marker >= 0xd0 && marker <= 0xd7)) {
65
+ continue;
66
+ }
67
+
68
+ if (i + 1 >= max) return null;
69
+ const segLen = (buffer[i] << 8) | buffer[i + 1];
70
+ if (segLen < 2 || i + segLen > max) return null;
71
+
72
+ if (JPEG_SOF_MARKERS.has(marker)) {
73
+ // Layout: [length 2B][precision 1B][height 2B][width 2B]
74
+ // i currently points at the length field's first byte.
75
+ if (i + 6 >= max) return null;
76
+ const height = (buffer[i + 3] << 8) | buffer[i + 4];
77
+ const width = (buffer[i + 5] << 8) | buffer[i + 6];
78
+ if (width <= 0 || height <= 0) return null;
79
+ return { width, height };
80
+ }
81
+
82
+ // Skip this segment to its end and continue scanning.
83
+ i += segLen;
84
+ }
85
+ return null;
86
+ }
87
+
88
+ // Decode a small prefix of base64 data and dispatch to the right parser based
89
+ // on media_type. Returns { width, height } or null.
90
+ //
91
+ // We decode only the first ~512 bytes — enough for PNG IHDR (always near the
92
+ // start) and the typical JPEG SOF location (most image encoders place the SOF
93
+ // within the first few hundred bytes).
94
+ const HEADER_PROBE_BYTES = 1024;
95
+
96
+ export function parseImageDimensions(mediaType, base64Data) {
97
+ if (!mediaType || !base64Data || typeof base64Data !== "string") return null;
98
+ // Base64 expands by ~4/3, so to get HEADER_PROBE_BYTES decoded bytes we need
99
+ // ~HEADER_PROBE_BYTES * 4 / 3 base64 chars. Round up generously.
100
+ const probeChars = Math.min(base64Data.length, HEADER_PROBE_BYTES * 2);
101
+ let buffer;
102
+ try {
103
+ buffer = Buffer.from(base64Data.slice(0, probeChars), "base64");
104
+ } catch {
105
+ return null;
106
+ }
107
+ if (!buffer || buffer.length === 0) return null;
108
+
109
+ switch (mediaType.toLowerCase()) {
110
+ case "image/png":
111
+ return parsePngDimensions(buffer);
112
+ case "image/jpeg":
113
+ case "image/jpg":
114
+ return parseJpegDimensions(buffer);
115
+ default:
116
+ // Unsupported format — fail-open. Caller treats null as "can't measure"
117
+ // and keeps the image rather than stripping what it can't verify.
118
+ return null;
119
+ }
120
+ }