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'); + }); });