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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 37 additions & 19 deletions src/core/png.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/**
* Minimal PNG encoder (grayscale + RGB, 8-bit, filter=None, single IDAT).
* Minimal PNG encoder (grayscale + RGB, 8-bit, filter=Average, single IDAT).
* Pure Uint8Array — uses CompressionStream (Node 18+, Workers, browsers); no Buffer/node:zlib.
*/

Expand Down Expand Up @@ -77,6 +77,40 @@ async function deflateZlib(input: Uint8Array): Promise<Uint8Array> {

const PNG_SIGNATURE = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);

/**
* Prepend scanline filter bytes using PNG's Average filter (type 3):
* Filt(x) = Orig(x) − floor((Recon(a) + Recon(b)) / 2)
* where `a` is the same channel of the pixel to the left (x − bpp) and `b` the byte directly
* above. Encoding may read the ORIGINAL bytes for a/b because the decoder reconstructs those
* positions exactly before it needs them — the transform is bit-exact reversible, so decoded
* pixels are byte-identical to `pixels`. Nothing about the image the model sees changes.
*
* Chosen over filter=None (the previous behavior): residuals of 5×8 bitmap glyphs on a flat
* background collapse to mostly zeros, which deflate encodes in both less space and less time —
* ~34% smaller IDAT at roughly half the compression cost on a representative dense page. That
* matters because every image is re-uploaded on every turn, so IDAT size is a per-request
* bandwidth cost. Filtering is pure JS ahead of the compressor, so it stays portable to
* Workers unlike a deflate-level change (see the CompressionStream note above).
*/
function filterAverage(pixels: Uint8Array, width: number, height: number, bpp: number): Uint8Array {
const rowBytes = width * bpp;
const stride = rowBytes + 1;
const out = new Uint8Array(stride * height);
for (let y = 0; y < height; y++) {
const src = y * rowBytes;
const dst = y * stride;
out[dst] = 3; // filter: Average
for (let x = 0; x < rowBytes; x++) {
// Off-image neighbors are defined as zero by the spec, not clamped/wrapped.
const a = x >= bpp ? pixels[src + x - bpp]! : 0;
const b = y > 0 ? pixels[src - rowBytes + x]! : 0;
// a + b ≤ 510 so the shift is exact; only the result wraps to a byte.
out[dst + 1 + x] = (pixels[src + x]! - ((a + b) >> 1)) & 0xff;
}
}
return out;
}

/** Encode a single-channel (grayscale) buffer as PNG bytes. pixels is row-major, length = width × height. */
export async function encodeGrayPng(pixels: Uint8Array, width: number, height: number): Promise<Uint8Array> {
if (pixels.length !== width * height) {
Expand All @@ -90,15 +124,7 @@ export async function encodeGrayPng(pixels: Uint8Array, width: number, height: n
ihdr[8] = 8;
ihdr[9] = 0; // colorType 0 = grayscale; bytes 10-12 already zero

// Prepend per-scanline filter byte (0 = None).
const stride = width + 1;
const raw = new Uint8Array(stride * height);
for (let y = 0; y < height; y++) {
raw[y * stride] = 0; // filter: None
raw.set(pixels.subarray(y * width, (y + 1) * width), y * stride + 1);
}

const compressed = await deflateZlib(raw);
const compressed = await deflateZlib(filterAverage(pixels, width, height, 1));

return concat([
PNG_SIGNATURE,
Expand All @@ -120,15 +146,7 @@ export async function encodeRgbPng(pixels: Uint8Array, width: number, height: nu
ihdr[8] = 8; // bit depth per channel
ihdr[9] = 2; // colorType 2 = truecolor RGB; bytes 10-12 already zero

// Prepend per-scanline filter byte (0 = None).
const stride = width * 3 + 1;
const raw = new Uint8Array(stride * height);
for (let y = 0; y < height; y++) {
raw[y * stride] = 0; // filter: None
raw.set(pixels.subarray(y * width * 3, (y + 1) * width * 3), y * stride + 1);
}

const compressed = await deflateZlib(raw);
const compressed = await deflateZlib(filterAverage(pixels, width, height, 3));

return concat([
PNG_SIGNATURE,
Expand Down
12 changes: 12 additions & 0 deletions src/core/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,10 @@ export interface ProxyEvent {
durationMs: number;
/** Wall-clock ms from request start to upstream response headers. */
firstByteMs?: number;
/** Wall-clock ms spent in the local transform (render + encode), excluding any
* upstream probe. `durationMs - transformMs` is the upstream half, so a slow
* request can be attributed to our own CPU vs the provider without guessing. */
transformMs?: number;
info?: TransformInfo;
/** Usage block from Anthropic's response — input/output/cache tokens. */
usage?: Usage;
Expand Down Expand Up @@ -1007,6 +1011,8 @@ export function createProxy(config: ProxyConfig = {}) {
let reqBodyBytes: Uint8Array | undefined;
let reqBodySha8: string | undefined;
let reqBodySha256: string | undefined;
// Set once the transform returns; read by fire() at event time.
let transformMs: number | undefined;

const fire = (
status: number,
Expand Down Expand Up @@ -1088,6 +1094,7 @@ export function createProxy(config: ProxyConfig = {}) {
status,
durationMs: Date.now() - t0,
firstByteMs,
transformMs,
info,
usage: eventUsage,
error,
Expand Down Expand Up @@ -1218,6 +1225,10 @@ export function createProxy(config: ProxyConfig = {}) {
: bridgedChatMessages
? anthropicMessagesToOpenAIChat(bodyIn, chatStamp ?? undefined)
: bodyIn;
// Local render+encode cost only. The Google branch below issues upstream
// count_tokens probes, so the timer closes here rather than after them —
// otherwise network latency would be charged to our own CPU.
const tTransform = Date.now();
let r = isGoogle
? await transformGoogleGenerateContent(bodyIn, model!, effectiveOpts)
: isMessages
Expand All @@ -1229,6 +1240,7 @@ export function createProxy(config: ProxyConfig = {}) {
: isOpenAIChat
? await transformOpenAIChatCompletions(bodyIn, effectiveOpts)
: await transformOpenAIResponses(bodyIn, effectiveOpts);
transformMs = Date.now() - tTransform;
if (isGoogle && r.info.compressed) {
const countHeaders = applyGatewayHeaders(filterHeaders(req.headers, STRIP_REQ_HEADERS));
countHeaders.set('content-type', 'application/json');
Expand Down
132 changes: 132 additions & 0 deletions src/core/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1048,13 +1048,145 @@ export async function renderTextToPngsReflow(
}

/** Split text into N PNGs each ≤ MAX_HEIGHT_PX tall, respecting per-image char budget. */
// --- rendered-page cache ---------------------------------------------------

/**
* Rendering is a pure function of (text, cols, maxCharsPerImage, style, maxHeightPx,
* slotText): the atlases are decoded once at module init and never mutated, and no
* module-level state feeds the output. So identical inputs can safely return the
* identical pages instead of being re-rasterized.
*
* This is worth a cache because the proxy deliberately re-renders the same bytes every
* turn: history chunks are frozen so they stay byte-identical for the upstream prompt
* cache (see the cache-stability note in history.ts), which means a long session pays
* full render cost for pages that provably did not change. Measured on a real session,
* ~134 images cost ~10s of single-threaded CPU per request while the payload hash held
* constant across consecutive turns.
*
* Bounded by total retained bytes (PNG payloads + the key strings, which embed the
* source text and would otherwise be the larger half). Eviction is LRU via Map's
* insertion order: re-inserting on hit moves an entry to the newest position.
*/
const RENDER_CACHE_MAX_BYTES = (() => {
// Edge-safe: `process` is undefined off-Node.
const raw = typeof process !== 'undefined' ? process.env?.PXPIPE_RENDER_CACHE_BYTES : undefined;
const parsed = raw !== undefined && raw.trim() !== '' ? Number(raw) : NaN;
// 0 disables the cache outright; negative/garbage falls back to the default.
if (Number.isFinite(parsed) && parsed >= 0) return Math.floor(parsed);
// 64 MiB holds several turns of a heavy session while staying well inside a
// Workers isolate's memory ceiling.
return 64 * 1024 * 1024;
})();

interface RenderCacheEntry {
readonly images: readonly RenderedImage[];
readonly bytes: number;
}

const renderCache = new Map<string, RenderCacheEntry>();
let renderCacheBytes = 0;
let renderCacheHits = 0;
let renderCacheMisses = 0;

/** Observability for the dashboard/tests. Bytes counts retained PNG + key bytes. */
export function renderCacheStats(): {
entries: number;
bytes: number;
hits: number;
misses: number;
} {
return { entries: renderCache.size, bytes: renderCacheBytes, hits: renderCacheHits, misses: renderCacheMisses };
}

/** Drop every entry. Tests use this to isolate hit/miss accounting. */
export function clearRenderCache(): void {
renderCache.clear();
renderCacheBytes = 0;
renderCacheHits = 0;
renderCacheMisses = 0;
}

/** Stable serialization of RenderStyle. Every field is a primitive (see the interface),
* so String() is unambiguous; sorting keys removes any dependence on literal order.
* Undefined values are skipped so `{}` and `{aa: undefined}` share one entry. */
function styleCacheKey(style: RenderStyle): string {
const src = style as Record<string, unknown>;
let out = '';
for (const k of Object.keys(src).sort()) {
const v = src[k];
if (v !== undefined) out += `${k}=${String(v)};`;
}
return out;
}

/** Copy the array and each entry's mutable `droppedCodepoints` map so a caller cannot
* mutate cached state. The PNG bytes are shared deliberately — copying multi-MB
* payloads on every hit would defeat the point; callers only base64-encode them. */
function cloneForCaller(images: readonly RenderedImage[]): RenderedImage[] {
return images.map((img) => ({ ...img, droppedCodepoints: new Map(img.droppedCodepoints) }));
}

export async function renderTextToPngsWithCharLimit(
text: string,
cols: number = DEFAULT_COLS,
maxCharsPerImage: number = READABLE_CHARS_PER_IMAGE,
style: RenderStyle = {},
maxHeightPx: number = MAX_HEIGHT_PX,
slotText?: string,
): Promise<RenderedImage[]> {
if (RENDER_CACHE_MAX_BYTES === 0) {
return renderTextToPngsUncached(text, cols, maxCharsPerImage, style, maxHeightPx, slotText);
}
// The key embeds the source text verbatim rather than a digest, so it is
// collision-free by construction — a digest collision here would serve the WRONG
// image, a silent correctness bug far worse than a cache miss.
//
// `slotText` is length-prefixed because it and `text` are both free-form: plain
// concatenation would give ("a b", "c") and ("a", "b c") one key. -1 marks absent,
// which must stay distinct from empty — renderTextToPngsUncached branches on
// `slotText !== undefined`. The numeric and style segments contain no `|` (style
// values are booleans, numbers, and dashed string unions), so the fixed-arity
// prefix can never be confused with content.
const slotLen = slotText === undefined ? -1 : slotText.length;
const key =
`${cols}|${maxCharsPerImage}|${maxHeightPx}|${styleCacheKey(style)}|${slotLen}|${slotText ?? ''}${text}`;
const hit = renderCache.get(key);
if (hit !== undefined) {
renderCacheHits++;
renderCache.delete(key); // re-insert to refresh LRU position
renderCache.set(key, hit);
return cloneForCaller(hit.images);
}
renderCacheMisses++;

const images = await renderTextToPngsUncached(text, cols, maxCharsPerImage, style, maxHeightPx, slotText);

// Key strings are UTF-16 in memory; 2 bytes/unit is the honest estimate.
let bytes = key.length * 2;
for (const img of images) bytes += img.png.byteLength;
// A single render larger than the whole budget is not cacheable — storing it would
// evict everything and then itself. Serve it and move on.
if (bytes <= RENDER_CACHE_MAX_BYTES) {
renderCache.set(key, { images, bytes });
renderCacheBytes += bytes;
// Evict oldest-first until back within budget. Map iteration is insertion order.
for (const [k, v] of renderCache) {
if (renderCacheBytes <= RENDER_CACHE_MAX_BYTES) break;
if (k === key) continue; // never evict the entry just stored
renderCache.delete(k);
renderCacheBytes -= v.bytes;
}
}
return cloneForCaller(images);
}

async function renderTextToPngsUncached(
text: string,
cols: number,
maxCharsPerImage: number,
style: RenderStyle,
maxHeightPx: number,
slotText?: string,
): Promise<RenderedImage[]> {
const markerScale = Math.max(1, Math.floor(style.markerScale ?? 1));
const cellH = renderCellHeight(style);
Expand Down
3 changes: 3 additions & 0 deletions src/core/tracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ export interface TrackEvent {
status: number;
duration_ms: number;
first_byte_ms?: number;
/** Local render+encode ms. `duration_ms - transform_ms` isolates upstream. */
transform_ms?: number;

// From TransformInfo:
compressed?: boolean;
Expand Down Expand Up @@ -188,6 +190,7 @@ export function toTrackEvent(ev: ProxyEvent): TrackEvent {
if (ev.model) out.model = ev.model;
if (ev.accountingProvider) out.accounting_provider = ev.accountingProvider;
if (ev.firstByteMs !== undefined) out.first_byte_ms = ev.firstByteMs;
if (ev.transformMs !== undefined) out.transform_ms = ev.transformMs;
if (ev.error) out.error = ev.error;
if (ev.errorBody) out.error_body = ev.errorBody;
if (ev.reqBodySha8) out.req_body_sha8 = ev.reqBodySha8;
Expand Down
23 changes: 22 additions & 1 deletion src/node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,10 @@ Environment:
PXPIPE_LOG JSONL events path (default ~/.pxpipe/events.jsonl)
PXPIPE_DUMP_DIR debug: write every rendered PNG here (what the model
sees); off unless set. Compress arm only.
PXPIPE_RENDER_CACHE_BYTES max bytes of rendered pages to keep in memory
(default 64 MiB). Frozen history chunks are
byte-identical across turns, so re-rendering them is
wasted CPU; 0 disables the cache.
PXPIPE_DEBUG_CAPTURE_4XX debug: set to 1 to persist full 4xx request and
upstream error bodies (prompts + any secrets in
context) to disk. Off by default.
Expand Down Expand Up @@ -1173,8 +1177,25 @@ async function main(): Promise<void> {
e.usage !== undefined
? ` tokens=${inputTokens}+${e.usage.output_tokens ?? 0} cache_read=${cacheRead}`
: '';
// Split the wall clock into the half we control and the half we don't:
// `tx` is local render+encode, the remainder is upstream. Without this the
// duration alone can't distinguish our CPU from a slow provider.
//
// `fb` further splits the upstream half: request start → response headers,
// so it covers upload + provider queue/processing but NOT generation. With
// all three, `fb - tx` isolates how much a large image payload costs to put
// on the wire, which is the number that decides whether shrinking IDATs pays.
const timingParts = [`${e.durationMs}ms`];
if (e.transformMs !== undefined) {
timingParts.push(
`tx=${e.transformMs}ms`,
`up=${Math.max(0, e.durationMs - e.transformMs)}ms`,
);
}
if (e.firstByteMs !== undefined) timingParts.push(`fb=${e.firstByteMs}ms`);
const timing = timingParts.join(' ');
console.log(
`[${new Date().toISOString()}] ${e.method} ${e.path} → ${e.status} (${e.durationMs}ms) ${tag}${usageTag}`,
`[${new Date().toISOString()}] ${e.method} ${e.path} → ${e.status} (${timing}) ${tag}${usageTag}`,
);

// Upstream error bodies are present only under PXPIPE_DEBUG_CAPTURE_4XX;
Expand Down
Loading