@nijaru/pi-provider-compaction 0.0.6 → 0.0.8

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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # pi-provider-compaction
2
2
 
3
- Experimental provider-native compaction for [Pi](https://github.com/earendil-works/pi), with a meaningful portable summary saved alongside each native checkpoint. Minimum runtime: Pi 0.87.0. Development checks target Pi 1.2.0.
3
+ Experimental provider-native compaction for [Pi](https://github.com/earendil-works/pi), with a meaningful portable summary saved alongside each native checkpoint. Minimum runtime: Pi 0.87.0. Development checks target Pi 1.3.0.
4
4
 
5
5
  **This experimental design is not production-validated. Do not enable it globally yet.** Real runner/adapter fixtures and a Codex live smoke test pass; broader backend and deployment validation remains incomplete.
6
6
 
@@ -73,7 +73,7 @@ pi --extension ./dist/index.js
73
73
 
74
74
  The manifest loads the built `dist/index.js`, including a bundled stateless Responses serializer validator; source or host dependency changes require a rebuild. `bun run check` rebuilds first and tests the artifact against Pi's actual host serializer, including function-call history replayed as grammar tools. Keep global installation disabled while testing. Publishing and global activation are separate approval steps.
75
75
 
76
- Tests include real Pi 1.2.0 runner/adapter fixtures for all three protocols, repeated prefix compaction, transformed suffix preservation, unchanged fallback, grammar tools, strict defaults, tool IDs, images and prompt updates. Set `PI_COMPACTOR_FIXTURE_PATH` to a `pi-compactor` entrypoint to check SDK flag precedence in both extension orders. The optional installed-fast-mode fixture (`PI_FAST_MODE_FIXTURE_PATH`) checks actual `pi-fast-mode` composition across `/reload`; it creates an isolated fast-mode configuration.
76
+ Tests include real Pi 1.3.0 runner/adapter fixtures for all three protocols, repeated prefix compaction, transformed suffix preservation, unchanged fallback, grammar tools, strict defaults, tool IDs, images, prompt updates, cross-model thinking and mid-conversation effort changes. Set `PI_COMPACTOR_FIXTURE_PATH` to a `pi-compactor` entrypoint to check SDK flag precedence in both extension orders. The optional installed-fast-mode fixture (`PI_FAST_MODE_FIXTURE_PATH`) checks actual `pi-fast-mode` composition across `/reload`; it creates an isolated fast-mode configuration.
77
77
 
78
78
  Authenticated protocol smoke tests are separate and send three small requests:
79
79
 
package/dist/index.js CHANGED
@@ -200,6 +200,8 @@ function installObserver(pi, ctx, observe) {
200
200
  const id = model.provider;
201
201
  const native = registry.getRegisteredNativeProvider(id);
202
202
  if (native) {
203
+ if (typeof native.getModels !== "function" || !native.getModels().some((entry) => entry.api === model.api))
204
+ return;
203
205
  const isInstalled = () => registry.getRegisteredNativeProvider(id) === wrapper;
204
206
  const guarded = (...args) => {
205
207
  if (!isInstalled())
@@ -637,19 +639,13 @@ function transformMessages(messages, model, normalizeToolCallId) {
637
639
  const isSameModel = assistantMsg.provider === model.provider && assistantMsg.api === model.api && assistantMsg.model === model.id;
638
640
  const transformedContent = assistantMsg.content.flatMap((block) => {
639
641
  if (block.type === "thinking") {
640
- if (block.redacted) {
641
- return isSameModel ? block : [];
642
- }
643
- if (isSameModel && block.thinkingSignature)
642
+ if (!isSameModel)
643
+ return [];
644
+ if (block.redacted || block.thinkingSignature)
644
645
  return block;
645
646
  if (!block.thinking || block.thinking.trim() === "")
646
647
  return [];
647
- if (isSameModel)
648
- return block;
649
- return {
650
- type: "text",
651
- text: block.thinking
652
- };
648
+ return block;
653
649
  }
654
650
  if (block.type === "text") {
655
651
  if (isSameModel)
@@ -840,6 +836,12 @@ function convertResponsesMessages(model, context, allowedToolCallProviders, opti
840
836
  });
841
837
  };
842
838
  const includeInitialSystemMessage = options?.includeSystemPrompt ?? true;
839
+ const pushConfigurationUpdate = (effort) => {
840
+ messages.push({
841
+ type: "configuration_update",
842
+ reasoning: { effort }
843
+ });
844
+ };
843
845
  const compat = model.compat;
844
846
  const instructionRole = model.reasoning && compat?.supportsDeveloperRole !== false ? "developer" : "system";
845
847
  let msgIndex = 0;
@@ -946,6 +948,9 @@ function convertResponsesMessages(model, context, allowedToolCallProviders, opti
946
948
  }
947
949
  if (output.length === 0)
948
950
  continue;
951
+ if (options?.configurationUpdateEffort !== undefined && isSameModel && msg.providerThinkingLevel !== undefined) {
952
+ pushConfigurationUpdate(msg.providerThinkingLevel);
953
+ }
949
954
  messages.push(...output);
950
955
  } else if (msg.role === "toolResult") {
951
956
  const [callId] = msg.toolCallId.split("|");
@@ -967,6 +972,8 @@ function convertResponsesMessages(model, context, allowedToolCallProviders, opti
967
972
  if (!isLeadingSystemMessage)
968
973
  msgIndex++;
969
974
  }
975
+ if (options?.configurationUpdateEffort !== undefined)
976
+ pushConfigurationUpdate(options.configurationUpdateEffort);
970
977
  return messages;
971
978
  }
972
979
  function convertResponsesTools(tools, options) {
@@ -1223,6 +1230,15 @@ async function collectProviderUsage(stream, signal) {
1223
1230
  throw new Error("Native compaction provider stream ended without usage");
1224
1231
  return usage;
1225
1232
  }
1233
+
1234
+ class NativeCompactionValidationError extends Error {
1235
+ usage;
1236
+ constructor(message, usage) {
1237
+ super(message);
1238
+ this.usage = usage;
1239
+ this.name = "NativeCompactionValidationError";
1240
+ }
1241
+ }
1226
1242
  async function requestResponsesCompact(request) {
1227
1243
  let preparedPayload;
1228
1244
  let compacted;
@@ -1249,9 +1265,13 @@ async function requestResponsesCompact(request) {
1249
1265
  if (!response.ok)
1250
1266
  return response;
1251
1267
  const parsed = await readJsonBounded(response, request.signal);
1252
- if (!isObject(parsed))
1253
- throw new Error("Responses Compact returned a non-object response");
1254
- compacted = { response: parsed, output: validateCompactedOutput(parsed) };
1268
+ try {
1269
+ if (!isObject(parsed))
1270
+ throw new Error("Responses Compact returned a non-object response");
1271
+ compacted = { response: parsed, output: validateCompactedOutput(parsed) };
1272
+ } catch (error) {
1273
+ throw new NativeCompactionValidationError(error instanceof Error ? error.message : String(error));
1274
+ }
1255
1275
  return syntheticCompletion(parsed, preparedPayload);
1256
1276
  } catch (error) {
1257
1277
  bridgeError = error;
@@ -1263,6 +1283,7 @@ async function requestResponsesCompact(request) {
1263
1283
  headers: request.headers,
1264
1284
  env: request.env,
1265
1285
  signal: request.signal,
1286
+ ...request.serviceTier === undefined ? {} : { serviceTier: request.serviceTier },
1266
1287
  transport: "sse",
1267
1288
  cacheRetention: "none",
1268
1289
  maxRetries: 0,
@@ -1281,6 +1302,8 @@ async function requestResponsesCompact(request) {
1281
1302
  throw bridgeError;
1282
1303
  throw error;
1283
1304
  });
1305
+ if (bridgeError instanceof NativeCompactionValidationError)
1306
+ throw new NativeCompactionValidationError(bridgeError.message, usage);
1284
1307
  if (bridgeError)
1285
1308
  throw bridgeError;
1286
1309
  if (!compacted || dispatches !== 1)
@@ -1425,6 +1448,7 @@ async function requestRemoteV2(request) {
1425
1448
  headers: request.headers,
1426
1449
  env: request.env,
1427
1450
  signal: request.signal,
1451
+ ...request.serviceTier === undefined ? {} : { serviceTier: request.serviceTier },
1428
1452
  transport: "sse",
1429
1453
  cacheRetention: "none",
1430
1454
  maxRetries: 0,
@@ -1452,7 +1476,11 @@ async function requestRemoteV2(request) {
1452
1476
  }
1453
1477
  async function requestProviderCompaction(request) {
1454
1478
  const result = await (request.protocol === "remote-v2" ? requestRemoteV2(request) : requestResponsesCompact(request));
1455
- return { ...result, output: validateCompactedOutput(result) };
1479
+ try {
1480
+ return { ...result, output: validateCompactedOutput(result) };
1481
+ } catch (error) {
1482
+ throw new NativeCompactionValidationError(error instanceof Error ? error.message : String(error), result.usage);
1483
+ }
1456
1484
  }
1457
1485
 
1458
1486
  // replay.ts
@@ -1463,7 +1491,7 @@ var SNAPSHOT_TTL_MS = 5 * 60000;
1463
1491
  function equal(left, right) {
1464
1492
  return JSON.stringify(left) === JSON.stringify(right);
1465
1493
  }
1466
- function serializeInput(model, context, declarations = context) {
1494
+ function serializeInput(model, context, declarations = context, configurationUpdateEffort) {
1467
1495
  const compat = model.compat;
1468
1496
  const flag = (name) => compat?.[name] === true;
1469
1497
  const codex = model.api === "openai-codex-responses";
@@ -1471,6 +1499,7 @@ function serializeInput(model, context, declarations = context) {
1471
1499
  const tools = resolveTranscript2(declarations, flag("supportsMidConvoSystemMessages"));
1472
1500
  return convertResponsesMessages(model, normalized, model.api === "azure-openai-responses" ? AZURE_TOOL_CALL_PROVIDERS : TOOL_CALL_PROVIDERS, {
1473
1501
  includeSystemPrompt: !codex,
1502
+ configurationUpdateEffort,
1474
1503
  grammarToolInputProperties: createGrammarToolInputProperties(getDeclaredTools(tools.messages), flag("supportsOpenAIGrammarTools")),
1475
1504
  supportsMidConvoSystemMessages: flag("supportsMidConvoSystemMessages"),
1476
1505
  supportsAdditionalTools: flag("supportsAdditionalTools"),
@@ -1495,12 +1524,15 @@ function capturePreparedRequest(model, context, canonical, payload, reject = ()
1495
1524
  reject("snapshot exceeds size limit");
1496
1525
  return;
1497
1526
  }
1498
- const expected = serializeInput(model, context);
1527
+ const last = payload.input.at(-1);
1528
+ const compat = model.compat;
1529
+ const configurationUpdateEffort = model.api === "openai-responses" && compat?.supportsMidConvoEffort === true && last?.type === "configuration_update" && isObject(last.reasoning) && typeof last.reasoning.effort === "string" ? last.reasoning.effort : undefined;
1530
+ const expected = serializeInput(model, context, context, configurationUpdateEffort);
1499
1531
  if (!equal(expected, payload.input)) {
1500
1532
  reject("prepared input differs from serializer");
1501
1533
  return;
1502
1534
  }
1503
- return { context: structuredClone(context), canonical: structuredClone(canonical), payload: structuredClone(payload), portableInput: expected, createdAt: Date.now() };
1535
+ return { context: structuredClone(context), canonical: structuredClone(canonical), payload: structuredClone(payload), portableInput: expected, configurationUpdateEffort, createdAt: Date.now() };
1504
1536
  }
1505
1537
  function promptCount(input) {
1506
1538
  let count = 0;
@@ -1508,6 +1540,10 @@ function promptCount(input) {
1508
1540
  count++;
1509
1541
  return count;
1510
1542
  }
1543
+ function serializePrefix(model, snapshot, prefix) {
1544
+ const input = serializeInput(model, prefix, snapshot.context, snapshot.configurationUpdateEffort);
1545
+ return snapshot.configurationUpdateEffort === undefined ? input : input.slice(0, -1);
1546
+ }
1511
1547
  function closedTools(context) {
1512
1548
  const pending = new Set;
1513
1549
  for (const message of context.messages) {
@@ -1551,7 +1587,7 @@ function selectCoveredPrefix(model, snapshot, prefix, reject = () => {}) {
1551
1587
  reject("projected prefix differs from request context");
1552
1588
  return;
1553
1589
  }
1554
- const expected = serializeInput(model, mapped, snapshot.context);
1590
+ const expected = serializePrefix(model, snapshot, mapped);
1555
1591
  if (!equal(snapshot.portableInput.slice(0, expected.length), expected)) {
1556
1592
  reject("serialized prefix does not match prepared input");
1557
1593
  return;
@@ -1584,7 +1620,7 @@ function summarySlot(model, snapshot, summaryPrefix) {
1584
1620
  const mapped = mappedPrefix(snapshot, summaryPrefix);
1585
1621
  if (!mapped)
1586
1622
  return;
1587
- const prefix = serializeInput(model, mapped, snapshot.context);
1623
+ const prefix = serializePrefix(model, snapshot, mapped);
1588
1624
  const index = promptCount(prefix);
1589
1625
  if (prefix.length !== index + 1 || !equal(snapshot.portableInput.slice(0, prefix.length), prefix))
1590
1626
  return;
@@ -1763,7 +1799,8 @@ Last attempt: ${compactionStatus}`, "info");
1763
1799
  return;
1764
1800
  }
1765
1801
  if (!options.onPayload) {
1766
- captureRejected("no payload callback");
1802
+ if (captureStatus !== "eligible prepared request captured")
1803
+ captureRejected("no payload callback");
1767
1804
  return;
1768
1805
  }
1769
1806
  if (generic(owner)) {
@@ -1782,7 +1819,7 @@ Last attempt: ${compactionStatus}`, "info");
1782
1819
  if (generation !== ticket || ctx !== owner || options.signal?.aborted || !supportedRouteOptions(model, options))
1783
1820
  return false;
1784
1821
  try {
1785
- return digest(owner.sessionManager.getBranch()) === branchHash;
1822
+ return unchanged(owner.sessionManager.getBranch(), branch.length, branchHash);
1786
1823
  } catch {
1787
1824
  retire();
1788
1825
  return false;
@@ -1814,8 +1851,9 @@ Last attempt: ${compactionStatus}`, "info");
1814
1851
  const routeHash = route(model, options);
1815
1852
  const policyHash = digest(requestPolicy(model, prepared));
1816
1853
  const latest = latestCompaction(branch);
1854
+ const payloadModel = isObject(payload) && typeof payload.model === "string" ? payload.model : undefined;
1817
1855
  let result = payload;
1818
- if (active && latest?.id === active.entryId && latest.summary === active.summary && digest(latest.details) === active.detailsHash && unchanged(branch, active.branchLength, active.branchHash) && active.routeHash === routeHash && active.policyHash === policyHash) {
1856
+ if (active && latest?.id === active.entryId && latest.summary === active.summary && active.payloadModel === payloadModel && unchanged(branch, active.branchLength, active.branchHash) && active.routeHash === routeHash && active.policyHash === policyHash) {
1819
1857
  const details = readNativeCompactionDetails(latest.details);
1820
1858
  const projection = owner.sessionManager.buildSessionProjection();
1821
1859
  const contribution = projection.entries.find((entry) => entry.sourceEntry.id === latest.id);
@@ -1838,7 +1876,7 @@ Last attempt: ${compactionStatus}`, "info");
1838
1876
  prepared,
1839
1877
  model: structuredClone(model),
1840
1878
  provider,
1841
- options: { apiKey: options.apiKey, headers: structuredClone(options.headers), env: structuredClone(options.env), fetch: options.fetch },
1879
+ options: { apiKey: options.apiKey, headers: structuredClone(options.headers), env: structuredClone(options.env), fetch: options.fetch, ...typeof options.serviceTier === "string" ? { serviceTier: options.serviceTier } : {} },
1842
1880
  branchLength: branch.length,
1843
1881
  branchHash,
1844
1882
  routeHash,
@@ -1892,6 +1930,7 @@ Last attempt: ${compactionStatus}`, "info");
1892
1930
  removeInvalidation();
1893
1931
  });
1894
1932
  pi.on("session_before_compact", async (event, context) => {
1933
+ compactionStatus = "preparing compaction";
1895
1934
  const capture = snapshot;
1896
1935
  snapshot = undefined;
1897
1936
  pending = undefined;
@@ -1925,7 +1964,7 @@ Last attempt: ${compactionStatus}`, "info");
1925
1964
  const input = selectCoveredPrefix(capture.model, capture.prepared, prefix, fallback);
1926
1965
  if (!input)
1927
1966
  return;
1928
- const current = () => ticket === generation && !event.signal.aborted && supportedRouteOptions(capture.model, capture.options) && !generic(context) && context.modelRegistry.getProvider(capture.model.provider) === capture.provider && digest(context.sessionManager.getBranch()) === branchHash;
1967
+ const current = () => ticket === generation && !event.signal.aborted && supportedRouteOptions(capture.model, capture.options) && !generic(context) && context.modelRegistry.getProvider(capture.model.provider) === capture.provider && unchanged(context.sessionManager.getBranch(), branch.length, branchHash);
1929
1968
  const auth = await context.modelRegistry.getApiKeyAndHeaders(context.model);
1930
1969
  if (!current())
1931
1970
  return { cancel: true };
@@ -1938,7 +1977,10 @@ Last attempt: ${compactionStatus}`, "info");
1938
1977
  try {
1939
1978
  portable = await compact(event.preparation, capture.model, auth.apiKey, auth.headers, event.customInstructions, event.signal, context.thinkingLevel, capture.provider.streamSimple.bind(capture.provider), auth.env, undefined, undefined, capture.sessionId);
1940
1979
  } catch {
1941
- return !current() ? { cancel: true } : undefined;
1980
+ if (!current())
1981
+ return { cancel: true };
1982
+ fallback("portable summary generation failed");
1983
+ return;
1942
1984
  }
1943
1985
  if (!current() || !portable.summary.trim() || portable.summary === NATIVE_COMPACTION_SUMMARY)
1944
1986
  return { cancel: true };
@@ -1962,7 +2004,8 @@ Last attempt: ${compactionStatus}`, "info");
1962
2004
  headers: capture.options.headers,
1963
2005
  env: capture.options.env,
1964
2006
  signal: event.signal,
1965
- fetch: capture.options.fetch
2007
+ fetch: capture.options.fetch,
2008
+ ...capture.options.serviceTier === undefined ? {} : { serviceTier: capture.options.serviceTier }
1966
2009
  });
1967
2010
  if (!current())
1968
2011
  return { cancel: true };
@@ -1989,11 +2032,13 @@ Last attempt: ${compactionStatus}`, "info");
1989
2032
  };
1990
2033
  pending = { details, capture, summary: portable.summary };
1991
2034
  return { compaction: { ...portable, usage: combineUsage(native.usage, portable.usage), details } };
1992
- } catch {
2035
+ } catch (error) {
1993
2036
  if (!current())
1994
2037
  return { cancel: true };
1995
2038
  fallback("native acquisition or validation failed");
1996
- return { compaction: portable };
2039
+ return {
2040
+ compaction: error instanceof NativeCompactionValidationError && error.usage !== undefined ? { ...portable, usage: combineUsage(error.usage, portable.usage) } : portable
2041
+ };
1997
2042
  }
1998
2043
  });
1999
2044
  pi.on("session_compact", (event, context) => {
@@ -2011,9 +2056,18 @@ Last attempt: ${compactionStatus}`, "info");
2011
2056
  return;
2012
2057
  const branch = context.sessionManager.getBranch();
2013
2058
  const coverage = candidate.details.coverage;
2014
- if (!coverage || event.compactionEntry.firstKeptEntryId !== coverage.firstKeptEntryId || event.compactionEntry.parentId !== coverage.sourceLeafId || branch.at(-1)?.id !== event.compactionEntry.id || digest(branch.slice(0, -1)) !== coverage.sourceHash)
2059
+ const index = branch.findIndex((entry) => entry.id === event.compactionEntry.id);
2060
+ if (!coverage || index < 0 || event.compactionEntry.firstKeptEntryId !== coverage.firstKeptEntryId || event.compactionEntry.parentId !== coverage.sourceLeafId || digest(branch.slice(0, index)) !== coverage.sourceHash)
2015
2061
  return;
2016
- active = { entryId: event.compactionEntry.id, detailsHash: digest(candidate.details), summary: candidate.summary, branchLength: branch.length, branchHash: digest(branch), routeHash: candidate.capture.routeHash, policyHash: candidate.capture.policyHash };
2062
+ active = {
2063
+ entryId: event.compactionEntry.id,
2064
+ summary: candidate.summary,
2065
+ branchLength: index + 1,
2066
+ branchHash: digest(branch.slice(0, index + 1)),
2067
+ routeHash: candidate.capture.routeHash,
2068
+ policyHash: candidate.capture.policyHash,
2069
+ ...typeof candidate.capture.prepared.payload.model === "string" ? { payloadModel: candidate.capture.prepared.payload.model } : {}
2070
+ };
2017
2071
  });
2018
2072
  pi.on("session_compact_failed", () => {
2019
2073
  pending = undefined;
package/index.ts CHANGED
@@ -5,7 +5,7 @@ import { compact, convertToLlm, type ExtensionAPI, type ExtensionContext, type S
5
5
  import { resolveCompactionModelPolicy } from "./policy";
6
6
  import { installObserver, type InstalledObserver, type ObserveRequest } from "./provider";
7
7
  import { capturePreparedRequest, equal, requestPolicy, selectCoveredPrefix, substituteSummary, summarySlot, type PreparedRequest } from "./replay";
8
- import { isObject, requestProviderCompaction, resolveNativeProtocol, supportsResponsesApi, validateCompactedOutput, type NativeProtocol, type ResponseItem } from "./protocol";
8
+ import { isObject, NativeCompactionValidationError, requestProviderCompaction, resolveNativeProtocol, supportsResponsesApi, validateCompactedOutput, type NativeProtocol, type ResponseItem } from "./protocol";
9
9
 
10
10
  export const NATIVE_COMPACTION_TYPE = "pi-provider-compaction/openai-responses";
11
11
  export const NATIVE_COMPACTION_VERSION = 3;
@@ -124,7 +124,7 @@ interface Captured {
124
124
  prepared: PreparedRequest;
125
125
  model: Model<any>;
126
126
  provider: Provider;
127
- options: ProviderStreamOptions | SimpleStreamOptions;
127
+ options: (ProviderStreamOptions | SimpleStreamOptions) & { serviceTier?: string };
128
128
  branchLength: number;
129
129
  branchHash: string;
130
130
  routeHash: string;
@@ -134,12 +134,13 @@ interface Captured {
134
134
  }
135
135
  interface Active {
136
136
  entryId: string;
137
- detailsHash: string;
138
137
  summary: string;
139
138
  branchLength: number;
140
139
  branchHash: string;
141
140
  routeHash: string;
142
141
  policyHash: string;
142
+ /** Model field of the prepared payload; a request hook rewrite invalidates native state. */
143
+ payloadModel?: string;
143
144
  }
144
145
 
145
146
  export default function (pi: ExtensionAPI): void {
@@ -190,7 +191,12 @@ export default function (pi: ExtensionAPI): void {
190
191
  }
191
192
  if (options.sessionId !== manager.getSessionId()) return;
192
193
  if (!supportsNativeCompaction(model)) { captureRejected("unsupported API"); return; }
193
- if (!options.onPayload) { captureRejected("no payload callback"); return; }
194
+ if (!options.onPayload) {
195
+ // The portable-summary request re-enters this observer without a payload
196
+ // callback. Do not overwrite the main request's capture diagnostic.
197
+ if (captureStatus !== "eligible prepared request captured") captureRejected("no payload callback");
198
+ return;
199
+ }
194
200
  if (generic(owner)) { captureRejected("generic compaction model takes precedence"); return; }
195
201
  // The Azure adapter also accepts API-specific overrides and process-env
196
202
  // fallbacks. Those must not silently select a route absent from our evidence.
@@ -201,7 +207,7 @@ export default function (pi: ExtensionAPI): void {
201
207
  const canonical = transcript(manager.buildSessionProjection().messages);
202
208
  const current = () => {
203
209
  if (generation !== ticket || ctx !== owner || options.signal?.aborted || !supportedRouteOptions(model, options)) return false;
204
- try { return digest(owner.sessionManager.getBranch()) === branchHash; }
210
+ try { return unchanged(owner.sessionManager.getBranch(), branch.length, branchHash); }
205
211
  catch { retire(); return false; }
206
212
  };
207
213
  return async (payload) => {
@@ -225,8 +231,9 @@ export default function (pi: ExtensionAPI): void {
225
231
  const routeHash = route(model, options);
226
232
  const policyHash = digest(requestPolicy(model, prepared));
227
233
  const latest = latestCompaction(branch);
234
+ const payloadModel = isObject(payload) && typeof payload.model === "string" ? payload.model : undefined;
228
235
  let result = payload;
229
- if (active && latest?.id === active.entryId && latest.summary === active.summary && digest(latest.details) === active.detailsHash && unchanged(branch, active.branchLength, active.branchHash) && active.routeHash === routeHash && active.policyHash === policyHash) {
236
+ if (active && latest?.id === active.entryId && latest.summary === active.summary && active.payloadModel === payloadModel && unchanged(branch, active.branchLength, active.branchHash) && active.routeHash === routeHash && active.policyHash === policyHash) {
230
237
  const details = readNativeCompactionDetails(latest.details);
231
238
  const projection = owner.sessionManager.buildSessionProjection();
232
239
  const contribution = projection.entries.find((entry) => entry.sourceEntry.id === latest.id);
@@ -244,7 +251,7 @@ export default function (pi: ExtensionAPI): void {
244
251
  captureStatus = "eligible prepared request captured";
245
252
  snapshot = {
246
253
  prepared, model: structuredClone(model), provider,
247
- options: { apiKey: options.apiKey, headers: structuredClone(options.headers), env: structuredClone(options.env), fetch: options.fetch },
254
+ options: { apiKey: options.apiKey, headers: structuredClone(options.headers), env: structuredClone(options.env), fetch: options.fetch, ...(typeof (options as { serviceTier?: unknown }).serviceTier === "string" ? { serviceTier: (options as { serviceTier: string }).serviceTier } : {}) },
248
255
  branchLength: branch.length, branchHash, routeHash, authHash: routeDigest(auth), policyHash,
249
256
  sessionId: owner.sessionManager.getSessionId(),
250
257
  };
@@ -281,6 +288,7 @@ export default function (pi: ExtensionAPI): void {
281
288
  pi.on("session_shutdown", () => { if (typeof removeInvalidation === "function") removeInvalidation(); });
282
289
 
283
290
  pi.on("session_before_compact", async (event, context) => {
291
+ compactionStatus = "preparing compaction";
284
292
  const capture = snapshot;
285
293
  snapshot = undefined; pending = undefined;
286
294
  if (!capture) { fallback(`no eligible snapshot (${captureStatus})`); return; }
@@ -297,7 +305,7 @@ export default function (pi: ExtensionAPI): void {
297
305
  const prefix = transcript(projection.entries.slice(0, boundary).flatMap((entry) => entry.messages));
298
306
  const input = selectCoveredPrefix(capture.model, capture.prepared, prefix, fallback);
299
307
  if (!input) return;
300
- const current = () => ticket === generation && !event.signal.aborted && supportedRouteOptions(capture.model, capture.options) && !generic(context) && context.modelRegistry.getProvider(capture.model.provider) === capture.provider && digest(context.sessionManager.getBranch()) === branchHash;
308
+ const current = () => ticket === generation && !event.signal.aborted && supportedRouteOptions(capture.model, capture.options) && !generic(context) && context.modelRegistry.getProvider(capture.model.provider) === capture.provider && unchanged(context.sessionManager.getBranch(), branch.length, branchHash);
301
309
  const auth = await context.modelRegistry.getApiKeyAndHeaders(context.model);
302
310
  if (!current()) return { cancel: true };
303
311
  if (!auth.ok || routeDigest(auth) !== capture.authHash) { fallback("auth changed since capture"); return; }
@@ -307,7 +315,9 @@ export default function (pi: ExtensionAPI): void {
307
315
  portable = await compact(event.preparation, capture.model, auth.apiKey, auth.headers as Record<string, string> | undefined, event.customInstructions, event.signal, context.thinkingLevel, capture.provider.streamSimple.bind(capture.provider), auth.env, undefined, undefined, capture.sessionId);
308
316
  } catch {
309
317
  // No result exists to return; let Pi own its ordinary failure/fallback behavior.
310
- return !current() ? { cancel: true } : undefined;
318
+ if (!current()) return { cancel: true };
319
+ fallback("portable summary generation failed");
320
+ return undefined;
311
321
  }
312
322
  if (!current() || !portable.summary.trim() || portable.summary === NATIVE_COMPACTION_SUMMARY) return { cancel: true };
313
323
  try {
@@ -320,6 +330,7 @@ export default function (pi: ExtensionAPI): void {
320
330
  input, preparedPayload: capture.prepared.payload, protocol,
321
331
  apiKey: capture.options.apiKey, headers: capture.options.headers, env: capture.options.env,
322
332
  signal: event.signal, fetch: capture.options.fetch,
333
+ ...(capture.options.serviceTier === undefined ? {} : { serviceTier: capture.options.serviceTier }),
323
334
  });
324
335
  if (!current()) return { cancel: true };
325
336
  const finalAuth = await context.modelRegistry.getApiKeyAndHeaders(context.model);
@@ -334,12 +345,18 @@ export default function (pi: ExtensionAPI): void {
334
345
  };
335
346
  pending = { details, capture, summary: portable.summary };
336
347
  return { compaction: { ...portable, usage: combineUsage(native.usage, portable.usage), details } };
337
- } catch {
348
+ } catch (error) {
338
349
  if (!current()) return { cancel: true };
339
350
  // Do not expose exception text: provider errors may contain request data.
340
351
  fallback("native acquisition or validation failed");
341
- // The portable request already succeeded. Never pay for a duplicate summary.
342
- return { compaction: portable };
352
+ // The portable request already succeeded. Never pay for a duplicate summary,
353
+ // and keep the native call's usage when the endpoint answered but produced
354
+ // an unusable window.
355
+ return {
356
+ compaction: error instanceof NativeCompactionValidationError && error.usage !== undefined
357
+ ? { ...portable, usage: combineUsage(error.usage, portable.usage) }
358
+ : portable,
359
+ };
343
360
  }
344
361
  });
345
362
  pi.on("session_compact", (event, context) => {
@@ -352,8 +369,17 @@ export default function (pi: ExtensionAPI): void {
352
369
  if (!candidate || !equal(event.compactionEntry.details, candidate.details) || event.compactionEntry.summary !== candidate.summary) return;
353
370
  const branch = context.sessionManager.getBranch();
354
371
  const coverage = candidate.details.coverage;
355
- if (!coverage || event.compactionEntry.firstKeptEntryId !== coverage.firstKeptEntryId || event.compactionEntry.parentId !== coverage.sourceLeafId || branch.at(-1)?.id !== event.compactionEntry.id || digest(branch.slice(0, -1)) !== coverage.sourceHash) return;
356
- active = { entryId: event.compactionEntry.id, detailsHash: digest(candidate.details), summary: candidate.summary, branchLength: branch.length, branchHash: digest(branch), routeHash: candidate.capture.routeHash, policyHash: candidate.capture.policyHash };
372
+ const index = branch.findIndex((entry) => entry.id === event.compactionEntry.id);
373
+ if (!coverage || index < 0 || event.compactionEntry.firstKeptEntryId !== coverage.firstKeptEntryId || event.compactionEntry.parentId !== coverage.sourceLeafId || digest(branch.slice(0, index)) !== coverage.sourceHash) return;
374
+ active = {
375
+ entryId: event.compactionEntry.id,
376
+ summary: candidate.summary,
377
+ branchLength: index + 1,
378
+ branchHash: digest(branch.slice(0, index + 1)),
379
+ routeHash: candidate.capture.routeHash,
380
+ policyHash: candidate.capture.policyHash,
381
+ ...(typeof candidate.capture.prepared.payload.model === "string" ? { payloadModel: candidate.capture.prepared.payload.model } : {}),
382
+ };
357
383
  });
358
384
  pi.on("session_compact_failed", () => { pending = undefined; snapshot = undefined; compactionStatus = "compaction failed or cancelled"; });
359
385
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nijaru/pi-provider-compaction",
3
- "version": "0.0.6",
3
+ "version": "0.0.8",
4
4
  "type": "module",
5
5
  "description": "Provider-native context compaction adapters for Pi",
6
6
  "keywords": [
@@ -44,9 +44,9 @@
44
44
  "LICENSE"
45
45
  ],
46
46
  "devDependencies": {
47
- "@earendil-works/pi-ai": "1.2.0",
48
- "@earendil-works/pi-agent-core": "1.2.0",
49
- "@earendil-works/pi-coding-agent": "1.2.0",
47
+ "@earendil-works/pi-ai": "1.3.0",
48
+ "@earendil-works/pi-agent-core": "1.3.0",
49
+ "@earendil-works/pi-coding-agent": "1.3.0",
50
50
  "@types/bun": "latest",
51
51
  "oxlint": "^1.81.0",
52
52
  "typescript": "^6.0.3"
package/protocol.ts CHANGED
@@ -225,10 +225,20 @@ interface TransportRequest {
225
225
  apiKey?: string;
226
226
  headers?: ProviderHeaders;
227
227
  env?: Record<string, string>;
228
+ /** Cost-relevant request option captured with the original request. */
229
+ serviceTier?: string;
228
230
  signal: AbortSignal;
229
231
  fetch?: typeof globalThis.fetch;
230
232
  }
231
233
 
234
+ /** The provider answered and was billed, but its window cannot be committed. */
235
+ export class NativeCompactionValidationError extends Error {
236
+ constructor(message: string, readonly usage?: Usage) {
237
+ super(message);
238
+ this.name = "NativeCompactionValidationError";
239
+ }
240
+ }
241
+
232
242
  export interface TransportResponse {
233
243
  output: ResponseItem[];
234
244
  usage: Usage;
@@ -258,8 +268,12 @@ async function requestResponsesCompact(request: TransportRequest): Promise<Trans
258
268
  });
259
269
  if (!response.ok) return response;
260
270
  const parsed = await readJsonBounded(response, request.signal);
261
- if (!isObject(parsed)) throw new Error("Responses Compact returned a non-object response");
262
- compacted = { response: parsed, output: validateCompactedOutput(parsed) };
271
+ try {
272
+ if (!isObject(parsed)) throw new Error("Responses Compact returned a non-object response");
273
+ compacted = { response: parsed, output: validateCompactedOutput(parsed) };
274
+ } catch (error) {
275
+ throw new NativeCompactionValidationError(error instanceof Error ? error.message : String(error));
276
+ }
263
277
  return syntheticCompletion(parsed, preparedPayload);
264
278
  } catch (error) {
265
279
  bridgeError = error;
@@ -272,6 +286,7 @@ async function requestResponsesCompact(request: TransportRequest): Promise<Trans
272
286
  headers: request.headers,
273
287
  env: request.env,
274
288
  signal: request.signal,
289
+ ...(request.serviceTier === undefined ? {} : { serviceTier: request.serviceTier }),
275
290
  transport: "sse",
276
291
  cacheRetention: "none",
277
292
  maxRetries: 0,
@@ -288,6 +303,7 @@ async function requestResponsesCompact(request: TransportRequest): Promise<Trans
288
303
  if (bridgeError) throw bridgeError;
289
304
  throw error;
290
305
  });
306
+ if (bridgeError instanceof NativeCompactionValidationError) throw new NativeCompactionValidationError(bridgeError.message, usage);
291
307
  if (bridgeError) throw bridgeError;
292
308
  if (!compacted || dispatches !== 1) throw new Error("Responses Compact did not complete exactly one request");
293
309
  return { output: compacted.output, usage };
@@ -410,6 +426,7 @@ async function requestRemoteV2(request: TransportRequest): Promise<TransportResp
410
426
  headers: request.headers,
411
427
  env: request.env,
412
428
  signal: request.signal,
429
+ ...(request.serviceTier === undefined ? {} : { serviceTier: request.serviceTier }),
413
430
  transport: "sse",
414
431
  cacheRetention: "none",
415
432
  maxRetries: 0,
@@ -436,5 +453,9 @@ export async function requestProviderCompaction(
436
453
  request: TransportRequest & { protocol: NativeProtocol },
437
454
  ): Promise<TransportResponse> {
438
455
  const result = await (request.protocol === "remote-v2" ? requestRemoteV2(request) : requestResponsesCompact(request));
439
- return { ...result, output: validateCompactedOutput(result) };
456
+ try {
457
+ return { ...result, output: validateCompactedOutput(result) };
458
+ } catch (error) {
459
+ throw new NativeCompactionValidationError(error instanceof Error ? error.message : String(error), result.usage);
460
+ }
440
461
  }
package/provider.ts CHANGED
@@ -59,6 +59,11 @@ export function installObserver(pi: ExtensionAPI, ctx: ExtensionContext, observe
59
59
  const id = model.provider;
60
60
  const native = registry.getRegisteredNativeProvider(id);
61
61
  if (native) {
62
+ // Pi composes a native base only when its catalog serves the active API.
63
+ // Otherwise the request goes to the global API provider and bypasses this
64
+ // wrapper; claiming an observer there would report native compaction as
65
+ // installed while never capturing anything.
66
+ if (typeof native.getModels !== "function" || !native.getModels().some((entry) => entry.api === model.api)) return;
62
67
  const isInstalled = () => registry.getRegisteredNativeProvider(id) === wrapper;
63
68
  const guarded: ObserveRequest = (...args) => {
64
69
  if (!isInstalled()) return;
package/replay.ts CHANGED
@@ -14,7 +14,7 @@ export function equal(left: unknown, right: unknown): boolean {
14
14
  }
15
15
 
16
16
  /** Validator only. No serialized item from this function is transmitted. */
17
- export function serializeInput(model: Model<any>, context: TranscriptContext, declarations = context): ResponseItem[] {
17
+ export function serializeInput(model: Model<any>, context: TranscriptContext, declarations = context, configurationUpdateEffort?: string): ResponseItem[] {
18
18
  const compat = model.compat as Record<string, unknown> | undefined;
19
19
  const flag = (name: string) => compat?.[name] === true;
20
20
  const codex = model.api === "openai-codex-responses";
@@ -22,6 +22,7 @@ export function serializeInput(model: Model<any>, context: TranscriptContext, de
22
22
  const tools = resolveTranscript(declarations, flag("supportsMidConvoSystemMessages"));
23
23
  return convertResponsesMessages(model, normalized, model.api === "azure-openai-responses" ? AZURE_TOOL_CALL_PROVIDERS : TOOL_CALL_PROVIDERS, {
24
24
  includeSystemPrompt: !codex,
25
+ configurationUpdateEffort,
25
26
  grammarToolInputProperties: createGrammarToolInputProperties(getDeclaredTools(tools.messages), flag("supportsOpenAIGrammarTools")),
26
27
  supportsMidConvoSystemMessages: flag("supportsMidConvoSystemMessages"),
27
28
  supportsAdditionalTools: flag("supportsAdditionalTools"),
@@ -40,6 +41,8 @@ export interface PreparedRequest {
40
41
  payload: JsonObject & { input: ResponseItem[] };
41
42
  /** The portable wire input before our sole summary substitution. */
42
43
  portableInput: ResponseItem[];
44
+ /** The final request-only effort update; historical efforts come from the transcript. */
45
+ configurationUpdateEffort?: string;
43
46
  replacement?: { index: number; count: number };
44
47
  createdAt: number;
45
48
  }
@@ -52,9 +55,16 @@ export function capturePreparedRequest(
52
55
  // Reject stateful provider requests: coverage must be the complete visible input.
53
56
  if (payload.previous_response_id || payload.conversation) { reject("stateful request"); return; }
54
57
  if (Buffer.byteLength(JSON.stringify({ context, payload })) > MAX_SNAPSHOT_BYTES) { reject("snapshot exceeds size limit"); return; }
55
- const expected = serializeInput(model, context);
58
+ // Read the actual prepared request's mode, not provider/auth guesses. Exact conversion
59
+ // below still validates every historical update, its position and its complete shape.
60
+ const last = payload.input.at(-1);
61
+ const compat = model.compat as Record<string, unknown> | undefined;
62
+ const configurationUpdateEffort = model.api === "openai-responses" && compat?.supportsMidConvoEffort === true &&
63
+ last?.type === "configuration_update" && isObject(last.reasoning) && typeof last.reasoning.effort === "string"
64
+ ? last.reasoning.effort : undefined;
65
+ const expected = serializeInput(model, context, context, configurationUpdateEffort);
56
66
  if (!equal(expected, payload.input)) { reject("prepared input differs from serializer"); return; }
57
- return { context: structuredClone(context), canonical: structuredClone(canonical), payload: structuredClone(payload) as PreparedRequest["payload"], portableInput: expected, createdAt: Date.now() };
67
+ return { context: structuredClone(context), canonical: structuredClone(canonical), payload: structuredClone(payload) as PreparedRequest["payload"], portableInput: expected, configurationUpdateEffort, createdAt: Date.now() };
58
68
  }
59
69
 
60
70
  function promptCount(input: readonly ResponseItem[]): number {
@@ -63,6 +73,13 @@ function promptCount(input: readonly ResponseItem[]): number {
63
73
  return count;
64
74
  }
65
75
 
76
+ function serializePrefix(model: Model<any>, snapshot: PreparedRequest, prefix: TranscriptContext): ResponseItem[] {
77
+ const input = serializeInput(model, prefix, snapshot.context, snapshot.configurationUpdateEffort);
78
+ // The converter appends the next reply's effort even to a partial transcript.
79
+ // It is not historical coverage and must remain in the live suffix on replay.
80
+ return snapshot.configurationUpdateEffort === undefined ? input : input.slice(0, -1);
81
+ }
82
+
66
83
  /** No open/synthetic tool exchange may cross the coverage boundary. */
67
84
  function closedTools(context: TranscriptContext): boolean {
68
85
  const pending = new Set<string>();
@@ -94,7 +111,7 @@ export function selectCoveredPrefix(model: Model<any>, snapshot: PreparedRequest
94
111
  if (!closedTools(prefix)) { reject("prefix has an incomplete tool exchange or failed assistant"); return; }
95
112
  const mapped = mappedPrefix(snapshot, prefix);
96
113
  if (!mapped) { reject("projected prefix differs from request context"); return; }
97
- const expected = serializeInput(model, mapped, snapshot.context);
114
+ const expected = serializePrefix(model, snapshot, mapped);
98
115
  if (!equal(snapshot.portableInput.slice(0, expected.length), expected)) { reject("serialized prefix does not match prepared input"); return; }
99
116
  const start = promptCount(expected);
100
117
  let end = expected.length;
@@ -121,7 +138,7 @@ export function substituteSummary(
121
138
  export function summarySlot(model: Model<any>, snapshot: PreparedRequest, summaryPrefix: TranscriptContext): number | undefined {
122
139
  const mapped = mappedPrefix(snapshot, summaryPrefix);
123
140
  if (!mapped) return;
124
- const prefix = serializeInput(model, mapped, snapshot.context);
141
+ const prefix = serializePrefix(model, snapshot, mapped);
125
142
  const index = promptCount(prefix);
126
143
  if (prefix.length !== index + 1 || !equal(snapshot.portableInput.slice(0, prefix.length), prefix)) return;
127
144
  return index;