@oh-my-pi/pi-agent-core 18.6.0 → 18.6.3

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/src/tokenizer.ts CHANGED
@@ -5,6 +5,7 @@ import { materializeString, stringifyJson } from "@oh-my-pi/pi-utils";
5
5
  import { LRUCache } from "@oh-my-pi/pi-utils/lru";
6
6
  import * as snapcompact from "@oh-my-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
  }