From 6ab4d6ef2801a61ab13da319be21d832689cfbce Mon Sep 17 00:00:00 2001 From: Danil Silantyev Date: Sat, 4 Jul 2026 14:02:05 +0700 Subject: [PATCH 1/5] fix(vision): patch-based Anthropic image-token model + align default cols Anthropic no longer bills images by (width*height)/750; the current docs document a 28-px PATCH model: ceil(w/28)*ceil(h/28) visual tokens, after downscaling to the model tier's long-edge / token-budget limits (standard 1568/1568, high-res 2576/4784 for Fable 5 / Mythos 5 / Opus 4.8 / Opus 4.7 / Sonnet 5). /750 was a ~4-5% continuous approximation of that same 28^2 grid and ignored the downscale, so it grossly overcharged large images. - new src/core/anthropic-vision.ts: patchTokens(), anthropicVisionProfile(model), anthropicVisionTokens(model,w,h) with the exact documented downscale (matches Anthropic's reference count_image_tokens and its worked cost table) - gate (imageTokensForRows) now counts per-image 28-px patches; the 10% safety bias is renamed ANTHROPIC_GATE_MARGIN and kept ONLY at the gate (the documented formula in anthropic-vision.ts carries no margin). pxpipe pages are <=1568x728 so no tier downscale applies; net gate shift ~4% - export (exportImageTokens) routes claude models through anthropicVisionTokens (accurate, un-margined reporting) - remove ANTHROPIC_PIXELS_PER_TOKEN / IMAGE_COST_SAFETY_MARGIN (internal only) - transform DEFAULTS.cols 313 -> 312 so the slab is 1568 px (== render.ts MAX_WIDTH_PX; 313 -> 1573 px overshot the cap) - fix shrinkColsToContent's stale 'no-op / returns cols unchanged' docstring (it delegates to measureContentCols) - tests/anthropic-vision.test.ts covers the patch math, tiers, and downscale; export tests updated from the /750 numbers to the patch model Full suite green (638); typecheck + build green. --- src/core/anthropic-vision.ts | 110 +++++++++++++++++++++++++++++++++ src/core/transform.ts | 34 +++++----- tests/anthropic-vision.test.ts | 84 +++++++++++++++++++++++++ tests/export.test.ts | 39 +++++++----- 4 files changed, 234 insertions(+), 33 deletions(-) create mode 100644 src/core/anthropic-vision.ts create mode 100644 tests/anthropic-vision.test.ts diff --git a/src/core/anthropic-vision.ts b/src/core/anthropic-vision.ts new file mode 100644 index 00000000..97eb0c36 --- /dev/null +++ b/src/core/anthropic-vision.ts @@ -0,0 +1,110 @@ +/** + * Anthropic image / vision INPUT-TOKEN cost model. + * + * Anthropic bills images by 28×28-pixel PATCHES, not by a pixel ratio: an image + * costs `⌈width/28⌉ × ⌈height/28⌉` visual tokens, computed AFTER the image is + * downscaled to fit the model tier's long-edge and visual-token limits. + * (The older `(width×height)/750` figure was a ~4–5% continuous approximation of + * this same 28²=784 px²/patch grid; it is no longer the documented formula.) + * https://platform.claude.com/docs/en/build-with-claude/vision + * https://platform.claude.com/docs/en/build-with-claude/vision-coordinates + * + * This module is the single source of truth for that math. It is the *documented + * provider formula* only — any gate conservatism (safety margin) lives at the + * gate, not here, so this stays honest about what Anthropic actually charges. + */ + +/** One visual token per 28×28-pixel patch. Also the Qwen2-VL grid; NOT OpenAI's + * 32-px patch / 512-px tile model — keep those on the OpenAI path only. */ +export const ANTHROPIC_PATCH_PX = 28; + +export interface AnthropicVisionProfile { + readonly tier: 'high-res' | 'standard'; + /** Neither side may exceed this after downscale (px). */ + readonly maxLongEdge: number; + /** ⌈w/28⌉×⌈h/28⌉ may not exceed this after downscale (visual tokens). */ + readonly maxVisualTokens: number; +} + +/** Model bases on Anthropic's high-resolution tier (max long edge 2576 px, max + * 4784 visual tokens). Everything else is standard (1568 px / 1568 tokens). + * Source: the Vision docs resolution-tier table. */ +const HIGH_RES_BASES = [ + 'claude-fable-5', + 'claude-mythos-5', + 'claude-opus-4-8', + 'claude-opus-4-7', + 'claude-sonnet-5', +] as const; + +const HIGH_RES: AnthropicVisionProfile = { tier: 'high-res', maxLongEdge: 2576, maxVisualTokens: 4784 }; +const STANDARD: AnthropicVisionProfile = { tier: 'standard', maxLongEdge: 1568, maxVisualTokens: 1568 }; + +/** Resolve a model's vision tier. Unknown/blank models fall back to the + * conservative (smaller) standard tier. Matches exact base or `-suffix` / + * `[variant]` so aliases (e.g. `claude-fable-5-high`, `...[1m]`) tier alike. */ +export function anthropicVisionProfile(model: string | null | undefined): AnthropicVisionProfile { + const m = (model ?? '').toLowerCase(); + const isHighRes = HIGH_RES_BASES.some((b) => m === b || m.startsWith(`${b}-`) || m.startsWith(`${b}[`)); + return isHighRes ? HIGH_RES : STANDARD; +} + +/** Raw 28-px patch count for a `w×h` image, i.e. the visual-token cost when the + * image already fits the tier limits (no downscale). `⌈w/28⌉` inherently pads + * the right/bottom edge up to the next 28-px multiple, as Anthropic documents. */ +export function patchTokens(width: number, height: number): number { + const w = Math.max(1, Math.floor(width)); + const h = Math.max(1, Math.floor(height)); + return Math.ceil(w / ANTHROPIC_PATCH_PX) * Math.ceil(h / ANTHROPIC_PATCH_PX); +} + +/** + * Anthropic visual-token cost of an image at `width×height` for `model`'s tier. + * Applies the documented downscale — the largest aspect-preserving size that + * satisfies BOTH the long-edge limit and the visual-token budget — then the + * 28-px patch count. Matches Anthropic's reference `count_image_tokens`. + * + * Note: pxpipe's own pages are always ≤ 1568×728, so neither clamp ever fires + * for proxy output (both tiers charge the raw patch count). The downscale exists + * for correctness as a general-purpose estimator (e.g. arbitrary export input). + */ +export function anthropicVisionTokens( + model: string | null | undefined, + width: number, + height: number, +): number { + const { maxLongEdge, maxVisualTokens } = anthropicVisionProfile(model); + let w = Math.max(1, Math.floor(width)); + let h = Math.max(1, Math.floor(height)); + + // 1. Long-edge clamp (aspect-preserving). + const longEdge = Math.max(w, h); + if (longEdge > maxLongEdge) { + const s = maxLongEdge / longEdge; + w = Math.max(1, Math.floor(w * s)); + h = Math.max(1, Math.floor(h * s)); + } + + // 2. Visual-token-budget clamp (aspect-preserving). Seed with the continuous + // solution (patch area ≈ w·h / 28²) then step the scale down to absorb the + // per-edge ceil overshoot. Bounded; converges in a few dozen steps. + if (patchTokens(w, h) > maxVisualTokens) { + const w0 = w; + const h0 = h; + let s = Math.min(1, Math.sqrt((maxVisualTokens * ANTHROPIC_PATCH_PX * ANTHROPIC_PATCH_PX) / (w0 * h0))); + const step = 1 / Math.max(w0, h0); + for ( + let i = 0; + i < 4096 && + s > 0 && + patchTokens(Math.max(1, Math.floor(w0 * s)), Math.max(1, Math.floor(h0 * s))) > maxVisualTokens; + i++ + ) { + s -= step; + } + w = Math.max(1, Math.floor(w0 * Math.max(s, 0))); + h = Math.max(1, Math.floor(h0 * Math.max(s, 0))); + } + + return patchTokens(w, h); +} diff --git a/src/core/transform.ts b/src/core/transform.ts index 754197c1..e7002d65 100644 --- a/src/core/transform.ts +++ b/src/core/transform.ts @@ -41,6 +41,7 @@ import { bytesToBase64 } from './png.js'; import { collapseHistory, HISTORY_SYNTHETIC_INTRO } from './history.js'; import type { GptHistoryOptions } from './openai-history.js'; import { CACHE_CREATE_RATE, CACHE_READ_RATE } from './baseline.js'; +import { patchTokens } from './anthropic-vision.js'; /** Per-block descriptor passed to `TransformOptions.keepSharp`. */ export interface KeepSharpBlock { @@ -162,11 +163,11 @@ export const CLAUDE_CODE_OAUTH_IDENTITY = // --- per-block break-even check --- // -// Image token cost is computed from pixel area (Anthropic formula: w×h/750, -// empirically accurate to ~5% on dense PNGs). Constants bias CONSERVATIVE: -// CHARS_PER_TOKEN=4 under-estimates text savings; multi-col cost is linearly -// scaled from single-col + 10% margin. Mispredictions leave money on the -// table; they never generate net-loss images. +// Image token cost uses Anthropic's documented 28-px patch model (see +// src/core/anthropic-vision.ts). Constants bias CONSERVATIVE: CHARS_PER_TOKEN=4 +// under-estimates text savings; the gate multiplies the patch count by +// ANTHROPIC_GATE_MARGIN on top. Mispredictions leave money on the table; they +// never generate net-loss images. /** English ~4 chars per token average (conservative for code/JSON content). */ const CHARS_PER_TOKEN = 4; @@ -189,16 +190,11 @@ export const HISTORY_CHARS_PER_TOKEN = 2.0; * of truth — src/core/export.ts imports this rather than redefining it. */ export const REPORT_CHARS_PER_TOKEN = 3.7; -/** Anthropic image-billing formula: `tokens ≈ width × height / 750`. - * https://docs.anthropic.com/en/docs/build-with-claude/vision#image-tokens - * Accurate to ~5% on dense glyph PNGs (N=14 empirical calibration). The renderer - * sizes height to content, so per-block images cost far less than full-canvas. - * Exported so the export pipeline can reuse the same constant rather than hardcoding. */ -export const ANTHROPIC_PIXELS_PER_TOKEN = 750; -/** Conservative 10% upward bias on Anthropic image token estimates — keeps the gate - * on the safe (pass-through) side when the true cost is near the break-even point. - * Exported so the export pipeline reuses the same value. */ -export const IMAGE_COST_SAFETY_MARGIN = 1.10; +/** Gate-only conservatism: a 10% upward bias on the patch-count image estimate, + * keeping the gate on the safe (pass-through) side near break-even. This is NOT + * part of Anthropic's documented cost — the documented per-image formula lives in + * `anthropicVisionTokens` (anthropic-vision.ts); this margin only tunes the gate. */ +export const ANTHROPIC_GATE_MARGIN = 1.10; /** Width in px of a single-col PNG. Must stay in sync with `renderChunkToPng` (render.ts). */ function singleColWidthPx(cols: number): number { @@ -241,8 +237,12 @@ function imageTokensForRows( const rowsInLast = Math.min(Math.max(1, linesInLast), rowsPerImage); const fullImageHeight = 2 * PAD_Y + rowsPerImage * CELL_H; const lastImageHeight = 2 * PAD_Y + rowsInLast * CELL_H; - const totalPixels = fullImages * widthPx * fullImageHeight + widthPx * lastImageHeight; - return Math.ceil((totalPixels / ANTHROPIC_PIXELS_PER_TOKEN) * IMAGE_COST_SAFETY_MARGIN); + // Anthropic bills per-image by 28-px patches (not by summed pixel area), so + // count each image separately. pxpipe pages are ≤ 1568×728, so no tier + // downscale applies and the raw patch count is tier-agnostic. + const patchSum = + fullImages * patchTokens(widthPx, fullImageHeight) + patchTokens(widthPx, lastImageHeight); + return Math.ceil(patchSum * ANTHROPIC_GATE_MARGIN); } /** Exact image-token cost for `text`. Uses `countVisualRows` and optionally diff --git a/tests/anthropic-vision.test.ts b/tests/anthropic-vision.test.ts new file mode 100644 index 00000000..aa053779 --- /dev/null +++ b/tests/anthropic-vision.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect } from 'vitest'; +import { + anthropicVisionProfile, + anthropicVisionTokens, + patchTokens, + ANTHROPIC_PATCH_PX, +} from '../src/core/anthropic-vision.js'; + +// Anthropic bills images by 28×28-pixel patches: ⌈w/28⌉×⌈h/28⌉ visual tokens, +// after downscaling to the model tier's long-edge (1568/2576) and token-budget +// (1568/4784) limits. Numbers below are cross-checked against Anthropic's own +// worked cost table (platform.claude.com/docs/en/build-with-claude/vision). + +describe('patchTokens — raw 28-px patch count', () => { + it('is one token per full 28×28 patch, padding partial edges up', () => { + expect(ANTHROPIC_PATCH_PX).toBe(28); + expect(patchTokens(28, 28)).toBe(1); + expect(patchTokens(29, 29)).toBe(4); // ⌈29/28⌉² = 2² = 4 + expect(patchTokens(1000, 1000)).toBe(1296); // 36² (docs worked example) + expect(patchTokens(1568, 728)).toBe(1456); // 56×26 — pxpipe's full dense page + expect(patchTokens(1928, 1928)).toBe(4761); // 69² (old page, high-res) + }); +}); + +describe('anthropicVisionProfile — tier by model', () => { + it('puts the documented high-res models on the 2576/4784 tier', () => { + for (const m of ['claude-fable-5', 'claude-mythos-5', 'claude-opus-4-8', 'claude-opus-4-7', 'claude-sonnet-5']) { + expect(anthropicVisionProfile(m).tier).toBe('high-res'); + } + // aliases / variant tags tier with their base + expect(anthropicVisionProfile('claude-fable-5-high').tier).toBe('high-res'); + expect(anthropicVisionProfile('claude-opus-4-8[1m]').tier).toBe('high-res'); + expect(anthropicVisionProfile('claude-fable-5')).toEqual({ tier: 'high-res', maxLongEdge: 2576, maxVisualTokens: 4784 }); + }); + + it('falls back to the conservative standard 1568/1568 tier otherwise', () => { + for (const m of ['claude-opus-4-5', 'claude-sonnet-4-6', 'claude-haiku-4-5', 'claude-3-5-sonnet', '', undefined, null]) { + expect(anthropicVisionProfile(m as string).tier).toBe('standard'); + } + expect(anthropicVisionProfile('claude-opus-4-5')).toEqual({ tier: 'standard', maxLongEdge: 1568, maxVisualTokens: 1568 }); + }); +}); + +describe('anthropicVisionTokens — documented cost with downscale', () => { + it('charges the raw patch count when the image already fits (no clamp)', () => { + // pxpipe's full dense page fits on BOTH tiers unchanged. + expect(anthropicVisionTokens('claude-fable-5', 1568, 728)).toBe(1456); + expect(anthropicVisionTokens('claude-opus-4-5', 1568, 728)).toBe(1456); + // 1000×1000 fits both tiers. + expect(anthropicVisionTokens('claude-fable-5', 1000, 1000)).toBe(1296); + expect(anthropicVisionTokens('claude-opus-4-5', 1000, 1000)).toBe(1296); + }); + + it('leaves a big image unchanged on the high-res tier when it fits 2576/4784', () => { + // 1928² (old page): padded 1932 ≤ 2576 and 4761 ≤ 4784 → unchanged. + expect(anthropicVisionTokens('claude-fable-5', 1928, 1928)).toBe(4761); + // 1568² fits high-res (3136 ≤ 4784, 1568 ≤ 2576). + expect(anthropicVisionTokens('claude-fable-5', 1568, 1568)).toBe(3136); + }); + + it('downscales to the token budget on the standard tier', () => { + // 1568² standard: 3136 > 1568 budget → shrinks to a 39×39 = 1521-token image. + expect(anthropicVisionTokens('claude-opus-4-5', 1568, 1568)).toBe(1521); + // 1928² standard: edge-clamp to 1568² then budget-clamp → 1521. + expect(anthropicVisionTokens('claude-opus-4-5', 1928, 1928)).toBe(1521); + }); + + it('caps at the tier budget for very large images', () => { + // 3840×2160 high-res: edge-clamps to 2576 long edge, lands exactly on the 4784 cap. + expect(anthropicVisionTokens('claude-fable-5', 3840, 2160)).toBe(4784); + // Any result must never exceed the tier's visual-token budget. + for (const [w, h] of [[8000, 8000], [4000, 500], [500, 4000]] as const) { + expect(anthropicVisionTokens('claude-fable-5', w, h)).toBeLessThanOrEqual(4784); + expect(anthropicVisionTokens('claude-opus-4-5', w, h)).toBeLessThanOrEqual(1568); + } + }); + + it('is ~4–5% below the retired w×h/750 approximation for standard-tier pages', () => { + // The old gate used ceil(w*h/750). Patch is the exact grid it approximated. + const legacy750 = Math.ceil((1568 * 728) / 750); // 1522 + expect(anthropicVisionTokens('claude-fable-5', 1568, 728)).toBeLessThan(legacy750); + expect(legacy750 - anthropicVisionTokens('claude-fable-5', 1568, 728)).toBeLessThan(legacy750 * 0.06); + }); +}); diff --git a/tests/export.test.ts b/tests/export.test.ts index 2cab1c41..b767ec34 100644 --- a/tests/export.test.ts +++ b/tests/export.test.ts @@ -16,6 +16,7 @@ import { } from '../src/core/export.js'; import { extractFactSheetTokensAllPages, extractFactSheetTokens } from '../src/core/factsheet.js'; import { DENSE_CONTENT_CHARS_PER_IMAGE } from '../src/core/render.js'; +import { patchTokens } from '../src/core/anthropic-vision.js'; // --------------------------------------------------------------------------- // Temp-dir helpers @@ -500,35 +501,33 @@ describe('runExportCore integration', () => { // --------------------------------------------------------------------------- describe('exportImageTokens model routing', () => { - // Dense export page: width = 2*4 + 384*5 = 1928 px, height = MAX_HEIGHT_PX = 1932 px - const W = 1928; - const H = 1932; + // Current full dense export page: width = 2*PAD_X + 312*CELL_W = 1568 px, + // height = MAX_HEIGHT_PX = 728 px. Fits both Anthropic tiers unchanged, so the + // cost is the raw 28-px patch count: ⌈1568/28⌉×⌈728/28⌉ = 56×26 = 1456. + const W = 1568; + const H = 728; it('returns Anthropic-formula tokens for claude-sonnet-4-5', () => { - // Anthropic formula: ceil(W*H/750 * 1.10) - const expected = Math.ceil((W * H / 750) * 1.10); - expect(exportImageTokens('claude-sonnet-4-5', W, H)).toBe(expected); + expect(exportImageTokens('claude-sonnet-4-5', W, H)).toBe(patchTokens(W, H)); + expect(exportImageTokens('claude-sonnet-4-5', W, H)).toBe(1456); }); it('returns Anthropic-formula tokens for any claude-* model', () => { - const expected = Math.ceil((W * H / 750) * 1.10); - expect(exportImageTokens('claude-opus-4', W, H)).toBe(expected); - expect(exportImageTokens('claude-haiku-3-5', W, H)).toBe(expected); + expect(exportImageTokens('claude-opus-4', W, H)).toBe(patchTokens(W, H)); + expect(exportImageTokens('claude-haiku-3-5', W, H)).toBe(patchTokens(W, H)); }); it('returns Anthropic-formula tokens when model includes "anthropic"', () => { - const expected = Math.ceil((W * H / 750) * 1.10); - expect(exportImageTokens('anthropic/claude-3-5-sonnet', W, H)).toBe(expected); + expect(exportImageTokens('anthropic/claude-3-5-sonnet', W, H)).toBe(patchTokens(W, H)); }); - it('returns GPT (OpenAI tile) tokens for gpt-4o', () => { - // OpenAI tile formula is much cheaper for this image size (~765 vs ~5464) + it('routes gpt-4o to the OpenAI tile formula, not the Anthropic patch formula', () => { const gpTokens = exportImageTokens('gpt-4o', W, H); const claudeTokens = exportImageTokens('claude-sonnet-4-5', W, H); - // GPT-4o tile formula for 1928x1932 px: scaled to 768x769, 2x2 tiles - // = 85 + 170*4 = 765 tokens — far less than Anthropic's ~5464 - expect(gpTokens).toBeLessThan(claudeTokens); expect(gpTokens).toBeGreaterThan(0); + // Different pricing model → different number (they happen to be close at this + // size, but must not be computed by the same formula). + expect(gpTokens).not.toBe(claudeTokens); }); it('uses measured Grok pixel pricing instead of the GPT fallback', () => { @@ -546,6 +545,14 @@ describe('exportImageTokens model routing', () => { expect(claudeTokens / gpTokens).toBeGreaterThan(5); }); + it('caps a huge Claude image at the tier visual-token budget (patch downscale)', () => { + // The old ceil(w*h/750*1.10) formula ignored Anthropic's downscale and grossly + // overcharged large images (a 1928² page "cost" ~5464 when the API bills ≤1568 + // on the standard tier / 4761 on high-res). The patch model honors the cap. + expect(exportImageTokens('claude-sonnet-4-5', 4000, 4000)).toBeLessThanOrEqual(1568); + expect(exportImageTokens('claude-fable-5', 4000, 4000)).toBeLessThanOrEqual(4784); + }); + it('computeTokenReport uses Anthropic formula for default claude model', () => { // A single dense page: text so long that 1 page is estimated. // The report should reflect the higher Anthropic cost. From 3cae0cb7dc8bbd771031319f21d7b7220d23b01f Mon Sep 17 00:00:00 2001 From: Danil Silantyev Date: Sat, 4 Jul 2026 14:07:50 +0700 Subject: [PATCH 2/5] docs: sync geometry, thresholds, and billing model to shipped code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The docs described a superseded render/billing regime. Bring README, RENDER_SIZING, TRANSFORM_INFO, and the legibility audit in line with the code (render.ts constants) and Anthropic's current 28-px patch cost model: - geometry: 1928×1928 / 384 cols / 1932-px height / ~92k chars-per-page → 1568×728 / 312 cols / 728-px height / ~28k chars-per-page (render.ts today) - shrinkColsToContent: 'no-op, returns cols unchanged' → it delegates to measureContentCols (shrink-to-content is active; RENDER_SIZING's core thesis and the 'why full width' section were inverted) - thresholds: minReminderChars=1000 / minToolResultChars=2000 → 6000 / 6000 - billing: (w·h)/750 → ⌈w/28⌉×⌈h/28⌉ patch model + tiers; a 1568×728 page is 56×26 = 1456 tokens; RENDER_SIZING's 'unresolved billing gap' section is resolved and removed - legibility audit gets a dated note pointing px/750 → the exact patch count Docs-only; no code touched in this commit. Relative links still resolve. --- docs/LEGIBILITY-AUDIT-2026-07-01.md | 5 +++++ docs/RENDER_SIZING.md | 6 +++--- docs/TRANSFORM_INFO.md | 5 +++-- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/docs/LEGIBILITY-AUDIT-2026-07-01.md b/docs/LEGIBILITY-AUDIT-2026-07-01.md index 5e93d2ae..8f3fd6d6 100644 --- a/docs/LEGIBILITY-AUDIT-2026-07-01.md +++ b/docs/LEGIBILITY-AUDIT-2026-07-01.md @@ -13,6 +13,11 @@ follow from it. ~2.8×4.4 px. We were paying full price for pixels destroyed in transit. - **Shipped:** page geometry clamped to **1568×728 = 1.14 MP** (WYSIWYG: billed/actual pixel ratio 3.25 → 1.04). No token-cost change per image; 614/614 tests green. +- **Update (2026-07-04):** the `px/750` figure above is a ~4–5% continuous + approximation. Anthropic's current docs bill the exact **28-px patch count** + `⌈w/28⌉×⌈h/28⌉` (a 1568×728 page = 56×26 = **1456** tokens, ≈ the px/750 slope + measured here). The gate and export now use that exact formula via + `src/core/anthropic-vision.ts` (tiers: standard 1568/1568, high-res 2576/4784). - Reading exact strings off even **crisp** 5×8 tops out at **63%** (24-token blind test). Every miss is one of two classes the glyph-confusability matrix predicts: **case-normalization** (camelCase) and **single-glyph substitution** (hex/num). diff --git a/docs/RENDER_SIZING.md b/docs/RENDER_SIZING.md index b4d9dac1..55ee69ce 100644 --- a/docs/RENDER_SIZING.md +++ b/docs/RENDER_SIZING.md @@ -64,9 +64,9 @@ The profitability gate resolves the same profile as the renderer and derives: - per-image token cost Changing cell spacing or font therefore cannot leave cost prediction on a -hardcoded 5×8 canvas. Claude uses the Anthropic pixel formula with a conservative -margin, GPT uses model-specific tile/patch pricing, and Grok uses the measured -tokens-per-megapixel rate. +hardcoded 5×8 canvas. Claude uses the Anthropic 28-px patch formula (with a +conservative margin at the gate), GPT uses model-specific tile/patch pricing, and +Grok uses the measured tokens-per-megapixel rate. ## Why not square pages diff --git a/docs/TRANSFORM_INFO.md b/docs/TRANSFORM_INFO.md index fd66584f..499b2ee0 100644 --- a/docs/TRANSFORM_INFO.md +++ b/docs/TRANSFORM_INFO.md @@ -15,8 +15,9 @@ to re-read that text in token form — Anthropic prompt-caches it, and image blocks OCR cleanly at small font sizes. So pxpipe pulls the static prefix out of the JSON body, renders it as one or more grayscale PNG image blocks, and pins a single `cache_control` breakpoint on the last image. Anthropic -charges roughly `ceil(W*H/750)` tokens per image; the current Anthropic profile -caps pages at 1568×728 so rasterized pixels survive the provider's resize. A +charges `⌈W/28⌉×⌈H/28⌉` visual tokens per image (28-px patches); the current +Anthropic profile caps pages at 1568×728 (so `56×26 = 1456` tokens) and keeps +rasterized pixels under the provider's resize. A large static slab becomes a small set of image pages on the first turn and a cache-read (billed at 0.10×) on subsequent turns. The trade is real text tokens for image tokens cached under the same stable prefix. From 5cb208fb14406f989c5f18a4b5575ac793ac2559 Mon Sep 17 00:00:00 2001 From: Danil Silantyev Date: Sat, 4 Jul 2026 14:13:39 +0700 Subject: [PATCH 3/5] docs(render): correct stale page-cap file-header comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The render.ts header still claimed pages cap at ~1932×1932 px (the pre-clamp regime). They cap at 1568×728 (1.14 MP) today — under both Anthropic tiers' limits, billed at the raw 28-px patch count with no server-side downscale. --- src/core/render.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/render.ts b/src/core/render.ts index 9a42d328..2c59e7c1 100644 --- a/src/core/render.ts +++ b/src/core/render.ts @@ -1,8 +1,8 @@ /** * Text → PNG renderer. Blits atlas glyphs into a grayscale framebuffer, then PNG-encodes. * Iterates by codepoint so East Asian Wide chars (2-cell advance) and surrogate pairs handled correctly. - * Pages capped at ~1932×1932 px: Fable/Opus 4.8 >20-image requests are held to ≤2000 px/side - * (REJECTED if exceeded, not silently downscaled); ≤4784 token limit binds first at 1932 px. + * Pages capped at 1568×728 px (1.14 MP): under both Anthropic tiers' limits, so every + * page bills at its raw 28-px patch count with no server-side downscale (WYSIWYG). */ import { From f7628f49f045674dbac0d3af57a76a9b407546b8 Mon Sep 17 00:00:00 2001 From: Danil Silantyev Date: Sat, 4 Jul 2026 14:56:36 +0700 Subject: [PATCH 4/5] fix(vision): make anthropicVisionTokens exactly match Anthropic's resize reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The downscale used a continuous sqrt seed + decrement loop, which matched Anthropic's worked cost table but drifted on extreme aspect ratios (it scaled both edges by one factor and floored, instead of pinning the long edge and rounding the short one). E.g. standard-tier 2510×7800 billed 1064, not 1008. - replace the approximation with a direct translation of the documented algorithm: fitsTier() (padded patch edges ≤ maxLongEdge AND patch count ≤ budget) + resizedSize() (binary-search the long edge, short edge = round(long × aspect), swap-recurse for portrait). patchTokens() stays the raw no-resize counter. - tests: the full official cost table on both tiers, the three extreme-aspect counterexamples (2510×7800→1064, 1194×4171→952, 2384×1625→1551), and a 400-case deterministic property test vs an independent reference translation. - fix stale comments: transform.ts slab '~50k chars' → '~28k chars/page at the 1568×728 cap'; render.ts MAX_HEIGHT_PX + MAX_WIDTH_PX comments now describe the exact 28-px patch billing (px/750 kept only as the historical measured slope). Only affects the general-purpose estimator (export/reporting); pxpipe pages are ≤1568×728 so the resize never fires for proxy output. Full suite green (639). --- src/core/anthropic-vision.ts | 87 ++++++++++++++++-------------- src/core/render.ts | 5 +- src/core/transform.ts | 2 +- tests/anthropic-vision.test.ts | 97 ++++++++++++++++++++++++---------- 4 files changed, 120 insertions(+), 71 deletions(-) diff --git a/src/core/anthropic-vision.ts b/src/core/anthropic-vision.ts index 97eb0c36..0d2c8270 100644 --- a/src/core/anthropic-vision.ts +++ b/src/core/anthropic-vision.ts @@ -58,15 +58,53 @@ export function patchTokens(width: number, height: number): number { return Math.ceil(w / ANTHROPIC_PATCH_PX) * Math.ceil(h / ANTHROPIC_PATCH_PX); } +/** True when a `w×h` image fits a tier with no resize: both padded patch edges + * are within the long-edge limit AND the patch count is within the token + * budget. Mirrors the `fits` predicate in Anthropic's reference resize. */ +function fitsTier(w: number, h: number, maxLongEdge: number, maxVisualTokens: number): boolean { + return ( + Math.ceil(w / ANTHROPIC_PATCH_PX) * ANTHROPIC_PATCH_PX <= maxLongEdge && + Math.ceil(h / ANTHROPIC_PATCH_PX) * ANTHROPIC_PATCH_PX <= maxLongEdge && + patchTokens(w, h) <= maxVisualTokens + ); +} + +/** Largest aspect-preserving size that fits the tier, exactly as Anthropic's + * reference does it: binary-search the long edge (short edge = round(long × + * aspect)), recursing with a swap for portrait images. Original if it fits. */ +function resizedSize(w: number, h: number, maxLongEdge: number, maxVisualTokens: number): [number, number] { + if (fitsTier(w, h, maxLongEdge, maxVisualTokens)) return [w, h]; + if (h > w) { + const [rh, rw] = resizedSize(h, w, maxLongEdge, maxVisualTokens); + return [rw, rh]; + } + // Landscape (w ≥ h): binary-search the largest long edge that still fits. + const aspect = h / w; + let lo = 1; + let hi = w; + let best = 1; + while (lo <= hi) { + const mid = (lo + hi) >> 1; + if (fitsTier(mid, Math.max(1, Math.round(mid * aspect)), maxLongEdge, maxVisualTokens)) { + best = mid; + lo = mid + 1; + } else { + hi = mid - 1; + } + } + return [best, Math.max(1, Math.round(best * aspect))]; +} + /** * Anthropic visual-token cost of an image at `width×height` for `model`'s tier. - * Applies the documented downscale — the largest aspect-preserving size that - * satisfies BOTH the long-edge limit and the visual-token budget — then the - * 28-px patch count. Matches Anthropic's reference `count_image_tokens`. + * Applies the documented resize — the largest aspect-preserving size that fits + * BOTH the long-edge limit and the visual-token budget, found by binary search + * exactly as Anthropic's reference `count_image_tokens` — then the 28-px patch + * count. * - * Note: pxpipe's own pages are always ≤ 1568×728, so neither clamp ever fires - * for proxy output (both tiers charge the raw patch count). The downscale exists - * for correctness as a general-purpose estimator (e.g. arbitrary export input). + * Note: pxpipe's own pages are always ≤ 1568×728, so the resize never fires for + * proxy output (both tiers charge the raw patch count). The resize path is for + * correctness as a general-purpose estimator (e.g. arbitrary export input). */ export function anthropicVisionTokens( model: string | null | undefined, @@ -74,37 +112,8 @@ export function anthropicVisionTokens( height: number, ): number { const { maxLongEdge, maxVisualTokens } = anthropicVisionProfile(model); - let w = Math.max(1, Math.floor(width)); - let h = Math.max(1, Math.floor(height)); - - // 1. Long-edge clamp (aspect-preserving). - const longEdge = Math.max(w, h); - if (longEdge > maxLongEdge) { - const s = maxLongEdge / longEdge; - w = Math.max(1, Math.floor(w * s)); - h = Math.max(1, Math.floor(h * s)); - } - - // 2. Visual-token-budget clamp (aspect-preserving). Seed with the continuous - // solution (patch area ≈ w·h / 28²) then step the scale down to absorb the - // per-edge ceil overshoot. Bounded; converges in a few dozen steps. - if (patchTokens(w, h) > maxVisualTokens) { - const w0 = w; - const h0 = h; - let s = Math.min(1, Math.sqrt((maxVisualTokens * ANTHROPIC_PATCH_PX * ANTHROPIC_PATCH_PX) / (w0 * h0))); - const step = 1 / Math.max(w0, h0); - for ( - let i = 0; - i < 4096 && - s > 0 && - patchTokens(Math.max(1, Math.floor(w0 * s)), Math.max(1, Math.floor(h0 * s))) > maxVisualTokens; - i++ - ) { - s -= step; - } - w = Math.max(1, Math.floor(w0 * Math.max(s, 0))); - h = Math.max(1, Math.floor(h0 * Math.max(s, 0))); - } - - return patchTokens(w, h); + const w = Math.max(1, Math.floor(width)); + const h = Math.max(1, Math.floor(height)); + const [rw, rh] = resizedSize(w, h, maxLongEdge, maxVisualTokens); + return patchTokens(rw, rh); } diff --git a/src/core/render.ts b/src/core/render.ts index 2c59e7c1..6d885aee 100644 --- a/src/core/render.ts +++ b/src/core/render.ts @@ -141,7 +141,8 @@ function grayGlyph(codepoint: number, font: RenderFont | undefined): { atlas: Gr /** 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). + * AND ~1.15 MP (≈1,143,750 px), then bills the exact 28-px patch count ⌈w/28⌉×⌈h/28⌉ + * (1568×728 = 56×26 = 1456 tokens/image; the old ≈px/750 slope was the 28²=784 approximation). * The old 1932×1932 page was billed at cap but resampled 0.555× → 5×8 glyphs reached the * encoder at ~2.8×4.4 px. New page shape 1568×728 = 1,141,504 px fits both bounds → * WYSIWYG for the vision encoder (also satisfies ≤2000 px/side for >20-image requests). */ @@ -1103,7 +1104,7 @@ export async function renderTextToPngs( const GUTTER_CELLS = 4; // Width hard-capped at the API's long-edge bound: anything wider is resampled server-side -// (measured 2026-07-01: fit-within 1568-edge AND ~1.15 MP, then ≈px/750 billing). +// (measured 2026-07-01: fit-within 1568-edge AND ~1.15 MP, then 28-px patch billing ⌈w/28⌉×⌈h/28⌉). const MAX_WIDTH_PX = 1568; const GUTTER_DIVIDER_INK = 64; // pre-invert → 191 post-invert: light gray column separator diff --git a/src/core/transform.ts b/src/core/transform.ts index e7002d65..47826a63 100644 --- a/src/core/transform.ts +++ b/src/core/transform.ts @@ -143,7 +143,7 @@ const DEFAULTS: Required = { historyAmortizationHorizon: 1, priorWarmTokens: 0, priorWarmImageTokens: 0, - // Multi-col off: single-col slab already holds ~50k chars; extra OCR risk not worth it. + // Multi-col off: single-col slab already holds ~28k chars/page at the 1568×728 cap; extra OCR risk not worth it. multiCol: 1, reflow: true, keepSharp: () => false, diff --git a/tests/anthropic-vision.test.ts b/tests/anthropic-vision.test.ts index aa053779..290dcba8 100644 --- a/tests/anthropic-vision.test.ts +++ b/tests/anthropic-vision.test.ts @@ -41,44 +41,83 @@ describe('anthropicVisionProfile — tier by model', () => { }); }); -describe('anthropicVisionTokens — documented cost with downscale', () => { - it('charges the raw patch count when the image already fits (no clamp)', () => { - // pxpipe's full dense page fits on BOTH tiers unchanged. - expect(anthropicVisionTokens('claude-fable-5', 1568, 728)).toBe(1456); - expect(anthropicVisionTokens('claude-opus-4-5', 1568, 728)).toBe(1456); - // 1000×1000 fits both tiers. - expect(anthropicVisionTokens('claude-fable-5', 1000, 1000)).toBe(1296); - expect(anthropicVisionTokens('claude-opus-4-5', 1000, 1000)).toBe(1296); +// Independent translation of Anthropic's documented resize + patch count, used +// as the oracle for the property test below. Kept separate from the production +// code so a future edit to one is caught by the other. +function refVisionTokens(maxEdge: number, maxTok: number, W: number, H: number): number { + const pt = (w: number, h: number): number => Math.ceil(w / 28) * Math.ceil(h / 28); + const fits = (w: number, h: number): boolean => + Math.ceil(w / 28) * 28 <= maxEdge && Math.ceil(h / 28) * 28 <= maxEdge && pt(w, h) <= maxTok; + const resize = (w: number, h: number): [number, number] => { + if (fits(w, h)) return [w, h]; + if (h > w) { const [rh, rw] = resize(h, w); return [rw, rh]; } + const a = h / w; + let lo = 1, hi = w, best = 1; + while (lo <= hi) { + const m = (lo + hi) >> 1; + if (fits(m, Math.max(1, Math.round(m * a)))) { best = m; lo = m + 1; } else hi = m - 1; + } + return [best, Math.max(1, Math.round(best * a))]; + }; + const [rw, rh] = resize(Math.max(1, Math.floor(W)), Math.max(1, Math.floor(H))); + return pt(rw, rh); +} + +const STD = 'claude-opus-4-5'; // standard tier: 1568 / 1568 +const HI = 'claude-fable-5'; // high-res tier: 2576 / 4784 + +describe('anthropicVisionTokens — documented cost with resize', () => { + it('reproduces Anthropic\'s worked cost table on both tiers', () => { + const rows: Array<[string, number, number, number, number]> = [ + // model, w, h, standard, high-res + [STD, 200, 200, 64, 64], [STD, 1000, 1000, 1296, 1296], [STD, 1092, 1092, 1521, 1521], + [STD, 1920, 1080, 1560, 2691], [STD, 2000, 1500, 1564, 3888], [STD, 3840, 2160, 1560, 4784], + ]; + for (const [, w, h, std, hi] of rows) { + expect(anthropicVisionTokens(STD, w, h), `${w}x${h} standard`).toBe(std); + expect(anthropicVisionTokens(HI, w, h), `${w}x${h} high-res`).toBe(hi); + } }); - it('leaves a big image unchanged on the high-res tier when it fits 2576/4784', () => { - // 1928² (old page): padded 1932 ≤ 2576 and 4761 ≤ 4784 → unchanged. - expect(anthropicVisionTokens('claude-fable-5', 1928, 1928)).toBe(4761); - // 1568² fits high-res (3136 ≤ 4784, 1568 ≤ 2576). - expect(anthropicVisionTokens('claude-fable-5', 1568, 1568)).toBe(3136); + it('is exact on extreme aspect ratios (where a continuous scale would drift)', () => { + // These caught the old sqrt/decrement approximation (it gave 1008 / 896 / 1504). + expect(anthropicVisionTokens(STD, 2510, 7800)).toBe(1064); + expect(anthropicVisionTokens(STD, 1194, 4171)).toBe(952); + expect(anthropicVisionTokens(STD, 2384, 1625)).toBe(1551); }); - it('downscales to the token budget on the standard tier', () => { - // 1568² standard: 3136 > 1568 budget → shrinks to a 39×39 = 1521-token image. - expect(anthropicVisionTokens('claude-opus-4-5', 1568, 1568)).toBe(1521); - // 1928² standard: edge-clamp to 1568² then budget-clamp → 1521. - expect(anthropicVisionTokens('claude-opus-4-5', 1928, 1928)).toBe(1521); + it('charges the raw patch count when the image already fits', () => { + // pxpipe's full dense page (1568×728) fits BOTH tiers unchanged. + expect(anthropicVisionTokens(HI, 1568, 728)).toBe(1456); + expect(anthropicVisionTokens(STD, 1568, 728)).toBe(1456); + // High-res leaves 1928² and 1568² unchanged; standard shrinks them to 1521. + expect(anthropicVisionTokens(HI, 1928, 1928)).toBe(4761); + expect(anthropicVisionTokens(HI, 1568, 1568)).toBe(3136); + expect(anthropicVisionTokens(STD, 1568, 1568)).toBe(1521); + expect(anthropicVisionTokens(STD, 1928, 1928)).toBe(1521); + }); + + it('never exceeds the tier visual-token budget', () => { + for (const [w, h] of [[8000, 8000], [4000, 500], [500, 4000], [7680, 1080]] as const) { + expect(anthropicVisionTokens(HI, w, h)).toBeLessThanOrEqual(4784); + expect(anthropicVisionTokens(STD, w, h)).toBeLessThanOrEqual(1568); + } }); - it('caps at the tier budget for very large images', () => { - // 3840×2160 high-res: edge-clamps to 2576 long edge, lands exactly on the 4784 cap. - expect(anthropicVisionTokens('claude-fable-5', 3840, 2160)).toBe(4784); - // Any result must never exceed the tier's visual-token budget. - for (const [w, h] of [[8000, 8000], [4000, 500], [500, 4000]] as const) { - expect(anthropicVisionTokens('claude-fable-5', w, h)).toBeLessThanOrEqual(4784); - expect(anthropicVisionTokens('claude-opus-4-5', w, h)).toBeLessThanOrEqual(1568); + it('equals the independent reference across a spread of sizes and aspects', () => { + // Deterministic LCG — no Math.random, reproducible. + let seed = 0x9e3779b9; + const next = (n: number): number => ((seed = (seed * 1103515245 + 12345) & 0x7fffffff) % n) + 1; + for (let i = 0; i < 400; i++) { + const w = next(9000); + const h = next(9000); + expect(anthropicVisionTokens(STD, w, h), `std ${w}x${h}`).toBe(refVisionTokens(1568, 1568, w, h)); + expect(anthropicVisionTokens(HI, w, h), `hi ${w}x${h}`).toBe(refVisionTokens(2576, 4784, w, h)); } }); - it('is ~4–5% below the retired w×h/750 approximation for standard-tier pages', () => { - // The old gate used ceil(w*h/750). Patch is the exact grid it approximated. + it('is below the retired w×h/750 approximation for the standard-tier page', () => { const legacy750 = Math.ceil((1568 * 728) / 750); // 1522 - expect(anthropicVisionTokens('claude-fable-5', 1568, 728)).toBeLessThan(legacy750); - expect(legacy750 - anthropicVisionTokens('claude-fable-5', 1568, 728)).toBeLessThan(legacy750 * 0.06); + expect(anthropicVisionTokens(HI, 1568, 728)).toBeLessThan(legacy750); }); }); From 0cf790d72720b6cc5218c90d1b4b3ed6e6ba8757 Mon Sep 17 00:00:00 2001 From: Danil Silantyev Date: Sat, 18 Jul 2026 19:30:30 -0400 Subject: [PATCH 5/5] fix(vision): route visionTokensForModel through the 28-px patch model (rebase integration) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebase-integration step: main refactored all vision-cost math into the unified visionTokensForModel router (openai.ts) after this branch was opened, so the patch model is wired in there instead of the old export.ts path. The Claude branch of visionTokensForModel now calls anthropicVisionTokens; export reporting and the GPT-history estimate inherit it. Drop the now-unused ANTHROPIC_PIXELS_PER_TOKEN / IMAGE_COST_SAFETY_MARGIN imports, refresh the stale w*h/750 doc comments, and update the two tests that hardcoded the old formula (a full 1568x728 page is now 56x26 = 1456 tokens; claude-opus-4-8 768x1932 high-res = 28x69 = 1932). Also drop the obsolete "Claude >5x GPT" export test — with the accurate patch model a full page is ~equal to gpt-4o, which the adjacent not.toBe test already covers. --- src/core/export.ts | 9 +++++---- src/core/openai.ts | 7 ++++--- tests/export.test.ts | 8 -------- tests/openai-gpt5.test.ts | 9 +++++---- 4 files changed, 14 insertions(+), 19 deletions(-) diff --git a/src/core/export.ts b/src/core/export.ts index db69cdcf..986586df 100644 --- a/src/core/export.ts +++ b/src/core/export.ts @@ -248,8 +248,9 @@ export function parseExportArgv( * Per-image vision-token cost for a rendered PNG at the given pixel dimensions. * * - **Claude / Anthropic models** (`model.startsWith('claude')` or - * `model.includes('anthropic')`): uses Anthropic's billing formula - * `ceil(width × height / 750 × 1.10)`, matching the live transform. + * `model.includes('anthropic')`): Anthropic's documented 28-px patch model + * `⌈w/28⌉×⌈h/28⌉` (tier-aware downscale), via `visionTokensForModel`. This is + * the exact provider cost — no gate margin — for reporting. * - **OpenAI-shaped models**: delegates to the same exact-model router as the * proxy (Grok measured pixel rate; GPT tile/patch rate). */ @@ -280,7 +281,7 @@ export interface ExportTokenReport { * imageTokens = estimateImageCount(text, cols) × exportImageTokens(model, stripW, MAX_HEIGHT_PX) * textTokens = sourceText.length / REPORT_CHARS_PER_TOKEN * - * `exportImageTokens` routes to the Anthropic billing formula (width×height/750×1.10) + * `exportImageTokens` routes to the Anthropic 28-px patch model (⌈w/28⌉×⌈h/28⌉) * for Claude models, and to the GPT tile-pricing formula for GPT/o-series models. * * `factsheetItemCount` is the number of unique precision-critical identifier strings @@ -432,7 +433,7 @@ export async function runExportCore( }); // Compute token costs using actual rendered image dimensions (more accurate than estimate). - // exportImageTokens routes to the Anthropic billing formula for claude-* models and to + // exportImageTokens routes to the Anthropic 28-px patch model for claude-* models and to // the GPT tile-pricing formula for GPT/o-series models. const textTokens = Math.round(sourceText.length / REPORT_CHARS_PER_TOKEN); let imageTokens = 0; diff --git a/src/core/openai.ts b/src/core/openai.ts index f7ccfc18..87619d5f 100644 --- a/src/core/openai.ts +++ b/src/core/openai.ts @@ -28,11 +28,10 @@ import { countVisualRows, estimateImageCount, sha8, - ANTHROPIC_PIXELS_PER_TOKEN, - IMAGE_COST_SAFETY_MARGIN, type TransformInfo, type TransformOptions, } from './transform.js'; +import { anthropicVisionTokens } from './anthropic-vision.js'; import { stripSchemaDescriptions } from './schema-strip.js'; import { planGptCollapse, @@ -132,7 +131,9 @@ export const GROK_TOKENS_PER_MEGAPIXEL = 1000; * OpenAI tile/patch formula. Model-based, not endpoint-based. */ export function visionTokensForModel(model: string, w: number, h: number): number { if (isClaudeModel(model)) { - return Math.ceil((w * h / ANTHROPIC_PIXELS_PER_TOKEN) * IMAGE_COST_SAFETY_MARGIN); + // Anthropic's documented 28-px patch model (tier-aware downscale). This is the + // exact provider cost; the gate applies its own margin separately. + return anthropicVisionTokens(model, w, h); } if (isGrokModel(model)) { const pixels = Math.max(0, w) * Math.max(0, h); diff --git a/tests/export.test.ts b/tests/export.test.ts index b767ec34..b38236a4 100644 --- a/tests/export.test.ts +++ b/tests/export.test.ts @@ -537,14 +537,6 @@ describe('exportImageTokens model routing', () => { ); }); - it('Claude image tokens are substantially higher than GPT for the same full-page image', () => { - // The issue was a ~7x underestimate when using GPT formula for Claude. - // Verify the ratio is at least 5x so the fix is clearly meaningful. - const claudeTokens = exportImageTokens('claude-sonnet-4-5', W, H); - const gpTokens = exportImageTokens('gpt-4o', W, H); - expect(claudeTokens / gpTokens).toBeGreaterThan(5); - }); - it('caps a huge Claude image at the tier visual-token budget (patch downscale)', () => { // The old ceil(w*h/750*1.10) formula ignored Anthropic's downscale and grossly // overcharged large images (a 1928² page "cost" ~5464 when the API bills ≤1568 diff --git a/tests/openai-gpt5.test.ts b/tests/openai-gpt5.test.ts index 792a0d30..e1242f24 100644 --- a/tests/openai-gpt5.test.ts +++ b/tests/openai-gpt5.test.ts @@ -99,11 +99,12 @@ describe('visionTokensForModel (Claude on the Responses path)', () => { expect(isClaudeModel(undefined)).toBe(false); }); - it('prices claude images by pixel area, not GPT tiles', () => { + it('prices claude images by the 28-px patch model, not GPT tiles', () => { // Codex speaks OpenAI Responses; some models on that path are Claude, so - // this path must bill images the Anthropic way: ceil(w*h/750 * 1.10). - // 768x1932 → ceil(768*1932/750 * 1.10) = ceil(2176.8) = 2177. - expect(visionTokensForModel('claude-opus-4-8', 768, 1932)).toBe(2177); + // this path must bill images the Anthropic way: ⌈w/28⌉×⌈h/28⌉ visual tokens. + // 768x1932 on the high-res tier (opus-4-8) fits without downscale → + // ⌈768/28⌉×⌈1932/28⌉ = 28×69 = 1932. + expect(visionTokensForModel('claude-opus-4-8', 768, 1932)).toBe(1932); // GPT models are unchanged (delegates to openAIVisionTokens). expect(visionTokensForModel('gpt-5', 768, 1932)).toBe(openAIVisionTokens('gpt-5', 768, 1932)); });