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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@ behavioral changes, patch = fixes).
models (#159).
- `build.mjs` resolves `tsc` via `typescript/package.json` instead of the
removed `./bin/tsc` export, fixing builds on newer TypeScript.
- When the legible history geometry (#170) overflowed the image-byte budget on a
long session, the collapse was abandoned and the raw history forwarded as
text — a request larger than the render it refused, which the upstream 400s.
The collapse now degrades to the dense geometry to fit instead of bailing;
only a history too big for dense too keeps its text. New `history_degraded_dense`
event marks the fallback (#216).

## 0.12.1 — 2026-08-08

Expand Down
5 changes: 5 additions & 0 deletions src/core/tracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,10 @@ export interface TrackEvent {
* was therefore allowed to repack for density. Pair with cache_read_tokens:
* a repack that lands on a live cache would show as a cache_create spike. */
history_pack_fill?: boolean;
/** Set when the model's legible history render overflowed the image-byte
* budget and the collapse was re-rendered at the dense geometry to fit,
* rather than abandoned to a raw-text forward (#216). */
history_degraded_dense?: boolean;
/** Codepoints not in the glyph atlas. A spike means users type glyphs we don't ship — widen ATLAS_PROFILE. */
dropped_chars?: number;
/** Top-20 dropped codepoints (U+HHHH keys) by frequency. Only present when dropped_chars > 0. */
Expand Down Expand Up @@ -305,6 +309,7 @@ export function toTrackEvent(ev: ProxyEvent): TrackEvent {
if (info.historyFreezeStep !== undefined) out.history_freeze_step = info.historyFreezeStep;
if (info.historyBudgetTrimmed) out.history_budget_trimmed = true;
if (info.historyPackFill) out.history_pack_fill = true;
if (info.historyDegradedDense) out.history_degraded_dense = true;
if (info.droppedChars !== undefined && info.droppedChars > 0) {
out.dropped_chars = info.droppedChars;
}
Expand Down
161 changes: 95 additions & 66 deletions src/core/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import {
ANTHROPIC_MAX_IMAGES,
ANTHROPIC_HISTORY_IMAGE_BUDGET,
} from './history.js';
import type { HistoryCollapseInfo } from './history.js';
import { noteHistoryRequest, recordFreezeStep } from './session-state.js';
import type { GptHistoryOptions } from './openai-history.js';
import { CACHE_CREATE_RATE, CACHE_READ_RATE } from './baseline.js';
Expand Down Expand Up @@ -812,6 +813,11 @@ export interface TransformInfo {
* didn't (exclude from rollup — cacheable=0 fallback is dishonest). 'failed': no
* baseline. undefined: no probe attempted. */
baselineProbeStatus?: 'ok' | 'partial' | 'failed';
/** The model's legible history render (jetbrains-mono-14, #170) overflowed the
* decoded image-byte budget, so the collapse was re-rendered at the dense
* geometry and admitted instead of being abandoned to a raw-text forward
* (#216). Diagnostic only. */
historyDegradedDense?: boolean;
}

// --- helpers ---------------------------------------------------------------
Expand Down Expand Up @@ -1918,6 +1924,79 @@ function historyGridTuning(
return { imageBudget, packFill: session.cold, minFreezeStep: session.minFreezeStep };
}

/** Collapse the history at the model's default (possibly legible) history
* geometry, and — when that render overflows the decoded image-byte budget —
* retry once at the dense geometry before giving up.
*
* #216: since #170, misread-prone Claude models (e.g. `claude-opus-5`) default
* to the legible history geometry (`jetbrains-mono-14`), which renders several
* times heavier per char than dense (`spleen-5x8`). On a long session the
* legible collapse can exceed the 18 MiB headroom while the dense render still
* fits. The atomic-admission check then abandoned the collapse and forwarded
* the raw history as text — a request LARGER than the render it refused (raw
* forwards of 1.65-1.73M tokens observed, upstream 400s). Degrading to dense
* keeps the collapse (and its token saving); only a history too big for even
* the dense geometry falls through to the original text passthrough. */
async function collapseHistoryWithinByteBudget(
messages: Message[],
info: TransformInfo,
o: Required<TransformOptions>,
opts: TransformOptions,
protectedPrefix: number,
tuning: { imageBudget: number; packFill: boolean; minFreezeStep: number },
): Promise<{ messages: Message[]; info: HistoryCollapseInfo; degradedToDense: boolean }> {
const historyCpt = opts.charsPerToken !== undefined
? o.charsPerToken
: HISTORY_CHARS_PER_TOKEN;
const horizon = Math.max(1, Math.floor(o.historyAmortizationHorizon));
const collapseAt = (geometry: GateGeometry) => {
// Gate with the same model geometry the history renderer will use. The
// symmetric warm-cache burn (priorWarmImageTokens) is passed through here
// too: without it the history gate flipped sessions out of image mode even
// when symmetric burn would have kept the slab gate in (prod 2026-05-23,
// three-turn sessions paying cache_create every turn).
const profitable = (text: string, _cols: number): boolean =>
isCompressionProfitableAmortized(
text, geometry.cols, undefined, historyCpt, horizon,
o.priorWarmTokens, o.priorWarmImageTokens, true, geometry.maxChars, geometry,
);
return collapseHistory(messages, profitable, {
cols: geometry.cols,
protectedPrefix,
reflow: o.reflow,
style: geometry.style,
maxHeightPx: geometry.maxHeightPx,
pageChars: geometry.maxChars,
imageBudget: tuning.imageBudget,
packFill: tuning.packFill,
minFreezeStep: tuning.minFreezeStep,
});
};
const overflows = (histInfo: HistoryCollapseInfo): boolean =>
histInfo.collapsedTurns > 0 &&
histInfo.collapsedImageBytes > imageByteHeadroom(info, o.maxImageBytes);

const first = await collapseAt(historyGateGeometry(o, opts.cols !== undefined));
if (!overflows(first.info)) return { ...first, degradedToDense: false };

// The retry only helps when the model actually renders history legibly; when
// the history geometry already IS the dense one (Fable, GPT), re-rendering
// would reproduce the same overflow. `historyGateGeometry` diverges from dense
// exactly when the profile sets a history override, so mirror that condition.
const profile = o.model ? resolveGptProfile(o.model) : undefined;
const rendersLegibleHistory =
profile?.historyStripCols !== undefined || profile?.historyStyle !== undefined;
if (!rendersLegibleHistory) return { ...first, degradedToDense: false };

const retry = await collapseAt(denseGateGeometry(o));
// Keep the dense collapse only when it fits; otherwise the original atomic
// admission stands (a history too big for any geometry keeps its text).
if (retry.info.collapsedTurns > 0 && !overflows(retry.info)) {
return { ...retry, degradedToDense: true };
}
return { ...first, degradedToDense: false };
}

async function runHistoryCollapseAndFinalize(
req: MessagesRequest,
info: TransformInfo,
Expand All @@ -1936,24 +2015,6 @@ async function runHistoryCollapseAndFinalize(
info.imageBudgetSkips = (info.imageBudgetSkips ?? 0) + 1;
info.historyReason ??= 'too_many_images';
} else if (Array.isArray(req.messages) && req.messages.length > 0) {
const historyCpt = opts.charsPerToken !== undefined
? o.charsPerToken
: HISTORY_CHARS_PER_TOKEN;
const horizon = Math.max(1, Math.floor(o.historyAmortizationHorizon));
// Pass the symmetric warm-cache burn through to the history-collapse
// gate as well. The slab gate alone got the symmetric treatment, which
// let the history gate flip a session out of image mode even when
// symmetric burn would have kept the slab gate in. Production data
// 2026-05-23 showed three-turn sessions paying cache_create every
// turn because the history gate ignored priorWarmImageTokens.
const historyGeometry = historyGateGeometry(o, opts.cols !== undefined);
const historyProfitable = (text: string, cols: number): boolean => {
// Gate with the same model profile used by the history renderer.
return isCompressionProfitableAmortized(
text, historyGeometry.cols, undefined, historyCpt, horizon,
o.priorWarmTokens, o.priorWarmImageTokens, true, historyGeometry.maxChars, historyGeometry,
);
};
// The slab needs no shield here: this path runs only when the slab did NOT
// image (it stays as text in req.system). But project instructions do.
// CLAUDE.md arrives in the FIRST user message wrapped in <system-reminder>,
Expand All @@ -1964,31 +2025,20 @@ async function runHistoryCollapseAndFinalize(
// non-collapse path already keeps <system-reminder> as text below.
const protectedPrefix = firstMessageHasSystemReminder(req.messages) ? 1 : 0;
const tuning = historyGridTuning(info);
const { messages: newMessages, info: histInfo } = await collapseHistory(
req.messages,
historyProfitable,
{
cols: historyGeometry.cols,
protectedPrefix,
reflow: o.reflow,
style: historyGeometry.style,
maxHeightPx: historyGeometry.maxHeightPx,
pageChars: historyGeometry.maxChars,
imageBudget: tuning.imageBudget,
packFill: tuning.packFill,
minFreezeStep: tuning.minFreezeStep,
},
);
// Collapses at the model geometry, degrading legible -> dense if that render
// overflows the byte budget instead of abandoning the collapse (#216).
const { messages: newMessages, info: histInfo, degradedToDense } =
await collapseHistoryWithinByteBudget(req.messages, info, o, opts, protectedPrefix, tuning);
recordFreezeStep(info.firstUserSha8, histInfo.freezeStep);
if (histInfo.freezeStep !== undefined) info.historyFreezeStep = histInfo.freezeStep;
if (histInfo.budgetTrimmed) info.historyBudgetTrimmed = true;
if (tuning.packFill) info.historyPackFill = true;
if (degradedToDense) info.historyDegradedDense = true;
// Atomic admission by weight. The collapse is one semantic group: applying
// it means every collapsed message becomes pages, so a group that does not
// fit the byte budget is not applied at all and the original text stands.
// Rendering it first and discarding it costs CPU, which is the cheap half of
// the trade: the alternative is estimating PNG size from character counts and
// being wrong on the request that mattered.
// Reaching the bail here means even the dense re-render overflowed, so no
// geometry fits and the original text is the only safe forward.
if (
histInfo.collapsedTurns > 0 &&
histInfo.collapsedImageBytes > imageByteHeadroom(info, o.maxImageBytes)
Expand Down Expand Up @@ -2538,45 +2588,24 @@ export async function transformRequest(
// protectedPrefix excludes the slab-bearing first user message — collapsing it
// would reduce slab images to [image] placeholders and destroy the cache anchor.
if (Array.isArray(req.messages) && req.messages.length > 0) {
const historyCpt = opts.charsPerToken !== undefined
? o.charsPerToken
: HISTORY_CHARS_PER_TOKEN;
const horizon = Math.max(1, Math.floor(o.historyAmortizationHorizon));
const historyGeometry = historyGateGeometry(o, opts.cols !== undefined);
const historyProfitable = (text: string, cols: number): boolean => {
// Gate with the same model profile used by the history renderer.
return isCompressionProfitableAmortized(
text, historyGeometry.cols, undefined, historyCpt, horizon,
o.priorWarmTokens, o.priorWarmImageTokens, true, historyGeometry.maxChars, historyGeometry,
);
};
const slabAnchorIdx = (req.messages ?? []).findIndex((m) => m.role === 'user');
const tuning = historyGridTuning(info);
const { messages: newMessages, info: histInfo } = await collapseHistory(
req.messages,
historyProfitable,
{
cols: historyGeometry.cols,
protectedPrefix: slabAnchorIdx >= 0 ? slabAnchorIdx + 1 : 0,
reflow: o.reflow,
style: historyGeometry.style,
maxHeightPx: historyGeometry.maxHeightPx,
pageChars: historyGeometry.maxChars,
imageBudget: tuning.imageBudget,
packFill: tuning.packFill,
minFreezeStep: tuning.minFreezeStep,
},
);
// Collapses at the model geometry, degrading legible -> dense if that render
// overflows the byte budget instead of abandoning the collapse (#216).
const { messages: newMessages, info: histInfo, degradedToDense } =
await collapseHistoryWithinByteBudget(
req.messages, info, o, opts, slabAnchorIdx >= 0 ? slabAnchorIdx + 1 : 0, tuning,
);
recordFreezeStep(info.firstUserSha8, histInfo.freezeStep);
if (histInfo.freezeStep !== undefined) info.historyFreezeStep = histInfo.freezeStep;
if (histInfo.budgetTrimmed) info.historyBudgetTrimmed = true;
if (tuning.packFill) info.historyPackFill = true;
if (degradedToDense) info.historyDegradedDense = true;
// Atomic admission by weight. The collapse is one semantic group: applying
// it means every collapsed message becomes pages, so a group that does not
// fit the byte budget is not applied at all and the original text stands.
// Rendering it first and discarding it costs CPU, which is the cheap half of
// the trade: the alternative is estimating PNG size from character counts and
// being wrong on the request that mattered.
// Reaching the bail here means even the dense re-render overflowed, so no
// geometry fits and the original text is the only safe forward.
if (
histInfo.collapsedTurns > 0 &&
histInfo.collapsedImageBytes > imageByteHeadroom(info, o.maxImageBytes)
Expand Down
106 changes: 105 additions & 1 deletion tests/image-byte-budget.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
*
* Run just this file: pnpm vitest run tests/image-byte-budget.test.ts
*/
import { beforeEach, describe, expect, it } from 'vitest';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
countNativeImageBytes,
imageByteHeadroom,
Expand Down Expand Up @@ -147,6 +147,110 @@ describe('a group that does not fit keeps its text', () => {
});
});

describe('a legible history that overflows degrades to dense before abandoning the collapse (#216)', () => {
// Since #170, misread-prone Claude ids (claude-opus-5) default to the legible
// history geometry (jetbrains-mono-14), which renders several times heavier per
// char than dense (spleen-5x8). On a long session the legible collapse can blow
// the byte budget while the dense render still fits. The old atomic-admission
// check then abandoned the collapse and forwarded the RAW history as text — a
// request larger than the render it refused, which the upstream 400s. The fix
// re-renders at dense and admits it; only a history too big for dense too still
// falls back to text.
const TURNS = 100;
const REP = 50;
const IMAGED_SLAB_CHARS = 12_000; // above minCompressChars -> slab is imaged (site: transformRequest inline)
const TINY_SLAB_CHARS = 50; // below minCompressChars -> slab stays text (site: runHistoryCollapseAndFinalize)
const HUGE_BUDGET = 100 * 1024 * 1024; // never binds; used only to measure the render size

beforeEach(() => {
resetSessionState();
process.env.PXPIPE_MODELS = 'claude-opus-5,claude-fable-5';
});
afterEach(() => {
delete process.env.PXPIPE_MODELS;
});

/** A long collapsible history behind a slab of the given size. */
function longHistory(model: string, slabChars: number): Uint8Array {
const bulk =
'commit a1b2c3d4e5f6 sha 9f8e7d6c ts 2026-07-04T14:50:50Z ratio 0.734 $30.99 Tampa-0 stock 120 reorder 40. ';
const messages: unknown[] = [];
for (let i = 0; i < TURNS; i++) {
messages.push({
role: i % 2 === 0 ? 'user' : 'assistant',
content: [{ type: 'text', text: bulk.repeat(REP) + ` turn ${i}` }],
});
}
messages.push({ role: 'user', content: [{ type: 'text', text: 'continue' }] });
return enc({
model,
max_tokens: 1024,
system: [{ type: 'text', text: 'S'.repeat(slabChars), cache_control: { type: 'ephemeral' } }],
messages,
});
}

async function render(model: string, slabChars: number, maxImageBytes: number) {
resetSessionState();
return transformRequest(longHistory(model, slabChars), { model, maxImageBytes });
}

// Both admission sites share the bug; exercise each. Site 2 is the inline path
// in transformRequest (slab imaged); site 1 is runHistoryCollapseAndFinalize
// (slab below the gate, history still collapses — the "tiny system, huge
// messages" shape).
for (const [siteLabel, slabChars] of [
['imaged-slab path', IMAGED_SLAB_CHARS],
['text-slab path', TINY_SLAB_CHARS],
] as const) {
it(`re-renders the collapse at dense instead of forwarding raw text (${siteLabel})`, async () => {
// Measure both geometries with a non-binding budget: legible must be the
// heavier of the two, or there is nothing to degrade.
const dense = (await render('claude-fable-5', slabChars, HUGE_BUDGET)).info;
const legible = (await render('claude-opus-5', slabChars, HUGE_BUDGET)).info;
expect(dense.historyReason, 'fixture must collapse at dense').toBe('collapsed');
expect(legible.historyReason, 'fixture must collapse at legible').toBe('collapsed');
expect(legible.imageBytes).toBeGreaterThan(dense.imageBytes);

// A budget between the two: legible overflows, dense fits.
const between = Math.floor((dense.imageBytes + legible.imageBytes) / 2);
const { info } = await render('claude-opus-5', slabChars, between);

// Fixed: the collapse is admitted at the dense geometry, not abandoned.
expect(info.historyReason).toBe('collapsed');
expect(info.historyDegradedDense).toBe(true);
expect(info.imageByteSkips ?? 0).toBe(0);
// The admitted render stays within the budget it was given, and matches
// the dense measurement — i.e. it really is the dense re-render, not the
// legible one squeaking under a coincidental budget.
expect(info.imageBytes).toBeLessThanOrEqual(between);
expect(info.imageBytes).toBe(dense.imageBytes);
});
}

it('leaves Fable (already dense) on the original atomic-admission bail', async () => {
// Fable renders history dense by default, so there is no legible->dense step
// to take: a budget under its render must still bail, not loop.
const dense = (await render('claude-fable-5', IMAGED_SLAB_CHARS, HUGE_BUDGET)).info;
const tight = Math.floor(dense.imageBytes / 2);
const { info } = await render('claude-fable-5', IMAGED_SLAB_CHARS, tight);
expect(info.historyReason).toBe('image_bytes');
expect(info.imageByteSkips ?? 0).toBeGreaterThan(0);
expect(info.historyDegradedDense ?? false).toBe(false);
});

it('still bails when even the dense render overflows the budget', async () => {
// A history too big for any geometry keeps its text — the atomic-admission
// guarantee must survive the degrade path.
const dense = (await render('claude-fable-5', IMAGED_SLAB_CHARS, HUGE_BUDGET)).info;
const tooTight = Math.floor(dense.imageBytes / 2); // below the dense render too
const { info } = await render('claude-opus-5', IMAGED_SLAB_CHARS, tooTight);
expect(info.historyReason).toBe('image_bytes');
expect(info.imageByteSkips ?? 0).toBeGreaterThan(0);
expect(info.historyDegradedDense ?? false).toBe(false);
});
});

describe('the caller outranks us', () => {
beforeEach(() => resetSessionState());

Expand Down