mirror of
https://github.com/teamchong/pxpipe.git
synced 2026-07-22 02:02:51 +02:00
e433b26caa
Production data (2026-05-20): real 161,101-char system slab being rejected by the row-aware profitability gate as `not_profitable`. multiCol=2 packs 282 rows/image, but 2000+ newline-bounded lines need ~10 images, and at 2500 tok × 10 × 1.10 = 27,500 tok the gate (correctly) refuses since the text baseline is cheaper. Fix: strip per-line trailing whitespace and collapse 3+ consecutive newlines to exactly 2 *before* feeding the renderer. Both transforms are lossless for the model (whitespace at line-end carries no signal; blank- line runs longer than 2 are visual noise). This drops visual-row count without changing meaning, flipping borderline-rejected slabs profitable. Accounting uses raw (pre-compaction) length for `origChars` and `compressedChars`, so the savings credit reflects what Anthropic would have billed — not the post-compaction figure. Applied at four sites: - combined static slab (system + tool docs) - system-reminder block - tool_result string content - tool_result array-of-blocks (joined text) 8 new unit tests for `compactSlabWhitespace`: empty input, trailing whitespace stripping, 3+ newline collapse, paragraph-break preservation, indent preservation, idempotency, realistic markdown shrink. 270 tests pass, typecheck clean, build clean.
1807 lines
77 KiB
TypeScript
1807 lines
77 KiB
TypeScript
/**
|
||
* Request-body transformer. Takes an Anthropic Messages API request body,
|
||
* extracts the large static parts (system prompt + tool definitions),
|
||
* renders them as PNG image blocks, and rewrites the body to reference
|
||
* those images instead — saving 65-73% input tokens on Opus 4.7 while
|
||
* preserving 100% reasoning quality.
|
||
*
|
||
* Originally ported from a Python reference implementation; the Python
|
||
* has since been removed (live cache-rate validation passed at 98.7% by
|
||
* tokens). Byte-output determinism is now verified by tests alone.
|
||
*/
|
||
|
||
import type {
|
||
ContentBlock,
|
||
ImageBlock,
|
||
MessagesRequest,
|
||
SystemField,
|
||
TextBlock,
|
||
ToolDef,
|
||
ToolResultBlock,
|
||
ToolUseBlock,
|
||
} from './types.js';
|
||
import {
|
||
renderTextToPngs,
|
||
renderTextToPngsMultiCol,
|
||
maxFittingCols,
|
||
MAX_HEIGHT_PX,
|
||
PAD_Y,
|
||
} from './render.js';
|
||
import { bytesToBase64 } from './png.js';
|
||
import { ATLAS_CELL_H } from './atlas.js';
|
||
import { collapseHistory } from './history.js';
|
||
|
||
export interface TransformOptions {
|
||
/** Master switch — false makes this a no-op pass-through. */
|
||
compress?: boolean;
|
||
/** Move tool descriptions into the same image (and stub the originals). */
|
||
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;
|
||
/** Compress large tool_result text content across all user messages. Tool
|
||
* output is static once produced and accumulates across the conversation,
|
||
* so image-rendering it compounds savings as the session grows. */
|
||
compressToolResults?: boolean;
|
||
/** Don't compress if total compressible chars below this. */
|
||
minCompressChars?: number;
|
||
/** Per-block threshold for compressReminders (chars). */
|
||
minReminderChars?: number;
|
||
/** Per-block threshold for compressToolResults (chars). */
|
||
minToolResultChars?: number;
|
||
/** Soft-wrap column count. */
|
||
cols?: number;
|
||
/** Hard upper bound on images emitted per single tool_result. Above this,
|
||
* the source text is truncated (head + paging marker + tail) BEFORE
|
||
* rendering so the request stays under Anthropic's 100-image-per-request
|
||
* cap even when a single tool dumps a huge log. Default 10. */
|
||
maxImagesPerToolResult?: number;
|
||
/** R2 multi-column rendering: pack N text columns side-by-side per image
|
||
* so each image covers `N×LINES_PER_IMAGE` wrapped lines instead of one.
|
||
* Default 1 (single column = current behavior). 2 roughly halves image
|
||
* count on real Claude Code workloads at the cost of OCR ordering risk
|
||
* — the model must read column 1 fully top-to-bottom before column 2.
|
||
* Modern vision LLMs handle this well on newspaper layouts; keep this
|
||
* off until a smoke test against the real slab confirms ordering.
|
||
* Auto-clamped if the resulting canvas would exceed 1568 px wide. */
|
||
multiCol?: number;
|
||
/** Chars-per-token assumption used by `isCompressionProfitable()`. Default
|
||
* 4 (Anthropic's published English-text average). Host may override per
|
||
* request if it has a better number for the specific deployment. */
|
||
charsPerToken?: number;
|
||
}
|
||
|
||
const DEFAULTS: Required<TransformOptions> = {
|
||
compress: true,
|
||
compressTools: true,
|
||
compressSchemas: true,
|
||
compressReminders: true,
|
||
compressToolResults: true,
|
||
minCompressChars: 2000,
|
||
// Coarse pre-filter — blocks below this length skip the per-block
|
||
// break-even check entirely (saves CPU on the obviously-not-profitable
|
||
// cases). The REAL gate is `isCompressionProfitable()` below; this is
|
||
// just a fast-path skip. Set to 10,000 (= break-even point at the
|
||
// current cell config) so anything below it can't possibly net-save.
|
||
minReminderChars: 10000,
|
||
minToolResultChars: 10000,
|
||
// NOTE: Anthropic's `system` field accepts text blocks only — image blocks
|
||
// there come back as `400 system.N.type: Input should be 'text'`. Images
|
||
// are always attached to the first user message; there's no flag for this
|
||
// because the system-field path is API-rejected. (Removed `placement` +
|
||
// `compressSystem` knobs that gated the dead system-field branch.)
|
||
cols: 100,
|
||
// Cap at 10 images per tool_result. With ~14k chars/image at current cell,
|
||
// a single tool_result can grow to ~140k chars before paging kicks in. A
|
||
// `find` over a big tree or `grep -r` can easily exceed this; the paging
|
||
// marker tells the model what was elided. Tuneable per session.
|
||
maxImagesPerToolResult: 10,
|
||
// English ~4 chars/tok default (= the CHARS_PER_TOKEN constant declared
|
||
// later in this file — kept as a literal here to avoid forward-reference).
|
||
// Host overrides per-request when the dashboard's live fit has converged.
|
||
charsPerToken: 4,
|
||
// R2 multi-column ON (2 cols) — at single-col the break-even gate
|
||
// correctly rejects compression on real tool-doc-shaped slabs (~38 chars/
|
||
// row → ~29 imgs vs 39k text tokens → net loss). Two columns packs ~2×
|
||
// rows per image, dropping image count to ~15 and crossing break-even.
|
||
// Set to 1 via `--multi-col 1` if the OCR ordering ever turns out wrong.
|
||
multiCol: 2,
|
||
};
|
||
|
||
// --- per-block break-even check ---
|
||
//
|
||
// Anthropic's real per-image cost is ~2,500 tokens at SINGLE-COL
|
||
// (history-researcher's round-3 N=33 measurement on cold-miss events
|
||
// 2026-05-18, single-col 508×1559 PNGs). The published theoretical
|
||
// formula `(w × h) / 750` gives ~1,056 tokens for that geometry and
|
||
// underpredicts actual Anthropic billing by ~2.4×. We use the empirical
|
||
// 2,500 at numCols=1 and SCALE LINEARLY by numCols for wider canvases
|
||
// (multi-col packs N text columns side-by-side, multiplying pixel area
|
||
// and — per Anthropic's area-proportional billing model — token cost).
|
||
//
|
||
// Safety: the gate's job is to compress a block only when doing so saves
|
||
// tokens. The constants below bias CONSERVATIVE — every uncertainty
|
||
// resolves in favor of pass-through, so a misprediction at worst leaves
|
||
// money on the table; it never burns money on a net-loss image:
|
||
// • CHARS_PER_TOKEN = 4 over-estimates tokens-per-char for typical
|
||
// tool_result code/JSON (real cpt ≈ 3-3.5), which UNDER-estimates
|
||
// text savings → bias toward pass-through.
|
||
// • numCols=1 cost is empirical (2500). numCols≥2 cost is linearly
|
||
// extrapolated + 10% margin since we don't yet have empirical
|
||
// measurements at wider canvases. Over-stating image cost ALSO
|
||
// biases toward pass-through.
|
||
//
|
||
// Production bug context (2026-05-19): a request with orig_chars=169k spread
|
||
// across 88 small blocks each cost ~2,500 tokens as images = 220k tokens
|
||
// when the text would have been only 42k tokens. The flat per-block-min
|
||
// threshold (5k) was wide of the break-even point (10k) and let net-loss
|
||
// compressions through.
|
||
//
|
||
// Multi-col safety hole (closed 2026-05-19): production runs multiCol=2
|
||
// by default. The OLD flat `TOKENS_PER_IMAGE = 2500` applied at all
|
||
// numCols, so the gate believed multi-col images were ~2× cheaper than
|
||
// they actually are and would compress slabs that net-lost in reality.
|
||
// The scaled cost below fixes that — at multiCol≥2, image cost reflects
|
||
// the wider canvas.
|
||
|
||
/** English ~4 chars per token average. Holds well enough for code + prose
|
||
* mix; tool_result content is typically code-shaped. */
|
||
const CHARS_PER_TOKEN = 4;
|
||
|
||
/** Empirical per-image cost at numCols=1. Source: dashboard.ts measurement
|
||
* trace. Kept here as a constant rather than imported from dashboard.ts
|
||
* to keep `src/core/` free of dashboard imports — that's a one-way edge. */
|
||
const TOKENS_PER_IMAGE_SINGLE_COL = 2500;
|
||
|
||
/** Effective per-image token cost at the given `numCols`. Single-col is
|
||
* the calibrated measurement; multi-col scales linearly with the number
|
||
* of text columns packed per image (Anthropic bills proportional to
|
||
* pixel area, which doubles/triples with numCols). The 10% multi-col
|
||
* margin absorbs extrapolation noise since we don't yet have empirical
|
||
* cost measurements at numCols≥2 — biases toward pass-through, never
|
||
* toward letting a net-loss through.
|
||
*
|
||
* Why bias conservative: the gate's only job is "compress if and only if
|
||
* doing so saves tokens." If the constant is too low, we compress
|
||
* net-losers and overpay. If it's too high, we miss profitable
|
||
* compressions but never overpay. The user's constraint is "don't lose
|
||
* money" — accept missed opportunities, reject misses-that-overpay. */
|
||
function effectiveTokensPerImage(numCols: number): number {
|
||
const n = Math.max(1, numCols | 0);
|
||
if (n === 1) return TOKENS_PER_IMAGE_SINGLE_COL;
|
||
return Math.ceil(TOKENS_PER_IMAGE_SINGLE_COL * n * 1.10);
|
||
}
|
||
|
||
/** Characters per rendered image at the current renderer config. Derived
|
||
* at runtime from `ATLAS_CELL_H` (cell height) and the render canvas
|
||
* dimensions imported from render.ts — single source of truth.
|
||
*
|
||
* Formula: `cols × floor((MAX_HEIGHT_PX − 2·PAD_Y) / ATLAS_CELL_H)`
|
||
*
|
||
* At the shipping config (Unifont, cell 5×11, cols=100):
|
||
* 100 × floor((1568 − 8) / 11) = 100 × 141 = 14,100
|
||
*
|
||
* When the atlas swaps (e.g. Cozette 4×7, cell H=7), this auto-updates:
|
||
* 100 × floor(1560 / 7) = 100 × 222 = 22,200
|
||
* …and the break-even threshold drops accordingly. Without this, the
|
||
* hardcoded 14,100 would silently let net-loss compressions through on
|
||
* every smaller-cell atlas. */
|
||
/** Visual rows per image at the current atlas cell. Derived once at module
|
||
* load. Auto-updates when gen-atlas regenerates with a different font/size. */
|
||
export const LINES_PER_IMAGE = Math.max(1, Math.floor((MAX_HEIGHT_PX - 2 * PAD_Y) / ATLAS_CELL_H));
|
||
|
||
export function maxCharsPerImage(cols: number): number {
|
||
return cols * LINES_PER_IMAGE;
|
||
}
|
||
|
||
/** Lossless pre-render slab compactor. Reduces the visual-row count the
|
||
* renderer sees (each `\n` is at least one row regardless of column width),
|
||
* without changing what the model reads:
|
||
*
|
||
* 1. Strip trailing whitespace per line (preserves leading indent — code
|
||
* and JSON structure stay intact).
|
||
* 2. Collapse runs of 3+ newlines down to 2 (one blank line max between
|
||
* paragraphs). Multi-blank "section dividers" cost rows but carry no
|
||
* information once rendered to a flat image.
|
||
*
|
||
* Real Claude Code system slabs hit production data 2026-05-20 had ~2,000
|
||
* newline-bounded rows in 161 KB — the row-aware gate correctly rejected
|
||
* them as unprofitable. This compactor typically drops 10-25% of rows on
|
||
* markdown-heavy / tool-doc-heavy slabs, which is enough to flip a
|
||
* meaningful fraction of currently-rejected slabs to profitable.
|
||
*
|
||
* Exported so the per-block reminder/tool_result compressions can share
|
||
* the same pre-processor — same row-cost dynamics. */
|
||
export function compactSlabWhitespace(text: string): string {
|
||
if (!text) return text;
|
||
// Per-line trailing whitespace strip. Iterate the buffer once instead
|
||
// of split/join — avoids materialising an intermediate array on a
|
||
// ~160 KB slab. We only touch spaces/tabs (codepoint 32 and 9) so
|
||
// newlines are passed through verbatim and the line count is unchanged
|
||
// at this step.
|
||
let trimmed = '';
|
||
let lineStart = 0;
|
||
for (let i = 0; i <= text.length; i++) {
|
||
if (i === text.length || text.charCodeAt(i) === 10 /* \n */) {
|
||
let end = i;
|
||
while (end > lineStart) {
|
||
const c = text.charCodeAt(end - 1);
|
||
if (c !== 32 && c !== 9) break;
|
||
end--;
|
||
}
|
||
trimmed += text.slice(lineStart, end);
|
||
if (i < text.length) trimmed += '\n';
|
||
lineStart = i + 1;
|
||
}
|
||
}
|
||
// Collapse 3+ consecutive newlines to exactly 2. Preserves paragraph
|
||
// breaks while killing multi-blank section dividers — they cost one
|
||
// row each in the renderer and carry zero information once flattened
|
||
// to an image.
|
||
return trimmed.replace(/\n{3,}/g, '\n\n');
|
||
}
|
||
|
||
/** Returns true iff image-compressing a text block would actually save tokens
|
||
* vs leaving it as text. Used as the gate before every image-encoding
|
||
* decision in transformRequest.
|
||
*
|
||
* Pass the **actual text string** when possible — the function will
|
||
* soft-wrap-count visual rows to match what `renderTextToPngs` will
|
||
* actually produce. Newline-heavy content (low fill ratio) renders to
|
||
* *more* images than the naive `chars / charsPerImage` estimate, and
|
||
* using the looser estimate lets net-losing compressions through.
|
||
*
|
||
* Passing a `number` falls back to the looser chars-only estimate for
|
||
* back-compat with existing unit tests; production transform call sites
|
||
* should always pass the string.
|
||
*
|
||
* `cols` defaults to `DEFAULTS.cols` (100) so existing callers and unit
|
||
* tests that pass only `textLen` keep working byte-identically at the
|
||
* current atlas. New call sites should pass `o.cols` so a runtime
|
||
* `--cols` override flows into the break-even math too. */
|
||
export function isCompressionProfitable(
|
||
textOrLen: string | number,
|
||
cols: number = DEFAULTS.cols,
|
||
imageCountCap?: number,
|
||
numCols: number = 1,
|
||
/** Chars-per-token assumption for the text side of the break-even math.
|
||
* Default 4 (Anthropic's English-text average). Lower values = more
|
||
* profitable text compressions (each char buys more tokens back). */
|
||
charsPerToken: number = CHARS_PER_TOKEN,
|
||
): boolean {
|
||
const n = Math.max(1, numCols | 0);
|
||
let estImages: number;
|
||
let textLen: number;
|
||
if (typeof textOrLen === 'string') {
|
||
// Row-aware: matches renderTextToPngs() image budgeting exactly.
|
||
estImages = estimateImageCount(textOrLen, cols, n);
|
||
textLen = textOrLen.length;
|
||
} else {
|
||
// Looser chars-only estimate. Assumes lines fill width — wrong for
|
||
// newline-heavy code/logs but kept for back-compat.
|
||
const charsPerImage = maxCharsPerImage(cols) * n;
|
||
estImages = Math.max(1, Math.ceil(textOrLen / charsPerImage));
|
||
textLen = textOrLen;
|
||
}
|
||
// For code paths that truncate before rendering (tool_results), the
|
||
// actual image cost is bounded by the cap — text savings are still
|
||
// measured against the full pre-truncation length.
|
||
if (imageCountCap !== undefined && imageCountCap > 0) {
|
||
estImages = Math.min(estImages, imageCountCap);
|
||
}
|
||
// Defensive clamp: a corrupt or pathological charsPerToken (≤0 / NaN)
|
||
// would either crash or give a misleading-true. Fall back to the
|
||
// baked-in default in that case.
|
||
const cpt = Number.isFinite(charsPerToken) && charsPerToken > 0
|
||
? charsPerToken
|
||
: CHARS_PER_TOKEN;
|
||
const imageTokensCost = estImages * effectiveTokensPerImage(n);
|
||
const textTokensEquivalent = textLen / cpt;
|
||
return imageTokensCost < textTokensEquivalent;
|
||
}
|
||
|
||
/** Increment a passthrough-reason counter on `info`. Lazily allocates the
|
||
* `passthroughReasons` sub-object so happy-path events stay lean. */
|
||
function bumpPassthrough(
|
||
info: TransformInfo,
|
||
reason: 'below_threshold' | 'not_profitable',
|
||
): void {
|
||
if (!info.passthroughReasons) info.passthroughReasons = {};
|
||
info.passthroughReasons[reason] = (info.passthroughReasons[reason] ?? 0) + 1;
|
||
}
|
||
|
||
/** Parsed contents of Claude Code's <env> + git status blocks. All optional —
|
||
* fields are only populated if the corresponding line is present. */
|
||
export interface EnvFields {
|
||
/** Working directory at the time `claude` was launched. */
|
||
cwd?: string;
|
||
isGitRepo?: boolean;
|
||
/** Current git branch, parsed from <git_status> or a "Branch:" line. */
|
||
gitBranch?: string;
|
||
platform?: string;
|
||
osVersion?: string;
|
||
/** "Today's date" as Claude Code reported it (YYYY-MM-DD). */
|
||
today?: string;
|
||
}
|
||
|
||
export interface TransformInfo {
|
||
compressed: boolean;
|
||
reason?: string;
|
||
origChars: number;
|
||
/** Total chars of source text that were image-encoded across ALL blocks
|
||
* this request (static slab + reminders + tool_results). Pairs with
|
||
* `imageCount` for honest savings math:
|
||
* textTokens = compressedChars / 4
|
||
* imageTokens = imageCount × 2500
|
||
* savings = textTokens − imageTokens
|
||
* Unlike `origChars` (which is just static slab + tool docs),
|
||
* `compressedChars` reflects what `imageCount` actually replaced. */
|
||
compressedChars: number;
|
||
imageCount: number;
|
||
imageBytes: number;
|
||
/** Total pixel area summed across all rendered images this request
|
||
* (`Σ width × height`). Pairs with `cache_create_tokens` on cold-miss
|
||
* events to derive empirical pixels-per-token under the current model —
|
||
* the dashboard's `OPUS_IMAGE_TOKEN_COST` and the gate's `TOKENS_PER_IMAGE`
|
||
* are both stale empirical constants from a different model; this gives
|
||
* us the raw data to re-ground them via regression instead of guessing. */
|
||
imagePixels?: number;
|
||
/** Total chars of TEXT remaining in the outgoing transformed body — every
|
||
* TextBlock across `system`, `messages[].content`, and any tool_result
|
||
* text that didn't get image-compressed. Pairs with `imagePixels` and
|
||
* the upstream token count so we can solve for chars-per-token (α) and
|
||
* pixels-per-token (β) empirically: `total_tokens ≈ α·outgoingTextChars +
|
||
* β·imagePixels`. On a cold-miss event the upstream `cache_create_tokens`
|
||
* is the full LHS, so a regression over N cold-misses pins both. */
|
||
outgoingTextChars?: number;
|
||
/** Length of the static (cacheable) slab rendered into the image. */
|
||
staticChars: number;
|
||
/** Length of the dynamic (per-turn) slab kept as plain text. */
|
||
dynamicChars: number;
|
||
/** Number of dynamic blocks detected (<env>, <context>, etc.). */
|
||
dynamicBlockCount: number;
|
||
/** Tag-shaped blocks found in the *static* slab that are NOT in
|
||
* DYNAMIC_BLOCK_TAGS. Early-warning canary: if Claude Code ships a new
|
||
* per-turn tag, it'll show up here before our cache hit rate collapses. */
|
||
unknownStaticTags?: string[];
|
||
/** Parsed env block, if Claude Code injected one. Useful for telemetry
|
||
* (per-project compression ratios, etc.). */
|
||
env?: EnvFields;
|
||
/** sha256[0..8] of the static slab + tool docs (what ends up in the image).
|
||
* Repeats across turns → cache_control SHOULD be hitting upstream. */
|
||
systemSha8?: string;
|
||
/** sha256[0..8] of just the CLAUDE.md section if detectable. Lets us
|
||
* bucket requests by project even when cwd is absent. */
|
||
claudeMdSha8?: string;
|
||
/** sha256[0..8] of the first user message text (first 4 KiB). Rough
|
||
* thread/session id since the wire protocol carries none. */
|
||
firstUserSha8?: string;
|
||
/** Raw bytes of the FIRST rendered image. Used by the in-process dashboard
|
||
* to show a preview. NOT persisted to JSONL (toTrackEvent drops it). */
|
||
firstImagePng?: Uint8Array;
|
||
/** 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;
|
||
/** Number of images we added by compressing tool_result content across
|
||
* user messages. */
|
||
toolResultImgs?: number;
|
||
/** Codepoints in the rendered text that weren't in the atlas. They
|
||
* rendered as blank cells. A non-zero count means the user is producing
|
||
* glyphs we don't ship — useful telemetry for tuning the atlas profile
|
||
* (e.g. switch from `practical` → `full-bmp` if Hangul shows up). */
|
||
droppedChars?: number;
|
||
/** Top dropped codepoints by frequency for this request, keyed `U+HHHH`
|
||
* (uppercase hex, at least 4 digits). At most 20 entries, sorted by count
|
||
* descending. Only set when `droppedChars > 0`. Lets the operator
|
||
* identify which Unicode blocks to add to the atlas profile without
|
||
* having to capture & inspect the request body. */
|
||
droppedCodepointsTop?: Record<string, number>;
|
||
/** Counters for why blocks didn't get image-compressed this request.
|
||
* Helps tune the break-even check vs the flat threshold:
|
||
* - `below_threshold`: block below `minReminderChars` / `minToolResultChars`
|
||
* (the fast-path skip; saves CPU on obvious-no cases)
|
||
* - `not_profitable`: block above the threshold but `isCompressionProfitable`
|
||
* returned false (image cost ≥ text cost at current cell config)
|
||
* Only emitted when at least one counter is > 0. */
|
||
passthroughReasons?: { below_threshold?: number; not_profitable?: number };
|
||
/** Number of tool_result blocks where the source text exceeded the
|
||
* per-tool_result image budget and was truncated before rendering. */
|
||
truncatedToolResults?: number;
|
||
/** Total chars elided by paging across all tool_results this request. */
|
||
omittedChars?: number;
|
||
/** Variant C history-image: how many original `messages[]` entries got
|
||
* collapsed into the prepended synthetic user message. 0 / unset when
|
||
* no collapse happened (no closed prefix, too few turns, gate rejected
|
||
* as not_profitable, etc. — see `historyReason`). */
|
||
collapsedTurns?: number;
|
||
/** Variant C: total chars of text serialized into the history image(s)
|
||
* before render (pre-OCR loss). */
|
||
collapsedChars?: number;
|
||
/** Variant C: number of PNG image blocks emitted for the history. Folded
|
||
* into `info.imageCount` too — surfaced separately so dashboards can
|
||
* attribute image-count growth to history vs system-slab vs reminders. */
|
||
collapsedImages?: number;
|
||
/** Variant C: why the history collapse didn't run (or did). Diagnostic
|
||
* only — see `HistoryCollapseInfo.reason` for the value set. */
|
||
historyReason?:
|
||
| 'no_history'
|
||
| 'prefix_too_short'
|
||
| 'no_closed_prefix'
|
||
| 'not_profitable'
|
||
| 'render_empty'
|
||
| 'collapsed';
|
||
/** Ground-truth baseline token count for THIS request, from a parallel
|
||
* call to /v1/messages/count_tokens on the PRE-COMPRESSION body. The
|
||
* endpoint is free (no input-token billing). Absent when the probe
|
||
* failed (network, 4xx) — that event is then excluded from the
|
||
* savings rollup. */
|
||
baselineTokens?: number;
|
||
/** Second baseline probe: input_tokens of the original body TRUNCATED at
|
||
* the last `cache_control` marker — the prefix that would have cached
|
||
* on the unproxied path. Used by the dashboard to weight the baseline by
|
||
* the SAME cache class the proxied request landed in (cache_create ×1.25,
|
||
* cache_read ×0.10, no-cache ×1.0), giving an exact cache-aware
|
||
* counterfactual instead of cold-every-time. Absent when the original
|
||
* body has no cache_control markers anywhere (in which case the unproxied
|
||
* path doesn't cache and cacheable_prefix_tokens = 0). */
|
||
baselineCacheableTokens?: number;
|
||
}
|
||
|
||
// --- helpers ---------------------------------------------------------------
|
||
|
||
/** Extract `(text, remainder)` from a system field that may be string or list. */
|
||
function extractSystemText(sys: SystemField | undefined): { text: string; kept: SystemField } {
|
||
if (sys == null) return { text: '', kept: [] };
|
||
if (typeof sys === 'string') return { text: sys, kept: '' };
|
||
const textParts: string[] = [];
|
||
const kept: SystemField = [];
|
||
for (const block of sys) {
|
||
if (block && typeof block === 'object' && block.type === 'text') {
|
||
textParts.push(block.text);
|
||
} else {
|
||
kept.push(block);
|
||
}
|
||
}
|
||
return { text: textParts.join('\n\n'), kept };
|
||
}
|
||
|
||
/**
|
||
* Claude Code injects a handful of per-turn dynamic blocks into the system
|
||
* prompt (e.g. <env>, <context>, <git_status>, <directoryStructure>,
|
||
* <system-reminder>). Including these in the rendered image kills the
|
||
* Anthropic prompt cache because the bytes drift turn-to-turn. Splitting
|
||
* them out lets us render the static slab (CLAUDE.md, agent defs, tool docs)
|
||
* with cache_control while forwarding the dynamic slab as cheap text so the
|
||
* model still sees cwd / git status / today's date.
|
||
*/
|
||
const DYNAMIC_BLOCK_TAGS = [
|
||
'env',
|
||
'context',
|
||
'git_status',
|
||
'directoryStructure',
|
||
'system-reminder',
|
||
] as const;
|
||
|
||
/**
|
||
* Tag-shaped blocks that DO appear in the static slab and SHOULD be baked into
|
||
* the cached image. These are part of Claude Code's built-in system prompt /
|
||
* tool documentation, not per-turn injections, so they're stable across turns.
|
||
*
|
||
* The canary in splitStaticDynamic flags any tag-shaped block in the static
|
||
* slab that isn't in DYNAMIC_BLOCK_TAGS — designed to catch a new Claude Code
|
||
* release that ships a per-turn tag we'd accidentally cache. Without this
|
||
* allowlist, known-static tags like <types> trigger the canary on most turns
|
||
* and drown out the real signal. Add a tag here only after confirming it's
|
||
* static (appears in the cacheable part of the prompt, not rotated per turn).
|
||
*/
|
||
const KNOWN_STATIC_TAGS = ['types'] as const;
|
||
|
||
function splitStaticDynamic(text: string): {
|
||
staticText: string;
|
||
dynamicText: string;
|
||
blockCount: number;
|
||
unknownTags: string[];
|
||
} {
|
||
if (!text)
|
||
return { staticText: '', dynamicText: '', blockCount: 0, unknownTags: [] };
|
||
// Match <tag ...?>...</tag> where tag ∈ DYNAMIC_BLOCK_TAGS. Closing tag
|
||
// must match opening tag exactly. Non-greedy body — earliest close wins.
|
||
const pattern = new RegExp(
|
||
`<(${DYNAMIC_BLOCK_TAGS.join('|')})(\\s[^>]*)?>[\\s\\S]*?</\\1>`,
|
||
'g',
|
||
);
|
||
const dynamicParts: string[] = [];
|
||
let staticBuf = '';
|
||
let cursor = 0;
|
||
let m: RegExpExecArray | null;
|
||
while ((m = pattern.exec(text)) !== null) {
|
||
staticBuf += text.slice(cursor, m.index);
|
||
dynamicParts.push(m[0]);
|
||
cursor = m.index + m[0].length;
|
||
}
|
||
staticBuf += text.slice(cursor);
|
||
|
||
// Sniff for OTHER tag-shaped blocks in the static slab. If Claude Code
|
||
// ships a new per-turn tag (say <recent_files>...</recent_files>) we'd
|
||
// silently bake it into the cached image and our cache hit rate would
|
||
// collapse. Surfacing the tag name as telemetry lets us detect that
|
||
// within hours of a Claude Code release.
|
||
const known = new Set<string>(DYNAMIC_BLOCK_TAGS);
|
||
const knownStatic = new Set<string>(KNOWN_STATIC_TAGS);
|
||
const sniffer = /<([a-zA-Z][a-zA-Z0-9_-]*)(?:\s[^>]*)?>[\s\S]*?<\/\1>/g;
|
||
const unknown = new Set<string>();
|
||
let s: RegExpExecArray | null;
|
||
while ((s = sniffer.exec(staticBuf)) !== null) {
|
||
const tag = s[1]!;
|
||
if (!known.has(tag) && !knownStatic.has(tag) && tag.length <= 64)
|
||
unknown.add(tag);
|
||
}
|
||
|
||
return {
|
||
// Collapse the run of blank lines left behind by removed blocks.
|
||
staticText: staticBuf.replace(/\n{3,}/g, '\n\n').trim(),
|
||
dynamicText: dynamicParts.join('\n\n'),
|
||
blockCount: dynamicParts.length,
|
||
unknownTags: [...unknown],
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Compute sha256 and return the first 8 hex chars. Web Crypto so it works
|
||
* the same in Node 18+ and Workers. 8 chars = 32 bits = collision-safe for
|
||
* the request volume we'd see in a single proxy instance.
|
||
*/
|
||
export async function sha8(text: string): Promise<string> {
|
||
const buf = new TextEncoder().encode(text);
|
||
const digest = await crypto.subtle.digest('SHA-256', buf);
|
||
const bytes = new Uint8Array(digest);
|
||
let hex = '';
|
||
for (let i = 0; i < 4; i++) hex += bytes[i]!.toString(16).padStart(2, '0');
|
||
return hex;
|
||
}
|
||
|
||
/**
|
||
* Best-effort: pull out the CLAUDE.md slab from a system text. Heuristic —
|
||
* Claude Code typically wraps it with a heading like "Claude Code Rules"
|
||
* or includes it under a `# CLAUDE.md` / system-reminder block. Returns
|
||
* empty string if nothing CLAUDE.md-shaped is detected; callers should
|
||
* skip hashing in that case.
|
||
*/
|
||
export function extractClaudeMdSlab(staticText: string): string {
|
||
if (!staticText) return '';
|
||
// Common markers Claude Code uses around the CLAUDE.md content.
|
||
const startPatterns = [
|
||
/^\s*#+\s*Claude\s+Code\s+Rules\s*$/im,
|
||
/^\s*#+\s*CLAUDE\.md\s*$/im,
|
||
/^\s*Claude\s+Code\s+Rules:?\s*$/im,
|
||
];
|
||
let startIdx = -1;
|
||
for (const p of startPatterns) {
|
||
const m = p.exec(staticText);
|
||
if (m && (startIdx === -1 || m.index < startIdx)) startIdx = m.index;
|
||
}
|
||
if (startIdx === -1) return '';
|
||
// Run until the next top-level heading (# foo) or end of text.
|
||
const tail = staticText.slice(startIdx);
|
||
const endMatch = /\n#\s+\S/.exec(tail.slice(1));
|
||
const end = endMatch ? endMatch.index + 1 : tail.length;
|
||
return tail.slice(0, end).trim();
|
||
}
|
||
|
||
/**
|
||
* Hash the first user message text, capped at 4 KiB so very long initial
|
||
* pastes don't dominate hashing time and so we still get a stable id for
|
||
* the conversation thread (initial prompt usually fits well within 4 KiB).
|
||
*/
|
||
export function firstUserText(req: MessagesRequest): string {
|
||
const msgs = req.messages ?? [];
|
||
for (const m of msgs) {
|
||
if (m.role !== 'user') continue;
|
||
if (typeof m.content === 'string') return m.content.slice(0, 4096);
|
||
if (Array.isArray(m.content)) {
|
||
for (const block of m.content) {
|
||
if (block && (block as any).type === 'text' && typeof (block as any).text === 'string') {
|
||
return ((block as any).text as string).slice(0, 4096);
|
||
}
|
||
}
|
||
}
|
||
// First user message found but unreadable → return empty so we don't
|
||
// accidentally hash some downstream user message.
|
||
return '';
|
||
}
|
||
return '';
|
||
}
|
||
|
||
/**
|
||
* Pull structured fields out of the dynamic slab. Only reads — does not
|
||
* modify the text. Used purely for telemetry / improvement signals.
|
||
*/
|
||
export function extractEnvFields(dynamicText: string): EnvFields {
|
||
const out: EnvFields = {};
|
||
if (!dynamicText) return out;
|
||
|
||
const envMatch = /<env>([\s\S]*?)<\/env>/i.exec(dynamicText);
|
||
if (envMatch) {
|
||
const body = envMatch[1]!;
|
||
const cwd = /(?:^|\n)\s*Working directory:\s*(.+?)\s*(?:\n|$)/i.exec(body);
|
||
if (cwd) out.cwd = cwd[1]!.trim();
|
||
const gitRepo = /(?:^|\n)\s*Is directory a git repo:\s*(Yes|No)\b/i.exec(body);
|
||
if (gitRepo) out.isGitRepo = gitRepo[1]!.toLowerCase() === 'yes';
|
||
const platform = /(?:^|\n)\s*Platform:\s*(.+?)\s*(?:\n|$)/i.exec(body);
|
||
if (platform) out.platform = platform[1]!.trim();
|
||
const osVer = /(?:^|\n)\s*OS Version:\s*(.+?)\s*(?:\n|$)/i.exec(body);
|
||
if (osVer) out.osVersion = osVer[1]!.trim();
|
||
const today = /(?:^|\n)\s*Today'?s date:\s*(.+?)\s*(?:\n|$)/i.exec(body);
|
||
if (today) out.today = today[1]!.trim();
|
||
}
|
||
|
||
// Git branch may live in <git_status>, <context name="git">, or just a
|
||
// "Branch: foo" / "On branch foo" line somewhere in the dynamic slab.
|
||
const branch =
|
||
/(?:^|\n)\s*(?:On branch|Branch:)\s*([^\s\n]+)/i.exec(dynamicText) ??
|
||
/(?:^|\n)\s*Current branch:\s*([^\s\n]+)/i.exec(dynamicText);
|
||
if (branch) out.gitBranch = branch[1]!.trim();
|
||
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* Strip the per-turn random billing header line that Claude Code injects.
|
||
* It changes every turn and would defeat prompt-cache hits if we left it
|
||
* inside the image. We keep it as a leading text block so the upstream
|
||
* still receives it.
|
||
*/
|
||
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 };
|
||
}
|
||
|
||
/** Maximum recursion depth when stripping descriptions out of an input_schema.
|
||
* Real tool schemas can be deeper than naive 3-level shapes — think filter
|
||
* DSLs, query objects, structured-output schemas. 20 is generous enough to
|
||
* handle anything realistic; deeper than that and we leave the node untouched
|
||
* rather than corrupt it. */
|
||
const SCHEMA_STRIP_MAX_DEPTH = 20;
|
||
|
||
/** Long-form description / metadata keys that contribute tokens but no
|
||
* validation. The image already carries this content for the model to read,
|
||
* so we strip them from the wire payload to recover the tokens. */
|
||
const SCHEMA_STRIP_KEYS = new Set([
|
||
'description',
|
||
'title',
|
||
'examples',
|
||
'default',
|
||
'$schema',
|
||
'$id',
|
||
'$comment',
|
||
]);
|
||
|
||
/** JSON Schema composition keys whose values are *arrays of subschemas*. We
|
||
* recurse into each element so descriptions inside variant branches still get
|
||
* stripped while the variant structure is preserved. */
|
||
const SCHEMA_COMPOSITION_KEYS = new Set(['oneOf', 'anyOf', 'allOf']);
|
||
|
||
/** JSON Schema keys whose values are *objects keyed by name* (each value is
|
||
* itself a subschema). Both `properties` and `patternProperties` use this
|
||
* shape; `definitions` / `$defs` are pre-2020 and 2020-12 spellings of the
|
||
* same idea and we strip descriptions inside them too. */
|
||
const SCHEMA_NAMED_SUBSCHEMA_KEYS = new Set([
|
||
'properties',
|
||
'patternProperties',
|
||
'definitions',
|
||
'$defs',
|
||
]);
|
||
|
||
/** Keys whose values are a *single subschema* — recurse but don't unwrap. */
|
||
const SCHEMA_SINGLE_SUBSCHEMA_KEYS = new Set([
|
||
'items',
|
||
'additionalProperties',
|
||
'not',
|
||
'contains',
|
||
'propertyNames',
|
||
'unevaluatedItems',
|
||
'unevaluatedProperties',
|
||
'if',
|
||
'then',
|
||
'else',
|
||
]);
|
||
|
||
/** Keys that are *arrays of primitives* (or otherwise opaque) — preserve
|
||
* verbatim, don't recurse. */
|
||
const SCHEMA_VERBATIM_KEYS = new Set([
|
||
'required',
|
||
'enum',
|
||
'const',
|
||
'type', // string or array of strings
|
||
'$ref', // we don't resolve refs but we mustn't drop them
|
||
'minimum',
|
||
'maximum',
|
||
'exclusiveMinimum',
|
||
'exclusiveMaximum',
|
||
'minLength',
|
||
'maxLength',
|
||
'minItems',
|
||
'maxItems',
|
||
'minProperties',
|
||
'maxProperties',
|
||
'multipleOf',
|
||
'uniqueItems',
|
||
'pattern',
|
||
]);
|
||
|
||
/** `format` values from JSON Schema's vocabulary are short tokens
|
||
* (`date-time`, `uri`, `email`, `ipv4`, …). If something larger than this
|
||
* shows up it's almost certainly a human-readable hint that belongs in the
|
||
* image, not the wire payload. */
|
||
const FORMAT_MAX_LEN = 32;
|
||
|
||
/** Strip long-form metadata from a JSON-Schema-shaped node while preserving
|
||
* the structural keys Anthropic's tool-use validator needs to type-check the
|
||
* model's calls.
|
||
*
|
||
* PRESERVED (verbatim or recursed):
|
||
* - `type`, `enum`, `const`, `$ref`
|
||
* - `properties` / `patternProperties` / `definitions` / `$defs` (recurse
|
||
* into each named subschema)
|
||
* - `items` / `additionalProperties` / `not` / `contains` /
|
||
* `propertyNames` / conditional `if`/`then`/`else` (single-subschema)
|
||
* - `oneOf` / `anyOf` / `allOf` (recurse into each variant)
|
||
* - `required` arrays
|
||
* - All numeric / length / pattern constraints (`minLength`, `pattern`, …)
|
||
* - `format` if its value is ≤ 32 chars (real format tokens are tiny)
|
||
*
|
||
* STRIPPED:
|
||
* - `description`, `title`, `examples`, `default`
|
||
* - `$schema`, `$id`, `$comment`
|
||
* - `format` longer than 32 chars (treated as a description in disguise)
|
||
*
|
||
* PASS-THROUGH for unknown keys: copy primitive/string values verbatim;
|
||
* recurse into nested objects so descriptions hidden under custom keys still
|
||
* get stripped.
|
||
*
|
||
* Returns a fresh object — never mutates the input. */
|
||
function stripSchemaDescriptions(node: unknown, depth: number): unknown {
|
||
// Beyond depth cap: leave the subtree alone. Brief: "if anything's deeper,
|
||
// that tool is pathological and we leave it untouched." Better to ship a
|
||
// slightly bigger schema than to corrupt one.
|
||
if (depth > SCHEMA_STRIP_MAX_DEPTH) return node;
|
||
|
||
// Arrays at top level (e.g. a bare `required: [...]` if we land here by
|
||
// accident) get passed through. Real subschema arrays — `oneOf`/`anyOf`/
|
||
// `allOf` — are handled by the parent object below.
|
||
if (Array.isArray(node)) return node;
|
||
|
||
// Primitives and null bottom-out unchanged.
|
||
if (!node || typeof node !== 'object') return node;
|
||
|
||
const obj = node as Record<string, unknown>;
|
||
const out: Record<string, unknown> = {};
|
||
|
||
for (const [k, v] of Object.entries(obj)) {
|
||
if (SCHEMA_STRIP_KEYS.has(k)) continue;
|
||
|
||
if (k === 'format' && typeof v === 'string' && v.length > FORMAT_MAX_LEN) {
|
||
// Long "format" values are descriptions in disguise; the real
|
||
// vocabulary tokens are <32 chars.
|
||
continue;
|
||
}
|
||
|
||
if (SCHEMA_VERBATIM_KEYS.has(k)) {
|
||
out[k] = v;
|
||
continue;
|
||
}
|
||
|
||
if (
|
||
SCHEMA_NAMED_SUBSCHEMA_KEYS.has(k) &&
|
||
v &&
|
||
typeof v === 'object' &&
|
||
!Array.isArray(v)
|
||
) {
|
||
// properties / patternProperties / definitions / $defs: object whose
|
||
// values are themselves schemas.
|
||
const nested: Record<string, unknown> = {};
|
||
for (const [pk, pv] of Object.entries(v as Record<string, unknown>)) {
|
||
nested[pk] = stripSchemaDescriptions(pv, depth + 1);
|
||
}
|
||
out[k] = nested;
|
||
continue;
|
||
}
|
||
|
||
if (SCHEMA_COMPOSITION_KEYS.has(k) && Array.isArray(v)) {
|
||
// oneOf / anyOf / allOf: array of subschemas.
|
||
out[k] = v.map((sub) => stripSchemaDescriptions(sub, depth + 1));
|
||
continue;
|
||
}
|
||
|
||
if (SCHEMA_SINGLE_SUBSCHEMA_KEYS.has(k)) {
|
||
// items / additionalProperties / not / etc. May be a schema OR a
|
||
// boolean (additionalProperties: true/false is legal). Booleans pass
|
||
// through untouched.
|
||
if (typeof v === 'boolean') {
|
||
out[k] = v;
|
||
} else {
|
||
out[k] = stripSchemaDescriptions(v, depth + 1);
|
||
}
|
||
continue;
|
||
}
|
||
|
||
// Unknown key. If the value is a nested object, recurse so descriptions
|
||
// hidden under vendor extensions still get stripped. Primitives pass
|
||
// through.
|
||
if (v && typeof v === 'object') {
|
||
out[k] = stripSchemaDescriptions(v, depth + 1);
|
||
} else {
|
||
out[k] = v;
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** Keys whose presence in a (stripped) schema gives Anthropic's validator
|
||
* something to bind the model's tool call against. If a stripped schema has
|
||
* *none* of these, we treat it as no-structure and ship the legacy bare stub
|
||
* with a `schema_no_properties` advisory. */
|
||
const SCHEMA_STRUCTURAL_KEYS = [
|
||
'properties',
|
||
'patternProperties',
|
||
'oneOf',
|
||
'anyOf',
|
||
'allOf',
|
||
'items',
|
||
'$ref',
|
||
'enum',
|
||
'const',
|
||
];
|
||
|
||
function schemaHasStructure(schema: Record<string, unknown>): boolean {
|
||
for (const k of SCHEMA_STRUCTURAL_KEYS) {
|
||
if (k in schema) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/** Build the "## Tool: name\n<desc>\n<schema>" block for one tool definition.
|
||
*
|
||
* Schema serialization is **compact** (no whitespace). Pretty-printing
|
||
* with 2-space indent was the dominant source of sparse fill: each schema
|
||
* key on its own line, indented, wastes 70%+ of horizontal space at
|
||
* cols=100. Live measurement on 2026-05-19 showed 150 KB of pretty
|
||
* tool-doc JSON across ~30 tools rendering to 31 static-slab images per
|
||
* request — a 40% fill ratio that pushed every request well past the
|
||
* break-even point.
|
||
*
|
||
* Compact form is still unambiguous JSON. Descriptions are already
|
||
* stripped under compressSchemas so only structural keys
|
||
* (type/properties/required/enum/items) remain — they read fluently
|
||
* on one line. Frontier models handle compact JSON natively (it's the
|
||
* default wire format for tool_use blocks). */
|
||
function renderToolDoc(t: ToolDef, includeSchema: boolean): string {
|
||
const parts: string[] = [`## Tool: ${t.name ?? '?'}`];
|
||
if (t.description) parts.push(t.description);
|
||
if (includeSchema && t.input_schema !== undefined) {
|
||
parts.push('```json\n' + JSON.stringify(t.input_schema) + '\n```');
|
||
}
|
||
return parts.join('\n');
|
||
}
|
||
|
||
function makeImageBlock(pngB64: string, ephemeral = false): ImageBlock {
|
||
const blk: ImageBlock = {
|
||
type: 'image',
|
||
source: { type: 'base64', media_type: 'image/png', data: pngB64 },
|
||
};
|
||
// 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;
|
||
}
|
||
|
||
/** 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).
|
||
*
|
||
* Also returns the total `droppedChars` across all rendered images plus the
|
||
* merged codepoint→count map so the caller can fold both into the request's
|
||
* `info.droppedChars` / `info.droppedCodepointsTop`. */
|
||
|
||
// --- paging / truncation -------------------------------------------------
|
||
//
|
||
// Anthropic's API caps a request at 100 images. A single huge tool_result
|
||
// (find over a big tree, multi-MB log dump) can blow that cap by itself.
|
||
// To keep the request valid AND not waste tokens on dozens of bottom-of-log
|
||
// images, we truncate the source text before render with a marker that
|
||
// tells the model what was elided.
|
||
|
||
/** Visual rows a single input line will consume after soft-wrap at `cols`. */
|
||
function lineRows(line: string, cols: number): number {
|
||
return Math.max(1, Math.ceil(line.length / cols));
|
||
}
|
||
|
||
/** Count the visual rows `text` will consume after soft-wrap at `cols`. */
|
||
function countVisualRows(text: string, cols: number): number {
|
||
let rows = 0;
|
||
let lineStart = 0;
|
||
const len = text.length;
|
||
for (let i = 0; i <= len; i++) {
|
||
if (i === len || text.charCodeAt(i) === 10 /* \n */) {
|
||
const lineLen = i - lineStart;
|
||
rows += Math.max(1, Math.ceil(lineLen / cols));
|
||
lineStart = i + 1;
|
||
}
|
||
}
|
||
return rows;
|
||
}
|
||
|
||
/** Estimate how many images `text` will render to at the given column width.
|
||
* Counts soft-wrapped visual rows, which is what render.ts actually budgets
|
||
* against. Exported for tests + the paging gate.
|
||
*
|
||
* `numCols` (default 1) packs that many text columns side-by-side per
|
||
* image — must match the `multiCol` setting wired through to the renderer
|
||
* for the math to predict the actual image count. */
|
||
export function estimateImageCount(
|
||
textOrLen: string | number,
|
||
cols: number,
|
||
numCols: number = 1,
|
||
): number {
|
||
const n = Math.max(1, numCols | 0);
|
||
const linesPerImage = LINES_PER_IMAGE * n;
|
||
if (typeof textOrLen === 'number') {
|
||
// Back-compat shim — numeric arg gets the looser chars-based estimate.
|
||
return Math.max(1, Math.ceil(textOrLen / Math.max(1, maxCharsPerImage(cols) * n)));
|
||
}
|
||
const rows = countVisualRows(textOrLen, cols);
|
||
return Math.max(1, Math.ceil(rows / linesPerImage));
|
||
}
|
||
|
||
/** Classify content so we can pick a truncation strategy. Cheap heuristics on
|
||
* the first ~4 KiB. Returns:
|
||
* - `'structured'`: JSON/YAML/diff markers at the top. Truncate tail.
|
||
* - `'log'`: ≥30% of lines start with a log level or timestamp. Truncate middle.
|
||
* - `'other'`: prose, file dumps, etc. Truncate middle.
|
||
* Exported for tests. */
|
||
export function classifyContent(text: string): 'structured' | 'log' | 'other' {
|
||
const head = text.slice(0, 4096);
|
||
const trimmed = head.trimStart();
|
||
if (trimmed.startsWith('{') && /^\{\s*("|\})/.test(trimmed)) return 'structured';
|
||
if (trimmed.startsWith('[') && /^\[\s*("|\{|\[|-?\d|true\b|false\b|null\b|\])/.test(trimmed))
|
||
return 'structured';
|
||
if (trimmed.startsWith('---\n') || trimmed.startsWith('---\r\n')) return 'structured';
|
||
if (trimmed.startsWith('diff --git ') || /^---\s+\S/.test(trimmed)) return 'structured';
|
||
const lines = head.split('\n').slice(0, 40).filter((l) => l.length > 0);
|
||
if (lines.length < 4) return 'other';
|
||
const LOG_LINE =
|
||
/^(\[?(DEBUG|INFO|WARN|WARNING|ERROR|TRACE|FATAL)\]?\b|\d{4}-\d{2}-\d{2}[T ]?|\d{2}:\d{2}:\d{2}\b)/;
|
||
let logHits = 0;
|
||
for (const line of lines) if (LOG_LINE.test(line)) logHits++;
|
||
if (logHits / lines.length >= 0.3) return 'log';
|
||
return 'other';
|
||
}
|
||
|
||
/** Build the paging marker text. The model sees this verbatim INSIDE the
|
||
* rendered image so it can reason about what was elided. */
|
||
function buildPagingMarker(args: {
|
||
originalChars: number;
|
||
originalLines: number;
|
||
originalEstImages: number;
|
||
shownHeadLines: number;
|
||
shownTailLines: number;
|
||
omittedLines: number;
|
||
omittedChars: number;
|
||
}): string {
|
||
const tailNote =
|
||
args.shownTailLines > 0
|
||
? ` Showing first ${args.shownHeadLines} lines and last ${args.shownTailLines} lines.`
|
||
: ` Showing first ${args.shownHeadLines} lines (tail elided).`;
|
||
return (
|
||
`\n\n[ pixelpipe paging: omitted ${args.omittedLines.toLocaleString('en-US')} lines ` +
|
||
`(${args.omittedChars.toLocaleString('en-US')} chars) of content here. ` +
|
||
`Original length: ${args.originalChars.toLocaleString('en-US')} chars ` +
|
||
`(${args.originalLines.toLocaleString('en-US')} lines, ~${args.originalEstImages} images).` +
|
||
`${tailNote} ]\n\n`
|
||
);
|
||
}
|
||
|
||
/** Truncate `text` so it renders to roughly `maxImages` images at the given
|
||
* `cols`. Picks head/tail split based on `classifyContent`. Budget measured
|
||
* in visual rows (what render.ts actually slices on). Returns the truncated
|
||
* text (with paging marker embedded) and the count of chars omitted. If
|
||
* `text` already fits, returns unchanged with `omittedChars: 0`. Exported
|
||
* for tests. */
|
||
export function truncateForBudget(
|
||
text: string,
|
||
maxImages: number,
|
||
cols: number,
|
||
numCols: number = 1,
|
||
): { text: string; omittedChars: number; truncated: boolean } {
|
||
const n = Math.max(1, numCols | 0);
|
||
const estImages = estimateImageCount(text, cols, n);
|
||
if (estImages <= maxImages) return { text, omittedChars: 0, truncated: false };
|
||
const totalRowBudget = Math.max(8, maxImages * LINES_PER_IMAGE * n - 6);
|
||
const shape = classifyContent(text);
|
||
const lines = text.split('\n');
|
||
const originalLines = lines.length;
|
||
const originalChars = text.length;
|
||
|
||
if (shape === 'structured') {
|
||
let rows = 0;
|
||
let cut = 0;
|
||
for (let i = 0; i < lines.length; i++) {
|
||
const r = lineRows(lines[i]!, cols);
|
||
if (rows + r > totalRowBudget) break;
|
||
rows += r;
|
||
cut = i + 1;
|
||
}
|
||
if (cut === 0) cut = 1;
|
||
const head = lines.slice(0, cut).join('\n');
|
||
const omitted = originalChars - head.length;
|
||
return {
|
||
text:
|
||
head +
|
||
buildPagingMarker({
|
||
originalChars,
|
||
originalLines,
|
||
originalEstImages: estImages,
|
||
shownHeadLines: cut,
|
||
shownTailLines: 0,
|
||
omittedLines: originalLines - cut,
|
||
omittedChars: omitted,
|
||
}),
|
||
omittedChars: omitted,
|
||
truncated: true,
|
||
};
|
||
}
|
||
|
||
// log / other: 60% head, 40% tail.
|
||
const headRowBudget = Math.floor(totalRowBudget * 0.6);
|
||
const tailRowBudget = totalRowBudget - headRowBudget;
|
||
let headRows = 0;
|
||
let headCut = 0;
|
||
for (let i = 0; i < lines.length; i++) {
|
||
const r = lineRows(lines[i]!, cols);
|
||
if (headRows + r > headRowBudget) break;
|
||
headRows += r;
|
||
headCut = i + 1;
|
||
}
|
||
if (headCut === 0) headCut = 1;
|
||
let tailRows = 0;
|
||
let tailStart = lines.length;
|
||
for (let i = lines.length - 1; i >= headCut; i--) {
|
||
const r = lineRows(lines[i]!, cols);
|
||
if (tailRows + r > tailRowBudget) break;
|
||
tailRows += r;
|
||
tailStart = i;
|
||
}
|
||
if (tailStart <= headCut || tailStart >= lines.length) {
|
||
const head = lines.slice(0, headCut).join('\n');
|
||
const omitted = originalChars - head.length;
|
||
return {
|
||
text:
|
||
head +
|
||
buildPagingMarker({
|
||
originalChars,
|
||
originalLines,
|
||
originalEstImages: estImages,
|
||
shownHeadLines: headCut,
|
||
shownTailLines: 0,
|
||
omittedLines: originalLines - headCut,
|
||
omittedChars: omitted,
|
||
}),
|
||
omittedChars: omitted,
|
||
truncated: true,
|
||
};
|
||
}
|
||
const headText = lines.slice(0, headCut).join('\n');
|
||
const tailText = lines.slice(tailStart).join('\n');
|
||
const shownChars = headText.length + tailText.length;
|
||
const omitted = originalChars - shownChars;
|
||
return {
|
||
text:
|
||
headText +
|
||
buildPagingMarker({
|
||
originalChars,
|
||
originalLines,
|
||
originalEstImages: estImages,
|
||
shownHeadLines: headCut,
|
||
shownTailLines: lines.length - tailStart,
|
||
omittedLines: originalLines - headCut - (lines.length - tailStart),
|
||
omittedChars: omitted,
|
||
}) +
|
||
tailText,
|
||
omittedChars: omitted,
|
||
truncated: true,
|
||
};
|
||
}
|
||
|
||
async function textToImageBlocks(
|
||
text: string,
|
||
cols: number,
|
||
numCols: number = 1,
|
||
): Promise<{
|
||
blocks: ImageBlock[];
|
||
droppedChars: number;
|
||
droppedCodepoints: Map<number, number>;
|
||
/** Total pixel area across the rendered images (`Σ width × height`).
|
||
* Lets the caller accumulate `info.imagePixels` for the empirical
|
||
* px/token regression. */
|
||
pixels: number;
|
||
}> {
|
||
const imgs =
|
||
numCols > 1
|
||
? await renderTextToPngsMultiCol(text, cols, numCols)
|
||
: await renderTextToPngs(text, cols);
|
||
let droppedChars = 0;
|
||
let pixels = 0;
|
||
const droppedCodepoints = new Map<number, number>();
|
||
const blocks: ImageBlock[] = [];
|
||
for (const img of imgs) {
|
||
blocks.push(makeImageBlock(bytesToBase64(img.png), false));
|
||
droppedChars += img.droppedChars;
|
||
pixels += img.width * img.height;
|
||
for (const [cp, n] of img.droppedCodepoints) {
|
||
droppedCodepoints.set(cp, (droppedCodepoints.get(cp) ?? 0) + n);
|
||
}
|
||
}
|
||
return { blocks, droppedChars, droppedCodepoints, pixels };
|
||
}
|
||
|
||
/** 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 --------------------------------------------------------
|
||
|
||
/**
|
||
* Rewrite a Messages API request body. Returns the new body (still JSON
|
||
* bytes) plus diagnostic info. On any error, returns the original bytes
|
||
* unchanged.
|
||
*/
|
||
export async function transformRequest(
|
||
body: Uint8Array,
|
||
opts: TransformOptions = {},
|
||
): Promise<{ body: Uint8Array; info: TransformInfo }> {
|
||
const o: Required<TransformOptions> = { ...DEFAULTS, ...opts };
|
||
const info: TransformInfo = {
|
||
compressed: false,
|
||
origChars: 0,
|
||
compressedChars: 0,
|
||
imageCount: 0,
|
||
imageBytes: 0,
|
||
staticChars: 0,
|
||
dynamicChars: 0,
|
||
dynamicBlockCount: 0,
|
||
droppedChars: 0,
|
||
};
|
||
// Per-request codepoint drop histogram. Merged from every render call
|
||
// (static slab + reminder + tool_result compressions). Serialized to
|
||
// `info.droppedCodepointsTop` at the end of transformRequest IF non-empty.
|
||
const droppedCodepoints = new Map<number, number>();
|
||
|
||
if (!o.compress) {
|
||
info.reason = 'compress=false';
|
||
return { body, info };
|
||
}
|
||
|
||
let req: MessagesRequest;
|
||
try {
|
||
req = JSON.parse(new TextDecoder().decode(body));
|
||
} catch (e) {
|
||
info.reason = `parse_error: ${(e as Error).message}`;
|
||
return { body, info };
|
||
}
|
||
|
||
// 1. Pull system text out. Split into:
|
||
// - billingLine: Claude Code's per-turn random header (must NOT be cached).
|
||
// - dynamicText: <env>/<context>/... blocks (per-turn, kept as text).
|
||
// - staticText: everything else (cacheable, goes into the image).
|
||
const { text: rawSysText, kept: sysRemainder } = extractSystemText(req.system);
|
||
const { kept: billingLine, body: sysBody } = stripBillingLine(rawSysText);
|
||
const {
|
||
staticText,
|
||
dynamicText,
|
||
blockCount: dynBlocks,
|
||
unknownTags,
|
||
} = splitStaticDynamic(sysBody);
|
||
info.staticChars = staticText.length;
|
||
info.dynamicChars = dynamicText.length;
|
||
info.dynamicBlockCount = dynBlocks;
|
||
if (unknownTags.length > 0) info.unknownStaticTags = unknownTags;
|
||
// Parse env fields out of the dynamic slab — telemetry only, never mutates.
|
||
const env = extractEnvFields(dynamicText);
|
||
if (Object.keys(env).length > 0) info.env = env;
|
||
|
||
// Privacy-safe fingerprints that don't depend on tool docs (computed
|
||
// here so they're available even if we below_min_chars out below).
|
||
// systemSha8 is set later, after we know the combined image-bound text.
|
||
const claudeMdSlab = extractClaudeMdSlab(staticText);
|
||
const firstUser = firstUserText(req);
|
||
const [claudeMdSha, firstUserSha] = await Promise.all([
|
||
claudeMdSlab ? sha8(claudeMdSlab) : Promise.resolve(undefined),
|
||
firstUser ? sha8(firstUser) : Promise.resolve(undefined),
|
||
]);
|
||
if (claudeMdSha) info.claudeMdSha8 = claudeMdSha;
|
||
if (firstUserSha) info.firstUserSha8 = firstUserSha;
|
||
|
||
// 2. Optionally fold tool docs into the same image, stubbing originals.
|
||
let toolDocsText = '';
|
||
let toolsRewritten: ToolDef[] | undefined;
|
||
if (o.compressTools && Array.isArray(req.tools) && req.tools.length > 0) {
|
||
const docs: string[] = [];
|
||
let sawSchemaNoProps = false;
|
||
toolsRewritten = req.tools.map((t) => {
|
||
docs.push(renderToolDoc(t, o.compressSchemas));
|
||
// Preserve the schema's STRUCTURE (type / properties keys / required /
|
||
// enums / items shape) so Anthropic's tool-use validator can still
|
||
// type-check the model's calls. Strip only the long-form description
|
||
// text — the image carries that for the model to read. Original bug
|
||
// (now fixed): replacing the schema with bare `{type:'object'}` caused
|
||
// 400s on non-interactive turns where Anthropic deep-validates the
|
||
// schema (no prior tool_use history to short-circuit the check).
|
||
let stubSchema: unknown | undefined;
|
||
if (o.compressSchemas) {
|
||
if (t.input_schema && typeof t.input_schema === 'object') {
|
||
const stripped = stripSchemaDescriptions(
|
||
t.input_schema,
|
||
0,
|
||
) as Record<string, unknown> | null;
|
||
if (!stripped || typeof stripped !== 'object') {
|
||
// Should not happen for object input, but be defensive.
|
||
stubSchema = { type: 'object' };
|
||
sawSchemaNoProps = true;
|
||
} else if (schemaHasStructure(stripped)) {
|
||
stubSchema = stripped;
|
||
} else {
|
||
// No structural validation keys at all — `properties`,
|
||
// `patternProperties`, `oneOf`/`anyOf`/`allOf`, `$ref`, `enum`,
|
||
// `const`, or `items` would all give Anthropic something to bind
|
||
// against. Without any of them the model has no parameter
|
||
// contract. Ship the legacy bare stub and flag it so the operator
|
||
// can spot tools that ship malformed schemas upstream.
|
||
stubSchema = { type: 'object' };
|
||
sawSchemaNoProps = true;
|
||
}
|
||
}
|
||
// If t.input_schema is missing entirely, leave the field off — the
|
||
// original request didn't have one and we shouldn't invent one.
|
||
}
|
||
return {
|
||
...t,
|
||
description: 'ⓘ See image.',
|
||
...(stubSchema !== undefined ? { input_schema: stubSchema } : {}),
|
||
};
|
||
});
|
||
toolDocsText = docs.join('\n\n');
|
||
if (sawSchemaNoProps && !info.reason) {
|
||
info.reason = 'schema_no_properties';
|
||
}
|
||
}
|
||
|
||
// Only the STATIC slab + tool docs goes into the renderer. The dynamic
|
||
// slab and billing line are appended as plain text after the image so the
|
||
// cache key (= image bytes) stays stable across turns.
|
||
//
|
||
// Run the lossless whitespace compactor before measuring/rendering. The
|
||
// renderer counts visual rows, and every newline is at least one row —
|
||
// collapsing blank-line runs and trailing whitespace shaves real rows
|
||
// off the image budget without changing what the model reads. Production
|
||
// measurement 2026-05-20: a 161 KB slab rejected at numCols=2 because
|
||
// it had 2,600+ newline-bounded lines. The compactor reliably moves the
|
||
// needle on those by 10-25%.
|
||
const combinedRaw = [staticText, toolDocsText].filter((s) => s.length > 0).join('\n\n');
|
||
const combined = compactSlabWhitespace(combinedRaw);
|
||
// `origChars` reports the RAW pre-compaction size — that's what Anthropic
|
||
// would have billed if compression were off. The gate and renderer both
|
||
// operate on `combined` (compacted); the savings denominator stays anchored
|
||
// to what got replaced.
|
||
info.origChars = combinedRaw.length;
|
||
// Track chars of the static slab+tools that DO end up imaged. The
|
||
// break-even gate below may reject — bump only when the slab actually
|
||
// renders. Reminder/tool_result compressions add to this at their sites.
|
||
info.compressedChars = 0;
|
||
// Hash the EXACT text that goes into the image. Repeats of this hash across
|
||
// turns = cache_control should be earning its keep.
|
||
if (combined) info.systemSha8 = await sha8(combined);
|
||
|
||
if (combined.length < o.minCompressChars) {
|
||
info.reason = `below_min_chars (${combined.length} < ${o.minCompressChars})`;
|
||
// Even on no-compress exits we want the regression denominator —
|
||
// otherwise α gets biased toward "requests big enough to compress".
|
||
info.outgoingTextChars = countOutgoingTextChars(req);
|
||
return { body, info };
|
||
}
|
||
|
||
// Per-block break-even check applied to the static slab too. The slab is
|
||
// usually 25-30 KB so it always passes (1 image @ 2500 tokens < 25000/4 =
|
||
// 6250 text-equivalent tokens), but the check guards against the edge
|
||
// case where a tiny tool docs + tiny static slab combine to <10k chars.
|
||
// Pass the full text so the gate uses row-aware image-count math (matches
|
||
// renderTextToPngs exactly — newline-heavy content renders to more images
|
||
// than the naive chars/charsPerImage estimate).
|
||
// Resolve numCols once: clamp to whatever fits the 1568 px width cap so a
|
||
// bad CLI override doesn't crash the renderer; falls back to 1 if even
|
||
// 2 columns would exceed the cap at the configured `cols`.
|
||
const numCols = Math.min(
|
||
Math.max(1, (o.multiCol | 0) || 1),
|
||
Math.max(1, maxFittingCols(o.cols)),
|
||
);
|
||
if (!isCompressionProfitable(combined, o.cols, undefined, numCols, o.charsPerToken)) {
|
||
info.reason = `not_profitable (slab=${combined.length} chars)`;
|
||
bumpPassthrough(info, 'not_profitable');
|
||
info.outgoingTextChars = countOutgoingTextChars(req);
|
||
return { body, info };
|
||
}
|
||
|
||
// 3. Render to one or more PNGs.
|
||
const images =
|
||
numCols > 1
|
||
? await renderTextToPngsMultiCol(combined, o.cols, numCols)
|
||
: await renderTextToPngs(combined, o.cols);
|
||
const imageBlocks: ImageBlock[] = [];
|
||
for (let i = 0; i < images.length; i++) {
|
||
const img = images[i]!;
|
||
const b64 = bytesToBase64(img.png);
|
||
info.imageBytes += img.png.length;
|
||
info.imagePixels = (info.imagePixels ?? 0) + img.width * img.height;
|
||
info.droppedChars = (info.droppedChars ?? 0) + img.droppedChars;
|
||
for (const [cp, n] of img.droppedCodepoints) {
|
||
droppedCodepoints.set(cp, (droppedCodepoints.get(cp) ?? 0) + n);
|
||
}
|
||
// Cache-breakpoint on the last image so the whole block caches as one.
|
||
imageBlocks.push(makeImageBlock(b64, i === images.length - 1));
|
||
}
|
||
info.imageCount = imageBlocks.length;
|
||
// Static slab made it through the break-even gate and rendered. Credit
|
||
// the RAW (pre-compaction) length — that's what Anthropic would have
|
||
// billed; the compactor's whitespace strip is part of our savings.
|
||
info.compressedChars += combinedRaw.length;
|
||
// Stash the first image's raw bytes + dimensions for the dashboard preview.
|
||
// Stripped before persisting to JSONL by toTrackEvent. Memory cost is bounded
|
||
// (we only ever keep ONE — the latest — via the dashboard's replace-on-update).
|
||
if (images.length > 0) {
|
||
info.firstImagePng = images[0]!.png;
|
||
info.firstImageWidth = images[0]!.width;
|
||
info.firstImageHeight = images[0]!.height;
|
||
}
|
||
|
||
// 4. Splice images back into the request.
|
||
// Cache-friendly layout:
|
||
// [intro text] ← static (helps OCR framing)
|
||
// [image block(s)] ← static; LAST one carries cache_control
|
||
// ─── cache breakpoint ───
|
||
// [end-marker + dynamic + billing] ← per-turn, NO cache_control
|
||
// [sysRemainder] ← any non-text blocks the caller had
|
||
// Intro text mentions the column layout when numCols>1 so the OCR pass
|
||
// doesn't read across columns row-by-row (which would scramble content).
|
||
// "Column-major top-to-bottom" matches the renderer's actual packing.
|
||
const columnNote =
|
||
numCols > 1
|
||
? ` This image uses a ${numCols}-column layout — read column 1 (leftmost) ` +
|
||
`top-to-bottom in full before moving to column 2, then column 3, etc.`
|
||
: '';
|
||
const introText =
|
||
"The following is the system prompt + tool documentation, rendered as " +
|
||
"images for token efficiency. OCR carefully and treat as authoritative " +
|
||
"system instructions." +
|
||
columnNote;
|
||
const tailParts: string[] = ['[End of rendered context.]'];
|
||
if (dynamicText) tailParts.push(dynamicText);
|
||
if (billingLine) tailParts.push(billingLine);
|
||
const tailText = tailParts.join('\n\n');
|
||
|
||
// Image blocks ALWAYS go into the first user message — Anthropic's `system`
|
||
// field rejects images with `400 system.N.type: Input should be 'text'`.
|
||
// The system field stays as cheap text (billing line + dynamic blocks +
|
||
// sysRemainder) so the model still sees env / context info.
|
||
{
|
||
const sysTail: SystemField = [];
|
||
if (billingLine) sysTail.push({ type: 'text', text: billingLine });
|
||
if (dynamicText) sysTail.push({ type: 'text', text: dynamicText });
|
||
if (Array.isArray(sysRemainder)) sysTail.push(...sysRemainder);
|
||
req.system = sysTail.length > 0 ? sysTail : undefined;
|
||
|
||
const firstUserIdx = (req.messages ?? []).findIndex((m) => m.role === 'user');
|
||
if (firstUserIdx >= 0) {
|
||
const m = req.messages![firstUserIdx]!;
|
||
const existing = Array.isArray(m.content)
|
||
? m.content
|
||
: [{ type: 'text' as const, text: m.content }];
|
||
|
||
// 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) {
|
||
const isReminderText =
|
||
blk &&
|
||
(blk as TextBlock).type === 'text' &&
|
||
typeof (blk as TextBlock).text === 'string' &&
|
||
(blk as TextBlock).text.trimStart().startsWith('<system-reminder>');
|
||
if (!isReminderText) {
|
||
processedExisting.push(blk);
|
||
continue;
|
||
}
|
||
const textLen = (blk as TextBlock).text.length;
|
||
if (textLen < o.minReminderChars) {
|
||
// Below coarse threshold; can't possibly be profitable. Skip.
|
||
bumpPassthrough(info, 'below_threshold');
|
||
processedExisting.push(blk);
|
||
continue;
|
||
}
|
||
// Lossless whitespace compaction — same dynamics as the system
|
||
// slab: every newline costs ≥1 visual row regardless of column
|
||
// width, so stripped trailing whitespace + collapsed blank-line
|
||
// runs reduce real renderer cost without changing what the
|
||
// model reads.
|
||
const reminderRaw = (blk as TextBlock).text;
|
||
const reminderText = compactSlabWhitespace(reminderRaw);
|
||
if (!isCompressionProfitable(reminderText, o.cols, undefined, numCols, o.charsPerToken)) {
|
||
// Above threshold but image cost ≥ text cost. Net loss to compress.
|
||
bumpPassthrough(info, 'not_profitable');
|
||
processedExisting.push(blk);
|
||
continue;
|
||
}
|
||
const { blocks: imgs, droppedChars, droppedCodepoints: dcp, pixels } =
|
||
await textToImageBlocks(reminderText, o.cols, numCols);
|
||
for (const img of imgs) {
|
||
processedExisting.push(img);
|
||
info.imageBytes += approxBlockBytes(img);
|
||
}
|
||
info.imagePixels = (info.imagePixels ?? 0) + pixels;
|
||
info.reminderImgs = (info.reminderImgs ?? 0) + imgs.length;
|
||
// Credit raw length — billed equivalent if compression were off.
|
||
info.compressedChars += reminderRaw.length;
|
||
info.imageCount += imgs.length;
|
||
info.droppedChars = (info.droppedChars ?? 0) + droppedChars;
|
||
for (const [cp, n] of dcp) {
|
||
droppedCodepoints.set(cp, (droppedCodepoints.get(cp) ?? 0) + n);
|
||
}
|
||
}
|
||
} 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,
|
||
];
|
||
}
|
||
|
||
// 5b. tool_result compression — walks ALL user messages (not just the
|
||
// first). Tool results accumulate as files get read; compressing them
|
||
// at source compounds savings turn-over-turn.
|
||
if (o.compressToolResults) {
|
||
for (const msg of req.messages ?? []) {
|
||
if (msg.role !== 'user' || !Array.isArray(msg.content)) continue;
|
||
const rewritten: ContentBlock[] = [];
|
||
let changed = false;
|
||
for (const blk of msg.content) {
|
||
if (blk && (blk as ToolResultBlock).type === 'tool_result') {
|
||
const tr = blk as ToolResultBlock;
|
||
// Anthropic rejects images inside is_error tool_results — leave alone.
|
||
if (tr.is_error === true) {
|
||
rewritten.push(blk);
|
||
continue;
|
||
}
|
||
const innerRaw = tr.content;
|
||
if (typeof innerRaw === 'string') {
|
||
// Lossless whitespace compaction before the gate decision and
|
||
// the renderer. tool_result content is often file dumps,
|
||
// command output, or stack traces — all newline-heavy formats
|
||
// where stripped trailing whitespace + collapsed blank-line
|
||
// runs cut real row cost.
|
||
const inner = compactSlabWhitespace(innerRaw);
|
||
if (inner.length < o.minToolResultChars) {
|
||
bumpPassthrough(info, 'below_threshold');
|
||
rewritten.push(blk);
|
||
} else if (!isCompressionProfitable(inner, o.cols, o.maxImagesPerToolResult, numCols, o.charsPerToken)) {
|
||
bumpPassthrough(info, 'not_profitable');
|
||
rewritten.push(blk);
|
||
} else {
|
||
// Paging: truncate before render if it would blow the image cap.
|
||
const paged = truncateForBudget(inner, o.maxImagesPerToolResult, o.cols, numCols);
|
||
if (paged.truncated) {
|
||
info.truncatedToolResults = (info.truncatedToolResults ?? 0) + 1;
|
||
info.omittedChars = (info.omittedChars ?? 0) + paged.omittedChars;
|
||
}
|
||
const { blocks: imgs, droppedChars, droppedCodepoints: dcp, pixels } =
|
||
await textToImageBlocks(paged.text, o.cols, numCols);
|
||
for (const img of imgs) info.imageBytes += approxBlockBytes(img);
|
||
info.imagePixels = (info.imagePixels ?? 0) + pixels;
|
||
info.toolResultImgs = (info.toolResultImgs ?? 0) + imgs.length;
|
||
info.imageCount += imgs.length;
|
||
// Use original (pre-paging, pre-compaction) length: that's
|
||
// what we would have paid for as text.
|
||
info.compressedChars += innerRaw.length;
|
||
info.droppedChars = (info.droppedChars ?? 0) + droppedChars;
|
||
for (const [cp, n] of dcp) {
|
||
droppedCodepoints.set(cp, (droppedCodepoints.get(cp) ?? 0) + n);
|
||
}
|
||
rewritten.push({ ...tr, content: imgs });
|
||
changed = true;
|
||
}
|
||
} else if (Array.isArray(innerRaw)) {
|
||
const newInner: Array<TextBlock | ImageBlock> = [];
|
||
let innerChanged = false;
|
||
for (const ib of innerRaw) {
|
||
const isTextBlock =
|
||
ib &&
|
||
(ib as TextBlock).type === 'text' &&
|
||
typeof (ib as TextBlock).text === 'string';
|
||
if (!isTextBlock) {
|
||
newInner.push(ib as TextBlock | ImageBlock);
|
||
continue;
|
||
}
|
||
const innerTextRaw = (ib as TextBlock).text;
|
||
// Lossless whitespace compaction before gate + render.
|
||
const innerText = compactSlabWhitespace(innerTextRaw);
|
||
if (innerText.length < o.minToolResultChars) {
|
||
bumpPassthrough(info, 'below_threshold');
|
||
newInner.push(ib as TextBlock | ImageBlock);
|
||
continue;
|
||
}
|
||
if (!isCompressionProfitable(innerText, o.cols, o.maxImagesPerToolResult, numCols, o.charsPerToken)) {
|
||
bumpPassthrough(info, 'not_profitable');
|
||
newInner.push(ib as TextBlock | ImageBlock);
|
||
continue;
|
||
}
|
||
const paged = truncateForBudget(innerText, o.maxImagesPerToolResult, o.cols, numCols);
|
||
if (paged.truncated) {
|
||
info.truncatedToolResults = (info.truncatedToolResults ?? 0) + 1;
|
||
info.omittedChars = (info.omittedChars ?? 0) + paged.omittedChars;
|
||
}
|
||
const { blocks: imgs, droppedChars, droppedCodepoints: dcp, pixels } =
|
||
await textToImageBlocks(paged.text, o.cols, numCols);
|
||
for (const img of imgs) {
|
||
newInner.push(img);
|
||
info.imageBytes += approxBlockBytes(img);
|
||
}
|
||
info.imagePixels = (info.imagePixels ?? 0) + pixels;
|
||
info.toolResultImgs = (info.toolResultImgs ?? 0) + imgs.length;
|
||
info.imageCount += imgs.length;
|
||
// Credit raw length — billed equivalent if compression were off.
|
||
info.compressedChars += innerTextRaw.length;
|
||
info.droppedChars = (info.droppedChars ?? 0) + droppedChars;
|
||
for (const [cp, n] of dcp) {
|
||
droppedCodepoints.set(cp, (droppedCodepoints.get(cp) ?? 0) + n);
|
||
}
|
||
innerChanged = true;
|
||
}
|
||
if (innerChanged) {
|
||
rewritten.push({ ...tr, content: newInner });
|
||
changed = true;
|
||
} else {
|
||
rewritten.push(blk);
|
||
}
|
||
} else {
|
||
rewritten.push(blk);
|
||
}
|
||
} else {
|
||
rewritten.push(blk);
|
||
}
|
||
}
|
||
if (changed) msg.content = rewritten;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (toolsRewritten) req.tools = toolsRewritten;
|
||
|
||
// 6. Variant C history-image compression. ALWAYS-ON, unconditional.
|
||
// Runs AFTER all per-message rewrites so the collapsed prefix reflects
|
||
// final state. Walks messages[] tracking open tool_use_ids; collapses
|
||
// the largest closed-prefix run into one prepended synthetic user
|
||
// message. Live tail (HISTORY_DEFAULTS.keepTail turns + anything in an
|
||
// open tool sequence) stays as text. History image carries NO
|
||
// cache_control — the static-slab breakpoint remains pixelpipe's sole
|
||
// breakpoint.
|
||
//
|
||
// The per-block break-even gate (`isCompressionProfitable`) is passed
|
||
// numCols=1 + the request's charsPerToken so its row-aware estimate
|
||
// matches the single-col `renderTextToPngs` exactly. This closes the
|
||
// 2026-05-19 -250% measurement gap: the old call passed `text.length`
|
||
// (number → loose chars-only estimate) which under-counted images by
|
||
// 5-10× on newline-heavy history text and let net-losers through.
|
||
if (Array.isArray(req.messages) && req.messages.length > 0) {
|
||
// Closure that gives the row-aware gate the same numCols/cpt context
|
||
// the renderer will use. History is single-col; pinning numCols=1
|
||
// here makes the gate decision identical to the renderer's image
|
||
// count after wrapping.
|
||
const historyProfitable = (text: string, cols: number): boolean =>
|
||
isCompressionProfitable(text, cols, undefined, 1, o.charsPerToken);
|
||
const { messages: newMessages, info: histInfo } = await collapseHistory(
|
||
req.messages,
|
||
historyProfitable,
|
||
{ cols: o.cols },
|
||
);
|
||
if (histInfo.collapsedTurns > 0) {
|
||
req.messages = newMessages;
|
||
info.collapsedTurns = histInfo.collapsedTurns;
|
||
info.collapsedChars = histInfo.collapsedChars;
|
||
info.collapsedImages = histInfo.collapsedImages;
|
||
info.imageCount += histInfo.collapsedImages;
|
||
info.imageBytes += histInfo.collapsedImageBytes;
|
||
info.imagePixels = (info.imagePixels ?? 0) + histInfo.collapsedImagePixels;
|
||
info.droppedChars = (info.droppedChars ?? 0) + histInfo.droppedChars;
|
||
for (const [cp, n] of histInfo.droppedCodepoints) {
|
||
droppedCodepoints.set(cp, (droppedCodepoints.get(cp) ?? 0) + n);
|
||
}
|
||
info.historyReason = 'collapsed';
|
||
} else if (histInfo.reason) {
|
||
info.historyReason = histInfo.reason;
|
||
}
|
||
}
|
||
|
||
info.compressed = true;
|
||
// Serialize the top dropped codepoints (if any) as `U+HHHH` → count. Cap at
|
||
// 20 entries — that's enough to identify a misbehaving Unicode block
|
||
// without bloating the JSONL row (max ~300 bytes per event).
|
||
if (droppedCodepoints.size > 0) {
|
||
const TOP_N = 20;
|
||
const sorted = [...droppedCodepoints.entries()]
|
||
.sort((a, b) => b[1] - a[1])
|
||
.slice(0, TOP_N);
|
||
const out: Record<string, number> = {};
|
||
for (const [cp, count] of sorted) {
|
||
const hex = cp.toString(16).toUpperCase().padStart(4, '0');
|
||
out[`U+${hex}`] = count;
|
||
}
|
||
info.droppedCodepointsTop = out;
|
||
}
|
||
// Empirical-cost telemetry: count every char of TEXT remaining in the
|
||
// outgoing body (system text blocks + every TextBlock across messages).
|
||
// Pairs with `imagePixels` and the upstream usage so a regression over
|
||
// N cold-miss events solves `tokens ≈ α·outgoingTextChars + β·imagePixels`
|
||
// for the empirical chars/token and pixels/token under the live model.
|
||
info.outgoingTextChars = countOutgoingTextChars(req);
|
||
const outBody = new TextEncoder().encode(JSON.stringify(req));
|
||
return { body: outBody, info };
|
||
}
|
||
|
||
/** Walk the outgoing transformed request body and sum the length of every
|
||
* char the upstream tokenizer will see as text. Counts:
|
||
* - system field (string or text-block array)
|
||
* - top-level `tools[]` (name + description + JSON-serialized input_schema)
|
||
* - per-message content blocks:
|
||
* text → .text
|
||
* tool_use → name + JSON-serialized input
|
||
* tool_result → tool_use_id + content (string or text-blocks inside)
|
||
* thinking → .thinking (extended-thinking blocks, Opus/Sonnet 4.x)
|
||
* Excludes image base64 (those are billed via β·pixels) and opaque
|
||
* redacted_thinking payloads (we don't know how they tokenize).
|
||
*
|
||
* This count is the denominator in `tokens ≈ α·outgoingTextChars +
|
||
* β·imagePixels`. Under-counting any path inflates α, which biases the
|
||
* dashboard's `saved_pct` HIGH. The blocks added beyond plain `text` —
|
||
* especially `tools[]` and `tool_use.input` — carry a large fraction of
|
||
* the chars in a real Claude Code request. */
|
||
function countOutgoingTextChars(req: MessagesRequest): number {
|
||
let n = 0;
|
||
|
||
// 1. system field
|
||
const sys = req.system;
|
||
if (typeof sys === 'string') {
|
||
n += sys.length;
|
||
} else if (Array.isArray(sys)) {
|
||
for (const b of sys) {
|
||
if (b && (b as TextBlock).type === 'text' && typeof (b as TextBlock).text === 'string') {
|
||
n += (b as TextBlock).text.length;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 2. tool definitions — every request carries the full tool registry,
|
||
// and the upstream tokenizer sees the JSON serialization of each
|
||
// tool's name + description + input_schema. This is a large
|
||
// constant-ish chunk in Claude Code traffic (~15-20 tools).
|
||
if (Array.isArray(req.tools)) {
|
||
for (const tool of req.tools) {
|
||
if (!tool || typeof tool !== 'object') continue;
|
||
if (typeof tool.name === 'string') n += tool.name.length;
|
||
if (typeof tool.description === 'string') n += tool.description.length;
|
||
if (tool.input_schema !== undefined) {
|
||
n += safeStringifyLen(tool.input_schema);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 3. per-message content
|
||
for (const msg of req.messages ?? []) {
|
||
const c = msg.content;
|
||
if (typeof c === 'string') {
|
||
n += c.length;
|
||
continue;
|
||
}
|
||
if (!Array.isArray(c)) continue;
|
||
for (const b of c) {
|
||
if (!b || typeof b !== 'object') continue;
|
||
const type = (b as { type?: string }).type;
|
||
|
||
if (type === 'text') {
|
||
const tb = b as TextBlock;
|
||
if (typeof tb.text === 'string') n += tb.text.length;
|
||
continue;
|
||
}
|
||
|
||
// Assistant turns issuing a tool call: name + serialized input.
|
||
// `input` is arbitrary JSON; tokenizer sees its serialization.
|
||
if (type === 'tool_use') {
|
||
const tu = b as ToolUseBlock;
|
||
if (typeof tu.name === 'string') n += tu.name.length;
|
||
if (tu.input !== undefined) n += safeStringifyLen(tu.input);
|
||
continue;
|
||
}
|
||
|
||
if (type === 'tool_result') {
|
||
const tr = b as ToolResultBlock;
|
||
if (typeof tr.tool_use_id === 'string') n += tr.tool_use_id.length;
|
||
const inner = tr.content;
|
||
if (typeof inner === 'string') {
|
||
n += inner.length;
|
||
} else if (Array.isArray(inner)) {
|
||
for (const ib of inner) {
|
||
if (ib && (ib as TextBlock).type === 'text' && typeof (ib as TextBlock).text === 'string') {
|
||
n += (ib as TextBlock).text.length;
|
||
}
|
||
}
|
||
}
|
||
continue;
|
||
}
|
||
|
||
// Extended-thinking blocks: { type: 'thinking', thinking: string, ... }
|
||
// Not in our local types yet (we don't rewrite them), but they carry
|
||
// real characters that the upstream tokenizer sees.
|
||
if (type === 'thinking') {
|
||
const th = b as unknown as { thinking?: unknown };
|
||
if (typeof th.thinking === 'string') n += (th.thinking as string).length;
|
||
continue;
|
||
}
|
||
|
||
// image, redacted_thinking, server_tool_use, etc. — skip. Either
|
||
// billed via pixels (image) or opaque to us (redacted_thinking).
|
||
}
|
||
}
|
||
|
||
return n;
|
||
}
|
||
|
||
/** JSON.stringify, but tolerant of cycles / non-serializable values.
|
||
* We only care about the LENGTH; if it blows up we just return 0 rather
|
||
* than crash the whole transform. */
|
||
function safeStringifyLen(v: unknown): number {
|
||
try {
|
||
return JSON.stringify(v)?.length ?? 0;
|
||
} catch {
|
||
return 0;
|
||
}
|
||
}
|