mirror of
https://github.com/teamchong/pxpipe.git
synced 2026-07-22 02:02:51 +02:00
transform: default to placement='user' and stamp ttl='1h' on image cache_control
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.
This commit is contained in:
+10
-2
@@ -36,7 +36,10 @@ const DEFAULTS: Required<TransformOptions> = {
|
|||||||
compressTools: true,
|
compressTools: true,
|
||||||
compressSchemas: true,
|
compressSchemas: true,
|
||||||
minCompressChars: 2000,
|
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,
|
cols: 100,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -296,7 +299,12 @@ function makeImageBlock(pngB64: string, ephemeral = false): ImageBlock {
|
|||||||
type: 'image',
|
type: 'image',
|
||||||
source: { type: 'base64', media_type: 'image/png', data: pngB64 },
|
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;
|
return blk;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+59
-23
@@ -67,10 +67,17 @@ describe('transform', () => {
|
|||||||
expect(info.imageCount).toBeGreaterThanOrEqual(1);
|
expect(info.imageCount).toBeGreaterThanOrEqual(1);
|
||||||
|
|
||||||
const out = JSON.parse(new TextDecoder().decode(body));
|
const out = JSON.parse(new TextDecoder().decode(body));
|
||||||
expect(Array.isArray(out.system)).toBe(true);
|
// Default placement is 'user' — images go into the first user message,
|
||||||
const imageBlocks = out.system.filter((b: any) => b.type === 'image');
|
// 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.length).toBe(info.imageCount);
|
||||||
expect(imageBlocks[0].source.media_type).toBe('image/png');
|
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 () => {
|
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);
|
expect(textBlocks.some((b: any) => b.text.includes('x-anthropic-billing-header'))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps <env> as text after the image so cache_control stays stable', async () => {
|
it('keeps <env> as text outside the image so cache_control stays stable', async () => {
|
||||||
const staticSlab = 'claude.md ground truth.\n'.repeat(500);
|
const staticSlab = 'claude.md ground truth.\n'.repeat(500);
|
||||||
const envBlock =
|
const envBlock =
|
||||||
"<env>\nWorking directory: /tmp/parityproj\nIs directory a git repo: Yes\nPlatform: darwin\nToday's date: 2026-05-18\n</env>";
|
"<env>\nWorking directory: /tmp/parityproj\nIs directory a git repo: Yes\nPlatform: darwin\nToday's date: 2026-05-18\n</env>";
|
||||||
@@ -130,25 +137,27 @@ describe('transform', () => {
|
|||||||
expect(info.staticChars).toBeGreaterThan(info.dynamicChars);
|
expect(info.staticChars).toBeGreaterThan(info.dynamicChars);
|
||||||
|
|
||||||
const out = JSON.parse(new TextDecoder().decode(outBytes));
|
const out = JSON.parse(new TextDecoder().decode(outBytes));
|
||||||
const blocks = out.system as any[];
|
// With placement='user' (the default), images live in the first user
|
||||||
// Find the last image block.
|
// message and the dynamic <env> block is kept as text in the system
|
||||||
let lastImageIdx = -1;
|
// field — so cache_control on the image is unaffected by env drift.
|
||||||
for (let i = 0; i < blocks.length; i++) if (blocks[i].type === 'image') lastImageIdx = i;
|
const userContent = out.messages[0].content as any[];
|
||||||
expect(lastImageIdx).toBeGreaterThanOrEqual(0);
|
const sysBlocks = (Array.isArray(out.system) ? out.system : []) as any[];
|
||||||
|
|
||||||
// Everything AFTER the last image should be text and should contain the
|
const hasImage = userContent.some((b: any) => b.type === 'image');
|
||||||
// <env> block verbatim — that's the whole point of the split.
|
expect(hasImage).toBe(true);
|
||||||
const tail = blocks
|
|
||||||
.slice(lastImageIdx + 1)
|
// <env> 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')
|
.filter((b: any) => b.type === 'text')
|
||||||
.map((b: any) => b.text)
|
.map((b: any) => b.text)
|
||||||
.join('\n');
|
.join('\n');
|
||||||
expect(tail).toContain('<env>');
|
expect(allText).toContain('<env>');
|
||||||
expect(tail).toContain('Working directory: /tmp/parityproj');
|
expect(allText).toContain('Working directory: /tmp/parityproj');
|
||||||
|
|
||||||
// And the static slab must NOT show up in any text block — it lives in
|
// The static slab must NOT appear in any text block — it lives in the
|
||||||
// the image now.
|
// image now.
|
||||||
for (const b of blocks) {
|
for (const b of [...sysBlocks, ...userContent]) {
|
||||||
if (b.type === 'text') expect(b.text).not.toContain('claude.md ground truth.');
|
if (b.type === 'text') expect(b.text).not.toContain('claude.md ground truth.');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -169,7 +178,11 @@ describe('transform', () => {
|
|||||||
expect(info.dynamicBlockCount).toBe(2);
|
expect(info.dynamicBlockCount).toBe(2);
|
||||||
|
|
||||||
const out = JSON.parse(new TextDecoder().decode(outBytes));
|
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.length).toBe(1);
|
||||||
expect(cached[0].type).toBe('image');
|
expect(cached[0].type).toBe('image');
|
||||||
});
|
});
|
||||||
@@ -279,11 +292,12 @@ describe('transform', () => {
|
|||||||
);
|
);
|
||||||
// Compare image PNG bytes only — the request envelope wraps the same
|
// Compare image PNG bytes only — the request envelope wraps the same
|
||||||
// bytes but JSON ordering is deterministic too, so the whole body should
|
// bytes but JSON ordering is deterministic too, so the whole body should
|
||||||
// match.
|
// match. Default placement is 'user', so the images live in the first
|
||||||
const sa = JSON.parse(new TextDecoder().decode(a.body)).system as any[];
|
// user message.
|
||||||
const sb = JSON.parse(new TextDecoder().decode(b.body)).system as any[];
|
const ua = (JSON.parse(new TextDecoder().decode(a.body)).messages[0].content ?? []) as any[];
|
||||||
const imgsA = sa.filter((x: any) => x.type === 'image').map((x: any) => x.source.data);
|
const ub = (JSON.parse(new TextDecoder().decode(b.body)).messages[0].content ?? []) as any[];
|
||||||
const imgsB = sb.filter((x: any) => x.type === 'image').map((x: any) => x.source.data);
|
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.length).toBeGreaterThan(0);
|
||||||
expect(imgsA).toEqual(imgsB);
|
expect(imgsA).toEqual(imgsB);
|
||||||
expect(a.info.systemSha8).toBe(b.info.systemSha8);
|
expect(a.info.systemSha8).toBe(b.info.systemSha8);
|
||||||
@@ -337,4 +351,26 @@ describe('transform', () => {
|
|||||||
const out = JSON.parse(new TextDecoder().decode(outBytes));
|
const out = JSON.parse(new TextDecoder().decode(outBytes));
|
||||||
expect(out.system).toBe(sys);
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user