pi-cache-optimizer 2.8.9 → 2.8.10

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
@@ -136,13 +136,13 @@ The explicit setting is stored in `pi-cache-optimizer-config.json` under Pi's ag
136
136
 
137
137
  Some OpenAI-compatible endpoints reject `prompt_cache_key` with HTTP 400 even though the same field is valid for other providers. Pi 0.85.1 has no native `supportsPromptCacheKey` compat field; do **not** add that unknown field to `models.json`. `supportsLongCacheRetention` is not an equivalent switch and should not be used for this purpose.
138
138
 
139
- When the extension observes an explicit `prompt_cache_key` unsupported error for the exact provider/model, ordinary `/cache-optimizer fix` offers a confirmed model-scoped repair. If you already know that the endpoint rejects the field, use the explicit command:
139
+ When the extension observes an explicit field-level `prompt_cache_key` unsupported error for the exact provider/model, ordinary `/cache-optimizer fix` offers a confirmed model-scoped repair. Value-validation failures and conditional restrictions such as “not allowed when temperature is set” do not qualify. If concurrent responses from different models cannot be correlated because Pi provides no request ID, header-only evidence is ignored unless the finalized assistant message supplies exact provider/model identity. If you already know that the endpoint rejects the field, use the explicit command:
140
140
 
141
141
  ```text
142
142
  /cache-optimizer fix prompt-cache-key
143
143
  ```
144
144
 
145
- The preview explains that the setting is stored in the extension-owned `pi-cache-optimizer-config.json`. After confirmation, the final `before_provider_request` stage removes both `prompt_cache_key` and `promptCacheKey`, including a key that Pi core supplied earlier. This can reduce provider prompt-cache hits for that exact model, while other models retain the existing fallback behavior. The file is updated atomically, a privacy-safe backup/receipt is created, and `/reload` or a Pi restart is required. `/cache-optimizer rollback` restores the previous extension configuration without resetting `footerMode`.
145
+ The preview explains that the setting is stored in the extension-owned `pi-cache-optimizer-config.json`. After confirmation, the final `before_provider_request` stage removes both `prompt_cache_key` and `promptCacheKey`, including a key that Pi core supplied earlier. This can reduce provider prompt-cache hits for that exact model, while other models retain the existing fallback behavior. The file is updated atomically without overwriting a concurrently created config, a privacy-safe backup/receipt is created, and `/reload` or a Pi restart is required. `/cache-optimizer rollback` restores the previous extension configuration without resetting `footerMode`; it binds the previewed receipt by file identity and hash, refuses a replaced receipt, and compensates the config if receipt marking fails.
146
146
 
147
147
  ## OpenAI-compatible proxy setup
148
148
 
package/README.zh-CN.md CHANGED
@@ -136,13 +136,13 @@ Footer 默认使用 `session`,避免另一个并行 Pi 终端使用相同 prov
136
136
 
137
137
  某些 OpenAI-compatible endpoint 会因 `prompt_cache_key` 返回 HTTP 400,但同一个字段对其他 provider 可能有效。Pi 0.85.1 没有原生的 `supportsPromptCacheKey` compat 字段,**不要**把这个未知字段加入 `models.json`。`supportsLongCacheRetention` 也不是等价开关,不应借用来实现此目的。
138
138
 
139
- 当扩展观察到精确 provider/model 明确拒绝 `prompt_cache_key` 后,普通 `/cache-optimizer fix` 会提供经过确认的模型级修复。如果你已经确定 endpoint 不支持该字段,可以主动执行:
139
+ 当扩展观察到精确 provider/model 对 `prompt_cache_key` 的明确字段级拒绝后,普通 `/cache-optimizer fix` 会提供经过确认的模型级修复。参数值校验失败,以及“设置 temperature 时不允许”这类条件限制都不构成证据。若不同模型的响应并发交错,而 Pi 又没有提供 request ID,扩展会忽略无法安全关联的 response-header 证据;只有最终 assistant message 提供精确 provider/model 身份时才恢复归因。如果你已经确定 endpoint 不支持该字段,可以主动执行:
140
140
 
141
141
  ```text
142
142
  /cache-optimizer fix prompt-cache-key
143
143
  ```
144
144
 
145
- 预览会说明设置保存在扩展自有的 `pi-cache-optimizer-config.json` 中。确认后,`before_provider_request` 的最终阶段会移除 `prompt_cache_key` 和 `promptCacheKey`,包括 Pi core 之前已经放入的 key。这个精确模型可能因此失去 provider prompt-cache 命中,其他模型仍保持原有 fallback 行为。配置会使用原子替换写入,并生成不含敏感内容的备份/receipt;需要 `/reload` 或重启 Pi。`/cache-optimizer rollback` 可恢复之前的扩展配置,且不会重置 `footerMode`。
145
+ 预览会说明设置保存在扩展自有的 `pi-cache-optimizer-config.json` 中。确认后,`before_provider_request` 的最终阶段会移除 `prompt_cache_key` 和 `promptCacheKey`,包括 Pi core 之前已经放入的 key。这个精确模型可能因此失去 provider prompt-cache 命中,其他模型仍保持原有 fallback 行为。配置通过原子、不可覆盖并发新建文件的方式写入,并生成不含敏感内容的备份/receipt;需要 `/reload` 或重启 Pi。`/cache-optimizer rollback` 可恢复之前的扩展配置且不会重置 `footerMode`;它会绑定预览时 receipt 的文件身份和 hash,在 receipt 被替换时拒绝操作,并在 receipt 状态写入失败时补偿恢复配置。
146
146
 
147
147
  ## OpenAI-compatible 代理配置
148
148
 
package/index.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
- import { chmod, copyFile, lstat, mkdir, readFile, readdir, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
2
+ import { chmod, copyFile, link, lstat, mkdir, readFile, readdir, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
3
3
  import { constants as fsConstants, readFileSync, statSync, watch } from "node:fs";
4
4
  import { homedir } from "node:os";
5
5
  import { basename, dirname, join } from "node:path";
@@ -204,6 +204,12 @@ type PromptCacheKeyConfigReceipt = {
204
204
  status?: "rolled_back";
205
205
  rolledBackAt?: number;
206
206
  };
207
+ type PromptCacheKeyConfigReceiptSnapshot = {
208
+ receipt: PromptCacheKeyConfigReceipt;
209
+ receiptPath: string;
210
+ hash: string;
211
+ identity: FileIdentity;
212
+ };
207
213
  const PI_ROUTING_REGISTRY_SYMBOL = Symbol.for("pi.routing.registry.v1");
208
214
  const PI_CACHE_HINTS_SYMBOL = Symbol.for("pi.cache.hints.v1");
209
215
  const PI_CACHE_HINTS_OWNER_SYMBOL = Symbol.for("pi.cache.optimizer.hints-owner.v1");
@@ -2041,21 +2047,7 @@ async function writePersistedCacheOptimizerConfigUnlocked(
2041
2047
  return;
2042
2048
  }
2043
2049
 
2044
- const tempPath = uniqueTempPath(configPath, "config");
2045
- try {
2046
- await writeFile(tempPath, payloadText, { encoding: "utf8", mode: targetMode, flag: "wx" });
2047
- await chmod(tempPath, targetMode);
2048
- try {
2049
- await lstat(configPath);
2050
- throw new Error("optimizer config appeared during write; no changes were made");
2051
- } catch (error) {
2052
- if (getErrorCode(error) !== "ENOENT") throw error;
2053
- }
2054
- await rename(tempPath, configPath);
2055
- } catch (error) {
2056
- await unlink(tempPath).catch((cleanupError) => { if (getErrorCode(cleanupError) !== "ENOENT") console.warn(`${LOG_PREFIX}: failed to remove temporary config`, cleanupError); });
2057
- throw error;
2058
- }
2050
+ await atomicCreateTextFileNoReplace(configPath, payloadText, targetMode, "config");
2059
2051
  }
2060
2052
 
2061
2053
  async function writePersistedCacheOptimizerConfig(
@@ -2087,24 +2079,15 @@ async function writePersistedFooterModeUnlocked(
2087
2079
  if (targetInfo && (targetInfo.isSymbolicLink() || !targetInfo.isFile())) throw new Error("optimizer config is not a regular file; no changes were made");
2088
2080
  const targetText = targetInfo ? await readFile(configPath, "utf8") : undefined;
2089
2081
  const targetMode = targetInfo ? targetInfo.mode & 0o7777 : 0o600;
2090
- const tempPath = uniqueTempPath(configPath, "config-footer");
2091
- try {
2092
- await writeFile(tempPath, JSON.stringify({ version: 1, footerMode: mode }, null, 2) + "\n", { encoding: "utf8", mode: targetMode, flag: "wx" });
2093
- await chmod(tempPath, targetMode);
2094
- if (targetInfo && targetText !== undefined) {
2095
- await validateAtomicTarget(configPath, { identity: targetInfo, hash: hashText(targetText), mode: targetMode });
2096
- } else {
2097
- try {
2098
- await lstat(configPath);
2099
- throw new Error("optimizer config appeared during footer write; no changes were made");
2100
- } catch (error) {
2101
- if (getErrorCode(error) !== "ENOENT") throw error;
2102
- }
2103
- }
2104
- await rename(tempPath, configPath);
2105
- } catch (error) {
2106
- await unlink(tempPath).catch(() => {});
2107
- throw error;
2082
+ const footerText = JSON.stringify({ version: 1, footerMode: mode }, null, 2) + "\n";
2083
+ if (targetInfo && targetText !== undefined) {
2084
+ await atomicReplaceTextFilePreservingMode(configPath, footerText, targetMode, "config-footer", {
2085
+ identity: targetInfo,
2086
+ hash: hashText(targetText),
2087
+ mode: targetMode,
2088
+ });
2089
+ } else {
2090
+ await atomicCreateTextFileNoReplace(configPath, footerText, targetMode, "config-footer");
2108
2091
  }
2109
2092
  return;
2110
2093
  }
@@ -2132,7 +2115,12 @@ function parsePromptCacheKeyConfigReceipt(value: unknown): PromptCacheKeyConfigR
2132
2115
  if (![record.transactionId, record.provider, record.modelId].every(isSafeReceiptText)) return undefined;
2133
2116
  if (!isSha256(record.beforeHash) || !isSha256(record.afterHash) || record.beforeHash === record.afterHash) return undefined;
2134
2117
  if (!isSafeReceiptText(record.backupFile) || basename(record.backupFile) !== record.backupFile || !record.backupFile.startsWith("pi-cache-optimizer-config.backup-")) return undefined;
2135
- if (typeof record.targetExistedBefore !== "boolean" || !isReceiptTimestamp(record.createdAt) || !isReceiptTimestamp(record.appliedAt)) return undefined;
2118
+ if (
2119
+ typeof record.targetExistedBefore !== "boolean" ||
2120
+ !isReceiptTimestamp(record.createdAt) ||
2121
+ !isReceiptTimestamp(record.appliedAt) ||
2122
+ record.appliedAt < record.createdAt
2123
+ ) return undefined;
2136
2124
  const addedModelKey = `${record.provider}/${record.modelId}`;
2137
2125
  const targetHadModelKey = record.version === 1 ? false : record.targetHadModelKey;
2138
2126
  if (typeof targetHadModelKey !== "boolean") return undefined;
@@ -2162,25 +2150,95 @@ function isActionablePromptCacheKeyConfigReceipt(receipt: PromptCacheKeyConfigRe
2162
2150
  return receipt !== undefined && receipt.status === undefined;
2163
2151
  }
2164
2152
 
2165
- async function readPromptCacheKeyConfigReceipt(receiptPath: string = CONFIG_RECEIPT_PATH): Promise<PromptCacheKeyConfigReceipt | undefined> {
2153
+ async function assertPromptCacheKeyConfigReceiptSnapshotUnchanged(
2154
+ snapshot: PromptCacheKeyConfigReceiptSnapshot,
2155
+ ): Promise<void> {
2156
+ const info = await lstat(snapshot.receiptPath);
2157
+ if (info.isSymbolicLink() || !info.isFile() || !sameFileIdentity(snapshot.identity, info)) {
2158
+ throw new Error("prompt-cache-key receipt changed since the rollback preview");
2159
+ }
2160
+ const text = await readFile(snapshot.receiptPath, "utf8");
2161
+ const afterRead = await lstat(snapshot.receiptPath);
2162
+ if (
2163
+ afterRead.isSymbolicLink() ||
2164
+ !afterRead.isFile() ||
2165
+ !sameFileIdentity(info, afterRead) ||
2166
+ hashText(text) !== snapshot.hash
2167
+ ) {
2168
+ throw new Error("prompt-cache-key receipt changed since the rollback preview");
2169
+ }
2170
+ }
2171
+
2172
+ async function readPromptCacheKeyConfigReceiptSnapshot(
2173
+ receiptPath: string = CONFIG_RECEIPT_PATH,
2174
+ ): Promise<PromptCacheKeyConfigReceiptSnapshot | undefined> {
2166
2175
  try {
2167
2176
  const info = await lstat(receiptPath);
2168
2177
  if (info.isSymbolicLink() || !info.isFile()) return undefined;
2169
- const receipt = parsePromptCacheKeyConfigReceipt(JSON.parse(await readFile(receiptPath, "utf8")));
2170
- return receipt;
2178
+ const text = await readFile(receiptPath, "utf8");
2179
+ const afterRead = await lstat(receiptPath);
2180
+ if (afterRead.isSymbolicLink() || !afterRead.isFile() || !sameFileIdentity(info, afterRead)) return undefined;
2181
+ const receipt = parsePromptCacheKeyConfigReceipt(JSON.parse(text));
2182
+ if (!receipt) return undefined;
2183
+ return { receipt, receiptPath, hash: hashText(text), identity: afterRead };
2171
2184
  } catch {
2172
2185
  return undefined;
2173
2186
  }
2174
2187
  }
2175
2188
 
2176
- async function writePromptCacheKeyConfigReceipt(receipt: PromptCacheKeyConfigReceipt, receiptPath: string = CONFIG_RECEIPT_PATH): Promise<void> {
2189
+ async function readPromptCacheKeyConfigReceipt(receiptPath: string = CONFIG_RECEIPT_PATH): Promise<PromptCacheKeyConfigReceipt | undefined> {
2190
+ return (await readPromptCacheKeyConfigReceiptSnapshot(receiptPath))?.receipt;
2191
+ }
2192
+
2193
+ async function writePromptCacheKeyConfigReceipt(
2194
+ receipt: PromptCacheKeyConfigReceipt,
2195
+ receiptPath: string = CONFIG_RECEIPT_PATH,
2196
+ expectedSnapshot?: PromptCacheKeyConfigReceiptSnapshot,
2197
+ /** Test-only race injector; production callers leave this undefined. */
2198
+ beforeRename?: () => Promise<void>,
2199
+ ): Promise<void> {
2177
2200
  if (!parsePromptCacheKeyConfigReceipt(receipt)) throw new Error("invalid prompt cache key config receipt");
2201
+ if (expectedSnapshot?.receiptPath !== undefined && expectedSnapshot.receiptPath !== receiptPath) {
2202
+ throw new Error("prompt-cache-key receipt path changed since the rollback preview");
2203
+ }
2204
+ if (expectedSnapshot) await assertPromptCacheKeyConfigReceiptSnapshotUnchanged(expectedSnapshot);
2178
2205
  await mkdir(dirname(receiptPath), { recursive: true });
2206
+ let existingReceiptInfo: Awaited<ReturnType<typeof lstat>> | undefined;
2207
+ try {
2208
+ const info = await lstat(receiptPath);
2209
+ if (info.isSymbolicLink() || !info.isFile()) throw new Error("invalid prompt-cache-key receipt path");
2210
+ existingReceiptInfo = info;
2211
+ } catch (error) {
2212
+ if (getErrorCode(error) !== "ENOENT") throw error;
2213
+ }
2179
2214
  const tempPath = uniqueTempPath(receiptPath, "config-receipt");
2180
2215
  try {
2181
2216
  await writeFile(tempPath, JSON.stringify(receipt, null, 2) + "\n", { encoding: "utf8", mode: 0o600, flag: "wx" });
2217
+ const tempInfo = await lstat(tempPath);
2218
+ if (tempInfo.isSymbolicLink() || !tempInfo.isFile()) throw new Error("invalid temporary prompt-cache-key receipt");
2182
2219
  await chmod(tempPath, 0o600);
2183
- await rename(tempPath, receiptPath);
2220
+ const assertDestinationUnchanged = async (): Promise<void> => {
2221
+ try {
2222
+ const currentInfo = await lstat(receiptPath);
2223
+ if (!existingReceiptInfo || !sameFileIdentity(existingReceiptInfo, currentInfo)) {
2224
+ throw new Error("prompt-cache-key receipt changed during atomic write");
2225
+ }
2226
+ } catch (error) {
2227
+ if (getErrorCode(error) !== "ENOENT" || existingReceiptInfo) throw error;
2228
+ }
2229
+ if (expectedSnapshot) await assertPromptCacheKeyConfigReceiptSnapshotUnchanged(expectedSnapshot);
2230
+ };
2231
+ await assertDestinationUnchanged();
2232
+ if (beforeRename) await beforeRename();
2233
+ await assertDestinationUnchanged();
2234
+ if (existingReceiptInfo) {
2235
+ await rename(tempPath, receiptPath);
2236
+ } else {
2237
+ await link(tempPath, receiptPath);
2238
+ await unlink(tempPath).catch((cleanupError) => {
2239
+ console.warn(`${LOG_PREFIX}: committed prompt-cache-key receipt but failed to remove its temporary hard link`, cleanupError);
2240
+ });
2241
+ }
2184
2242
  } catch (error) {
2185
2243
  await unlink(tempPath).catch((cleanupError) => { if (getErrorCode(cleanupError) !== "ENOENT") console.warn(`${LOG_PREFIX}: failed to remove temporary config receipt`, cleanupError); });
2186
2244
  throw error;
@@ -2211,6 +2269,7 @@ async function applyPromptCacheKeyConfigFixUnderLock(
2211
2269
  const key = modelKey(model);
2212
2270
  const existingOmit = current.promptCacheKey?.omit ?? [];
2213
2271
  const targetHadModelKey = existingOmit.includes(key);
2272
+ if (targetHadModelKey) throw new Error("prompt-cache-key is already configured; no changes were made");
2214
2273
  const omit = [...new Set([...existingOmit, key])].sort();
2215
2274
  const next: PersistedCacheOptimizerConfigV2 = { ...current, version: 2, promptCacheKey: { omit } };
2216
2275
  const modifiedText = JSON.stringify(next, null, 2) + "\n";
@@ -2238,8 +2297,19 @@ async function applyPromptCacheKeyConfigFixUnderLock(
2238
2297
  if (getErrorCode(error) !== "ENOENT") throw error;
2239
2298
  }
2240
2299
  }
2241
- await rename(tempPath, configPath);
2242
- committedInfo = await lstat(configPath);
2300
+ if (originalInfo) {
2301
+ await rename(tempPath, configPath);
2302
+ committedInfo = await lstat(configPath);
2303
+ } else {
2304
+ // Do not overwrite a config created after the absence check. Record the
2305
+ // committed inode before cleaning up its temporary hard-link name so a
2306
+ // cleanup failure can still compensate the config transaction.
2307
+ await link(tempPath, configPath);
2308
+ committedInfo = await lstat(configPath);
2309
+ await unlink(tempPath).catch((cleanupError) => {
2310
+ console.warn(`${LOG_PREFIX}: committed optimizer config but failed to remove its temporary hard link`, cleanupError);
2311
+ });
2312
+ }
2243
2313
  const receipt: PromptCacheKeyConfigReceipt = {
2244
2314
  version: 2,
2245
2315
  kind: "pi-cache-optimizer-config-receipt",
@@ -2259,10 +2329,17 @@ async function applyPromptCacheKeyConfigFixUnderLock(
2259
2329
  return { receipt, backupPath };
2260
2330
  } catch (error) {
2261
2331
  await unlink(tempPath).catch(() => {});
2262
- if (targetExistedBefore && committedInfo) {
2263
- await atomicRestoreFileFromBackup(backupPath, configPath, mode, { identity: committedInfo, hash: afterHash, mode }).catch(() => {});
2264
- } else if (!targetExistedBefore && committedInfo) {
2265
- await validateAtomicTarget(configPath, { identity: committedInfo, hash: afterHash, mode }).then(() => unlink(configPath)).catch(() => {});
2332
+ try {
2333
+ if (targetExistedBefore && committedInfo) {
2334
+ await atomicRestoreFileFromBackup(backupPath, configPath, mode, { identity: committedInfo, hash: afterHash, mode });
2335
+ } else if (!targetExistedBefore && committedInfo) {
2336
+ await validateAtomicTarget(configPath, { identity: committedInfo, hash: afterHash, mode });
2337
+ await unlink(configPath);
2338
+ }
2339
+ } catch (compensationError) {
2340
+ const writeMessage = error instanceof Error ? error.message : String(error);
2341
+ const compensationMessage = compensationError instanceof Error ? compensationError.message : String(compensationError);
2342
+ throw new Error(`prompt-cache-key fix receipt update failed (${writeMessage}) and config compensation failed (${compensationMessage})`);
2266
2343
  }
2267
2344
  throw error;
2268
2345
  }
@@ -2276,15 +2353,26 @@ async function applyPromptCacheKeyConfigFix(
2276
2353
  return withModelsJsonTransactionLock(() => applyPromptCacheKeyConfigFixUnderLock(model, configPath, receiptPath));
2277
2354
  }
2278
2355
 
2356
+ type PromptCacheKeyRollbackOptions = {
2357
+ /** Test-only race injector; production callers leave this undefined. */
2358
+ beforeReceiptRename?: () => Promise<void>;
2359
+ };
2360
+
2279
2361
  async function rollbackPromptCacheKeyConfigUnderLock(
2280
- receipt: PromptCacheKeyConfigReceipt,
2362
+ snapshot: PromptCacheKeyConfigReceiptSnapshot,
2281
2363
  configPath: string = CONFIG_FILE_PATH,
2282
2364
  receiptPath: string = CONFIG_RECEIPT_PATH,
2365
+ options: PromptCacheKeyRollbackOptions = {},
2283
2366
  ): Promise<void> {
2367
+ if (snapshot.receiptPath !== receiptPath) throw new Error("prompt-cache-key receipt path changed since the rollback preview");
2368
+ await assertPromptCacheKeyConfigReceiptSnapshotUnchanged(snapshot);
2369
+ const receipt = snapshot.receipt;
2284
2370
  const currentInfo = await lstat(configPath);
2285
2371
  if (currentInfo.isSymbolicLink() || !currentInfo.isFile()) throw new Error("optimizer config is not a regular file; refusing to overwrite user changes");
2286
2372
  const currentText = await readFile(configPath, "utf8");
2287
- if (hashText(currentText) !== receipt.afterHash) throw new Error("optimizer config changed after the fix; refusing to overwrite user changes");
2373
+ const currentHash = hashText(currentText);
2374
+ const currentMode = currentInfo.mode & 0o7777;
2375
+ if (currentHash !== receipt.afterHash) throw new Error("optimizer config changed after the fix; refusing to overwrite user changes");
2288
2376
  const current = parsePersistedCacheOptimizerConfig(JSON.parse(currentText));
2289
2377
  if (!current) throw new Error("optimizer config is invalid; refusing to overwrite user changes");
2290
2378
  const key = `${receipt.provider}/${receipt.modelId}`;
@@ -2292,46 +2380,90 @@ async function rollbackPromptCacheKeyConfigUnderLock(
2292
2380
  if (receipt.targetHadModelKey) throw new Error("prompt-cache-key was already configured before this fix; refusing to remove user configuration");
2293
2381
  const omit = current.version === 2 ? current.promptCacheKey?.omit ?? [] : [];
2294
2382
  if (!omit.includes(receipt.addedModelKey)) throw new Error("prompt-cache-key opt-out is no longer present; refusing to change user config");
2383
+
2384
+ let rollbackResultText: string | undefined;
2385
+ let rollbackResultMode: number | undefined;
2386
+ let rollbackResultInfo: Awaited<ReturnType<typeof lstat>> | undefined;
2295
2387
  const backupPath = configReceiptBackupPath(receipt, receiptPath);
2296
2388
  if (receipt.targetExistedBefore) {
2297
2389
  const backupInfo = await lstat(backupPath);
2298
2390
  if (backupInfo.isSymbolicLink() || !backupInfo.isFile()) throw new Error("config backup is not a regular file");
2299
2391
  const backupText = await readFile(backupPath, "utf8");
2300
2392
  if (hashText(backupText) !== receipt.beforeHash) throw new Error("config backup hash does not match the fix receipt");
2393
+ rollbackResultText = backupText;
2394
+ rollbackResultMode = backupInfo.mode & 0o7777;
2301
2395
  await atomicRestoreFileFromBackup(
2302
2396
  backupPath,
2303
2397
  configPath,
2304
- backupInfo.mode & 0o7777,
2305
- { backupHash: receipt.beforeHash, identity: currentInfo, hash: hashText(currentText), mode: currentInfo.mode & 0o7777 },
2398
+ rollbackResultMode,
2399
+ { backupHash: receipt.beforeHash, identity: currentInfo, hash: currentHash, mode: currentMode },
2306
2400
  );
2401
+ rollbackResultInfo = await lstat(configPath);
2402
+ } else if (current.footerMode || omit.length > 1) {
2403
+ const restored: PersistedCacheOptimizerConfigV2 = {
2404
+ version: 2,
2405
+ ...(current.footerMode ? { footerMode: current.footerMode } : {}),
2406
+ ...(omit.length > 1 ? { promptCacheKey: { omit: omit.filter((item) => item !== receipt.addedModelKey) } } : {}),
2407
+ };
2408
+ rollbackResultText = JSON.stringify(restored, null, 2) + "\n";
2409
+ rollbackResultMode = currentMode;
2410
+ await atomicReplaceTextFilePreservingMode(
2411
+ configPath,
2412
+ rollbackResultText,
2413
+ rollbackResultMode,
2414
+ "config-rollback",
2415
+ { identity: currentInfo, hash: currentHash, mode: currentMode },
2416
+ );
2417
+ rollbackResultInfo = await lstat(configPath);
2307
2418
  } else {
2308
- if (current.footerMode || omit.length > 1) {
2309
- const restored: PersistedCacheOptimizerConfigV2 = {
2310
- version: 2,
2311
- ...(current.footerMode ? { footerMode: current.footerMode } : {}),
2312
- ...(omit.length > 1 ? { promptCacheKey: { omit: omit.filter((item) => item !== receipt.addedModelKey) } } : {}),
2313
- };
2314
- await atomicReplaceTextFilePreservingMode(
2315
- configPath,
2316
- JSON.stringify(restored, null, 2) + "\n",
2317
- currentInfo.mode & 0o7777,
2318
- "config-rollback",
2319
- { identity: currentInfo, hash: hashText(currentText), mode: currentInfo.mode & 0o7777 },
2320
- );
2321
- } else {
2322
- await validateAtomicTarget(configPath, { identity: currentInfo, hash: hashText(currentText), mode: currentInfo.mode & 0o7777 });
2323
- await unlink(configPath);
2419
+ await validateAtomicTarget(configPath, { identity: currentInfo, hash: currentHash, mode: currentMode });
2420
+ await unlink(configPath);
2421
+ }
2422
+
2423
+ try {
2424
+ await writePromptCacheKeyConfigReceipt(
2425
+ { ...receipt, status: "rolled_back", rolledBackAt: Date.now() },
2426
+ receiptPath,
2427
+ snapshot,
2428
+ options.beforeReceiptRename,
2429
+ );
2430
+ } catch (receiptError) {
2431
+ // Receipt marking is part of the transaction. If it fails after the config
2432
+ // mutation, restore the exact post-fix config rather than leaving an
2433
+ // actionable receipt paired with an already-rolled-back file.
2434
+ try {
2435
+ if (rollbackResultText === undefined) {
2436
+ await atomicCreateTextFileNoReplace(configPath, currentText, currentMode, "config-rollback-compensation");
2437
+ } else {
2438
+ if (!rollbackResultInfo || rollbackResultMode === undefined) throw new Error("missing rollback result guard");
2439
+ await atomicReplaceTextFilePreservingMode(
2440
+ configPath,
2441
+ currentText,
2442
+ currentMode,
2443
+ "config-rollback-compensation",
2444
+ {
2445
+ identity: rollbackResultInfo,
2446
+ hash: hashText(rollbackResultText),
2447
+ mode: rollbackResultMode,
2448
+ },
2449
+ );
2450
+ }
2451
+ } catch (compensationError) {
2452
+ const receiptMessage = receiptError instanceof Error ? receiptError.message : String(receiptError);
2453
+ const compensationMessage = compensationError instanceof Error ? compensationError.message : String(compensationError);
2454
+ throw new Error(`prompt-cache-key rollback receipt update failed (${receiptMessage}) and config compensation failed (${compensationMessage})`);
2324
2455
  }
2456
+ throw receiptError;
2325
2457
  }
2326
- await writePromptCacheKeyConfigReceipt({ ...receipt, status: "rolled_back", rolledBackAt: Date.now() }, receiptPath);
2327
2458
  }
2328
2459
 
2329
2460
  async function rollbackPromptCacheKeyConfig(
2330
- receipt: PromptCacheKeyConfigReceipt,
2461
+ snapshot: PromptCacheKeyConfigReceiptSnapshot,
2331
2462
  configPath: string = CONFIG_FILE_PATH,
2332
2463
  receiptPath: string = CONFIG_RECEIPT_PATH,
2464
+ options: PromptCacheKeyRollbackOptions = {},
2333
2465
  ): Promise<void> {
2334
- return withModelsJsonTransactionLock(() => rollbackPromptCacheKeyConfigUnderLock(receipt, configPath, receiptPath));
2466
+ return withModelsJsonTransactionLock(() => rollbackPromptCacheKeyConfigUnderLock(snapshot, configPath, receiptPath, options));
2335
2467
  }
2336
2468
 
2337
2469
  function resolveFooterStatsMode(
@@ -3679,22 +3811,25 @@ function hasPromptCacheRetentionUnsupportedErrorMessage(message: unknown): boole
3679
3811
  }
3680
3812
 
3681
3813
  function hasPromptCacheKeyUnsupportedText(value: unknown): boolean {
3682
- const normalized = lower(value).replace(/["'`]/g, "").replace(/[_-]+/g, "_").replace(/\s+/g, " ").trim();
3683
- const key = "(?:prompt ?cache ?key|prompt_cache_key)";
3814
+ const normalized = lower(value)
3815
+ .replace(/["'`]/g, "")
3816
+ .replace(/[\s_-]+/g, " ")
3817
+ .replace(/\s+/g, " ")
3818
+ .trim();
3819
+ const key = String.raw`(?:prompt ?cache ?key|promptcachekey)`;
3820
+ const field = String.raw`(?:parameter|field|input|argument)`;
3821
+ const unsupported = String.raw`(?:unsupported|unknown|unrecognized|unexpected|not supported|not allowed|not permitted|must be omitted|should be omitted)`;
3684
3822
  if (!new RegExp(key).test(normalized)) return false;
3685
3823
 
3686
- // Only classify a field-level capability rejection. Value validation,
3687
- // conditional restrictions, and generic invalid-parameter messages are not
3688
- // proof that the endpoint lacks support for the field.
3689
- return [
3690
- new RegExp(`unsupported (?:parameter|field|input|argument)[: ]+.*${key}`),
3691
- new RegExp(`unknown (?:parameter|field|input|argument)[: ]+.*${key}`),
3692
- new RegExp(`unrecognized (?:parameter|field|input|argument)[: ]+.*${key}`),
3693
- new RegExp(`(?:parameter|field|input|argument)[: ]+${key} (?:is )?(?:unsupported|unknown|unrecognized|not supported|not allowed|not permitted)`),
3694
- new RegExp(`${key} (?:is )?(?:unsupported|unknown|unrecognized|not supported|not allowed|not permitted)`),
3695
- new RegExp(`extra inputs?(?: are)? (?:not permitted|not allowed)[: ]+.*${key}`),
3696
- new RegExp(`(?:not supported|not permitted|not allowed)[: ]+.*${key}`),
3697
- ].some((pattern) => pattern.test(normalized));
3824
+ // Keep this deliberately grammar-bound. A rejected value or a conditional
3825
+ // restriction (for example, "not allowed when temperature is set") is not
3826
+ // proof that the endpoint lacks support for the field itself.
3827
+ const terminal = String.raw`(?=$|[}\]>,.;])`;
3828
+ return (
3829
+ new RegExp(String.raw`(?:unsupported|unknown|unrecognized|unexpected)\s+${field}\s*[:=]?\s*${key}${terminal}`).test(normalized) ||
3830
+ new RegExp(String.raw`(?:extra\s+inputs?|${field}\s+not\s+(?:allowed|permitted|supported))\s*[:=]\s*${key}${terminal}`).test(normalized) ||
3831
+ new RegExp(String.raw`${key}(?:\s+${field})?\s*[:=]?\s*(?:is\s+)?${unsupported}${terminal}`).test(normalized)
3832
+ );
3698
3833
  }
3699
3834
 
3700
3835
  function hasPromptCacheKeyUnsupportedSignal(headers: Record<string, string> | undefined): boolean {
@@ -8688,6 +8823,41 @@ async function atomicReplaceTextFilePreservingMode(
8688
8823
  }
8689
8824
  }
8690
8825
 
8826
+ async function atomicCreateTextFileNoReplace(
8827
+ targetPath: string,
8828
+ content: string,
8829
+ mode: number,
8830
+ purpose: string,
8831
+ /** Test-only race injector; production callers leave this undefined. */
8832
+ beforeLink?: () => Promise<void>,
8833
+ ): Promise<void> {
8834
+ const tempPath = uniqueTempPath(targetPath, purpose);
8835
+ try {
8836
+ await writeFile(tempPath, content, { encoding: "utf8", mode, flag: "wx" });
8837
+ const tempInfo = await lstat(tempPath);
8838
+ if (tempInfo.isSymbolicLink() || !tempInfo.isFile()) {
8839
+ throw new Error("temporary creation is not a regular file");
8840
+ }
8841
+ await chmod(tempPath, mode);
8842
+ // A hard link gives us an atomic no-replace create. Unlike rename(), it
8843
+ // cannot overwrite a file that appeared after the absence check.
8844
+ if (beforeLink) await beforeLink();
8845
+ await link(tempPath, targetPath);
8846
+ await unlink(tempPath).catch((cleanupError) => {
8847
+ console.warn(`${LOG_PREFIX}: committed config file but failed to remove its temporary hard link`, cleanupError);
8848
+ });
8849
+ } catch (error) {
8850
+ try {
8851
+ await unlink(tempPath);
8852
+ } catch (cleanupError) {
8853
+ if (getErrorCode(cleanupError) !== "ENOENT") {
8854
+ console.warn(`${LOG_PREFIX}: failed to remove temporary config file`, cleanupError);
8855
+ }
8856
+ }
8857
+ throw error;
8858
+ }
8859
+ }
8860
+
8691
8861
  async function atomicRestoreFileFromBackup(
8692
8862
  backupPath: string,
8693
8863
  targetPath: string,
@@ -9205,7 +9375,14 @@ async function writeModelsJsonFixReceipt(
9205
9375
  await assertReceiptDestinationUnchanged();
9206
9376
  if (beforeRename) await beforeRename();
9207
9377
  await assertReceiptDestinationUnchanged();
9208
- await rename(tempPath, receiptPath);
9378
+ if (existingReceiptInfo) {
9379
+ await rename(tempPath, receiptPath);
9380
+ } else {
9381
+ await link(tempPath, receiptPath);
9382
+ await unlink(tempPath).catch((cleanupError) => {
9383
+ console.warn(`${LOG_PREFIX}: committed fix receipt but failed to remove its temporary hard link`, cleanupError);
9384
+ });
9385
+ }
9209
9386
  } catch (error) {
9210
9387
  try {
9211
9388
  await unlink(tempPath);
@@ -9925,6 +10102,7 @@ export const __internals_for_tests = {
9925
10102
  parsePromptCacheKeyConfigReceipt,
9926
10103
  isActionablePromptCacheKeyConfigReceipt,
9927
10104
  readPromptCacheKeyConfigReceipt,
10105
+ readPromptCacheKeyConfigReceiptSnapshot,
9928
10106
  writePromptCacheKeyConfigReceipt,
9929
10107
  applyPromptCacheKeyConfigFix,
9930
10108
  rollbackPromptCacheKeyConfig,
@@ -9998,6 +10176,7 @@ export const __internals_for_tests = {
9998
10176
  formatCompatKeysForInsertion,
9999
10177
  backupTimestamp,
10000
10178
  atomicReplaceTextFilePreservingMode,
10179
+ atomicCreateTextFileNoReplace,
10001
10180
  atomicRestoreFileFromBackup,
10002
10181
  applyModelsJsonFixTransaction,
10003
10182
  hashText,
@@ -10071,7 +10250,11 @@ export default function (pi: ExtensionAPI) {
10071
10250
  // finalized assistant message consumes the oldest completed record, falling
10072
10251
  // back to the oldest request only when a transport emitted no response hook.
10073
10252
  // This preserves response A when request B/C starts before message_end(A).
10074
- type ProviderRequestState = { model: PiModel; responseReceived: boolean };
10253
+ type ProviderRequestState = {
10254
+ model: PiModel;
10255
+ responseReceived: boolean;
10256
+ correlationAmbiguous: boolean;
10257
+ };
10075
10258
  const providerRequestStates: ProviderRequestState[] = [];
10076
10259
  let shardCreatedAt = Date.now();
10077
10260
  const PERSIST_DEBOUNCE_MS = 2000;
@@ -10772,10 +10955,11 @@ export default function (pi: ExtensionAPI) {
10772
10955
 
10773
10956
  pi.on("before_provider_request", (event, ctx) => {
10774
10957
  const requestModel = resolveRouteModel(ctx.model, ctx) ?? ctx.model;
10775
- if (runtimeOptimizerEnabled) {
10776
- const snapshot = snapshotProviderRequestModel(requestModel);
10777
- if (snapshot) providerRequestStates.push({ model: snapshot, responseReceived: false });
10778
- }
10958
+ // Request-local identity is also needed by the always-on Anthropic TTL
10959
+ // validity repair, so retain the credential-blind snapshot even while the
10960
+ // optional runtime optimizer features are disabled.
10961
+ const snapshot = snapshotProviderRequestModel(requestModel);
10962
+ if (snapshot) providerRequestStates.push({ model: snapshot, responseReceived: false, correlationAmbiguous: false });
10779
10963
  let requestPayload: unknown = event.payload;
10780
10964
  let toolOrderChanged = false;
10781
10965
 
@@ -10843,9 +11027,25 @@ export default function (pi: ExtensionAPI) {
10843
11027
  });
10844
11028
 
10845
11029
  pi.on("after_provider_response", async (event, ctx) => {
10846
- const responseState = providerRequestStates.find((state) => !state.responseReceived);
11030
+ const pendingStates = providerRequestStates.filter((state) => !state.responseReceived);
11031
+ let responseState: ProviderRequestState | undefined;
11032
+ if (pendingStates.length === 1) {
11033
+ responseState = pendingStates[0];
11034
+ } else if (pendingStates.length > 1) {
11035
+ const pendingModelKeys = new Set(pendingStates.map((state) => modelKey(state.model)));
11036
+ if (pendingModelKeys.size === 1) {
11037
+ // Identity is still exact when concurrent requests use the same model.
11038
+ responseState = pendingStates[0];
11039
+ } else {
11040
+ // Pi supplies no request id here, so out-of-order concurrent responses
11041
+ // cannot be assigned safely. Preserve the lifecycle records for a
11042
+ // message-local identity, but never turn ambiguous headers into a
11043
+ // model-scoped persistent-fix suggestion.
11044
+ for (const state of pendingStates) state.correlationAmbiguous = true;
11045
+ }
11046
+ }
10847
11047
  if (responseState) responseState.responseReceived = true;
10848
- const model = responseState?.model ?? (resolveRouteModel(ctx.model, ctx) ?? ctx.model);
11048
+ const model = responseState?.model ?? (pendingStates.length === 0 ? (resolveRouteModel(ctx.model, ctx) ?? ctx.model) : undefined);
10849
11049
  if (!runtimeOptimizerEnabled || !model) return;
10850
11050
 
10851
11051
  // Keep only the category, never the provider's complete error text. This
@@ -10938,21 +11138,39 @@ export default function (pi: ExtensionAPI) {
10938
11138
  pi.on("message_end", async (event, ctx) => {
10939
11139
  syncSessionHash(ctx);
10940
11140
  const msgRecord = asRecord(event.message);
10941
- const requestModelForMessage = msgRecord?.role === "assistant"
11141
+ const requestCorrelationForMessage = msgRecord?.role === "assistant"
10942
11142
  ? (() => {
11143
+ const explicitModel = modelFromAssistantMessage(event.message, undefined);
11144
+ const explicitIndex = explicitModel
11145
+ ? providerRequestStates.findIndex((state) => modelKey(state.model) === modelKey(explicitModel))
11146
+ : -1;
10943
11147
  const completedIndex = providerRequestStates.findIndex((state) => state.responseReceived);
10944
- const index = completedIndex >= 0 ? completedIndex : 0;
10945
- return providerRequestStates.splice(index, 1)[0]?.model;
11148
+ const contextModel = resolveRouteModel(ctx.model, ctx) ?? ctx.model;
11149
+ const contextIndex = contextModel && !providerRequestStates.some((state) => state.correlationAmbiguous)
11150
+ ? providerRequestStates.findIndex((state) => modelKey(state.model) === modelKey(contextModel))
11151
+ : -1;
11152
+ const index = explicitIndex >= 0
11153
+ ? explicitIndex
11154
+ : (completedIndex >= 0 ? completedIndex : (contextIndex >= 0 ? contextIndex : 0));
11155
+ const state = providerRequestStates.splice(index, 1)[0];
11156
+ if (!state) return { model: undefined, ambiguous: false };
11157
+ return {
11158
+ model: state.correlationAmbiguous && explicitIndex < 0 ? undefined : state.model,
11159
+ ambiguous: state.correlationAmbiguous && explicitIndex < 0,
11160
+ };
10946
11161
  })()
10947
- : undefined;
11162
+ : { model: undefined, ambiguous: false };
11163
+ const requestModelForMessage = requestCorrelationForMessage.model;
11164
+ const contextualFallbackForMessage = requestCorrelationForMessage.ambiguous
11165
+ ? undefined
11166
+ : (resolveRouteModel(ctx.model, ctx) ?? ctx.model);
10948
11167
 
10949
11168
  // Some providers expose an HTTP 400 error body only through the finalized
10950
11169
  // assistant error message; after_provider_response may contain the status
10951
11170
  // and no diagnostic response headers. Record only the model-scoped
10952
11171
  // reasoning-protocol category from that authoritative message identity.
10953
11172
  if (runtimeOptimizerEnabled && hasReasoningProtocolRejectionErrorMessage(event.message)) {
10954
- const fallbackModel = requestModelForMessage
10955
- ?? (resolveRouteModel(ctx.model, ctx) ?? ctx.model);
11173
+ const fallbackModel = requestModelForMessage ?? contextualFallbackForMessage;
10956
11174
  const messageModel = modelFromAssistantMessage(event.message, fallbackModel) ?? fallbackModel;
10957
11175
  const errorModel = messageModel
10958
11176
  ? findModelInRegistry(ctx.modelRegistry, messageModel.provider, messageModel.id) ?? messageModel
@@ -10967,7 +11185,7 @@ export default function (pi: ExtensionAPI) {
10967
11185
  }
10968
11186
  }
10969
11187
  if (runtimeOptimizerEnabled && hasPromptCacheKeyUnsupportedErrorMessage(event.message)) {
10970
- const fallbackModel = requestModelForMessage ?? (resolveRouteModel(ctx.model, ctx) ?? ctx.model);
11188
+ const fallbackModel = requestModelForMessage ?? contextualFallbackForMessage;
10971
11189
  const messageModel = modelFromAssistantMessage(event.message, fallbackModel) ?? fallbackModel;
10972
11190
  const errorModel = messageModel
10973
11191
  ? findModelInRegistry(ctx.modelRegistry, messageModel.provider, messageModel.id) ?? messageModel
@@ -10985,8 +11203,7 @@ export default function (pi: ExtensionAPI) {
10985
11203
  }
10986
11204
  }
10987
11205
  if (runtimeOptimizerEnabled && hasPromptCacheRetentionUnsupportedErrorMessage(event.message)) {
10988
- const fallbackModel = requestModelForMessage
10989
- ?? (resolveRouteModel(ctx.model, ctx) ?? ctx.model);
11206
+ const fallbackModel = requestModelForMessage ?? contextualFallbackForMessage;
10990
11207
  const messageModel = modelFromAssistantMessage(event.message, fallbackModel) ?? fallbackModel;
10991
11208
  const errorModel = messageModel
10992
11209
  ? findModelInRegistry(ctx.modelRegistry, messageModel.provider, messageModel.id) ?? messageModel
@@ -11010,8 +11227,7 @@ export default function (pi: ExtensionAPI) {
11010
11227
  // non-retryable 400 in Pi 0.82.1, so the fallback applies to the next
11011
11228
  // subsequent request (and to a retry only if another layer initiates one).
11012
11229
  if (hasAnthropicCacheTtlOrderError(event.message)) {
11013
- const fallbackModel = requestModelForMessage
11014
- ?? (resolveRouteModel(ctx.model, ctx) ?? ctx.model);
11230
+ const fallbackModel = requestModelForMessage ?? contextualFallbackForMessage;
11015
11231
  const errorModel = modelFromAssistantMessage(event.message, fallbackModel) ?? fallbackModel;
11016
11232
  if (errorModel && isAnthropicMessagesApi(errorModel.api)) {
11017
11233
  const key = modelKey(errorModel);
@@ -11240,7 +11456,8 @@ export default function (pi: ExtensionAPI) {
11240
11456
  cmdCtx.ui.notify("No active model selected. Select a model first with /model or pi --model.", "warning");
11241
11457
  return;
11242
11458
  }
11243
- const configReceipt = await readPromptCacheKeyConfigReceipt();
11459
+ const configReceiptSnapshot = await readPromptCacheKeyConfigReceiptSnapshot();
11460
+ const configReceipt = configReceiptSnapshot?.receipt;
11244
11461
  const modelsReceipt = await readModelsJsonFixReceipt();
11245
11462
  const useConfigReceipt = isActionablePromptCacheKeyConfigReceipt(configReceipt) &&
11246
11463
  configReceipt.provider === model.provider &&
@@ -11260,7 +11477,8 @@ export default function (pi: ExtensionAPI) {
11260
11477
  return;
11261
11478
  }
11262
11479
  try {
11263
- await rollbackPromptCacheKeyConfig(configReceipt);
11480
+ if (!configReceiptSnapshot) throw new Error("prompt-cache-key receipt changed since the rollback preview");
11481
+ await rollbackPromptCacheKeyConfig(configReceiptSnapshot);
11264
11482
  setPersistedCacheOptimizerConfig(readPersistedCacheOptimizerConfig());
11265
11483
  cmdCtx.ui.notify(`✅ Restored prompt_cache_key behavior for ${modelKey(model)}. Run /reload or restart Pi for the change to take effect.`, "info");
11266
11484
  } catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-cache-optimizer",
3
- "version": "2.8.9",
3
+ "version": "2.8.10",
4
4
  "description": "Improve Pi prompt/KV cache hit rates with stable prompts, OpenAI-compatible cache keys, proxy compat warnings, and footer cache stats.",
5
5
  "keywords": [
6
6
  "pi-package",