@oh-my-pi/pi-agent-core 18.10.2 → 18.10.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/CHANGELOG.md CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.10.3] - 2026-10-12
6
+
7
+ ### Breaking Changes
8
+
9
+ - Removed `estimateImageContentTokens()` from `@oh-my-pi/pi-agent-core/image-tokens`; use `Tokenizer.imageTokens(size, detail)`, which prices with the reading model's catalog image rule ([#15446](https://github.com/can1357/oh-my-pi/pull/15446) by [@will-bogusz](https://github.com/will-bogusz))
10
+ - `applyShakeRegion()` and `applyShakeRegions()` take the replacement's token count (`replacementTokens`); a block rewrite of an assistant turn adds the tokens it removes to `elidedOutputTokens`, so a replayed Claude turn's count stays lowered after a reload ([#15446](https://github.com/can1357/oh-my-pi/pull/15446) by [@will-bogusz](https://github.com/will-bogusz))
11
+
12
+ ### Fixed
13
+
14
+ - Fixed Claude context counts reading Opus 5.5 requests about 55% over their billed input: a replayed thinking turn now counts at the output size Claude billed for it, not its signature's bytes ([#15446](https://github.com/can1357/oh-my-pi/pull/15446) by [@will-bogusz](https://github.com/will-bogusz))
15
+ - Fixed screenshots and other non-frame images being counted at the OpenAI rate for every model instead of the reading model's own; Claude billed them about 16% higher ([#15446](https://github.com/can1357/oh-my-pi/pull/15446) by [@will-bogusz](https://github.com/will-bogusz))
16
+ - Fixed OpenAI Responses and Codex context estimates leaving out replayed reasoning: each turn whose encrypted reasoning actually replays now counts its reported reasoning tokens (its ciphertext when the backend reports none), and none while a cold session rebuilds it out of the request, so compaction and snapcompact sizing match billing; branch summaries no longer budget for encrypted reasoning they never send ([#15447](https://github.com/can1357/oh-my-pi/pull/15447) by [@will-bogusz](https://github.com/will-bogusz))
17
+
5
18
  ## [18.10.2] - 2026-10-12
6
19
 
7
20
  ### Added
@@ -395,8 +395,10 @@ export declare class Agent {
395
395
  get state(): AgentState;
396
396
  /**
397
397
  * Tokenizer for the active model. The instance is replaced whenever the
398
- * active model's encoding or snapcompact frame pricing changes (see
399
- * {@link setModel}), so callers must not cache it across model switches.
398
+ * active model's encoding, image or thinking-replay pricing, or
399
+ * replayed-reasoning reader changes (see {@link setModel}), or its Responses
400
+ * session turns cold or warm, so callers must not cache it across model
401
+ * switches or requests.
400
402
  */
401
403
  get tokenizer(): Tokenizer;
402
404
  get appendOnlyContext(): AppendOnlyContextManager | undefined;
@@ -87,12 +87,13 @@ export declare function collectShakeRegions(entries: SessionEntry[], tokenizer:
87
87
  *
88
88
  * Tool-result: replaces its first non-empty text block with the placeholder,
89
89
  * removes its other text blocks, preserves every non-text block, and stamps
90
- * `prunedAt`. Block: splices `replacement` over `[start, end)` of the
91
- * target text block. When several block regions share one text block they MUST
92
- * be applied highest-start-first so earlier offsets stay valid — use
93
- * {@link applyShakeRegions}, which orders them correctly.
90
+ * `prunedAt`. Block: splices `replacement` (`replacementTokens` long) over
91
+ * `[start, end)` of the target text block, and on an assistant turn adds the
92
+ * tokens removed to `elidedOutputTokens`. When several block regions share one
93
+ * text block they MUST be applied highest-start-first so earlier offsets stay
94
+ * valid — use {@link applyShakeRegions}, which orders them correctly.
94
95
  */
95
- export declare function applyShakeRegion(region: ShakeRegion, replacement: string): void;
96
+ export declare function applyShakeRegion(region: ShakeRegion, replacement: string, replacementTokens: number): void;
96
97
  /**
97
98
  * Apply many regions at once. Block regions are applied highest-start-first so
98
99
  * that splicing one region never shifts the offsets of another in the same text
@@ -101,4 +102,5 @@ export declare function applyShakeRegion(region: ShakeRegion, replacement: strin
101
102
  export declare function applyShakeRegions(items: Array<{
102
103
  region: ShakeRegion;
103
104
  replacement: string;
105
+ replacementTokens: number;
104
106
  }>): void;
@@ -1,29 +1,26 @@
1
1
  import type { ImageContent } from "@oh-my-pi/pi-ai";
2
- import { type ImageSize } from "@oh-my-pi/pi-catalog/compat/image-tokenization";
2
+ import { type ImageSize, type ImageTokenization } from "@oh-my-pi/pi-catalog/compat/image-tokenization";
3
3
  /**
4
4
  * Dimension-based image token estimates shared by the local context counter
5
5
  * ({@link Tokenizer.countMessage}) and the native remote-compaction fit probe.
6
6
  * Both sides must price the same image the same way: when they disagreed
7
7
  * (1.2k flat locally vs a 12k flat worst case remotely) an image-heavy session
8
8
  * stayed under the local compaction trigger yet was refused as over-window by
9
- * the remote probe.
10
- *
11
- * Follows the catalog's image rule for the OpenAI Responses wire (GPT-5.5's
12
- * 32px patches, a per-detail pixel limit and patch budget, x1.2 multiplier),
13
- * which is also a close upper estimate for other providers once omp has
14
- * downscaled the image (≤1568px by default).
9
+ * the remote probe. Both price with the reading model's catalog image rule
10
+ * ({@link Tokenizer.imageTokens}); a reader without one falls back to the
11
+ * OpenAI Responses wire's rule (GPT-5.5's 32px patches, a per-detail pixel
12
+ * limit and patch budget, x1.2 multiplier).
15
13
  */
16
14
  /** OpenAI image `detail` values; `undefined` means the provider default (`auto`). */
17
15
  export type ImageDetail = ImageContent["detail"];
18
16
  export type { ImageSize };
19
17
  /**
20
- * Estimated input tokens for one image. Unknown dimensions (undecodable data,
21
- * remote URLs, provider file ids) charge the detail level's full patch budget.
18
+ * Estimated input tokens for one image under `rule`, the OpenAI Responses
19
+ * wire's by default. Unknown dimensions (undecodable data, remote URLs,
20
+ * provider file ids) charge the rule's most expensive size.
22
21
  */
23
- export declare function estimateImageTokens(size: ImageSize | null | undefined, detail?: ImageDetail): number;
22
+ export declare function estimateImageTokens(size: ImageSize | null | undefined, detail?: ImageDetail, rule?: ImageTokenization): number;
24
23
  /** Pixel size read from the header of base64-encoded image data, or null. */
25
24
  export declare function base64ImageSize(base64: string): ImageSize | null;
26
25
  /** Pixel size of a `data:<mime>;base64,` URL, or null for any other URL. */
27
26
  export declare function dataUrlImageSize(url: string): ImageSize | null;
28
- /** {@link estimateImageTokens} for an in-memory image block. */
29
- export declare function estimateImageContentTokens(image: Pick<ImageContent, "data" | "detail">): number;
@@ -1,9 +1,27 @@
1
1
  import type { Model } from "@oh-my-pi/pi-ai";
2
+ import { type ThinkingReplayTokens } from "@oh-my-pi/pi-catalog/compat/thinking-replay";
2
3
  import * as natives from "@oh-my-pi/pi-natives";
3
4
  import * as snapcompact from "@oh-my-pi/snapcompact";
5
+ import { type ImageDetail, type ImageSize } from "./image-tokens.js";
4
6
  import type { AgentMessage } from "./types.js";
5
7
  /** Maps the catalog-resolved tokenizer family to its native implementation. */
6
8
  export declare function tokenizerEncodingForModel(model: Pick<Model, "tokenizer"> | null | undefined): natives.Encoding | null;
9
+ type TokenizerModel = Pick<Model, "tokenizer"> & snapcompact.ShapeTarget & {
10
+ compat?: Model["compat"];
11
+ thinking?: Model["thinking"];
12
+ };
13
+ /** Identity of the reader whose replayed reasoning a {@link Tokenizer} counts; equal keys count it alike. */
14
+ export declare function reasoningReplayKey(model: TokenizerModel | null | undefined): string;
15
+ /** Request state a {@link Tokenizer} counts for, beyond its model. */
16
+ export interface TokenizerOptions {
17
+ /**
18
+ * Whether the request replays native Responses history (pi-ai's
19
+ * `openAIResponsesReplaysNativeHistory`). A cold session rebuilds a turn
20
+ * holding encrypted reasoning from its text and tool calls, without the
21
+ * reasoning or its signature. Default: true.
22
+ */
23
+ nativeHistoryReplay?: boolean;
24
+ }
7
25
  /**
8
26
  * `strict` always tries an exact native count (the catalog-resolved tokenizer
9
27
  * when known, o200k_base otherwise), falling back to the byte upper bound when
@@ -19,11 +37,15 @@ export interface MessageCountOptions {
19
37
  /**
20
38
  * Drop opaque provider reasoning payloads (`thinkingSignature`,
21
39
  * `redactedThinking`, native server-tool blocks) from the estimate. Those
22
- * are billed by the provider on replay, so the default counts them — but
23
- * their *local* byte size can diverge wildly from what the provider
24
- * charges, so the compaction floor (which only needs the reliably-countable,
25
- * on-wire-compressible content) excludes them to avoid false triggers on
26
- * thinking-heavy turns.
40
+ * are billed by the provider on replay, so the default counts them (a reader
41
+ * billing replayed thinking at `output` size: the reasoning, at its turn's
42
+ * output size) — but their *local* byte size can diverge wildly from what
43
+ * the provider charges, so the compaction floor (which only needs the
44
+ * reliably-countable, on-wire-compressible content) excludes them to avoid
45
+ * false triggers on thinking-heavy turns.
46
+ * Native Responses reasoning that actually replays is not an opaque byte
47
+ * guess: it is charged at the turn's provider-reported reasoning tokens in
48
+ * both arms.
27
49
  */
28
50
  excludeEncryptedReasoning?: boolean;
29
51
  }
@@ -43,15 +65,28 @@ export interface TokenBudgetCheck {
43
65
  * Model-aware local token counter. Immutable: the catalog-resolved encoding
44
66
  * is fixed at construction, so a cached count can never straddle two
45
67
  * encodings. An `Agent` owns one for its active model (swapping the instance
46
- * when the model's encoding or snapcompact frame pricing changes); one-shot
47
- * flows construct their own for the model that will be billed. Known
48
- * tokenizer families use exact native counts; unknown models keep the fast
49
- * byte estimate (or o200k when `PI_TOKENIZER_ACCURATE=1`).
68
+ * when the model's encoding, image or thinking-replay pricing, replay
69
+ * identity or native-history replay state changes); one-shot flows construct
70
+ * their own for the model that will be billed. Known tokenizer families use
71
+ * exact native counts; unknown models keep the fast byte estimate (or o200k
72
+ * when `PI_TOKENIZER_ACCURATE=1`).
50
73
  */
51
74
  export declare class Tokenizer {
52
75
  #private;
53
76
  readonly frameBillingKey: string;
54
- constructor(model?: (Pick<Model, "tokenizer"> & snapcompact.ShapeTarget) | null);
77
+ readonly reasoningReplayKey: string;
78
+ /** How the reader bills replayed signed thinking (catalog `thinking-replay-tokens`). */
79
+ readonly thinkingReplayTokens: ThinkingReplayTokens;
80
+ /** Whether the counted request replays native Responses history; see {@link TokenizerOptions}. */
81
+ readonly nativeHistoryReplay: boolean;
82
+ /**
83
+ * Counts what `streamSimple` sends `model`. Its catalog compat decides
84
+ * whether Responses reasoning history is filtered: `streamOpenAIResponses`'
85
+ * per-request `filterReasoningHistory` override is a debugging hook
86
+ * `streamSimple` never forwards, so a caller using it passes `model` with
87
+ * that compat set.
88
+ */
89
+ constructor(model?: TokenizerModel | null, options?: TokenizerOptions);
55
90
  get encoding(): natives.Encoding | null;
56
91
  countTokens(text: string | string[], mode?: TokenCountMode): number;
57
92
  /**
@@ -72,10 +107,25 @@ export declare class Tokenizer {
72
107
  * Settled historical messages are counted once and reused until an owner
73
108
  * (prune/shake/strip-images) calls `invalidateMessageCache`; streaming
74
109
  * assistants bypass the memo entirely (see the message-cache settle-gate
75
- * invariant). Image blocks charge {@link estimateImageContentTokens}, the
76
- * same dimension-based estimate the native remote-compaction probe applies.
110
+ * invariant). Image blocks charge {@link imageTokens}, the same estimate
111
+ * the native remote-compaction probe applies.
77
112
  */
78
113
  countMessage(message: AgentMessage, options?: MessageCountOptions): number;
79
114
  /** Sum of {@link countMessage} over `messages`. */
80
115
  countMessages(messages: readonly AgentMessage[], options?: MessageCountOptions): number;
116
+ /**
117
+ * Input tokens for one image (not a snapcompact frame) under the reader's
118
+ * catalog image rule, or the generic estimate when it has none.
119
+ */
120
+ imageTokens(size: ImageSize | null | undefined, detail?: ImageDetail): number;
121
+ /**
122
+ * The replayed native Responses reasoning {@link countMessage} charges
123
+ * across `messages`: the part of a count that changes with the reading
124
+ * model. A replay whose backend reported no reasoning tokens counts at the
125
+ * signature price `countMessage` gives it.
126
+ */
127
+ replayedReasoningTokens(messages: readonly AgentMessage[]): number;
128
+ /** Whether a Tokenizer built for `model` with `options` would count every message as this one does. */
129
+ countsAlike(model: TokenizerModel | null | undefined, options?: TokenizerOptions): boolean;
81
130
  }
131
+ export {};
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-agent-core",
4
- "version": "18.10.2",
4
+ "version": "18.10.3",
5
5
  "description": "General-purpose agent with transport abstraction, state management, and attachment support",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": {
@@ -38,16 +38,16 @@
38
38
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
39
39
  },
40
40
  "dependencies": {
41
- "@oh-my-pi/pi-ai": "18.10.2",
42
- "@oh-my-pi/pi-catalog": "18.10.2",
43
- "@oh-my-pi/pi-natives": "18.10.2",
44
- "@oh-my-pi/pi-utils": "18.10.2",
45
- "@oh-my-pi/pi-wire": "18.10.2",
46
- "@oh-my-pi/snapcompact": "18.10.2",
41
+ "@oh-my-pi/pi-ai": "18.10.3",
42
+ "@oh-my-pi/pi-catalog": "18.10.3",
43
+ "@oh-my-pi/pi-natives": "18.10.3",
44
+ "@oh-my-pi/pi-utils": "18.10.3",
45
+ "@oh-my-pi/pi-wire": "18.10.3",
46
+ "@oh-my-pi/snapcompact": "18.10.3",
47
47
  "@opentelemetry/api": "^1.9.1"
48
48
  },
49
49
  "devDependencies": {
50
- "@oh-my-pi/omptype": "18.10.2",
50
+ "@oh-my-pi/omptype": "18.10.3",
51
51
  "@opentelemetry/context-async-hooks": "^2.9.0",
52
52
  "@opentelemetry/sdk-trace-base": "^2.9.0",
53
53
  "@types/bun": "^1.3.14"
package/src/agent.ts CHANGED
@@ -23,10 +23,10 @@ import {
23
23
  type ToolResultMessage,
24
24
  } from "@oh-my-pi/pi-ai";
25
25
  import type { Dialect } from "@oh-my-pi/pi-ai/dialect";
26
+ import { openAIResponsesReplaysNativeHistory } from "@oh-my-pi/pi-ai/providers/openai-responses";
26
27
  import type { HarmonyAuditEvent } from "@oh-my-pi/pi-ai/utils/harmony-leak";
27
28
  import { getBundledModel } from "@oh-my-pi/pi-catalog/models";
28
29
  import { logger } from "@oh-my-pi/pi-utils";
29
- import * as snapcompact from "@oh-my-pi/snapcompact";
30
30
  import {
31
31
  abortReasonText,
32
32
  agentLoop,
@@ -41,7 +41,7 @@ import {
41
41
  import type { AppendOnlyContextManager } from "./append-only-context";
42
42
  import { isProviderRefusalMessage } from "./replay-policy";
43
43
  import { SentToolDefinitions } from "./sent-tool-definitions";
44
- import { Tokenizer, tokenizerEncodingForModel } from "./tokenizer";
44
+ import { Tokenizer } from "./tokenizer";
45
45
  import {
46
46
  createAdditionalContextMessage,
47
47
  joinAdditionalContext,
@@ -850,24 +850,32 @@ export class Agent {
850
850
 
851
851
  /**
852
852
  * Tokenizer for the active model. The instance is replaced whenever the
853
- * active model's encoding or snapcompact frame pricing changes (see
854
- * {@link setModel}), so callers must not cache it across model switches.
853
+ * active model's encoding, image or thinking-replay pricing, or
854
+ * replayed-reasoning reader changes (see {@link setModel}), or its Responses
855
+ * session turns cold or warm, so callers must not cache it across model
856
+ * switches or requests.
855
857
  */
856
858
  get tokenizer(): Tokenizer {
859
+ // The provider warms or closes a session inside a request, so the replay
860
+ // state is read here rather than only on model switches.
861
+ const model = this.#state.model;
862
+ if (
863
+ openAIResponsesReplaysNativeHistory(model, this.#providerSessionState) !== this.#tokenizer.nativeHistoryReplay
864
+ ) {
865
+ this.#syncTokenizer(model);
866
+ }
857
867
  return this.#tokenizer;
858
868
  }
859
869
 
860
870
  /**
861
- * Swap the tokenizer only when the encoding or frame pricing actually
862
- * changes, so the warm per-message memo survives same-pricing model switches.
871
+ * Swap the tokenizer only when the encoding, image or thinking-replay
872
+ * pricing, replayed-reasoning reader or native-history replay state
873
+ * actually changes, so the warm
874
+ * per-message memo survives same-pricing model switches.
863
875
  */
864
876
  #syncTokenizer(model: Model | null | undefined): void {
865
- if (
866
- tokenizerEncodingForModel(model) !== this.#tokenizer.encoding ||
867
- snapcompact.frameBillingKey(snapcompact.frameBilling(model ?? undefined)) !== this.#tokenizer.frameBillingKey
868
- ) {
869
- this.#tokenizer = new Tokenizer(model);
870
- }
877
+ const options = { nativeHistoryReplay: openAIResponsesReplaysNativeHistory(model, this.#providerSessionState) };
878
+ if (!this.#tokenizer.countsAlike(model, options)) this.#tokenizer = new Tokenizer(model, options);
871
879
  }
872
880
 
873
881
  get appendOnlyContext(): AppendOnlyContextManager | undefined {
@@ -208,7 +208,16 @@ function getMessageFromEntry(entry: SessionEntry): AgentMessage | undefined {
208
208
  }
209
209
  }
210
210
 
211
+ /**
212
+ * Tokens `message` adds to the summarizer's input, which is the branch
213
+ * serialized as plain text: tool results truncated as serialization truncates
214
+ * them, assistants without their native replay items or opaque reasoning
215
+ * payloads, which the text never carries.
216
+ */
211
217
  function estimateBranchSummaryTokens(message: AgentMessage, tokenizer: Tokenizer): number {
218
+ if (message.role === "assistant") {
219
+ return tokenizer.countMessage({ ...message, providerPayload: undefined }, { excludeEncryptedReasoning: true });
220
+ }
212
221
  if (message.role !== "toolResult") return tokenizer.countMessage(message);
213
222
  const text = message.content
214
223
  .filter((c): c is { type: "text"; text: string } => c.type === "text")
@@ -54,7 +54,7 @@ import {
54
54
  OPENAI_HEADERS,
55
55
  } from "@oh-my-pi/pi-catalog/wire/codex";
56
56
  import { $env, isRecord, logger, prompt, ptree, stringifyJson, structuredCloneJSON } from "@oh-my-pi/pi-utils";
57
- import { dataUrlImageSize, estimateImageTokens } from "../image-tokens";
57
+ import { dataUrlImageSize } from "../image-tokens";
58
58
  import { Tokenizer } from "../tokenizer";
59
59
  import { appendAzureApiVersion, resolveAzureOpenAiBaseUrl } from "./azure-openai-endpoint";
60
60
  import { prepareBedrockCompactionRequest } from "./bedrock";
@@ -82,8 +82,11 @@ export const REMOTE_COMPACTION_TIMEOUT_MS = 300_000;
82
82
  export const CONTEXT_WINDOW_TRUNCATED_OUTPUT_MESSAGE = prompt.render(contextWindowTruncatedOutputPrompt);
83
83
 
84
84
  const REMOTE_COMPACTION_REQUEST_OVERHEAD_TOKENS = 256;
85
- /** Per-`input_image` record estimates; trim re-probes share untouched items by reference. */
86
- const remoteImageTokenCache = new WeakMap<Record<string, unknown>, number>();
85
+ /**
86
+ * Per-`input_image` record estimates, tagged with the pricing tokenizer's
87
+ * billing key; trim re-probes share untouched items by reference.
88
+ */
89
+ const remoteImageTokenCache = new WeakMap<Record<string, unknown>, { billingKey: string; tokens: number }>();
87
90
  const TOOL_RESULT_IMAGE_ATTACHMENT_TEXT = "Attached image(s) from tool result:";
88
91
 
89
92
  interface NormalizedEstimateValue {
@@ -91,12 +94,16 @@ interface NormalizedEstimateValue {
91
94
  imageTokens: number;
92
95
  }
93
96
 
94
- function normalizeRemoteCompactionEstimateValue(value: unknown, dropEncrypted = true): NormalizedEstimateValue {
97
+ function normalizeRemoteCompactionEstimateValue(
98
+ value: unknown,
99
+ tokenizer: Tokenizer,
100
+ dropEncrypted = true,
101
+ ): NormalizedEstimateValue {
95
102
  if (Array.isArray(value)) {
96
103
  const normalized: unknown[] = [];
97
104
  let imageTokens = 0;
98
105
  for (const item of value) {
99
- const result = normalizeRemoteCompactionEstimateValue(item, dropEncrypted);
106
+ const result = normalizeRemoteCompactionEstimateValue(item, tokenizer, dropEncrypted);
100
107
  normalized.push(result.value);
101
108
  imageTokens += result.imageTokens;
102
109
  }
@@ -106,18 +113,20 @@ function normalizeRemoteCompactionEstimateValue(value: unknown, dropEncrypted =
106
113
 
107
114
  const record = value as Record<string, unknown>;
108
115
  if (record.type === "input_image") {
109
- let imageTokens = remoteImageTokenCache.get(record);
110
- if (imageTokens === undefined) {
116
+ let cached = remoteImageTokenCache.get(record);
117
+ if (cached?.billingKey !== tokenizer.frameBillingKey) {
111
118
  const detail = record.detail;
112
- imageTokens = estimateImageTokens(
113
- typeof record.image_url === "string" ? dataUrlImageSize(record.image_url) : null,
114
- detail === "low" || detail === "high" || detail === "original" || detail === "auto" ? detail : undefined,
115
- );
116
- remoteImageTokenCache.set(record, imageTokens);
119
+ cached = {
120
+ billingKey: tokenizer.frameBillingKey,
121
+ tokens: tokenizer.imageTokens(
122
+ typeof record.image_url === "string" ? dataUrlImageSize(record.image_url) : null,
123
+ detail === "low" || detail === "high" || detail === "original" || detail === "auto" ? detail : undefined,
124
+ ),
125
+ };
126
+ remoteImageTokenCache.set(record, cached);
117
127
  }
118
- return { value: { ...record, image_url: "<image>" }, imageTokens };
128
+ return { value: { ...record, image_url: "<image>" }, imageTokens: cached.tokens };
119
129
  }
120
-
121
130
  const normalized: Record<string, unknown> = {};
122
131
  let imageTokens = 0;
123
132
  for (const [key, item] of Object.entries(record)) {
@@ -125,7 +134,7 @@ function normalizeRemoteCompactionEstimateValue(value: unknown, dropEncrypted =
125
134
  // exceeds what the provider bills, so it stays out of the fit estimate
126
135
  // (same policy as `MessageCountOptions.excludeEncryptedReasoning`).
127
136
  if (dropEncrypted && key === "encrypted_content" && typeof item === "string") continue;
128
- const result = normalizeRemoteCompactionEstimateValue(item, dropEncrypted);
137
+ const result = normalizeRemoteCompactionEstimateValue(item, tokenizer, dropEncrypted);
129
138
  normalized[key] = result.value;
130
139
  imageTokens += result.imageTokens;
131
140
  }
@@ -138,7 +147,7 @@ function normalizeRemoteCompactionEstimateValue(value: unknown, dropEncrypted =
138
147
  * opaque compaction state included, counts as serialized.
139
148
  */
140
149
  export function countResponsesHistoryTokens(items: unknown[], tokenizer: Tokenizer): number {
141
- const normalized = normalizeRemoteCompactionEstimateValue(items, false);
150
+ const normalized = normalizeRemoteCompactionEstimateValue(items, tokenizer, false);
142
151
  const serialized = stringifyJson(normalized.value);
143
152
  return (serialized === undefined ? 0 : tokenizer.countTokens(serialized)) + normalized.imageTokens;
144
153
  }
@@ -181,7 +190,10 @@ function probeRemoteCompactionInputBudget(
181
190
  tools: unknown[] | undefined,
182
191
  contextWindow: number | null | undefined,
183
192
  ): RemoteCompactionBudgetProbe {
184
- const normalized = normalizeRemoteCompactionEstimateValue({ instructions, input, ...(tools ? { tools } : {}) });
193
+ const normalized = normalizeRemoteCompactionEstimateValue(
194
+ { instructions, input, ...(tools ? { tools } : {}) },
195
+ tokenizer,
196
+ );
185
197
  const serialized = stringifyJson(normalized.value) ?? "";
186
198
  const flatTokens = normalized.imageTokens + REMOTE_COMPACTION_REQUEST_OVERHEAD_TOKENS;
187
199
  if (!contextWindow || contextWindow <= 0) {
@@ -422,12 +422,13 @@ function getBlockTextSlot(entry: SessionMessageEntry | CustomMessageEntry, block
422
422
  *
423
423
  * Tool-result: replaces its first non-empty text block with the placeholder,
424
424
  * removes its other text blocks, preserves every non-text block, and stamps
425
- * `prunedAt`. Block: splices `replacement` over `[start, end)` of the
426
- * target text block. When several block regions share one text block they MUST
427
- * be applied highest-start-first so earlier offsets stay valid — use
428
- * {@link applyShakeRegions}, which orders them correctly.
425
+ * `prunedAt`. Block: splices `replacement` (`replacementTokens` long) over
426
+ * `[start, end)` of the target text block, and on an assistant turn adds the
427
+ * tokens removed to `elidedOutputTokens`. When several block regions share one
428
+ * text block they MUST be applied highest-start-first so earlier offsets stay
429
+ * valid — use {@link applyShakeRegions}, which orders them correctly.
429
430
  */
430
- export function applyShakeRegion(region: ShakeRegion, replacement: string): void {
431
+ export function applyShakeRegion(region: ShakeRegion, replacement: string, replacementTokens: number): void {
431
432
  if (region.kind === "toolResult") {
432
433
  const message = region.entry.message as ToolResultMessage;
433
434
  const replacementIndex = message.content.findIndex(block => block.type === "text" && block.text.length > 0);
@@ -451,7 +452,14 @@ export function applyShakeRegion(region: ShakeRegion, replacement: string): void
451
452
  // rebuilds, so an in-place block rewrite must drop its cached estimate/convert.
452
453
  // Custom-message entries are re-materialized into a fresh AgentMessage on every
453
454
  // buildSessionContext, so they carry no stable cached identity to invalidate.
454
- if (region.entry.type === "message") invalidateMessageCache(region.entry.message);
455
+ if (region.entry.type !== "message") return;
456
+ const message = region.entry.message;
457
+ // `usage.output` keeps billing the removed text; the persisted tally lets
458
+ // every later count of a replayed turn take it off again.
459
+ if (message.role === "assistant") {
460
+ message.elidedOutputTokens = (message.elidedOutputTokens ?? 0) + Math.max(0, region.tokens - replacementTokens);
461
+ }
462
+ invalidateMessageCache(message);
455
463
  }
456
464
 
457
465
  /**
@@ -459,11 +467,14 @@ export function applyShakeRegion(region: ShakeRegion, replacement: string): void
459
467
  * that splicing one region never shifts the offsets of another in the same text
460
468
  * block; tool-result regions are independent.
461
469
  */
462
- export function applyShakeRegions(items: Array<{ region: ShakeRegion; replacement: string }>): void {
470
+ export function applyShakeRegions(
471
+ items: Array<{ region: ShakeRegion; replacement: string; replacementTokens: number }>,
472
+ ): void {
463
473
  const ordered = [...items].sort((a, b) => {
464
474
  const aStart = a.region.kind === "block" ? a.region.start : -1;
465
475
  const bStart = b.region.kind === "block" ? b.region.start : -1;
466
476
  return bStart - aStart;
467
477
  });
468
- for (const { region, replacement } of ordered) applyShakeRegion(region, replacement);
478
+ for (const { region, replacement, replacementTokens } of ordered)
479
+ applyShakeRegion(region, replacement, replacementTokens);
469
480
  }
@@ -13,12 +13,10 @@ import { parseImageMetadata } from "@oh-my-pi/pi-utils";
13
13
  * Both sides must price the same image the same way: when they disagreed
14
14
  * (1.2k flat locally vs a 12k flat worst case remotely) an image-heavy session
15
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).
16
+ * the remote probe. Both price with the reading model's catalog image rule
17
+ * ({@link Tokenizer.imageTokens}); a reader without one falls back to the
18
+ * OpenAI Responses wire's rule (GPT-5.5's 32px patches, a per-detail pixel
19
+ * limit and patch budget, x1.2 multiplier).
22
20
  */
23
21
 
24
22
  /** OpenAI image `detail` values; `undefined` means the provider default (`auto`). */
@@ -42,11 +40,16 @@ function openAiWireRule(): ImageTokenization {
42
40
  }
43
41
 
44
42
  /**
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.
43
+ * Estimated input tokens for one image under `rule`, the OpenAI Responses
44
+ * wire's by default. Unknown dimensions (undecodable data, remote URLs,
45
+ * provider file ids) charge the rule's most expensive size.
47
46
  */
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);
47
+ export function estimateImageTokens(
48
+ size: ImageSize | null | undefined,
49
+ detail?: ImageDetail,
50
+ rule: ImageTokenization = openAiWireRule(),
51
+ ): number {
52
+ return imageTokens(rule, size && size.width > 0 && size.height > 0 ? size : UNKNOWN_SIZE, detail);
50
53
  }
51
54
 
52
55
  // Enough decoded bytes to reach a JPEG SOF marker behind typical EXIF/ICC
@@ -70,8 +73,3 @@ export function dataUrlImageSize(url: string): ImageSize | null {
70
73
  if (comma < 0 || !url.slice(0, comma).endsWith(";base64")) return null;
71
74
  return base64ImageSize(url.slice(comma + 1));
72
75
  }
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
- }
package/src/tokenizer.ts CHANGED
@@ -1,11 +1,18 @@
1
- import type { Model } from "@oh-my-pi/pi-ai";
1
+ import type { AssistantMessage, Model } from "@oh-my-pi/pi-ai";
2
+ import { isColdReplayableResponsesTurn } from "@oh-my-pi/pi-ai/providers/openai-shared";
3
+ import { shouldDropTruncatedThinkingOnlyAssistant } from "@oh-my-pi/pi-ai/providers/transform-messages";
4
+ import {
5
+ getOpenAIResponsesHistoryItems,
6
+ sanitizeOpenAIResponsesAssistantHistoryItemsForReplay,
7
+ } from "@oh-my-pi/pi-ai/utils";
8
+ import { resolveThinkingReplayTokens, type ThinkingReplayTokens } from "@oh-my-pi/pi-catalog/compat/thinking-replay";
2
9
  import type { ModelTokenizer } from "@oh-my-pi/pi-catalog/types";
3
10
  import * as natives from "@oh-my-pi/pi-natives";
4
11
  import { materializeString, stringifyJson } from "@oh-my-pi/pi-utils";
5
12
  import { LRUCache } from "@oh-my-pi/pi-utils/lru";
6
13
  import * as snapcompact from "@oh-my-pi/snapcompact";
7
14
  import { isEstimateCacheable, messageEstimateVersion } from "./compaction/message-cache";
8
- import { base64ImageSize, estimateImageContentTokens } from "./image-tokens";
15
+ import { base64ImageSize, estimateImageTokens, type ImageDetail, type ImageSize } from "./image-tokens";
9
16
  import type { AgentMessage } from "./types";
10
17
 
11
18
  const testEnv = Bun.env.NODE_ENV === "test";
@@ -20,6 +27,7 @@ const NATIVE_ENCODING: Record<ModelTokenizer, natives.Encoding> = {
20
27
  "deepseek-v3": natives.Encoding.DeepSeekV3,
21
28
  "kimi-k2": natives.Encoding.KimiK2,
22
29
  glm5: natives.Encoding.Glm5,
30
+ o200k: natives.Encoding.O200kBase,
23
31
  };
24
32
 
25
33
  /** Maps the catalog-resolved tokenizer family to its native implementation. */
@@ -27,6 +35,41 @@ export function tokenizerEncodingForModel(model: Pick<Model, "tokenizer"> | null
27
35
  return model?.tokenizer ? NATIVE_ENCODING[model.tokenizer] : null;
28
36
  }
29
37
 
38
+ type TokenizerModel = Pick<Model, "tokenizer"> &
39
+ snapcompact.ShapeTarget & { compat?: Model["compat"]; thinking?: Model["thinking"] };
40
+
41
+ /** The model an assistant turn's native reasoning (signed thinking, Responses history) is replayed to. */
42
+ interface ReasoningReplayReader {
43
+ api: string;
44
+ provider: string;
45
+ id: string;
46
+ }
47
+
48
+ function reasoningReplayReader(model: TokenizerModel | null | undefined): ReasoningReplayReader | undefined {
49
+ if (!model?.api || !model.provider || !model.id) return undefined;
50
+ // Filtering targets strip native reasoning items before the request is encoded.
51
+ const compat = model.compat;
52
+ if (compat && "filterReasoningHistory" in compat && compat.filterReasoningHistory) return undefined;
53
+ return { api: model.api, provider: model.provider, id: model.id };
54
+ }
55
+
56
+ /** Identity of the reader whose replayed reasoning a {@link Tokenizer} counts; equal keys count it alike. */
57
+ export function reasoningReplayKey(model: TokenizerModel | null | undefined): string {
58
+ const reader = reasoningReplayReader(model);
59
+ return reader ? `${reader.api}\0${reader.provider}\0${reader.id}` : "";
60
+ }
61
+
62
+ /** Request state a {@link Tokenizer} counts for, beyond its model. */
63
+ export interface TokenizerOptions {
64
+ /**
65
+ * Whether the request replays native Responses history (pi-ai's
66
+ * `openAIResponsesReplaysNativeHistory`). A cold session rebuilds a turn
67
+ * holding encrypted reasoning from its text and tool calls, without the
68
+ * reasoning or its signature. Default: true.
69
+ */
70
+ nativeHistoryReplay?: boolean;
71
+ }
72
+
30
73
  /**
31
74
  * `strict` always tries an exact native count (the catalog-resolved tokenizer
32
75
  * when known, o200k_base otherwise), falling back to the byte upper bound when
@@ -43,11 +86,15 @@ export interface MessageCountOptions {
43
86
  /**
44
87
  * Drop opaque provider reasoning payloads (`thinkingSignature`,
45
88
  * `redactedThinking`, native server-tool blocks) from the estimate. Those
46
- * are billed by the provider on replay, so the default counts them — but
47
- * their *local* byte size can diverge wildly from what the provider
48
- * charges, so the compaction floor (which only needs the reliably-countable,
49
- * on-wire-compressible content) excludes them to avoid false triggers on
50
- * thinking-heavy turns.
89
+ * are billed by the provider on replay, so the default counts them (a reader
90
+ * billing replayed thinking at `output` size: the reasoning, at its turn's
91
+ * output size) — but their *local* byte size can diverge wildly from what
92
+ * the provider charges, so the compaction floor (which only needs the
93
+ * reliably-countable, on-wire-compressible content) excludes them to avoid
94
+ * false triggers on thinking-heavy turns.
95
+ * Native Responses reasoning that actually replays is not an opaque byte
96
+ * guess: it is charged at the turn's provider-reported reasoning tokens in
97
+ * both arms.
51
98
  */
52
99
  excludeEncryptedReasoning?: boolean;
53
100
  }
@@ -149,15 +196,24 @@ interface MessageEstimate {
149
196
  * Model-aware local token counter. Immutable: the catalog-resolved encoding
150
197
  * is fixed at construction, so a cached count can never straddle two
151
198
  * encodings. An `Agent` owns one for its active model (swapping the instance
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`).
199
+ * when the model's encoding, image or thinking-replay pricing, replay
200
+ * identity or native-history replay state changes); one-shot flows construct
201
+ * their own for the model that will be billed. Known tokenizer families use
202
+ * exact native counts; unknown models keep the fast byte estimate (or o200k
203
+ * when `PI_TOKENIZER_ACCURATE=1`).
156
204
  */
157
205
  export class Tokenizer {
158
206
  readonly #encoding: natives.Encoding | null;
159
207
  readonly #frameBilling: snapcompact.FrameBilling;
160
208
  readonly frameBillingKey: string;
209
+ readonly #reasoningReader: ReasoningReplayReader | undefined;
210
+ /** Claude's own API and prefix-bound hosts keep another model's signature from their deployment. */
211
+ readonly #readsDeploymentSignatures: boolean;
212
+ readonly reasoningReplayKey: string;
213
+ /** How the reader bills replayed signed thinking (catalog `thinking-replay-tokens`). */
214
+ readonly thinkingReplayTokens: ThinkingReplayTokens;
215
+ /** Whether the counted request replays native Responses history; see {@link TokenizerOptions}. */
216
+ readonly nativeHistoryReplay: boolean;
161
217
 
162
218
  /** Exact counts only; byte fallbacks remain mode-dependent and uncached. */
163
219
  readonly #nativeCounts = new LRUCache<string, number>({
@@ -176,10 +232,26 @@ export class Tokenizer {
176
232
  */
177
233
  #estimates = new WeakMap<AgentMessage, MessageEstimate>();
178
234
 
179
- constructor(model?: (Pick<Model, "tokenizer"> & snapcompact.ShapeTarget) | null) {
235
+ /**
236
+ * Counts what `streamSimple` sends `model`. Its catalog compat decides
237
+ * whether Responses reasoning history is filtered: `streamOpenAIResponses`'
238
+ * per-request `filterReasoningHistory` override is a debugging hook
239
+ * `streamSimple` never forwards, so a caller using it passes `model` with
240
+ * that compat set.
241
+ */
242
+ constructor(model?: TokenizerModel | null, options?: TokenizerOptions) {
180
243
  this.#encoding = tokenizerEncodingForModel(model);
181
244
  this.#frameBilling = snapcompact.frameBilling(model ?? undefined);
182
245
  this.frameBillingKey = snapcompact.frameBillingKey(this.#frameBilling);
246
+ this.#reasoningReader = reasoningReplayReader(model);
247
+ const compat = model?.compat;
248
+ this.#readsDeploymentSignatures =
249
+ model?.api === "anthropic-messages" &&
250
+ ((compat !== undefined && "officialEndpoint" in compat && compat.officialEndpoint) ||
251
+ model.thinking?.prefixBinding === true);
252
+ this.reasoningReplayKey = reasoningReplayKey(model);
253
+ this.thinkingReplayTokens = resolveThinkingReplayTokens(model ?? {});
254
+ this.nativeHistoryReplay = options?.nativeHistoryReplay ?? true;
183
255
  }
184
256
 
185
257
  get encoding(): natives.Encoding | null {
@@ -218,8 +290,8 @@ export class Tokenizer {
218
290
  * Settled historical messages are counted once and reused until an owner
219
291
  * (prune/shake/strip-images) calls `invalidateMessageCache`; streaming
220
292
  * assistants bypass the memo entirely (see the message-cache settle-gate
221
- * invariant). Image blocks charge {@link estimateImageContentTokens}, the
222
- * same dimension-based estimate the native remote-compaction probe applies.
293
+ * invariant). Image blocks charge {@link imageTokens}, the same estimate
294
+ * the native remote-compaction probe applies.
223
295
  */
224
296
  countMessage(message: AgentMessage, options?: MessageCountOptions): number {
225
297
  const floored = options?.excludeEncryptedReasoning === true;
@@ -245,6 +317,50 @@ export class Tokenizer {
245
317
  return total;
246
318
  }
247
319
 
320
+ /**
321
+ * Input tokens for one image (not a snapcompact frame) under the reader's
322
+ * catalog image rule, or the generic estimate when it has none.
323
+ */
324
+ imageTokens(size: ImageSize | null | undefined, detail?: ImageDetail): number {
325
+ return estimateImageTokens(size, detail, this.#frameBilling.rule);
326
+ }
327
+
328
+ /**
329
+ * Whether `message`'s signed reasoning reaches this reader natively: from
330
+ * the same model, host and wire, or from another Claude model on the same
331
+ * Anthropic deployment, which keeps the signature (`transformMessages`).
332
+ * Claude bills that reasoning when the reader can read it and drops the
333
+ * block otherwise, so such a turn counts high rather than low.
334
+ */
335
+ #replaysReasoningNatively(message: AssistantMessage): boolean {
336
+ const reader = this.#reasoningReader;
337
+ return (
338
+ reader !== undefined &&
339
+ message.api === reader.api &&
340
+ message.provider === reader.provider &&
341
+ (message.model === reader.id || this.#readsDeploymentSignatures)
342
+ );
343
+ }
344
+
345
+ /**
346
+ * The replayed native Responses reasoning {@link countMessage} charges
347
+ * across `messages`: the part of a count that changes with the reading
348
+ * model. A replay whose backend reported no reasoning tokens counts at the
349
+ * signature price `countMessage` gives it.
350
+ */
351
+ replayedReasoningTokens(messages: readonly AgentMessage[]): number {
352
+ let total = 0;
353
+ for (const message of messages) {
354
+ if (message.role !== "assistant") continue;
355
+ const replayed = this.#replayedReasoningTokens(message);
356
+ total +=
357
+ replayed === null
358
+ ? this.countMessage(message) - this.countMessage(message, { excludeEncryptedReasoning: true })
359
+ : (replayed ?? 0);
360
+ }
361
+ return total;
362
+ }
363
+
248
364
  #measureMessage(message: AgentMessage, excludeEncryptedReasoning: boolean): number {
249
365
  const fragments: string[] = [];
250
366
  let extra = 0;
@@ -273,35 +389,63 @@ export class Tokenizer {
273
389
  if (block.type === "text" && block.text) {
274
390
  fragments.push(block.text);
275
391
  } else if (block.type === "image") {
276
- extra += estimateImageContentTokens(block);
392
+ extra += this.imageTokens(base64ImageSize(block.data), block.detail);
277
393
  }
278
394
  }
279
395
  }
280
396
  break;
281
397
  }
282
398
  case "assistant": {
283
- for (const block of message.content) {
399
+ // Replayed native Responses reasoning is billed as input at the turn's
400
+ // reported reasoning tokens, not at its ~3x larger ciphertext size, so it
401
+ // is a token price and counts in both arms. Its thinking signature never
402
+ // reaches the wire then (the payload items replay instead); a turn whose
403
+ // backend reported no reasoning tokens keeps the signature as its price.
404
+ const replayedReasoning = this.#replayedReasoningTokens(message);
405
+ if (typeof replayedReasoning === "number") extra += replayedReasoning;
406
+ const signaturesPriced = this.thinkingReplayTokens === "signature";
407
+ // The replay's signature rule (`transformMessages`): an aborted or
408
+ // errored turn's final block can hold a partial signature, and a turn
409
+ // whose tool calls ended without a tool-use stop is abandoned, so all
410
+ // its signatures are stripped. Every other block completed. The latest
411
+ // assistant turn keeps an abandoned turn's signatures byte-for-byte;
412
+ // a per-message count cannot see position, so it follows the rule for
413
+ // older turns, and that latest turn's own usage anchors transcript counts.
414
+ const content = message.content;
415
+ const invalidStop = message.stopReason === "aborted" || message.stopReason === "error";
416
+ const abandonedToolUse =
417
+ !invalidStop && message.stopReason !== "toolUse" && content.some(block => block.type === "toolCall");
418
+ const untrustedIndex = invalidStop ? content.length - 1 : -1;
419
+ let signedReasoning = false;
420
+ for (let index = 0; index < content.length; index++) {
421
+ const block = content[index];
284
422
  if (block.type === "text") {
285
423
  fragments.push(block.text);
286
424
  } else if (block.type === "thinking") {
287
425
  fragments.push(block.thinking);
288
426
  // Providers charge for the opaque signature/reasoning payload that
289
427
  // rides alongside the thinking text (OpenAI Responses encrypted
290
- // reasoning items, Anthropic signed thinking blocks, etc.). Without
291
- // counting it, this estimator can read ~half of the provider-reported
292
- // usage on thinking-heavy turns — see #2275 for the resulting
293
- // compaction-trigger / post-check metric divergence. The compaction
294
- // floor excludes it (its local byte size diverges from provider billing).
295
- if (block.thinkingSignature && !excludeEncryptedReasoning) {
428
+ // reasoning items, Gemini thought signatures). A reader billing at
429
+ // `output` size (Claude) charges the reasoning, priced below, not
430
+ // these bytes. The compaction floor excludes it (its local byte size
431
+ // diverges from provider billing).
432
+ if (
433
+ block.thinkingSignature &&
434
+ !excludeEncryptedReasoning &&
435
+ signaturesPriced &&
436
+ typeof replayedReasoning !== "number"
437
+ ) {
296
438
  fragments.push(block.thinkingSignature);
297
439
  }
440
+ if (block.thinkingSignature && !abandonedToolUse && index !== untrustedIndex) signedReasoning = true;
298
441
  } else if (block.type === "toolCall") {
299
442
  fragments.push(block.name);
300
443
  fragments.push(stringifyJson(block.arguments) ?? "null");
301
444
  } else if (block.type === "redactedThinking") {
302
445
  // Encrypted reasoning blob the provider still bills for on replay;
303
446
  // excluded from the compaction floor for the same reason as above.
304
- if (!excludeEncryptedReasoning) fragments.push(block.data);
447
+ signedReasoning = true;
448
+ if (!excludeEncryptedReasoning && signaturesPriced) fragments.push(block.data);
305
449
  } else if (block.type === "anthropicServerTool") {
306
450
  // Native Anthropic server-tool call/result replayed verbatim on the
307
451
  // wire (server_tool_use input and opaque result content). The provider
@@ -311,6 +455,19 @@ export class Tokenizer {
311
455
  if (!excludeEncryptedReasoning) fragments.push(stringifyJson(block.block) ?? "null");
312
456
  }
313
457
  }
458
+ // The visible text undercounts a native replay: the provider rebuilds
459
+ // the full reasoning from its signature, billed at the turn's output
460
+ // size less the visible text shake has since elided from it. The
461
+ // floor keeps the visible count.
462
+ if (
463
+ signedReasoning &&
464
+ !excludeEncryptedReasoning &&
465
+ !signaturesPriced &&
466
+ this.#replaysReasoningNatively(message)
467
+ ) {
468
+ const visible = fragments.length === 0 ? extra : extra + this.countTokens(fragments);
469
+ return Math.max(visible, message.usage.output - (message.elidedOutputTokens ?? 0));
470
+ }
314
471
  break;
315
472
  }
316
473
  case "custom":
@@ -323,7 +480,7 @@ export class Tokenizer {
323
480
  if (block.type === "text" && block.text) {
324
481
  fragments.push(block.text);
325
482
  } else if (block.type === "image") {
326
- extra += estimateImageContentTokens(block);
483
+ extra += this.imageTokens(base64ImageSize(block.data), block.detail);
327
484
  }
328
485
  }
329
486
  }
@@ -354,6 +511,41 @@ export class Tokenizer {
354
511
  return extra + this.countTokens(fragments);
355
512
  }
356
513
 
514
+ /**
515
+ * Input tokens this reader bills for replaying `message`'s native Responses
516
+ * reasoning; `undefined` when the reader re-encodes the turn from its
517
+ * blocks instead, `null` when it replays but the backend reported no
518
+ * reasoning tokens to price it by. The payload goes back on the wire only
519
+ * to the api, model and provider that produced it (`convertMessages` in
520
+ * pi-ai's Responses providers), and then only what survives replay: a
521
+ * truncated thinking-only turn is dropped, a hidden-empty or reasoning-only
522
+ * payload sanitizes to nothing, and a cold session rebuilds the turn without
523
+ * its reasoning, so those send no reasoning and no signature (0).
524
+ */
525
+ #replayedReasoningTokens(message: AssistantMessage): number | null | undefined {
526
+ const reader = this.#reasoningReader;
527
+ if (!reader || message.api !== reader.api || message.model !== reader.id) return undefined;
528
+ const items = getOpenAIResponsesHistoryItems(message.providerPayload, reader.provider, message.provider);
529
+ if (!items) return undefined;
530
+ if (!items.some(item => item.type === "reasoning") || shouldDropTruncatedThinkingOnlyAssistant(message)) return 0;
531
+ const replay = sanitizeOpenAIResponsesAssistantHistoryItemsForReplay(items);
532
+ if (!replay?.some(item => item.type === "reasoning")) return 0;
533
+ // Cold, only plaintext reasoning bound to no server state replays natively.
534
+ if (!this.nativeHistoryReplay && !isColdReplayableResponsesTurn(replay)) return 0;
535
+ return message.usage?.reasoningTokens ?? null;
536
+ }
537
+
538
+ /** Whether a Tokenizer built for `model` with `options` would count every message as this one does. */
539
+ countsAlike(model: TokenizerModel | null | undefined, options?: TokenizerOptions): boolean {
540
+ return (
541
+ tokenizerEncodingForModel(model) === this.#encoding &&
542
+ snapcompact.frameBillingKey(snapcompact.frameBilling(model ?? undefined)) === this.frameBillingKey &&
543
+ reasoningReplayKey(model) === this.reasoningReplayKey &&
544
+ resolveThinkingReplayTokens(model ?? {}) === this.thinkingReplayTokens &&
545
+ (options?.nativeHistoryReplay ?? true) === this.nativeHistoryReplay
546
+ );
547
+ }
548
+
357
549
  /** One snapcompact frame at the active model's price for its pixel size; unreadable frames cost the ceiling. */
358
550
  #frameTokens(data: string): number {
359
551
  const size = base64ImageSize(data);