@oh-my-pi/pi-agent-core 18.10.1 → 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 +19 -0
- package/THIRD-PARTY-NOTICES.txt +3 -3
- package/dist/types/agent.d.ts +4 -2
- package/dist/types/compaction/shake.d.ts +7 -5
- package/dist/types/image-tokens.d.ts +9 -12
- package/dist/types/tokenizer.d.ts +62 -12
- package/dist/types/types.d.ts +9 -0
- package/package.json +8 -8
- package/src/agent-loop.ts +4 -2
- package/src/agent.ts +21 -12
- package/src/compaction/branch-summarization.ts +9 -0
- package/src/compaction/openai.ts +29 -17
- package/src/compaction/shake.ts +19 -8
- package/src/image-tokens.ts +13 -15
- package/src/tokenizer.ts +216 -24
- package/src/types.ts +7 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,25 @@
|
|
|
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
|
+
|
|
18
|
+
## [18.10.2] - 2026-10-12
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- Added `AgentLoopConfig.onDetachedToolUpdate`: a tool's `onUpdate` frames that arrive after its run's event stream ended (a returned call whose background job keeps reporting) reach it instead of being dropped; `Agent` forwards them to its subscribers as `tool_execution_update` events
|
|
23
|
+
|
|
5
24
|
## [18.9.2] - 2026-10-11
|
|
6
25
|
|
|
7
26
|
### Changed
|
package/THIRD-PARTY-NOTICES.txt
CHANGED
|
@@ -79,9 +79,9 @@ uutils coreutils (https://github.com/uutils/coreutils), MIT
|
|
|
79
79
|
--------------------------------------------------------------------------------
|
|
80
80
|
Covers: base32, base64, basename, cat, cksum (shared checksum machinery),
|
|
81
81
|
b2sum, md5sum, sha1sum, sha224sum, sha256sum, sha384sum, sha512sum, comm, cp,
|
|
82
|
-
cut, date, dirname, head, hostname, ln, ls, mkdir, mktemp, mv,
|
|
83
|
-
printenv, readlink, realpath, rm, seq, sort, stat, tac, tail, tee,
|
|
84
|
-
truncate, uname, uniq, wc, whoami, yes.
|
|
82
|
+
cut, date, dirname, head, hostname, ln, ls, mkdir, mktemp, mv, nl, nproc,
|
|
83
|
+
paste, printenv, readlink, realpath, rm, seq, sort, stat, tac, tail, tee,
|
|
84
|
+
touch, tr, truncate, uname, uniq, wc, whoami, yes.
|
|
85
85
|
|
|
86
86
|
Copyright (c) uutils developers
|
|
87
87
|
|
package/dist/types/agent.d.ts
CHANGED
|
@@ -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
|
|
399
|
-
* {@link setModel}),
|
|
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`
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
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
|
-
*
|
|
12
|
-
*
|
|
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
|
|
21
|
-
*
|
|
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
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
* byte estimate (or o200k
|
|
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
|
-
|
|
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
|
|
76
|
-
*
|
|
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/dist/types/types.d.ts
CHANGED
|
@@ -279,6 +279,15 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
|
|
|
279
279
|
* tools finish first.
|
|
280
280
|
*/
|
|
281
281
|
hasQueuedAsides?: () => boolean | Promise<boolean>;
|
|
282
|
+
/**
|
|
283
|
+
* Receives a tool's `onUpdate` frames that arrive after this run's event
|
|
284
|
+
* stream ended: a call that returned while its background job keeps
|
|
285
|
+
* reporting (`details.async`), whose run yielded before the job settled.
|
|
286
|
+
* Without it those frames are dropped.
|
|
287
|
+
*/
|
|
288
|
+
onDetachedToolUpdate?: (event: Extract<AgentEvent, {
|
|
289
|
+
type: "tool_execution_update";
|
|
290
|
+
}>) => void;
|
|
282
291
|
/**
|
|
283
292
|
* Returns follow-up messages to process after the agent would otherwise stop.
|
|
284
293
|
*
|
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.
|
|
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.
|
|
42
|
-
"@oh-my-pi/pi-catalog": "18.10.
|
|
43
|
-
"@oh-my-pi/pi-natives": "18.10.
|
|
44
|
-
"@oh-my-pi/pi-utils": "18.10.
|
|
45
|
-
"@oh-my-pi/pi-wire": "18.10.
|
|
46
|
-
"@oh-my-pi/snapcompact": "18.10.
|
|
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.
|
|
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-loop.ts
CHANGED
|
@@ -3570,13 +3570,15 @@ async function executeToolCalls(
|
|
|
3570
3570
|
executionArgs,
|
|
3571
3571
|
record.signal,
|
|
3572
3572
|
partialResult => {
|
|
3573
|
-
|
|
3573
|
+
const event: Extract<AgentEvent, { type: "tool_execution_update" }> = {
|
|
3574
3574
|
type: "tool_execution_update",
|
|
3575
3575
|
toolCallId: toolCall.id,
|
|
3576
3576
|
toolName: toolCall.name,
|
|
3577
3577
|
args: executionArgs,
|
|
3578
3578
|
partialResult: coerceToolResult(partialResult).result,
|
|
3579
|
-
}
|
|
3579
|
+
};
|
|
3580
|
+
if (stream.done) config.onDetachedToolUpdate?.(event);
|
|
3581
|
+
else stream.push(event);
|
|
3580
3582
|
},
|
|
3581
3583
|
toolContext,
|
|
3582
3584
|
);
|
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
|
|
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
|
|
854
|
-
* {@link setModel}),
|
|
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
|
|
862
|
-
*
|
|
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
|
-
|
|
866
|
-
|
|
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 {
|
|
@@ -1950,6 +1958,7 @@ export class Agent {
|
|
|
1950
1958
|
getFollowUpMessages: signal => this.#dequeueFollowUpMessagesAfterHooks(signal ?? loopSignal),
|
|
1951
1959
|
getAsideMessages: async () => (await this.#asideMessageProvider?.()) ?? [],
|
|
1952
1960
|
onBeforeYield: () => this.#onBeforeYield?.(),
|
|
1961
|
+
onDetachedToolUpdate: event => this.emitExternalEvent(event),
|
|
1953
1962
|
telemetry: this.#telemetry,
|
|
1954
1963
|
};
|
|
1955
1964
|
|
|
@@ -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")
|
package/src/compaction/openai.ts
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
86
|
-
|
|
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(
|
|
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
|
|
110
|
-
if (
|
|
116
|
+
let cached = remoteImageTokenCache.get(record);
|
|
117
|
+
if (cached?.billingKey !== tokenizer.frameBillingKey) {
|
|
111
118
|
const detail = record.detail;
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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(
|
|
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) {
|
package/src/compaction/shake.ts
CHANGED
|
@@ -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`
|
|
426
|
-
*
|
|
427
|
-
*
|
|
428
|
-
*
|
|
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
|
|
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(
|
|
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)
|
|
478
|
+
for (const { region, replacement, replacementTokens } of ordered)
|
|
479
|
+
applyShakeRegion(region, replacement, replacementTokens);
|
|
469
480
|
}
|
package/src/image-tokens.ts
CHANGED
|
@@ -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
|
-
*
|
|
19
|
-
*
|
|
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
|
|
46
|
-
*
|
|
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(
|
|
49
|
-
|
|
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,
|
|
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
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
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
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
* byte estimate (or o200k
|
|
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
|
-
|
|
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
|
|
222
|
-
*
|
|
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 +=
|
|
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
|
-
|
|
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,
|
|
291
|
-
//
|
|
292
|
-
//
|
|
293
|
-
//
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
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 +=
|
|
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);
|
package/src/types.ts
CHANGED
|
@@ -338,6 +338,13 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
|
|
|
338
338
|
* tools finish first.
|
|
339
339
|
*/
|
|
340
340
|
hasQueuedAsides?: () => boolean | Promise<boolean>;
|
|
341
|
+
/**
|
|
342
|
+
* Receives a tool's `onUpdate` frames that arrive after this run's event
|
|
343
|
+
* stream ended: a call that returned while its background job keeps
|
|
344
|
+
* reporting (`details.async`), whose run yielded before the job settled.
|
|
345
|
+
* Without it those frames are dropped.
|
|
346
|
+
*/
|
|
347
|
+
onDetachedToolUpdate?: (event: Extract<AgentEvent, { type: "tool_execution_update" }>) => void;
|
|
341
348
|
|
|
342
349
|
/**
|
|
343
350
|
* Returns follow-up messages to process after the agent would otherwise stop.
|