@oh-my-pi/pi-agent-core 18.18.0 → 18.18.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.18.2] - 2026-11-08
6
+
7
+ ### Added
8
+
9
+ - Added `canReplayRemoteCompactionFrom()` to determine whether a model can replay provider-native compaction produced by another model.
10
+
11
+ ### Fixed
12
+
13
+ - Fixed GenAI message-content capture configured in omp's own `.env` files so it continues to apply when those values are no longer exposed through the process environment.
14
+ - Fixed compaction preparation for remote OpenAI compaction that the active model cannot replay, ensuring the content is expanded and remains available for compaction.
15
+ - Fixed generic remote compaction requests to `/chat/completions` so gateway sessions retain the correct per-request model attribution.
16
+
5
17
  ## [18.17.0] - 2026-11-06
6
18
 
7
19
  ### Added
@@ -460,6 +460,8 @@ export declare class Agent {
460
460
  * recompute their own snapshot from `peekSteeringQueue()`/`peekFollowUpQueue()`
461
461
  * (or a higher-level view) on notification. */
462
462
  onQueueChange(listener: () => void): () => void;
463
+ /** Notify a session when this agent parks or resumes at a safe process-pause boundary. */
464
+ onPauseChange(listener: (paused: boolean) => void): () => void;
463
465
  /** Register an independently removable hook that runs before queued messages are consumed. */
464
466
  addBeforeQueuedMessageDequeueHook(hook: (signal?: AbortSignal) => Promise<void> | void): () => void;
465
467
  /** Register an independently removable hook that runs immediately before each model call, with the
@@ -205,6 +205,8 @@ export declare const AUTO_HANDOFF_THRESHOLD_FOCUS: string;
205
205
  export interface SummaryOptions {
206
206
  promptOverride?: string;
207
207
  extraContext?: string[];
208
+ /** Additional summary context fetched only when a compaction request consumes it. */
209
+ loadExtraContext?: () => Promise<string[] | undefined>;
208
210
  remoteEndpoint?: string;
209
211
  /** Stable system-prompt segments from the live turn, preserved for provider cache reuse. */
210
212
  remoteSystemPrompt?: string[];
@@ -264,6 +266,8 @@ export interface SummaryOptions {
264
266
  tools?: Tool[];
265
267
  /** Optional fetch implementation threaded into remote compaction calls. */
266
268
  fetch?: FetchImpl;
269
+ /** Inspect or replace a generic remote-compaction payload before it is sent. */
270
+ onPayload?: SimpleStreamOptions["onPayload"];
267
271
  /**
268
272
  * Optional completion transport override for host-level request wrappers
269
273
  * (e.g. the coding-agent provider-concurrency limiter). When provided,
@@ -378,6 +382,13 @@ export interface CompactionPreparation {
378
382
  * Responses-family replay. Local summaries have no provider restriction.
379
383
  */
380
384
  export declare function canReplayRemoteCompaction(preserveData: Record<string, unknown> | undefined, activeModel: Model): boolean;
385
+ /**
386
+ * Whether `reader` can replay the provider-native compaction `compactor` would
387
+ * write. Only the OpenAI Responses lanes (the model's own API or its
388
+ * `remoteCompaction.api` override) store a provider-bound payload; the
389
+ * Anthropic lane and local methods leave a summary any model reads.
390
+ */
391
+ export declare function canReplayRemoteCompactionFrom(compactor: Model, reader: Model): boolean;
381
392
  /**
382
393
  * Whether compaction preparation may reuse a native boundary instead of
383
394
  * re-expanding its original messages. This is deliberately stricter than normal
@@ -14,7 +14,7 @@
14
14
  * summarization endpoints that accept `{ systemPrompt, prompt }` and reply
15
15
  * with `{ summary, shortSummary? }`.
16
16
  */
17
- import type { CodexCompactionContext, FetchImpl, Message, Model, ProviderSessionState } from "@oh-my-pi/pi-ai/types";
17
+ import type { CodexCompactionContext, FetchImpl, Message, Model, ProviderSessionState, SimpleStreamOptions } from "@oh-my-pi/pi-ai/types";
18
18
  import { Tokenizer } from "../tokenizer.js";
19
19
  export * from "./compaction-v2-streaming.js";
20
20
  export declare const OPENAI_REMOTE_COMPACTION_PRESERVE_KEY = "openaiRemoteCompaction";
@@ -153,14 +153,17 @@ export declare function requestOpenAiRemoteCompaction(model: Model, apiKey: stri
153
153
  * HTTP 400 `"'messages' is required"`, compaction silently fell back to
154
154
  * local summarization, and context grew unbounded.
155
155
  *
156
- * When `context.model` is provided the chat-completions body is tagged with
157
- * that model's wire id (llama.cpp requires the field) and `context.apiKey` is
158
- * forwarded as `Authorization: Bearer`. Callers wrap this in `withAuth` so
159
- * 401s force-refresh through the standard credential rotation policy.
156
+ * On chat-completions endpoints the model's request headers are resolved for
157
+ * each call, its wire id is sent (llama.cpp requires it), and `opts.apiKey` is
158
+ * forwarded as `Authorization: Bearer`. Custom summarizers receive no model
159
+ * credentials. `opts.onPayload` can inspect or replace either request body.
160
+ * Callers wrap this in `withAuth` so 401s force-refresh through the standard
161
+ * credential rotation policy.
160
162
  */
161
163
  export declare function requestRemoteCompaction(endpoint: string, request: RemoteCompactionRequest, signal?: AbortSignal, opts?: {
162
164
  fetch?: FetchImpl;
163
165
  timeoutMs?: number;
164
166
  model?: Model;
165
167
  apiKey?: string;
168
+ onPayload?: SimpleStreamOptions["onPayload"];
166
169
  }): Promise<RemoteCompactionResponse>;
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.18.0",
4
+ "version": "18.18.2",
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.18.0",
42
- "@oh-my-pi/pi-catalog": "18.18.0",
43
- "@oh-my-pi/pi-natives": "18.18.0",
44
- "@oh-my-pi/pi-utils": "18.18.0",
45
- "@oh-my-pi/pi-wire": "18.18.0",
46
- "@oh-my-pi/snapcompact": "18.18.0",
41
+ "@oh-my-pi/pi-ai": "18.18.2",
42
+ "@oh-my-pi/pi-catalog": "18.18.2",
43
+ "@oh-my-pi/pi-natives": "18.18.2",
44
+ "@oh-my-pi/pi-utils": "18.18.2",
45
+ "@oh-my-pi/pi-wire": "18.18.2",
46
+ "@oh-my-pi/snapcompact": "18.18.2",
47
47
  "@opentelemetry/api": "^1.9.1"
48
48
  },
49
49
  "devDependencies": {
50
- "@oh-my-pi/omptype": "18.18.0",
50
+ "@oh-my-pi/omptype": "18.18.2",
51
51
  "@opentelemetry/context-async-hooks": "2.9.0",
52
52
  "@opentelemetry/sdk-trace-base": "2.9.0",
53
53
  "@types/bun": "^1.3.14"
package/src/agent.ts CHANGED
@@ -423,6 +423,7 @@ export class Agent {
423
423
  #tokenizer = new Tokenizer(this.#state.model);
424
424
  #listeners = new Set<(e: AgentEvent) => void>();
425
425
  #queueListeners = new Set<() => void>();
426
+ #pauseListeners = new Set<(paused: boolean) => void>();
426
427
  #abortController?: AbortController;
427
428
  #convertToLlm: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
428
429
  #transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise<AgentMessage[]>;
@@ -1014,6 +1015,12 @@ export class Agent {
1014
1015
  return () => this.#queueListeners.delete(listener);
1015
1016
  }
1016
1017
 
1018
+ /** Notify a session when this agent parks or resumes at a safe process-pause boundary. */
1019
+ onPauseChange(listener: (paused: boolean) => void): () => void {
1020
+ this.#pauseListeners.add(listener);
1021
+ return () => this.#pauseListeners.delete(listener);
1022
+ }
1023
+
1017
1024
  /** Register an independently removable hook that runs before queued messages are consumed. */
1018
1025
  addBeforeQueuedMessageDequeueHook(hook: (signal?: AbortSignal) => Promise<void> | void): () => void {
1019
1026
  const registration = (signal?: AbortSignal) => hook(signal);
@@ -1378,6 +1385,19 @@ export class Agent {
1378
1385
  }
1379
1386
  }
1380
1387
 
1388
+ /** Report a safe-boundary park/resume transition; a throwing observer never disrupts the loop. */
1389
+ #emitPauseChanged(paused: boolean): void {
1390
+ for (const listener of this.#pauseListeners) {
1391
+ try {
1392
+ listener(paused);
1393
+ } catch (err) {
1394
+ logger.warn("Agent pause listener threw", {
1395
+ error: err instanceof Error ? err.message : String(err),
1396
+ });
1397
+ }
1398
+ }
1399
+ }
1400
+
1381
1401
  replaceQueues(steering: AgentMessage[], followUp: AgentMessage[]) {
1382
1402
  this.#steeringQueue = steering.slice();
1383
1403
  this.#followUpQueue = followUp.slice();
@@ -2078,7 +2098,9 @@ export class Agent {
2078
2098
  onHarmonyLeak: this.#onHarmonyLeak,
2079
2099
  onTurnEnd: (messages, signal, context) => this.#onTurnEnd?.(messages, signal, context),
2080
2100
  onPauseChange: paused => {
2101
+ if (this.#isPaused === paused) return;
2081
2102
  this.#isPaused = paused;
2103
+ this.#emitPauseChanged(paused);
2082
2104
  },
2083
2105
  getToolChoice,
2084
2106
  softToolRequirementState: this.#softToolRequirementState,
@@ -691,6 +691,8 @@ function shouldRetryHandoffWithAutoToolChoice(response: AssistantMessage): boole
691
691
  export interface SummaryOptions {
692
692
  promptOverride?: string;
693
693
  extraContext?: string[];
694
+ /** Additional summary context fetched only when a compaction request consumes it. */
695
+ loadExtraContext?: () => Promise<string[] | undefined>;
694
696
  remoteEndpoint?: string;
695
697
  /** Stable system-prompt segments from the live turn, preserved for provider cache reuse. */
696
698
  remoteSystemPrompt?: string[];
@@ -755,6 +757,8 @@ export interface SummaryOptions {
755
757
  tools?: Tool[];
756
758
  /** Optional fetch implementation threaded into remote compaction calls. */
757
759
  fetch?: FetchImpl;
760
+ /** Inspect or replace a generic remote-compaction payload before it is sent. */
761
+ onPayload?: SimpleStreamOptions["onPayload"];
758
762
  /**
759
763
  * Optional completion transport override for host-level request wrappers
760
764
  * (e.g. the coding-agent provider-concurrency limiter). When provided,
@@ -1038,7 +1042,7 @@ async function summarizeConversationWindow(
1038
1042
  endpoint,
1039
1043
  { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, prompt: promptText, maxTokens },
1040
1044
  signal,
1041
- { fetch: options.fetch, model, apiKey: key },
1045
+ { fetch: options.fetch, model, apiKey: key, onPayload: options.onPayload },
1042
1046
  ),
1043
1047
  { signal, missingKeyMessage: "Remote compaction credentials unavailable" },
1044
1048
  );
@@ -1269,7 +1273,7 @@ async function generateShortSummary(
1269
1273
  endpoint,
1270
1274
  { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, prompt: promptText, maxTokens },
1271
1275
  signal,
1272
- { fetch: options?.fetch, model, apiKey: key },
1276
+ { fetch: options?.fetch, model, apiKey: key, onPayload: options?.onPayload },
1273
1277
  ),
1274
1278
  { signal, missingKeyMessage: "Remote compaction credentials unavailable" },
1275
1279
  );
@@ -1398,6 +1402,19 @@ export function canReplayRemoteCompaction(
1398
1402
  return !remote || (remote.provider === activeModel.provider && isOpenAiRemoteCompactionApi(activeModel.api));
1399
1403
  }
1400
1404
 
1405
+ /**
1406
+ * Whether `reader` can replay the provider-native compaction `compactor` would
1407
+ * write. Only the OpenAI Responses lanes (the model's own API or its
1408
+ * `remoteCompaction.api` override) store a provider-bound payload; the
1409
+ * Anthropic lane and local methods leave a summary any model reads.
1410
+ */
1411
+ export function canReplayRemoteCompactionFrom(compactor: Model, reader: Model): boolean {
1412
+ return (
1413
+ !isOpenAiRemoteCompactionApi(compactor.remoteCompaction?.api ?? compactor.api) ||
1414
+ (compactor.provider === reader.provider && isOpenAiRemoteCompactionApi(reader.api))
1415
+ );
1416
+ }
1417
+
1401
1418
  /**
1402
1419
  * Whether compaction preparation may reuse a native boundary instead of
1403
1420
  * re-expanding its original messages. This is deliberately stricter than normal
@@ -1461,7 +1478,12 @@ export function prepareCompaction(
1461
1478
  ): CompactionPreparation | undefined {
1462
1479
  const lastEntry = pathEntries[pathEntries.length - 1];
1463
1480
  // A speculative native record may leave uncovered messages before the record.
1464
- if (lastEntry?.type === "compaction" && !lastEntry.providerReplayThroughEntryId) {
1481
+ // A trailing boundary this preparation re-expands covers nothing yet.
1482
+ if (
1483
+ lastEntry?.type === "compaction" &&
1484
+ !lastEntry.providerReplayThroughEntryId &&
1485
+ (!activeModel || remotePreserveReusable(lastEntry.preserveData, activeModel, settings))
1486
+ ) {
1465
1487
  return undefined;
1466
1488
  }
1467
1489
 
@@ -1776,11 +1798,20 @@ export async function compact(
1776
1798
  codexCompaction: options?.codexCompaction,
1777
1799
  tools: options?.tools,
1778
1800
  fetch: options?.fetch,
1801
+ onPayload: options?.onPayload,
1779
1802
  completeImpl: options?.completeImpl,
1780
1803
  // The caller's opt-out must reach every summarization oneshot, otherwise
1781
1804
  // an outer retry loop multiplies with the inner one (see SummaryOptions).
1782
1805
  oneshotRetry: options?.oneshotRetry,
1783
1806
  };
1807
+ let extraContextLoaded = false;
1808
+ const ensureExtraContext = async (): Promise<void> => {
1809
+ if (extraContextLoaded || !options?.loadExtraContext) return;
1810
+ const context = await options.loadExtraContext();
1811
+ signal?.throwIfAborted();
1812
+ if (context?.length) summaryOptions.extraContext = [...(summaryOptions.extraContext ?? []), ...context];
1813
+ extraContextLoaded = true;
1814
+ };
1784
1815
 
1785
1816
  // An archive keeps the reasoning policy of the model it was rendered for;
1786
1817
  // Claude refuses inputs that reproduce reasoning (issue #6093), so a
@@ -2214,6 +2245,7 @@ export async function compact(
2214
2245
  ].filter(files => files.after >= filesDueBefore);
2215
2246
  if (carried.length > 0) nativeRetainedFiles = carried;
2216
2247
  }
2248
+ await ensureExtraContext();
2217
2249
  try {
2218
2250
  // The live turn's own system prompt, wire tools and transformed
2219
2251
  // history when the host can build them: kept thinking remains valid
@@ -2274,6 +2306,7 @@ export async function compact(
2274
2306
  if (!usedRemoteCompaction && nativeCompactionError !== undefined && !summaryOptions.remoteEndpoint) {
2275
2307
  throw new NativeCompactionError(nativeCompactionError);
2276
2308
  }
2309
+ if (!usedRemoteCompaction && nativeSummary === undefined) await ensureExtraContext();
2277
2310
 
2278
2311
  // Generate summaries (can be parallel if both needed) and merge into one
2279
2312
  let summary: string;
@@ -2332,11 +2365,7 @@ export async function compact(
2332
2365
 
2333
2366
  const shortSummary = usedRemoteCompaction
2334
2367
  ? "Remote compaction"
2335
- : await generateShortSummary(recentMessages, summary, model, reserveTokens, apiKey, signal, {
2336
- ...summaryOptions,
2337
- extraContext: options?.extraContext,
2338
- thinkingLevel: options?.thinkingLevel,
2339
- });
2368
+ : await generateShortSummary(recentMessages, summary, model, reserveTokens, apiKey, signal, summaryOptions);
2340
2369
 
2341
2370
  // Compute file lists and append to summary
2342
2371
  const { readFiles, modifiedFiles } = snapcompact.computeFileLists(fileOps);
@@ -39,6 +39,7 @@ import type {
39
39
  Message,
40
40
  Model,
41
41
  ProviderSessionState,
42
+ SimpleStreamOptions,
42
43
  } from "@oh-my-pi/pi-ai/types";
43
44
  import {
44
45
  dropMalformedOpenAIResponsesToolCalls,
@@ -59,7 +60,16 @@ import {
59
60
  OPENAI_HEADER_VALUES,
60
61
  OPENAI_HEADERS,
61
62
  } from "@oh-my-pi/pi-catalog/wire/codex";
62
- import { $env, isRecord, logger, prompt, ptree, stringifyJson, structuredCloneJSON } from "@oh-my-pi/pi-utils";
63
+ import {
64
+ $env,
65
+ isRecord,
66
+ logger,
67
+ prompt,
68
+ ptree,
69
+ stringifyJson,
70
+ structuredCloneJSON,
71
+ untilAborted,
72
+ } from "@oh-my-pi/pi-utils";
63
73
  import { dataUrlImageSize } from "../image-tokens";
64
74
  import { Tokenizer } from "../tokenizer";
65
75
  import { appendAzureApiVersion, resolveAzureOpenAiBaseUrl } from "./azure-openai-endpoint";
@@ -1058,16 +1068,24 @@ export async function requestOpenAiRemoteCompaction(
1058
1068
  * HTTP 400 `"'messages' is required"`, compaction silently fell back to
1059
1069
  * local summarization, and context grew unbounded.
1060
1070
  *
1061
- * When `context.model` is provided the chat-completions body is tagged with
1062
- * that model's wire id (llama.cpp requires the field) and `context.apiKey` is
1063
- * forwarded as `Authorization: Bearer`. Callers wrap this in `withAuth` so
1064
- * 401s force-refresh through the standard credential rotation policy.
1071
+ * On chat-completions endpoints the model's request headers are resolved for
1072
+ * each call, its wire id is sent (llama.cpp requires it), and `opts.apiKey` is
1073
+ * forwarded as `Authorization: Bearer`. Custom summarizers receive no model
1074
+ * credentials. `opts.onPayload` can inspect or replace either request body.
1075
+ * Callers wrap this in `withAuth` so 401s force-refresh through the standard
1076
+ * credential rotation policy.
1065
1077
  */
1066
1078
  export async function requestRemoteCompaction(
1067
1079
  endpoint: string,
1068
1080
  request: RemoteCompactionRequest,
1069
1081
  signal?: AbortSignal,
1070
- opts?: { fetch?: FetchImpl; timeoutMs?: number; model?: Model; apiKey?: string },
1082
+ opts?: {
1083
+ fetch?: FetchImpl;
1084
+ timeoutMs?: number;
1085
+ model?: Model;
1086
+ apiKey?: string;
1087
+ onPayload?: SimpleStreamOptions["onPayload"];
1088
+ },
1071
1089
  ): Promise<RemoteCompactionResponse> {
1072
1090
  let endpointPath = endpoint;
1073
1091
  try {
@@ -1076,13 +1094,22 @@ export async function requestRemoteCompaction(
1076
1094
  // Keep the raw endpoint for relative/custom fetch implementations.
1077
1095
  }
1078
1096
  const isChatCompletions = /\/chat\/completions\/?$/.test(endpointPath);
1079
- const headers: Record<string, string> = { "content-type": "application/json" };
1097
+ const headers = new Headers({ "content-type": "application/json" });
1080
1098
  if (isChatCompletions) {
1081
- if (opts?.apiKey) headers.Authorization = `Bearer ${opts.apiKey}`;
1082
- if (opts?.model?.headers) Object.assign(headers, opts.model.headers);
1099
+ if (opts?.apiKey) headers.set("Authorization", `Bearer ${opts.apiKey}`);
1100
+ const model = opts?.model;
1101
+ const resolveHeaders = model?.resolveHeaders;
1102
+ const configuredHeaders = resolveHeaders
1103
+ ? await untilAborted(signal, () => resolveHeaders(signal))
1104
+ : model?.headers;
1105
+ signal?.throwIfAborted();
1106
+ // `set` replaces case-insensitively, so a configured `authorization` overrides the default bearer.
1107
+ if (configuredHeaders) {
1108
+ for (const name in configuredHeaders) headers.set(name, configuredHeaders[name]!);
1109
+ }
1083
1110
  }
1084
1111
 
1085
- const body: Record<string, unknown> = isChatCompletions
1112
+ let body: unknown = isChatCompletions
1086
1113
  ? {
1087
1114
  model: opts?.model ? resolveOpenAiCompactModel(opts.model) : undefined,
1088
1115
  messages: [
@@ -1093,6 +1120,8 @@ export async function requestRemoteCompaction(
1093
1120
  max_tokens: request.maxTokens,
1094
1121
  }
1095
1122
  : { systemPrompt: request.systemPrompt, prompt: request.prompt, maxTokens: request.maxTokens };
1123
+ const replacement = await opts?.onPayload?.(body, opts.model, signal);
1124
+ if (replacement !== undefined) body = replacement;
1096
1125
 
1097
1126
  const response = await (opts?.fetch ?? fetch)(endpoint, {
1098
1127
  method: "POST",
package/src/telemetry.ts CHANGED
@@ -39,6 +39,7 @@ import {
39
39
  type ToolChoice,
40
40
  type Usage,
41
41
  } from "@oh-my-pi/pi-ai";
42
+ import { $otelEnv } from "@oh-my-pi/pi-utils";
42
43
  import {
43
44
  type Attributes,
44
45
  type AttributeValue,
@@ -453,7 +454,7 @@ export function resolveTelemetry(
453
454
  let contentCaptureEnvCache: ResolvedTelemetryContentCapture | undefined;
454
455
  function readContentCaptureEnv(): ResolvedTelemetryContentCapture {
455
456
  if (contentCaptureEnvCache !== undefined) return contentCaptureEnvCache;
456
- const raw = process.env[CONTENT_CAPTURE_ENV];
457
+ const raw = $otelEnv(CONTENT_CAPTURE_ENV);
457
458
  if (!raw) {
458
459
  contentCaptureEnvCache = "none";
459
460
  return "none";
package/src/tokenizer.ts CHANGED
@@ -3,16 +3,16 @@ import { renderDemotedThinking } from "@oh-my-pi/pi-ai/dialect/demotion";
3
3
  import { isFactoryDroidGeminiWire, keepsFactoryGeminiReasoning } from "@oh-my-pi/pi-ai/providers/factory-droid/gemini";
4
4
  import { ollamaChatDropsOwnThinking } from "@oh-my-pi/pi-ai/providers/ollama";
5
5
  import { completionsDropsOwnThinking, isLiteLLMThinkingSignature } from "@oh-my-pi/pi-ai/providers/openai-completions";
6
- import { isColdReplayableResponsesTurn } from "@oh-my-pi/pi-ai/providers/openai-shared";
6
+ import {
7
+ getNativeResponsesHistoryItems,
8
+ getReplayableNativeResponsesHistoryItems,
9
+ replaysNativeResponsesTurn,
10
+ } from "@oh-my-pi/pi-ai/providers/openai-shared";
7
11
  import {
8
12
  dropsForeignReasoningPayload,
9
13
  isSameModelTurn,
10
14
  shouldDropTruncatedThinkingOnlyAssistant,
11
15
  } from "@oh-my-pi/pi-ai/providers/transform-messages";
12
- import {
13
- getOpenAIResponsesHistoryItems,
14
- sanitizeOpenAIResponsesAssistantHistoryItemsForReplay,
15
- } from "@oh-my-pi/pi-ai/utils";
16
16
  import { resolveThinkingReplayTokens, type ThinkingReplayTokens } from "@oh-my-pi/pi-catalog/compat/thinking-replay";
17
17
  import type { ModelTokenizer } from "@oh-my-pi/pi-catalog/types";
18
18
  import * as natives from "@oh-my-pi/pi-natives";
@@ -595,15 +595,19 @@ export class Tokenizer {
595
595
  * its reasoning, so those send no reasoning and no signature (0).
596
596
  */
597
597
  #replayedReasoningTokens(message: AssistantMessage): number | null | undefined {
598
- const reader = this.#reasoningReader;
599
- if (!reader || message.api !== reader.api || message.model !== reader.id) return undefined;
600
- const items = getOpenAIResponsesHistoryItems(message.providerPayload, reader.provider, message.provider);
601
- if (!items) return undefined;
602
- if (!items.some(item => item.type === "reasoning") || shouldDropTruncatedThinkingOnlyAssistant(message)) return 0;
603
- const replay = sanitizeOpenAIResponsesAssistantHistoryItemsForReplay(items);
604
- if (!replay?.some(item => item.type === "reasoning")) return 0;
605
- // Cold, only plaintext reasoning bound to no server state replays natively.
606
- if (!this.nativeHistoryReplay && !isColdReplayableResponsesTurn(replay)) return 0;
598
+ // A payload this reader does not own is not native history at all: the turn
599
+ // is rebuilt from its blocks, and its signatures are priced below.
600
+ if (!getNativeResponsesHistoryItems(message, this.#reasoningReader)) return undefined;
601
+ // The request decides whether the payload goes on the wire; `/shake` reads
602
+ // that same decision, so a kept turn and a billed turn always agree.
603
+ const replay = getReplayableNativeResponsesHistoryItems(message, this.#reasoningReader);
604
+ if (!replay || !replaysNativeResponsesTurn(replay, this.nativeHistoryReplay)) return 0;
605
+ // Then the amount: nothing reasoning-shaped survives to price, or the turn is
606
+ // dropped whole as truncated thinking, so it sends no reasoning and no
607
+ // signature either. An answer-only payload replays with neither.
608
+ if (!replay.some(item => item.type === "reasoning") || shouldDropTruncatedThinkingOnlyAssistant(message)) {
609
+ return 0;
610
+ }
607
611
  return message.usage?.reasoningTokens ?? null;
608
612
  }
609
613