@linxiraos/pi-snapcompact 1.1.26 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,10 +1,24 @@
1
- # Changelog
2
-
3
1
  ## [Unreleased]
4
2
 
5
- ## [1.1.26] - 2026-10-03
3
+ ## [1.2.0] - 2026-10-12
4
+
5
+ - 版本线推进至 1.1.28;本版无独立用户可见变化。
6
+
7
+ ## [1.1.27] - 2026-10-07
8
+
9
+ - 版本线推进至 1.1.27;本版无独立用户可见变化。
10
+
11
+ ### Breaking Changes
12
+
13
+ - `maxFramesForDataBudget()` now takes the frame shape instead of a byte budget, so the default 1568px shapes get 26 frames instead of 17 ([#14277](https://github.com/can1357/oh-my-pi/pull/14277) by [@will-bogusz](https://github.com/will-bogusz)).
14
+
15
+ ### Added
16
+
17
+ - Added `frameBilling`, `frameTokens` and `frameBillingKey`, which price a rendered frame at what the model reading it is billed ([#14291](https://github.com/can1357/oh-my-pi/pull/14291) by [@will-bogusz](https://github.com/will-bogusz))
18
+
19
+ ### Fixed
6
20
 
7
- - 版本线推进至 1.1.26;本版无独立用户可见变化。
21
+ - Fixed snapcompact frame token estimates following the gateway instead of the model reading the frames, which mispriced Claude behind OpenRouter or Vertex and Claude 4.6 and older ([#14286](https://github.com/can1357/oh-my-pi/pull/14286) by [@will-bogusz](https://github.com/will-bogusz)).
8
22
 
9
23
  ## [1.1.25] - 2026-10-03
10
24
 
package/README.md CHANGED
@@ -10,7 +10,7 @@ Built for [omp](https://github.com/can1357/oh-my-pi)'s compaction pipeline, but
10
10
 
11
11
  1. Discarded history is serialized to compact text (`serializeConversation`), with per-tool-result and per-argument character caps.
12
12
  2. Text is normalized for the selected native font (`normalize`): ANSI sequences stripped, whitespace collapsed, newline runs folded into a single full-block glyph, box drawing and compatibility symbols folded to ASCII, semantic emoji folded to ASCII labels, decorative emoji dropped, and non-Latin glyphs preserved when either the selected font or the embedded Silver fallback can render them.
13
- 3. Pages of text are rasterized into PNG frames (`render` / `renderMany`). Frame width is fixed per shape; height hugs the rows actually printed, so a partially filled frame never bills blank pixel rows.
13
+ 3. Pages of text are rasterized into PNG frames (`render` / `renderMany`). Frame width is fixed per shape; height hugs the rows actually printed, with a 64px minimum so short final frames remain valid for vision processors.
14
14
  4. Frames persist in the compaction entry's `preserveData` and are re-attached to the summary message on every context rebuild.
15
15
 
16
16
  Frame shapes are provider-aware, chosen by SQuAD recall evals (see `research/`) against real provider billing:
@@ -22,7 +22,7 @@ Frame shapes are provider-aware, chosen by SQuAD recall evals (see `research/`)
22
22
  | OpenAI | `8on22-bw` | X.org 8x13 glyphs on a 22px pitch, sent at `detail: "original"` |
23
23
  | Unknown | Anthropic shape | Per-provider image-count budgets guard against gateways that silently drop frames |
24
24
 
25
- `resolveShape({ api, id })` matches the model id, not just the wire API — a Claude routed through Vertex or OpenRouter keeps its Claude shape, priced for the gateway actually carrying the request.
25
+ `resolveShape({ api, id })` matches the model id, not just the wire API — a Claude routed through Vertex or OpenRouter keeps its Claude shape. Frames are priced by the reading model's lineage: its catalog `image-tokenization` rule (so that Claude is billed as Claude, and Opus 4.6 under the standard 1,568-token tier), falling back to the catalog rule for the wire API carrying the request when the lineage has none. A reader with neither costs `FRAME_TOKEN_ESTIMATE` per frame.
26
26
 
27
27
  Bitmap shapes keep their provider-tuned geometry and draw missing glyphs through the embedded Silver TrueType fallback one character at a time; East Asian (CJK/Kana/Hangul) glyphs render full-width across two cells so they stay legible in the narrow ASCII grid. Selecting `silver16-bw` uses Silver for the whole frame.
28
28
 
@@ -62,7 +62,7 @@ const result = await compact(preparation, { model });
62
62
  - **Rendering**: `render`, `renderMany`, `frames`, `geometry`
63
63
  - **Shapes**: `SHAPES`, `SHAPE_VARIANTS`, `resolveShape`, `resolveShapeForText`, `idealShapeVariant`, `isShape`, `isShapeVariantName`
64
64
  - **Text**: `serializeConversation`, `normalize`, `scanRenderability`, `renderabilityProbeText`, `dimStopwords`, `wrap`
65
- - **Budgets**: `providerImageBudget`, `MAX_FRAMES_DEFAULT`, `FRAME_TOKEN_ESTIMATE`, `HQ_EDGE_FRAMES`
65
+ - **Budgets**: `providerImageBudget`, `MAX_FRAMES_DEFAULT`, `FRAME_TOKEN_ESTIMATE`, `frameBilling`, `frameTokens`, `frameBillingKey`, `HQ_EDGE_FRAMES`
66
66
  - **File ops**: `createFileOps`, `computeFileLists`, `upsertFileOperations`
67
67
 
68
68
  ## References
@@ -5,7 +5,8 @@
5
5
  * conversation is rendered into PNG frames of pixel-font text that vision
6
6
  * models read back directly, like an archivist at a snapcompact frame
7
7
  * reader. Frames are `frameSize` wide; their height hugs the text rows
8
- * actually printed, so a partially filled frame never bills blank rows.
8
+ * actually printed, with a 64px floor for vision processors that reject
9
+ * smaller images.
9
10
  *
10
11
  * The frame shape is provider-aware. Original choices came from the SQuAD
11
12
  * prose evals (`packages/snapcompact`, 200k-token monolithic runs); the
@@ -31,12 +32,17 @@
31
32
  * billing (32px × 1.2, 10k-patch budget at `detail: "original"`) is
32
33
  * area-proportional, so resolution cannot improve chars/$ — 1568 stays.
33
34
  * `detail: "high"` would downgrade (2,500-patch cap); `original` is sent.
34
- * - **Unknown providers** default to `8on22-bw` with Anthropic-style
35
- * visual-token area billing. `providerImageBudget` still caps per-request
36
- * images per provider so inline imaging cannot flood a request with
37
- * attachments, but the old OpenRouter-specific 8-image cap is gone; routers
38
- * now use the same permissive budget as direct Anthropic/Claude lines unless
39
- * configured otherwise upstream.
35
+ * - **Unknown providers** default to `8on22-bw`. `providerImageBudget` still
36
+ * caps per-request images per provider so inline imaging cannot flood a
37
+ * request with attachments, but the old OpenRouter-specific 8-image cap is
38
+ * gone; routers now use the same permissive budget as direct
39
+ * Anthropic/Claude lines unless configured otherwise upstream.
40
+ *
41
+ * The per-frame token estimate follows the reading model's catalog
42
+ * `image-tokenization` rule (its lineage, not the gateway), so Claude behind
43
+ * OpenRouter is billed as Claude and Opus 4.6 under its 1,568-token cap.
44
+ * Models without a lineage rule fall back to the catalog rule for their wire
45
+ * API; a reader with neither costs {@link FRAME_TOKEN_ESTIMATE} per frame.
40
46
  *
41
47
  * The whole pass is local and deterministic — no LLM call, no API key, no
42
48
  * latency beyond rendering. Rasterization and PNG encoding happen in native
@@ -45,6 +51,7 @@
45
51
  * re-attached to the compaction summary message on every context rebuild.
46
52
  */
47
53
  import type { Api, ImageContent, Message, TextContent } from "@linxiraos/pi-ai";
54
+ import { type ImageSize, type ImageTokenization, type ImageTokenizationTarget } from "@linxiraos/pi-catalog/compat/image-tokenization";
48
55
  /** One eval-validated frame shape: font, cell, ink, repetition, and size. */
49
56
  export interface Shape {
50
57
  /** Bundled font in the native renderer. */
@@ -245,6 +252,23 @@ export type ShapeVariantName = keyof typeof SHAPE_VARIANTS;
245
252
  export declare const SHAPE_VARIANT_NAMES: readonly ShapeVariantName[];
246
253
  /** Runtime guard for variant names loaded from config. */
247
254
  export declare function isShapeVariantName(value: unknown): value is ShapeVariantName;
255
+ /**
256
+ * How a reader is billed per frame: its catalog `image-tokenization` rule
257
+ * (the model's lineage, else its wire API's fallback) and the `detail` hint
258
+ * snapcompact sends. OpenAI-family wires send `detail: "original"` (`high`
259
+ * caps the frame at 2,500 patches). Without a rule, every frame costs
260
+ * {@link FRAME_TOKEN_ESTIMATE}.
261
+ */
262
+ export interface FrameBilling {
263
+ rule?: ImageTokenization;
264
+ imageDetail?: ImageContent["detail"];
265
+ }
266
+ /** Resolve the frame billing of `target`. */
267
+ export declare function frameBilling(target: ShapeTarget | undefined): FrameBilling;
268
+ /** Identity of a frame price list: equal keys price every frame alike. */
269
+ export declare function frameBillingKey(billing: FrameBilling): string;
270
+ /** Billed-token estimate for one rendered frame of `size` under `billing`. */
271
+ export declare function frameTokens(billing: FrameBilling, size: ImageSize): number;
248
272
  /** Eval-validated shapes, keyed by the provider family they won on. */
249
273
  export declare const SHAPES: {
250
274
  /** `11on16-bw`: 8x13 glyphs on an 11px advance (extra tracking), black ink.
@@ -275,19 +299,23 @@ export interface IdealShape {
275
299
  }
276
300
  /** Eval-ideal format for a model id, or undefined when unmeasured. */
277
301
  export declare function idealShapeVariant(modelId: string): IdealShape | undefined;
278
- /** What will read the frames: the wire API (billing) and model id (shape). */
302
+ /** What will read the frames: the model id (shape and billing), the wire API
303
+ * (request hints, fallback billing), and optionally the host and parsed
304
+ * identity of a built model. */
279
305
  export interface ShapeTarget {
280
306
  api?: Api;
281
307
  id?: string;
308
+ provider?: string;
309
+ identity?: ImageTokenizationTarget["identity"];
282
310
  }
283
311
  /**
284
312
  * Pick the frame shape for a reader. An explicit `variant` (anything but
285
313
  * `"auto"`) forces that geometry; otherwise the model id selects the
286
314
  * eval-winning shape — and frame size — for its model line, falling back to
287
315
  * the API family's winner when the model is unmeasured. Billing (token
288
- * estimate, detail hint) always follows the API family actually carrying
289
- * the request, computed for the resolved frame size. Accepts a full pi-ai
290
- * `Model` or any `{ api, id }` subset.
316
+ * estimate, detail hint) is computed for the resolved frame size from the
317
+ * model's catalog image rule. Accepts a full pi-ai `Model` or any
318
+ * `{ api, id }` subset.
291
319
  */
292
320
  export declare function resolveShape(model?: ShapeTarget, variant?: ShapeVariantName | "auto"): Shape;
293
321
  /**
@@ -312,10 +340,11 @@ export declare const MAX_FRAMES_DEFAULT = 80;
312
340
  * foveated archive — the session head (oldest) and the slice just before the
313
341
  * text region (newest) — with the denser low-quality tier filling the middle. */
314
342
  export declare const HQ_EDGE_FRAMES = 3;
315
- /** Conservative per-frame token estimate used for context budgeting — the
316
- * upper bound across shapes: high-res Claude frames hit the 4,784 visual-token
317
- * cap, billed at +5% margin (ceil(4784 * 1.05)). Keeps the overflow guard from
318
- * undercounting a high-res archive at the raised {@link MAX_FRAMES_DEFAULT}. */
343
+ /** Conservative per-frame token estimate for archive sizing and for frames of
344
+ * unknown size — the upper bound across shapes: high-res Claude frames hit the
345
+ * 4,784 visual-token cap, billed at +5% margin (ceil(4784 * 1.05)). Keeps the
346
+ * overflow guard from undercounting a high-res archive at the raised
347
+ * {@link MAX_FRAMES_DEFAULT}. */
319
348
  export declare const FRAME_TOKEN_ESTIMATE = 5024;
320
349
  /** Conservative upper bound for one persisted frame's base64 payload. The
321
350
  * measured high-res Anthropic `8x13`/`11on16` PNG frames sit around 159 KB;
@@ -328,8 +357,11 @@ export declare const FRAME_DATA_BYTES_ESTIMATE = 170000;
328
357
  * a 1M-token model can afford 70 images on paper, but not the resulting
329
358
  * ~11 MB JSON payload on every turn. */
330
359
  export declare const FRAME_DATA_BYTES_BUDGET = 3000000;
331
- /** Frame-count cap implied by {@link FRAME_DATA_BYTES_BUDGET}. */
332
- export declare function maxFramesForDataBudget(maxFrameDataBytes?: number): number;
360
+ /** Frame-count cap implied by {@link FRAME_DATA_BYTES_BUDGET} for frames
361
+ * rendered by `shape`. {@link AREA_PRICED_VARIANTS} below 1932px are charged
362
+ * {@link FRAME_DATA_BYTES_ESTIMATE} scaled by pixel area; every other shape
363
+ * pays the full estimate. */
364
+ export declare function maxFramesForDataBudget(shape: ShapeGeometry): number;
333
365
  /** Base64 byte length for persisted snapcompact frames. */
334
366
  export declare function frameDataBytes(frames: readonly Pick<Frame, "data">[]): number;
335
367
  /**
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@linxiraos/pi-snapcompact",
4
- "version": "1.1.26",
4
+ "version": "1.2.0",
5
5
  "description": "Bitmap-frame context compression for vision-capable LLMs",
6
6
  "homepage": "https://linxira-os.github.io/zeta/",
7
7
  "author": "Linxira-OS",
@@ -31,11 +31,11 @@
31
31
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
32
32
  },
33
33
  "dependencies": {
34
- "@linxiraos/pi-ai": "1.1.26",
35
- "@linxiraos/pi-catalog": "1.1.26",
36
- "@linxiraos/pi-natives": "1.1.26",
37
- "@linxiraos/pi-utils": "1.1.26",
38
- "@linxiraos/pi-wire": "1.1.26"
34
+ "@linxiraos/pi-ai": "1.2.0",
35
+ "@linxiraos/pi-catalog": "1.2.0",
36
+ "@linxiraos/pi-natives": "1.2.0",
37
+ "@linxiraos/pi-utils": "1.2.0",
38
+ "@linxiraos/pi-wire": "1.2.0"
39
39
  },
40
40
  "devDependencies": {
41
41
  "@types/bun": "^1.3.14"
@@ -5,7 +5,8 @@
5
5
  * conversation is rendered into PNG frames of pixel-font text that vision
6
6
  * models read back directly, like an archivist at a snapcompact frame
7
7
  * reader. Frames are `frameSize` wide; their height hugs the text rows
8
- * actually printed, so a partially filled frame never bills blank rows.
8
+ * actually printed, with a 64px floor for vision processors that reject
9
+ * smaller images.
9
10
  *
10
11
  * The frame shape is provider-aware. Original choices came from the SQuAD
11
12
  * prose evals (`packages/snapcompact`, 200k-token monolithic runs); the
@@ -31,12 +32,17 @@
31
32
  * billing (32px × 1.2, 10k-patch budget at `detail: "original"`) is
32
33
  * area-proportional, so resolution cannot improve chars/$ — 1568 stays.
33
34
  * `detail: "high"` would downgrade (2,500-patch cap); `original` is sent.
34
- * - **Unknown providers** default to `8on22-bw` with Anthropic-style
35
- * visual-token area billing. `providerImageBudget` still caps per-request
36
- * images per provider so inline imaging cannot flood a request with
37
- * attachments, but the old OpenRouter-specific 8-image cap is gone; routers
38
- * now use the same permissive budget as direct Anthropic/Claude lines unless
39
- * configured otherwise upstream.
35
+ * - **Unknown providers** default to `8on22-bw`. `providerImageBudget` still
36
+ * caps per-request images per provider so inline imaging cannot flood a
37
+ * request with attachments, but the old OpenRouter-specific 8-image cap is
38
+ * gone; routers now use the same permissive budget as direct
39
+ * Anthropic/Claude lines unless configured otherwise upstream.
40
+ *
41
+ * The per-frame token estimate follows the reading model's catalog
42
+ * `image-tokenization` rule (its lineage, not the gateway), so Claude behind
43
+ * OpenRouter is billed as Claude and Opus 4.6 under its 1,568-token cap.
44
+ * Models without a lineage rule fall back to the catalog rule for their wire
45
+ * API; a reader with neither costs {@link FRAME_TOKEN_ESTIMATE} per frame.
40
46
  *
41
47
  * The whole pass is local and deterministic — no LLM call, no API key, no
42
48
  * latency beyond rendering. Rasterization and PNG encoding happen in native
@@ -45,6 +51,7 @@
45
51
  * re-attached to the compaction summary message on every context rebuild.
46
52
  */
47
53
  import type { Api, ImageContent, Message, TextContent } from "@linxiraos/pi-ai";
54
+ import { type ImageSize, type ImageTokenization, type ImageTokenizationTarget } from "@linxiraos/pi-catalog/compat/image-tokenization";
48
55
  /** One eval-validated frame shape: font, cell, ink, repetition, and size. */
49
56
  export interface Shape {
50
57
  /** Bundled font in the native renderer. */
@@ -245,6 +252,23 @@ export type ShapeVariantName = keyof typeof SHAPE_VARIANTS;
245
252
  export declare const SHAPE_VARIANT_NAMES: readonly ShapeVariantName[];
246
253
  /** Runtime guard for variant names loaded from config. */
247
254
  export declare function isShapeVariantName(value: unknown): value is ShapeVariantName;
255
+ /**
256
+ * How a reader is billed per frame: its catalog `image-tokenization` rule
257
+ * (the model's lineage, else its wire API's fallback) and the `detail` hint
258
+ * snapcompact sends. OpenAI-family wires send `detail: "original"` (`high`
259
+ * caps the frame at 2,500 patches). Without a rule, every frame costs
260
+ * {@link FRAME_TOKEN_ESTIMATE}.
261
+ */
262
+ export interface FrameBilling {
263
+ rule?: ImageTokenization;
264
+ imageDetail?: ImageContent["detail"];
265
+ }
266
+ /** Resolve the frame billing of `target`. */
267
+ export declare function frameBilling(target: ShapeTarget | undefined): FrameBilling;
268
+ /** Identity of a frame price list: equal keys price every frame alike. */
269
+ export declare function frameBillingKey(billing: FrameBilling): string;
270
+ /** Billed-token estimate for one rendered frame of `size` under `billing`. */
271
+ export declare function frameTokens(billing: FrameBilling, size: ImageSize): number;
248
272
  /** Eval-validated shapes, keyed by the provider family they won on. */
249
273
  export declare const SHAPES: {
250
274
  /** `11on16-bw`: 8x13 glyphs on an 11px advance (extra tracking), black ink.
@@ -275,19 +299,23 @@ export interface IdealShape {
275
299
  }
276
300
  /** Eval-ideal format for a model id, or undefined when unmeasured. */
277
301
  export declare function idealShapeVariant(modelId: string): IdealShape | undefined;
278
- /** What will read the frames: the wire API (billing) and model id (shape). */
302
+ /** What will read the frames: the model id (shape and billing), the wire API
303
+ * (request hints, fallback billing), and optionally the host and parsed
304
+ * identity of a built model. */
279
305
  export interface ShapeTarget {
280
306
  api?: Api;
281
307
  id?: string;
308
+ provider?: string;
309
+ identity?: ImageTokenizationTarget["identity"];
282
310
  }
283
311
  /**
284
312
  * Pick the frame shape for a reader. An explicit `variant` (anything but
285
313
  * `"auto"`) forces that geometry; otherwise the model id selects the
286
314
  * eval-winning shape — and frame size — for its model line, falling back to
287
315
  * the API family's winner when the model is unmeasured. Billing (token
288
- * estimate, detail hint) always follows the API family actually carrying
289
- * the request, computed for the resolved frame size. Accepts a full pi-ai
290
- * `Model` or any `{ api, id }` subset.
316
+ * estimate, detail hint) is computed for the resolved frame size from the
317
+ * model's catalog image rule. Accepts a full pi-ai `Model` or any
318
+ * `{ api, id }` subset.
291
319
  */
292
320
  export declare function resolveShape(model?: ShapeTarget, variant?: ShapeVariantName | "auto"): Shape;
293
321
  /**
@@ -312,10 +340,11 @@ export declare const MAX_FRAMES_DEFAULT = 80;
312
340
  * foveated archive — the session head (oldest) and the slice just before the
313
341
  * text region (newest) — with the denser low-quality tier filling the middle. */
314
342
  export declare const HQ_EDGE_FRAMES = 3;
315
- /** Conservative per-frame token estimate used for context budgeting — the
316
- * upper bound across shapes: high-res Claude frames hit the 4,784 visual-token
317
- * cap, billed at +5% margin (ceil(4784 * 1.05)). Keeps the overflow guard from
318
- * undercounting a high-res archive at the raised {@link MAX_FRAMES_DEFAULT}. */
343
+ /** Conservative per-frame token estimate for archive sizing and for frames of
344
+ * unknown size — the upper bound across shapes: high-res Claude frames hit the
345
+ * 4,784 visual-token cap, billed at +5% margin (ceil(4784 * 1.05)). Keeps the
346
+ * overflow guard from undercounting a high-res archive at the raised
347
+ * {@link MAX_FRAMES_DEFAULT}. */
319
348
  export declare const FRAME_TOKEN_ESTIMATE = 5024;
320
349
  /** Conservative upper bound for one persisted frame's base64 payload. The
321
350
  * measured high-res Anthropic `8x13`/`11on16` PNG frames sit around 159 KB;
@@ -328,8 +357,11 @@ export declare const FRAME_DATA_BYTES_ESTIMATE = 170000;
328
357
  * a 1M-token model can afford 70 images on paper, but not the resulting
329
358
  * ~11 MB JSON payload on every turn. */
330
359
  export declare const FRAME_DATA_BYTES_BUDGET = 3000000;
331
- /** Frame-count cap implied by {@link FRAME_DATA_BYTES_BUDGET}. */
332
- export declare function maxFramesForDataBudget(maxFrameDataBytes?: number): number;
360
+ /** Frame-count cap implied by {@link FRAME_DATA_BYTES_BUDGET} for frames
361
+ * rendered by `shape`. {@link AREA_PRICED_VARIANTS} below 1932px are charged
362
+ * {@link FRAME_DATA_BYTES_ESTIMATE} scaled by pixel area; every other shape
363
+ * pays the full estimate. */
364
+ export declare function maxFramesForDataBudget(shape: ShapeGeometry): number;
333
365
  /** Base64 byte length for persisted snapcompact frames. */
334
366
  export declare function frameDataBytes(frames: readonly Pick<Frame, "data">[]): number;
335
367
  /**
@@ -5,7 +5,8 @@
5
5
  * conversation is rendered into PNG frames of pixel-font text that vision
6
6
  * models read back directly, like an archivist at a snapcompact frame
7
7
  * reader. Frames are `frameSize` wide; their height hugs the text rows
8
- * actually printed, so a partially filled frame never bills blank rows.
8
+ * actually printed, with a 64px floor for vision processors that reject
9
+ * smaller images.
9
10
  *
10
11
  * The frame shape is provider-aware. Original choices came from the SQuAD
11
12
  * prose evals (`packages/snapcompact`, 200k-token monolithic runs); the
@@ -31,12 +32,17 @@
31
32
  * billing (32px × 1.2, 10k-patch budget at `detail: "original"`) is
32
33
  * area-proportional, so resolution cannot improve chars/$ — 1568 stays.
33
34
  * `detail: "high"` would downgrade (2,500-patch cap); `original` is sent.
34
- * - **Unknown providers** default to `8on22-bw` with Anthropic-style
35
- * visual-token area billing. `providerImageBudget` still caps per-request
36
- * images per provider so inline imaging cannot flood a request with
37
- * attachments, but the old OpenRouter-specific 8-image cap is gone; routers
38
- * now use the same permissive budget as direct Anthropic/Claude lines unless
39
- * configured otherwise upstream.
35
+ * - **Unknown providers** default to `8on22-bw`. `providerImageBudget` still
36
+ * caps per-request images per provider so inline imaging cannot flood a
37
+ * request with attachments, but the old OpenRouter-specific 8-image cap is
38
+ * gone; routers now use the same permissive budget as direct
39
+ * Anthropic/Claude lines unless configured otherwise upstream.
40
+ *
41
+ * The per-frame token estimate follows the reading model's catalog
42
+ * `image-tokenization` rule (its lineage, not the gateway), so Claude behind
43
+ * OpenRouter is billed as Claude and Opus 4.6 under its 1,568-token cap.
44
+ * Models without a lineage rule fall back to the catalog rule for their wire
45
+ * API; a reader with neither costs {@link FRAME_TOKEN_ESTIMATE} per frame.
40
46
  *
41
47
  * The whole pass is local and deterministic — no LLM call, no API key, no
42
48
  * latency beyond rendering. Rasterization and PNG encoding happen in native
@@ -46,6 +52,13 @@
46
52
  */
47
53
 
48
54
  import type { Api, ImageContent, Message, TextContent } from "@linxiraos/pi-ai";
55
+ import {
56
+ type ImageSize,
57
+ type ImageTokenization,
58
+ type ImageTokenizationTarget,
59
+ imageTokens,
60
+ resolveImageTokenization,
61
+ } from "@linxiraos/pi-catalog/compat/image-tokenization";
49
62
  import { classifyModel, compareRevision, parseRevision } from "@linxiraos/pi-catalog/identity";
50
63
  import { renderSnapcompactPng, snapcompactSupportedChars } from "@linxiraos/pi-natives";
51
64
  import { formatGroupedPaths, prompt } from "@linxiraos/pi-utils";
@@ -202,10 +215,10 @@ export function isShapeVariantName(value: unknown): value is ShapeVariantName {
202
215
  return typeof value === "string" && value in SHAPE_VARIANTS;
203
216
  }
204
217
 
205
- /** Provider families with distinct image billing. */
206
- type BillingFamily = "anthropic" | "google" | "openai" | "unknown";
218
+ /** Wire API families with distinct default shapes and request hints. */
219
+ type ApiFamily = "anthropic" | "google" | "openai" | "unknown";
207
220
 
208
- function billingFamily(api?: Api): BillingFamily {
221
+ function apiFamily(api?: Api): ApiFamily {
209
222
  switch (api) {
210
223
  case "anthropic-messages":
211
224
  case "bedrock-converse-stream":
@@ -220,39 +233,47 @@ function billingFamily(api?: Api): BillingFamily {
220
233
  case "google-vertex":
221
234
  return "google";
222
235
  default:
223
- // Unknown APIs share Anthropic's pixel-area pricing as the safe ceiling.
224
236
  return "unknown";
225
237
  }
226
238
  }
227
239
 
228
240
  /**
229
- * Per-frame billing for a square frame of edge `frameSize`, by family.
230
- * Formulas verified against live bills in the resolution benchmarks:
231
- * - Anthropic: 28px patches, capped at 4,784 visual tokens (the API
232
- * downscales past the cap; 1568 → 3,136 measured) + 5% margin.
233
- * - Google: Gemini 3.x bills a fixed `media_resolution` budget per image —
234
- * default HIGH = 1,120 tokens — regardless of pixel size.
235
- * - OpenAI: 32px patches × 1.2 flagship multiplier, 10,000-patch budget at
236
- * `detail: "original"` (1568 → 2,881 measured).
241
+ * How a reader is billed per frame: its catalog `image-tokenization` rule
242
+ * (the model's lineage, else its wire API's fallback) and the `detail` hint
243
+ * snapcompact sends. OpenAI-family wires send `detail: "original"` (`high`
244
+ * caps the frame at 2,500 patches). Without a rule, every frame costs
245
+ * {@link FRAME_TOKEN_ESTIMATE}.
237
246
  */
238
- function familyBilling(family: BillingFamily, frameSize: number): Pick<Shape, "frameTokenEstimate" | "imageDetail"> {
239
- switch (family) {
240
- case "google":
241
- return { frameTokenEstimate: 1120 };
242
- case "openai": {
243
- const patches = Math.min(Math.ceil(frameSize / 32) ** 2, 10_000);
244
- return { frameTokenEstimate: Math.ceil(patches * 1.2), imageDetail: "original" };
245
- }
246
- default: {
247
- const patches = Math.min(Math.ceil(frameSize / 28) ** 2, 4784);
248
- return { frameTokenEstimate: Math.ceil(patches * 1.05) };
249
- }
250
- }
247
+ export interface FrameBilling {
248
+ rule?: ImageTokenization;
249
+ imageDetail?: ImageContent["detail"];
250
+ }
251
+
252
+ /** Resolve the frame billing of `target`. */
253
+ export function frameBilling(target: ShapeTarget | undefined): FrameBilling {
254
+ const rule = target ? resolveImageTokenization(target) : undefined;
255
+ const imageDetail = apiFamily(target?.api) === "openai" ? "original" : undefined;
256
+ return { ...(rule && { rule }), ...(imageDetail && { imageDetail }) };
257
+ }
258
+
259
+ /** Identity of a frame price list: equal keys price every frame alike. */
260
+ export function frameBillingKey(billing: FrameBilling): string {
261
+ return `${billing.imageDetail ?? "auto"}:${billing.rule ? JSON.stringify(billing.rule) : "ceiling"}`;
251
262
  }
252
263
 
253
- /** Attach a provider family's billing to a variant geometry. */
254
- function priceShape(base: ShapeGeometry, family: BillingFamily): Shape {
255
- return { ...base, ...familyBilling(family, base.frameSize) };
264
+ /** Billed-token estimate for one rendered frame of `size` under `billing`. */
265
+ export function frameTokens(billing: FrameBilling, size: ImageSize): number {
266
+ return billing.rule ? imageTokens(billing.rule, size, billing.imageDetail) : FRAME_TOKEN_ESTIMATE;
267
+ }
268
+
269
+ /**
270
+ * Attach the reader's billing to a variant geometry. The square price is a
271
+ * planning bound: shorter frames can bill differently.
272
+ */
273
+ function priceShape(base: ShapeGeometry, target: ShapeTarget | undefined): Shape {
274
+ const billing = frameBilling(target);
275
+ const frameTokenEstimate = frameTokens(billing, { width: base.frameSize, height: base.frameSize });
276
+ return { ...base, frameTokenEstimate, ...(billing.imageDetail && { imageDetail: billing.imageDetail }) };
256
277
  }
257
278
 
258
279
  /** Eval-validated shapes, keyed by the provider family they won on. */
@@ -262,18 +283,18 @@ export const SHAPES = {
262
283
  * on opus-4.8: f1 .806 vs .755 for plain `8on16-bw` and .351 for the prior
263
284
  * `6x12-dim` default — letter-spacing the readable cell wins; the dense
264
285
  * 6x12 was below the OCR ~16px/char floor and abstained. */
265
- anthropic: priceShape(SHAPE_VARIANTS["11on16-bw"], "anthropic"),
286
+ anthropic: priceShape(SHAPE_VARIANTS["11on16-bw"], { api: "anthropic-messages" }),
266
287
  /** `8on22-bw`: 8x13 glyphs on a 22px pitch (extra leading), black ink.
267
288
  * Tool-result legibility bench on gemini-3.5-flash: f1 .934 vs .807 for
268
289
  * plain `8on16-bw` and .287 for the prior `doc-8on16-sent-dim`; the
269
290
  * line-spacing reduces row crowding so line numbers stay legible. */
270
- google: priceShape(SHAPE_VARIANTS["8on22-bw"], "google"),
291
+ google: priceShape(SHAPE_VARIANTS["8on22-bw"], { api: "google-generative-ai" }),
271
292
  /** `8on22-bw`: 8x13 glyphs on a 22px pitch (extra leading), black ink.
272
293
  * Same line-spacing win for OpenAI; bench on gpt-5.5/gpt-5.4-mini showed
273
294
  * leading lifts recall on the readable cell over plain `8on16-bw`. */
274
- openai: priceShape(SHAPE_VARIANTS["8on22-bw"], "openai"),
295
+ openai: priceShape(SHAPE_VARIANTS["8on22-bw"], { api: "openai-responses" }),
275
296
  /** Original 5x8 X.org shape (pre-shape-table sessions rendered this). */
276
- legacy: priceShape(SHAPE_VARIANTS["5x8-sent"], "anthropic"),
297
+ legacy: priceShape(SHAPE_VARIANTS["5x8-sent"], { api: "anthropic-messages" }),
277
298
  } satisfies Record<string, Shape>;
278
299
 
279
300
  /** Runtime guard for shape overrides loaded from config or preserve data. */
@@ -303,9 +324,9 @@ export function isShape(value: unknown): value is Shape {
303
324
  );
304
325
  }
305
326
 
306
- /** Eval-winning variant per provider family (billing fallback when the
307
- * model id matches no known reader line). */
308
- const FAMILY_VARIANT: Record<BillingFamily, ShapeVariantName> = {
327
+ /** Eval-winning variant per wire family (fallback when the model id matches
328
+ * no known reader line). */
329
+ const FAMILY_VARIANT: Record<ApiFamily, ShapeVariantName> = {
309
330
  anthropic: "11on16-bw",
310
331
  google: "8on22-bw",
311
332
  openai: "8on22-bw",
@@ -316,20 +337,13 @@ const FAMILY_VARIANT: Record<BillingFamily, ShapeVariantName> = {
316
337
  * pixels (identical per-frame bill) but a tighter 8px cell, trading some
317
338
  * legibility for ~40% more chars per frame so the least-important middle of a
318
339
  * long archive compresses into fewer frames. */
319
- const FAMILY_VARIANT_LOW: Record<BillingFamily, ShapeVariantName> = {
340
+ const FAMILY_VARIANT_LOW: Record<ApiFamily, ShapeVariantName> = {
320
341
  anthropic: "8on16-bw",
321
342
  google: "8on16-bw",
322
343
  openai: "8on16-bw",
323
344
  unknown: "8on16-bw",
324
345
  };
325
346
 
326
- const FAMILY_SHAPE: Record<BillingFamily, Shape> = {
327
- anthropic: SHAPES.anthropic,
328
- google: SHAPES.google,
329
- openai: SHAPES.openai,
330
- unknown: priceShape(SHAPE_VARIANTS["8on22-bw"], "unknown"),
331
- };
332
-
333
347
  /** One model line's ideal format: variant plus an optional frame-size
334
348
  * override when the line reads larger frames at no extra cost. */
335
349
  export interface IdealShape {
@@ -386,10 +400,14 @@ export function idealShapeVariant(modelId: string): IdealShape | undefined {
386
400
  return MODEL_VARIANTS.find(([pattern]) => pattern.test(modelId))?.[1];
387
401
  }
388
402
 
389
- /** What will read the frames: the wire API (billing) and model id (shape). */
403
+ /** What will read the frames: the model id (shape and billing), the wire API
404
+ * (request hints, fallback billing), and optionally the host and parsed
405
+ * identity of a built model. */
390
406
  export interface ShapeTarget {
391
407
  api?: Api;
392
408
  id?: string;
409
+ provider?: string;
410
+ identity?: ImageTokenizationTarget["identity"];
393
411
  }
394
412
 
395
413
  /**
@@ -397,18 +415,15 @@ export interface ShapeTarget {
397
415
  * `"auto"`) forces that geometry; otherwise the model id selects the
398
416
  * eval-winning shape — and frame size — for its model line, falling back to
399
417
  * the API family's winner when the model is unmeasured. Billing (token
400
- * estimate, detail hint) always follows the API family actually carrying
401
- * the request, computed for the resolved frame size. Accepts a full pi-ai
402
- * `Model` or any `{ api, id }` subset.
418
+ * estimate, detail hint) is computed for the resolved frame size from the
419
+ * model's catalog image rule. Accepts a full pi-ai `Model` or any
420
+ * `{ api, id }` subset.
403
421
  */
404
422
  export function resolveShape(model?: ShapeTarget, variant?: ShapeVariantName | "auto"): Shape {
405
- const family = billingFamily(model?.api);
406
- if (variant && variant !== "auto") return priceShape(SHAPE_VARIANTS[variant], family);
423
+ if (variant && variant !== "auto") return priceShape(SHAPE_VARIANTS[variant], model);
407
424
  const ideal = model?.id ? idealShapeVariant(model.id) : undefined;
408
- const name = ideal?.variant ?? FAMILY_VARIANT[family];
409
- if (name === FAMILY_VARIANT[family] && ideal?.frameSize === undefined) return FAMILY_SHAPE[family];
410
- const base = SHAPE_VARIANTS[name];
411
- return priceShape(ideal?.frameSize ? { ...base, frameSize: ideal.frameSize } : base, family);
425
+ const base = SHAPE_VARIANTS[ideal?.variant ?? FAMILY_VARIANT[apiFamily(model?.api)]];
426
+ return priceShape(ideal?.frameSize ? { ...base, frameSize: ideal.frameSize } : base, model);
412
427
  }
413
428
 
414
429
  const CJK_HEAVY_MIN_WIDE_CHARS = 8;
@@ -468,10 +483,11 @@ export const MAX_FRAMES_DEFAULT = 80;
468
483
  * text region (newest) — with the denser low-quality tier filling the middle. */
469
484
  export const HQ_EDGE_FRAMES = 3;
470
485
 
471
- /** Conservative per-frame token estimate used for context budgeting — the
472
- * upper bound across shapes: high-res Claude frames hit the 4,784 visual-token
473
- * cap, billed at +5% margin (ceil(4784 * 1.05)). Keeps the overflow guard from
474
- * undercounting a high-res archive at the raised {@link MAX_FRAMES_DEFAULT}. */
486
+ /** Conservative per-frame token estimate for archive sizing and for frames of
487
+ * unknown size — the upper bound across shapes: high-res Claude frames hit the
488
+ * 4,784 visual-token cap, billed at +5% margin (ceil(4784 * 1.05)). Keeps the
489
+ * overflow guard from undercounting a high-res archive at the raised
490
+ * {@link MAX_FRAMES_DEFAULT}. */
475
491
  export const FRAME_TOKEN_ESTIMATE = 5024;
476
492
 
477
493
  /** Conservative upper bound for one persisted frame's base64 payload. The
@@ -487,9 +503,29 @@ export const FRAME_DATA_BYTES_ESTIMATE = 170_000;
487
503
  * ~11 MB JSON payload on every turn. */
488
504
  export const FRAME_DATA_BYTES_BUDGET = 3_000_000;
489
505
 
490
- /** Frame-count cap implied by {@link FRAME_DATA_BYTES_BUDGET}. */
491
- export function maxFramesForDataBudget(maxFrameDataBytes: number = FRAME_DATA_BYTES_BUDGET): number {
492
- return Math.max(1, Math.floor(maxFrameDataBytes / FRAME_DATA_BYTES_ESTIMATE));
506
+ /** Default edge variants whose frames shrink with pixel area. Inkier variants
507
+ * keep the full {@link FRAME_DATA_BYTES_ESTIMATE} at any frame size. */
508
+ const AREA_PRICED_VARIANTS: readonly ShapeGeometry[] = [SHAPE_VARIANTS["8on22-bw"], SHAPE_VARIANTS["11on16-bw"]];
509
+
510
+ /** Frame-count cap implied by {@link FRAME_DATA_BYTES_BUDGET} for frames
511
+ * rendered by `shape`. {@link AREA_PRICED_VARIANTS} below 1932px are charged
512
+ * {@link FRAME_DATA_BYTES_ESTIMATE} scaled by pixel area; every other shape
513
+ * pays the full estimate. */
514
+ export function maxFramesForDataBudget(shape: ShapeGeometry): number {
515
+ const areaPriced = AREA_PRICED_VARIANTS.some(
516
+ variant =>
517
+ variant.font === shape.font &&
518
+ variant.cellWidth === shape.cellWidth &&
519
+ variant.cellHeight === shape.cellHeight &&
520
+ variant.stretch === shape.stretch &&
521
+ variant.variant === shape.variant &&
522
+ variant.stopwordDim === shape.stopwordDim &&
523
+ variant.columns === shape.columns &&
524
+ variant.lineRepeat === shape.lineRepeat,
525
+ );
526
+ const areaRatio = areaPriced ? (shape.frameSize / HIGH_RES_ANTHROPIC_VARIANT.frameSize) ** 2 : 1;
527
+ const frameBytes = Math.min(FRAME_DATA_BYTES_ESTIMATE, Math.ceil(FRAME_DATA_BYTES_ESTIMATE * areaRatio));
528
+ return Math.max(1, Math.floor(FRAME_DATA_BYTES_BUDGET / frameBytes));
493
529
  }
494
530
 
495
531
  /** Base64 byte length for persisted snapcompact frames. */
@@ -1945,8 +1981,12 @@ export function historyBlocks(archive: Archive, options: HistoryBlockOptions = {
1945
1981
  * variant exists (foveation off). */
1946
1982
  function denseCompanion(high: Shape, api: Api | undefined): Shape {
1947
1983
  if (high.columns === 2 || high.font === "silver") return high;
1948
- const family = billingFamily(api);
1949
- const low = priceShape({ ...SHAPE_VARIANTS[FAMILY_VARIANT_LOW[family]], frameSize: high.frameSize }, family);
1984
+ const low: Shape = {
1985
+ ...SHAPE_VARIANTS[FAMILY_VARIANT_LOW[apiFamily(api)]],
1986
+ frameSize: high.frameSize,
1987
+ frameTokenEstimate: high.frameTokenEstimate,
1988
+ ...(high.imageDetail && { imageDetail: high.imageDetail }),
1989
+ };
1950
1990
  return geometry(low).capacity > geometry(high).capacity ? low : high;
1951
1991
  }
1952
1992