feat: add model-specific render profiles and recall evidence

This commit is contained in:
Steven Chong
2026-07-09 22:25:06 -04:00
parent bfcf15ca95
commit cd4c9efddd
54 changed files with 13941 additions and 503 deletions
+12 -10
View File
@@ -24,17 +24,19 @@ function baseModelId(model: string): string {
/** Dashboard runtime override; null = fall back to PXPIPE_MODELS env / built-in default. In-memory only. */
let runtimeModelBases: readonly string[] | null = null;
/** Built-in default scope when PXPIPE_MODELS is unset: Fable 5 (Claude) plus
* GPT 5.6. Everything else is opt-in via dashboard chips or PXPIPE_MODELS:
/** Built-in default scope when PXPIPE_MODELS is unset: Fable 5 only.
* Everything else is opt-in via dashboard chips or PXPIPE_MODELS:
* - Opus 4.7/4.8 — worse at reading imaged content (FINDINGS.md 2026-06-16:
* Opus 4.8 ~2pp arithmetic, 6/15 dense-hex vs Fable 100/100).
* - GPT 5.5 — degrades on imaged history/context.
* - Grok — pure-image exact OCR fails at production 5×8 (0/4 IDs); image+
* factsheet recovers exact IDs but the family is not default-quality for
* pxpipe yet (eval/grok-density/FACTSHEET_RESULTS.md). Keep off until that
* changes.
* Silently imaging weak readers is the wrong default. */
const DEFAULT_MODEL_BASES = ['claude-fable-5', 'gpt-5.6'];
* - GPT 5.6 Sol — direct raw-image calls at both its 6×11 profile and the
* old shared 5×8 profile scored 0/4 exact with four confabulations. Its
* exact model profile remains available for explicit opt-in and retuning.
* - Grok — shared 5×8 pure-image exact recall failed 0/4 with silent
* confabulation. Effective 9×12 and a factsheet each rescued one synthetic
* fixture, but neither has representative-workload evidence yet.
* Silently imaging weak or unvalidated readers is the wrong default. */
const DEFAULT_MODEL_BASES = ['claude-fable-5'];
function falsey(v: string): boolean {
return /^(0|false|no|off|none)$/i.test(v.trim());
@@ -42,9 +44,9 @@ function falsey(v: string): boolean {
/** PXPIPE_MODELS env / built-in default, ignoring the runtime override. One CSV
* controls every family (Claude + GPT). Resolution (read per-call so scope flips LIVE):
* - unset or empty → built-in default (Fable 5 + GPT 5.6)
* - unset or empty → built-in default (Fable 5 only)
* - `off`/`0`/`false`/... → compress nothing
* - CSV of model bases → exactly those families (e.g. `claude-fable-5,gpt-5.6`) */
* - CSV of model bases → exactly those families (e.g. `claude-fable-5,gpt-5.6-sol`) */
function envOrDefaultBases(): string[] {
// Edge-safe: `process` is undefined off-Node; `typeof` avoids a ReferenceError.
const raw = typeof process !== 'undefined' ? process.env?.PXPIPE_MODELS : undefined;
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+6 -11
View File
@@ -18,8 +18,8 @@ import {
// consumers import (pxpipe-proxy/transform → renderTextToImages), not the
// internal leaf renderer.
import { renderTextToImages } from './library.js';
import { estimateImageCount, ANTHROPIC_PIXELS_PER_TOKEN, IMAGE_COST_SAFETY_MARGIN, REPORT_CHARS_PER_TOKEN } from './transform.js';
import { openAIVisionTokens } from './openai.js';
import { estimateImageCount, REPORT_CHARS_PER_TOKEN } from './transform.js';
import { visionTokensForModel } from './openai.js';
import {
factSheetTextFromEntries,
extractFactSheetTokensAllPages,
@@ -249,17 +249,12 @@ export function parseExportArgv(
*
* - **Claude / Anthropic models** (`model.startsWith('claude')` or
* `model.includes('anthropic')`): uses Anthropic's billing formula
* `ceil(width × height / 750 × 1.10)` (the same formula and constants as
* `imageTokensForRows` in transform.ts, reusing the exported
* `ANTHROPIC_PIXELS_PER_TOKEN` / `IMAGE_COST_SAFETY_MARGIN` consts).
* - **GPT / o-series models**: delegates to `openAIVisionTokens` which uses the
* GPT-4o tile-pricing formula (85 + 170 × tiles after scaling).
* `ceil(width × height / 750 × 1.10)`, matching the live transform.
* - **OpenAI-shaped models**: delegates to the same exact-model router as the
* proxy (Grok measured pixel rate; GPT tile/patch rate).
*/
export function exportImageTokens(model: string, width: number, height: number): number {
if (model.startsWith('claude') || model.includes('anthropic')) {
return Math.ceil((width * height / ANTHROPIC_PIXELS_PER_TOKEN) * IMAGE_COST_SAFETY_MARGIN);
}
return openAIVisionTokens(model, width, height);
return visionTokensForModel(model, width, height);
}
// ---------------------------------------------------------------------------
+95 -62
View File
@@ -3,17 +3,16 @@
*
* One place to retune when a new model ships with different image tokenization,
* a different downscale threshold (max safe portrait-strip width), or a different
* max image height. Every built-in profile is BEHAVIOR-IDENTICAL to the old
* hardcoded `resolveVisionCost` + `GPT_STRIP_COLS` + `MAX_HEIGHT_PX`, so existing
* cost numbers (1190 / 1445 / 2372 / 1464 / 630 …) are unchanged.
* max image height. Unknown models preserve the conservative legacy fallback;
* named profiles may deliberately select different fonts and geometry.
*
* Retune without a code change via the PXPIPE_GPT_PROFILES env var (JSON map of
* model-id PREFIX -> partial profile; longest matching prefix wins, checked
* BEFORE the built-in table). Partial fields fall back to the built-in match, so
* you can override just one knob:
*
* PXPIPE_GPT_PROFILES='{"gpt-5.6":{"vision":{"regime":"patch","multiplier":1,"patchCap":12000},"stripCols":200,"maxHeightPx":2400}}'
* PXPIPE_GPT_PROFILES='{"gpt-5.6":{"stripCols":176}}' # widen only
* PXPIPE_GPT_PROFILES='{"gpt-5.6-sol":{"vision":{"regime":"patch","multiplier":1,"patchCap":12000},"stripCols":120,"maxHeightPx":1900}}'
* PXPIPE_GPT_PROFILES='{"gpt-5.6-sol":{"style":{"grid":true,"gridCols":4}}}'
*/
/**
@@ -26,7 +25,9 @@
*/
import {
MAX_HEIGHT_PX as ANTHROPIC_MAX_HEIGHT_PX,
DENSE_CONTENT_COLS as ANTHROPIC_STRIP_COLS,
ANTHROPIC_SLAB_COLS as ANTHROPIC_STRIP_COLS,
DEFAULT_RENDER_FONT,
type RenderFont,
} from './render.js';
export const GPT_MAX_HEIGHT_PX = 1932;
@@ -37,27 +38,37 @@ export type GptVisionCost =
| { regime: 'patch'; multiplier: number; patchCap: number };
export interface GptRenderStyle {
/** Rasterized font atlas. */
font: RenderFont;
/** Extra px beside the 5px glyph (cell width = 5 + this). */
cellWBonus?: number;
cellWBonus: number;
/** Extra px above the 8px glyph (cell height = 8 + this). */
cellHBonus?: number;
cellHBonus: number;
/** Grayscale AA atlas. Default true for production dense pages. */
aa?: boolean;
aa: boolean;
/** Faint cell guides. */
grid: boolean;
/** Vertical guide cadence when grid is enabled. */
gridCols: number;
/** Per-glyph color cycle. */
colorCycle: boolean;
/** Horizontal scale for the hard-newline marker. */
markerScale: number;
/** Render hard-newline markers in red. */
markerRed: boolean;
}
export interface GptModelProfile {
/** How OpenAI bills the rendered images as input tokens. */
vision: GptVisionCost;
/** Max portrait-strip width in COLUMNS before the API downscales (destroying
* 5px glyphs). 152 cols x 5px + 8px pad = 768px = OpenAI's shortest-side floor. */
/** Max portrait-strip width in columns. Combined with `style`, this must stay
* at or below the provider's no-resize pixel width. */
stripCols: number;
/** Max rendered image height in px. Threaded into the renderer so the gate's
* cost estimate and the actual page split agree. */
maxHeightPx: number;
/** Glyph density / spacing. Omitted fields fall through to renderer defaults
* (production 5x8: cellWBonus=0, cellHBonus=0, aa=true). Models that confabulate
* exact strings at production density set larger bonuses here. */
style?: GptRenderStyle;
/** Complete model-specific font, cell spacing, color, and marker style. */
style: GptRenderStyle;
}
/** Default downscale-safe strip width (768px). Exported as the global cols default. */
@@ -65,6 +76,17 @@ export const DEFAULT_GPT_STRIP_COLS = 152;
const C = DEFAULT_GPT_STRIP_COLS;
const H = GPT_MAX_HEIGHT_PX;
const BASE_STYLE: GptRenderStyle = {
font: DEFAULT_RENDER_FONT,
cellWBonus: 0,
cellHBonus: 0,
aa: true,
grid: false,
gridCols: 0,
colorCycle: false,
markerScale: 1,
markerRed: false,
};
/**
* Conservative fallback for unrecognized models: tile 85/170 over-states cost,
@@ -74,7 +96,16 @@ export const DEFAULT_GPT_PROFILE: GptModelProfile = {
vision: { regime: 'tile', base: 85, perTile: 170 },
stripCols: C,
maxHeightPx: H,
style: { cellWBonus: 0, cellHBonus: 0, aa: true },
style: BASE_STYLE,
};
const GPT56_SOL_PROFILE: GptModelProfile = {
vision: { regime: 'patch', multiplier: 1, patchCap: 10000 },
stripCols: 126,
maxHeightPx: H,
// Separate atlas selected by model id. Cell remains within OpenAI's 768px
// short-side floor (126 cols x 6px + padding = 764px).
style: { ...BASE_STYLE, font: 'jetbrains-mono-10' },
};
interface ProfileRule {
@@ -95,33 +126,34 @@ const BUILTIN_RULES: ProfileRule[] = [
// nano patch models: ceil(patches * 2.46), cap 1536
{
test: (m) => isMiniNanoPatch(m) && /nano/.test(m),
profile: { vision: { regime: 'patch', multiplier: 2.46, patchCap: 1536 }, stripCols: C, maxHeightPx: H },
profile: { vision: { regime: 'patch', multiplier: 2.46, patchCap: 1536 }, stripCols: C, maxHeightPx: H, style: BASE_STYLE },
},
// mini / o4-mini patch models: ceil(patches * 1.62), cap 1536
{
test: (m) => isMiniNanoPatch(m) && !/nano/.test(m),
profile: { vision: { regime: 'patch', multiplier: 1.62, patchCap: 1536 }, stripCols: C, maxHeightPx: H },
profile: { vision: { regime: 'patch', multiplier: 1.62, patchCap: 1536 }, stripCols: C, maxHeightPx: H, style: BASE_STYLE },
},
// gpt-5.6 flagship — EXPLICIT slot so the day-one retune is one line (or one env
// var). Identical to the generic 5.x rule below until the real numbers land.
// Exact Sol variant observed on production traffic. Do not match bare 5.6 or
// sibling variants (for example gpt-5.6-terra): model-specific visual tuning
// must not leak across variants merely because they share a version number.
{
test: (m) => /^gpt-5\.6/.test(m),
profile: { vision: { regime: 'patch', multiplier: 1, patchCap: 10000 }, stripCols: C, maxHeightPx: H },
test: (m) => m === 'gpt-5.6-sol' || m.startsWith('gpt-5.6-sol-'),
profile: GPT56_SOL_PROFILE,
},
// 5.x flagship (gpt-5.4/5.5/…, no -mini/-nano): patch, multiplier 1, detail:original cap
{
test: (m) => /^gpt-5\.\d/.test(m),
profile: { vision: { regime: 'patch', multiplier: 1, patchCap: 10000 }, stripCols: C, maxHeightPx: H },
profile: { vision: { regime: 'patch', multiplier: 1, patchCap: 10000 }, stripCols: C, maxHeightPx: H, style: BASE_STYLE },
},
// gpt-5 / gpt-5-chat-latest: tile 70/140
{
test: (m) => /^gpt-5/.test(m),
profile: { vision: { regime: 'tile', base: 70, perTile: 140 }, stripCols: C, maxHeightPx: H },
profile: { vision: { regime: 'tile', base: 70, perTile: 140 }, stripCols: C, maxHeightPx: H, style: BASE_STYLE },
},
// o1 / o3 reasoning: tile 75/150
{
test: (m) => /^o[13]/.test(m),
profile: { vision: { regime: 'tile', base: 75, perTile: 150 }, stripCols: C, maxHeightPx: H },
profile: { vision: { regime: 'tile', base: 75, perTile: 150 }, stripCols: C, maxHeightPx: H, style: BASE_STYLE },
},
// Claude on the Responses path (Codex-style clients). Selection is by model
@@ -137,15 +169,15 @@ const BUILTIN_RULES: ProfileRule[] = [
vision: { regime: 'tile', base: 85, perTile: 170 },
stripCols: ANTHROPIC_STRIP_COLS,
maxHeightPx: ANTHROPIC_MAX_HEIGHT_PX,
style: { cellWBonus: 0, cellHBonus: 0, aa: true },
style: { ...BASE_STYLE },
},
},
// Grok (Responses path). Production 5x8 packing maximizes pixel savings
// (measured ~1000 image tok/MPix). Exact-ID OCR is weak at that density, so
// the Responses transform keeps a verbatim fact-sheet of paths/hex/ports/
// camelCase ids next to the image (see factsheet.ts). Do NOT inflate cell
// bonuses here just to pass OCR tests — that trades away the savings.
// Grok (Responses path). Live climb 2026-07-09 on grok-4.5: 5x8 and 7x10
// confabulate exact IDs; effective 9x12 (Spleen 5x8 + 4px spacing) is the
// densest measured arm that reached 4/4 exact, 0 confab, gist+guard ok. The
// fact-sheet still rides beside images as defense in depth. Grok remains
// opt-in only; this profile applies only after an operator enables it.
{
test: (m) => /^grok-/.test(m),
profile: {
@@ -153,9 +185,9 @@ const BUILTIN_RULES: ProfileRule[] = [
// measured pixel model. Keep a conservative tile placeholder for tools
// that only read the profile struct.
vision: { regime: 'tile', base: 85, perTile: 170 },
stripCols: C,
stripCols: 84,
maxHeightPx: H,
style: { cellWBonus: 0, cellHBonus: 0, aa: true },
style: { ...BASE_STYLE, cellWBonus: 4, cellHBonus: 4 },
},
},
];
@@ -184,6 +216,14 @@ function posInt(v: unknown, fallback: number): number {
return Number.isFinite(v) && (v as number) > 0 ? Math.floor(v as number) : fallback;
}
function nonNegativeInt(v: unknown, fallback: number): number {
return Number.isFinite(v) && (v as number) >= 0 ? Math.floor(v as number) : fallback;
}
function renderFont(v: unknown, fallback: RenderFont): RenderFont {
return v === 'spleen-5x8' || v === 'jetbrains-mono-10' ? v : fallback;
}
function parseEnvProfiles(raw: string): Map<string, GptModelProfile> {
const out = new Map<string, GptModelProfile>();
if (!raw) return out;
@@ -197,18 +237,27 @@ function parseEnvProfiles(raw: string): Map<string, GptModelProfile> {
for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
if (!v || typeof v !== 'object') continue;
const key = k.toLowerCase();
const base = resolveBuiltin(key); // partial fields fall back to the built-in match
// An exact override key may be a prefix of the real model id. Resolve the
// known family explicitly so `{"gpt-5.6-sol":{...}}` inherits Sol's font
// and geometry even when a suffixed runtime id is expected.
const base = key === 'gpt-5.6-sol' ? GPT56_SOL_PROFILE : resolveBuiltin(key);
const p = v as Partial<GptModelProfile>;
const styleIn = (p as { style?: GptRenderStyle }).style;
const baseStyle = base.style ?? { cellWBonus: 0, cellHBonus: 0, aa: true };
const baseStyle = base.style;
const style: GptRenderStyle = {
cellWBonus: styleIn && Number.isFinite(styleIn.cellWBonus as number)
? Math.floor(styleIn.cellWBonus as number)
: baseStyle.cellWBonus,
cellHBonus: styleIn && Number.isFinite(styleIn.cellHBonus as number)
? Math.floor(styleIn.cellHBonus as number)
: baseStyle.cellHBonus,
font: renderFont(styleIn?.font, baseStyle.font),
cellWBonus: nonNegativeInt(styleIn?.cellWBonus, baseStyle.cellWBonus),
cellHBonus: nonNegativeInt(styleIn?.cellHBonus, baseStyle.cellHBonus),
aa: styleIn && typeof styleIn.aa === 'boolean' ? styleIn.aa : baseStyle.aa,
grid: styleIn && typeof styleIn.grid === 'boolean' ? styleIn.grid : baseStyle.grid,
gridCols: nonNegativeInt(styleIn?.gridCols, baseStyle.gridCols),
colorCycle: styleIn && typeof styleIn.colorCycle === 'boolean'
? styleIn.colorCycle
: baseStyle.colorCycle,
markerScale: posInt(styleIn?.markerScale, baseStyle.markerScale),
markerRed: styleIn && typeof styleIn.markerRed === 'boolean'
? styleIn.markerRed
: baseStyle.markerRed,
};
out.set(key, {
vision: isValidVision(p.vision) ? p.vision : base.vision,
@@ -229,26 +278,10 @@ function envProfiles(): Map<string, GptModelProfile> {
return envMap;
}
/**
* Resolve the full rendering + vision-cost profile for a model id. Env overrides
* (longest matching prefix) win over the built-in table; unknown models get the
* conservative `DEFAULT_GPT_PROFILE`.
*/
function withDefaultStyle(profile: GptModelProfile): GptModelProfile {
const d = DEFAULT_GPT_PROFILE.style!;
const st = profile.style;
return {
...profile,
style: {
cellWBonus: st?.cellWBonus ?? d.cellWBonus,
cellHBonus: st?.cellHBonus ?? d.cellHBonus,
aa: st?.aa ?? d.aa,
},
};
}
export function resolveGptProfile(model: string | null | undefined): GptModelProfile {
const m = (model ?? '').toLowerCase();
// Match applicability.ts: bracketed transport variants (for example [1m])
// do not define a different visual reader profile.
const m = (model ?? '').toLowerCase().replace(/\[[^\]]*\]/g, '');
const env = envProfiles();
if (env.size > 0) {
let best: GptModelProfile | undefined;
@@ -259,7 +292,7 @@ export function resolveGptProfile(model: string | null | undefined): GptModelPro
bestLen = k.length;
}
}
if (best) return withDefaultStyle(best);
if (best) return best;
}
return withDefaultStyle(resolveBuiltin(m));
return resolveBuiltin(m);
}
+10 -4
View File
@@ -21,8 +21,14 @@
* HistoryTurn list and the planner/renderer are shared.
*/
import { renderTextToPngs, reflow, neutralizeSentinel, type RenderedImage } from './render.js';
import { GPT_MAX_HEIGHT_PX } from './gpt-model-profiles.js';
import {
renderTextToPngs,
reflow,
neutralizeSentinel,
type RenderedImage,
type RenderStyle,
} from './render.js';
import { DEFAULT_GPT_PROFILE, GPT_MAX_HEIGHT_PX } from './gpt-model-profiles.js';
import { countTokens as o200kCountTokens } from 'gpt-tokenizer/encoding/o200k_base';
/** Portrait-strip width for GPT history images. Mirrors GPT_STRIP_COLS in
@@ -81,7 +87,7 @@ export interface GptHistoryOptions {
* into renderTextToPngs so history pages split at the same height the gate prices. */
maxHeightPx: number;
/** Glyph density from the model profile. Empty = production 5x8. */
style: { cellWBonus?: number; cellHBonus?: number; aa?: boolean };
style: RenderStyle;
/** Hard cap on GPT history image count. This is a TRUE cap, not a threshold:
* collapse the oldest completed sections until the next section would exceed
* the cap, then leave the remaining history as ordinary text. Prevents 80+
@@ -107,7 +113,7 @@ export const GPT_HISTORY_DEFAULTS: GptHistoryOptions = {
// GPT path: OpenAI's resize bounds (2048-bbox / 768 short side) permit the tall
// strip — do NOT re-link to render.ts MAX_HEIGHT_PX (Anthropic's 1568/1.15 MP clamp).
maxHeightPx: GPT_MAX_HEIGHT_PX,
style: { cellWBonus: 0, cellHBonus: 0, aa: true },
style: DEFAULT_GPT_PROFILE.style,
maxImages: GPT_HISTORY_MAX_IMAGES,
reflow: true,
};
+46 -34
View File
@@ -10,13 +10,15 @@ import {
renderTextToPngs,
reflow,
shrinkColsToContent,
renderCellWidth,
renderCellHeight,
PAD_X,
CELL_W,
PAD_Y,
READABLE_CHARS_PER_IMAGE,
type RenderedImage,
} from './render.js';
import {
resolveGptProfile,
DEFAULT_GPT_STRIP_COLS,
type GptVisionCost,
} from './gpt-model-profiles.js';
import { bytesToBase64 } from './png.js';
@@ -218,7 +220,7 @@ interface OpenAIResolvedOptions {
compress: boolean;
compressTools: boolean;
minCompressChars: number;
cols: number;
cols?: number;
multiCol: number;
charsPerToken: number;
reflow: boolean;
@@ -230,7 +232,7 @@ const DEFAULTS: OpenAIResolvedOptions = {
compress: true,
compressTools: true,
minCompressChars: 2000,
cols: DEFAULT_GPT_STRIP_COLS,
cols: undefined,
multiCol: 1,
charsPerToken: 4, // conservative OpenAI default; override after telemetry
reflow: true,
@@ -242,7 +244,7 @@ function resolveOptions(opts: TransformOptions): OpenAIResolvedOptions {
compress: opts.compress ?? DEFAULTS.compress,
compressTools: opts.compressTools ?? DEFAULTS.compressTools,
minCompressChars: opts.minCompressChars ?? DEFAULTS.minCompressChars,
cols: opts.cols ?? DEFAULTS.cols,
cols: opts.cols,
multiCol: opts.multiCol ?? DEFAULTS.multiCol,
charsPerToken: opts.charsPerToken ?? DEFAULTS.charsPerToken,
reflow: opts.reflow ?? DEFAULTS.reflow,
@@ -502,10 +504,25 @@ function evalOpenAIGate(
cols: number,
charsPerToken: number,
): { imageTokens: number; textTokens: number; profitable: boolean } {
const stripW = 2 * PAD_X + cols * CELL_W;
const estImages = estimateImageCount(renderedText, cols, 1);
const perStrip = visionTokensForModel(model, stripW, resolveGptProfile(model).maxHeightPx);
const imageTokens = estImages * perStrip;
const profile = resolveGptProfile(model);
const style = profile.style;
const cellW = renderCellWidth(style);
const cellH = renderCellHeight(style);
const stripW = 2 * PAD_X + cols * cellW;
const maxLines = Math.max(1, Math.floor((profile.maxHeightPx - 2 * PAD_Y) / cellH));
const maxCharsPerImage = Math.min(
READABLE_CHARS_PER_IMAGE,
Math.max(1, cols) * maxLines,
);
const estImages = estimateImageCount(
renderedText,
cols,
1,
maxCharsPerImage,
maxLines,
);
const perFullStrip = visionTokensForModel(model, stripW, profile.maxHeightPx);
const imageTokens = estImages * perFullStrip;
const textTokens = renderedText.length / charsPerToken;
return { imageTokens, textTokens, profitable: imageTokens < textTokens };
}
@@ -541,21 +558,6 @@ function gptTextTokens(text: string): number {
/** Vision-token cost of the rendered images, summed over their real dims —
* what GPT actually bills as input for the slab pxpipe imaged. */
function profileRenderStyle(
model: string | null | undefined,
): { cellWBonus?: number; cellHBonus?: number; aa?: boolean; grid?: boolean; colorCycle?: boolean } {
const st = resolveGptProfile(model).style as
| { cellWBonus?: number; cellHBonus?: number; aa?: boolean; grid?: boolean; colorCycle?: boolean }
| undefined;
return {
cellWBonus: st?.cellWBonus ?? 0,
cellHBonus: st?.cellHBonus ?? 0,
aa: st?.aa ?? true,
...(st?.grid ? { grid: true } : {}),
...(st?.colorCycle ? { colorCycle: true } : {}),
};
}
function gptImageTokens(model: string, images: RenderedImage[]): number {
let n = 0;
for (const img of images) n += visionTokensForModel(model, img.width, img.height);
@@ -705,7 +707,12 @@ export async function transformOpenAIChatCompletions(
: '';
const header = CHAT_HEADER.replace('\n====', reflowNote + '\n====');
const renderedText = header + combined;
const cols = Math.min(shrinkColsToContent(renderedText, o.cols), resolveGptProfile(req.model).stripCols);
const profile = resolveGptProfile(req.model);
const maxCols = o.cols ?? profile.stripCols;
const cols = Math.min(
shrinkColsToContent(renderedText, maxCols, profile.style.markerScale, profile.style.font),
profile.stripCols,
);
const gate = evalOpenAIGate(req.model, renderedText, cols, o.charsPerToken);
info.gateEval = {
@@ -722,7 +729,7 @@ export async function transformOpenAIChatCompletions(
return { body, info };
}
const images = await renderTextToPngs(renderedText, cols, profileRenderStyle(req.model), resolveGptProfile(req.model).maxHeightPx);
const images = await renderTextToPngs(renderedText, cols, profile.style, profile.maxHeightPx);
if (images.length === 0) {
info.reason = 'render_empty';
return { body, info };
@@ -782,9 +789,9 @@ export async function transformOpenAIChatCompletions(
const plan = await planGptCollapse(turns, firstUserIdx + 1, profitable, {
...o.gptHistory,
reflow: o.reflow,
cols: o.gptHistory?.cols ?? resolveGptProfile(req.model).stripCols,
maxHeightPx: o.gptHistory?.maxHeightPx ?? resolveGptProfile(req.model).maxHeightPx,
style: o.gptHistory?.style ?? profileRenderStyle(req.model),
cols: o.gptHistory?.cols ?? profile.stripCols,
maxHeightPx: o.gptHistory?.maxHeightPx ?? profile.maxHeightPx,
style: o.gptHistory?.style ?? profile.style,
});
foldGptHistory(info, req.model, plan);
const allImages = [...plan.images, ...plan.imagesAfter];
@@ -913,7 +920,12 @@ export async function transformOpenAIResponses(
: '';
const header = RESPONSES_HEADER.replace('\n====', reflowNote + '\n====');
const renderedText = header + combined;
const cols = Math.min(shrinkColsToContent(renderedText, o.cols), resolveGptProfile(req.model).stripCols);
const profile = resolveGptProfile(req.model);
const maxCols = o.cols ?? profile.stripCols;
const cols = Math.min(
shrinkColsToContent(renderedText, maxCols, profile.style.markerScale, profile.style.font),
profile.stripCols,
);
const gate = evalOpenAIGate(req.model, renderedText, cols, o.charsPerToken);
info.gateEval = {
@@ -930,7 +942,7 @@ export async function transformOpenAIResponses(
return { body, info };
}
const images = await renderTextToPngs(renderedText, cols, profileRenderStyle(req.model), resolveGptProfile(req.model).maxHeightPx);
const images = await renderTextToPngs(renderedText, cols, profile.style, profile.maxHeightPx);
if (images.length === 0) {
info.reason = 'render_empty';
return { body, info };
@@ -1021,9 +1033,9 @@ export async function transformOpenAIResponses(
const plan = await planGptCollapse(turns, firstUserIdx + 1, profitable, {
...o.gptHistory,
reflow: o.reflow,
cols: o.gptHistory?.cols ?? resolveGptProfile(req.model).stripCols,
maxHeightPx: o.gptHistory?.maxHeightPx ?? resolveGptProfile(req.model).maxHeightPx,
style: o.gptHistory?.style ?? profileRenderStyle(req.model),
cols: o.gptHistory?.cols ?? profile.stripCols,
maxHeightPx: o.gptHistory?.maxHeightPx ?? profile.maxHeightPx,
style: o.gptHistory?.style ?? profile.style,
});
foldGptHistory(info, req.model, plan);
const allImages = [...plan.images, ...plan.imagesAfter];
+213 -50
View File
@@ -8,6 +8,7 @@
import {
ATLAS_CELL_W,
ATLAS_CELL_H,
ATLAS_ASCENT,
ATLAS_PIXELS,
ATLAS_OFFSETS,
ATLAS_WIDE_FLAGS,
@@ -16,13 +17,128 @@ import {
import {
ATLAS_GRAY_CELL_W,
ATLAS_GRAY_CELL_H,
ATLAS_GRAY_ASCENT,
ATLAS_GRAY_PIXELS,
ATLAS_GRAY_OFFSETS,
ATLAS_GRAY_WIDE_FLAGS,
atlasGrayRank,
} from './atlas-gray.js';
import {
ATLAS_CELL_W as JBM10_CELL_W,
ATLAS_CELL_H as JBM10_CELL_H,
ATLAS_ASCENT as JBM10_ASCENT,
ATLAS_PIXELS as JBM10_PIXELS,
ATLAS_OFFSETS as JBM10_OFFSETS,
ATLAS_WIDE_FLAGS as JBM10_WIDE_FLAGS,
atlasRank as jbMono10Rank,
} from './atlas-jbmono10.js';
import {
ATLAS_GRAY_CELL_W as JBM10_GRAY_CELL_W,
ATLAS_GRAY_CELL_H as JBM10_GRAY_CELL_H,
ATLAS_GRAY_ASCENT as JBM10_GRAY_ASCENT,
ATLAS_GRAY_PIXELS as JBM10_GRAY_PIXELS,
ATLAS_GRAY_OFFSETS as JBM10_GRAY_OFFSETS,
ATLAS_GRAY_WIDE_FLAGS as JBM10_GRAY_WIDE_FLAGS,
atlasGrayRank as jbMono10GrayRank,
} from './atlas-gray-jbmono10.js';
import { encodeGrayPng, encodeRgbPng } from './png.js';
export type RenderFont = 'spleen-5x8' | 'jetbrains-mono-10';
export const DEFAULT_RENDER_FONT: RenderFont = 'spleen-5x8';
interface BitAtlas {
cellW: number;
cellH: number;
ascent: number;
pixels: Uint8Array;
offsets: Uint32Array;
wideFlags: Uint8Array;
rank: (codepoint: number) => number;
}
interface GrayAtlas {
cellW: number;
cellH: number;
ascent: number;
pixels: Uint8Array;
offsets: Uint32Array;
wideFlags: Uint8Array;
rank: (codepoint: number) => number;
}
interface AtlasSet {
bit: BitAtlas;
gray: GrayAtlas;
}
const DEFAULT_ATLAS: AtlasSet = {
bit: {
cellW: ATLAS_CELL_W,
cellH: ATLAS_CELL_H,
ascent: ATLAS_ASCENT,
pixels: ATLAS_PIXELS,
offsets: ATLAS_OFFSETS,
wideFlags: ATLAS_WIDE_FLAGS,
rank: atlasRank,
},
gray: {
cellW: ATLAS_GRAY_CELL_W,
cellH: ATLAS_GRAY_CELL_H,
ascent: ATLAS_GRAY_ASCENT,
pixels: ATLAS_GRAY_PIXELS,
offsets: ATLAS_GRAY_OFFSETS,
wideFlags: ATLAS_GRAY_WIDE_FLAGS,
rank: atlasGrayRank,
},
};
const JBM10_ATLAS: AtlasSet = {
bit: {
cellW: JBM10_CELL_W,
cellH: JBM10_CELL_H,
ascent: JBM10_ASCENT,
pixels: JBM10_PIXELS,
offsets: JBM10_OFFSETS,
wideFlags: JBM10_WIDE_FLAGS,
rank: jbMono10Rank,
},
gray: {
cellW: JBM10_GRAY_CELL_W,
cellH: JBM10_GRAY_CELL_H,
ascent: JBM10_GRAY_ASCENT,
pixels: JBM10_GRAY_PIXELS,
offsets: JBM10_GRAY_OFFSETS,
wideFlags: JBM10_GRAY_WIDE_FLAGS,
rank: jbMono10GrayRank,
},
};
function atlasSet(font: RenderFont | undefined): AtlasSet {
return font === 'jetbrains-mono-10' ? JBM10_ATLAS : DEFAULT_ATLAS;
}
function bitGlyph(codepoint: number, font: RenderFont | undefined): { atlas: BitAtlas; rank: number } | null {
const selected = atlasSet(font).bit;
const rank = selected.rank(codepoint);
if (rank >= 0) return { atlas: selected, rank };
if (selected !== DEFAULT_ATLAS.bit) {
const fallbackRank = DEFAULT_ATLAS.bit.rank(codepoint);
if (fallbackRank >= 0) return { atlas: DEFAULT_ATLAS.bit, rank: fallbackRank };
}
return null;
}
function grayGlyph(codepoint: number, font: RenderFont | undefined): { atlas: GrayAtlas; rank: number } | null {
const selected = atlasSet(font).gray;
const rank = selected.rank(codepoint);
if (rank >= 0) return { atlas: selected, rank };
if (selected !== DEFAULT_ATLAS.gray) {
const fallbackRank = DEFAULT_ATLAS.gray.rank(codepoint);
if (fallbackRank >= 0) return { atlas: DEFAULT_ATLAS.gray, rank: fallbackRank };
}
return null;
}
/** Page-height ceiling. Measured (2026-07-01, count_tokens sweep, claude-sonnet-4-5 — see
* /tmp/pxexp/LEVER1-findings.md): the API downscales any image to fit BOTH long-edge ≤1568
* AND ~1.15 MP (≈1,143,750 px), then bills ≈ px/750 (≈1525 tok cap, applied per-image).
@@ -40,9 +156,11 @@ export const DENSE_CONTENT_COLS = 312;
/** Bare 5×8 cell (no padding). A/B showed 5×8 beats 7×10 on dense JSON (4/5 vs 3/5 reads, 42% fewer tokens).
* Revert to {cellWBonus:2, cellHBonus:2} if misread rates rise. */
export const DENSE_RENDER_STYLE: RenderStyle = { cellWBonus: 0, cellHBonus: 0, aa: true };
/** Anthropic static slab uses the same measured 312-column no-resize geometry. */
export const ANTHROPIC_SLAB_COLS = DENSE_CONTENT_COLS;
/** Default columns for the static slab. 312 × 5 px + 8 px pad = 1568 px — exactly the API's
* long-edge bound (313 cols = 1573 px would trigger a 0.997× resample, blurring every glyph). */
const DEFAULT_COLS = 312;
const DEFAULT_COLS = ANTHROPIC_SLAB_COLS;
/** Horizontal padding (left + right each), px. Exported for transform.ts token-cost math. */
export const PAD_X = 4;
/** Vertical padding (top + bottom each), px. Exported for transform.ts token-cost math. */
@@ -72,6 +190,8 @@ export interface RenderedImage {
/** Optional render-time styling. All fields unset = production default 5×8 cell.
* Eval harness overrides per variant to A/B cell sizes and structure aids. */
export interface RenderStyle {
/** Rasterized font atlas. Alternate atlases fall back to Spleen/Unifont for missing Unicode. */
font?: RenderFont;
/** Draw faint grey grid rules onto background pixels (zero pixel cost). */
grid?: boolean;
/** Draw a vertical grid rule every N columns. 0/unset = row rules only. */
@@ -84,7 +204,7 @@ export interface RenderStyle {
cellHBonus?: number;
/** Extra blank columns beside the 5px glyph (cell width = 5 + this). Negative overlaps glyphs. Unset = DEFAULT_CELL_W_BONUS. */
cellWBonus?: number;
/** Use the AA grayscale atlas (atlas-gray.ts). EVAL-ONLY; default 1-bit path is unchanged. */
/** Use the AA grayscale companion atlas. */
aa?: boolean;
/** Cycle palette colors per glyph for per-character boundary cues. Forces RGB output. Composes with aa. */
colorCycle?: boolean;
@@ -93,16 +213,30 @@ export interface RenderStyle {
colorByRole?: boolean;
}
export function renderCellWidth(style: RenderStyle = {}): number {
const atlas = style.aa === true ? atlasSet(style.font).gray : atlasSet(style.font).bit;
return Math.max(1, atlas.cellW + Math.floor(style.cellWBonus ?? DEFAULT_CELL_W_BONUS));
}
export function renderCellHeight(style: RenderStyle = {}): number {
const atlas = style.aa === true ? atlasSet(style.font).gray : atlasSet(style.font).bit;
return atlas.cellH + Math.max(0, Math.floor(style.cellHBonus ?? DEFAULT_CELL_H_BONUS));
}
// --- column-aware wrapping -------------------------------------------------
/** Visual width of a codepoint in cells (1 = Latin, 2 = East Asian Wide).
* Missing codepoints advance 1 cell so wrap math stays stable. */
function cellsFor(codepoint: number, markerScale: number = 1): number {
function cellsFor(
codepoint: number,
markerScale: number = 1,
font: RenderFont = DEFAULT_RENDER_FONT,
): number {
// Enlarged ↵ occupies markerScale cells of wrap budget instead of 1.
if (codepoint === NL_SENTINEL_CP && markerScale > 1) return markerScale;
const rank = atlasRank(codepoint);
if (rank < 0) return 1;
return ATLAS_WIDE_FLAGS[rank] === 1 ? 2 : 1;
const glyph = bitGlyph(codepoint, font);
if (!glyph) return 1;
return glyph.atlas.wideFlags[glyph.rank] === 1 ? 2 : 1;
}
const TAB_WIDTH = 4; // standard 4-space tab stops (logs, code, tool output are all 4-oriented)
@@ -251,21 +385,30 @@ export function expandTabsInLine(line: string): string {
}
/** Visual width of a line in cells. Wide CJK = 2; enlarged ↵ = markerScale. */
export function measureLineCols(line: string, markerScale: number = 1): number {
export function measureLineCols(
line: string,
markerScale: number = 1,
font: RenderFont = DEFAULT_RENDER_FONT,
): number {
let w = 0;
for (const ch of line) w += cellsFor(ch.codePointAt(0)!, markerScale);
for (const ch of line) w += cellsFor(ch.codePointAt(0)!, markerScale, font);
return w;
}
/** Always renders at full canvas width. Signature kept for transform.ts compatibility; returns cols unchanged. */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export function shrinkColsToContent(text: string, cols: number, markerScale: number = 1): number {
export function shrinkColsToContent(
text: string,
cols: number,
markerScale: number = 1,
font: RenderFont = DEFAULT_RENDER_FONT,
): number {
// Real content-width measurement — delegates to measureContentCols (was historically a
// no-op stub). The proxy's cost gate AND renderer both call this, so sizing the canvas to
// the widest line here makes the proxy match the SDK/export path (renderTextToImages),
// which already uses measureContentCols. Never narrows below the widest line ⇒ row count
// (and thus image/paging count) is unchanged; only the canvas WIDTH (pixel cost) drops.
return measureContentCols(text, cols, markerScale);
return measureContentCols(text, cols, markerScale, font);
}
/**
@@ -276,13 +419,18 @@ export function shrinkColsToContent(text: string, cols: number, markerScale: num
* deterministic width → cache-prefix-safe. Tabs are expanded so the measured width
* matches what the renderer actually lays out.
*/
export function measureContentCols(text: string, maxCols: number, markerScale: number = 1): number {
export function measureContentCols(
text: string,
maxCols: number,
markerScale: number = 1,
font: RenderFont = DEFAULT_RENDER_FONT,
): number {
const cap = Math.max(1, maxCols | 0);
let widest = 1;
let start = 0;
for (let i = 0; i <= text.length; i++) {
if (i === text.length || text[i] === '\n') {
const w = measureLineCols(expandTabsInLine(text.slice(start, i)), markerScale);
const w = measureLineCols(expandTabsInLine(text.slice(start, i)), markerScale, font);
if (w > widest) widest = w;
if (widest >= cap) return cap;
start = i + 1;
@@ -291,7 +439,12 @@ export function measureContentCols(text: string, maxCols: number, markerScale: n
return Math.min(cap, widest);
}
export function wrapLines(text: string, cols: number, markerScale: number = 1): string[] {
export function wrapLines(
text: string,
cols: number,
markerScale: number = 1,
font: RenderFont = DEFAULT_RENDER_FONT,
): string[] {
const out: string[] = [];
const minified = minifyForRender(text);
for (const rawWithTabs of minified.split('\n')) {
@@ -306,7 +459,7 @@ export function wrapLines(text: string, cols: number, markerScale: number = 1):
// ↵ is treated as an inline glyph — it never forces a row break.
for (const ch of raw) {
const cp = ch.codePointAt(0)!;
const w = cellsFor(cp, markerScale);
const w = cellsFor(cp, markerScale, font);
if (curCols + w > cols) {
out.push(cur);
cur = ch;
@@ -363,20 +516,23 @@ function blitGlyph(
x: number,
y: number,
codepoint: number,
font: RenderFont = DEFAULT_RENDER_FONT,
markerMask: Uint8Array | null = null,
): number {
const rank = atlasRank(codepoint);
if (rank < 0) return 0;
const wide = ATLAS_WIDE_FLAGS[rank] === 1;
const srcW = wide ? 2 * ATLAS_CELL_W : ATLAS_CELL_W;
const glyph = bitGlyph(codepoint, font);
if (!glyph) return 0;
const { atlas, rank } = glyph;
const wide = atlas.wideFlags[rank] === 1;
const srcW = wide ? 2 * atlas.cellW : atlas.cellW;
// ATLAS_OFFSETS is a bit offset (MSB-first packing). Pixel (gx,gy): byte = bitIdx>>>3, bit = 7-(bitIdx&7).
const srcOff = ATLAS_OFFSETS[rank]!;
for (let gy = 0; gy < ATLAS_CELL_H; gy++) {
const dstRow = (y + gy) * fbW + x;
const srcOff = atlas.offsets[rank]!;
const yOffset = atlasSet(font).bit.ascent - atlas.ascent;
for (let gy = 0; gy < atlas.cellH; gy++) {
const dstRow = (y + yOffset + gy) * fbW + x;
const bitRowStart = srcOff + gy * srcW;
for (let gx = 0; gx < srcW; gx++) {
const bitIdx = bitRowStart + gx;
const byte = ATLAS_PIXELS[bitIdx >>> 3]!;
const byte = atlas.pixels[bitIdx >>> 3]!;
const bit = (byte >>> (7 - (bitIdx & 7))) & 1;
if (bit) {
fb[dstRow + gx] = 255; // glyphs never overlap in grid layout; set unconditionally
@@ -397,18 +553,21 @@ function blitGlyphGray(
x: number,
y: number,
codepoint: number,
font: RenderFont = DEFAULT_RENDER_FONT,
): number {
const rank = atlasGrayRank(codepoint);
if (rank < 0) return 0;
const wide = ATLAS_GRAY_WIDE_FLAGS[rank] === 1;
const srcW = wide ? 2 * ATLAS_GRAY_CELL_W : ATLAS_GRAY_CELL_W;
const glyph = grayGlyph(codepoint, font);
if (!glyph) return 0;
const { atlas, rank } = glyph;
const wide = atlas.wideFlags[rank] === 1;
const srcW = wide ? 2 * atlas.cellW : atlas.cellW;
// ATLAS_GRAY_OFFSETS is a byte offset (1 byte/pixel, unlike the bit-packed 1-bit atlas).
const srcOff = ATLAS_GRAY_OFFSETS[rank]!;
for (let gy = 0; gy < ATLAS_GRAY_CELL_H; gy++) {
const dstRow = (y + gy) * fbW + x;
const srcOff = atlas.offsets[rank]!;
const yOffset = atlasSet(font).gray.ascent - atlas.ascent;
for (let gy = 0; gy < atlas.cellH; gy++) {
const dstRow = (y + yOffset + gy) * fbW + x;
const srcRow = srcOff + gy * srcW;
for (let gx = 0; gx < srcW; gx++) {
const coverage = ATLAS_GRAY_PIXELS[srcRow + gx]!;
const coverage = atlas.pixels[srcRow + gx]!;
if (coverage > 0) {
const idx = dstRow + gx;
if (coverage > fb[idx]!) fb[idx] = coverage;
@@ -429,19 +588,22 @@ function blitGlyphScaled(
y: number,
codepoint: number,
scaleX: number,
font: RenderFont = DEFAULT_RENDER_FONT,
): number {
const rank = atlasRank(codepoint);
if (rank < 0) return 0;
const wide = ATLAS_WIDE_FLAGS[rank] === 1;
const srcW = wide ? 2 * ATLAS_CELL_W : ATLAS_CELL_W;
const srcOff = ATLAS_OFFSETS[rank]!;
for (let gy = 0; gy < ATLAS_CELL_H; gy++) {
const py = y + gy;
const glyph = bitGlyph(codepoint, font);
if (!glyph) return 0;
const { atlas, rank } = glyph;
const wide = atlas.wideFlags[rank] === 1;
const srcW = wide ? 2 * atlas.cellW : atlas.cellW;
const srcOff = atlas.offsets[rank]!;
const yOffset = atlasSet(font).bit.ascent - atlas.ascent;
for (let gy = 0; gy < atlas.cellH; gy++) {
const py = y + yOffset + gy;
if (py >= fbH) break;
const bitRowStart = srcOff + gy * srcW;
for (let gx = 0; gx < srcW; gx++) {
const bitIdx = bitRowStart + gx;
const byte = ATLAS_PIXELS[bitIdx >>> 3]!;
const byte = atlas.pixels[bitIdx >>> 3]!;
if (((byte >>> (7 - (bitIdx & 7))) & 1) === 0) continue;
for (let sx = 0; sx < scaleX; sx++) {
const px = x + gx * scaleX + sx;
@@ -497,18 +659,19 @@ export async function renderChunkToPng(
slotText?: string,
): Promise<RenderedImage> {
const useAA = style.aa === true;
const atlasH = useAA ? ATLAS_GRAY_CELL_H : ATLAS_CELL_H;
const atlasW = useAA ? ATLAS_GRAY_CELL_W : ATLAS_CELL_W;
const selected = atlasSet(style.font);
const atlasH = useAA ? selected.gray.cellH : selected.bit.cellH;
const atlasW = useAA ? selected.gray.cellW : selected.bit.cellW;
const markerScale = Math.max(1, Math.floor(style.markerScale ?? 1));
const cellH = atlasH + Math.max(0, Math.floor(style.cellHBonus ?? DEFAULT_CELL_H_BONUS));
const cellW = Math.max(1, atlasW + Math.floor(style.cellWBonus ?? DEFAULT_CELL_W_BONUS));
const lines = wrapLines(text, cols, markerScale);
const cellH = renderCellHeight(style);
const cellW = renderCellWidth(style);
const lines = wrapLines(text, cols, markerScale, style.font);
// Slot string carries role attribution by position. It is width-identical to
// `text`, so wrapLines splits it into the exact same rows — fitSlotLines[r] aligns
// codepoint-for-codepoint with fitLines[r]. Only built when coloring is on.
const slotLines: string[] | null =
style.colorByRole === true && slotText !== undefined
? wrapLines(slotText, cols, markerScale)
? wrapLines(slotText, cols, markerScale, style.font)
: null;
const maxLines = Math.max(1, Math.floor((maxHeightPx - 2 * PAD_Y) / cellH));
@@ -567,7 +730,7 @@ export async function renderChunkToPng(
: (glyphIndex % GLYPH_PALETTE.length) + 1; // 0 reserved for background in colorMask
let advance: number;
if (isMarker && markerScale > 1) {
advance = blitGlyphScaled(fb, markerMask, width, height, baseX, baseY, codepoint, markerScale);
advance = blitGlyphScaled(fb, markerMask, width, height, baseX, baseY, codepoint, markerScale, style.font);
if (colorMask) {
for (let gy = 0; gy < atlasH; gy++) {
const py = baseY + gy;
@@ -581,7 +744,7 @@ export async function renderChunkToPng(
}
}
} else if (useAA) {
advance = blitGlyphGray(fb, width, baseX, baseY, codepoint);
advance = blitGlyphGray(fb, width, baseX, baseY, codepoint, style.font);
if (colorMask && advance > 0) {
const srcW = advance * atlasW;
for (let gy = 0; gy < atlasH; gy++) {
@@ -596,7 +759,7 @@ export async function renderChunkToPng(
}
}
} else {
advance = blitGlyph(fb, width, baseX, baseY, codepoint, isMarker ? markerMask : null);
advance = blitGlyph(fb, width, baseX, baseY, codepoint, style.font, isMarker ? markerMask : null);
if (colorMask && advance > 0) {
const srcW = advance * atlasW;
for (let gy = 0; gy < atlasH; gy++) {
@@ -694,13 +857,13 @@ export async function renderTextToPngsWithCharLimit(
slotText?: string,
): Promise<RenderedImage[]> {
const markerScale = Math.max(1, Math.floor(style.markerScale ?? 1));
const cellH = ATLAS_CELL_H + Math.max(0, Math.floor(style.cellHBonus ?? DEFAULT_CELL_H_BONUS));
const lines = wrapLines(text, cols, markerScale);
const cellH = renderCellHeight(style);
const lines = wrapLines(text, cols, markerScale, style.font);
// Width-identical slot rows align 1:1 with `lines`; pages are contiguous slices,
// so the same index range gives each page its slot half. Only built when coloring.
const slotLines: string[] | null =
style.colorByRole === true && slotText !== undefined
? wrapLines(slotText, cols, markerScale)
? wrapLines(slotText, cols, markerScale, style.font)
: null;
const hardLinesPerImg = Math.max(1, Math.floor((maxHeightPx - 2 * PAD_Y) / cellH));
// Dense pages (DENSE_CONTENT_CHARS_PER_IMAGE) fill the full 1932 px height;
+6 -3
View File
@@ -32,6 +32,7 @@ import {
DENSE_CONTENT_CHARS_PER_IMAGE,
DENSE_CONTENT_COLS,
DENSE_RENDER_STYLE,
ANTHROPIC_SLAB_COLS,
renderTextToPngsWithCharLimit,
} from './render.js';
import { factSheetText } from './factsheet.js';
@@ -134,8 +135,8 @@ const DEFAULTS: Required<TransformOptions> = {
minToolResultChars: 6000,
// system field rejects images (400 system.N.type: Input should be 'text') —
// images always go into the first user message.
// 313 cols × 5 px + 8 px pad = 1573 px slab width (under 2000 px ceiling).
cols: 313,
// 312 cols × 5 px + 8 px pad = 1568 px (Anthropic no-resize edge).
cols: ANTHROPIC_SLAB_COLS,
maxImagesPerToolResult: 10,
charsPerToken: 4,
historyAmortizationHorizon: 1,
@@ -1097,10 +1098,12 @@ export function estimateImageCount(
cols: number,
numCols: number = 1,
maxCharsPerImage: number = READABLE_CHARS_PER_IMAGE,
maxLinesPerColumn: number = LINES_PER_IMAGE,
): number {
const n = Math.max(1, numCols | 0);
const readableLinesPerCol = Math.max(1, Math.floor(maxCharsPerImage / Math.max(1, cols)));
const linesPerImage = Math.min(LINES_PER_IMAGE, readableLinesPerCol) * n;
const hardLinesPerCol = Math.max(1, Math.floor(maxLinesPerColumn));
const linesPerImage = Math.min(hardLinesPerCol, readableLinesPerCol) * n;
const charBudget = Math.max(1, maxCharsPerImage * n);
if (typeof textOrLen === 'number') {
// Back-compat shim — numeric arg gets the looser chars-based estimate.