@linxiraos/pi-agent-core 1.1.26 → 1.1.27

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.
@@ -14,7 +14,7 @@ import {
14
14
  isSkillReadToolResult,
15
15
  type ProtectedToolMatcher,
16
16
  } from "./tool-protection";
17
- import { splitReadSelector } from "./utils";
17
+ import { isAlternateFormReadSelector, splitReadSelector } from "./utils";
18
18
 
19
19
  export interface PruneConfig {
20
20
  /** Keep the most recent tool output tokens intact. */
@@ -30,6 +30,8 @@ export interface PruneConfig {
30
30
  * unchanged.
31
31
  */
32
32
  supersedeKey?: SupersedeKeyFn;
33
+ /** Whether a keyed result shows its whole target (see {@link SupersedePruneConfig.supersedeComplete}). */
34
+ supersedeComplete?: SupersedeCompleteFn;
33
35
  /** Useless-flagged results bypass the protect window (see {@link USELESS_NOTICE}). Default true. */
34
36
  pruneUseless?: boolean;
35
37
  /**
@@ -100,16 +102,37 @@ export const USELESS_NOTICE = "[Uneventful result elided]";
100
102
 
101
103
  /**
102
104
  * Maps a tool call to a supersede key. Results sharing a key form a group in
103
- * which every result except the newest is a supersede candidate. A key `K`
104
- * additionally supersedes keys with prefix `K + "\u0000"` (selector-free read
105
- * supersedes selector-carrying reads of the same base path). Return
106
- * `undefined` to exempt a call from supersede grouping.
105
+ * which a newer successful result supersedes every older one. A key `K` also
106
+ * covers keys with prefix `K + "\u0000"`: they are superseded only when the
107
+ * newest successful result for `K` is complete (see
108
+ * {@link SupersedeCompleteFn}). Return `undefined` to exempt a call from
109
+ * supersede grouping.
107
110
  */
108
111
  export type SupersedeKeyFn = (toolName: string, args: Record<string, unknown>) => string | undefined;
109
112
 
113
+ /**
114
+ * Whether a successful keyed result shows its whole target, so a result for a
115
+ * parent key (e.g. a selector-free read) may supersede child-key results (e.g.
116
+ * range reads). A summary, truncated page, or notice is not complete.
117
+ */
118
+ export type SupersedeCompleteFn = (message: ToolResultMessage) => boolean;
119
+
110
120
  export interface SupersedePruneConfig {
111
- /** Supersede key function; results sharing a key supersede older ones. */
121
+ /** Supersede key function; a newer successful result with the same key supersedes older ones (see {@link SupersedeKeyFn}). */
112
122
  supersedeKey?: SupersedeKeyFn;
123
+ /**
124
+ * Whether a successful keyed result is complete. Absent, every successful
125
+ * result is complete. Child-key results are superseded only when the parent
126
+ * key's newest successful result is complete; same-key results follow
127
+ * newest-wins, since a same-key re-read that shows a different view means
128
+ * the target changed. A failed result supersedes only older failed results.
129
+ *
130
+ * Trade-off, by design: after `read foo.ts:50-200` → edit → a bare
131
+ * `read foo.ts` that returns a summary, the pre-edit range stays in context
132
+ * until a complete bare read or a same-range re-read. Keeping possibly stale
133
+ * detail is preferred over losing detail the summary does not show.
134
+ */
135
+ supersedeComplete?: SupersedeCompleteFn;
113
136
  /** Also prune results flagged useless by their tool. Default false. */
114
137
  pruneUseless?: boolean;
115
138
  /** Prune a candidate now when all messages after it total at most this many estimated tokens. Default 8 000. */
@@ -196,21 +219,35 @@ interface SupersedeCandidate {
196
219
  notice: string;
197
220
  }
198
221
 
222
+ /**
223
+ * Newer results of one key. An entry exists once any newer result (even a
224
+ * failed one) was seen. `complete` describes the newest successful result
225
+ * only: older successes of the key are superseded, so they cannot vouch for
226
+ * child keys.
227
+ */
228
+ interface NewerResults {
229
+ success: boolean;
230
+ complete: boolean;
231
+ }
232
+
199
233
  /**
200
234
  * Collect superseded tool results: for every unpruned, unprotected tool result
201
- * whose paired call resolves a supersede key, a LATER result with the same key
202
- * — or with a key that is the `"\u0000"`-prefix parent of this one — marks it
203
- * superseded. Returned in message order.
235
+ * whose paired call resolves a supersede key, a LATER successful result of the
236
+ * same key supersedes it, as does its `"\u0000"`-prefix parent key when that
237
+ * key's newest successful result is complete. A failed result supersedes only
238
+ * older failed results; an older failed result is superseded by any later
239
+ * result of its key or a later successful result of its parent key.
204
240
  */
205
241
  function collectSupersededResults(
206
242
  entries: readonly SessionEntry[],
207
243
  tokenizer: Tokenizer,
208
244
  toolCallsById: ReadonlyMap<string, AgentToolCall>,
209
245
  supersedeKey: SupersedeKeyFn,
246
+ supersedeComplete: SupersedeCompleteFn | undefined,
210
247
  protectedTools: readonly ProtectedToolMatcher[],
211
248
  ): SupersedeCandidate[] {
212
249
  const candidates: SupersedeCandidate[] = [];
213
- const seenKeys = new Set<string>();
250
+ const newerByKey = new Map<string, NewerResults>();
214
251
  for (let i = entries.length - 1; i >= 0; i--) {
215
252
  const entry = entries[i];
216
253
  const message = getToolResultMessage(entry);
@@ -221,8 +258,17 @@ function collectSupersededResults(
221
258
  const key = supersedeKey(toolCall.name, toolCall.arguments as Record<string, unknown>);
222
259
  if (key === undefined) continue;
223
260
  const separator = key.indexOf("\u0000");
224
- const superseded = seenKeys.has(key) || (separator >= 0 && seenKeys.has(key.slice(0, separator)));
225
- seenKeys.add(key);
261
+ const sameKey = newerByKey.get(key);
262
+ const parent = separator >= 0 ? newerByKey.get(key.slice(0, separator)) : undefined;
263
+ const superseded = message.isError
264
+ ? sameKey !== undefined || parent?.success === true
265
+ : sameKey?.success === true || parent?.complete === true;
266
+ const newer = sameKey ?? { success: false, complete: false };
267
+ if (!message.isError && !newer.success) {
268
+ newer.success = true;
269
+ newer.complete = supersedeComplete?.(message) ?? true;
270
+ }
271
+ newerByKey.set(key, newer);
226
272
  if (!superseded) continue;
227
273
  candidates.push({
228
274
  entry: entry as SessionMessageEntry,
@@ -278,7 +324,14 @@ export function pruneSupersededToolResults(
278
324
  ): PruneResult {
279
325
  const toolCallsById = collectToolCallsById(entries);
280
326
  const candidates = config.supersedeKey
281
- ? collectSupersededResults(entries, tokenizer, toolCallsById, config.supersedeKey, config.protectedTools)
327
+ ? collectSupersededResults(
328
+ entries,
329
+ tokenizer,
330
+ toolCallsById,
331
+ config.supersedeKey,
332
+ config.supersedeComplete,
333
+ config.protectedTools,
334
+ )
282
335
  : [];
283
336
  if (config.pruneUseless) {
284
337
  const exclude = new Set(candidates.map(candidate => candidate.message));
@@ -341,9 +394,14 @@ export function pruneToolOutputs(
341
394
  const toolCallsById = collectToolCallsById(entries);
342
395
  const supersededMessages = config.supersedeKey
343
396
  ? new Set(
344
- collectSupersededResults(entries, tokenizer, toolCallsById, config.supersedeKey, config.protectedTools).map(
345
- candidate => candidate.message,
346
- ),
397
+ collectSupersededResults(
398
+ entries,
399
+ tokenizer,
400
+ toolCallsById,
401
+ config.supersedeKey,
402
+ config.supersedeComplete,
403
+ config.protectedTools,
404
+ ).map(candidate => candidate.message),
347
405
  )
348
406
  : undefined;
349
407
  const uselessMessages =
@@ -440,10 +498,11 @@ export function pruneToolOutputs(
440
498
  * selector stripped (the read tool's own splitter grammar via
441
499
  * {@link splitReadSelector}, e.g. `src/foo.ts:50-200`, `:2-4:raw`).
442
500
  * Internal/URL-scheme paths (`skill://…`, `https://…`) are exempt.
443
- * Selector-free reads key on the bare path; selector-carrying reads key on
444
- * `path + "\u0000" + selector`, so two reads collide only when the newer is
445
- * selector-free or the selectors are identical (the pass's prefix rule lets a
446
- * bare-path read supersede selector-carrying reads of the same file).
501
+ * Selector-free reads key on the bare path; line-range reads key on
502
+ * `path + "\u0000" + selector`, so a complete bare-path read can supersede them.
503
+ * `raw` and `conflicts` reads show a different form than a plain read (e.g. raw
504
+ * notebook JSON vs converted notebook text), so they key on
505
+ * `path + "\u0001" + selector` and are superseded only by the same selector.
447
506
  */
448
507
  export function readToolSupersedeKey(toolName: string, args: Record<string, unknown>): string | undefined {
449
508
  if (toolName !== "read") return undefined;
@@ -451,5 +510,6 @@ export function readToolSupersedeKey(toolName: string, args: Record<string, unkn
451
510
  if (typeof path !== "string" || path.length === 0) return undefined;
452
511
  if (path.includes("://")) return undefined;
453
512
  const { path: base, sel } = splitReadSelector(path);
454
- return sel === undefined ? base : `${base}\u0000${sel}`;
513
+ if (sel === undefined) return base;
514
+ return `${base}${isAlternateFormReadSelector(sel) ? "\u0001" : "\u0000"}${sel}`;
455
515
  }
@@ -38,6 +38,7 @@ const RANGE_LIST_SRC = `${RANGE_CHUNK_SRC}(?:,${RANGE_CHUNK_SRC})*`;
38
38
  const READ_SELECTOR_RE = new RegExp(`^(?:${RANGE_LIST_SRC}|raw|conflicts)$`, "i");
39
39
  const READ_RANGE_ONLY_RE = new RegExp(`^${RANGE_LIST_SRC}$`, "i");
40
40
  const READ_RAW_ONLY_RE = /^raw$/i;
41
+ const READ_ALTERNATE_FORM_RE = /^(?:raw|conflicts)$/i;
41
42
 
42
43
  /**
43
44
  * Split a read-tool path into its base path and trailing selector, mirroring the
@@ -77,6 +78,14 @@ export function stripReadSelector(path: string): string {
77
78
  return splitReadSelector(path).path;
78
79
  }
79
80
 
81
+ /**
82
+ * Whether a selector from {@link splitReadSelector} shows the file in a form other
83
+ * than its plain lines: verbatim `raw` bytes or the merge `conflicts` view.
84
+ */
85
+ export function isAlternateFormReadSelector(sel: string): boolean {
86
+ return sel.split(":").some(part => READ_ALTERNATE_FORM_RE.test(part));
87
+ }
88
+
80
89
  /**
81
90
  * A real filesystem path never contains a `scheme://` URL. Tool-call paths that
82
91
  * do — `conflict://1`, `artifact://3`, `local://ctx.md`, `history://…`,
@@ -0,0 +1,77 @@
1
+ import type { ImageContent } from "@linxiraos/pi-ai";
2
+ import {
3
+ type ImageSize,
4
+ type ImageTokenization,
5
+ imageTokens,
6
+ resolveImageTokenization,
7
+ } from "@linxiraos/pi-catalog/compat/image-tokenization";
8
+ import { parseImageMetadata } from "@linxiraos/pi-utils";
9
+
10
+ /**
11
+ * Dimension-based image token estimates shared by the local context counter
12
+ * ({@link Tokenizer.countMessage}) and the native remote-compaction fit probe.
13
+ * Both sides must price the same image the same way: when they disagreed
14
+ * (1.2k flat locally vs a 12k flat worst case remotely) an image-heavy session
15
+ * stayed under the local compaction trigger yet was refused as over-window by
16
+ * the remote probe.
17
+ *
18
+ * Follows the catalog's image rule for the OpenAI Responses wire (GPT-5.5's
19
+ * 32px patches, a per-detail pixel limit and patch budget, x1.2 multiplier),
20
+ * which is also a close upper estimate for other providers once omp has
21
+ * downscaled the image (≤1568px by default).
22
+ */
23
+
24
+ /** OpenAI image `detail` values; `undefined` means the provider default (`auto`). */
25
+ export type ImageDetail = ImageContent["detail"];
26
+
27
+ export type { ImageSize };
28
+
29
+ // Larger than every detail level's pixel limit, so an unknown size charges
30
+ // the level's full patch budget.
31
+ const UNKNOWN_SIZE: ImageSize = { width: 65_535, height: 65_535 };
32
+
33
+ let wireRule: ImageTokenization | undefined;
34
+
35
+ /** The OpenAI Responses wire's image rule, resolved from the catalog once. */
36
+ function openAiWireRule(): ImageTokenization {
37
+ if (wireRule) return wireRule;
38
+ const rule = resolveImageTokenization({ api: "openai-responses" });
39
+ if (!rule) throw new Error("The catalog has no image-tokenization rule for the openai-responses wire");
40
+ wireRule = rule;
41
+ return rule;
42
+ }
43
+
44
+ /**
45
+ * Estimated input tokens for one image. Unknown dimensions (undecodable data,
46
+ * remote URLs, provider file ids) charge the detail level's full patch budget.
47
+ */
48
+ export function estimateImageTokens(size: ImageSize | null | undefined, detail?: ImageDetail): number {
49
+ return imageTokens(openAiWireRule(), size && size.width > 0 && size.height > 0 ? size : UNKNOWN_SIZE, detail);
50
+ }
51
+
52
+ // Enough decoded bytes to reach a JPEG SOF marker behind typical EXIF/ICC
53
+ // segments; PNG/GIF/WebP need only the first 30 bytes. Multiple of 4 so the
54
+ // base64 slice decodes cleanly.
55
+ const HEADER_BASE64_CHARS = 4 * Math.ceil((64 * 1024) / 3);
56
+
57
+ /** Pixel size read from the header of base64-encoded image data, or null. */
58
+ export function base64ImageSize(base64: string): ImageSize | null {
59
+ if (base64.length === 0) return null;
60
+ const header = Buffer.from(base64.slice(0, HEADER_BASE64_CHARS), "base64");
61
+ const metadata = parseImageMetadata(header);
62
+ if (!metadata?.width || !metadata.height) return null;
63
+ return { width: metadata.width, height: metadata.height };
64
+ }
65
+
66
+ /** Pixel size of a `data:<mime>;base64,` URL, or null for any other URL. */
67
+ export function dataUrlImageSize(url: string): ImageSize | null {
68
+ if (!url.startsWith("data:")) return null;
69
+ const comma = url.indexOf(",");
70
+ if (comma < 0 || !url.slice(0, comma).endsWith(";base64")) return null;
71
+ return base64ImageSize(url.slice(comma + 1));
72
+ }
73
+
74
+ /** {@link estimateImageTokens} for an in-memory image block. */
75
+ export function estimateImageContentTokens(image: Pick<ImageContent, "data" | "detail">): number {
76
+ return estimateImageTokens(base64ImageSize(image.data), image.detail);
77
+ }
@@ -0,0 +1,19 @@
1
+ Tool call failed: your previous response tried to invoke a tool, but the DSML markup was malformed, so it arrived as plain text, no tool was executed, and the broken markup was removed from your response. Usually the opening `<|DSML|tool_calls>` and/or `<|DSML|invoke name="…">` tags are missing, or the tool name was written as a plain line.
2
+
3
+ A tool call must be one complete envelope:
4
+
5
+ ```
6
+ <|DSML|tool_calls>
7
+ <|DSML|invoke name="TOOL_NAME">
8
+ <|DSML|parameter name="ARG_NAME" string="true">text value</|DSML|parameter>
9
+ <|DSML|parameter name="OTHER_ARG" string="false">{"json": "value"}</|DSML|parameter>
10
+ </|DSML|invoke>
11
+ </|DSML|tool_calls>
12
+ ```
13
+
14
+ - `<|DSML|tool_calls>` wraps every call; put one `<|DSML|invoke>` per call inside it.
15
+ - The tool name goes in the `name` attribute of `<|DSML|invoke>`, never on its own line.
16
+ - Use `string="true"` for raw text values and `string="false"` for numbers, booleans, arrays, and objects (JSON).
17
+ - Close every tag.
18
+
19
+ Re-issue the intended call now in this shape, or reply in plain prose if no tool is needed.
package/src/telemetry.ts CHANGED
@@ -204,10 +204,18 @@ export interface ChatUsageSnapshot {
204
204
 
205
205
  /** Context passed to the cost estimator. */
206
206
  export interface CostEstimatorContext {
207
+ /** OTel provider label (`gen_ai.provider.name`). */
207
208
  readonly provider: string;
209
+ /** Provider id before OTel normalization (e.g. `openai-codex` for the `openai` label). */
210
+ readonly providerId: string | undefined;
211
+ /** Model id reported on the response; a provider may report a served model instead of the requested one. */
208
212
  readonly model: string;
213
+ /** Requested model id, when the caller knows it. */
214
+ readonly modelId: string | undefined;
209
215
  readonly serviceTier: ServiceTier | undefined;
210
216
  readonly usage: ChatUsageSnapshot;
217
+ /** Cost the provider already computed for this request; the value persisted with the message. */
218
+ readonly usageCost: Usage["cost"];
211
219
  }
212
220
 
213
221
  /**
@@ -1138,6 +1146,8 @@ export async function finishChatSpan(
1138
1146
  message: AssistantMessage,
1139
1147
  options: {
1140
1148
  readonly stepNumber: number;
1149
+ /** Requested model id; `message.model` may name a served fallback model instead. */
1150
+ readonly modelId?: string;
1141
1151
  readonly serviceTier?: ServiceTier;
1142
1152
  readonly responseHeaders?: Readonly<Record<string, string>>;
1143
1153
  readonly baseUrl?: string;
@@ -1147,7 +1157,7 @@ export async function finishChatSpan(
1147
1157
  applyChatResponseAttributes(span, message);
1148
1158
  applyUsageAttributes(span, message.usage);
1149
1159
  applyGatewayAttributes(span, options.responseHeaders, options.baseUrl);
1150
- const cost = applyCostEstimate(telemetry, span, message, options.serviceTier, options.stepNumber);
1160
+ const cost = applyCostEstimate(telemetry, span, message, options.serviceTier, options.stepNumber, options.modelId);
1151
1161
  if (telemetry) {
1152
1162
  await emitChatUsage(telemetry, span, {
1153
1163
  operation: GenAIOperation.Chat,
@@ -1367,10 +1377,12 @@ function applyCostEstimate(
1367
1377
  message: AssistantMessage,
1368
1378
  serviceTier: ServiceTier | undefined,
1369
1379
  stepNumber: number | undefined,
1380
+ modelId: string | undefined,
1370
1381
  ): AppliedCostEstimate {
1371
1382
  if (!telemetry) return EMPTY_COST;
1372
1383
  return applyCostEstimateForUsage(telemetry, span, {
1373
1384
  model: message.model,
1385
+ modelId,
1374
1386
  provider: message.provider,
1375
1387
  serviceTier,
1376
1388
  stepNumber,
@@ -1383,6 +1395,7 @@ function applyCostEstimateForUsage(
1383
1395
  span: Span,
1384
1396
  input: {
1385
1397
  readonly model: string;
1398
+ readonly modelId: string | undefined;
1386
1399
  readonly provider: string | undefined;
1387
1400
  readonly serviceTier: ServiceTier | undefined;
1388
1401
  readonly stepNumber: number | undefined;
@@ -1398,9 +1411,12 @@ function applyCostEstimateForUsage(
1398
1411
  try {
1399
1412
  result = estimator({
1400
1413
  provider,
1414
+ providerId: input.provider,
1401
1415
  model: input.model,
1416
+ modelId: input.modelId,
1402
1417
  serviceTier: input.serviceTier,
1403
1418
  usage,
1419
+ usageCost: input.usage.cost,
1404
1420
  });
1405
1421
  } catch (err) {
1406
1422
  emitTelemetryWarning(telemetry, {
@@ -1620,6 +1636,7 @@ export async function recordManualChatTelemetry(
1620
1636
  if (telemetry) {
1621
1637
  const applied = applyCostEstimateForUsage(telemetry, span, {
1622
1638
  model: options.responseModel ?? options.model.id,
1639
+ modelId: options.model.id,
1623
1640
  provider: options.model.provider,
1624
1641
  serviceTier: options.serviceTier,
1625
1642
  stepNumber: options.stepNumber,
@@ -1889,6 +1906,7 @@ export async function instrumentedCompleteSimple<TApi extends Api>(
1889
1906
  : await runOnce();
1890
1907
  await finishChatSpan(telemetry, chatSpan, message, {
1891
1908
  stepNumber,
1909
+ modelId: model.id,
1892
1910
  serviceTier: options.serviceTier,
1893
1911
  responseHeaders: capturedHeaders,
1894
1912
  baseUrl: model.baseUrl,
package/src/tokenizer.ts CHANGED
@@ -5,6 +5,7 @@ import { materializeString, stringifyJson } from "@linxiraos/pi-utils";
5
5
  import { LRUCache } from "@linxiraos/pi-utils/lru";
6
6
  import * as snapcompact from "@linxiraos/pi-snapcompact";
7
7
  import { isEstimateCacheable, messageEstimateVersion } from "./compaction/message-cache";
8
+ import { base64ImageSize, estimateImageContentTokens } from "./image-tokens";
8
9
  import type { AgentMessage } from "./types";
9
10
 
10
11
  const testEnv = Bun.env.NODE_ENV === "test";
@@ -131,12 +132,6 @@ export interface TokenBudgetCheck {
131
132
  exact: boolean;
132
133
  }
133
134
 
134
- /**
135
- * Image content has no tokenizer representation; charge a fixed estimate
136
- * matching what providers typically bill for inline images.
137
- */
138
- const IMAGE_TOKEN_ESTIMATE = 1200;
139
-
140
135
  /**
141
136
  * Memoized estimates for one message under this tokenizer's encoding, split by
142
137
  * the {@link MessageCountOptions.excludeEncryptedReasoning} option so the two
@@ -154,13 +149,15 @@ interface MessageEstimate {
154
149
  * Model-aware local token counter. Immutable: the catalog-resolved encoding
155
150
  * is fixed at construction, so a cached count can never straddle two
156
151
  * encodings. An `Agent` owns one for its active model (swapping the instance
157
- * when the model's encoding changes); one-shot flows construct their own for
158
- * the model that will be billed. Known tokenizer families use exact native
159
- * counts; unknown models keep the fast byte estimate (or o200k when
160
- * `PI_TOKENIZER_ACCURATE=1`).
152
+ * when the model's encoding or snapcompact frame pricing changes); one-shot
153
+ * flows construct their own for the model that will be billed. Known
154
+ * tokenizer families use exact native counts; unknown models keep the fast
155
+ * byte estimate (or o200k when `PI_TOKENIZER_ACCURATE=1`).
161
156
  */
162
157
  export class Tokenizer {
163
158
  readonly #encoding: natives.Encoding | null;
159
+ readonly #frameBilling: snapcompact.FrameBilling;
160
+ readonly frameBillingKey: string;
164
161
 
165
162
  /** Exact counts only; byte fallbacks remain mode-dependent and uncached. */
166
163
  readonly #nativeCounts = new LRUCache<string, number>({
@@ -179,8 +176,10 @@ export class Tokenizer {
179
176
  */
180
177
  #estimates = new WeakMap<AgentMessage, MessageEstimate>();
181
178
 
182
- constructor(model?: Pick<Model, "tokenizer"> | null) {
179
+ constructor(model?: (Pick<Model, "tokenizer"> & snapcompact.ShapeTarget) | null) {
183
180
  this.#encoding = tokenizerEncodingForModel(model);
181
+ this.#frameBilling = snapcompact.frameBilling(model ?? undefined);
182
+ this.frameBillingKey = snapcompact.frameBillingKey(this.#frameBilling);
184
183
  }
185
184
 
186
185
  get encoding(): natives.Encoding | null {
@@ -219,7 +218,8 @@ export class Tokenizer {
219
218
  * Settled historical messages are counted once and reused until an owner
220
219
  * (prune/shake/strip-images) calls `invalidateMessageCache`; streaming
221
220
  * assistants bypass the memo entirely (see the message-cache settle-gate
222
- * invariant). Image blocks charge a fixed per-image estimate.
221
+ * invariant). Image blocks charge {@link estimateImageContentTokens}, the
222
+ * same dimension-based estimate the native remote-compaction probe applies.
223
223
  */
224
224
  countMessage(message: AgentMessage, options?: MessageCountOptions): number {
225
225
  const floored = options?.excludeEncryptedReasoning === true;
@@ -273,7 +273,7 @@ export class Tokenizer {
273
273
  if (block.type === "text" && block.text) {
274
274
  fragments.push(block.text);
275
275
  } else if (block.type === "image") {
276
- extra += IMAGE_TOKEN_ESTIMATE;
276
+ extra += estimateImageContentTokens(block);
277
277
  }
278
278
  }
279
279
  }
@@ -323,7 +323,7 @@ export class Tokenizer {
323
323
  if (block.type === "text" && block.text) {
324
324
  fragments.push(block.text);
325
325
  } else if (block.type === "image") {
326
- extra += IMAGE_TOKEN_ESTIMATE;
326
+ extra += estimateImageContentTokens(block);
327
327
  }
328
328
  }
329
329
  }
@@ -336,11 +336,12 @@ export class Tokenizer {
336
336
  if (message.blocks) {
337
337
  for (const block of message.blocks) {
338
338
  if (block.type === "text") fragments.push(block.text);
339
- else extra += snapcompact.FRAME_TOKEN_ESTIMATE;
339
+ else extra += this.#frameTokens(block.data);
340
340
  }
341
341
  } else if (message.images) {
342
- // Snapcompact frames render at ≥1568px; providers bill the downscaled cap.
343
- extra += message.images.length * snapcompact.FRAME_TOKEN_ESTIMATE;
342
+ for (const image of message.images) {
343
+ extra += this.#frameTokens(image.data);
344
+ }
344
345
  }
345
346
  }
346
347
  break;
@@ -352,4 +353,10 @@ export class Tokenizer {
352
353
  if (fragments.length === 0) return extra;
353
354
  return extra + this.countTokens(fragments);
354
355
  }
356
+
357
+ /** One snapcompact frame at the active model's price for its pixel size; unreadable frames cost the ceiling. */
358
+ #frameTokens(data: string): number {
359
+ const size = base64ImageSize(data);
360
+ return size ? snapcompact.frameTokens(this.#frameBilling, size) : snapcompact.FRAME_TOKEN_ESTIMATE;
361
+ }
355
362
  }