@linxiraos/pi-agent-core 1.2.0 → 1.3.0
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 +40 -2
- package/THIRD-PARTY-NOTICES.txt +55 -5
- package/dist/types/agent.d.ts +15 -5
- package/dist/types/compaction/compaction.d.ts +20 -4
- package/dist/types/compaction/openai.d.ts +13 -2
- package/dist/types/compaction/shake.d.ts +7 -5
- package/dist/types/compaction/utils.d.ts +7 -2
- package/dist/types/image-tokens.d.ts +9 -12
- package/dist/types/output-budget.d.ts +17 -7
- package/dist/types/tokenizer.d.ts +72 -12
- package/dist/types/types.d.ts +22 -1
- package/package.json +10 -10
- package/src/agent-loop.ts +91 -27
- package/src/agent.ts +56 -22
- package/src/compaction/branch-summarization.ts +28 -3
- package/src/compaction/compaction.ts +286 -149
- package/src/compaction/openai.ts +97 -49
- package/src/compaction/shake.ts +19 -8
- package/src/compaction/utils.ts +8 -10
- package/src/image-tokens.ts +13 -15
- package/src/output-budget.ts +22 -9
- package/src/tokenizer.ts +238 -27
- package/src/types.ts +24 -1
package/src/compaction/openai.ts
CHANGED
|
@@ -42,6 +42,7 @@ import {
|
|
|
42
42
|
getOpenAIResponsesHistoryPayload,
|
|
43
43
|
normalizeResponsesToolCallId,
|
|
44
44
|
stripOpenAIResponsesOutputOnlyStatusesForReplay,
|
|
45
|
+
truncateResponseItemId,
|
|
45
46
|
} from "@linxiraos/pi-ai/utils";
|
|
46
47
|
import { captureOpenAIHttpError } from "@linxiraos/pi-ai/utils/openai-http";
|
|
47
48
|
import { isBedrockOpenAIUrl } from "@linxiraos/pi-catalog/hosts";
|
|
@@ -54,7 +55,7 @@ import {
|
|
|
54
55
|
OPENAI_HEADERS,
|
|
55
56
|
} from "@linxiraos/pi-catalog/wire/codex";
|
|
56
57
|
import { $env, isRecord, logger, prompt, ptree, stringifyJson, structuredCloneJSON } from "@linxiraos/pi-utils";
|
|
57
|
-
import { dataUrlImageSize
|
|
58
|
+
import { dataUrlImageSize } from "../image-tokens";
|
|
58
59
|
import { Tokenizer } from "../tokenizer";
|
|
59
60
|
import { appendAzureApiVersion, resolveAzureOpenAiBaseUrl } from "./azure-openai-endpoint";
|
|
60
61
|
import { prepareBedrockCompactionRequest } from "./bedrock";
|
|
@@ -82,65 +83,94 @@ export const REMOTE_COMPACTION_TIMEOUT_MS = 300_000;
|
|
|
82
83
|
export const CONTEXT_WINDOW_TRUNCATED_OUTPUT_MESSAGE = prompt.render(contextWindowTruncatedOutputPrompt);
|
|
83
84
|
|
|
84
85
|
const REMOTE_COMPACTION_REQUEST_OVERHEAD_TOKENS = 256;
|
|
85
|
-
/**
|
|
86
|
-
|
|
86
|
+
/**
|
|
87
|
+
* Per-`input_image` record estimates, tagged with the pricing tokenizer's
|
|
88
|
+
* billing key; trim re-probes share untouched items by reference.
|
|
89
|
+
*/
|
|
90
|
+
const remoteImageTokenCache = new WeakMap<Record<string, unknown>, { billingKey: string; tokens: number }>();
|
|
87
91
|
const TOOL_RESULT_IMAGE_ATTACHMENT_TEXT = "Attached image(s) from tool result:";
|
|
88
92
|
|
|
89
|
-
|
|
90
|
-
|
|
93
|
+
/**
|
|
94
|
+
* A request as the model reads it: every string value decoded (no quoting or
|
|
95
|
+
* escapes), with keys and scalar values kept as one framing fragment.
|
|
96
|
+
*/
|
|
97
|
+
interface RemoteEstimateParts {
|
|
98
|
+
fragments: string[];
|
|
99
|
+
framing: string[];
|
|
91
100
|
imageTokens: number;
|
|
92
101
|
}
|
|
93
102
|
|
|
94
|
-
function
|
|
103
|
+
function collectRemoteCompactionEstimate(
|
|
104
|
+
value: unknown,
|
|
105
|
+
tokenizer: Tokenizer,
|
|
106
|
+
dropEncrypted: boolean,
|
|
107
|
+
parts: RemoteEstimateParts,
|
|
108
|
+
): void {
|
|
109
|
+
if (typeof value === "string") {
|
|
110
|
+
if (value) parts.fragments.push(value);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
95
113
|
if (Array.isArray(value)) {
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
}
|
|
103
|
-
return { value: normalized, imageTokens };
|
|
114
|
+
for (const item of value) collectRemoteCompactionEstimate(item, tokenizer, dropEncrypted, parts);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (value === null || typeof value !== "object") {
|
|
118
|
+
if (value !== undefined) parts.framing.push(String(value));
|
|
119
|
+
return;
|
|
104
120
|
}
|
|
105
|
-
if (!value || typeof value !== "object") return { value, imageTokens: 0 };
|
|
106
121
|
|
|
107
122
|
const record = value as Record<string, unknown>;
|
|
108
123
|
if (record.type === "input_image") {
|
|
109
|
-
let
|
|
110
|
-
if (
|
|
124
|
+
let cached = remoteImageTokenCache.get(record);
|
|
125
|
+
if (cached?.billingKey !== tokenizer.frameBillingKey) {
|
|
111
126
|
const detail = record.detail;
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
127
|
+
cached = {
|
|
128
|
+
billingKey: tokenizer.frameBillingKey,
|
|
129
|
+
tokens: tokenizer.imageTokens(
|
|
130
|
+
typeof record.image_url === "string" ? dataUrlImageSize(record.image_url) : null,
|
|
131
|
+
detail === "low" || detail === "high" || detail === "original" || detail === "auto" ? detail : undefined,
|
|
132
|
+
),
|
|
133
|
+
};
|
|
134
|
+
remoteImageTokenCache.set(record, cached);
|
|
117
135
|
}
|
|
118
|
-
|
|
136
|
+
parts.imageTokens += cached.tokens;
|
|
137
|
+
return;
|
|
119
138
|
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
let imageTokens = 0;
|
|
123
|
-
for (const [key, item] of Object.entries(record)) {
|
|
139
|
+
for (const key in record) {
|
|
140
|
+
const item = record[key];
|
|
124
141
|
// Opaque encrypted reasoning/compaction state: its local base64 size far
|
|
125
142
|
// exceeds what the provider bills, so it stays out of the fit estimate
|
|
126
143
|
// (same policy as `MessageCountOptions.excludeEncryptedReasoning`).
|
|
127
|
-
if (dropEncrypted && key === "encrypted_content" && typeof item === "string") continue;
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
imageTokens += result.imageTokens;
|
|
144
|
+
if (item === undefined || (dropEncrypted && key === "encrypted_content" && typeof item === "string")) continue;
|
|
145
|
+
parts.framing.push(key);
|
|
146
|
+
collectRemoteCompactionEstimate(item, tokenizer, dropEncrypted, parts);
|
|
131
147
|
}
|
|
132
|
-
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Text fragments and image tokens for `value`. Strings count decoded: JSON
|
|
152
|
+
* escaping inflates quote-, backslash- and newline-dense text (code,
|
|
153
|
+
* disassembly) well past what the model reads.
|
|
154
|
+
*/
|
|
155
|
+
function remoteCompactionEstimateParts(
|
|
156
|
+
value: unknown,
|
|
157
|
+
tokenizer: Tokenizer,
|
|
158
|
+
dropEncrypted: boolean,
|
|
159
|
+
): { fragments: string[]; imageTokens: number } {
|
|
160
|
+
const parts: RemoteEstimateParts = { fragments: [], framing: [], imageTokens: 0 };
|
|
161
|
+
collectRemoteCompactionEstimate(value, tokenizer, dropEncrypted, parts);
|
|
162
|
+
if (parts.framing.length > 0) parts.fragments.push(parts.framing.join(" "));
|
|
163
|
+
return { fragments: parts.fragments, imageTokens: parts.imageTokens };
|
|
133
164
|
}
|
|
134
165
|
|
|
135
166
|
/**
|
|
136
167
|
* Tokens of stored Responses replacement history. Retained `input_image` parts
|
|
137
168
|
* count at their image estimate rather than as base64 text; everything else,
|
|
138
|
-
* opaque compaction state included, counts as
|
|
169
|
+
* opaque compaction state included, counts as decoded text.
|
|
139
170
|
*/
|
|
140
171
|
export function countResponsesHistoryTokens(items: unknown[], tokenizer: Tokenizer): number {
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
return (serialized === undefined ? 0 : tokenizer.countTokens(serialized)) + normalized.imageTokens;
|
|
172
|
+
const { fragments, imageTokens } = remoteCompactionEstimateParts(items, tokenizer, false);
|
|
173
|
+
return (fragments.length === 0 ? 0 : tokenizer.countTokens(fragments)) + imageTokens;
|
|
144
174
|
}
|
|
145
175
|
|
|
146
176
|
export interface TrimRemoteCompactionInputResult {
|
|
@@ -169,10 +199,10 @@ interface RemoteCompactionBudgetProbe {
|
|
|
169
199
|
/**
|
|
170
200
|
* Cheap-first sizing of a remote-compaction request. Images (priced from their
|
|
171
201
|
* dimensions) and the request frame come off the budget rather than through
|
|
172
|
-
* the tokenizer; opaque `encrypted_content` payloads are excluded. The
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
202
|
+
* the tokenizer; opaque `encrypted_content` payloads are excluded. The decoded
|
|
203
|
+
* request text is then probed with {@link Tokenizer.checkTokenBudget}, which
|
|
204
|
+
* only pays for an exact count when the byte bound cannot already prove the
|
|
205
|
+
* request fits.
|
|
176
206
|
*/
|
|
177
207
|
function probeRemoteCompactionInputBudget(
|
|
178
208
|
input: Array<Record<string, unknown>>,
|
|
@@ -181,13 +211,16 @@ function probeRemoteCompactionInputBudget(
|
|
|
181
211
|
tools: unknown[] | undefined,
|
|
182
212
|
contextWindow: number | null | undefined,
|
|
183
213
|
): RemoteCompactionBudgetProbe {
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
|
|
214
|
+
const { fragments, imageTokens } = remoteCompactionEstimateParts(
|
|
215
|
+
{ instructions, input, ...(tools ? { tools } : {}) },
|
|
216
|
+
tokenizer,
|
|
217
|
+
true,
|
|
218
|
+
);
|
|
219
|
+
const flatTokens = imageTokens + REMOTE_COMPACTION_REQUEST_OVERHEAD_TOKENS;
|
|
187
220
|
if (!contextWindow || contextWindow <= 0) {
|
|
188
|
-
return { tokens: tokenizer.countTokens(
|
|
221
|
+
return { tokens: tokenizer.countTokens(fragments, "upperbound") + flatTokens, fits: true, textFits: true };
|
|
189
222
|
}
|
|
190
|
-
const budget = tokenizer.checkTokenBudget(
|
|
223
|
+
const budget = tokenizer.checkTokenBudget(fragments, Math.max(0, contextWindow - flatTokens));
|
|
191
224
|
return {
|
|
192
225
|
tokens: budget.tokens + flatTokens,
|
|
193
226
|
fits: budget.fits,
|
|
@@ -219,6 +252,16 @@ function isToolResultImageAttachment(item: Record<string, unknown>): boolean {
|
|
|
219
252
|
return hasLabel && hasImage;
|
|
220
253
|
}
|
|
221
254
|
|
|
255
|
+
/** Options for {@link trimRemoteCompactionInputToContextWindow}. */
|
|
256
|
+
export interface TrimRemoteCompactionOptions {
|
|
257
|
+
/**
|
|
258
|
+
* Whether oversized trailing tool outputs may be rewritten (default true).
|
|
259
|
+
* False when a caller can compact a shorter prefix instead, so a kept tool
|
|
260
|
+
* result replays intact rather than being replaced.
|
|
261
|
+
*/
|
|
262
|
+
rewriteOutputs?: boolean;
|
|
263
|
+
}
|
|
264
|
+
|
|
222
265
|
/**
|
|
223
266
|
* Preserve the full native transcript unless trailing tool outputs alone push a
|
|
224
267
|
* remote compaction request beyond the model window. Replacing only those
|
|
@@ -233,15 +276,16 @@ export function trimRemoteCompactionInputToContextWindow(
|
|
|
233
276
|
contextWindow: number | null | undefined,
|
|
234
277
|
instructions: string,
|
|
235
278
|
tools?: unknown[],
|
|
279
|
+
options?: TrimRemoteCompactionOptions,
|
|
236
280
|
): TrimRemoteCompactionInputResult {
|
|
237
281
|
const before = probeRemoteCompactionInputBudget(input, tokenizer, instructions, tools, contextWindow);
|
|
238
|
-
if (before.fits) {
|
|
282
|
+
if (before.fits || options?.rewriteOutputs === false) {
|
|
239
283
|
return {
|
|
240
284
|
input,
|
|
241
285
|
rewrittenOutputs: 0,
|
|
242
286
|
estimatedTokensBefore: before.tokens,
|
|
243
287
|
estimatedTokensAfter: before.tokens,
|
|
244
|
-
fits:
|
|
288
|
+
fits: before.fits || before.textFits,
|
|
245
289
|
};
|
|
246
290
|
}
|
|
247
291
|
|
|
@@ -670,8 +714,8 @@ export function buildOpenAiNativeHistory(
|
|
|
670
714
|
let msgId = parsedSignature?.id;
|
|
671
715
|
if (!msgId) {
|
|
672
716
|
msgId = `msg_${msgIndex}`;
|
|
673
|
-
} else
|
|
674
|
-
msgId =
|
|
717
|
+
} else {
|
|
718
|
+
msgId = truncateResponseItemId(msgId, "msg");
|
|
675
719
|
}
|
|
676
720
|
input.push({
|
|
677
721
|
type: "message",
|
|
@@ -816,6 +860,8 @@ export async function requestOpenAiRemoteCompaction(
|
|
|
816
860
|
sessionId?: string;
|
|
817
861
|
providerSessionState?: Map<string, ProviderSessionState>;
|
|
818
862
|
codexCompaction?: CodexCompactionContext;
|
|
863
|
+
/** Forwarded to {@link trimRemoteCompactionInputToContextWindow}. */
|
|
864
|
+
rewriteOutputs?: boolean;
|
|
819
865
|
},
|
|
820
866
|
): Promise<OpenAiRemoteCompactionResponse> {
|
|
821
867
|
let fetchImpl: FetchImpl = opts?.fetch ?? fetch;
|
|
@@ -829,6 +875,8 @@ export async function requestOpenAiRemoteCompaction(
|
|
|
829
875
|
new Tokenizer(model),
|
|
830
876
|
model.contextWindow,
|
|
831
877
|
instructions,
|
|
878
|
+
undefined,
|
|
879
|
+
{ rewriteOutputs: opts?.rewriteOutputs },
|
|
832
880
|
);
|
|
833
881
|
if (trimmed.rewrittenOutputs > 0) {
|
|
834
882
|
logger.info("Rewrote trailing tool outputs before OpenAI remote compaction", {
|
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/compaction/utils.ts
CHANGED
|
@@ -226,9 +226,10 @@ export function escapeSummaryBoundaryTags(text: string): string {
|
|
|
226
226
|
|
|
227
227
|
/**
|
|
228
228
|
* Serialize LLM messages as plain summary input without provider control tokens.
|
|
229
|
+
* `dropThinking` is the summarizer's resolved `classifier-cot-extraction` policy.
|
|
229
230
|
*/
|
|
230
|
-
export function serializeConversationForSummary(messages: Message[], dialect?: Dialect): string {
|
|
231
|
-
const conversation = serializeConversation(messages, dialect);
|
|
231
|
+
export function serializeConversationForSummary(messages: Message[], dialect?: Dialect, dropThinking = false): string {
|
|
232
|
+
const conversation = serializeConversation(messages, dialect, dropThinking);
|
|
232
233
|
const escaped = dialect === "harmony" ? escapeHarmonyControlTokens(conversation) : conversation;
|
|
233
234
|
return escapeSummaryBoundaryTags(escaped);
|
|
234
235
|
}
|
|
@@ -236,8 +237,12 @@ export function serializeConversationForSummary(messages: Message[], dialect?: D
|
|
|
236
237
|
/**
|
|
237
238
|
* Serialize LLM messages to transcript text.
|
|
238
239
|
* Call convertToLlm() first to handle custom message types.
|
|
240
|
+
* `dropThinking` omits assistant reasoning from dialect transcripts: set it from
|
|
241
|
+
* the reader's resolved `classifier-cot-extraction` catalog policy (Claude's
|
|
242
|
+
* classifier refuses inputs that reproduce the model's own reasoning as text,
|
|
243
|
+
* "reasoning_extraction"). Reasoning is ephemeral and low-signal for a summary.
|
|
239
244
|
*/
|
|
240
|
-
export function serializeConversation(messages: Message[], dialect?: Dialect): string {
|
|
245
|
+
export function serializeConversation(messages: Message[], dialect?: Dialect, dropThinking = false): string {
|
|
241
246
|
// Tool results flagged contextually useless (and their paired calls) are
|
|
242
247
|
// dropped from the serialized text: the source region is discarded after
|
|
243
248
|
// summarization anyway, so excluding them costs nothing and keeps garbage
|
|
@@ -249,13 +254,6 @@ export function serializeConversation(messages: Message[], dialect?: Dialect): s
|
|
|
249
254
|
}
|
|
250
255
|
}
|
|
251
256
|
if (dialect) {
|
|
252
|
-
// Claude's classifier refuses inputs that reproduce the model's own
|
|
253
|
-
// reasoning as text ("reasoning_extraction"), and the anthropic dialect
|
|
254
|
-
// otherwise renders thinking verbatim inside <thinking> tags. Reasoning is
|
|
255
|
-
// ephemeral and low-signal for a summary, so drop it from Anthropic-target
|
|
256
|
-
// summary input. Other dialects (e.g. Harmony) carry reasoning natively in
|
|
257
|
-
// their transcript format and keep it.
|
|
258
|
-
const dropThinking = dialect === "anthropic";
|
|
259
257
|
const processed: Message[] = [];
|
|
260
258
|
for (const msg of messages) {
|
|
261
259
|
if (msg.role === "assistant") {
|
package/src/image-tokens.ts
CHANGED
|
@@ -13,12 +13,10 @@ import { parseImageMetadata } from "@linxiraos/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/output-budget.ts
CHANGED
|
@@ -17,14 +17,23 @@ export const MIN_FITTED_OUTPUT_TOKENS = 1024;
|
|
|
17
17
|
*/
|
|
18
18
|
const PROMPT_ESTIMATE_MARGIN_DIVISOR = 10;
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Chat-template framing charged per locally counted message. `Tokenizer.countMessage`
|
|
22
|
+
* counts content only, but every template wraps each message in role markers and
|
|
23
|
+
* separators, and tool results in response wrappers: about 5 tokens per merged
|
|
24
|
+
* Qwen3 `<tool_response>` or Llama 3 turn, 10–15 for harmony or Mistral tool results.
|
|
25
|
+
* The proportional margin scales with content, so many short messages (a turn's
|
|
26
|
+
* parallel tool results) leave this uncovered. Provider-anchored messages need no
|
|
27
|
+
* charge: the host's own report already includes their framing.
|
|
28
|
+
*/
|
|
29
|
+
export const MESSAGE_FRAMING_TOKENS = 16;
|
|
30
|
+
|
|
20
31
|
/**
|
|
21
32
|
* Absolute headway subtracted from the remaining room (unconditionally, on anchored and
|
|
22
|
-
* fully-local counts alike).
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* with 400s. 64 tokens covers the observed drift with margin to spare; the
|
|
27
|
-
* {@link MIN_FITTED_OUTPUT_TOKENS} floor still applies.
|
|
33
|
+
* fully-local counts alike). Covers per-request framing no message count sees: the
|
|
34
|
+
* generation prompt, the anchoring turn's end marker, reasoning wrappers. Measured
|
|
35
|
+
* against a 262,144-token host: fitted caps landed +11 to +42 tokens over and were
|
|
36
|
+
* rejected with 400s. The {@link MIN_FITTED_OUTPUT_TOKENS} floor still applies.
|
|
28
37
|
*/
|
|
29
38
|
export const OUTPUT_FIT_HEADWAY_TOKENS = 64;
|
|
30
39
|
|
|
@@ -43,7 +52,9 @@ export const OUTPUT_FIT_HEADWAY_TOKENS = 64;
|
|
|
43
52
|
* The prompt size is the provider's own report from the newest trustworthy
|
|
44
53
|
* assistant turn (see {@link findRequestUsageAnchor}) plus a local count of
|
|
45
54
|
* only the messages appended after it; the whole context is counted locally
|
|
46
|
-
* only when no turn can anchor (fresh or freshly rewritten context).
|
|
55
|
+
* only when no turn can anchor (fresh or freshly rewritten context). Local
|
|
56
|
+
* counts carry a proportional tokenizer margin plus
|
|
57
|
+
* {@link MESSAGE_FRAMING_TOKENS} per message.
|
|
47
58
|
*
|
|
48
59
|
* Returns `maxTokens` unchanged when the requested cap already fits, the
|
|
49
60
|
* model declares no window, the host ends generation at the window itself
|
|
@@ -122,12 +133,14 @@ function withMargin(localTokens: number): number {
|
|
|
122
133
|
function countPromptTokens(context: Context, tokenizer: Tokenizer): number {
|
|
123
134
|
const { messages } = context;
|
|
124
135
|
const anchor = findRequestUsageAnchor(messages);
|
|
125
|
-
if (!anchor)
|
|
136
|
+
if (!anchor) {
|
|
137
|
+
return withMargin(countContextTokens(context, tokenizer)) + messages.length * MESSAGE_FRAMING_TOKENS;
|
|
138
|
+
}
|
|
126
139
|
let tail = 0;
|
|
127
140
|
for (let index = anchor.index + 1; index < messages.length; index++) {
|
|
128
141
|
tail += tokenizer.countMessage(messages[index]);
|
|
129
142
|
}
|
|
130
|
-
return anchor.tokens + withMargin(tail);
|
|
143
|
+
return anchor.tokens + withMargin(tail) + (messages.length - anchor.index - 1) * MESSAGE_FRAMING_TOKENS;
|
|
131
144
|
}
|
|
132
145
|
|
|
133
146
|
function countContextTokens(context: Context, tokenizer: Tokenizer): number {
|