pi-cache-optimizer 2.8.7 → 2.8.9

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.1. 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,11 +132,23 @@ 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.
136
150
 
137
- Pi 0.84.1 also fixes built-in Fireworks compatibility for models that reject `prompt_cache_retention`; the extension avoids provider-name special cases and resolves exact provider/model compat from `models.json` plus the runtime model. Pi 0.81+ also has a built-in `llama.cpp` provider using an OpenAI-shaped transport. Pi 0.82+ core generates a session `prompt_cache_key` for it when cache retention is enabled, so this extension preserves that key and may add the same conservative fallback when missing. The built-in provider's explicit compat fingerprint is excluded from generic proxy routing/session-affinity advice, but a custom or overridden provider that merely reuses the id `llama.cpp` is treated like any other OpenAI-compatible channel. `prompt_cache_retention` remains subject to the normal safety rule: keep it only for official OpenAI or an explicit effective `supportsLongCacheRetention: true` opt-in in `models.json`; otherwise strip it before sending. Effective values follow Pi's precedence: `modelOverrides[modelId].compat` first, then the matching `models[].compat`, then provider-level `compat`. An explicit `false` at a higher layer overrides `true` below it.
151
+ Pi 0.84.1 also fixes built-in Fireworks compatibility for models that reject `prompt_cache_retention`; the extension avoids provider-name special cases and resolves exact provider/model compat from `models.json` plus the runtime model. Pi 0.81+ also has a built-in `llama.cpp` provider using an OpenAI-shaped transport. Pi 0.82+ core generates a session `prompt_cache_key` for it when cache retention is enabled, so this extension preserves that key and may add the same conservative fallback when missing. The built-in provider's explicit compat fingerprint is excluded from generic proxy routing/session-affinity advice, but a custom or overridden provider that merely reuses the id `llama.cpp` is treated like any other OpenAI-compatible channel. `prompt_cache_retention` remains subject to the normal safety rule: keep it only for official OpenAI or an explicit effective `supportsLongCacheRetention: true` opt-in in `models.json`; otherwise strip it before sending. Pi 0.85.1 has no native `supportsPromptCacheKey` compat field, so the per-model key opt-out is stored in this extension's `pi-cache-optimizer-config.json` instead of `models.json`. The extension config is independent of Pi's compat precedence and only affects the exact provider/model listed there.
138
152
 
139
153
  For real proxies, start with session affinity:
140
154
 
@@ -160,6 +174,7 @@ Notes:
160
174
 
161
175
  - `sendSessionAffinityHeaders: true` is the safe default when your proxy supports sticky routing.
162
176
  - `supportsLongCacheRetention: true` is optional. Add it only when the endpoint explicitly supports OpenAI long prompt cache retention.
177
+ - Do not add `supportsPromptCacheKey` to `models.json`: Pi 0.85.1 does not define that compat field. Use `/cache-optimizer fix prompt-cache-key` to store an exact provider/model omit rule in the extension-owned config; it removes both key spellings, including a key supplied by Pi.
163
178
  - If you see `400 Unsupported parameter: prompt_cache_retention`, remove/avoid `supportsLongCacheRetention` for that channel. Keep `sendSessionAffinityHeaders` if supported. The extension detects the explicit error from response headers or the finalized assistant error message and strips the parameter from subsequent requests in the current process.
164
179
  - Use `/cache-optimizer compat` or `/cache-optimizer doctor` to see model-specific advice.
165
180
  - DeepSeek model names select the `DS cache` adapter only; they do not prove a reasoning wire protocol. Generic cache/routing advice remains active for absent or non-DeepSeek formats. DeepSeek replay advice is shown only when effective `compat.thinkingFormat: "deepseek"` is explicitly configured; it never treats `thinkingFormat` as a missing fix key.
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.1 验证。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,11 +132,23 @@ 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 被拆散。
136
150
 
137
- Pi 0.84.1 还修复了内置 Fireworks 渠道对拒绝 `prompt_cache_retention` 的模型兼容性;本扩展不按 provider 名称增加特殊分支,而是结合 `models.json` 与 runtime model,按精确 provider/model 解析有效 compat。Pi 0.81+ 也内置了使用 OpenAI-shaped transport 的 `llama.cpp` provider。Pi 0.82+ core 在启用 cache retention 时会为它生成 session `prompt_cache_key`,因此本扩展会保留该 key,并在缺失时使用同样的保守 fallback。只有符合 Pi 内置 provider 明确 compat 指纹的模型会跳过通用 proxy 路由 / session-affinity 建议;仅复用 `llama.cpp` id 的自定义或覆盖 provider 仍按普通 OpenAI-compatible 渠道处理。`prompt_cache_retention` 继续遵循统一安全规则:仅官方 OpenAI 或 `models.json` 中有效配置为 `supportsLongCacheRetention: true` 时保留,否则发送前移除。有效值遵循 Pi 的优先级:先看 `modelOverrides[modelId].compat`,再看匹配的 `models[].compat`,最后看 provider 级 `compat`;高层显式 `false` 会覆盖低层的 `true`。
151
+ Pi 0.84.1 还修复了内置 Fireworks 渠道对拒绝 `prompt_cache_retention` 的模型兼容性;本扩展不按 provider 名称增加特殊分支,而是结合 `models.json` 与 runtime model,按精确 provider/model 解析有效 compat。Pi 0.81+ 也内置了使用 OpenAI-shaped transport 的 `llama.cpp` provider。Pi 0.82+ core 在启用 cache retention 时会为它生成 session `prompt_cache_key`,因此本扩展会保留该 key,并在缺失时使用同样的保守 fallback。只有符合 Pi 内置 provider 明确 compat 指纹的模型会跳过通用 proxy 路由 / session-affinity 建议;仅复用 `llama.cpp` id 的自定义或覆盖 provider 仍按普通 OpenAI-compatible 渠道处理。`prompt_cache_retention` 继续遵循统一安全规则:仅官方 OpenAI 或 `models.json` 中有效配置为 `supportsLongCacheRetention: true` 时保留,否则发送前移除。Pi 0.85.1 没有原生的 `supportsPromptCacheKey` compat 字段,因此按模型关闭 key 的策略保存在本扩展的 `pi-cache-optimizer-config.json` 中,而不是 `models.json`。扩展配置独立于 Pi 的 compat 优先级,只影响其中列出的精确 provider/model。
138
152
 
139
153
  对真正的代理,建议先启用 session affinity:
140
154
 
@@ -160,6 +174,7 @@ Pi 0.84.1 还修复了内置 Fireworks 渠道对拒绝 `prompt_cache_retention`
160
174
 
161
175
  - `sendSessionAffinityHeaders: true` 是安全默认项,前提是你的代理支持 sticky routing。
162
176
  - `supportsLongCacheRetention: true` 是可选项。只有 endpoint 明确支持 OpenAI long prompt cache retention 时才添加。
177
+ - 不要把 `supportsPromptCacheKey` 加入 `models.json`:Pi 0.85.1 没有定义这个 compat 字段。请使用 `/cache-optimizer fix prompt-cache-key` 在扩展自有配置中保存精确 provider/model 的 omit 规则;它会移除两种 key 写法,包括 Pi 提供的 key。
163
178
  - 如果出现 `400 Unsupported parameter: prompt_cache_retention`,请为该渠道移除 / 避免 `supportsLongCacheRetention`;如支持,可保留 `sendSessionAffinityHeaders`。扩展会从响应头或最终 assistant error message 中识别这条明确错误,并在当前进程的后续请求中移除该参数。
164
179
  - 使用 `/cache-optimizer compat` 或 `/cache-optimizer doctor` 查看当前模型的具体建议。
165
180
  - DeepSeek 模型名只用于选择 `DS cache` adapter,不能证明 reasoning wire protocol。缺少或使用非 DeepSeek format 时仍保留通用缓存 / 路由建议;只有 effective `compat.thinkingFormat: "deepseek"` 被明确配置时,才显示 DeepSeek replay 建议,且不会把 `thinkingFormat` 列为缺失修复项。
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");
@@ -1655,7 +1682,9 @@ function isValidAnthropicMessagesCompat(compat: UnknownRecord): boolean {
1655
1682
  function isValidCompatRecord(value: unknown): boolean {
1656
1683
  if (value === undefined) return true;
1657
1684
  const compat = asRecord(value);
1658
- return !!compat && (
1685
+ return !!compat
1686
+ && !Object.prototype.hasOwnProperty.call(compat, "supportsPromptCacheKey")
1687
+ && (
1659
1688
  isValidOpenAICompletionsCompat(compat)
1660
1689
  || isValidOpenAIResponsesCompat(compat)
1661
1690
  || isValidAnthropicMessagesCompat(compat)
@@ -1867,6 +1896,7 @@ const CACHE_OPTIMIZER_COMMANDS = [
1867
1896
  const CACHE_OPTIMIZER_CONFIG_ARGUMENTS = ["footer-mode"] as const;
1868
1897
  const CACHE_OPTIMIZER_FOOTER_MODES = ["total", "session", "process"] as const;
1869
1898
  const CACHE_OPTIMIZER_STATS_ARGUMENTS = ["all", "contributors"] as const;
1899
+ const CACHE_OPTIMIZER_FIX_ARGUMENTS = ["prompt-cache-key"] as const;
1870
1900
 
1871
1901
  function filterCommandCompletionItems(
1872
1902
  values: readonly string[],
@@ -1917,6 +1947,12 @@ function getCacheOptimizerArgumentCompletions(argumentPrefix: string): CommandCo
1917
1947
  : null;
1918
1948
  }
1919
1949
 
1950
+ if (parts[0].toLowerCase() === "fix") {
1951
+ return parts.length === 2
1952
+ ? filterCommandCompletionItems(CACHE_OPTIMIZER_FIX_ARGUMENTS, parts[1], "fix")
1953
+ : null;
1954
+ }
1955
+
1920
1956
  if (parts[0].toLowerCase() !== "config") return null;
1921
1957
 
1922
1958
  if (parts.length === 2) {
@@ -1934,36 +1970,368 @@ function getCacheOptimizerArgumentCompletions(argumentPrefix: string): CommandCo
1934
1970
  return null;
1935
1971
  }
1936
1972
 
1937
- function parsePersistedCacheOptimizerConfig(value: unknown): PersistedCacheOptimizerConfigV1 | undefined {
1973
+ function parsePersistedCacheOptimizerConfig(value: unknown): PersistedCacheOptimizerConfig | undefined {
1938
1974
  const record = asRecord(value);
1939
- if (!record || record.version !== 1) return undefined;
1975
+ if (!record || (record.version !== 1 && record.version !== 2)) return undefined;
1976
+ const allowedTopLevel = new Set(record.version === 1 ? ["version", "footerMode"] : ["version", "footerMode", "promptCacheKey"]);
1977
+ if (Object.keys(record).some((key) => !allowedTopLevel.has(key))) return undefined;
1940
1978
  const footerMode = parseFooterStatsMode(record.footerMode);
1941
1979
  if (record.footerMode !== undefined && !footerMode) return undefined;
1942
- return { version: 1, ...(footerMode ? { footerMode } : {}) };
1980
+ if (record.version === 1) return { version: 1, ...(footerMode ? { footerMode } : {}) };
1981
+
1982
+ const rawPromptCacheKey = record.promptCacheKey;
1983
+ if (rawPromptCacheKey !== undefined && !asRecord(rawPromptCacheKey)) return undefined;
1984
+ const promptCacheKey = asRecord(rawPromptCacheKey);
1985
+ if (promptCacheKey && Object.keys(promptCacheKey).some((key) => key !== "omit")) return undefined;
1986
+ const omit = promptCacheKey?.omit;
1987
+ if (omit !== undefined && (!Array.isArray(omit) || omit.some((value): value is string => !isNonEmptyString(value)))) return undefined;
1988
+ const stringOmit = omit as string[] | undefined;
1989
+ const uniqueOmit = stringOmit ? [...new Set(stringOmit.map((value) => value.trim()))].sort() : undefined;
1990
+ return {
1991
+ version: 2,
1992
+ ...(footerMode ? { footerMode } : {}),
1993
+ ...(uniqueOmit && uniqueOmit.length > 0 ? { promptCacheKey: { omit: uniqueOmit } } : {}),
1994
+ };
1995
+ }
1996
+
1997
+ function normalizePersistedCacheOptimizerConfig(value: PersistedCacheOptimizerConfig | undefined): PersistedCacheOptimizerConfigV2 {
1998
+ if (!value) return { version: 2 };
1999
+ return value.version === 2
2000
+ ? value
2001
+ : { version: 2, ...(value.footerMode ? { footerMode: value.footerMode } : {}) };
2002
+ }
2003
+
2004
+ function readPersistedCacheOptimizerConfig(configPath: string = CONFIG_FILE_PATH): PersistedCacheOptimizerConfigV2 {
2005
+ try {
2006
+ return normalizePersistedCacheOptimizerConfig(parsePersistedCacheOptimizerConfig(JSON.parse(readFileSync(configPath, "utf8"))));
2007
+ } catch (error) {
2008
+ if (getErrorCode(error) !== "ENOENT") console.warn(`${LOG_PREFIX}: failed to read optimizer config; using defaults`, error);
2009
+ return { version: 2 };
2010
+ }
1943
2011
  }
1944
2012
 
1945
2013
  function readPersistedFooterMode(configPath: string = CONFIG_FILE_PATH): FooterStatsMode | undefined {
2014
+ return readPersistedCacheOptimizerConfig(configPath).footerMode;
2015
+ }
2016
+
2017
+ async function writePersistedCacheOptimizerConfigUnlocked(
2018
+ config: PersistedCacheOptimizerConfigV2,
2019
+ configPath: string,
2020
+ ): Promise<void> {
2021
+ await mkdir(dirname(configPath), { recursive: true });
2022
+ const payloadText = JSON.stringify(normalizePersistedCacheOptimizerConfig(config), null, 2) + "\n";
2023
+ let targetInfo: Awaited<ReturnType<typeof lstat>> | undefined;
2024
+ let targetMode = 0o600;
2025
+ let targetHash: string | undefined;
1946
2026
  try {
1947
- const parsed = parsePersistedCacheOptimizerConfig(JSON.parse(readFileSync(configPath, "utf8")));
1948
- if (!parsed) throw new Error("invalid footer config schema");
1949
- return parsed.footerMode;
2027
+ targetInfo = await lstat(configPath);
2028
+ if (targetInfo.isSymbolicLink() || !targetInfo.isFile()) throw new Error("optimizer config is not a regular file; no changes were made");
2029
+ const targetText = await readFile(configPath, "utf8");
2030
+ targetMode = targetInfo.mode & 0o7777;
2031
+ targetHash = hashText(targetText);
1950
2032
  } catch (error) {
1951
- if (getErrorCode(error) !== "ENOENT") {
1952
- console.warn(`${LOG_PREFIX}: failed to read footer config; using environment/default mode`, error);
2033
+ if (getErrorCode(error) !== "ENOENT") throw error;
2034
+ }
2035
+ if (targetInfo) {
2036
+ await atomicReplaceTextFilePreservingMode(configPath, payloadText, targetMode, "config", {
2037
+ identity: targetInfo,
2038
+ hash: targetHash,
2039
+ mode: targetMode,
2040
+ });
2041
+ return;
2042
+ }
2043
+
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;
1953
2053
  }
1954
- return undefined;
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
+ }
2059
+ }
2060
+
2061
+ async function writePersistedCacheOptimizerConfig(
2062
+ config: PersistedCacheOptimizerConfigV2,
2063
+ configPath: string = CONFIG_FILE_PATH,
2064
+ ): Promise<void> {
2065
+ await withModelsJsonTransactionLock(() => writePersistedCacheOptimizerConfigUnlocked(config, configPath));
2066
+ }
2067
+
2068
+ async function writePersistedFooterModeUnlocked(
2069
+ mode: FooterStatsMode,
2070
+ configPath: string,
2071
+ ): Promise<void> {
2072
+ let version: 1 | 2 = 1;
2073
+ let raw: PersistedCacheOptimizerConfig | undefined;
2074
+ let targetExists = false;
2075
+ try {
2076
+ raw = parsePersistedCacheOptimizerConfig(JSON.parse(readFileSync(configPath, "utf8")));
2077
+ targetExists = true;
2078
+ if (!raw) throw new Error("invalid footer config schema");
2079
+ version = raw.version === 2 ? 2 : 1;
2080
+ } catch (error) {
2081
+ if (getErrorCode(error) !== "ENOENT") throw new Error("invalid footer config schema");
2082
+ }
2083
+ const current = normalizePersistedCacheOptimizerConfig(raw);
2084
+ if (version === 1 && !current.promptCacheKey) {
2085
+ await mkdir(dirname(configPath), { recursive: true });
2086
+ const targetInfo = targetExists ? await lstat(configPath) : undefined;
2087
+ if (targetInfo && (targetInfo.isSymbolicLink() || !targetInfo.isFile())) throw new Error("optimizer config is not a regular file; no changes were made");
2088
+ const targetText = targetInfo ? await readFile(configPath, "utf8") : undefined;
2089
+ 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;
2108
+ }
2109
+ return;
1955
2110
  }
2111
+ await writePersistedCacheOptimizerConfigUnlocked({ ...current, version: 2, footerMode: mode }, configPath);
1956
2112
  }
1957
2113
 
1958
2114
  async function writePersistedFooterMode(
1959
2115
  mode: FooterStatsMode,
1960
2116
  configPath: string = CONFIG_FILE_PATH,
1961
2117
  ): Promise<void> {
2118
+ await withModelsJsonTransactionLock(() => writePersistedFooterModeUnlocked(mode, configPath));
2119
+ }
2120
+
2121
+ function configReceiptBackupPath(receipt: PromptCacheKeyConfigReceipt, receiptPath: string = CONFIG_RECEIPT_PATH): string {
2122
+ return join(dirname(receiptPath), receipt.backupFile);
2123
+ }
2124
+
2125
+ function parsePromptCacheKeyConfigReceipt(value: unknown): PromptCacheKeyConfigReceipt | undefined {
2126
+ const record = asRecord(value);
2127
+ if (!record || (record.version !== 1 && record.version !== 2) || record.kind !== "pi-cache-optimizer-config-receipt") return undefined;
2128
+ const allowed = new Set(record.version === 1
2129
+ ? ["version", "kind", "transactionId", "provider", "modelId", "beforeHash", "afterHash", "backupFile", "targetExistedBefore", "createdAt", "appliedAt", "status", "rolledBackAt"]
2130
+ : ["version", "kind", "transactionId", "provider", "modelId", "beforeHash", "afterHash", "backupFile", "targetExistedBefore", "targetHadModelKey", "addedModelKey", "createdAt", "appliedAt", "status", "rolledBackAt"]);
2131
+ if (Object.keys(record).some((key) => !allowed.has(key))) return undefined;
2132
+ if (![record.transactionId, record.provider, record.modelId].every(isSafeReceiptText)) return undefined;
2133
+ if (!isSha256(record.beforeHash) || !isSha256(record.afterHash) || record.beforeHash === record.afterHash) return undefined;
2134
+ 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;
2136
+ const addedModelKey = `${record.provider}/${record.modelId}`;
2137
+ const targetHadModelKey = record.version === 1 ? false : record.targetHadModelKey;
2138
+ if (typeof targetHadModelKey !== "boolean") return undefined;
2139
+ if (record.version === 2 && (!isSafeReceiptText(record.addedModelKey) || record.addedModelKey !== addedModelKey)) return undefined;
2140
+ if (record.status !== undefined && record.status !== "rolled_back") return undefined;
2141
+ if (record.status === "rolled_back" && (!isReceiptTimestamp(record.rolledBackAt) || record.rolledBackAt < record.appliedAt)) return undefined;
2142
+ if (record.status === undefined && record.rolledBackAt !== undefined) return undefined;
2143
+ return {
2144
+ version: 2,
2145
+ kind: "pi-cache-optimizer-config-receipt",
2146
+ transactionId: String(record.transactionId),
2147
+ provider: String(record.provider),
2148
+ modelId: String(record.modelId),
2149
+ beforeHash: String(record.beforeHash).toLowerCase(),
2150
+ afterHash: String(record.afterHash).toLowerCase(),
2151
+ backupFile: String(record.backupFile),
2152
+ targetExistedBefore: record.targetExistedBefore,
2153
+ targetHadModelKey,
2154
+ addedModelKey,
2155
+ createdAt: Number(record.createdAt),
2156
+ appliedAt: Number(record.appliedAt),
2157
+ ...(record.status === "rolled_back" ? { status: "rolled_back", rolledBackAt: Number(record.rolledBackAt) } : {}),
2158
+ };
2159
+ }
2160
+
2161
+ function isActionablePromptCacheKeyConfigReceipt(receipt: PromptCacheKeyConfigReceipt | undefined): receipt is PromptCacheKeyConfigReceipt {
2162
+ return receipt !== undefined && receipt.status === undefined;
2163
+ }
2164
+
2165
+ async function readPromptCacheKeyConfigReceipt(receiptPath: string = CONFIG_RECEIPT_PATH): Promise<PromptCacheKeyConfigReceipt | undefined> {
2166
+ try {
2167
+ const info = await lstat(receiptPath);
2168
+ if (info.isSymbolicLink() || !info.isFile()) return undefined;
2169
+ const receipt = parsePromptCacheKeyConfigReceipt(JSON.parse(await readFile(receiptPath, "utf8")));
2170
+ return receipt;
2171
+ } catch {
2172
+ return undefined;
2173
+ }
2174
+ }
2175
+
2176
+ async function writePromptCacheKeyConfigReceipt(receipt: PromptCacheKeyConfigReceipt, receiptPath: string = CONFIG_RECEIPT_PATH): Promise<void> {
2177
+ if (!parsePromptCacheKeyConfigReceipt(receipt)) throw new Error("invalid prompt cache key config receipt");
2178
+ await mkdir(dirname(receiptPath), { recursive: true });
2179
+ const tempPath = uniqueTempPath(receiptPath, "config-receipt");
2180
+ try {
2181
+ await writeFile(tempPath, JSON.stringify(receipt, null, 2) + "\n", { encoding: "utf8", mode: 0o600, flag: "wx" });
2182
+ await chmod(tempPath, 0o600);
2183
+ await rename(tempPath, receiptPath);
2184
+ } catch (error) {
2185
+ await unlink(tempPath).catch((cleanupError) => { if (getErrorCode(cleanupError) !== "ENOENT") console.warn(`${LOG_PREFIX}: failed to remove temporary config receipt`, cleanupError); });
2186
+ throw error;
2187
+ }
2188
+ }
2189
+
2190
+ async function applyPromptCacheKeyConfigFixUnderLock(
2191
+ model: PiModel,
2192
+ configPath: string = CONFIG_FILE_PATH,
2193
+ receiptPath: string = CONFIG_RECEIPT_PATH,
2194
+ ): Promise<{ receipt: PromptCacheKeyConfigReceipt; backupPath: string }> {
1962
2195
  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);
2196
+ let originalText = "";
2197
+ let targetExistedBefore = false;
2198
+ let mode = 0o600;
2199
+ try {
2200
+ const info = await lstat(configPath);
2201
+ if (info.isSymbolicLink() || !info.isFile()) throw new Error("optimizer config is not a regular file; no changes were made");
2202
+ originalText = await readFile(configPath, "utf8");
2203
+ targetExistedBefore = true;
2204
+ mode = info.mode & 0o7777;
2205
+ } catch (error) {
2206
+ if (getErrorCode(error) !== "ENOENT") throw error;
2207
+ }
2208
+ const parsedCurrent = targetExistedBefore ? parsePersistedCacheOptimizerConfig(JSON.parse(originalText)) : undefined;
2209
+ if (targetExistedBefore && !parsedCurrent) throw new Error("optimizer config is invalid; no changes were made");
2210
+ const current = normalizePersistedCacheOptimizerConfig(parsedCurrent);
2211
+ const key = modelKey(model);
2212
+ const existingOmit = current.promptCacheKey?.omit ?? [];
2213
+ const targetHadModelKey = existingOmit.includes(key);
2214
+ const omit = [...new Set([...existingOmit, key])].sort();
2215
+ const next: PersistedCacheOptimizerConfigV2 = { ...current, version: 2, promptCacheKey: { omit } };
2216
+ const modifiedText = JSON.stringify(next, null, 2) + "\n";
2217
+ const beforeHash = hashText(originalText);
2218
+ const afterHash = hashText(modifiedText);
2219
+ const originalInfo = targetExistedBefore ? await lstat(configPath) : undefined;
2220
+ const backupFile = `pi-cache-optimizer-config.backup-${backupTimestamp()}`;
2221
+ const backupPath = join(dirname(configPath), backupFile);
2222
+ if (targetExistedBefore) {
2223
+ await copyFile(configPath, backupPath, fsConstants.COPYFILE_EXCL);
2224
+ await chmod(backupPath, mode);
2225
+ }
2226
+ const tempPath = uniqueTempPath(configPath, "config-fix");
2227
+ let committedInfo: Awaited<ReturnType<typeof lstat>> | undefined;
2228
+ try {
2229
+ await writeFile(tempPath, modifiedText, { encoding: "utf8", mode, flag: "wx" });
2230
+ await chmod(tempPath, mode);
2231
+ if (originalInfo) {
2232
+ await validateAtomicTarget(configPath, { identity: originalInfo, hash: beforeHash, mode });
2233
+ } else {
2234
+ try {
2235
+ await lstat(configPath);
2236
+ throw new Error("optimizer config appeared during the fix; no changes were made");
2237
+ } catch (error) {
2238
+ if (getErrorCode(error) !== "ENOENT") throw error;
2239
+ }
2240
+ }
2241
+ await rename(tempPath, configPath);
2242
+ committedInfo = await lstat(configPath);
2243
+ const receipt: PromptCacheKeyConfigReceipt = {
2244
+ version: 2,
2245
+ kind: "pi-cache-optimizer-config-receipt",
2246
+ transactionId: randomUUID(),
2247
+ provider: model.provider,
2248
+ modelId: model.id,
2249
+ beforeHash,
2250
+ afterHash,
2251
+ backupFile,
2252
+ targetExistedBefore,
2253
+ targetHadModelKey,
2254
+ addedModelKey: key,
2255
+ createdAt: Date.now(),
2256
+ appliedAt: Date.now(),
2257
+ };
2258
+ await writePromptCacheKeyConfigReceipt(receipt, receiptPath);
2259
+ return { receipt, backupPath };
2260
+ } catch (error) {
2261
+ 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(() => {});
2266
+ }
2267
+ throw error;
2268
+ }
2269
+ }
2270
+
2271
+ async function applyPromptCacheKeyConfigFix(
2272
+ model: PiModel,
2273
+ configPath: string = CONFIG_FILE_PATH,
2274
+ receiptPath: string = CONFIG_RECEIPT_PATH,
2275
+ ): Promise<{ receipt: PromptCacheKeyConfigReceipt; backupPath: string }> {
2276
+ return withModelsJsonTransactionLock(() => applyPromptCacheKeyConfigFixUnderLock(model, configPath, receiptPath));
2277
+ }
2278
+
2279
+ async function rollbackPromptCacheKeyConfigUnderLock(
2280
+ receipt: PromptCacheKeyConfigReceipt,
2281
+ configPath: string = CONFIG_FILE_PATH,
2282
+ receiptPath: string = CONFIG_RECEIPT_PATH,
2283
+ ): Promise<void> {
2284
+ const currentInfo = await lstat(configPath);
2285
+ if (currentInfo.isSymbolicLink() || !currentInfo.isFile()) throw new Error("optimizer config is not a regular file; refusing to overwrite user changes");
2286
+ 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");
2288
+ const current = parsePersistedCacheOptimizerConfig(JSON.parse(currentText));
2289
+ if (!current) throw new Error("optimizer config is invalid; refusing to overwrite user changes");
2290
+ const key = `${receipt.provider}/${receipt.modelId}`;
2291
+ if (receipt.addedModelKey !== key) throw new Error("prompt-cache-key receipt identity does not match; refusing to change user config");
2292
+ if (receipt.targetHadModelKey) throw new Error("prompt-cache-key was already configured before this fix; refusing to remove user configuration");
2293
+ const omit = current.version === 2 ? current.promptCacheKey?.omit ?? [] : [];
2294
+ if (!omit.includes(receipt.addedModelKey)) throw new Error("prompt-cache-key opt-out is no longer present; refusing to change user config");
2295
+ const backupPath = configReceiptBackupPath(receipt, receiptPath);
2296
+ if (receipt.targetExistedBefore) {
2297
+ const backupInfo = await lstat(backupPath);
2298
+ if (backupInfo.isSymbolicLink() || !backupInfo.isFile()) throw new Error("config backup is not a regular file");
2299
+ const backupText = await readFile(backupPath, "utf8");
2300
+ if (hashText(backupText) !== receipt.beforeHash) throw new Error("config backup hash does not match the fix receipt");
2301
+ await atomicRestoreFileFromBackup(
2302
+ backupPath,
2303
+ configPath,
2304
+ backupInfo.mode & 0o7777,
2305
+ { backupHash: receipt.beforeHash, identity: currentInfo, hash: hashText(currentText), mode: currentInfo.mode & 0o7777 },
2306
+ );
2307
+ } 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);
2324
+ }
2325
+ }
2326
+ await writePromptCacheKeyConfigReceipt({ ...receipt, status: "rolled_back", rolledBackAt: Date.now() }, receiptPath);
2327
+ }
2328
+
2329
+ async function rollbackPromptCacheKeyConfig(
2330
+ receipt: PromptCacheKeyConfigReceipt,
2331
+ configPath: string = CONFIG_FILE_PATH,
2332
+ receiptPath: string = CONFIG_RECEIPT_PATH,
2333
+ ): Promise<void> {
2334
+ return withModelsJsonTransactionLock(() => rollbackPromptCacheKeyConfigUnderLock(receipt, configPath, receiptPath));
1967
2335
  }
1968
2336
 
1969
2337
  function resolveFooterStatsMode(
@@ -1982,7 +2350,8 @@ function footerStatsMode(
1982
2350
  return resolveFooterStatsMode(configuredMode, env).mode;
1983
2351
  }
1984
2352
 
1985
- let persistedFooterStatsMode = readPersistedFooterMode();
2353
+ let persistedCacheOptimizerConfig = readPersistedCacheOptimizerConfig();
2354
+ let persistedFooterStatsMode = persistedCacheOptimizerConfig.footerMode;
1986
2355
 
1987
2356
  function isDisabledEnv(value: string | undefined): boolean {
1988
2357
  if (!value) return false;
@@ -2101,9 +2470,31 @@ function isPiBuiltInLlamaCppModel(model: PiModel | undefined): boolean {
2101
2470
  }
2102
2471
 
2103
2472
  function shouldInjectOpenAIPromptCacheKeyForModel(model: PiModel | undefined): boolean {
2473
+ // Pi 0.85.1 has no native supportsPromptCacheKey compat field. Per-model
2474
+ // opt-out is owned by this extension's promptCacheKey.omit configuration;
2475
+ // this helper only exposes the transport API gate for fixture consumers.
2104
2476
  return isOpenAICompatibleApi(model?.api);
2105
2477
  }
2106
2478
 
2479
+ function isPromptCacheKeyOmittedForModel(model: PiModel | undefined, config: PersistedCacheOptimizerConfigV2 = persistedCacheOptimizerConfig): boolean {
2480
+ if (!model || !isOpenAICompatibleApi(model.api)) return false;
2481
+ return config.promptCacheKey?.omit?.includes(modelKey(model)) === true;
2482
+ }
2483
+
2484
+ function setPersistedCacheOptimizerConfig(config: PersistedCacheOptimizerConfigV2): void {
2485
+ persistedCacheOptimizerConfig = normalizePersistedCacheOptimizerConfig(config);
2486
+ persistedFooterStatsMode = persistedCacheOptimizerConfig.footerMode;
2487
+ }
2488
+
2489
+ function omitOpenAIPromptCacheKeys(payload: unknown): unknown | undefined {
2490
+ const record = asRecord(payload);
2491
+ if (!record || (!Object.prototype.hasOwnProperty.call(record, "prompt_cache_key") && !Object.prototype.hasOwnProperty.call(record, "promptCacheKey"))) return undefined;
2492
+ const copy = { ...record };
2493
+ delete copy.prompt_cache_key;
2494
+ delete copy.promptCacheKey;
2495
+ return copy;
2496
+ }
2497
+
2107
2498
  function collectAnthropicCacheControlsInWireOrder(payload: unknown): UnknownRecord[] {
2108
2499
  const record = asRecord(payload);
2109
2500
  if (!record) return [];
@@ -3287,6 +3678,41 @@ function hasPromptCacheRetentionUnsupportedErrorMessage(message: unknown): boole
3287
3678
  hasPromptCacheRetentionUnsupportedText(record.errorMessage);
3288
3679
  }
3289
3680
 
3681
+ 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)";
3684
+ if (!new RegExp(key).test(normalized)) return false;
3685
+
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));
3698
+ }
3699
+
3700
+ function hasPromptCacheKeyUnsupportedSignal(headers: Record<string, string> | undefined): boolean {
3701
+ if (!headers) return false;
3702
+ return Object.entries(headers).some(([key, value]) => hasPromptCacheKeyUnsupportedText(`${key}: ${value}`));
3703
+ }
3704
+
3705
+ function hasPromptCacheKeyUnsupportedErrorMessage(message: unknown): boolean {
3706
+ const record = getAssistantRecord(message);
3707
+ return record?.stopReason === "error" &&
3708
+ getOptionalAssistantHttpStatus(record) === 400 &&
3709
+ hasPromptCacheKeyUnsupportedText(record.errorMessage);
3710
+ }
3711
+
3712
+ function isPromptCacheKeyUnsupportedApplicable(model: PiModel): boolean {
3713
+ return isOpenAICompatibleApi(model.api);
3714
+ }
3715
+
3290
3716
  function hasReasoningProtocolRejectionText(value: unknown): boolean {
3291
3717
  const normalized = lower(value).replace(/\s+/g, " ").trim();
3292
3718
  if (!normalized) return false;
@@ -6107,7 +6533,7 @@ function getCompatCheckNotApplicableLines(model: PiModel): string[] {
6107
6533
  return ["ℹ️ Compat check not applicable for this model."];
6108
6534
  }
6109
6535
 
6110
- function buildDoctorDiagnosis(model: PiModel, options: { promptCacheRetention400?: boolean; anthropicTtlOrderError?: boolean; sessionAffinity403?: boolean; openAISdkHeader403?: boolean } = {}): string {
6536
+ function buildDoctorDiagnosis(model: PiModel, options: { promptCacheRetention400?: boolean; promptCacheKey400?: boolean; anthropicTtlOrderError?: boolean; sessionAffinity403?: boolean; openAISdkHeader403?: boolean } = {}): string {
6111
6537
  const lines: string[] = [];
6112
6538
  lines.push(`Provider: ${model.provider}`);
6113
6539
  lines.push(`Model: ${model.id}`);
@@ -6157,6 +6583,13 @@ function buildDoctorDiagnosis(model: PiModel, options: { promptCacheRetention400
6157
6583
  lines.push(...getCompatCheckNotApplicableLines(model));
6158
6584
  }
6159
6585
 
6586
+ if (options.promptCacheKey400 && isPromptCacheKeyUnsupportedApplicable(model)) {
6587
+ lines.push("");
6588
+ lines.push("⚠️ This model previously rejected prompt_cache_key with an explicit HTTP 400 signal.");
6589
+ lines.push(" /cache-optimizer fix will offer a precise model-scoped opt-out.");
6590
+ lines.push(" The opt-out removes both prompt_cache_key and promptCacheKey from the final request body.");
6591
+ }
6592
+
6160
6593
  if (isPromptCacheRetention400Applicable(model)) {
6161
6594
  lines.push("");
6162
6595
  if (options.promptCacheRetention400) {
@@ -9245,6 +9678,8 @@ export const __internals_for_tests = {
9245
9678
  addOpenAIPromptCacheKey,
9246
9679
  clampPromptCacheKey,
9247
9680
  hasEffectivePromptCacheKey,
9681
+ omitOpenAIPromptCacheKeys,
9682
+ isPromptCacheKeyOmittedForModel,
9248
9683
  isNonEmptyString,
9249
9684
  shouldInjectOpenAIPromptCacheKey,
9250
9685
  shouldInjectOpenAIPromptCacheKeyForModel,
@@ -9281,6 +9716,9 @@ export const __internals_for_tests = {
9281
9716
  isOpenAISdkHeader403Applicable,
9282
9717
  hasPromptCacheRetentionUnsupportedSignal,
9283
9718
  hasPromptCacheRetentionUnsupportedErrorMessage,
9719
+ hasPromptCacheKeyUnsupportedSignal,
9720
+ hasPromptCacheKeyUnsupportedErrorMessage,
9721
+ isPromptCacheKeyUnsupportedApplicable,
9284
9722
  hasReasoningProtocolRejectionText,
9285
9723
  hasReasoningProtocolRejectionSignal,
9286
9724
  hasReasoningProtocolRejectionErrorMessage,
@@ -9475,11 +9913,22 @@ export const __internals_for_tests = {
9475
9913
  selectFooterStatsForModel,
9476
9914
  parseFooterStatsMode,
9477
9915
  parsePersistedCacheOptimizerConfig,
9916
+ readPersistedCacheOptimizerConfig,
9917
+ writePersistedCacheOptimizerConfig,
9478
9918
  readPersistedFooterMode,
9479
9919
  writePersistedFooterMode,
9920
+ setPersistedCacheOptimizerConfig,
9480
9921
  resolveFooterStatsMode,
9481
9922
  footerStatsMode,
9482
9923
  CONFIG_FILE_PATH,
9924
+ CONFIG_RECEIPT_PATH,
9925
+ parsePromptCacheKeyConfigReceipt,
9926
+ isActionablePromptCacheKeyConfigReceipt,
9927
+ readPromptCacheKeyConfigReceipt,
9928
+ writePromptCacheKeyConfigReceipt,
9929
+ applyPromptCacheKeyConfigFix,
9930
+ rollbackPromptCacheKeyConfig,
9931
+ configReceiptBackupPath,
9483
9932
  FOOTER_MODE_ENV,
9484
9933
  // Routing-provider protocol helpers
9485
9934
  PI_ROUTING_REGISTRY_SYMBOL,
@@ -9592,6 +10041,8 @@ export default function (pi: ExtensionAPI) {
9592
10041
  const reasoningProtocolFallbackState = getReasoningProtocolFallbackState();
9593
10042
  const reasoningProtocolRejectedModels = reasoningProtocolFallbackState.modelKeys;
9594
10043
  const warnedReasoningProtocolRejectedModels = reasoningProtocolFallbackState.warnedModelKeys;
10044
+ const promptCacheKeyRejectedModels = new Set<string>();
10045
+ const warnedPromptCacheKeyRejectedModels = new Set<string>();
9595
10046
  let cacheStatsByModel: Record<string, CacheStats> = {};
9596
10047
  let cacheStatsProcessByModel: Record<string, CacheStats> = {};
9597
10048
  let cacheStatsTotalsByModel: Record<string, CacheStats> = {};
@@ -9615,7 +10066,13 @@ export default function (pi: ExtensionAPI) {
9615
10066
  let currentSessionHashSet = false;
9616
10067
  let lastActualRoutedModel: PersistedRoutedModelRef | undefined;
9617
10068
  let latestCacheHint: PiCacheHintSnapshot | undefined;
9618
- let pendingProviderResponseModel: PiModel | undefined;
10069
+ // Pi's response hooks do not expose a request id. Track one FIFO lifecycle
10070
+ // record per request and mark records when their response hook arrives. A
10071
+ // finalized assistant message consumes the oldest completed record, falling
10072
+ // back to the oldest request only when a transport emitted no response hook.
10073
+ // This preserves response A when request B/C starts before message_end(A).
10074
+ type ProviderRequestState = { model: PiModel; responseReceived: boolean };
10075
+ const providerRequestStates: ProviderRequestState[] = [];
9619
10076
  let shardCreatedAt = Date.now();
9620
10077
  const PERSIST_DEBOUNCE_MS = 2000;
9621
10078
  const SHARD_REFRESH_DEBOUNCE_MS = 250;
@@ -9661,6 +10118,21 @@ export default function (pi: ExtensionAPI) {
9661
10118
  forceModelLevel: regular.forceModelLevel || observed.forceModelLevel,
9662
10119
  };
9663
10120
  }
10121
+
10122
+ function promptCacheKeyFixApplies(model: PiModel): boolean {
10123
+ return isPromptCacheKeyUnsupportedApplicable(model) && promptCacheKeyRejectedModels.has(modelKey(model));
10124
+ }
10125
+
10126
+ function buildPromptCacheKeyConfigPreview(model: PiModel): string[] {
10127
+ const key = modelKey(model);
10128
+ return [
10129
+ `Target: ${key}`,
10130
+ `Action: persist prompt_cache_key mode = omit in ${CONFIG_FILE_PATH}.`,
10131
+ "The final request body will omit both prompt_cache_key and promptCacheKey.",
10132
+ "This can reduce provider-side prompt-cache reuse for this model; it does not change prompts, credentials, headers, or other models.",
10133
+ "The setting applies after Pi core has built the payload, including when Pi already supplied a key.",
10134
+ ];
10135
+ }
9664
10136
  /** In-memory recent usage samples per model key (not persisted, cleared on reload). */
9665
10137
  const recentSamplesByModelKey = new Map<string, CacheUsageSample[]>();
9666
10138
 
@@ -10158,7 +10630,7 @@ export default function (pi: ExtensionAPI) {
10158
10630
  shardWatcher?.close();
10159
10631
  shardWatcher = undefined;
10160
10632
  latestCacheHint = undefined;
10161
- pendingProviderResponseModel = undefined;
10633
+ providerRequestStates.length = 0;
10162
10634
  delete getProtocolGlobal().__piCacheOptimizerCacheKey__;
10163
10635
  uninstallCacheHintsService();
10164
10636
  restoreCacheRetentionEnv(STARTUP_CACHE_RETENTION_ENV);
@@ -10300,9 +10772,10 @@ export default function (pi: ExtensionAPI) {
10300
10772
 
10301
10773
  pi.on("before_provider_request", (event, ctx) => {
10302
10774
  const requestModel = resolveRouteModel(ctx.model, ctx) ?? ctx.model;
10303
- pendingProviderResponseModel = runtimeOptimizerEnabled
10304
- ? snapshotProviderRequestModel(requestModel)
10305
- : undefined;
10775
+ if (runtimeOptimizerEnabled) {
10776
+ const snapshot = snapshotProviderRequestModel(requestModel);
10777
+ if (snapshot) providerRequestStates.push({ model: snapshot, responseReceived: false });
10778
+ }
10306
10779
  let requestPayload: unknown = event.payload;
10307
10780
  let toolOrderChanged = false;
10308
10781
 
@@ -10356,6 +10829,11 @@ export default function (pi: ExtensionAPI) {
10356
10829
  }
10357
10830
  }
10358
10831
 
10832
+ if (isPromptCacheKeyOmittedForModel(requestModel)) {
10833
+ const omitted = omitOpenAIPromptCacheKeys(requestPayload);
10834
+ return omitted ?? (toolOrderChanged ? requestPayload : undefined);
10835
+ }
10836
+
10359
10837
  if (!shouldInjectOpenAIPromptCacheKey() || !shouldInjectOpenAIPromptCacheKeyForModel(requestModel)) {
10360
10838
  return toolOrderChanged ? requestPayload : undefined;
10361
10839
  }
@@ -10365,16 +10843,10 @@ export default function (pi: ExtensionAPI) {
10365
10843
  });
10366
10844
 
10367
10845
  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
- }
10846
+ const responseState = providerRequestStates.find((state) => !state.responseReceived);
10847
+ if (responseState) responseState.responseReceived = true;
10848
+ const model = responseState?.model ?? (resolveRouteModel(ctx.model, ctx) ?? ctx.model);
10849
+ if (!runtimeOptimizerEnabled || !model) return;
10378
10850
 
10379
10851
  // Keep only the category, never the provider's complete error text. This
10380
10852
  // is evidence for a later, model-scoped `/fix`; it never edits config from
@@ -10392,22 +10864,36 @@ export default function (pi: ExtensionAPI) {
10392
10864
  );
10393
10865
  }
10394
10866
 
10395
- // ── 400: prompt_cache_retention unsupported ──
10396
- if (event.status === 400) {
10397
- if (!isPromptCacheRetention400Applicable(model)) return;
10398
- if (!hasPromptCacheRetentionUnsupportedSignal(event.headers)) return;
10867
+ // ── 400: prompt_cache_key unsupported ──
10868
+ if (event.status === 400 && isPromptCacheKeyUnsupportedApplicable(model) && hasPromptCacheKeyUnsupportedSignal(event.headers)) {
10869
+ const key = modelKey(model);
10870
+ promptCacheKeyRejectedModels.add(key);
10871
+ if (!warnedPromptCacheKeyRejectedModels.has(key)) {
10872
+ warnedPromptCacheKeyRejectedModels.add(key);
10873
+ ctx.ui.notify(
10874
+ `⚠️ ${LOG_PREFIX}: ${key} rejected prompt_cache_key. Run /cache-optimizer fix to review a precise model-scoped repair. No configuration was changed automatically.`,
10875
+ "warning",
10876
+ );
10877
+ }
10878
+ }
10399
10879
 
10880
+ // ── 400: prompt_cache_retention unsupported ──
10881
+ if (
10882
+ event.status === 400 &&
10883
+ isPromptCacheRetention400Applicable(model) &&
10884
+ hasPromptCacheRetentionUnsupportedSignal(event.headers)
10885
+ ) {
10400
10886
  const key = modelKey(model);
10401
10887
  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;
10888
+ if (!warnedPromptCacheRetention400Models.has(key)) {
10889
+ warnedPromptCacheRetention400Models.add(key);
10890
+ ctx.ui.notify(
10891
+ `⚠️ ${LOG_PREFIX}: ${key} returned HTTP 400 while supportsLongCacheRetention is enabled. ` +
10892
+ getPromptCacheRetentionUnsupportedHint() +
10893
+ ` Run /cache-optimizer doctor for the exact edit location.`,
10894
+ "warning",
10895
+ );
10896
+ }
10411
10897
  }
10412
10898
 
10413
10899
  // ── 403: proxy/CDN/WAF blocking ──
@@ -10453,14 +10939,12 @@ export default function (pi: ExtensionAPI) {
10453
10939
  syncSessionHash(ctx);
10454
10940
  const msgRecord = asRecord(event.message);
10455
10941
  const requestModelForMessage = msgRecord?.role === "assistant"
10456
- ? pendingProviderResponseModel
10942
+ ? (() => {
10943
+ const completedIndex = providerRequestStates.findIndex((state) => state.responseReceived);
10944
+ const index = completedIndex >= 0 ? completedIndex : 0;
10945
+ return providerRequestStates.splice(index, 1)[0]?.model;
10946
+ })()
10457
10947
  : 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
10948
 
10465
10949
  // Some providers expose an HTTP 400 error body only through the finalized
10466
10950
  // assistant error message; after_provider_response may contain the status
@@ -10482,6 +10966,24 @@ export default function (pi: ExtensionAPI) {
10482
10966
  );
10483
10967
  }
10484
10968
  }
10969
+ if (runtimeOptimizerEnabled && hasPromptCacheKeyUnsupportedErrorMessage(event.message)) {
10970
+ const fallbackModel = requestModelForMessage ?? (resolveRouteModel(ctx.model, ctx) ?? ctx.model);
10971
+ const messageModel = modelFromAssistantMessage(event.message, fallbackModel) ?? fallbackModel;
10972
+ const errorModel = messageModel
10973
+ ? findModelInRegistry(ctx.modelRegistry, messageModel.provider, messageModel.id) ?? messageModel
10974
+ : undefined;
10975
+ if (errorModel && isPromptCacheKeyUnsupportedApplicable(errorModel)) {
10976
+ const key = modelKey(errorModel);
10977
+ promptCacheKeyRejectedModels.add(key);
10978
+ if (!warnedPromptCacheKeyRejectedModels.has(key)) {
10979
+ warnedPromptCacheKeyRejectedModels.add(key);
10980
+ ctx.ui.notify(
10981
+ `⚠️ ${LOG_PREFIX}: ${key} rejected prompt_cache_key. Run /cache-optimizer fix to review a precise model-scoped repair. No configuration was changed automatically.`,
10982
+ "warning",
10983
+ );
10984
+ }
10985
+ }
10986
+ }
10485
10987
  if (runtimeOptimizerEnabled && hasPromptCacheRetentionUnsupportedErrorMessage(event.message)) {
10486
10988
  const fallbackModel = requestModelForMessage
10487
10989
  ?? (resolveRouteModel(ctx.model, ctx) ?? ctx.model);
@@ -10630,7 +11132,7 @@ export default function (pi: ExtensionAPI) {
10630
11132
  // stats — show detailed current-session models; `stats all` shows all local shards
10631
11133
  // compat — show compat suggestion with file path
10632
11134
  // config footer-mode total|session|process — persist footer mode override
10633
- // fix — auto-fix compat issues (writes models.json, requires UI)
11135
+ // fix — auto-fix compat issues (writes models.json or extension config, requires UI)
10634
11136
  // rollback — undo the latest confirmed fix for the active model
10635
11137
  // reset — reset current provider/model footer stats bucket (local only)
10636
11138
  // (no args) — interactive menu (with UI) or help summary
@@ -10653,7 +11155,7 @@ export default function (pi: ExtensionAPI) {
10653
11155
  cmdCtx.ui.notify(`✅ Pi Cache Optimizer enabled for this Pi process. Local footer stats were reset for before/after comparison.\n${formatOptimizerRuntimeMode()}`, "info");
10654
11156
  } else if (subcommand === "disable") {
10655
11157
  setRuntimeOptimizerEnabled(false);
10656
- pendingProviderResponseModel = undefined;
11158
+ providerRequestStates.length = 0;
10657
11159
  await resetCurrentSessionStats();
10658
11160
  await flushPersistCacheStats(cmdCtx);
10659
11161
  await publishStatus(cmdCtx, model);
@@ -10664,7 +11166,7 @@ export default function (pi: ExtensionAPI) {
10664
11166
  cmdCtx.ui.notify("No active model selected. Select a model first with /model or pi --model.", "warning");
10665
11167
  return;
10666
11168
  }
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)) });
11169
+ 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
11170
  const adapter = selectAdapterForModel(model);
10669
11171
  const sk = model ? sessionModelKey(model) : undefined;
10670
11172
  const statsState = model ? cacheStatsTotalsByModel[modelKey(model)] : undefined;
@@ -10703,7 +11205,7 @@ export default function (pi: ExtensionAPI) {
10703
11205
  const nextMode = requestedMode as FooterStatsMode;
10704
11206
  try {
10705
11207
  await writePersistedFooterMode(nextMode);
10706
- persistedFooterStatsMode = nextMode;
11208
+ setPersistedCacheOptimizerConfig(readPersistedCacheOptimizerConfig());
10707
11209
  lastStatusText = undefined;
10708
11210
  await publishStatus(cmdCtx, model);
10709
11211
  const resolved = resolveFooterStatsMode(persistedFooterStatsMode);
@@ -10738,6 +11240,34 @@ export default function (pi: ExtensionAPI) {
10738
11240
  cmdCtx.ui.notify("No active model selected. Select a model first with /model or pi --model.", "warning");
10739
11241
  return;
10740
11242
  }
11243
+ const configReceipt = await readPromptCacheKeyConfigReceipt();
11244
+ const modelsReceipt = await readModelsJsonFixReceipt();
11245
+ const useConfigReceipt = isActionablePromptCacheKeyConfigReceipt(configReceipt) &&
11246
+ configReceipt.provider === model.provider &&
11247
+ configReceipt.modelId === model.id &&
11248
+ (!isActionableModelsJsonFixReceipt(modelsReceipt) || modelsReceipt.provider !== model.provider || modelsReceipt.modelId !== model.id || configReceipt.appliedAt >= modelsReceipt.appliedAt);
11249
+ if (useConfigReceipt) {
11250
+ if (!cmdCtx.hasUI) {
11251
+ 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");
11252
+ return;
11253
+ }
11254
+ const confirmed = await cmdCtx.ui.confirm(
11255
+ "Cache Optimizer — Rollback prompt_cache_key opt-out",
11256
+ `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?`,
11257
+ );
11258
+ if (!confirmed) {
11259
+ cmdCtx.ui.notify("No changes were made. Rollback canceled by user.", "info");
11260
+ return;
11261
+ }
11262
+ try {
11263
+ await rollbackPromptCacheKeyConfig(configReceipt);
11264
+ setPersistedCacheOptimizerConfig(readPersistedCacheOptimizerConfig());
11265
+ cmdCtx.ui.notify(`✅ Restored prompt_cache_key behavior for ${modelKey(model)}. Run /reload or restart Pi for the change to take effect.`, "info");
11266
+ } catch (error) {
11267
+ cmdCtx.ui.notify(`❌ Prompt cache key rollback refused: ${error instanceof Error ? error.message : String(error)}. No changes were made.`, "error");
11268
+ }
11269
+ return;
11270
+ }
10741
11271
  if (!cmdCtx.hasUI) {
10742
11272
  const receipt = await readModelsJsonFixReceipt();
10743
11273
  const backupHint = receipt && isActionableModelsJsonFixReceipt(receipt)
@@ -10868,7 +11398,58 @@ export default function (pi: ExtensionAPI) {
10868
11398
  return;
10869
11399
  }
10870
11400
 
10871
- const suggestion = buildCommandFixSuggestion(model);
11401
+ const promptCacheKeyRequested = commandParts[1] === "prompt-cache-key";
11402
+ const promptCacheKeyFixRequested = promptCacheKeyRequested || promptCacheKeyFixApplies(model);
11403
+ if (promptCacheKeyRequested && !isPromptCacheKeyUnsupportedApplicable(model)) {
11404
+ cmdCtx.ui.notify("ℹ️ Prompt cache key opt-out applies only to a known OpenAI-compatible provider/model endpoint. No changes were made.", "info");
11405
+ return;
11406
+ }
11407
+ const suggestion = promptCacheKeyFixRequested ? undefined : buildCommandFixSuggestion(model);
11408
+
11409
+ if (promptCacheKeyFixRequested) {
11410
+ if (isPromptCacheKeyOmittedForModel(model)) {
11411
+ cmdCtx.ui.notify(`✅ prompt_cache_key is already omitted for "${modelKey(model)}".`, "info");
11412
+ return;
11413
+ }
11414
+ if (!cmdCtx.hasUI) {
11415
+ cmdCtx.ui.notify(
11416
+ "❌ Non-interactive terminal detected. Prompt cache key opt-out requires UI confirmation. No changes were made.\n" +
11417
+ `Run /cache-optimizer fix prompt-cache-key in Pi's interactive UI for ${modelKey(model)}.`,
11418
+ "warning",
11419
+ );
11420
+ return;
11421
+ }
11422
+ const preview = [
11423
+ "📝 Preview of extension configuration change:",
11424
+ ...buildPromptCacheKeyConfigPreview(model),
11425
+ "",
11426
+ "⚠️ Risk notice:",
11427
+ " 1. This affects all sessions using this exact provider/model.",
11428
+ " 2. Provider prompt-cache reuse may decrease because both key spellings are removed.",
11429
+ " 3. This does not modify Pi's models.json, credentials, prompts, headers, or other models.",
11430
+ " 4. The extension config will be backed up and Pi must be reloaded/restarted.",
11431
+ "",
11432
+ "Apply this persistent opt-out?",
11433
+ ].join("\n");
11434
+ const confirmed = await cmdCtx.ui.confirm("Cache Optimizer — Omit prompt_cache_key", preview);
11435
+ if (!confirmed) {
11436
+ cmdCtx.ui.notify("No changes were made. Canceled by user.", "info");
11437
+ return;
11438
+ }
11439
+ try {
11440
+ const result = await applyPromptCacheKeyConfigFix(model);
11441
+ setPersistedCacheOptimizerConfig(readPersistedCacheOptimizerConfig());
11442
+ cmdCtx.ui.notify(
11443
+ `✅ Prompt cache key opt-out saved for ${modelKey(model)}.\n` +
11444
+ `Config backup saved to: ${result.backupPath}\n` +
11445
+ "Run /reload or restart Pi for the change to take effect. Use /cache-optimizer rollback to restore it.",
11446
+ "info",
11447
+ );
11448
+ } catch (error) {
11449
+ cmdCtx.ui.notify(`❌ Could not save prompt cache key opt-out: ${error instanceof Error ? error.message : String(error)}. No changes were made.`, "error");
11450
+ }
11451
+ return;
11452
+ }
10872
11453
 
10873
11454
  if (!suggestion) {
10874
11455
  const key = modelKey(model);
@@ -11301,7 +11882,8 @@ export default function (pi: ExtensionAPI) {
11301
11882
  "Doctor — Show cache configuration",
11302
11883
  "Stats — Show current-session model statistics",
11303
11884
  "Compat — Show compat suggestion",
11304
- "Fix — Auto-fix compat issues (writes models.json)",
11885
+ "Fix — Auto-fix compat issues (writes models.json or extension config)",
11886
+ "Disable prompt_cache_key — Omit it for the active model",
11305
11887
  "Rollback — Undo the latest confirmed fix",
11306
11888
  "Footer mode — Choose total, session, or process stats",
11307
11889
  "Reset — Reset local provider/model stats",
@@ -11321,8 +11903,10 @@ export default function (pi: ExtensionAPI) {
11321
11903
  } else if (choice === menuOptions[5]) {
11322
11904
  await handleCacheOptimizerCommand("fix", cmdCtx);
11323
11905
  } else if (choice === menuOptions[6]) {
11324
- await handleCacheOptimizerCommand("rollback", cmdCtx);
11906
+ await handleCacheOptimizerCommand("fix prompt-cache-key", cmdCtx);
11325
11907
  } else if (choice === menuOptions[7]) {
11908
+ await handleCacheOptimizerCommand("rollback", cmdCtx);
11909
+ } else if (choice === menuOptions[8]) {
11326
11910
  const modeOptions = ["session — Current Pi conversation session (default)", "total — All local sessions today", "process — Current extension instance only", "Cancel"];
11327
11911
  const modeChoice = await cmdCtx.ui.select("Footer cache stats mode", modeOptions);
11328
11912
  const nextMode = modeChoice === modeOptions[0]
@@ -11333,7 +11917,7 @@ export default function (pi: ExtensionAPI) {
11333
11917
  ? "process"
11334
11918
  : undefined;
11335
11919
  if (nextMode) await handleCacheOptimizerCommand(`config footer-mode ${nextMode}`, cmdCtx);
11336
- } else if (choice === menuOptions[8]) {
11920
+ } else if (choice === menuOptions[9]) {
11337
11921
  await handleCacheOptimizerCommand("reset", cmdCtx);
11338
11922
  }
11339
11923
  // choice === "cancel" or undefined → no action
@@ -11351,7 +11935,8 @@ export default function (pi: ExtensionAPI) {
11351
11935
  diagnosis.push(" stats contributors — Show per-session contributors for the active model");
11352
11936
  diagnosis.push(" compat — Show compat suggestion with edit location");
11353
11937
  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)");
11938
+ diagnosis.push(" fix — Auto-fix compat issues (writes models.json or extension config, requires UI)");
11939
+ diagnosis.push(" fix prompt-cache-key — Explicitly omit prompt_cache_key for the active model");
11355
11940
  diagnosis.push(" rollback — Undo the latest confirmed fix (requires UI confirmation)");
11356
11941
  diagnosis.push(" reset — Reset local provider/model stats for current model (does not affect upstream)");
11357
11942
  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.9",
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.1",
39
+ "@earendil-works/pi-server": "0.85.1",
39
40
  "@types/node": "^22.0.0",
40
41
  "jiti": "^2.7.0",
41
42
  "typescript": "^5.8.3"