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