mirror of
https://github.com/teamchong/pxpipe.git
synced 2026-07-22 02:02:51 +02:00
transform: compress <system-reminder> blocks in the first user message
Claude Code re-injects the same long <system-reminder> blocks (CLAUDE.md
hints, task-tools reminders, the dynamic skills list, etc.) into the
FIRST user message on every turn. They're text, so they're not part of
the system+tools image we already cache — they hit the wire fresh every
turn. This rewrite renders any reminder block ≥ minReminderChars (1000
by default, matching legacy/python/proxy.py) to PNG images placed inside
the user message content array, just like the static slab.
Why: the system+tools image is the only cache_control breakpoint we're
allowed (Anthropic caps at 4 and Claude Code uses the others). Per-block
reminder images therefore carry NO cache_control — they're per-turn
content that the model OCRs without prefix-cache benefit. The win is
token cost, not cache: reminders are 65-73% cheaper as PNG than as text,
and a typical turn has 1-3 of them.
Layout inside the first user message after compression:
[intro text] ← static (helps OCR framing)
[image block(s)] ← static; LAST has cache_control
↑ cache breakpoint (the only one)
[End of rendered context.] ← static text closer for the image
[processed existing content] ← per-turn (incl. reminder images)
Wire-up:
- src/core/transform.ts: new TransformOptions.compressReminders (default
true) + minReminderChars (default 1000). Two new helpers,
textToImageBlocks() (wraps renderTextToPngs + makeImageBlock with
no cache_control) and approxBlockBytes() (b64 → byte-count for the
imageBytes telemetry, no second base64 round-trip). The placement
='user' branch now walks `existing` and rewrites long reminders.
- src/core/tracker.ts: TrackEvent.reminder_imgs flows from
TransformInfo.reminderImgs through toTrackEvent so `pixelpipe stats`
and the dashboard can attribute compression contributions.
- src/worker.ts: COMPRESS_REMINDERS + MIN_REMINDER_CHARS env vars.
- src/node.ts: matching CliOpts, parseCli cases (--no-reminders,
--min-reminder-chars), help text, env-var summary, and `rem+N` in
the per-request console line when reminder_imgs > 0.
Tests:
- "compresses long <system-reminder> blocks in the first user message"
confirms info.reminderImgs ≥ 1, the reminder text is gone from
user-content text blocks, the user's actual prompt survives, and the
new reminder image blocks carry NO cache_control.
- "leaves short <system-reminder> blocks alone (below minReminderChars)"
confirms info.reminderImgs is 0 and the reminder text passes through
as text.
This commit is contained in:
@@ -33,6 +33,9 @@ export interface TrackEvent {
|
||||
static_chars?: number;
|
||||
dynamic_chars?: number;
|
||||
dynamic_block_count?: number;
|
||||
/** Image count attributable to compressing `<system-reminder>` blocks in
|
||||
* the first user message. */
|
||||
reminder_imgs?: number;
|
||||
/** Tag names found in the static slab we don't recognize. Canary for
|
||||
* Claude Code releases that add new dynamic tags. */
|
||||
unknown_static_tags?: string[];
|
||||
@@ -92,6 +95,7 @@ export function toTrackEvent(ev: ProxyEvent): TrackEvent {
|
||||
if (info.staticChars !== undefined) out.static_chars = info.staticChars;
|
||||
if (info.dynamicChars !== undefined) out.dynamic_chars = info.dynamicChars;
|
||||
if (info.dynamicBlockCount !== undefined) out.dynamic_block_count = info.dynamicBlockCount;
|
||||
if (info.reminderImgs !== undefined) out.reminder_imgs = info.reminderImgs;
|
||||
if (info.unknownStaticTags && info.unknownStaticTags.length > 0)
|
||||
out.unknown_static_tags = info.unknownStaticTags;
|
||||
if (info.systemSha8) out.system_sha8 = info.systemSha8;
|
||||
|
||||
+82
-5
@@ -9,7 +9,14 @@
|
||||
* minimum. Stricter byte-for-byte parity is verified in tests.
|
||||
*/
|
||||
|
||||
import type { ImageBlock, MessagesRequest, SystemField, TextBlock, ToolDef } from './types.js';
|
||||
import type {
|
||||
ContentBlock,
|
||||
ImageBlock,
|
||||
MessagesRequest,
|
||||
SystemField,
|
||||
TextBlock,
|
||||
ToolDef,
|
||||
} from './types.js';
|
||||
import { renderTextToPngs } from './render.js';
|
||||
import { bytesToBase64 } from './png.js';
|
||||
|
||||
@@ -22,8 +29,14 @@ export interface TransformOptions {
|
||||
compressTools?: boolean;
|
||||
/** Include full input_schema JSON for each tool. Adds tokens but maximizes parity. */
|
||||
compressSchemas?: boolean;
|
||||
/** Compress large `<system-reminder>` text blocks in the first user message.
|
||||
* Claude Code re-injects these every turn; rendering them to images shares
|
||||
* the cache anchor with the system+tools render. */
|
||||
compressReminders?: boolean;
|
||||
/** Don't compress if total compressible chars below this. */
|
||||
minCompressChars?: number;
|
||||
/** Per-block threshold for compressReminders (chars). */
|
||||
minReminderChars?: number;
|
||||
/** Where to attach the image block — system field, or first user message. */
|
||||
placement?: 'system' | 'user';
|
||||
/** Soft-wrap column count. */
|
||||
@@ -35,7 +48,10 @@ const DEFAULTS: Required<TransformOptions> = {
|
||||
compressSystem: true,
|
||||
compressTools: true,
|
||||
compressSchemas: true,
|
||||
compressReminders: true,
|
||||
minCompressChars: 2000,
|
||||
// Matches Python defaults: 1000 chars for <system-reminder>.
|
||||
minReminderChars: 1000,
|
||||
// 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.
|
||||
@@ -91,6 +107,9 @@ export interface TransformInfo {
|
||||
/** Pixel dimensions of the first image. */
|
||||
firstImageWidth?: number;
|
||||
firstImageHeight?: number;
|
||||
/** Number of images we added by compressing `<system-reminder>` blocks in
|
||||
* the first user message. */
|
||||
reminderImgs?: number;
|
||||
}
|
||||
|
||||
// --- helpers ---------------------------------------------------------------
|
||||
@@ -314,6 +333,25 @@ function makeImageBlock(pngB64: string, ephemeral = false): ImageBlock {
|
||||
return blk;
|
||||
}
|
||||
|
||||
/** Render a long text blob to one or more PNG image blocks. Helper for the
|
||||
* per-message compressions (reminders, tool_results) — no cache_control on
|
||||
* these (Anthropic caps at 4 breakpoints; the system+tools image already
|
||||
* anchors the cacheable prefix). */
|
||||
async function textToImageBlocks(text: string, cols: number): Promise<ImageBlock[]> {
|
||||
const imgs = await renderTextToPngs(text, cols);
|
||||
return imgs.map((img) => makeImageBlock(bytesToBase64(img.png), false));
|
||||
}
|
||||
|
||||
/** Best-effort byte-count of an image block's PNG payload (decoded from b64).
|
||||
* Used only for the imageBytes telemetry; an exact value isn't worth a
|
||||
* second base64 round-trip. */
|
||||
function approxBlockBytes(blk: ImageBlock): number {
|
||||
const b64 = blk.source.data;
|
||||
// base64 → bytes: every 4 chars decode to 3 bytes, minus padding.
|
||||
const pad = b64.endsWith('==') ? 2 : b64.endsWith('=') ? 1 : 0;
|
||||
return Math.floor((b64.length * 3) / 4) - pad;
|
||||
}
|
||||
|
||||
// --- main transform --------------------------------------------------------
|
||||
|
||||
/**
|
||||
@@ -472,10 +510,49 @@ export async function transformRequest(
|
||||
const existing = Array.isArray(m.content)
|
||||
? m.content
|
||||
: [{ type: 'text' as const, text: m.content }];
|
||||
// Only the intro + images belong in a user message — the end marker
|
||||
// and dynamic blocks live in the system field above.
|
||||
const userPrefix: TextBlock[] = [{ type: 'text', text: introText }];
|
||||
m.content = [...userPrefix, ...imageBlocks, ...existing];
|
||||
|
||||
// 5a. <system-reminder> compression — long reminder blocks in the first
|
||||
// user message get re-injected every turn; rendering them to images
|
||||
// shares the cache anchor (the system+tools image carries the only
|
||||
// cache_control). No cache_control on these images.
|
||||
const processedExisting: ContentBlock[] = [];
|
||||
if (o.compressReminders) {
|
||||
for (const blk of existing) {
|
||||
if (
|
||||
blk &&
|
||||
(blk as TextBlock).type === 'text' &&
|
||||
typeof (blk as TextBlock).text === 'string' &&
|
||||
(blk as TextBlock).text.trimStart().startsWith('<system-reminder>') &&
|
||||
(blk as TextBlock).text.length >= o.minReminderChars
|
||||
) {
|
||||
const imgs = await textToImageBlocks((blk as TextBlock).text, o.cols);
|
||||
for (const img of imgs) {
|
||||
processedExisting.push(img);
|
||||
info.imageBytes += approxBlockBytes(img);
|
||||
}
|
||||
info.reminderImgs = (info.reminderImgs ?? 0) + imgs.length;
|
||||
info.imageCount += imgs.length;
|
||||
} else {
|
||||
processedExisting.push(blk);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
processedExisting.push(...existing);
|
||||
}
|
||||
|
||||
// Cache-friendly layout:
|
||||
// [intro text] ← static (helps OCR framing)
|
||||
// [image block(s)] ← static; LAST has cache_control
|
||||
// ↑ cache breakpoint
|
||||
// [End of rendered context.] ← static text closer for the image
|
||||
// [processed existing content] ← per-turn (incl. reminder images,
|
||||
// which have NO cache_control)
|
||||
m.content = [
|
||||
{ type: 'text' as const, text: introText },
|
||||
...imageBlocks,
|
||||
{ type: 'text' as const, text: '[End of rendered context.]' },
|
||||
...processedExisting,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+18
-5
@@ -21,7 +21,9 @@ interface CliOpts {
|
||||
compress: boolean;
|
||||
compressTools: boolean;
|
||||
compressSchemas: boolean;
|
||||
compressReminders: boolean;
|
||||
minCompressChars: number;
|
||||
minReminderChars: number;
|
||||
placement: 'system' | 'user';
|
||||
cols: number;
|
||||
/** When true, append per-request events to eventsFile. Default-on. */
|
||||
@@ -43,7 +45,9 @@ function parseCli(argv: string[]): CliOpts {
|
||||
compress: envFlag('COMPRESS', true),
|
||||
compressTools: envFlag('COMPRESS_TOOLS', true),
|
||||
compressSchemas: envFlag('COMPRESS_SCHEMAS', true),
|
||||
compressReminders: envFlag('COMPRESS_REMINDERS', true),
|
||||
minCompressChars: Number(process.env.MIN_COMPRESS_CHARS ?? 2000),
|
||||
minReminderChars: Number(process.env.MIN_REMINDER_CHARS ?? 1000),
|
||||
placement: (process.env.PLACEMENT as 'system' | 'user') ?? 'user',
|
||||
cols: Number(process.env.COLS ?? 100),
|
||||
track: envFlag('PIXELPIPE_TRACK', true),
|
||||
@@ -61,7 +65,9 @@ function parseCli(argv: string[]): CliOpts {
|
||||
case '--no-compress': o.compress = false; break;
|
||||
case '--no-tools': o.compressTools = false; break;
|
||||
case '--no-schemas': o.compressSchemas = false; break;
|
||||
case '--no-reminders': o.compressReminders = false; break;
|
||||
case '--min-chars': o.minCompressChars = Number(eat()); break;
|
||||
case '--min-reminder-chars': o.minReminderChars = Number(eat()); break;
|
||||
case '--placement': o.placement = eat() as 'system' | 'user'; break;
|
||||
case '--cols': o.cols = Number(eat()); break;
|
||||
case '--no-track': o.track = false; break;
|
||||
@@ -92,7 +98,9 @@ Options:
|
||||
--no-compress disable all compression (pure passthrough)
|
||||
--no-tools don't fold tool docs into the image
|
||||
--no-schemas don't include input_schema JSON in the image
|
||||
--min-chars <N> skip compression below this many chars (default 2000)
|
||||
--no-reminders don't image-compress <system-reminder> blocks
|
||||
--min-chars <N> skip system compression below this many chars (default 2000)
|
||||
--min-reminder-chars <N> per-block threshold for --no-reminders (default 1000)
|
||||
--placement <where> 'system' or 'user' (default user; 'system' is
|
||||
rejected by the API for image blocks)
|
||||
--cols <N> soft-wrap column count (default 100)
|
||||
@@ -103,8 +111,8 @@ Options:
|
||||
|
||||
Environment:
|
||||
Same as flags via PORT, ANTHROPIC_UPSTREAM, COMPRESS, COMPRESS_TOOLS,
|
||||
COMPRESS_SCHEMAS, MIN_COMPRESS_CHARS, PLACEMENT, COLS, PIXELPIPE_TRACK,
|
||||
PIXELPIPE_LOG.
|
||||
COMPRESS_SCHEMAS, COMPRESS_REMINDERS, MIN_COMPRESS_CHARS,
|
||||
MIN_REMINDER_CHARS, PLACEMENT, COLS, PIXELPIPE_TRACK, PIXELPIPE_LOG.
|
||||
|
||||
Use with Claude Code:
|
||||
ANTHROPIC_BASE_URL=http://127.0.0.1:47821 claude
|
||||
@@ -305,7 +313,9 @@ async function main(): Promise<void> {
|
||||
compress: opts.compress,
|
||||
compressTools: opts.compressTools,
|
||||
compressSchemas: opts.compressSchemas,
|
||||
compressReminders: opts.compressReminders,
|
||||
minCompressChars: opts.minCompressChars,
|
||||
minReminderChars: opts.minReminderChars,
|
||||
placement: opts.placement,
|
||||
cols: opts.cols,
|
||||
};
|
||||
@@ -326,8 +336,11 @@ async function main(): Promise<void> {
|
||||
// info.firstImagePng, so capturing has to happen on the raw event.
|
||||
dashboard.update(e);
|
||||
// Terse human-readable console line.
|
||||
const extra: string[] = [];
|
||||
if (e.info?.reminderImgs) extra.push(`rem+${e.info.reminderImgs}`);
|
||||
const extraTag = extra.length > 0 ? ` (${extra.join(' ')})` : '';
|
||||
const tag = e.info?.compressed
|
||||
? `compressed ${e.info.origChars}ch → ${e.info.imageCount}img/${e.info.imageBytes}B`
|
||||
? `compressed ${e.info.origChars}ch → ${e.info.imageCount}img/${e.info.imageBytes}B${extraTag}`
|
||||
: (e.info?.reason ?? '');
|
||||
const cacheRead = e.usage?.cache_read_input_tokens ?? 0;
|
||||
const inputTokens = e.usage?.input_tokens ?? 0;
|
||||
@@ -389,7 +402,7 @@ async function main(): Promise<void> {
|
||||
server.listen(opts.port, () => {
|
||||
console.log(`[pixelpipe] listening on http://127.0.0.1:${opts.port} → ${opts.upstream}`);
|
||||
console.log(
|
||||
`[pixelpipe] config: compress=${opts.compress} tools=${opts.compressTools} schemas=${opts.compressSchemas} min=${opts.minCompressChars} placement=${opts.placement} cols=${opts.cols}`,
|
||||
`[pixelpipe] config: compress=${opts.compress} tools=${opts.compressTools} schemas=${opts.compressSchemas} reminders=${opts.compressReminders} min=${opts.minCompressChars} placement=${opts.placement} cols=${opts.cols}`,
|
||||
);
|
||||
if (opts.track) console.log(`[pixelpipe] tracking events → ${opts.eventsFile}`);
|
||||
else console.log('[pixelpipe] tracking disabled (--no-track or PIXELPIPE_TRACK=0)');
|
||||
|
||||
@@ -22,7 +22,9 @@ export interface Env {
|
||||
COMPRESS?: string;
|
||||
COMPRESS_TOOLS?: string;
|
||||
COMPRESS_SCHEMAS?: string;
|
||||
COMPRESS_REMINDERS?: string;
|
||||
MIN_COMPRESS_CHARS?: string;
|
||||
MIN_REMINDER_CHARS?: string;
|
||||
PLACEMENT?: string;
|
||||
COLS?: string;
|
||||
/** When "0" / "false", disable per-request event JSON logs. Default-on.
|
||||
@@ -40,7 +42,9 @@ export default {
|
||||
compress: truthy(env.COMPRESS, true),
|
||||
compressTools: truthy(env.COMPRESS_TOOLS, true),
|
||||
compressSchemas: truthy(env.COMPRESS_SCHEMAS, true),
|
||||
compressReminders: truthy(env.COMPRESS_REMINDERS, true),
|
||||
minCompressChars: env.MIN_COMPRESS_CHARS ? Number(env.MIN_COMPRESS_CHARS) : 2000,
|
||||
minReminderChars: env.MIN_REMINDER_CHARS ? Number(env.MIN_REMINDER_CHARS) : 1000,
|
||||
placement: (env.PLACEMENT as 'system' | 'user') ?? 'user',
|
||||
cols: env.COLS ? Number(env.COLS) : 100,
|
||||
};
|
||||
|
||||
@@ -373,4 +373,66 @@ describe('transform', () => {
|
||||
expect(cached.length).toBe(1);
|
||||
expect(cached[0].cache_control.ttl).toBe('1h');
|
||||
});
|
||||
|
||||
it('compresses long <system-reminder> blocks in the first user message', async () => {
|
||||
const reminder = '<system-reminder>\n' + 'a long policy note. '.repeat(200) + '\n</system-reminder>';
|
||||
const body = new TextEncoder().encode(
|
||||
JSON.stringify({
|
||||
model: 'claude',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'real user prompt' },
|
||||
{ type: 'text', text: reminder },
|
||||
],
|
||||
},
|
||||
],
|
||||
system: 'claude.md\n'.repeat(500),
|
||||
}),
|
||||
);
|
||||
const { body: outBytes, info } = await transformRequest(body);
|
||||
expect(info.reminderImgs).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const out = JSON.parse(new TextDecoder().decode(outBytes));
|
||||
const content = out.messages[0].content as any[];
|
||||
// Reminder text must NOT appear as a text block anymore.
|
||||
for (const b of content) {
|
||||
if (b.type === 'text') expect(b.text).not.toContain('<system-reminder>');
|
||||
}
|
||||
// But the user's actual prompt must still be there.
|
||||
const userTexts = content.filter((b: any) => b.type === 'text').map((b: any) => b.text);
|
||||
expect(userTexts.some((t: string) => t.includes('real user prompt'))).toBe(true);
|
||||
|
||||
// Reminder images carry NO cache_control (only the system+tools image
|
||||
// does — Anthropic caps at 4 breakpoints).
|
||||
const reminderImageBlocks = content.filter(
|
||||
(b: any) => b.type === 'image' && !b.cache_control,
|
||||
);
|
||||
expect(reminderImageBlocks.length).toBeGreaterThanOrEqual(info.reminderImgs ?? 0);
|
||||
});
|
||||
|
||||
it('leaves short <system-reminder> blocks alone (below minReminderChars)', async () => {
|
||||
const shortReminder = '<system-reminder>\nshort note\n</system-reminder>';
|
||||
const body = new TextEncoder().encode(
|
||||
JSON.stringify({
|
||||
model: 'claude',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: shortReminder }],
|
||||
},
|
||||
],
|
||||
system: 'claude.md\n'.repeat(500),
|
||||
}),
|
||||
);
|
||||
const { body: outBytes, info } = await transformRequest(body);
|
||||
expect(info.reminderImgs ?? 0).toBe(0);
|
||||
const out = JSON.parse(new TextDecoder().decode(outBytes));
|
||||
const allText = (out.messages[0].content as any[])
|
||||
.filter((b: any) => b.type === 'text')
|
||||
.map((b: any) => b.text)
|
||||
.join('\n');
|
||||
expect(allText).toContain('<system-reminder>');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user