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 +22 -1
- package/package.json +1 -1
- package/proxy/extensions/image-strip.mjs +106 -9
- package/proxy/image-dimensions.mjs +120 -0
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.
|
|
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.
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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
|
+
}
|