@linxiraos/pi-agent-core 1.2.1 → 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.
@@ -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, estimateImageTokens } from "../image-tokens";
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
- /** Per-`input_image` record estimates; trim re-probes share untouched items by reference. */
86
- const remoteImageTokenCache = new WeakMap<Record<string, unknown>, number>();
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
- interface NormalizedEstimateValue {
90
- value: unknown;
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 normalizeRemoteCompactionEstimateValue(value: unknown, dropEncrypted = true): NormalizedEstimateValue {
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 normalized: unknown[] = [];
97
- let imageTokens = 0;
98
- for (const item of value) {
99
- const result = normalizeRemoteCompactionEstimateValue(item, dropEncrypted);
100
- normalized.push(result.value);
101
- imageTokens += result.imageTokens;
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 imageTokens = remoteImageTokenCache.get(record);
110
- if (imageTokens === undefined) {
124
+ let cached = remoteImageTokenCache.get(record);
125
+ if (cached?.billingKey !== tokenizer.frameBillingKey) {
111
126
  const detail = record.detail;
112
- imageTokens = estimateImageTokens(
113
- typeof record.image_url === "string" ? dataUrlImageSize(record.image_url) : null,
114
- detail === "low" || detail === "high" || detail === "original" || detail === "auto" ? detail : undefined,
115
- );
116
- remoteImageTokenCache.set(record, imageTokens);
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
- return { value: { ...record, image_url: "<image>" }, imageTokens };
136
+ parts.imageTokens += cached.tokens;
137
+ return;
119
138
  }
120
-
121
- const normalized: Record<string, unknown> = {};
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
- const result = normalizeRemoteCompactionEstimateValue(item, dropEncrypted);
129
- normalized[key] = result.value;
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
- return { value: normalized, imageTokens };
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 serialized.
169
+ * opaque compaction state included, counts as decoded text.
139
170
  */
140
171
  export function countResponsesHistoryTokens(items: unknown[], tokenizer: Tokenizer): number {
141
- const normalized = normalizeRemoteCompactionEstimateValue(items, false);
142
- const serialized = stringifyJson(normalized.value);
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
- * serialized transcript is then probed with {@link Tokenizer.checkTokenBudget},
174
- * which only pays for an exact count when the byte bound cannot already prove
175
- * the request fits.
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 normalized = normalizeRemoteCompactionEstimateValue({ instructions, input, ...(tools ? { tools } : {}) });
185
- const serialized = stringifyJson(normalized.value) ?? "";
186
- const flatTokens = normalized.imageTokens + REMOTE_COMPACTION_REQUEST_OVERHEAD_TOKENS;
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(serialized, "upperbound") + flatTokens, fits: true, textFits: true };
221
+ return { tokens: tokenizer.countTokens(fragments, "upperbound") + flatTokens, fits: true, textFits: true };
189
222
  }
190
- const budget = tokenizer.checkTokenBudget(serialized, Math.max(0, contextWindow - flatTokens));
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: true,
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 if (msgId.length > 64) {
674
- msgId = `msg_${Bun.hash(msgId).toString(36)}`;
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", {
@@ -422,12 +422,13 @@ function getBlockTextSlot(entry: SessionMessageEntry | CustomMessageEntry, block
422
422
  *
423
423
  * Tool-result: replaces its first non-empty text block with the placeholder,
424
424
  * removes its other text blocks, preserves every non-text block, and stamps
425
- * `prunedAt`. Block: splices `replacement` over `[start, end)` of the
426
- * target text block. When several block regions share one text block they MUST
427
- * be applied highest-start-first so earlier offsets stay valid — use
428
- * {@link applyShakeRegions}, which orders them correctly.
425
+ * `prunedAt`. Block: splices `replacement` (`replacementTokens` long) over
426
+ * `[start, end)` of the target text block, and on an assistant turn adds the
427
+ * tokens removed to `elidedOutputTokens`. When several block regions share one
428
+ * text block they MUST be applied highest-start-first so earlier offsets stay
429
+ * valid — use {@link applyShakeRegions}, which orders them correctly.
429
430
  */
430
- export function applyShakeRegion(region: ShakeRegion, replacement: string): void {
431
+ export function applyShakeRegion(region: ShakeRegion, replacement: string, replacementTokens: number): void {
431
432
  if (region.kind === "toolResult") {
432
433
  const message = region.entry.message as ToolResultMessage;
433
434
  const replacementIndex = message.content.findIndex(block => block.type === "text" && block.text.length > 0);
@@ -451,7 +452,14 @@ export function applyShakeRegion(region: ShakeRegion, replacement: string): void
451
452
  // rebuilds, so an in-place block rewrite must drop its cached estimate/convert.
452
453
  // Custom-message entries are re-materialized into a fresh AgentMessage on every
453
454
  // buildSessionContext, so they carry no stable cached identity to invalidate.
454
- if (region.entry.type === "message") invalidateMessageCache(region.entry.message);
455
+ if (region.entry.type !== "message") return;
456
+ const message = region.entry.message;
457
+ // `usage.output` keeps billing the removed text; the persisted tally lets
458
+ // every later count of a replayed turn take it off again.
459
+ if (message.role === "assistant") {
460
+ message.elidedOutputTokens = (message.elidedOutputTokens ?? 0) + Math.max(0, region.tokens - replacementTokens);
461
+ }
462
+ invalidateMessageCache(message);
455
463
  }
456
464
 
457
465
  /**
@@ -459,11 +467,14 @@ export function applyShakeRegion(region: ShakeRegion, replacement: string): void
459
467
  * that splicing one region never shifts the offsets of another in the same text
460
468
  * block; tool-result regions are independent.
461
469
  */
462
- export function applyShakeRegions(items: Array<{ region: ShakeRegion; replacement: string }>): void {
470
+ export function applyShakeRegions(
471
+ items: Array<{ region: ShakeRegion; replacement: string; replacementTokens: number }>,
472
+ ): void {
463
473
  const ordered = [...items].sort((a, b) => {
464
474
  const aStart = a.region.kind === "block" ? a.region.start : -1;
465
475
  const bStart = b.region.kind === "block" ? b.region.start : -1;
466
476
  return bStart - aStart;
467
477
  });
468
- for (const { region, replacement } of ordered) applyShakeRegion(region, replacement);
478
+ for (const { region, replacement, replacementTokens } of ordered)
479
+ applyShakeRegion(region, replacement, replacementTokens);
469
480
  }
@@ -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") {
@@ -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
- * Follows the catalog's image rule for the OpenAI Responses wire (GPT-5.5's
19
- * 32px patches, a per-detail pixel limit and patch budget, x1.2 multiplier),
20
- * which is also a close upper estimate for other providers once omp has
21
- * downscaled the image (≤1568px by default).
16
+ * the remote probe. Both price with the reading model's catalog image rule
17
+ * ({@link Tokenizer.imageTokens}); a reader without one falls back to the
18
+ * OpenAI Responses wire's rule (GPT-5.5's 32px patches, a per-detail pixel
19
+ * limit and patch budget, x1.2 multiplier).
22
20
  */
23
21
 
24
22
  /** OpenAI image `detail` values; `undefined` means the provider default (`auto`). */
@@ -42,11 +40,16 @@ function openAiWireRule(): ImageTokenization {
42
40
  }
43
41
 
44
42
  /**
45
- * Estimated input tokens for one image. Unknown dimensions (undecodable data,
46
- * remote URLs, provider file ids) charge the detail level's full patch budget.
43
+ * Estimated input tokens for one image under `rule`, the OpenAI Responses
44
+ * wire's by default. Unknown dimensions (undecodable data, remote URLs,
45
+ * provider file ids) charge the rule's most expensive size.
47
46
  */
48
- export function estimateImageTokens(size: ImageSize | null | undefined, detail?: ImageDetail): number {
49
- return imageTokens(openAiWireRule(), size && size.width > 0 && size.height > 0 ? size : UNKNOWN_SIZE, detail);
47
+ export function estimateImageTokens(
48
+ size: ImageSize | null | undefined,
49
+ detail?: ImageDetail,
50
+ rule: ImageTokenization = openAiWireRule(),
51
+ ): number {
52
+ return imageTokens(rule, size && size.width > 0 && size.height > 0 ? size : UNKNOWN_SIZE, detail);
50
53
  }
51
54
 
52
55
  // Enough decoded bytes to reach a JPEG SOF marker behind typical EXIF/ICC
@@ -70,8 +73,3 @@ export function dataUrlImageSize(url: string): ImageSize | null {
70
73
  if (comma < 0 || !url.slice(0, comma).endsWith(";base64")) return null;
71
74
  return base64ImageSize(url.slice(comma + 1));
72
75
  }
73
-
74
- /** {@link estimateImageTokens} for an in-memory image block. */
75
- export function estimateImageContentTokens(image: Pick<ImageContent, "data" | "detail">): number {
76
- return estimateImageTokens(base64ImageSize(image.data), image.detail);
77
- }
@@ -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). Proportional padding covers
23
- * tokenizer drift that scales with the prompt, but a host can still count a few tokens more
24
- * than any local estimate can see (chat-template framing, reasoning wrappers). Measured
25
- * against a 262,144-token host: fitted caps landed +11 to +42 tokens over and were rejected
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) return withMargin(countContextTokens(context, tokenizer));
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 {