Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 25 additions & 11 deletions src/core/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1235,14 +1235,29 @@ export function extractEnvFields(dynamicText: string): EnvFields {
}

/** Strip the per-turn `x-anthropic-billing-header:` line (changes every turn;
* must not be baked into the image). Returned as `kept` for the system tail. */
* must not be baked into the image). Returned as `kept` for the system tail.
*
* Claude Code sends this as its own system block, so after extractSystemText
* joins the blocks the line is never line 1 and a first-line-only test never
* fires: on 86/86 captured bodies the header reached splitStaticDynamic, which
* has no XML wrapper to key on, classified it static, and baked it into the
* slab PNG. On 2.1.220 the value was session-stable (11/11 captures share
* `cch=07295`) so the PNG stayed byte-identical and nothing showed. 2.1.222
* added `cc_prev_req=<previous request id>`, making the line per-turn by
* construction — re-rendering the slab and voiding the whole cached prefix on
* every request. Match the line wherever it appears. */
function stripBillingLine(text: string): { kept: string | null; body: string } {
const nl = text.indexOf('\n');
const first = nl === -1 ? text : text.slice(0, nl);
if (first.startsWith('x-anthropic-billing-header:')) {
return { kept: first, body: nl === -1 ? '' : text.slice(nl + 1) };
}
return { kept: null, body: text };
const m = /(^|\n)(x-anthropic-billing-header:[^\n]*)(\n?)/.exec(text);
if (!m) return { kept: null, body: text };
const lead = m[1]!; // '' when the line starts the text, else the preceding \n
const trail = m[3]!; // '\n' unless the line ends the text
// Excise the line plus EXACTLY ONE adjacent newline, so the surviving text is
// byte-identical to the same text without the header: the trailing newline
// when there is one (keeps the preceding break), otherwise the leading one.
// Taking both would splice the neighbouring lines together and re-render the
// slab — the very churn this function exists to prevent.
const cutStart = m.index + (trail ? lead.length : 0);
return { kept: m[2]!, body: text.slice(0, cutStart) + text.slice(m.index + m[0].length) };
}

/** Extract the `# Environment` markdown section Claude Code injects into its
Expand Down Expand Up @@ -2079,10 +2094,9 @@ export async function transformRequest(
if (preservedIdentity) {
sysTail.push({ type: 'text', text: preservedIdentity });
}
// Session-stable, so it sits ahead of the churny blocks below.

// billingLine is session-stable (warm reads through the anchored prefix
// confirm it; a per-turn value here would zero every cache read).
// billingLine carries `cc_prev_req` on CLI >= 2.1.222, so it changes every
// turn. It sits with the other churny blocks below, after the anchor and
// outside the slab, where per-turn drift costs nothing.
if (billingLine) sysTail.push({ type: 'text', text: billingLine });
if (dynamicText) sysTail.push({ type: 'text', text: dynamicText });
if (envMarkdown) sysTail.push({ type: 'text', text: envMarkdown });
Expand Down
66 changes: 66 additions & 0 deletions tests/render.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1314,6 +1314,72 @@ describe('transform', () => {
expect(textBlocks.some((b: any) => b.text.includes('x-anthropic-billing-header'))).toBe(true);
});

// The billing header is per-turn on CLI >= 2.1.222, so the slab must render
// the SAME bytes whether the header is present or not, and wherever it sits.
// Asserting on slab bytes (not just "the line came back as text") is what
// catches an off-by-one newline: a stray leading \n or two spliced lines both
// re-render the PNG every turn and void the cached prefix.
describe('billing header never reaches the slab', () => {
const slabOf = async (system: string): Promise<string> => {
const bytes = new TextEncoder().encode(
JSON.stringify({
model: 'claude-3-5-sonnet',
messages: [{ role: 'user', content: 'hi' }],
system,
}),
);
const { body, info } = await transformRequest(bytes);
expect(info.compressed).toBe(true);
const out = JSON.parse(new TextDecoder().decode(body));
// A pure-static system string lands the slab on messages[0], not on
// `system`, so collect every image in the body rather than guessing.
const data: string[] = [];
const walk = (node: any): void => {
if (Array.isArray(node)) return void node.forEach(walk);
if (!node || typeof node !== 'object') return;
if (node.type === 'image' && node.source?.data) data.push(node.source.data);
Object.values(node).forEach(walk);
};
walk(out);
expect(data.length).toBeGreaterThan(0);
return data.join('');
};
const HDR = 'x-anthropic-billing-header: cc_version=2.1.222; cc_prev_req=req_011Cdk3';
const HEAD = 'real prompt text. '.repeat(1250);
const TAIL = 'more ground truth. '.repeat(1250);
const CLEAN = `${HEAD}\n${TAIL}`;

it('renders identical slab bytes when the header leads the system text', async () => {
expect(await slabOf(`${HDR}\n${CLEAN}`)).toBe(await slabOf(CLEAN));
});

it('renders identical slab bytes when the header sits mid-text', async () => {
// Claude Code sends this as its own system block, so after the blocks are
// joined the header is typically NOT line 1 — the case #177 missed.
expect(await slabOf(`${HEAD}\n${HDR}\n${TAIL}`)).toBe(await slabOf(CLEAN));
});

it('renders identical slab bytes when the header ends the system text', async () => {
expect(await slabOf(`${CLEAN}\n${HDR}`)).toBe(await slabOf(CLEAN));
});

it('relocates the header to the system tail from every position', async () => {
for (const system of [`${HDR}\n${CLEAN}`, `${HEAD}\n${HDR}\n${TAIL}`, `${CLEAN}\n${HDR}`]) {
const bytes = new TextEncoder().encode(
JSON.stringify({
model: 'claude-3-5-sonnet',
messages: [{ role: 'user', content: 'hi' }],
system,
}),
);
const { body } = await transformRequest(bytes);
const out = JSON.parse(new TextDecoder().decode(body));
const texts = out.system.filter((b: any) => b.type === 'text').map((b: any) => b.text);
expect(texts.some((t: string) => t.includes(HDR))).toBe(true);
}
});
});

it('keeps <env> as text outside the image so cache_control stays stable', async () => {
// Dense slab (long single line) so the row-aware break-even gate
// greenlights compression. Same total chars as the old short-line
Expand Down