From e0950efa6f605b4ffba94c60742b6f5ce0d5752a Mon Sep 17 00:00:00 2001 From: teamchong <25894545+teamchong@users.noreply.github.com> Date: Mon, 18 May 2026 17:11:56 -0400 Subject: [PATCH] transform: default to placement='user' and stamp ttl='1h' on image cache_control MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two protocol-correctness fixes that have to ship together. 1. **Default placement: 'system' → 'user'.** Anthropic's `system` field is typed as `string | TextBlock[]` — image blocks there come back as `400 system.N.type: Input should be 'text'`. Images must go into a user-message content array. 2. **ttl='1h' on the image's cache_control.** Anthropic enforces the rule "ttl='1h' must not come after ttl='5m'" in processing order (tools → system → messages). Claude Code marks its own user-message content with ttl='1h'; if we leave ttl unset it defaults to '5m', our block lands first in processing order, and the request 400s the moment Claude Code's own 1h breakpoint shows up later in the conversation. The existing image-placement tests now look in messages[0].content instead of out.system, and a new test pins the ttl='1h' invariant directly. --- src/core/transform.ts | 12 +++++-- tests/render.test.ts | 82 +++++++++++++++++++++++++++++++------------ 2 files changed, 69 insertions(+), 25 deletions(-) diff --git a/src/core/transform.ts b/src/core/transform.ts index d62d157..606ac65 100644 --- a/src/core/transform.ts +++ b/src/core/transform.ts @@ -36,7 +36,10 @@ const DEFAULTS: Required = { compressTools: true, compressSchemas: true, minCompressChars: 2000, - placement: 'system', + // Anthropic's `system` field accepts text blocks only — image blocks there + // come back as `400 system.N.type: Input should be 'text'`. Images must go + // into a user message instead. + placement: 'user', cols: 100, }; @@ -296,7 +299,12 @@ function makeImageBlock(pngB64: string, ephemeral = false): ImageBlock { type: 'image', source: { type: 'base64', media_type: 'image/png', data: pngB64 }, }; - if (ephemeral) blk.cache_control = { type: 'ephemeral' }; + // ttl='1h' is mandatory, not cosmetic. Claude Code marks its own + // user-message content with cache_control ttl='1h'; Anthropic enforces + // "ttl='1h' must not come after ttl='5m'" in processing order + // (tools → system → messages). If we leave ttl unset it defaults to '5m' + // and our block lands BEFORE Claude Code's 1h block → 400 at runtime. + if (ephemeral) blk.cache_control = { type: 'ephemeral', ttl: '1h' }; return blk; } diff --git a/tests/render.test.ts b/tests/render.test.ts index 80015fa..6a0c13a 100644 --- a/tests/render.test.ts +++ b/tests/render.test.ts @@ -67,10 +67,17 @@ describe('transform', () => { expect(info.imageCount).toBeGreaterThanOrEqual(1); const out = JSON.parse(new TextDecoder().decode(body)); - expect(Array.isArray(out.system)).toBe(true); - const imageBlocks = out.system.filter((b: any) => b.type === 'image'); + // Default placement is 'user' — images go into the first user message, + // not the system field (Anthropic rejects image blocks in `system`). + const userContent = out.messages[0].content as any[]; + expect(Array.isArray(userContent)).toBe(true); + const imageBlocks = userContent.filter((b: any) => b.type === 'image'); expect(imageBlocks.length).toBe(info.imageCount); expect(imageBlocks[0].source.media_type).toBe('image/png'); + // And the system field must NOT contain image blocks (would 400). + if (Array.isArray(out.system)) { + for (const b of out.system) expect(b.type).not.toBe('image'); + } }); it('folds tool docs into the same image and stubs originals', async () => { @@ -111,7 +118,7 @@ describe('transform', () => { expect(textBlocks.some((b: any) => b.text.includes('x-anthropic-billing-header'))).toBe(true); }); - it('keeps as text after the image so cache_control stays stable', async () => { + it('keeps as text outside the image so cache_control stays stable', async () => { const staticSlab = 'claude.md ground truth.\n'.repeat(500); const envBlock = "\nWorking directory: /tmp/parityproj\nIs directory a git repo: Yes\nPlatform: darwin\nToday's date: 2026-05-18\n"; @@ -130,25 +137,27 @@ describe('transform', () => { expect(info.staticChars).toBeGreaterThan(info.dynamicChars); const out = JSON.parse(new TextDecoder().decode(outBytes)); - const blocks = out.system as any[]; - // Find the last image block. - let lastImageIdx = -1; - for (let i = 0; i < blocks.length; i++) if (blocks[i].type === 'image') lastImageIdx = i; - expect(lastImageIdx).toBeGreaterThanOrEqual(0); + // With placement='user' (the default), images live in the first user + // message and the dynamic block is kept as text in the system + // field — so cache_control on the image is unaffected by env drift. + const userContent = out.messages[0].content as any[]; + const sysBlocks = (Array.isArray(out.system) ? out.system : []) as any[]; - // Everything AFTER the last image should be text and should contain the - // block verbatim — that's the whole point of the split. - const tail = blocks - .slice(lastImageIdx + 1) + const hasImage = userContent.some((b: any) => b.type === 'image'); + expect(hasImage).toBe(true); + + // must show up as text somewhere outside the image — the dynamic + // tail. With 'user' placement that's the system field. + const allText = [...sysBlocks, ...userContent] .filter((b: any) => b.type === 'text') .map((b: any) => b.text) .join('\n'); - expect(tail).toContain(''); - expect(tail).toContain('Working directory: /tmp/parityproj'); + expect(allText).toContain(''); + expect(allText).toContain('Working directory: /tmp/parityproj'); - // And the static slab must NOT show up in any text block — it lives in - // the image now. - for (const b of blocks) { + // The static slab must NOT appear in any text block — it lives in the + // image now. + for (const b of [...sysBlocks, ...userContent]) { if (b.type === 'text') expect(b.text).not.toContain('claude.md ground truth.'); } }); @@ -169,7 +178,11 @@ describe('transform', () => { expect(info.dynamicBlockCount).toBe(2); const out = JSON.parse(new TextDecoder().decode(outBytes)); - const cached = (out.system as any[]).filter((b: any) => b.cache_control); + // cache_control must land on exactly one image block — anywhere in the + // request (system field OR user message), never on a text block. + const sysBlocks = (Array.isArray(out.system) ? out.system : []) as any[]; + const userContent = (out.messages[0].content ?? []) as any[]; + const cached = [...sysBlocks, ...userContent].filter((b: any) => b.cache_control); expect(cached.length).toBe(1); expect(cached[0].type).toBe('image'); }); @@ -279,11 +292,12 @@ describe('transform', () => { ); // Compare image PNG bytes only — the request envelope wraps the same // bytes but JSON ordering is deterministic too, so the whole body should - // match. - const sa = JSON.parse(new TextDecoder().decode(a.body)).system as any[]; - const sb = JSON.parse(new TextDecoder().decode(b.body)).system as any[]; - const imgsA = sa.filter((x: any) => x.type === 'image').map((x: any) => x.source.data); - const imgsB = sb.filter((x: any) => x.type === 'image').map((x: any) => x.source.data); + // match. Default placement is 'user', so the images live in the first + // user message. + const ua = (JSON.parse(new TextDecoder().decode(a.body)).messages[0].content ?? []) as any[]; + const ub = (JSON.parse(new TextDecoder().decode(b.body)).messages[0].content ?? []) as any[]; + const imgsA = ua.filter((x: any) => x.type === 'image').map((x: any) => x.source.data); + const imgsB = ub.filter((x: any) => x.type === 'image').map((x: any) => x.source.data); expect(imgsA.length).toBeGreaterThan(0); expect(imgsA).toEqual(imgsB); expect(a.info.systemSha8).toBe(b.info.systemSha8); @@ -337,4 +351,26 @@ describe('transform', () => { const out = JSON.parse(new TextDecoder().decode(outBytes)); expect(out.system).toBe(sys); }); + + it("uses ttl='1h' on the image cache_control (Anthropic ordering rule)", async () => { + // Without ttl='1h' on our cache_control, Claude Code's own ttl='1h' + // breakpoint on later user-message content triggers 400: "ttl='1h' must + // not come after ttl='5m'" because our default 5m would land first. + const body = new TextEncoder().encode( + JSON.stringify({ + model: 'claude', + messages: [{ role: 'user', content: 'hi' }], + system: 'claude.md\n'.repeat(500), + }), + ); + const { body: outBytes } = await transformRequest(body); + const out = JSON.parse(new TextDecoder().decode(outBytes)); + const blocks = [ + ...((Array.isArray(out.system) ? out.system : []) as any[]), + ...((out.messages?.[0]?.content ?? []) as any[]), + ]; + const cached = blocks.filter((b: any) => b.cache_control); + expect(cached.length).toBe(1); + expect(cached[0].cache_control.ttl).toBe('1h'); + }); });