pi-cache-optimizer 2.8.7 → 2.8.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
@@ -17,6 +17,7 @@ Pi extension for improving provider-side KV / prompt cache hit rates. It keeps s
17
17
  - [Install](#install)
18
18
  - [Commands](#commands)
19
19
  - [Persistent opt-out](#persistent-opt-out)
20
+ - [Per-model `prompt_cache_key` opt-out](#per-model-prompt_cache_key-opt-out)
20
21
  - [Opt-in deterministic tool ordering](#opt-in-deterministic-tool-ordering)
21
22
  - [Footer cache stats mode](#footer-cache-stats-mode)
22
23
  - [OpenAI-compatible proxy setup](#openai-compatible-proxy-setup)
@@ -60,7 +61,7 @@ Run `/reload` in Pi after install/update/remove so extension hooks refresh.
60
61
 
61
62
  On Pi 0.79.7 and newer, `pi update` updates Pi itself only. To update installed Pi packages such as this extension, run `pi update --extensions` (packages only) or `pi update --all` (Pi + packages).
62
63
 
63
- This extension requires Pi 0.82+ and is validated against Pi 0.84.4. It uses the official Pi package types directly for type-checking, along with extension hooks, `getAgentDir()`, and prompt options shared by those versions; it does not depend on Pi 0.83+ APIs such as `ctx.scopedModels` or the bundled TypeBox 1.3 aliases.
64
+ This extension requires Pi 0.82+ and is validated against Pi 0.85.0. It uses the official Pi package types directly for type-checking, along with extension hooks, `getAgentDir()`, and prompt options shared by those versions; it does not depend on Pi 0.83+ APIs such as `ctx.scopedModels` or the bundled TypeBox 1.3 aliases.
64
65
 
65
66
  ## Commands
66
67
 
@@ -76,10 +77,11 @@ This extension requires Pi 0.82+ and is validated against Pi 0.84.4. It uses the
76
77
  | `/cache-optimizer stats contributors` | Shows current/other contributing sessions for the active exact provider/model without exposing session ids. |
77
78
  | `/cache-optimizer reset` | Resets local footer stats for the active provider/model; upstream provider cache is not modified. |
78
79
  | `/cache-optimizer config footer-mode total\|session\|process` | Persist the footer stats mode. Persistent command configuration overrides the environment variable. |
79
- | `/cache-optimizer fix` | Auto-repairs safe compat issues for the active model. Shows preview + risk warning, requires confirmation. **Only modifies `models.json` after explicit user approval.** |
80
- | `/cache-optimizer rollback` | Reviews the latest matching confirmed fix and, after UI confirmation, restores it safely without overwriting unrelated `models.json` changes. |
80
+ | `/cache-optimizer fix` | Auto-repairs safe compat issues for the active model. Shows preview + risk warning, requires confirmation. It writes `models.json` only for native compat fixes, or the extension config for an evidenced `prompt_cache_key` issue. |
81
+ | `/cache-optimizer fix prompt-cache-key` | Explicitly configures the active OpenAI-compatible provider/model to omit `prompt_cache_key` and `promptCacheKey` from the final request body. Requires confirmation. |
82
+ | `/cache-optimizer rollback` | Reviews the latest matching confirmed fix and, after UI confirmation, restores either the extension config or `models.json` fix safely. |
81
83
 
82
- `/cache-optimizer` uses Pi's native Tab completion. Type `/cache-optimizer <Tab>` for the supported subcommands, `/cache-optimizer stats <Tab>` for `all` or `contributors`, `/cache-optimizer c<Tab>` for `config`, `/cache-optimizer config <Tab>` for `footer-mode`, and `/cache-optimizer config footer-mode <Tab>` for `total`, `session`, or `process`. Suggestions are prefix-filtered and invalid prefixes are left to Pi's normal fallback behavior.
84
+ `/cache-optimizer` uses Pi's native Tab completion. Type `/cache-optimizer <Tab>` for the supported subcommands, `/cache-optimizer stats <Tab>` for `all` or `contributors`, `/cache-optimizer fix <Tab>` for `prompt-cache-key`, `/cache-optimizer c<Tab>` for `config`, `/cache-optimizer config <Tab>` for `footer-mode`, and `/cache-optimizer config footer-mode <Tab>` for `total`, `session`, or `process`. Suggestions are prefix-filtered and invalid prefixes are left to Pi's normal fallback behavior.
83
85
 
84
86
  The interactive `/cache-optimizer` menu includes `Footer mode`, where you can choose `total`, `session`, or `process`. `enable` / `disable` are current-process switches. For a persistent opt-out, use environment variables below.
85
87
 
@@ -130,6 +132,18 @@ Persistent command configuration takes precedence over the environment variable:
130
132
 
131
133
  The explicit setting is stored in `pi-cache-optimizer-config.json` under Pi's agent directory. If no command override exists, `PI_CACHE_OPTIMIZER_FOOTER_MODE=total|session|process` is used; values are case-insensitive, and missing or invalid values fall back to `session`. To return an existing installation to environment-controlled behavior, manually delete `pi-cache-optimizer-config.json` and run `/reload`.
132
134
 
135
+ ## Per-model `prompt_cache_key` opt-out
136
+
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
+
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:
140
+
141
+ ```text
142
+ /cache-optimizer fix prompt-cache-key
143
+ ```
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`.
146
+
133
147
  ## OpenAI-compatible proxy setup
134
148
 
135
149
  Third-party `openai-completions` proxies (LiteLLM / OneAPI / NewAPI / OpenRouter-like channels) often route one session across multiple upstream backends. That splits provider-side prompt caches.
package/README.zh-CN.md CHANGED
@@ -17,6 +17,7 @@
17
17
  - [安装](#安装)
18
18
  - [命令](#命令)
19
19
  - [持久 Opt-out](#持久-opt-out)
20
+ - [按模型关闭 `prompt_cache_key`](#按模型关闭-prompt_cache_key)
20
21
  - [Opt-in 确定性工具排序](#opt-in-确定性工具排序)
21
22
  - [Footer 缓存统计模式](#footer-缓存统计模式)
22
23
  - [OpenAI-compatible 代理配置](#openai-compatible-代理配置)
@@ -60,7 +61,7 @@ pi remove npm:pi-deepseek-cache-optimizer && pi install npm:pi-cache-optimizer
60
61
 
61
62
  Pi 0.79.7 及之后,`pi update` 默认只更新 Pi 本体。若要更新已安装的 Pi package(包括本扩展),请运行 `pi update --extensions`(只更新 packages)或 `pi update --all`(Pi 与 packages 一起更新)。
62
63
 
63
- 本扩展要求 Pi 0.82+,并已使用 Pi 0.84.4 验证。TypeScript 校验直接使用官方 Pi package 类型,同时只使用这些版本共有的 extension hooks、`getAgentDir()` 和 prompt options;不依赖 Pi 0.83+ 专有 API(例如 `ctx.scopedModels` 或 bundled TypeBox 1.3 aliases)。
64
+ 本扩展要求 Pi 0.82+,并已使用 Pi 0.85.0 验证。TypeScript 校验直接使用官方 Pi package 类型,同时只使用这些版本共有的 extension hooks、`getAgentDir()` 和 prompt options;不依赖 Pi 0.83+ 专有 API(例如 `ctx.scopedModels` 或 bundled TypeBox 1.3 aliases)。
64
65
 
65
66
  ## 命令
66
67
 
@@ -76,10 +77,11 @@ Pi 0.79.7 及之后,`pi update` 默认只更新 Pi 本体。若要更新已安
76
77
  | `/cache-optimizer stats contributors` | 显示当前精确 provider/model 的当前/其他贡献 session,不暴露 session id。 |
77
78
  | `/cache-optimizer reset` | 重置当前 provider/model 的本地 footer 统计;不会修改上游 provider 缓存。 |
78
79
  | `/cache-optimizer config footer-mode total\|session\|process` | 持久设置 footer 统计模式;持久命令配置优先于环境变量。 |
79
- | `/cache-optimizer fix` | 为当前模型自动修复安全的 compat 问题。展示预览 + 风险提示,需要用户确认。**仅在用户明确批准后才修改 `models.json`。** |
80
- | `/cache-optimizer rollback` | 查看最近一次匹配的已确认修复;经 UI 确认后安全撤销,不覆盖无关的 `models.json` 用户修改。 |
80
+ | `/cache-optimizer fix` | 为当前模型自动修复安全的 compat 问题。展示预览 + 风险提示,需要用户确认;原生 compat 修复写 `models.json`,已观测到的 `prompt_cache_key` 问题写扩展配置。 |
81
+ | `/cache-optimizer fix prompt-cache-key` | 明确把当前 OpenAI-compatible provider/model 配置为从最终请求体移除 `prompt_cache_key` 与 `promptCacheKey`。需要确认。 |
82
+ | `/cache-optimizer rollback` | 查看最近一次匹配的已确认修复;经 UI 确认后安全恢复扩展配置或 `models.json` 修复。 |
81
83
 
82
- `/cache-optimizer` 使用 Pi 原生 Tab 补全:输入 `/cache-optimizer <Tab>` 查看支持的子命令,输入 `/cache-optimizer stats <Tab>` 补全 `all` 或 `contributors`,输入 `/cache-optimizer c<Tab>` 补全 `config`,输入 `/cache-optimizer config <Tab>` 补全 `footer-mode`,输入 `/cache-optimizer config footer-mode <Tab>` 补全 `total`、`session` 或 `process`。建议会按当前前缀过滤;无效前缀返回空结果,由 Pi 正常回退处理。
84
+ `/cache-optimizer` 使用 Pi 原生 Tab 补全:输入 `/cache-optimizer <Tab>` 查看支持的子命令,输入 `/cache-optimizer stats <Tab>` 补全 `all` 或 `contributors`,输入 `/cache-optimizer fix <Tab>` 补全 `prompt-cache-key`,输入 `/cache-optimizer c<Tab>` 补全 `config`,输入 `/cache-optimizer config <Tab>` 补全 `footer-mode`,输入 `/cache-optimizer config footer-mode <Tab>` 补全 `total`、`session` 或 `process`。建议会按当前前缀过滤;无效前缀返回空结果,由 Pi 正常回退处理。
83
85
 
84
86
  交互式 `/cache-optimizer` 菜单包含 `Footer mode`,可以选择 `total`、`session` 或 `process`。`enable` / `disable` 是当前进程内开关。若要持久关闭某些能力,请使用下面的环境变量。
85
87
 
@@ -130,6 +132,18 @@ Footer 默认使用 `session`,避免另一个并行 Pi 终端使用相同 prov
130
132
 
131
133
  显式设置保存在 Pi agent 目录下的 `pi-cache-optimizer-config.json`。没有命令覆盖时,读取 `PI_CACHE_OPTIMIZER_FOOTER_MODE=total|session|process`;值不区分大小写,缺失或非法值均回退到 `session`。如需让已有安装重新由环境变量控制,请手动删除 `pi-cache-optimizer-config.json`,然后运行 `/reload`。
132
134
 
135
+ ## 按模型关闭 `prompt_cache_key`
136
+
137
+ 某些 OpenAI-compatible endpoint 会因 `prompt_cache_key` 返回 HTTP 400,但同一个字段对其他 provider 可能有效。Pi 0.85.1 没有原生的 `supportsPromptCacheKey` compat 字段,**不要**把这个未知字段加入 `models.json`。`supportsLongCacheRetention` 也不是等价开关,不应借用来实现此目的。
138
+
139
+ 当扩展观察到精确 provider/model 明确拒绝 `prompt_cache_key` 后,普通 `/cache-optimizer fix` 会提供经过确认的模型级修复。如果你已经确定 endpoint 不支持该字段,可以主动执行:
140
+
141
+ ```text
142
+ /cache-optimizer fix prompt-cache-key
143
+ ```
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`。
146
+
133
147
  ## OpenAI-compatible 代理配置
134
148
 
135
149
  LiteLLM / OneAPI / NewAPI / 类 OpenRouter 渠道等第三方 `openai-completions` 代理,常会把同一个 session 分散到多个上游后端,导致 provider 侧 prompt cache 被拆散。
package/index.ts CHANGED
@@ -114,6 +114,8 @@ const SHARD_GLOBAL_EPOCH_PATH = join(SHARD_EPOCH_DIR, "global.json");
114
114
  const SHARD_CLEANUP_LOCK_PATH = join(SHARD_MAINTENANCE_DIR, "cleanup.lock");
115
115
  const SHARD_CLEANUP_MARKER_PATH = join(SHARD_MAINTENANCE_DIR, "last-cleanup");
116
116
  const CONFIG_FILE_PATH = join(STATE_DIR, "pi-cache-optimizer-config.json");
117
+ const CONFIG_RECEIPT_FILE_NAME = "pi-cache-optimizer-config-receipt.json";
118
+ const CONFIG_RECEIPT_PATH = join(STATE_DIR, CONFIG_RECEIPT_FILE_NAME);
117
119
  const FIX_RECEIPT_FILE_NAME = "pi-cache-optimizer-fix-receipt.json";
118
120
  const FIX_RECEIPT_PATH = join(STATE_DIR, FIX_RECEIPT_FILE_NAME);
119
121
  const MODELS_TRANSACTION_LOCK_PATH = join(STATE_DIR, "pi-cache-optimizer-models-transaction.lock");
@@ -177,6 +179,31 @@ type PersistedCacheOptimizerConfigV1 = {
177
179
  version: 1;
178
180
  footerMode?: FooterStatsMode;
179
181
  };
182
+ type PersistedCacheOptimizerConfigV2 = {
183
+ version: 2;
184
+ footerMode?: FooterStatsMode;
185
+ promptCacheKey?: {
186
+ omit?: string[];
187
+ };
188
+ };
189
+ type PersistedCacheOptimizerConfig = PersistedCacheOptimizerConfigV1 | PersistedCacheOptimizerConfigV2;
190
+ type PromptCacheKeyConfigReceipt = {
191
+ version: 2;
192
+ kind: "pi-cache-optimizer-config-receipt";
193
+ transactionId: string;
194
+ provider: string;
195
+ modelId: string;
196
+ beforeHash: string;
197
+ afterHash: string;
198
+ backupFile: string;
199
+ targetExistedBefore: boolean;
200
+ targetHadModelKey: boolean;
201
+ addedModelKey: string;
202
+ createdAt: number;
203
+ appliedAt: number;
204
+ status?: "rolled_back";
205
+ rolledBackAt?: number;
206
+ };
180
207
  const PI_ROUTING_REGISTRY_SYMBOL = Symbol.for("pi.routing.registry.v1");
181
208
  const PI_CACHE_HINTS_SYMBOL = Symbol.for("pi.cache.hints.v1");
182
209
  const PI_CACHE_HINTS_OWNER_SYMBOL = Symbol.for("pi.cache.optimizer.hints-owner.v1");
@@ -1867,6 +1894,7 @@ const CACHE_OPTIMIZER_COMMANDS = [
1867
1894
  const CACHE_OPTIMIZER_CONFIG_ARGUMENTS = ["footer-mode"] as const;
1868
1895
  const CACHE_OPTIMIZER_FOOTER_MODES = ["total", "session", "process"] as const;
1869
1896
  const CACHE_OPTIMIZER_STATS_ARGUMENTS = ["all", "contributors"] as const;
1897
+ const CACHE_OPTIMIZER_FIX_ARGUMENTS = ["prompt-cache-key"] as const;
1870
1898
 
1871
1899
  function filterCommandCompletionItems(
1872
1900
  values: readonly string[],
@@ -1917,6 +1945,12 @@ function getCacheOptimizerArgumentCompletions(argumentPrefix: string): CommandCo
1917
1945
  : null;
1918
1946
  }
1919
1947
 
1948
+ if (parts[0].toLowerCase() === "fix") {
1949
+ return parts.length === 2
1950
+ ? filterCommandCompletionItems(CACHE_OPTIMIZER_FIX_ARGUMENTS, parts[1], "fix")
1951
+ : null;
1952
+ }
1953
+
1920
1954
  if (parts[0].toLowerCase() !== "config") return null;
1921
1955
 
1922
1956
  if (parts.length === 2) {
@@ -1934,36 +1968,368 @@ function getCacheOptimizerArgumentCompletions(argumentPrefix: string): CommandCo
1934
1968
  return null;
1935
1969
  }
1936
1970
 
1937
- function parsePersistedCacheOptimizerConfig(value: unknown): PersistedCacheOptimizerConfigV1 | undefined {
1971
+ function parsePersistedCacheOptimizerConfig(value: unknown): PersistedCacheOptimizerConfig | undefined {
1938
1972
  const record = asRecord(value);
1939
- if (!record || record.version !== 1) return undefined;
1973
+ if (!record || (record.version !== 1 && record.version !== 2)) return undefined;
1974
+ const allowedTopLevel = new Set(record.version === 1 ? ["version", "footerMode"] : ["version", "footerMode", "promptCacheKey"]);
1975
+ if (Object.keys(record).some((key) => !allowedTopLevel.has(key))) return undefined;
1940
1976
  const footerMode = parseFooterStatsMode(record.footerMode);
1941
1977
  if (record.footerMode !== undefined && !footerMode) return undefined;
1942
- return { version: 1, ...(footerMode ? { footerMode } : {}) };
1978
+ if (record.version === 1) return { version: 1, ...(footerMode ? { footerMode } : {}) };
1979
+
1980
+ const rawPromptCacheKey = record.promptCacheKey;
1981
+ if (rawPromptCacheKey !== undefined && !asRecord(rawPromptCacheKey)) return undefined;
1982
+ const promptCacheKey = asRecord(rawPromptCacheKey);
1983
+ if (promptCacheKey && Object.keys(promptCacheKey).some((key) => key !== "omit")) return undefined;
1984
+ const omit = promptCacheKey?.omit;
1985
+ if (omit !== undefined && (!Array.isArray(omit) || omit.some((value): value is string => !isNonEmptyString(value)))) return undefined;
1986
+ const stringOmit = omit as string[] | undefined;
1987
+ const uniqueOmit = stringOmit ? [...new Set(stringOmit.map((value) => value.trim()))].sort() : undefined;
1988
+ return {
1989
+ version: 2,
1990
+ ...(footerMode ? { footerMode } : {}),
1991
+ ...(uniqueOmit && uniqueOmit.length > 0 ? { promptCacheKey: { omit: uniqueOmit } } : {}),
1992
+ };
1993
+ }
1994
+
1995
+ function normalizePersistedCacheOptimizerConfig(value: PersistedCacheOptimizerConfig | undefined): PersistedCacheOptimizerConfigV2 {
1996
+ if (!value) return { version: 2 };
1997
+ return value.version === 2
1998
+ ? value
1999
+ : { version: 2, ...(value.footerMode ? { footerMode: value.footerMode } : {}) };
2000
+ }
2001
+
2002
+ function readPersistedCacheOptimizerConfig(configPath: string = CONFIG_FILE_PATH): PersistedCacheOptimizerConfigV2 {
2003
+ try {
2004
+ return normalizePersistedCacheOptimizerConfig(parsePersistedCacheOptimizerConfig(JSON.parse(readFileSync(configPath, "utf8"))));
2005
+ } catch (error) {
2006
+ if (getErrorCode(error) !== "ENOENT") console.warn(`${LOG_PREFIX}: failed to read optimizer config; using defaults`, error);
2007
+ return { version: 2 };
2008
+ }
1943
2009
  }
1944
2010
 
1945
2011
  function readPersistedFooterMode(configPath: string = CONFIG_FILE_PATH): FooterStatsMode | undefined {
2012
+ return readPersistedCacheOptimizerConfig(configPath).footerMode;
2013
+ }
2014
+
2015
+ async function writePersistedCacheOptimizerConfigUnlocked(
2016
+ config: PersistedCacheOptimizerConfigV2,
2017
+ configPath: string,
2018
+ ): Promise<void> {
2019
+ await mkdir(dirname(configPath), { recursive: true });
2020
+ const payloadText = JSON.stringify(normalizePersistedCacheOptimizerConfig(config), null, 2) + "\n";
2021
+ let targetInfo: Awaited<ReturnType<typeof lstat>> | undefined;
2022
+ let targetMode = 0o600;
2023
+ let targetHash: string | undefined;
1946
2024
  try {
1947
- const parsed = parsePersistedCacheOptimizerConfig(JSON.parse(readFileSync(configPath, "utf8")));
1948
- if (!parsed) throw new Error("invalid footer config schema");
1949
- return parsed.footerMode;
2025
+ targetInfo = await lstat(configPath);
2026
+ if (targetInfo.isSymbolicLink() || !targetInfo.isFile()) throw new Error("optimizer config is not a regular file; no changes were made");
2027
+ const targetText = await readFile(configPath, "utf8");
2028
+ targetMode = targetInfo.mode & 0o7777;
2029
+ targetHash = hashText(targetText);
1950
2030
  } catch (error) {
1951
- if (getErrorCode(error) !== "ENOENT") {
1952
- console.warn(`${LOG_PREFIX}: failed to read footer config; using environment/default mode`, error);
2031
+ if (getErrorCode(error) !== "ENOENT") throw error;
2032
+ }
2033
+ if (targetInfo) {
2034
+ await atomicReplaceTextFilePreservingMode(configPath, payloadText, targetMode, "config", {
2035
+ identity: targetInfo,
2036
+ hash: targetHash,
2037
+ mode: targetMode,
2038
+ });
2039
+ return;
2040
+ }
2041
+
2042
+ const tempPath = uniqueTempPath(configPath, "config");
2043
+ try {
2044
+ await writeFile(tempPath, payloadText, { encoding: "utf8", mode: targetMode, flag: "wx" });
2045
+ await chmod(tempPath, targetMode);
2046
+ try {
2047
+ await lstat(configPath);
2048
+ throw new Error("optimizer config appeared during write; no changes were made");
2049
+ } catch (error) {
2050
+ if (getErrorCode(error) !== "ENOENT") throw error;
1953
2051
  }
1954
- return undefined;
2052
+ await rename(tempPath, configPath);
2053
+ } catch (error) {
2054
+ await unlink(tempPath).catch((cleanupError) => { if (getErrorCode(cleanupError) !== "ENOENT") console.warn(`${LOG_PREFIX}: failed to remove temporary config`, cleanupError); });
2055
+ throw error;
2056
+ }
2057
+ }
2058
+
2059
+ async function writePersistedCacheOptimizerConfig(
2060
+ config: PersistedCacheOptimizerConfigV2,
2061
+ configPath: string = CONFIG_FILE_PATH,
2062
+ ): Promise<void> {
2063
+ await withModelsJsonTransactionLock(() => writePersistedCacheOptimizerConfigUnlocked(config, configPath));
2064
+ }
2065
+
2066
+ async function writePersistedFooterModeUnlocked(
2067
+ mode: FooterStatsMode,
2068
+ configPath: string,
2069
+ ): Promise<void> {
2070
+ let version: 1 | 2 = 1;
2071
+ let raw: PersistedCacheOptimizerConfig | undefined;
2072
+ let targetExists = false;
2073
+ try {
2074
+ raw = parsePersistedCacheOptimizerConfig(JSON.parse(readFileSync(configPath, "utf8")));
2075
+ targetExists = true;
2076
+ if (!raw) throw new Error("invalid footer config schema");
2077
+ version = raw.version === 2 ? 2 : 1;
2078
+ } catch (error) {
2079
+ if (getErrorCode(error) !== "ENOENT") throw new Error("invalid footer config schema");
2080
+ }
2081
+ const current = normalizePersistedCacheOptimizerConfig(raw);
2082
+ if (version === 1 && !current.promptCacheKey) {
2083
+ await mkdir(dirname(configPath), { recursive: true });
2084
+ const targetInfo = targetExists ? await lstat(configPath) : undefined;
2085
+ if (targetInfo && (targetInfo.isSymbolicLink() || !targetInfo.isFile())) throw new Error("optimizer config is not a regular file; no changes were made");
2086
+ const targetText = targetInfo ? await readFile(configPath, "utf8") : undefined;
2087
+ const targetMode = targetInfo ? targetInfo.mode & 0o7777 : 0o600;
2088
+ const tempPath = uniqueTempPath(configPath, "config-footer");
2089
+ try {
2090
+ await writeFile(tempPath, JSON.stringify({ version: 1, footerMode: mode }, null, 2) + "\n", { encoding: "utf8", mode: targetMode, flag: "wx" });
2091
+ await chmod(tempPath, targetMode);
2092
+ if (targetInfo && targetText !== undefined) {
2093
+ await validateAtomicTarget(configPath, { identity: targetInfo, hash: hashText(targetText), mode: targetMode });
2094
+ } else {
2095
+ try {
2096
+ await lstat(configPath);
2097
+ throw new Error("optimizer config appeared during footer write; no changes were made");
2098
+ } catch (error) {
2099
+ if (getErrorCode(error) !== "ENOENT") throw error;
2100
+ }
2101
+ }
2102
+ await rename(tempPath, configPath);
2103
+ } catch (error) {
2104
+ await unlink(tempPath).catch(() => {});
2105
+ throw error;
2106
+ }
2107
+ return;
1955
2108
  }
2109
+ await writePersistedCacheOptimizerConfigUnlocked({ ...current, version: 2, footerMode: mode }, configPath);
1956
2110
  }
1957
2111
 
1958
2112
  async function writePersistedFooterMode(
1959
2113
  mode: FooterStatsMode,
1960
2114
  configPath: string = CONFIG_FILE_PATH,
1961
2115
  ): Promise<void> {
2116
+ await withModelsJsonTransactionLock(() => writePersistedFooterModeUnlocked(mode, configPath));
2117
+ }
2118
+
2119
+ function configReceiptBackupPath(receipt: PromptCacheKeyConfigReceipt, receiptPath: string = CONFIG_RECEIPT_PATH): string {
2120
+ return join(dirname(receiptPath), receipt.backupFile);
2121
+ }
2122
+
2123
+ function parsePromptCacheKeyConfigReceipt(value: unknown): PromptCacheKeyConfigReceipt | undefined {
2124
+ const record = asRecord(value);
2125
+ if (!record || (record.version !== 1 && record.version !== 2) || record.kind !== "pi-cache-optimizer-config-receipt") return undefined;
2126
+ const allowed = new Set(record.version === 1
2127
+ ? ["version", "kind", "transactionId", "provider", "modelId", "beforeHash", "afterHash", "backupFile", "targetExistedBefore", "createdAt", "appliedAt", "status", "rolledBackAt"]
2128
+ : ["version", "kind", "transactionId", "provider", "modelId", "beforeHash", "afterHash", "backupFile", "targetExistedBefore", "targetHadModelKey", "addedModelKey", "createdAt", "appliedAt", "status", "rolledBackAt"]);
2129
+ if (Object.keys(record).some((key) => !allowed.has(key))) return undefined;
2130
+ if (![record.transactionId, record.provider, record.modelId].every(isSafeReceiptText)) return undefined;
2131
+ if (!isSha256(record.beforeHash) || !isSha256(record.afterHash) || record.beforeHash === record.afterHash) return undefined;
2132
+ if (!isSafeReceiptText(record.backupFile) || basename(record.backupFile) !== record.backupFile || !record.backupFile.startsWith("pi-cache-optimizer-config.backup-")) return undefined;
2133
+ if (typeof record.targetExistedBefore !== "boolean" || !isReceiptTimestamp(record.createdAt) || !isReceiptTimestamp(record.appliedAt)) return undefined;
2134
+ const addedModelKey = `${record.provider}/${record.modelId}`;
2135
+ const targetHadModelKey = record.version === 1 ? false : record.targetHadModelKey;
2136
+ if (typeof targetHadModelKey !== "boolean") return undefined;
2137
+ if (record.version === 2 && (!isSafeReceiptText(record.addedModelKey) || record.addedModelKey !== addedModelKey)) return undefined;
2138
+ if (record.status !== undefined && record.status !== "rolled_back") return undefined;
2139
+ if (record.status === "rolled_back" && (!isReceiptTimestamp(record.rolledBackAt) || record.rolledBackAt < record.appliedAt)) return undefined;
2140
+ if (record.status === undefined && record.rolledBackAt !== undefined) return undefined;
2141
+ return {
2142
+ version: 2,
2143
+ kind: "pi-cache-optimizer-config-receipt",
2144
+ transactionId: String(record.transactionId),
2145
+ provider: String(record.provider),
2146
+ modelId: String(record.modelId),
2147
+ beforeHash: String(record.beforeHash).toLowerCase(),
2148
+ afterHash: String(record.afterHash).toLowerCase(),
2149
+ backupFile: String(record.backupFile),
2150
+ targetExistedBefore: record.targetExistedBefore,
2151
+ targetHadModelKey,
2152
+ addedModelKey,
2153
+ createdAt: Number(record.createdAt),
2154
+ appliedAt: Number(record.appliedAt),
2155
+ ...(record.status === "rolled_back" ? { status: "rolled_back", rolledBackAt: Number(record.rolledBackAt) } : {}),
2156
+ };
2157
+ }
2158
+
2159
+ function isActionablePromptCacheKeyConfigReceipt(receipt: PromptCacheKeyConfigReceipt | undefined): receipt is PromptCacheKeyConfigReceipt {
2160
+ return receipt !== undefined && receipt.status === undefined;
2161
+ }
2162
+
2163
+ async function readPromptCacheKeyConfigReceipt(receiptPath: string = CONFIG_RECEIPT_PATH): Promise<PromptCacheKeyConfigReceipt | undefined> {
2164
+ try {
2165
+ const info = await lstat(receiptPath);
2166
+ if (info.isSymbolicLink() || !info.isFile()) return undefined;
2167
+ const receipt = parsePromptCacheKeyConfigReceipt(JSON.parse(await readFile(receiptPath, "utf8")));
2168
+ return receipt;
2169
+ } catch {
2170
+ return undefined;
2171
+ }
2172
+ }
2173
+
2174
+ async function writePromptCacheKeyConfigReceipt(receipt: PromptCacheKeyConfigReceipt, receiptPath: string = CONFIG_RECEIPT_PATH): Promise<void> {
2175
+ if (!parsePromptCacheKeyConfigReceipt(receipt)) throw new Error("invalid prompt cache key config receipt");
2176
+ await mkdir(dirname(receiptPath), { recursive: true });
2177
+ const tempPath = uniqueTempPath(receiptPath, "config-receipt");
2178
+ try {
2179
+ await writeFile(tempPath, JSON.stringify(receipt, null, 2) + "\n", { encoding: "utf8", mode: 0o600, flag: "wx" });
2180
+ await chmod(tempPath, 0o600);
2181
+ await rename(tempPath, receiptPath);
2182
+ } catch (error) {
2183
+ await unlink(tempPath).catch((cleanupError) => { if (getErrorCode(cleanupError) !== "ENOENT") console.warn(`${LOG_PREFIX}: failed to remove temporary config receipt`, cleanupError); });
2184
+ throw error;
2185
+ }
2186
+ }
2187
+
2188
+ async function applyPromptCacheKeyConfigFixUnderLock(
2189
+ model: PiModel,
2190
+ configPath: string = CONFIG_FILE_PATH,
2191
+ receiptPath: string = CONFIG_RECEIPT_PATH,
2192
+ ): Promise<{ receipt: PromptCacheKeyConfigReceipt; backupPath: string }> {
1962
2193
  await mkdir(dirname(configPath), { recursive: true });
1963
- const payload: PersistedCacheOptimizerConfigV1 = { version: 1, footerMode: mode };
1964
- const tempPath = `${configPath}.${process.pid}.${Date.now()}.tmp`;
1965
- await writeFile(tempPath, JSON.stringify(payload, null, 2) + "\n", "utf8");
1966
- await rename(tempPath, configPath);
2194
+ let originalText = "";
2195
+ let targetExistedBefore = false;
2196
+ let mode = 0o600;
2197
+ try {
2198
+ const info = await lstat(configPath);
2199
+ if (info.isSymbolicLink() || !info.isFile()) throw new Error("optimizer config is not a regular file; no changes were made");
2200
+ originalText = await readFile(configPath, "utf8");
2201
+ targetExistedBefore = true;
2202
+ mode = info.mode & 0o7777;
2203
+ } catch (error) {
2204
+ if (getErrorCode(error) !== "ENOENT") throw error;
2205
+ }
2206
+ const parsedCurrent = targetExistedBefore ? parsePersistedCacheOptimizerConfig(JSON.parse(originalText)) : undefined;
2207
+ if (targetExistedBefore && !parsedCurrent) throw new Error("optimizer config is invalid; no changes were made");
2208
+ const current = normalizePersistedCacheOptimizerConfig(parsedCurrent);
2209
+ const key = modelKey(model);
2210
+ const existingOmit = current.promptCacheKey?.omit ?? [];
2211
+ const targetHadModelKey = existingOmit.includes(key);
2212
+ const omit = [...new Set([...existingOmit, key])].sort();
2213
+ const next: PersistedCacheOptimizerConfigV2 = { ...current, version: 2, promptCacheKey: { omit } };
2214
+ const modifiedText = JSON.stringify(next, null, 2) + "\n";
2215
+ const beforeHash = hashText(originalText);
2216
+ const afterHash = hashText(modifiedText);
2217
+ const originalInfo = targetExistedBefore ? await lstat(configPath) : undefined;
2218
+ const backupFile = `pi-cache-optimizer-config.backup-${backupTimestamp()}`;
2219
+ const backupPath = join(dirname(configPath), backupFile);
2220
+ if (targetExistedBefore) {
2221
+ await copyFile(configPath, backupPath, fsConstants.COPYFILE_EXCL);
2222
+ await chmod(backupPath, mode);
2223
+ }
2224
+ const tempPath = uniqueTempPath(configPath, "config-fix");
2225
+ let committedInfo: Awaited<ReturnType<typeof lstat>> | undefined;
2226
+ try {
2227
+ await writeFile(tempPath, modifiedText, { encoding: "utf8", mode, flag: "wx" });
2228
+ await chmod(tempPath, mode);
2229
+ if (originalInfo) {
2230
+ await validateAtomicTarget(configPath, { identity: originalInfo, hash: beforeHash, mode });
2231
+ } else {
2232
+ try {
2233
+ await lstat(configPath);
2234
+ throw new Error("optimizer config appeared during the fix; no changes were made");
2235
+ } catch (error) {
2236
+ if (getErrorCode(error) !== "ENOENT") throw error;
2237
+ }
2238
+ }
2239
+ await rename(tempPath, configPath);
2240
+ committedInfo = await lstat(configPath);
2241
+ const receipt: PromptCacheKeyConfigReceipt = {
2242
+ version: 2,
2243
+ kind: "pi-cache-optimizer-config-receipt",
2244
+ transactionId: randomUUID(),
2245
+ provider: model.provider,
2246
+ modelId: model.id,
2247
+ beforeHash,
2248
+ afterHash,
2249
+ backupFile,
2250
+ targetExistedBefore,
2251
+ targetHadModelKey,
2252
+ addedModelKey: key,
2253
+ createdAt: Date.now(),
2254
+ appliedAt: Date.now(),
2255
+ };
2256
+ await writePromptCacheKeyConfigReceipt(receipt, receiptPath);
2257
+ return { receipt, backupPath };
2258
+ } catch (error) {
2259
+ await unlink(tempPath).catch(() => {});
2260
+ if (targetExistedBefore && committedInfo) {
2261
+ await atomicRestoreFileFromBackup(backupPath, configPath, mode, { identity: committedInfo, hash: afterHash, mode }).catch(() => {});
2262
+ } else if (!targetExistedBefore && committedInfo) {
2263
+ await validateAtomicTarget(configPath, { identity: committedInfo, hash: afterHash, mode }).then(() => unlink(configPath)).catch(() => {});
2264
+ }
2265
+ throw error;
2266
+ }
2267
+ }
2268
+
2269
+ async function applyPromptCacheKeyConfigFix(
2270
+ model: PiModel,
2271
+ configPath: string = CONFIG_FILE_PATH,
2272
+ receiptPath: string = CONFIG_RECEIPT_PATH,
2273
+ ): Promise<{ receipt: PromptCacheKeyConfigReceipt; backupPath: string }> {
2274
+ return withModelsJsonTransactionLock(() => applyPromptCacheKeyConfigFixUnderLock(model, configPath, receiptPath));
2275
+ }
2276
+
2277
+ async function rollbackPromptCacheKeyConfigUnderLock(
2278
+ receipt: PromptCacheKeyConfigReceipt,
2279
+ configPath: string = CONFIG_FILE_PATH,
2280
+ receiptPath: string = CONFIG_RECEIPT_PATH,
2281
+ ): Promise<void> {
2282
+ const currentInfo = await lstat(configPath);
2283
+ if (currentInfo.isSymbolicLink() || !currentInfo.isFile()) throw new Error("optimizer config is not a regular file; refusing to overwrite user changes");
2284
+ const currentText = await readFile(configPath, "utf8");
2285
+ if (hashText(currentText) !== receipt.afterHash) throw new Error("optimizer config changed after the fix; refusing to overwrite user changes");
2286
+ const current = parsePersistedCacheOptimizerConfig(JSON.parse(currentText));
2287
+ if (!current) throw new Error("optimizer config is invalid; refusing to overwrite user changes");
2288
+ const key = `${receipt.provider}/${receipt.modelId}`;
2289
+ if (receipt.addedModelKey !== key) throw new Error("prompt-cache-key receipt identity does not match; refusing to change user config");
2290
+ if (receipt.targetHadModelKey) throw new Error("prompt-cache-key was already configured before this fix; refusing to remove user configuration");
2291
+ const omit = current.version === 2 ? current.promptCacheKey?.omit ?? [] : [];
2292
+ if (!omit.includes(receipt.addedModelKey)) throw new Error("prompt-cache-key opt-out is no longer present; refusing to change user config");
2293
+ const backupPath = configReceiptBackupPath(receipt, receiptPath);
2294
+ if (receipt.targetExistedBefore) {
2295
+ const backupInfo = await lstat(backupPath);
2296
+ if (backupInfo.isSymbolicLink() || !backupInfo.isFile()) throw new Error("config backup is not a regular file");
2297
+ const backupText = await readFile(backupPath, "utf8");
2298
+ if (hashText(backupText) !== receipt.beforeHash) throw new Error("config backup hash does not match the fix receipt");
2299
+ await atomicRestoreFileFromBackup(
2300
+ backupPath,
2301
+ configPath,
2302
+ backupInfo.mode & 0o7777,
2303
+ { backupHash: receipt.beforeHash, identity: currentInfo, hash: hashText(currentText), mode: currentInfo.mode & 0o7777 },
2304
+ );
2305
+ } else {
2306
+ if (current.footerMode || omit.length > 1) {
2307
+ const restored: PersistedCacheOptimizerConfigV2 = {
2308
+ version: 2,
2309
+ ...(current.footerMode ? { footerMode: current.footerMode } : {}),
2310
+ ...(omit.length > 1 ? { promptCacheKey: { omit: omit.filter((item) => item !== receipt.addedModelKey) } } : {}),
2311
+ };
2312
+ await atomicReplaceTextFilePreservingMode(
2313
+ configPath,
2314
+ JSON.stringify(restored, null, 2) + "\n",
2315
+ currentInfo.mode & 0o7777,
2316
+ "config-rollback",
2317
+ { identity: currentInfo, hash: hashText(currentText), mode: currentInfo.mode & 0o7777 },
2318
+ );
2319
+ } else {
2320
+ await validateAtomicTarget(configPath, { identity: currentInfo, hash: hashText(currentText), mode: currentInfo.mode & 0o7777 });
2321
+ await unlink(configPath);
2322
+ }
2323
+ }
2324
+ await writePromptCacheKeyConfigReceipt({ ...receipt, status: "rolled_back", rolledBackAt: Date.now() }, receiptPath);
2325
+ }
2326
+
2327
+ async function rollbackPromptCacheKeyConfig(
2328
+ receipt: PromptCacheKeyConfigReceipt,
2329
+ configPath: string = CONFIG_FILE_PATH,
2330
+ receiptPath: string = CONFIG_RECEIPT_PATH,
2331
+ ): Promise<void> {
2332
+ return withModelsJsonTransactionLock(() => rollbackPromptCacheKeyConfigUnderLock(receipt, configPath, receiptPath));
1967
2333
  }
1968
2334
 
1969
2335
  function resolveFooterStatsMode(
@@ -1982,7 +2348,8 @@ function footerStatsMode(
1982
2348
  return resolveFooterStatsMode(configuredMode, env).mode;
1983
2349
  }
1984
2350
 
1985
- let persistedFooterStatsMode = readPersistedFooterMode();
2351
+ let persistedCacheOptimizerConfig = readPersistedCacheOptimizerConfig();
2352
+ let persistedFooterStatsMode = persistedCacheOptimizerConfig.footerMode;
1986
2353
 
1987
2354
  function isDisabledEnv(value: string | undefined): boolean {
1988
2355
  if (!value) return false;
@@ -2104,6 +2471,25 @@ function shouldInjectOpenAIPromptCacheKeyForModel(model: PiModel | undefined): b
2104
2471
  return isOpenAICompatibleApi(model?.api);
2105
2472
  }
2106
2473
 
2474
+ function isPromptCacheKeyOmittedForModel(model: PiModel | undefined, config: PersistedCacheOptimizerConfigV2 = persistedCacheOptimizerConfig): boolean {
2475
+ if (!model || !isOpenAICompatibleApi(model.api)) return false;
2476
+ return config.promptCacheKey?.omit?.includes(modelKey(model)) === true;
2477
+ }
2478
+
2479
+ function setPersistedCacheOptimizerConfig(config: PersistedCacheOptimizerConfigV2): void {
2480
+ persistedCacheOptimizerConfig = normalizePersistedCacheOptimizerConfig(config);
2481
+ persistedFooterStatsMode = persistedCacheOptimizerConfig.footerMode;
2482
+ }
2483
+
2484
+ function omitOpenAIPromptCacheKeys(payload: unknown): unknown | undefined {
2485
+ const record = asRecord(payload);
2486
+ if (!record || (!Object.prototype.hasOwnProperty.call(record, "prompt_cache_key") && !Object.prototype.hasOwnProperty.call(record, "promptCacheKey"))) return undefined;
2487
+ const copy = { ...record };
2488
+ delete copy.prompt_cache_key;
2489
+ delete copy.promptCacheKey;
2490
+ return copy;
2491
+ }
2492
+
2107
2493
  function collectAnthropicCacheControlsInWireOrder(payload: unknown): UnknownRecord[] {
2108
2494
  const record = asRecord(payload);
2109
2495
  if (!record) return [];
@@ -3287,6 +3673,41 @@ function hasPromptCacheRetentionUnsupportedErrorMessage(message: unknown): boole
3287
3673
  hasPromptCacheRetentionUnsupportedText(record.errorMessage);
3288
3674
  }
3289
3675
 
3676
+ function hasPromptCacheKeyUnsupportedText(value: unknown): boolean {
3677
+ const normalized = lower(value).replace(/["'`]/g, "").replace(/[_-]+/g, "_").replace(/\s+/g, " ").trim();
3678
+ const key = "(?:prompt ?cache ?key|prompt_cache_key)";
3679
+ if (!new RegExp(key).test(normalized)) return false;
3680
+
3681
+ // Only classify a field-level capability rejection. Value validation,
3682
+ // conditional restrictions, and generic invalid-parameter messages are not
3683
+ // proof that the endpoint lacks support for the field.
3684
+ return [
3685
+ new RegExp(`unsupported (?:parameter|field|input|argument)[: ]+.*${key}`),
3686
+ new RegExp(`unknown (?:parameter|field|input|argument)[: ]+.*${key}`),
3687
+ new RegExp(`unrecognized (?:parameter|field|input|argument)[: ]+.*${key}`),
3688
+ new RegExp(`(?:parameter|field|input|argument)[: ]+${key} (?:is )?(?:unsupported|unknown|unrecognized|not supported|not allowed|not permitted)`),
3689
+ new RegExp(`${key} (?:is )?(?:unsupported|unknown|unrecognized|not supported|not allowed|not permitted)`),
3690
+ new RegExp(`extra inputs?(?: are)? (?:not permitted|not allowed)[: ]+.*${key}`),
3691
+ new RegExp(`(?:not supported|not permitted|not allowed)[: ]+.*${key}`),
3692
+ ].some((pattern) => pattern.test(normalized));
3693
+ }
3694
+
3695
+ function hasPromptCacheKeyUnsupportedSignal(headers: Record<string, string> | undefined): boolean {
3696
+ if (!headers) return false;
3697
+ return Object.entries(headers).some(([key, value]) => hasPromptCacheKeyUnsupportedText(`${key}: ${value}`));
3698
+ }
3699
+
3700
+ function hasPromptCacheKeyUnsupportedErrorMessage(message: unknown): boolean {
3701
+ const record = getAssistantRecord(message);
3702
+ return record?.stopReason === "error" &&
3703
+ getOptionalAssistantHttpStatus(record) === 400 &&
3704
+ hasPromptCacheKeyUnsupportedText(record.errorMessage);
3705
+ }
3706
+
3707
+ function isPromptCacheKeyUnsupportedApplicable(model: PiModel): boolean {
3708
+ return isOpenAICompatibleApi(model.api);
3709
+ }
3710
+
3290
3711
  function hasReasoningProtocolRejectionText(value: unknown): boolean {
3291
3712
  const normalized = lower(value).replace(/\s+/g, " ").trim();
3292
3713
  if (!normalized) return false;
@@ -6107,7 +6528,7 @@ function getCompatCheckNotApplicableLines(model: PiModel): string[] {
6107
6528
  return ["ℹ️ Compat check not applicable for this model."];
6108
6529
  }
6109
6530
 
6110
- function buildDoctorDiagnosis(model: PiModel, options: { promptCacheRetention400?: boolean; anthropicTtlOrderError?: boolean; sessionAffinity403?: boolean; openAISdkHeader403?: boolean } = {}): string {
6531
+ function buildDoctorDiagnosis(model: PiModel, options: { promptCacheRetention400?: boolean; promptCacheKey400?: boolean; anthropicTtlOrderError?: boolean; sessionAffinity403?: boolean; openAISdkHeader403?: boolean } = {}): string {
6111
6532
  const lines: string[] = [];
6112
6533
  lines.push(`Provider: ${model.provider}`);
6113
6534
  lines.push(`Model: ${model.id}`);
@@ -6157,6 +6578,13 @@ function buildDoctorDiagnosis(model: PiModel, options: { promptCacheRetention400
6157
6578
  lines.push(...getCompatCheckNotApplicableLines(model));
6158
6579
  }
6159
6580
 
6581
+ if (options.promptCacheKey400 && isPromptCacheKeyUnsupportedApplicable(model)) {
6582
+ lines.push("");
6583
+ lines.push("⚠️ This model previously rejected prompt_cache_key with an explicit HTTP 400 signal.");
6584
+ lines.push(" /cache-optimizer fix will offer a precise model-scoped opt-out.");
6585
+ lines.push(" The opt-out removes both prompt_cache_key and promptCacheKey from the final request body.");
6586
+ }
6587
+
6160
6588
  if (isPromptCacheRetention400Applicable(model)) {
6161
6589
  lines.push("");
6162
6590
  if (options.promptCacheRetention400) {
@@ -9245,6 +9673,8 @@ export const __internals_for_tests = {
9245
9673
  addOpenAIPromptCacheKey,
9246
9674
  clampPromptCacheKey,
9247
9675
  hasEffectivePromptCacheKey,
9676
+ omitOpenAIPromptCacheKeys,
9677
+ isPromptCacheKeyOmittedForModel,
9248
9678
  isNonEmptyString,
9249
9679
  shouldInjectOpenAIPromptCacheKey,
9250
9680
  shouldInjectOpenAIPromptCacheKeyForModel,
@@ -9281,6 +9711,9 @@ export const __internals_for_tests = {
9281
9711
  isOpenAISdkHeader403Applicable,
9282
9712
  hasPromptCacheRetentionUnsupportedSignal,
9283
9713
  hasPromptCacheRetentionUnsupportedErrorMessage,
9714
+ hasPromptCacheKeyUnsupportedSignal,
9715
+ hasPromptCacheKeyUnsupportedErrorMessage,
9716
+ isPromptCacheKeyUnsupportedApplicable,
9284
9717
  hasReasoningProtocolRejectionText,
9285
9718
  hasReasoningProtocolRejectionSignal,
9286
9719
  hasReasoningProtocolRejectionErrorMessage,
@@ -9475,11 +9908,22 @@ export const __internals_for_tests = {
9475
9908
  selectFooterStatsForModel,
9476
9909
  parseFooterStatsMode,
9477
9910
  parsePersistedCacheOptimizerConfig,
9911
+ readPersistedCacheOptimizerConfig,
9912
+ writePersistedCacheOptimizerConfig,
9478
9913
  readPersistedFooterMode,
9479
9914
  writePersistedFooterMode,
9915
+ setPersistedCacheOptimizerConfig,
9480
9916
  resolveFooterStatsMode,
9481
9917
  footerStatsMode,
9482
9918
  CONFIG_FILE_PATH,
9919
+ CONFIG_RECEIPT_PATH,
9920
+ parsePromptCacheKeyConfigReceipt,
9921
+ isActionablePromptCacheKeyConfigReceipt,
9922
+ readPromptCacheKeyConfigReceipt,
9923
+ writePromptCacheKeyConfigReceipt,
9924
+ applyPromptCacheKeyConfigFix,
9925
+ rollbackPromptCacheKeyConfig,
9926
+ configReceiptBackupPath,
9483
9927
  FOOTER_MODE_ENV,
9484
9928
  // Routing-provider protocol helpers
9485
9929
  PI_ROUTING_REGISTRY_SYMBOL,
@@ -9592,6 +10036,8 @@ export default function (pi: ExtensionAPI) {
9592
10036
  const reasoningProtocolFallbackState = getReasoningProtocolFallbackState();
9593
10037
  const reasoningProtocolRejectedModels = reasoningProtocolFallbackState.modelKeys;
9594
10038
  const warnedReasoningProtocolRejectedModels = reasoningProtocolFallbackState.warnedModelKeys;
10039
+ const promptCacheKeyRejectedModels = new Set<string>();
10040
+ const warnedPromptCacheKeyRejectedModels = new Set<string>();
9595
10041
  let cacheStatsByModel: Record<string, CacheStats> = {};
9596
10042
  let cacheStatsProcessByModel: Record<string, CacheStats> = {};
9597
10043
  let cacheStatsTotalsByModel: Record<string, CacheStats> = {};
@@ -9615,7 +10061,13 @@ export default function (pi: ExtensionAPI) {
9615
10061
  let currentSessionHashSet = false;
9616
10062
  let lastActualRoutedModel: PersistedRoutedModelRef | undefined;
9617
10063
  let latestCacheHint: PiCacheHintSnapshot | undefined;
9618
- let pendingProviderResponseModel: PiModel | undefined;
10064
+ // Pi's response hooks do not expose a request id. Track one FIFO lifecycle
10065
+ // record per request and mark records when their response hook arrives. A
10066
+ // finalized assistant message consumes the oldest completed record, falling
10067
+ // back to the oldest request only when a transport emitted no response hook.
10068
+ // This preserves response A when request B/C starts before message_end(A).
10069
+ type ProviderRequestState = { model: PiModel; responseReceived: boolean };
10070
+ const providerRequestStates: ProviderRequestState[] = [];
9619
10071
  let shardCreatedAt = Date.now();
9620
10072
  const PERSIST_DEBOUNCE_MS = 2000;
9621
10073
  const SHARD_REFRESH_DEBOUNCE_MS = 250;
@@ -9661,6 +10113,21 @@ export default function (pi: ExtensionAPI) {
9661
10113
  forceModelLevel: regular.forceModelLevel || observed.forceModelLevel,
9662
10114
  };
9663
10115
  }
10116
+
10117
+ function promptCacheKeyFixApplies(model: PiModel): boolean {
10118
+ return isPromptCacheKeyUnsupportedApplicable(model) && promptCacheKeyRejectedModels.has(modelKey(model));
10119
+ }
10120
+
10121
+ function buildPromptCacheKeyConfigPreview(model: PiModel): string[] {
10122
+ const key = modelKey(model);
10123
+ return [
10124
+ `Target: ${key}`,
10125
+ `Action: persist prompt_cache_key mode = omit in ${CONFIG_FILE_PATH}.`,
10126
+ "The final request body will omit both prompt_cache_key and promptCacheKey.",
10127
+ "This can reduce provider-side prompt-cache reuse for this model; it does not change prompts, credentials, headers, or other models.",
10128
+ "The setting applies after Pi core has built the payload, including when Pi already supplied a key.",
10129
+ ];
10130
+ }
9664
10131
  /** In-memory recent usage samples per model key (not persisted, cleared on reload). */
9665
10132
  const recentSamplesByModelKey = new Map<string, CacheUsageSample[]>();
9666
10133
 
@@ -10158,7 +10625,7 @@ export default function (pi: ExtensionAPI) {
10158
10625
  shardWatcher?.close();
10159
10626
  shardWatcher = undefined;
10160
10627
  latestCacheHint = undefined;
10161
- pendingProviderResponseModel = undefined;
10628
+ providerRequestStates.length = 0;
10162
10629
  delete getProtocolGlobal().__piCacheOptimizerCacheKey__;
10163
10630
  uninstallCacheHintsService();
10164
10631
  restoreCacheRetentionEnv(STARTUP_CACHE_RETENTION_ENV);
@@ -10300,9 +10767,10 @@ export default function (pi: ExtensionAPI) {
10300
10767
 
10301
10768
  pi.on("before_provider_request", (event, ctx) => {
10302
10769
  const requestModel = resolveRouteModel(ctx.model, ctx) ?? ctx.model;
10303
- pendingProviderResponseModel = runtimeOptimizerEnabled
10304
- ? snapshotProviderRequestModel(requestModel)
10305
- : undefined;
10770
+ if (runtimeOptimizerEnabled) {
10771
+ const snapshot = snapshotProviderRequestModel(requestModel);
10772
+ if (snapshot) providerRequestStates.push({ model: snapshot, responseReceived: false });
10773
+ }
10306
10774
  let requestPayload: unknown = event.payload;
10307
10775
  let toolOrderChanged = false;
10308
10776
 
@@ -10356,6 +10824,11 @@ export default function (pi: ExtensionAPI) {
10356
10824
  }
10357
10825
  }
10358
10826
 
10827
+ if (isPromptCacheKeyOmittedForModel(requestModel)) {
10828
+ const omitted = omitOpenAIPromptCacheKeys(requestPayload);
10829
+ return omitted ?? (toolOrderChanged ? requestPayload : undefined);
10830
+ }
10831
+
10359
10832
  if (!shouldInjectOpenAIPromptCacheKey() || !shouldInjectOpenAIPromptCacheKeyForModel(requestModel)) {
10360
10833
  return toolOrderChanged ? requestPayload : undefined;
10361
10834
  }
@@ -10365,16 +10838,10 @@ export default function (pi: ExtensionAPI) {
10365
10838
  });
10366
10839
 
10367
10840
  pi.on("after_provider_response", async (event, ctx) => {
10368
- // Keep the request snapshot until the corresponding assistant message ends.
10369
- // Pi's response hook exposes only status/headers; the finalized error may
10370
- // carry the useful protocol text later in message_end. A subsequent request
10371
- // replaces this process-local snapshot, and shutdown clears it.
10372
- const correlatedModel = pendingProviderResponseModel;
10373
- const model = correlatedModel ?? (resolveRouteModel(ctx.model, ctx) ?? ctx.model);
10374
- if (!runtimeOptimizerEnabled || !model) {
10375
- pendingProviderResponseModel = undefined;
10376
- return;
10377
- }
10841
+ const responseState = providerRequestStates.find((state) => !state.responseReceived);
10842
+ if (responseState) responseState.responseReceived = true;
10843
+ const model = responseState?.model ?? (resolveRouteModel(ctx.model, ctx) ?? ctx.model);
10844
+ if (!runtimeOptimizerEnabled || !model) return;
10378
10845
 
10379
10846
  // Keep only the category, never the provider's complete error text. This
10380
10847
  // is evidence for a later, model-scoped `/fix`; it never edits config from
@@ -10392,22 +10859,36 @@ export default function (pi: ExtensionAPI) {
10392
10859
  );
10393
10860
  }
10394
10861
 
10395
- // ── 400: prompt_cache_retention unsupported ──
10396
- if (event.status === 400) {
10397
- if (!isPromptCacheRetention400Applicable(model)) return;
10398
- if (!hasPromptCacheRetentionUnsupportedSignal(event.headers)) return;
10862
+ // ── 400: prompt_cache_key unsupported ──
10863
+ if (event.status === 400 && isPromptCacheKeyUnsupportedApplicable(model) && hasPromptCacheKeyUnsupportedSignal(event.headers)) {
10864
+ const key = modelKey(model);
10865
+ promptCacheKeyRejectedModels.add(key);
10866
+ if (!warnedPromptCacheKeyRejectedModels.has(key)) {
10867
+ warnedPromptCacheKeyRejectedModels.add(key);
10868
+ ctx.ui.notify(
10869
+ `⚠️ ${LOG_PREFIX}: ${key} rejected prompt_cache_key. Run /cache-optimizer fix to review a precise model-scoped repair. No configuration was changed automatically.`,
10870
+ "warning",
10871
+ );
10872
+ }
10873
+ }
10399
10874
 
10875
+ // ── 400: prompt_cache_retention unsupported ──
10876
+ if (
10877
+ event.status === 400 &&
10878
+ isPromptCacheRetention400Applicable(model) &&
10879
+ hasPromptCacheRetentionUnsupportedSignal(event.headers)
10880
+ ) {
10400
10881
  const key = modelKey(model);
10401
10882
  promptCacheRetention400Models.add(key);
10402
- if (warnedPromptCacheRetention400Models.has(key)) return;
10403
- warnedPromptCacheRetention400Models.add(key);
10404
- ctx.ui.notify(
10405
- `⚠️ ${LOG_PREFIX}: ${key} returned HTTP 400 while supportsLongCacheRetention is enabled. ` +
10406
- getPromptCacheRetentionUnsupportedHint() +
10407
- ` Run /cache-optimizer doctor for the exact edit location.`,
10408
- "warning",
10409
- );
10410
- return;
10883
+ if (!warnedPromptCacheRetention400Models.has(key)) {
10884
+ warnedPromptCacheRetention400Models.add(key);
10885
+ ctx.ui.notify(
10886
+ `⚠️ ${LOG_PREFIX}: ${key} returned HTTP 400 while supportsLongCacheRetention is enabled. ` +
10887
+ getPromptCacheRetentionUnsupportedHint() +
10888
+ ` Run /cache-optimizer doctor for the exact edit location.`,
10889
+ "warning",
10890
+ );
10891
+ }
10411
10892
  }
10412
10893
 
10413
10894
  // ── 403: proxy/CDN/WAF blocking ──
@@ -10453,14 +10934,12 @@ export default function (pi: ExtensionAPI) {
10453
10934
  syncSessionHash(ctx);
10454
10935
  const msgRecord = asRecord(event.message);
10455
10936
  const requestModelForMessage = msgRecord?.role === "assistant"
10456
- ? pendingProviderResponseModel
10937
+ ? (() => {
10938
+ const completedIndex = providerRequestStates.findIndex((state) => state.responseReceived);
10939
+ const index = completedIndex >= 0 ? completedIndex : 0;
10940
+ return providerRequestStates.splice(index, 1)[0]?.model;
10941
+ })()
10457
10942
  : undefined;
10458
- if (msgRecord?.role === "assistant") {
10459
- // If a transport failed before emitting after_provider_response, do not
10460
- // let its request snapshot be consumed by a later response. Normal
10461
- // responses already clear this in after_provider_response.
10462
- pendingProviderResponseModel = undefined;
10463
- }
10464
10943
 
10465
10944
  // Some providers expose an HTTP 400 error body only through the finalized
10466
10945
  // assistant error message; after_provider_response may contain the status
@@ -10482,6 +10961,24 @@ export default function (pi: ExtensionAPI) {
10482
10961
  );
10483
10962
  }
10484
10963
  }
10964
+ if (runtimeOptimizerEnabled && hasPromptCacheKeyUnsupportedErrorMessage(event.message)) {
10965
+ const fallbackModel = requestModelForMessage ?? (resolveRouteModel(ctx.model, ctx) ?? ctx.model);
10966
+ const messageModel = modelFromAssistantMessage(event.message, fallbackModel) ?? fallbackModel;
10967
+ const errorModel = messageModel
10968
+ ? findModelInRegistry(ctx.modelRegistry, messageModel.provider, messageModel.id) ?? messageModel
10969
+ : undefined;
10970
+ if (errorModel && isPromptCacheKeyUnsupportedApplicable(errorModel)) {
10971
+ const key = modelKey(errorModel);
10972
+ promptCacheKeyRejectedModels.add(key);
10973
+ if (!warnedPromptCacheKeyRejectedModels.has(key)) {
10974
+ warnedPromptCacheKeyRejectedModels.add(key);
10975
+ ctx.ui.notify(
10976
+ `⚠️ ${LOG_PREFIX}: ${key} rejected prompt_cache_key. Run /cache-optimizer fix to review a precise model-scoped repair. No configuration was changed automatically.`,
10977
+ "warning",
10978
+ );
10979
+ }
10980
+ }
10981
+ }
10485
10982
  if (runtimeOptimizerEnabled && hasPromptCacheRetentionUnsupportedErrorMessage(event.message)) {
10486
10983
  const fallbackModel = requestModelForMessage
10487
10984
  ?? (resolveRouteModel(ctx.model, ctx) ?? ctx.model);
@@ -10630,7 +11127,7 @@ export default function (pi: ExtensionAPI) {
10630
11127
  // stats — show detailed current-session models; `stats all` shows all local shards
10631
11128
  // compat — show compat suggestion with file path
10632
11129
  // config footer-mode total|session|process — persist footer mode override
10633
- // fix — auto-fix compat issues (writes models.json, requires UI)
11130
+ // fix — auto-fix compat issues (writes models.json or extension config, requires UI)
10634
11131
  // rollback — undo the latest confirmed fix for the active model
10635
11132
  // reset — reset current provider/model footer stats bucket (local only)
10636
11133
  // (no args) — interactive menu (with UI) or help summary
@@ -10653,7 +11150,7 @@ export default function (pi: ExtensionAPI) {
10653
11150
  cmdCtx.ui.notify(`✅ Pi Cache Optimizer enabled for this Pi process. Local footer stats were reset for before/after comparison.\n${formatOptimizerRuntimeMode()}`, "info");
10654
11151
  } else if (subcommand === "disable") {
10655
11152
  setRuntimeOptimizerEnabled(false);
10656
- pendingProviderResponseModel = undefined;
11153
+ providerRequestStates.length = 0;
10657
11154
  await resetCurrentSessionStats();
10658
11155
  await flushPersistCacheStats(cmdCtx);
10659
11156
  await publishStatus(cmdCtx, model);
@@ -10664,7 +11161,7 @@ export default function (pi: ExtensionAPI) {
10664
11161
  cmdCtx.ui.notify("No active model selected. Select a model first with /model or pi --model.", "warning");
10665
11162
  return;
10666
11163
  }
10667
- const diagnosis = buildDoctorDiagnosis(model, { promptCacheRetention400: promptCacheRetention400Models.has(modelKey(model)), anthropicTtlOrderError: anthropicTtlOrderErrorModels.has(modelKey(model)), sessionAffinity403: sendSessionAffinityHeaders403Models.has(modelKey(model)), openAISdkHeader403: openAISdkHeader403Models.has(modelKey(model)) });
11164
+ const diagnosis = buildDoctorDiagnosis(model, { promptCacheRetention400: promptCacheRetention400Models.has(modelKey(model)), promptCacheKey400: promptCacheKeyRejectedModels.has(modelKey(model)), anthropicTtlOrderError: anthropicTtlOrderErrorModels.has(modelKey(model)), sessionAffinity403: sendSessionAffinityHeaders403Models.has(modelKey(model)), openAISdkHeader403: openAISdkHeader403Models.has(modelKey(model)) });
10668
11165
  const adapter = selectAdapterForModel(model);
10669
11166
  const sk = model ? sessionModelKey(model) : undefined;
10670
11167
  const statsState = model ? cacheStatsTotalsByModel[modelKey(model)] : undefined;
@@ -10703,7 +11200,7 @@ export default function (pi: ExtensionAPI) {
10703
11200
  const nextMode = requestedMode as FooterStatsMode;
10704
11201
  try {
10705
11202
  await writePersistedFooterMode(nextMode);
10706
- persistedFooterStatsMode = nextMode;
11203
+ setPersistedCacheOptimizerConfig(readPersistedCacheOptimizerConfig());
10707
11204
  lastStatusText = undefined;
10708
11205
  await publishStatus(cmdCtx, model);
10709
11206
  const resolved = resolveFooterStatsMode(persistedFooterStatsMode);
@@ -10738,6 +11235,34 @@ export default function (pi: ExtensionAPI) {
10738
11235
  cmdCtx.ui.notify("No active model selected. Select a model first with /model or pi --model.", "warning");
10739
11236
  return;
10740
11237
  }
11238
+ const configReceipt = await readPromptCacheKeyConfigReceipt();
11239
+ const modelsReceipt = await readModelsJsonFixReceipt();
11240
+ const useConfigReceipt = isActionablePromptCacheKeyConfigReceipt(configReceipt) &&
11241
+ configReceipt.provider === model.provider &&
11242
+ configReceipt.modelId === model.id &&
11243
+ (!isActionableModelsJsonFixReceipt(modelsReceipt) || modelsReceipt.provider !== model.provider || modelsReceipt.modelId !== model.id || configReceipt.appliedAt >= modelsReceipt.appliedAt);
11244
+ if (useConfigReceipt) {
11245
+ if (!cmdCtx.hasUI) {
11246
+ cmdCtx.ui.notify("❌ Rollback requires interactive confirmation. No changes were made.\nRun /cache-optimizer rollback in Pi's interactive UI to restore the prompt-cache-key setting.", "warning");
11247
+ return;
11248
+ }
11249
+ const confirmed = await cmdCtx.ui.confirm(
11250
+ "Cache Optimizer — Rollback prompt_cache_key opt-out",
11251
+ `Model: ${modelKey(model)}\nAction: restore the extension config before the confirmed opt-out.\nFooter mode and unrelated configuration will be preserved.\nAfterward, run /reload or restart Pi.\n\nProceed with rollback?`,
11252
+ );
11253
+ if (!confirmed) {
11254
+ cmdCtx.ui.notify("No changes were made. Rollback canceled by user.", "info");
11255
+ return;
11256
+ }
11257
+ try {
11258
+ await rollbackPromptCacheKeyConfig(configReceipt);
11259
+ setPersistedCacheOptimizerConfig(readPersistedCacheOptimizerConfig());
11260
+ cmdCtx.ui.notify(`✅ Restored prompt_cache_key behavior for ${modelKey(model)}. Run /reload or restart Pi for the change to take effect.`, "info");
11261
+ } catch (error) {
11262
+ cmdCtx.ui.notify(`❌ Prompt cache key rollback refused: ${error instanceof Error ? error.message : String(error)}. No changes were made.`, "error");
11263
+ }
11264
+ return;
11265
+ }
10741
11266
  if (!cmdCtx.hasUI) {
10742
11267
  const receipt = await readModelsJsonFixReceipt();
10743
11268
  const backupHint = receipt && isActionableModelsJsonFixReceipt(receipt)
@@ -10868,7 +11393,58 @@ export default function (pi: ExtensionAPI) {
10868
11393
  return;
10869
11394
  }
10870
11395
 
10871
- const suggestion = buildCommandFixSuggestion(model);
11396
+ const promptCacheKeyRequested = commandParts[1] === "prompt-cache-key";
11397
+ const promptCacheKeyFixRequested = promptCacheKeyRequested || promptCacheKeyFixApplies(model);
11398
+ if (promptCacheKeyRequested && !isPromptCacheKeyUnsupportedApplicable(model)) {
11399
+ cmdCtx.ui.notify("ℹ️ Prompt cache key opt-out applies only to a known OpenAI-compatible provider/model endpoint. No changes were made.", "info");
11400
+ return;
11401
+ }
11402
+ const suggestion = promptCacheKeyFixRequested ? undefined : buildCommandFixSuggestion(model);
11403
+
11404
+ if (promptCacheKeyFixRequested) {
11405
+ if (isPromptCacheKeyOmittedForModel(model)) {
11406
+ cmdCtx.ui.notify(`✅ prompt_cache_key is already omitted for "${modelKey(model)}".`, "info");
11407
+ return;
11408
+ }
11409
+ if (!cmdCtx.hasUI) {
11410
+ cmdCtx.ui.notify(
11411
+ "❌ Non-interactive terminal detected. Prompt cache key opt-out requires UI confirmation. No changes were made.\n" +
11412
+ `Run /cache-optimizer fix prompt-cache-key in Pi's interactive UI for ${modelKey(model)}.`,
11413
+ "warning",
11414
+ );
11415
+ return;
11416
+ }
11417
+ const preview = [
11418
+ "📝 Preview of extension configuration change:",
11419
+ ...buildPromptCacheKeyConfigPreview(model),
11420
+ "",
11421
+ "⚠️ Risk notice:",
11422
+ " 1. This affects all sessions using this exact provider/model.",
11423
+ " 2. Provider prompt-cache reuse may decrease because both key spellings are removed.",
11424
+ " 3. This does not modify Pi's models.json, credentials, prompts, headers, or other models.",
11425
+ " 4. The extension config will be backed up and Pi must be reloaded/restarted.",
11426
+ "",
11427
+ "Apply this persistent opt-out?",
11428
+ ].join("\n");
11429
+ const confirmed = await cmdCtx.ui.confirm("Cache Optimizer — Omit prompt_cache_key", preview);
11430
+ if (!confirmed) {
11431
+ cmdCtx.ui.notify("No changes were made. Canceled by user.", "info");
11432
+ return;
11433
+ }
11434
+ try {
11435
+ const result = await applyPromptCacheKeyConfigFix(model);
11436
+ setPersistedCacheOptimizerConfig(readPersistedCacheOptimizerConfig());
11437
+ cmdCtx.ui.notify(
11438
+ `✅ Prompt cache key opt-out saved for ${modelKey(model)}.\n` +
11439
+ `Config backup saved to: ${result.backupPath}\n` +
11440
+ "Run /reload or restart Pi for the change to take effect. Use /cache-optimizer rollback to restore it.",
11441
+ "info",
11442
+ );
11443
+ } catch (error) {
11444
+ cmdCtx.ui.notify(`❌ Could not save prompt cache key opt-out: ${error instanceof Error ? error.message : String(error)}. No changes were made.`, "error");
11445
+ }
11446
+ return;
11447
+ }
10872
11448
 
10873
11449
  if (!suggestion) {
10874
11450
  const key = modelKey(model);
@@ -11301,7 +11877,8 @@ export default function (pi: ExtensionAPI) {
11301
11877
  "Doctor — Show cache configuration",
11302
11878
  "Stats — Show current-session model statistics",
11303
11879
  "Compat — Show compat suggestion",
11304
- "Fix — Auto-fix compat issues (writes models.json)",
11880
+ "Fix — Auto-fix compat issues (writes models.json or extension config)",
11881
+ "Disable prompt_cache_key — Omit it for the active model",
11305
11882
  "Rollback — Undo the latest confirmed fix",
11306
11883
  "Footer mode — Choose total, session, or process stats",
11307
11884
  "Reset — Reset local provider/model stats",
@@ -11321,8 +11898,10 @@ export default function (pi: ExtensionAPI) {
11321
11898
  } else if (choice === menuOptions[5]) {
11322
11899
  await handleCacheOptimizerCommand("fix", cmdCtx);
11323
11900
  } else if (choice === menuOptions[6]) {
11324
- await handleCacheOptimizerCommand("rollback", cmdCtx);
11901
+ await handleCacheOptimizerCommand("fix prompt-cache-key", cmdCtx);
11325
11902
  } else if (choice === menuOptions[7]) {
11903
+ await handleCacheOptimizerCommand("rollback", cmdCtx);
11904
+ } else if (choice === menuOptions[8]) {
11326
11905
  const modeOptions = ["session — Current Pi conversation session (default)", "total — All local sessions today", "process — Current extension instance only", "Cancel"];
11327
11906
  const modeChoice = await cmdCtx.ui.select("Footer cache stats mode", modeOptions);
11328
11907
  const nextMode = modeChoice === modeOptions[0]
@@ -11333,7 +11912,7 @@ export default function (pi: ExtensionAPI) {
11333
11912
  ? "process"
11334
11913
  : undefined;
11335
11914
  if (nextMode) await handleCacheOptimizerCommand(`config footer-mode ${nextMode}`, cmdCtx);
11336
- } else if (choice === menuOptions[8]) {
11915
+ } else if (choice === menuOptions[9]) {
11337
11916
  await handleCacheOptimizerCommand("reset", cmdCtx);
11338
11917
  }
11339
11918
  // choice === "cancel" or undefined → no action
@@ -11351,7 +11930,8 @@ export default function (pi: ExtensionAPI) {
11351
11930
  diagnosis.push(" stats contributors — Show per-session contributors for the active model");
11352
11931
  diagnosis.push(" compat — Show compat suggestion with edit location");
11353
11932
  diagnosis.push(" config footer-mode total|session|process — Persist the footer stats mode");
11354
- diagnosis.push(" fix — Auto-fix compat issues (writes models.json, requires UI)");
11933
+ diagnosis.push(" fix — Auto-fix compat issues (writes models.json or extension config, requires UI)");
11934
+ diagnosis.push(" fix prompt-cache-key — Explicitly omit prompt_cache_key for the active model");
11355
11935
  diagnosis.push(" rollback — Undo the latest confirmed fix (requires UI confirmation)");
11356
11936
  diagnosis.push(" reset — Reset local provider/model stats for current model (does not affect upstream)");
11357
11937
  diagnosis.push("");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-cache-optimizer",
3
- "version": "2.8.7",
3
+ "version": "2.8.8",
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",
@@ -35,7 +35,8 @@
35
35
  "check": "npm run typecheck && npm test && npm run check:diff && npm run check:pack"
36
36
  },
37
37
  "devDependencies": {
38
- "@earendil-works/pi-coding-agent": "^0.84.4",
38
+ "@earendil-works/pi-coding-agent": "^0.85.0",
39
+ "@earendil-works/pi-server": "0.85.0",
39
40
  "@types/node": "^22.0.0",
40
41
  "jiti": "^2.7.0",
41
42
  "typescript": "^5.8.3"