mirror of
https://github.com/teamchong/pxpipe.git
synced 2026-07-22 02:02:51 +02:00
8af9829872
Fable 5 / Opus 4.8 accept up to 2576px / 4784 visual tokens per image, but a request with >20 images (pxpipe always sends many) is held to the stricter <=2000px/side rule, so the real ceiling is ~1932x1932 (1928x1928 = 69x69 = 4761 tokens). MAX_HEIGHT_PX 1568->1932; dense tool/history pages now render at DENSE_CONTENT_COLS=384 / DENSE_CONTENT_CHARS_PER_IMAGE=92160 (1928x1928 full page) -- fewer image blocks at the same OCR-validated 5x8 cell. The static slab is unchanged (313 cols / 1573x1280). Also fixes a regression the code review caught: the break-even gate, truncateForBudget, and estimateImageCount predicted against the slab geometry (313 cols / 159 rows) while the dense renderer emits 384 cols / 240 rows, so large tool_results were truncated far earlier than the 10-image cap required -- silently dropping output that would have rendered. A per-page char budget is threaded through the prediction chain (defaults to READABLE so the slab and unit tests are byte-identical) plus a denseGateGeometry helper, so the gate prices the same page the renderer actually produces.
336 lines
17 KiB
Markdown
336 lines
17 KiB
Markdown
# How pxpipe compresses Claude Code requests
|
||
|
||
This doc explains what the proxy actually does on the wire, why each piece is
|
||
shaped the way it is, and which invariants future contributors must not break.
|
||
The canonical source is `src/core/transform.ts`; everything here points back at
|
||
it.
|
||
|
||
## 1. Why this proxy exists
|
||
|
||
Claude Code sends a large, mostly-static prefix on every single turn: the
|
||
CLAUDE.md project rules, the agent / subagent definitions, the tool catalogue
|
||
with full input schemas, and a long list of internal "skill" reminders. On
|
||
Opus-class models that prefix runs ~68K input tokens. The model never *needs*
|
||
to re-read that text in token form — Anthropic prompt-caches it, and image
|
||
blocks OCR cleanly at small font sizes. So pxpipe pulls the static prefix
|
||
out of the JSON body, renders it as one or more grayscale PNG image blocks,
|
||
and pins a single `cache_control` breakpoint on the last image. Anthropic
|
||
charges roughly `ceil(W*H/750)` tokens per image; a full static-slab page is
|
||
1573×1280, so each slab tile costs ~2684 tokens regardless of how much text it
|
||
carries. A 68K-token static slab collapses to ~3.5K image tokens on the first
|
||
turn and to a cache-read (billed at 0.10×) on every subsequent turn. The
|
||
trade is real text tokens for a few image tokens we cache once.
|
||
|
||
## 2. The static / dynamic split
|
||
|
||
Claude Code does not send one monolithic system prompt. It stitches a handful
|
||
of *per-turn* dynamic blocks into the otherwise-stable text. The list lives in
|
||
`DYNAMIC_BLOCK_TAGS` (`src/core/transform.ts:154`):
|
||
|
||
```
|
||
env ← cwd, git status, platform, today's date
|
||
context ← directory listings, file pastes
|
||
git_status ← branch / staged / untracked files
|
||
directoryStructure ← ls-tree of the workspace
|
||
system-reminder ← skill catalogue + miscellaneous hints
|
||
```
|
||
|
||
Everything outside those tags — CLAUDE.md, agent definitions, the tool docs
|
||
Claude Code inlines — is *static*: byte-identical from turn to turn for the
|
||
lifetime of a session over the same project.
|
||
|
||
`splitStaticDynamic` (`src/core/transform.ts:162`) walks the system text with a
|
||
non-greedy regex (`<tag ...?>...</tag>`, earliest close wins) and partitions
|
||
it into two strings:
|
||
|
||
- `staticText` — what we render to PNG
|
||
- `dynamicText` — the concatenation of every dynamic block, kept as plain text
|
||
|
||
The reason for the split is the cache. `cache_control: ephemeral` only saves
|
||
tokens if the bytes it covers are identical across turns. If we baked the
|
||
`<env>` block (which contains today's date) into the image, the PNG bytes
|
||
would change every day at midnight UTC and the cache hit rate would silently
|
||
collapse. By splitting first and rendering only the static slab, the image
|
||
bytes stay frozen even as cwd, git branch, or the wall clock drift.
|
||
|
||
The dynamic tail is kept as text and forwarded to the model so it still sees
|
||
cwd / git status / today's date — just outside the cache anchor.
|
||
|
||
## 3. What the final request looks like
|
||
|
||
Default placement is `'user'` (`src/core/transform.ts:67`). The transform
|
||
rewrites the first user message to look like this:
|
||
|
||
```
|
||
messages[firstUserIdx].content = [
|
||
{ type: 'text', text: '<intro>' }, ← static, frames the OCR
|
||
{ type: 'image', source: {...} }, ← static, no cache_control
|
||
{ type: 'image', source: {...} }, ← static, no cache_control
|
||
{ type: 'image', source: {...}, cache_control: ← static, LAST image holds
|
||
{ type: 'ephemeral', ttl: '1h' } }, the one cache breakpoint
|
||
{ type: 'text', text: '[End of rendered context.]' },
|
||
...originalUserContent, ← per-turn (incl. compressed
|
||
] reminders + tool_results,
|
||
NO cache_control on any)
|
||
```
|
||
|
||
The `system` field carries:
|
||
|
||
- the `x-anthropic-billing-header:` line that Claude Code injects (stripped
|
||
off the static text by `stripBillingLine` because it rotates per-turn — see
|
||
`src/core/transform.ts:315`)
|
||
- the dynamic tail (`<env>...</env>`, `<git_status>...</git_status>`, etc.)
|
||
- any non-text blocks that lived in the original `system` field
|
||
|
||
Why not put the images in `system`? Anthropic's API rejects them outright with
|
||
`400 system.N.type: Input should be 'text'`. The `system` field accepts text
|
||
blocks only. Images have to ride on a user message.
|
||
|
||
A `placement: 'system'` mode exists (kept for symmetry with the legacy Python
|
||
proxy and for the `--placement system` flag) but it's strictly worse and you
|
||
should not use it. It's there because the original Python proxy supported
|
||
both modes and we wanted a clean port before deciding which one to keep.
|
||
|
||
## 4. The cache_control budget (the one invariant that matters)
|
||
|
||
Anthropic allows **four** `cache_control` breakpoints per request. Claude Code
|
||
already uses three of them on its own content:
|
||
|
||
1. One on the last `tools[]` entry
|
||
2. One on the `system` field
|
||
3. One inside `messages[]`, typically with `ttl='1h'`
|
||
|
||
That leaves us exactly **one** breakpoint. We spend it on the last image
|
||
block in the first user message.
|
||
|
||
Anthropic enforces two ordering rules that together pin our choice of `ttl`:
|
||
|
||
- Breakpoints are processed in the order `tools → system → messages`, and
|
||
within `messages` in array order.
|
||
- Within that processing order, **`ttl='1h'` must NOT appear after
|
||
`ttl='5m'`**. The API returns 400 if it does.
|
||
|
||
`cache_control` defaults to `ttl='5m'` if you leave the field off. Our
|
||
breakpoint lands inside `messages[firstUserIdx]`, *before* Claude Code's own
|
||
`ttl='1h'` breakpoint further down the message list. If we let our image
|
||
default to `ttl='5m'`, that 5m breakpoint would land before the 1h one and the
|
||
request would 400 at runtime. So `makeImageBlock` (`src/core/transform.ts:334`)
|
||
always stamps `ttl='1h'` on the cache anchor. This is verified by the
|
||
"uses ttl='1h' on the image cache_control" test in `tests/render.test.ts`.
|
||
|
||
This is the single most important invariant in the codebase. The string
|
||
`ttl: '1h'` is load-bearing. Do not "clean up" the comments above
|
||
`makeImageBlock`; they exist so the next person who tries to drop the field
|
||
or change it to `5m` reads the reason first.
|
||
|
||
## 5. Per-message compressions (no cache_control)
|
||
|
||
The static slab covers most of the wire savings, but two recurring sources of
|
||
text live in user messages and miss the cache anchor. The transform compresses
|
||
those too, but **without** attaching `cache_control` — we already spent our
|
||
one breakpoint, and these blocks are per-turn anyway.
|
||
|
||
### 5a. Reminder compression
|
||
|
||
Claude Code re-injects long `<system-reminder>...</system-reminder>` blocks
|
||
into the *first user message* every turn. The skill catalogue is the worst
|
||
offender (multiple KB of reusable-skill names and descriptions every single
|
||
turn). These reminders are text inside `messages[]`, not inside the system
|
||
prompt, so they do not hit the system+tools image cache.
|
||
|
||
`compressReminders` (default `true`, threshold `minReminderChars=1000`) walks
|
||
the first user message's content array, finds every text block that starts
|
||
with `<system-reminder>` and is at least 1000 chars, and replaces it with
|
||
image blocks. Short reminders are left alone — below the threshold the image
|
||
overhead would dominate. The replacement images carry **no `cache_control`**;
|
||
they're per-turn savings on raw token cost, not cache reuse.
|
||
|
||
Test coverage: "compresses long `<system-reminder>` blocks in the first user
|
||
message" and "leaves short `<system-reminder>` blocks alone (below
|
||
minReminderChars)" in `tests/render.test.ts`.
|
||
|
||
### 5b. Tool_result compression
|
||
|
||
Tool output (Bash stdout/stderr, file reads, Write confirmations) accumulates
|
||
across the session. Once a tool result is produced its bytes are static —
|
||
turn N+1 ships the same `tool_result` block as turn N, plus one more. Over a
|
||
long session this compounds into tens of KB of text re-sent every turn.
|
||
|
||
`compressToolResults` (default `true`, threshold `minToolResultChars=2000`)
|
||
walks **all** user messages (not just the first), finds every `tool_result`
|
||
block with content ≥ 2000 chars, and replaces the content with image blocks.
|
||
The block's `content` may be either a string or an array of TextBlock /
|
||
ImageBlock; the transform handles both shapes (see `src/core/transform.ts:573`
|
||
onwards).
|
||
|
||
One mandatory exception: **skip `is_error: true` tool_results**. Anthropic
|
||
rejects images nested inside an `is_error` tool_result. Test:
|
||
"leaves is_error tool_results untouched (Anthropic forbids images there)" in
|
||
`tests/render.test.ts`.
|
||
|
||
Like reminder images, tool_result images carry no `cache_control`.
|
||
|
||
## 6. Determinism and fingerprints
|
||
|
||
Identical input must produce byte-identical PNG output. Without that property,
|
||
two consecutive turns with the same static slab would render to different
|
||
image bytes and the cache hit rate would be 0%. This is a hard invariant:
|
||
|
||
- No `Math.random` on the render path.
|
||
- No timestamps in PNG metadata.
|
||
- No locale-dependent string handling (no `toLocaleString`, etc.).
|
||
- Glyph atlas is generated at *build* time (`scripts/gen-atlas.ts` →
|
||
`src/core/atlas.ts`) so runtime never touches the font file.
|
||
|
||
The locked-in test is "renders identical input to byte-identical output
|
||
(determinism = cacheability)" in `tests/render.test.ts:271`. If you change
|
||
anything on the render path, that test must continue to pass.
|
||
|
||
The transform also emits three SHA-256-prefixed (first 8 hex chars, 32 bits —
|
||
collision-safe for the request volume a single proxy instance sees)
|
||
fingerprints. They live on the `TransformInfo` struct and get persisted to the
|
||
JSONL event log so `pxpipe stats` can analyze them:
|
||
|
||
- **`systemSha8`** — hash of the exact text that goes into the image (static
|
||
slab + folded tool docs, joined with `\n\n`). If this value repeats across
|
||
turns, the cache_control breakpoint *should* be hitting upstream.
|
||
Mismatched `systemSha8` between turns is the signal that prompt drift is
|
||
killing your cache hit rate; check `pxpipe stats` for the
|
||
`system_sha8 reuse rate` line.
|
||
- **`claudeMdSha8`** — hash of just the CLAUDE.md section, if detectable by
|
||
the heuristic in `extractClaudeMdSlab` (`src/core/transform.ts:231`). Lets
|
||
you bucket requests by project even when `cwd` isn't reported in the env
|
||
block.
|
||
- **`firstUserSha8`** — hash of the first user message text, capped at 4 KiB
|
||
to keep long pastes from dominating. Rough thread/session id, since the
|
||
wire protocol doesn't include one.
|
||
|
||
None of these fingerprints carry raw text — they're privacy-safe to log.
|
||
|
||
## 7. The unknown-tag canary
|
||
|
||
`DYNAMIC_BLOCK_TAGS` is a hard-coded list. Claude Code is free to ship a new
|
||
per-turn dynamic tag at any time (a future `<recent_files>...`,
|
||
`<todo_list>...`, whatever). If that happens and we don't update the list,
|
||
the new tag's content will be silently baked into the cached image bytes.
|
||
Every turn, the dynamic content of that tag would differ, so the image bytes
|
||
would differ, and the cache hit rate would collapse — before anyone noticed.
|
||
|
||
The canary lives in `splitStaticDynamic` (`src/core/transform.ts:187`). After
|
||
splitting, it sweeps the *static* slab for any other tag-shaped opening
|
||
(`<foo>...</foo>` with `foo` under 64 chars, alphanumeric / dot / dash /
|
||
underscore) and emits the tag names on `info.unknownStaticTags`. Both the
|
||
Node host (`src/node.ts:367`) and the Worker (`src/worker.ts:74`) log a
|
||
warning to stderr / Workers Logs when this array is non-empty. `pxpipe
|
||
stats` also tallies these tag names across the JSONL log.
|
||
|
||
`<types>` is a *static* tag used inside Claude Code's built-in tool docs
|
||
(it was tripping the canary on 93% of real requests). Commit `167ce3d`
|
||
added a second list, `KNOWN_STATIC_TAGS`, alongside `DYNAMIC_BLOCK_TAGS`:
|
||
|
||
```typescript
|
||
const KNOWN_STATIC_TAGS = ['types'] as const;
|
||
```
|
||
|
||
The canary now excludes both lists. A tag is reported on
|
||
`info.unknownStaticTags` only when it appears in NEITHER set — i.e. it's
|
||
genuinely new. When you see a fresh entry, decide whether it's per-turn
|
||
(extend `DYNAMIC_BLOCK_TAGS`) or static-but-tag-shaped (extend
|
||
`KNOWN_STATIC_TAGS`). The warn-log line in `src/node.ts` names both
|
||
options.
|
||
|
||
## 8. The savings math
|
||
|
||
Source of truth for the formula is `src/dashboard.ts` (`effectiveCost`,
|
||
`baselineCost`). It was originally ported from a Python reference
|
||
implementation; that reference has been removed now that live
|
||
validation passed.
|
||
|
||
**Per-call effective input cost** — what the call actually billed for:
|
||
|
||
```
|
||
effective = input_tokens
|
||
+ cache_creation_input_tokens * 1.25
|
||
+ cache_read_input_tokens * 0.10
|
||
```
|
||
|
||
The `1.25` and `0.10` multipliers match Anthropic's published cache pricing
|
||
for Opus: writing to the cache costs 1.25× the per-token input rate; reading
|
||
from the cache costs 0.10×.
|
||
|
||
**Per-call baseline cost** — what the *same* call would have billed if we had
|
||
NOT compressed:
|
||
|
||
```
|
||
text_tokens_we_removed = origChars / 4 # ~4 chars per token, rough
|
||
image_tokens_we_added = imageCount * 4761 # dense 1928×1928 ≈ 4761 tokens (slab ~2684)
|
||
extra_text_baseline = max(0, text_tokens_we_removed - image_tokens_we_added)
|
||
|
||
# cache_create dominates the first turn; bias the baseline toward 1.25 in
|
||
# that regime. Otherwise assume a fully warm cache (0.10).
|
||
cache_total = cache_create + cache_read
|
||
baseline_rate = cache_create > 0
|
||
? (cache_create / cache_total) * 1.25 + (1 - cache_create / cache_total) * 0.10
|
||
: 0.10
|
||
|
||
baseline = effective + extra_text_baseline * baseline_rate
|
||
saved = baseline - effective
|
||
```
|
||
|
||
The dashboard's "tokens saved" and "$ saved" cards (and
|
||
`pxpipe stats` for the offline aggregate) both surface these numbers.
|
||
The $ figure in `src/dashboard.ts` uses a fixed per-Mtok input rate — note
|
||
Fable 5 (the current supported model) bills $10/M input, so re-check the
|
||
constant if you care about the dollar card.
|
||
|
||
**Important framing**: do not quote 65–73% as a benchmark. That number is
|
||
the architectural ceiling — the steady-state savings on a long session over
|
||
the same codebase where the image cache is warm and the cumulative
|
||
tool_result history is large. A short session with no warm cache may save
|
||
much less. The first turn always pays cache-creation cost; cache-read
|
||
amortization kicks in from turn 2 onwards. Cite the per-session number that
|
||
`pxpipe stats` reports, not the headline.
|
||
|
||
## 9. What deliberately did NOT get built
|
||
|
||
(From HANDOFF.md principle #7. These were considered during the original
|
||
session and rejected for stated reasons. The point of recording them is so
|
||
the next contributor doesn't relitigate the same decisions.)
|
||
|
||
- **Compression of user message content.** User text is volatile (different
|
||
every turn) so it would cache-miss anyway. Image overhead would dominate.
|
||
- **Per-conversation render caching.** `cache_control` already gives us this
|
||
upstream; adding a second layer in the proxy is duplicate work and
|
||
invalidation is harder than it sounds.
|
||
- **Smart heuristics for "should I compress this".** The current rules
|
||
(`minReminderChars`, `minToolResultChars`, fixed `DYNAMIC_BLOCK_TAGS`) are
|
||
simple, predictable, and correct. Heuristics that try to be cleverer
|
||
("only compress if X and Y and not Z") trade predictability for marginal
|
||
wins and make the failure modes harder to debug.
|
||
- **Streaming the request body to the renderer.** The transform is fast
|
||
enough that fully buffering the body is fine. Streaming would force a
|
||
rewrite of the splitter and the cache-control placement logic for no
|
||
measured latency win.
|
||
|
||
## 10. Wiring (one-paragraph map)
|
||
|
||
`src/core/transform.ts` is the transform itself — the runtime-agnostic
|
||
function that takes a request body and returns the rewritten body plus
|
||
`TransformInfo` telemetry. `src/core/proxy.ts` is the runtime-agnostic
|
||
request handler that calls the transform, forwards the rewritten request to
|
||
the Anthropic API, tees the response body to extract usage tokens, and fires
|
||
the `onRequest` callback. `src/node.ts` is the Node `http` server entrypoint
|
||
— it parses CLI flags / env vars, instantiates a `FileTracker` (JSONL log
|
||
writer with size-based rotation), and also serves the dashboard at `/` plus
|
||
the JSON / PNG endpoints (`/proxy-stats`, `/proxy-recent`,
|
||
`/proxy-latest-png`). `src/worker.ts` is the Cloudflare Worker entrypoint —
|
||
same proxy logic, `JsonLogTracker` writes to Workers Logs via `console.log`
|
||
(Logpush picks it up for R2/S3 export). `src/core/tracker.ts` defines the
|
||
`TrackEvent` shape that lands in JSONL and the `toTrackEvent` normalizer that
|
||
strips heavy fields (the `firstImagePng` byte buffer) before persistence.
|
||
`src/dashboard.ts` aggregates events in memory for the Node live view (capped
|
||
ring buffer, ~50 most recent calls). `src/stats.ts` is the offline aggregator
|
||
that powers `pxpipe stats`; it streams the JSONL file line-by-line so
|
||
100 MB logs don't blow the heap. Tests live in `tests/` and pin the
|
||
invariants — byte-output determinism most of all (see section 6).
|