pi-cache-optimizer 2.8.9 → 2.8.11

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.
Files changed (4) hide show
  1. package/README.md +10 -10
  2. package/README.zh-CN.md +10 -10
  3. package/index.ts +510 -120
  4. package/package.json +3 -3
package/README.md CHANGED
@@ -61,7 +61,7 @@ Run `/reload` in Pi after install/update/remove so extension hooks refresh.
61
61
 
62
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).
63
63
 
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
+ This extension requires Pi 0.82+ and is validated against Pi 0.87.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.
65
65
 
66
66
  ## Commands
67
67
 
@@ -134,21 +134,21 @@ The explicit setting is stored in `pi-cache-optimizer-config.json` under Pi's ag
134
134
 
135
135
  ## Per-model `prompt_cache_key` opt-out
136
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.
137
+ Some OpenAI-compatible endpoints reject `prompt_cache_key` with HTTP 400 even though the same field is valid for other providers. Pi 0.87.1 has no native `supportsPromptCacheKey` compat field; do **not** add that unknown field to `models.json`. `supportsLongCacheRetention` is not an equivalent switch and should not be used for this purpose.
138
138
 
139
- When the extension observes an explicit `prompt_cache_key` unsupported error for the exact provider/model, ordinary `/cache-optimizer fix` offers a confirmed model-scoped repair. If you already know that the endpoint rejects the field, use the explicit command:
139
+ When the extension observes an explicit field-level `prompt_cache_key` unsupported error for the exact provider/model, ordinary `/cache-optimizer fix` offers a confirmed model-scoped repair. Value-validation failures and conditional restrictions such as “not allowed when temperature is set” do not qualify. If concurrent responses from different models cannot be correlated because Pi provides no request ID, header-only evidence is ignored unless the finalized assistant message supplies exact provider/model identity. If you already know that the endpoint rejects the field, use the explicit command:
140
140
 
141
141
  ```text
142
142
  /cache-optimizer fix prompt-cache-key
143
143
  ```
144
144
 
145
- The preview explains that the setting is stored in the extension-owned `pi-cache-optimizer-config.json`. After confirmation, the final `before_provider_request` stage removes both `prompt_cache_key` and `promptCacheKey`, including a key that Pi core supplied earlier. This can reduce provider prompt-cache hits for that exact model, while other models retain the existing fallback behavior. The file is updated atomically, a privacy-safe backup/receipt is created, and `/reload` or a Pi restart is required. `/cache-optimizer rollback` restores the previous extension configuration without resetting `footerMode`.
145
+ The preview explains that the setting is stored in the extension-owned `pi-cache-optimizer-config.json`. After confirmation, the final `before_provider_request` stage removes both `prompt_cache_key` and `promptCacheKey`, including a key that Pi core supplied earlier. This can reduce provider prompt-cache hits for that exact model, while other models retain the existing fallback behavior. The file is updated atomically without overwriting a concurrently created config, a privacy-safe backup/receipt is created, and `/reload` or a Pi restart is required. `/cache-optimizer rollback` restores the previous extension configuration without resetting `footerMode`; it binds the previewed receipt by file identity and hash, refuses a replaced receipt, and compensates the config if receipt marking fails.
146
146
 
147
147
  ## OpenAI-compatible proxy setup
148
148
 
149
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.
150
150
 
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.
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.87.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.
152
152
 
153
153
  For real proxies, start with session affinity:
154
154
 
@@ -174,7 +174,7 @@ Notes:
174
174
 
175
175
  - `sendSessionAffinityHeaders: true` is the safe default when your proxy supports sticky routing.
176
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.
177
+ - Do not add `supportsPromptCacheKey` to `models.json`: Pi 0.87.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.
178
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.
179
179
  - Use `/cache-optimizer compat` or `/cache-optimizer doctor` to see model-specific advice.
180
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.
@@ -262,7 +262,7 @@ Pi 0.80.9+ already includes Kimi K3 in built-in Kimi Coding, Moonshot AI / China
262
262
  - DeepSeek Pi Mono replay compat (`requiresReasoningContentOnAssistantMessages: true` only when `thinkingFormat: "deepseek"` is already explicit; `/fix` never invents that format)
263
263
  - OpenAI-compatible proxy session affinity (`sendSessionAffinityHeaders: true` for `openai-completions`). Pi 0.80.7+ controls `openai-responses` header shape with `sessionAffinityFormat` and auto-detects its default; this extension no longer writes the removed `sendSessionIdHeader` field.
264
264
 
265
- **Scope:** only the currently active model. Other channels require switching models and running `fix` again.
265
+ **Scope:** the fix starts from the currently active model. An affinity-only repair can write provider-level compat once for all applicable models on that provider, including models absent from `models[]` when the provider entry already exists and no higher-priority setting shadows it. Explicit per-model `false` remains an opt-out. Other or ambiguous repairs stay model-scoped.
266
266
 
267
267
  **Safety:**
268
268
 
@@ -275,15 +275,15 @@ Pi 0.80.9+ already includes Kimi K3 in built-in Kimi Coding, Moonshot AI / China
275
275
  7. Writes a privacy-safe, versioned receipt atomically only after a successful write; the receipt contains transaction/model identity, placement, scalar compat before/after values, file hashes, backup filename, timestamps/status, and no credentials or request data
276
276
  8. Uses unique, non-overwriting backup names and falls back to manual guidance if the JSONC scanner cannot confidently locate the target
277
277
 
278
- Existing `modelOverrides[modelId]` entries have Pi's highest precedence, so `fix` repairs them directly. For built-in or API-login models without a custom `models[]` entry, `fix` creates a compat-only `modelOverrides` entry instead of inventing a custom model definition. Runtime-observed provider failures are always written to that highest-precedence model override so extension-provided runtime compat cannot shadow the repair. Self-validation checks the full provider → custom model → runtime model → modelOverride result and rejects an ineffective lower-layer edit.
278
+ Existing `modelOverrides[modelId]` entries have Pi's highest precedence, so `fix` repairs them directly. For built-in or API-login models without a custom `models[]` entry, an affinity-only fix prefers provider-level compat when the existing provider is safely locatable and no higher-priority explicit/runtime value shadows it; otherwise `fix` creates a compat-only `modelOverrides` entry instead of inventing a custom model definition. Runtime-observed provider failures are always written to that highest-precedence model override so extension-provided runtime compat cannot shadow the repair. Self-validation checks the full provider → custom model → runtime model → modelOverride result and rejects an ineffective lower-layer edit.
279
279
 
280
280
  **Non-interactive mode:** refuses to write; shows manual edit guidance instead.
281
281
 
282
- **Run:** `/cache-optimizer fix` when the active model has detected compat issues. The command shows "nothing to fix" when compat is already complete.
282
+ **Run:** `/cache-optimizer fix` when the active model has detected compat issues. Affinity-only missing-compat notifications are shown once per provider per extension instance and point to this confirmed command; model-specific warnings remain separate. The command shows "nothing to fix" when compat is already complete.
283
283
 
284
284
  ## `/cache-optimizer rollback`
285
285
 
286
- Rollback is available through completion, direct execution, and the interactive menu. It always requires UI confirmation; without an interactive UI it gives manual-review guidance and does not write. The command selects the latest unapplied receipt for the active provider/model, validates the recorded backup and current file hashes, creates a new access-mode-preserving rollback backup, and uses temp-file + atomic rename. Fix and rollback transactions are serialized across extension instances; rollback also binds the receipt transaction id/hash from preview through commit and refuses if another transaction replaces it.
286
+ Rollback is available through completion, direct execution, and the interactive menu. It always requires UI confirmation; without an interactive UI it gives manual-review guidance and does not write. The command selects the latest unapplied receipt for the active provider/model (or any active model on that provider for a provider-level receipt), validates the recorded backup and current file hashes, creates a new access-mode-preserving rollback backup, and uses temp-file + atomic rename. Fix and rollback transactions are serialized across extension instances; rollback also binds the receipt transaction id/hash from preview through commit and refuses if another transaction replaces it.
287
287
 
288
288
  If `models.json` is unchanged since the fix, rollback can restore the exact pre-fix JSONC. If the file changed, it never blindly replaces it: it may restore only receipt-owned scalar compat keys whose recorded post-fix values are still present, preserving later user changes. If a receipt-owned key changed, the target was removed/moved, or the fix created a new target entry, it refuses and points to the recorded backup for manual review. Successful rollback marks the receipt and requires `/reload` or a restart.
289
289
 
package/README.zh-CN.md CHANGED
@@ -61,7 +61,7 @@ pi remove npm:pi-deepseek-cache-optimizer && pi install npm:pi-cache-optimizer
61
61
 
62
62
  Pi 0.79.7 及之后,`pi update` 默认只更新 Pi 本体。若要更新已安装的 Pi package(包括本扩展),请运行 `pi update --extensions`(只更新 packages)或 `pi update --all`(Pi 与 packages 一起更新)。
63
63
 
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
+ 本扩展要求 Pi 0.82+,并已使用 Pi 0.87.1 验证。TypeScript 校验直接使用官方 Pi package 类型,同时只使用这些版本共有的 extension hooks、`getAgentDir()` 和 prompt options;不依赖 Pi 0.83+ 专有 API(例如 `ctx.scopedModels` 或 bundled TypeBox 1.3 aliases)。
65
65
 
66
66
  ## 命令
67
67
 
@@ -134,21 +134,21 @@ Footer 默认使用 `session`,避免另一个并行 Pi 终端使用相同 prov
134
134
 
135
135
  ## 按模型关闭 `prompt_cache_key`
136
136
 
137
- 某些 OpenAI-compatible endpoint 会因 `prompt_cache_key` 返回 HTTP 400,但同一个字段对其他 provider 可能有效。Pi 0.85.1 没有原生的 `supportsPromptCacheKey` compat 字段,**不要**把这个未知字段加入 `models.json`。`supportsLongCacheRetention` 也不是等价开关,不应借用来实现此目的。
137
+ 某些 OpenAI-compatible endpoint 会因 `prompt_cache_key` 返回 HTTP 400,但同一个字段对其他 provider 可能有效。Pi 0.87.1 没有原生的 `supportsPromptCacheKey` compat 字段,**不要**把这个未知字段加入 `models.json`。`supportsLongCacheRetention` 也不是等价开关,不应借用来实现此目的。
138
138
 
139
- 当扩展观察到精确 provider/model 明确拒绝 `prompt_cache_key` 后,普通 `/cache-optimizer fix` 会提供经过确认的模型级修复。如果你已经确定 endpoint 不支持该字段,可以主动执行:
139
+ 当扩展观察到精确 provider/model 对 `prompt_cache_key` 的明确字段级拒绝后,普通 `/cache-optimizer fix` 会提供经过确认的模型级修复。参数值校验失败,以及“设置 temperature 时不允许”这类条件限制都不构成证据。若不同模型的响应并发交错,而 Pi 又没有提供 request ID,扩展会忽略无法安全关联的 response-header 证据;只有最终 assistant message 提供精确 provider/model 身份时才恢复归因。如果你已经确定 endpoint 不支持该字段,可以主动执行:
140
140
 
141
141
  ```text
142
142
  /cache-optimizer fix prompt-cache-key
143
143
  ```
144
144
 
145
- 预览会说明设置保存在扩展自有的 `pi-cache-optimizer-config.json` 中。确认后,`before_provider_request` 的最终阶段会移除 `prompt_cache_key` 和 `promptCacheKey`,包括 Pi core 之前已经放入的 key。这个精确模型可能因此失去 provider prompt-cache 命中,其他模型仍保持原有 fallback 行为。配置会使用原子替换写入,并生成不含敏感内容的备份/receipt;需要 `/reload` 或重启 Pi。`/cache-optimizer rollback` 可恢复之前的扩展配置,且不会重置 `footerMode`。
145
+ 预览会说明设置保存在扩展自有的 `pi-cache-optimizer-config.json` 中。确认后,`before_provider_request` 的最终阶段会移除 `prompt_cache_key` 和 `promptCacheKey`,包括 Pi core 之前已经放入的 key。这个精确模型可能因此失去 provider prompt-cache 命中,其他模型仍保持原有 fallback 行为。配置通过原子、不可覆盖并发新建文件的方式写入,并生成不含敏感内容的备份/receipt;需要 `/reload` 或重启 Pi。`/cache-optimizer rollback` 可恢复之前的扩展配置且不会重置 `footerMode`;它会绑定预览时 receipt 的文件身份和 hash,在 receipt 被替换时拒绝操作,并在 receipt 状态写入失败时补偿恢复配置。
146
146
 
147
147
  ## OpenAI-compatible 代理配置
148
148
 
149
149
  LiteLLM / OneAPI / NewAPI / 类 OpenRouter 渠道等第三方 `openai-completions` 代理,常会把同一个 session 分散到多个上游后端,导致 provider 侧 prompt cache 被拆散。
150
150
 
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。
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.87.1 没有原生的 `supportsPromptCacheKey` compat 字段,因此按模型关闭 key 的策略保存在本扩展的 `pi-cache-optimizer-config.json` 中,而不是 `models.json`。扩展配置独立于 Pi 的 compat 优先级,只影响其中列出的精确 provider/model。
152
152
 
153
153
  对真正的代理,建议先启用 session affinity:
154
154
 
@@ -174,7 +174,7 @@ Pi 0.84.1 还修复了内置 Fireworks 渠道对拒绝 `prompt_cache_retention`
174
174
 
175
175
  - `sendSessionAffinityHeaders: true` 是安全默认项,前提是你的代理支持 sticky routing。
176
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。
177
+ - 不要把 `supportsPromptCacheKey` 加入 `models.json`:Pi 0.87.1 没有定义这个 compat 字段。请使用 `/cache-optimizer fix prompt-cache-key` 在扩展自有配置中保存精确 provider/model 的 omit 规则;它会移除两种 key 写法,包括 Pi 提供的 key。
178
178
  - 如果出现 `400 Unsupported parameter: prompt_cache_retention`,请为该渠道移除 / 避免 `supportsLongCacheRetention`;如支持,可保留 `sendSessionAffinityHeaders`。扩展会从响应头或最终 assistant error message 中识别这条明确错误,并在当前进程的后续请求中移除该参数。
179
179
  - 使用 `/cache-optimizer compat` 或 `/cache-optimizer doctor` 查看当前模型的具体建议。
180
180
  - DeepSeek 模型名只用于选择 `DS cache` adapter,不能证明 reasoning wire protocol。缺少或使用非 DeepSeek format 时仍保留通用缓存 / 路由建议;只有 effective `compat.thinkingFormat: "deepseek"` 被明确配置时,才显示 DeepSeek replay 建议,且不会把 `thinkingFormat` 列为缺失修复项。
@@ -262,7 +262,7 @@ Pi 0.80.9+ 已在内置 Kimi Coding、Moonshot AI / 中国区、OpenRouter 和 V
262
262
  - DeepSeek Pi Mono replay compat(仅当已明确配置 `thinkingFormat: "deepseek"` 时使用 `requiresReasoningContentOnAssistantMessages: true`;`/fix` 不会自行推断该 format)
263
263
  - OpenAI-compatible proxy session affinity(`openai-completions` 使用 `sendSessionAffinityHeaders: true`)。Pi 0.80.7+ 使用 `sessionAffinityFormat` 控制 `openai-responses` header 形式并自动检测默认值;本扩展不再写入已移除的 `sendSessionIdHeader`。
264
264
 
265
- **范围:** 仅当前 active model。其他渠道需切换模型后再次运行 `fix`。
265
+ **范围:** 从当前 active model 发起修复。仅缺少 affinity 时,如 provider 已有配置项且无更高优先级配置遮挡,可一次写入 provider 级 compat,覆盖其适用模型(包括不在 `models[]` 中的模型);模型级显式 `false` 仍为 opt-out。其他或有歧义的修复仍按模型处理。
266
266
 
267
267
  **安全机制:**
268
268
 
@@ -274,15 +274,15 @@ Pi 0.80.9+ 已在内置 Kimi Coding、Moonshot AI / 中国区、OpenRouter 和 V
274
274
  6. 完全保留 `models.json` 原有访问权限,不主动收紧或放宽(例如 `0600` 保持 `0600`,`0644` 保持 `0644`)
275
275
  7. 备份名唯一且不会覆盖已有备份;如果 JSONC 扫描器无法置信定位目标,则回退到手动修改指引
276
276
 
277
- 已有的 `modelOverrides[modelId]` 具有 Pi 的最高优先级,因此 `fix` 会直接修复该 entry。对于没有自定义 `models[]` entry 的内置模型或 API-login 模型,`fix` 会创建仅含 compat 的 `modelOverrides` entry,而不会凭空添加自定义模型定义。运行时观察到的 provider 失败也始终写入这一最高优先级 model override,避免 extension-provided runtime compat 遮挡修复。自检会验证完整的 provider → custom model → runtime model → modelOverride 结果;无效的低层写入会被拒绝。
277
+ 已有的 `modelOverrides[modelId]` 具有 Pi 的最高优先级,因此 `fix` 会直接修复该 entry。对于没有自定义 `models[]` entry 的内置模型或 API-login 模型,若现有 provider 可安全使用 provider 级 affinity 修复,则优先写入 provider compat;否则 `fix` 会创建仅含 compat 的 `modelOverrides` entry,而不会凭空添加自定义模型定义。运行时观察到的 provider 失败也始终写入这一最高优先级 model override,避免 extension-provided runtime compat 遮挡修复。自检会验证完整的 provider → custom model → runtime model → modelOverride 结果;无效的低层写入会被拒绝。
278
278
 
279
279
  **非交互模式:** 拒绝写入,显示手动编辑指引。
280
280
 
281
- **运行:** 当 active model 检测到 compat 问题时执行 `/cache-optimizer fix`。compat 已完整时,命令显示"无需修复"。
281
+ **运行:** 当 active model 检测到 compat 问题时执行 `/cache-optimizer fix`。仅缺少 affinity 的提醒按 provider 在当前扩展实例内去重,并指向这一确认式命令;模型专属提醒仍单独显示。compat 已完整时,命令显示"无需修复"。
282
282
 
283
283
  ## `/cache-optimizer rollback`
284
284
 
285
- Rollback 可通过补全、直接命令和交互菜单使用,并始终需要 UI 确认。命令会选择匹配当前 provider/model 的最新未回滚 receipt,验证备份与文件 hash,创建新的保留访问权限的 rollback backup,并使用临时文件 + 原子替换。Fix 与 rollback 在多个 extension instance 间串行执行;rollback 还会把预览时的 receipt transaction id/hash 绑定到提交阶段,若另一事务替换 receipt 就安全拒绝。没有交互式 UI 时只提供手动恢复指引,不会写入文件。
285
+ Rollback 可通过补全、直接命令和交互菜单使用,并始终需要 UI 确认。命令会选择匹配当前 provider/model(provider 级 receipt 可从同 provider 任一模型操作)的最新未回滚 receipt,验证备份与文件 hash,创建新的保留访问权限的 rollback backup,并使用临时文件 + 原子替换。Fix 与 rollback 在多个 extension instance 间串行执行;rollback 还会把预览时的 receipt transaction id/hash 绑定到提交阶段,若另一事务替换 receipt 就安全拒绝。没有交互式 UI 时只提供手动恢复指引,不会写入文件。
286
286
 
287
287
  如果 `models.json` 自 fix 后没有变化,rollback 可以恢复完整的 pre-fix JSONC;如果文件发生变化,则绝不会盲目替换整个文件,只会在 receipt-owned scalar key 仍等于记录的 post-fix 值时撤销该 key,并保留之后的用户修改。如果 receipt-owned key 已变化、目标被删除/移动,或 fix 新建了目标项,命令会安全拒绝并指向记录的 backup 供手动检查。成功后 receipt 会标记为已回滚,并需要 `/reload` 或重启。
288
288
 
package/index.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
- import { chmod, copyFile, lstat, mkdir, readFile, readdir, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
2
+ import { chmod, copyFile, link, lstat, mkdir, readFile, readdir, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
3
3
  import { constants as fsConstants, readFileSync, statSync, watch } from "node:fs";
4
4
  import { homedir } from "node:os";
5
5
  import { basename, dirname, join } from "node:path";
@@ -204,6 +204,12 @@ type PromptCacheKeyConfigReceipt = {
204
204
  status?: "rolled_back";
205
205
  rolledBackAt?: number;
206
206
  };
207
+ type PromptCacheKeyConfigReceiptSnapshot = {
208
+ receipt: PromptCacheKeyConfigReceipt;
209
+ receiptPath: string;
210
+ hash: string;
211
+ identity: FileIdentity;
212
+ };
207
213
  const PI_ROUTING_REGISTRY_SYMBOL = Symbol.for("pi.routing.registry.v1");
208
214
  const PI_CACHE_HINTS_SYMBOL = Symbol.for("pi.cache.hints.v1");
209
215
  const PI_CACHE_HINTS_OWNER_SYMBOL = Symbol.for("pi.cache.optimizer.hints-owner.v1");
@@ -2041,21 +2047,7 @@ async function writePersistedCacheOptimizerConfigUnlocked(
2041
2047
  return;
2042
2048
  }
2043
2049
 
2044
- const tempPath = uniqueTempPath(configPath, "config");
2045
- try {
2046
- await writeFile(tempPath, payloadText, { encoding: "utf8", mode: targetMode, flag: "wx" });
2047
- await chmod(tempPath, targetMode);
2048
- try {
2049
- await lstat(configPath);
2050
- throw new Error("optimizer config appeared during write; no changes were made");
2051
- } catch (error) {
2052
- if (getErrorCode(error) !== "ENOENT") throw error;
2053
- }
2054
- await rename(tempPath, configPath);
2055
- } catch (error) {
2056
- await unlink(tempPath).catch((cleanupError) => { if (getErrorCode(cleanupError) !== "ENOENT") console.warn(`${LOG_PREFIX}: failed to remove temporary config`, cleanupError); });
2057
- throw error;
2058
- }
2050
+ await atomicCreateTextFileNoReplace(configPath, payloadText, targetMode, "config");
2059
2051
  }
2060
2052
 
2061
2053
  async function writePersistedCacheOptimizerConfig(
@@ -2087,24 +2079,15 @@ async function writePersistedFooterModeUnlocked(
2087
2079
  if (targetInfo && (targetInfo.isSymbolicLink() || !targetInfo.isFile())) throw new Error("optimizer config is not a regular file; no changes were made");
2088
2080
  const targetText = targetInfo ? await readFile(configPath, "utf8") : undefined;
2089
2081
  const targetMode = targetInfo ? targetInfo.mode & 0o7777 : 0o600;
2090
- const tempPath = uniqueTempPath(configPath, "config-footer");
2091
- try {
2092
- await writeFile(tempPath, JSON.stringify({ version: 1, footerMode: mode }, null, 2) + "\n", { encoding: "utf8", mode: targetMode, flag: "wx" });
2093
- await chmod(tempPath, targetMode);
2094
- if (targetInfo && targetText !== undefined) {
2095
- await validateAtomicTarget(configPath, { identity: targetInfo, hash: hashText(targetText), mode: targetMode });
2096
- } else {
2097
- try {
2098
- await lstat(configPath);
2099
- throw new Error("optimizer config appeared during footer write; no changes were made");
2100
- } catch (error) {
2101
- if (getErrorCode(error) !== "ENOENT") throw error;
2102
- }
2103
- }
2104
- await rename(tempPath, configPath);
2105
- } catch (error) {
2106
- await unlink(tempPath).catch(() => {});
2107
- throw error;
2082
+ const footerText = JSON.stringify({ version: 1, footerMode: mode }, null, 2) + "\n";
2083
+ if (targetInfo && targetText !== undefined) {
2084
+ await atomicReplaceTextFilePreservingMode(configPath, footerText, targetMode, "config-footer", {
2085
+ identity: targetInfo,
2086
+ hash: hashText(targetText),
2087
+ mode: targetMode,
2088
+ });
2089
+ } else {
2090
+ await atomicCreateTextFileNoReplace(configPath, footerText, targetMode, "config-footer");
2108
2091
  }
2109
2092
  return;
2110
2093
  }
@@ -2132,7 +2115,12 @@ function parsePromptCacheKeyConfigReceipt(value: unknown): PromptCacheKeyConfigR
2132
2115
  if (![record.transactionId, record.provider, record.modelId].every(isSafeReceiptText)) return undefined;
2133
2116
  if (!isSha256(record.beforeHash) || !isSha256(record.afterHash) || record.beforeHash === record.afterHash) return undefined;
2134
2117
  if (!isSafeReceiptText(record.backupFile) || basename(record.backupFile) !== record.backupFile || !record.backupFile.startsWith("pi-cache-optimizer-config.backup-")) return undefined;
2135
- if (typeof record.targetExistedBefore !== "boolean" || !isReceiptTimestamp(record.createdAt) || !isReceiptTimestamp(record.appliedAt)) return undefined;
2118
+ if (
2119
+ typeof record.targetExistedBefore !== "boolean" ||
2120
+ !isReceiptTimestamp(record.createdAt) ||
2121
+ !isReceiptTimestamp(record.appliedAt) ||
2122
+ record.appliedAt < record.createdAt
2123
+ ) return undefined;
2136
2124
  const addedModelKey = `${record.provider}/${record.modelId}`;
2137
2125
  const targetHadModelKey = record.version === 1 ? false : record.targetHadModelKey;
2138
2126
  if (typeof targetHadModelKey !== "boolean") return undefined;
@@ -2162,25 +2150,95 @@ function isActionablePromptCacheKeyConfigReceipt(receipt: PromptCacheKeyConfigRe
2162
2150
  return receipt !== undefined && receipt.status === undefined;
2163
2151
  }
2164
2152
 
2165
- async function readPromptCacheKeyConfigReceipt(receiptPath: string = CONFIG_RECEIPT_PATH): Promise<PromptCacheKeyConfigReceipt | undefined> {
2153
+ async function assertPromptCacheKeyConfigReceiptSnapshotUnchanged(
2154
+ snapshot: PromptCacheKeyConfigReceiptSnapshot,
2155
+ ): Promise<void> {
2156
+ const info = await lstat(snapshot.receiptPath);
2157
+ if (info.isSymbolicLink() || !info.isFile() || !sameFileIdentity(snapshot.identity, info)) {
2158
+ throw new Error("prompt-cache-key receipt changed since the rollback preview");
2159
+ }
2160
+ const text = await readFile(snapshot.receiptPath, "utf8");
2161
+ const afterRead = await lstat(snapshot.receiptPath);
2162
+ if (
2163
+ afterRead.isSymbolicLink() ||
2164
+ !afterRead.isFile() ||
2165
+ !sameFileIdentity(info, afterRead) ||
2166
+ hashText(text) !== snapshot.hash
2167
+ ) {
2168
+ throw new Error("prompt-cache-key receipt changed since the rollback preview");
2169
+ }
2170
+ }
2171
+
2172
+ async function readPromptCacheKeyConfigReceiptSnapshot(
2173
+ receiptPath: string = CONFIG_RECEIPT_PATH,
2174
+ ): Promise<PromptCacheKeyConfigReceiptSnapshot | undefined> {
2166
2175
  try {
2167
2176
  const info = await lstat(receiptPath);
2168
2177
  if (info.isSymbolicLink() || !info.isFile()) return undefined;
2169
- const receipt = parsePromptCacheKeyConfigReceipt(JSON.parse(await readFile(receiptPath, "utf8")));
2170
- return receipt;
2178
+ const text = await readFile(receiptPath, "utf8");
2179
+ const afterRead = await lstat(receiptPath);
2180
+ if (afterRead.isSymbolicLink() || !afterRead.isFile() || !sameFileIdentity(info, afterRead)) return undefined;
2181
+ const receipt = parsePromptCacheKeyConfigReceipt(JSON.parse(text));
2182
+ if (!receipt) return undefined;
2183
+ return { receipt, receiptPath, hash: hashText(text), identity: afterRead };
2171
2184
  } catch {
2172
2185
  return undefined;
2173
2186
  }
2174
2187
  }
2175
2188
 
2176
- async function writePromptCacheKeyConfigReceipt(receipt: PromptCacheKeyConfigReceipt, receiptPath: string = CONFIG_RECEIPT_PATH): Promise<void> {
2189
+ async function readPromptCacheKeyConfigReceipt(receiptPath: string = CONFIG_RECEIPT_PATH): Promise<PromptCacheKeyConfigReceipt | undefined> {
2190
+ return (await readPromptCacheKeyConfigReceiptSnapshot(receiptPath))?.receipt;
2191
+ }
2192
+
2193
+ async function writePromptCacheKeyConfigReceipt(
2194
+ receipt: PromptCacheKeyConfigReceipt,
2195
+ receiptPath: string = CONFIG_RECEIPT_PATH,
2196
+ expectedSnapshot?: PromptCacheKeyConfigReceiptSnapshot,
2197
+ /** Test-only race injector; production callers leave this undefined. */
2198
+ beforeRename?: () => Promise<void>,
2199
+ ): Promise<void> {
2177
2200
  if (!parsePromptCacheKeyConfigReceipt(receipt)) throw new Error("invalid prompt cache key config receipt");
2201
+ if (expectedSnapshot?.receiptPath !== undefined && expectedSnapshot.receiptPath !== receiptPath) {
2202
+ throw new Error("prompt-cache-key receipt path changed since the rollback preview");
2203
+ }
2204
+ if (expectedSnapshot) await assertPromptCacheKeyConfigReceiptSnapshotUnchanged(expectedSnapshot);
2178
2205
  await mkdir(dirname(receiptPath), { recursive: true });
2206
+ let existingReceiptInfo: Awaited<ReturnType<typeof lstat>> | undefined;
2207
+ try {
2208
+ const info = await lstat(receiptPath);
2209
+ if (info.isSymbolicLink() || !info.isFile()) throw new Error("invalid prompt-cache-key receipt path");
2210
+ existingReceiptInfo = info;
2211
+ } catch (error) {
2212
+ if (getErrorCode(error) !== "ENOENT") throw error;
2213
+ }
2179
2214
  const tempPath = uniqueTempPath(receiptPath, "config-receipt");
2180
2215
  try {
2181
2216
  await writeFile(tempPath, JSON.stringify(receipt, null, 2) + "\n", { encoding: "utf8", mode: 0o600, flag: "wx" });
2217
+ const tempInfo = await lstat(tempPath);
2218
+ if (tempInfo.isSymbolicLink() || !tempInfo.isFile()) throw new Error("invalid temporary prompt-cache-key receipt");
2182
2219
  await chmod(tempPath, 0o600);
2183
- await rename(tempPath, receiptPath);
2220
+ const assertDestinationUnchanged = async (): Promise<void> => {
2221
+ try {
2222
+ const currentInfo = await lstat(receiptPath);
2223
+ if (!existingReceiptInfo || !sameFileIdentity(existingReceiptInfo, currentInfo)) {
2224
+ throw new Error("prompt-cache-key receipt changed during atomic write");
2225
+ }
2226
+ } catch (error) {
2227
+ if (getErrorCode(error) !== "ENOENT" || existingReceiptInfo) throw error;
2228
+ }
2229
+ if (expectedSnapshot) await assertPromptCacheKeyConfigReceiptSnapshotUnchanged(expectedSnapshot);
2230
+ };
2231
+ await assertDestinationUnchanged();
2232
+ if (beforeRename) await beforeRename();
2233
+ await assertDestinationUnchanged();
2234
+ if (existingReceiptInfo) {
2235
+ await rename(tempPath, receiptPath);
2236
+ } else {
2237
+ await link(tempPath, receiptPath);
2238
+ await unlink(tempPath).catch((cleanupError) => {
2239
+ console.warn(`${LOG_PREFIX}: committed prompt-cache-key receipt but failed to remove its temporary hard link`, cleanupError);
2240
+ });
2241
+ }
2184
2242
  } catch (error) {
2185
2243
  await unlink(tempPath).catch((cleanupError) => { if (getErrorCode(cleanupError) !== "ENOENT") console.warn(`${LOG_PREFIX}: failed to remove temporary config receipt`, cleanupError); });
2186
2244
  throw error;
@@ -2211,6 +2269,7 @@ async function applyPromptCacheKeyConfigFixUnderLock(
2211
2269
  const key = modelKey(model);
2212
2270
  const existingOmit = current.promptCacheKey?.omit ?? [];
2213
2271
  const targetHadModelKey = existingOmit.includes(key);
2272
+ if (targetHadModelKey) throw new Error("prompt-cache-key is already configured; no changes were made");
2214
2273
  const omit = [...new Set([...existingOmit, key])].sort();
2215
2274
  const next: PersistedCacheOptimizerConfigV2 = { ...current, version: 2, promptCacheKey: { omit } };
2216
2275
  const modifiedText = JSON.stringify(next, null, 2) + "\n";
@@ -2238,8 +2297,19 @@ async function applyPromptCacheKeyConfigFixUnderLock(
2238
2297
  if (getErrorCode(error) !== "ENOENT") throw error;
2239
2298
  }
2240
2299
  }
2241
- await rename(tempPath, configPath);
2242
- committedInfo = await lstat(configPath);
2300
+ if (originalInfo) {
2301
+ await rename(tempPath, configPath);
2302
+ committedInfo = await lstat(configPath);
2303
+ } else {
2304
+ // Do not overwrite a config created after the absence check. Record the
2305
+ // committed inode before cleaning up its temporary hard-link name so a
2306
+ // cleanup failure can still compensate the config transaction.
2307
+ await link(tempPath, configPath);
2308
+ committedInfo = await lstat(configPath);
2309
+ await unlink(tempPath).catch((cleanupError) => {
2310
+ console.warn(`${LOG_PREFIX}: committed optimizer config but failed to remove its temporary hard link`, cleanupError);
2311
+ });
2312
+ }
2243
2313
  const receipt: PromptCacheKeyConfigReceipt = {
2244
2314
  version: 2,
2245
2315
  kind: "pi-cache-optimizer-config-receipt",
@@ -2259,10 +2329,17 @@ async function applyPromptCacheKeyConfigFixUnderLock(
2259
2329
  return { receipt, backupPath };
2260
2330
  } catch (error) {
2261
2331
  await unlink(tempPath).catch(() => {});
2262
- if (targetExistedBefore && committedInfo) {
2263
- await atomicRestoreFileFromBackup(backupPath, configPath, mode, { identity: committedInfo, hash: afterHash, mode }).catch(() => {});
2264
- } else if (!targetExistedBefore && committedInfo) {
2265
- await validateAtomicTarget(configPath, { identity: committedInfo, hash: afterHash, mode }).then(() => unlink(configPath)).catch(() => {});
2332
+ try {
2333
+ if (targetExistedBefore && committedInfo) {
2334
+ await atomicRestoreFileFromBackup(backupPath, configPath, mode, { identity: committedInfo, hash: afterHash, mode });
2335
+ } else if (!targetExistedBefore && committedInfo) {
2336
+ await validateAtomicTarget(configPath, { identity: committedInfo, hash: afterHash, mode });
2337
+ await unlink(configPath);
2338
+ }
2339
+ } catch (compensationError) {
2340
+ const writeMessage = error instanceof Error ? error.message : String(error);
2341
+ const compensationMessage = compensationError instanceof Error ? compensationError.message : String(compensationError);
2342
+ throw new Error(`prompt-cache-key fix receipt update failed (${writeMessage}) and config compensation failed (${compensationMessage})`);
2266
2343
  }
2267
2344
  throw error;
2268
2345
  }
@@ -2276,15 +2353,26 @@ async function applyPromptCacheKeyConfigFix(
2276
2353
  return withModelsJsonTransactionLock(() => applyPromptCacheKeyConfigFixUnderLock(model, configPath, receiptPath));
2277
2354
  }
2278
2355
 
2356
+ type PromptCacheKeyRollbackOptions = {
2357
+ /** Test-only race injector; production callers leave this undefined. */
2358
+ beforeReceiptRename?: () => Promise<void>;
2359
+ };
2360
+
2279
2361
  async function rollbackPromptCacheKeyConfigUnderLock(
2280
- receipt: PromptCacheKeyConfigReceipt,
2362
+ snapshot: PromptCacheKeyConfigReceiptSnapshot,
2281
2363
  configPath: string = CONFIG_FILE_PATH,
2282
2364
  receiptPath: string = CONFIG_RECEIPT_PATH,
2365
+ options: PromptCacheKeyRollbackOptions = {},
2283
2366
  ): Promise<void> {
2367
+ if (snapshot.receiptPath !== receiptPath) throw new Error("prompt-cache-key receipt path changed since the rollback preview");
2368
+ await assertPromptCacheKeyConfigReceiptSnapshotUnchanged(snapshot);
2369
+ const receipt = snapshot.receipt;
2284
2370
  const currentInfo = await lstat(configPath);
2285
2371
  if (currentInfo.isSymbolicLink() || !currentInfo.isFile()) throw new Error("optimizer config is not a regular file; refusing to overwrite user changes");
2286
2372
  const currentText = await readFile(configPath, "utf8");
2287
- if (hashText(currentText) !== receipt.afterHash) throw new Error("optimizer config changed after the fix; refusing to overwrite user changes");
2373
+ const currentHash = hashText(currentText);
2374
+ const currentMode = currentInfo.mode & 0o7777;
2375
+ if (currentHash !== receipt.afterHash) throw new Error("optimizer config changed after the fix; refusing to overwrite user changes");
2288
2376
  const current = parsePersistedCacheOptimizerConfig(JSON.parse(currentText));
2289
2377
  if (!current) throw new Error("optimizer config is invalid; refusing to overwrite user changes");
2290
2378
  const key = `${receipt.provider}/${receipt.modelId}`;
@@ -2292,46 +2380,90 @@ async function rollbackPromptCacheKeyConfigUnderLock(
2292
2380
  if (receipt.targetHadModelKey) throw new Error("prompt-cache-key was already configured before this fix; refusing to remove user configuration");
2293
2381
  const omit = current.version === 2 ? current.promptCacheKey?.omit ?? [] : [];
2294
2382
  if (!omit.includes(receipt.addedModelKey)) throw new Error("prompt-cache-key opt-out is no longer present; refusing to change user config");
2383
+
2384
+ let rollbackResultText: string | undefined;
2385
+ let rollbackResultMode: number | undefined;
2386
+ let rollbackResultInfo: Awaited<ReturnType<typeof lstat>> | undefined;
2295
2387
  const backupPath = configReceiptBackupPath(receipt, receiptPath);
2296
2388
  if (receipt.targetExistedBefore) {
2297
2389
  const backupInfo = await lstat(backupPath);
2298
2390
  if (backupInfo.isSymbolicLink() || !backupInfo.isFile()) throw new Error("config backup is not a regular file");
2299
2391
  const backupText = await readFile(backupPath, "utf8");
2300
2392
  if (hashText(backupText) !== receipt.beforeHash) throw new Error("config backup hash does not match the fix receipt");
2393
+ rollbackResultText = backupText;
2394
+ rollbackResultMode = backupInfo.mode & 0o7777;
2301
2395
  await atomicRestoreFileFromBackup(
2302
2396
  backupPath,
2303
2397
  configPath,
2304
- backupInfo.mode & 0o7777,
2305
- { backupHash: receipt.beforeHash, identity: currentInfo, hash: hashText(currentText), mode: currentInfo.mode & 0o7777 },
2398
+ rollbackResultMode,
2399
+ { backupHash: receipt.beforeHash, identity: currentInfo, hash: currentHash, mode: currentMode },
2306
2400
  );
2401
+ rollbackResultInfo = await lstat(configPath);
2402
+ } else if (current.footerMode || omit.length > 1) {
2403
+ const restored: PersistedCacheOptimizerConfigV2 = {
2404
+ version: 2,
2405
+ ...(current.footerMode ? { footerMode: current.footerMode } : {}),
2406
+ ...(omit.length > 1 ? { promptCacheKey: { omit: omit.filter((item) => item !== receipt.addedModelKey) } } : {}),
2407
+ };
2408
+ rollbackResultText = JSON.stringify(restored, null, 2) + "\n";
2409
+ rollbackResultMode = currentMode;
2410
+ await atomicReplaceTextFilePreservingMode(
2411
+ configPath,
2412
+ rollbackResultText,
2413
+ rollbackResultMode,
2414
+ "config-rollback",
2415
+ { identity: currentInfo, hash: currentHash, mode: currentMode },
2416
+ );
2417
+ rollbackResultInfo = await lstat(configPath);
2307
2418
  } else {
2308
- if (current.footerMode || omit.length > 1) {
2309
- const restored: PersistedCacheOptimizerConfigV2 = {
2310
- version: 2,
2311
- ...(current.footerMode ? { footerMode: current.footerMode } : {}),
2312
- ...(omit.length > 1 ? { promptCacheKey: { omit: omit.filter((item) => item !== receipt.addedModelKey) } } : {}),
2313
- };
2314
- await atomicReplaceTextFilePreservingMode(
2315
- configPath,
2316
- JSON.stringify(restored, null, 2) + "\n",
2317
- currentInfo.mode & 0o7777,
2318
- "config-rollback",
2319
- { identity: currentInfo, hash: hashText(currentText), mode: currentInfo.mode & 0o7777 },
2320
- );
2321
- } else {
2322
- await validateAtomicTarget(configPath, { identity: currentInfo, hash: hashText(currentText), mode: currentInfo.mode & 0o7777 });
2323
- await unlink(configPath);
2419
+ await validateAtomicTarget(configPath, { identity: currentInfo, hash: currentHash, mode: currentMode });
2420
+ await unlink(configPath);
2421
+ }
2422
+
2423
+ try {
2424
+ await writePromptCacheKeyConfigReceipt(
2425
+ { ...receipt, status: "rolled_back", rolledBackAt: Date.now() },
2426
+ receiptPath,
2427
+ snapshot,
2428
+ options.beforeReceiptRename,
2429
+ );
2430
+ } catch (receiptError) {
2431
+ // Receipt marking is part of the transaction. If it fails after the config
2432
+ // mutation, restore the exact post-fix config rather than leaving an
2433
+ // actionable receipt paired with an already-rolled-back file.
2434
+ try {
2435
+ if (rollbackResultText === undefined) {
2436
+ await atomicCreateTextFileNoReplace(configPath, currentText, currentMode, "config-rollback-compensation");
2437
+ } else {
2438
+ if (!rollbackResultInfo || rollbackResultMode === undefined) throw new Error("missing rollback result guard");
2439
+ await atomicReplaceTextFilePreservingMode(
2440
+ configPath,
2441
+ currentText,
2442
+ currentMode,
2443
+ "config-rollback-compensation",
2444
+ {
2445
+ identity: rollbackResultInfo,
2446
+ hash: hashText(rollbackResultText),
2447
+ mode: rollbackResultMode,
2448
+ },
2449
+ );
2450
+ }
2451
+ } catch (compensationError) {
2452
+ const receiptMessage = receiptError instanceof Error ? receiptError.message : String(receiptError);
2453
+ const compensationMessage = compensationError instanceof Error ? compensationError.message : String(compensationError);
2454
+ throw new Error(`prompt-cache-key rollback receipt update failed (${receiptMessage}) and config compensation failed (${compensationMessage})`);
2324
2455
  }
2456
+ throw receiptError;
2325
2457
  }
2326
- await writePromptCacheKeyConfigReceipt({ ...receipt, status: "rolled_back", rolledBackAt: Date.now() }, receiptPath);
2327
2458
  }
2328
2459
 
2329
2460
  async function rollbackPromptCacheKeyConfig(
2330
- receipt: PromptCacheKeyConfigReceipt,
2461
+ snapshot: PromptCacheKeyConfigReceiptSnapshot,
2331
2462
  configPath: string = CONFIG_FILE_PATH,
2332
2463
  receiptPath: string = CONFIG_RECEIPT_PATH,
2464
+ options: PromptCacheKeyRollbackOptions = {},
2333
2465
  ): Promise<void> {
2334
- return withModelsJsonTransactionLock(() => rollbackPromptCacheKeyConfigUnderLock(receipt, configPath, receiptPath));
2466
+ return withModelsJsonTransactionLock(() => rollbackPromptCacheKeyConfigUnderLock(snapshot, configPath, receiptPath, options));
2335
2467
  }
2336
2468
 
2337
2469
  function resolveFooterStatsMode(
@@ -2470,7 +2602,7 @@ function isPiBuiltInLlamaCppModel(model: PiModel | undefined): boolean {
2470
2602
  }
2471
2603
 
2472
2604
  function shouldInjectOpenAIPromptCacheKeyForModel(model: PiModel | undefined): boolean {
2473
- // Pi 0.85.1 has no native supportsPromptCacheKey compat field. Per-model
2605
+ // Pi 0.87.1 has no native supportsPromptCacheKey compat field. Per-model
2474
2606
  // opt-out is owned by this extension's promptCacheKey.omit configuration;
2475
2607
  // this helper only exposes the transport API gate for fixture consumers.
2476
2608
  return isOpenAICompatibleApi(model?.api);
@@ -3679,22 +3811,25 @@ function hasPromptCacheRetentionUnsupportedErrorMessage(message: unknown): boole
3679
3811
  }
3680
3812
 
3681
3813
  function hasPromptCacheKeyUnsupportedText(value: unknown): boolean {
3682
- const normalized = lower(value).replace(/["'`]/g, "").replace(/[_-]+/g, "_").replace(/\s+/g, " ").trim();
3683
- const key = "(?:prompt ?cache ?key|prompt_cache_key)";
3814
+ const normalized = lower(value)
3815
+ .replace(/["'`]/g, "")
3816
+ .replace(/[\s_-]+/g, " ")
3817
+ .replace(/\s+/g, " ")
3818
+ .trim();
3819
+ const key = String.raw`(?:prompt ?cache ?key|promptcachekey)`;
3820
+ const field = String.raw`(?:parameter|field|input|argument)`;
3821
+ const unsupported = String.raw`(?:unsupported|unknown|unrecognized|unexpected|not supported|not allowed|not permitted|must be omitted|should be omitted)`;
3684
3822
  if (!new RegExp(key).test(normalized)) return false;
3685
3823
 
3686
- // Only classify a field-level capability rejection. Value validation,
3687
- // conditional restrictions, and generic invalid-parameter messages are not
3688
- // proof that the endpoint lacks support for the field.
3689
- return [
3690
- new RegExp(`unsupported (?:parameter|field|input|argument)[: ]+.*${key}`),
3691
- new RegExp(`unknown (?:parameter|field|input|argument)[: ]+.*${key}`),
3692
- new RegExp(`unrecognized (?:parameter|field|input|argument)[: ]+.*${key}`),
3693
- new RegExp(`(?:parameter|field|input|argument)[: ]+${key} (?:is )?(?:unsupported|unknown|unrecognized|not supported|not allowed|not permitted)`),
3694
- new RegExp(`${key} (?:is )?(?:unsupported|unknown|unrecognized|not supported|not allowed|not permitted)`),
3695
- new RegExp(`extra inputs?(?: are)? (?:not permitted|not allowed)[: ]+.*${key}`),
3696
- new RegExp(`(?:not supported|not permitted|not allowed)[: ]+.*${key}`),
3697
- ].some((pattern) => pattern.test(normalized));
3824
+ // Keep this deliberately grammar-bound. A rejected value or a conditional
3825
+ // restriction (for example, "not allowed when temperature is set") is not
3826
+ // proof that the endpoint lacks support for the field itself.
3827
+ const terminal = String.raw`(?=$|[}\]>,.;])`;
3828
+ return (
3829
+ new RegExp(String.raw`(?:unsupported|unknown|unrecognized|unexpected)\s+${field}\s*[:=]?\s*${key}${terminal}`).test(normalized) ||
3830
+ new RegExp(String.raw`(?:extra\s+inputs?|${field}\s+not\s+(?:allowed|permitted|supported))\s*[:=]\s*${key}${terminal}`).test(normalized) ||
3831
+ new RegExp(String.raw`${key}(?:\s+${field})?\s*[:=]?\s*(?:is\s+)?${unsupported}${terminal}`).test(normalized)
3832
+ );
3698
3833
  }
3699
3834
 
3700
3835
  function hasPromptCacheKeyUnsupportedSignal(headers: Record<string, string> | undefined): boolean {
@@ -4065,7 +4200,7 @@ function buildOpenAIProxyCompatWarningText(key: string, missing: string[]): stri
4065
4200
  const modelsJsonPath = getModelsJsonDisplayPath();
4066
4201
  const lines: string[] = [
4067
4202
  `💡 pi-cache-optimizer: ${key} is a third-party OpenAI-compatible proxy but merged compat lacks ${missing.join(" and ")}.`,
4068
- `Edit ${modelsJsonPath} -> providers["${providerLabel}"] -> compat (at the same level as baseUrl/api/apiKey/models).`,
4203
+ `Run /cache-optimizer fix to preview a confirmed repair (or edit ${modelsJsonPath} -> providers["${providerLabel}"] -> compat manually).`,
4069
4204
  ``,
4070
4205
  ];
4071
4206
 
@@ -5177,7 +5312,12 @@ function notifyCacheCompatIfNeeded(
5177
5312
  const text = adapter?.warningText?.(model);
5178
5313
  if (!adapter || !text) return;
5179
5314
 
5180
- const key = `${adapter.id}:${modelKey(model)}`;
5315
+ const affinityOnly = adapter.warningText !== undefined &&
5316
+ describeMissingOpenAICompatibleProxyCompat(model).length === 1 &&
5317
+ describeMissingCacheCompatForModel(model).length === 1;
5318
+ const key = affinityOnly
5319
+ ? `proxy-affinity:${model.provider}`
5320
+ : `${adapter.id}:${modelKey(model)}`;
5181
5321
  if (warnedModels.has(key)) return;
5182
5322
  warnedModels.add(key);
5183
5323
 
@@ -7290,6 +7430,50 @@ interface ModelOverrideNodeLocation {
7290
7430
  modelOverrideCompatEnd: number;
7291
7431
  }
7292
7432
 
7433
+ interface ProviderCompatNodeLocation {
7434
+ providerObjectBrace: number;
7435
+ providerObjectEnd: number;
7436
+ providerCompatBrace: number;
7437
+ providerCompatEnd: number;
7438
+ }
7439
+
7440
+ /** Locate a unique provider and its direct compat object without requiring models[]. */
7441
+ function locateProviderCompatInJsonc(
7442
+ text: string,
7443
+ providerLabel: string,
7444
+ ): ProviderCompatNodeLocation | undefined {
7445
+ const clean = stripJsoncComments(text);
7446
+ const rootBrace = skipJsonWhitespace(clean, 0);
7447
+ if (clean[rootBrace] !== "{") return undefined;
7448
+ const providersKey = findJsonObjectKey(clean, rootBrace, "providers");
7449
+ if (!providersKey || providersKey.count !== 1) return undefined;
7450
+ const providersBrace = skipJsonWhitespace(clean, providersKey.valueStart);
7451
+ if (clean[providersBrace] !== "{") return undefined;
7452
+ const providersEnd = findMatchingBracket(clean, providersBrace);
7453
+ if (providersEnd === undefined) return undefined;
7454
+ const providerKey = findJsonObjectKey(clean, providersBrace, providerLabel);
7455
+ if (!providerKey || providerKey.count !== 1 || providerKey.keyStart > providersEnd) return undefined;
7456
+ const providerObjectBrace = skipJsonWhitespace(clean, providerKey.valueStart);
7457
+ if (clean[providerObjectBrace] !== "{") return undefined;
7458
+ const providerObjectEnd = findMatchingBracket(clean, providerObjectBrace);
7459
+ if (providerObjectEnd === undefined || providerObjectEnd > providersEnd) return undefined;
7460
+
7461
+ for (const key of ["models", "modelOverrides"]) {
7462
+ const child = findJsonObjectKey(clean, providerObjectBrace, key);
7463
+ if (child?.count && child.count > 1) return undefined;
7464
+ }
7465
+ const compatKey = findJsonObjectKey(clean, providerObjectBrace, "compat");
7466
+ if (compatKey?.count && compatKey.count > 1) return undefined;
7467
+ if (!compatKey) {
7468
+ return { providerObjectBrace, providerObjectEnd, providerCompatBrace: -1, providerCompatEnd: -1 };
7469
+ }
7470
+ const providerCompatBrace = skipJsonWhitespace(clean, compatKey.valueStart);
7471
+ if (clean[providerCompatBrace] !== "{") return undefined;
7472
+ const providerCompatEnd = findMatchingBracket(clean, providerCompatBrace);
7473
+ if (providerCompatEnd === undefined || providerCompatEnd > providerObjectEnd) return undefined;
7474
+ return { providerObjectBrace, providerObjectEnd, providerCompatBrace, providerCompatEnd };
7475
+ }
7476
+
7293
7477
  /** Locate a provider and optional modelOverrides entry without requiring models[]. */
7294
7478
  function locateModelOverrideInJsonc(
7295
7479
  text: string,
@@ -8248,9 +8432,44 @@ function chooseFixPlacement(
8248
8432
  return decision;
8249
8433
  }
8250
8434
 
8435
+ type CompatInsertionLocation = Pick<ModelNodeLocation,
8436
+ | "modelObjectBrace"
8437
+ | "compatObjectBrace"
8438
+ | "compatObjectEnd"
8439
+ | "providerObjectBrace"
8440
+ | "providerCompatBrace"
8441
+ | "providerCompatEnd"
8442
+ | "modelOverrideObjectBrace"
8443
+ | "modelOverrideCompatBrace"
8444
+ | "modelOverrideCompatEnd"
8445
+ >;
8446
+
8447
+ function composeProviderAffinityInsertion(
8448
+ original: string,
8449
+ providerLabel: string,
8450
+ ): { modifiedText: string; placementLabel: string } | undefined {
8451
+ const location = locateProviderCompatInJsonc(original, providerLabel);
8452
+ if (!location) return undefined;
8453
+ const node: CompatInsertionLocation = {
8454
+ modelObjectBrace: -1,
8455
+ compatObjectBrace: -1,
8456
+ compatObjectEnd: -1,
8457
+ providerObjectBrace: location.providerObjectBrace,
8458
+ providerCompatBrace: location.providerCompatBrace,
8459
+ providerCompatEnd: location.providerCompatEnd,
8460
+ modelOverrideObjectBrace: -1,
8461
+ modelOverrideCompatBrace: -1,
8462
+ modelOverrideCompatEnd: -1,
8463
+ };
8464
+ return {
8465
+ modifiedText: composeFixInsertion(original, node, { sendSessionAffinityHeaders: true }, "provider"),
8466
+ placementLabel: `providers["${providerLabel}"] -> compat (provider level)`,
8467
+ };
8468
+ }
8469
+
8251
8470
  function composeFixInsertion(
8252
8471
  original: string,
8253
- location: ModelNodeLocation,
8472
+ location: CompatInsertionLocation,
8254
8473
  compatKeys: Record<string, unknown>,
8255
8474
  placement: "provider" | "model" | "modelOverride" = "model",
8256
8475
  ): string {
@@ -8688,6 +8907,41 @@ async function atomicReplaceTextFilePreservingMode(
8688
8907
  }
8689
8908
  }
8690
8909
 
8910
+ async function atomicCreateTextFileNoReplace(
8911
+ targetPath: string,
8912
+ content: string,
8913
+ mode: number,
8914
+ purpose: string,
8915
+ /** Test-only race injector; production callers leave this undefined. */
8916
+ beforeLink?: () => Promise<void>,
8917
+ ): Promise<void> {
8918
+ const tempPath = uniqueTempPath(targetPath, purpose);
8919
+ try {
8920
+ await writeFile(tempPath, content, { encoding: "utf8", mode, flag: "wx" });
8921
+ const tempInfo = await lstat(tempPath);
8922
+ if (tempInfo.isSymbolicLink() || !tempInfo.isFile()) {
8923
+ throw new Error("temporary creation is not a regular file");
8924
+ }
8925
+ await chmod(tempPath, mode);
8926
+ // A hard link gives us an atomic no-replace create. Unlike rename(), it
8927
+ // cannot overwrite a file that appeared after the absence check.
8928
+ if (beforeLink) await beforeLink();
8929
+ await link(tempPath, targetPath);
8930
+ await unlink(tempPath).catch((cleanupError) => {
8931
+ console.warn(`${LOG_PREFIX}: committed config file but failed to remove its temporary hard link`, cleanupError);
8932
+ });
8933
+ } catch (error) {
8934
+ try {
8935
+ await unlink(tempPath);
8936
+ } catch (cleanupError) {
8937
+ if (getErrorCode(cleanupError) !== "ENOENT") {
8938
+ console.warn(`${LOG_PREFIX}: failed to remove temporary config file`, cleanupError);
8939
+ }
8940
+ }
8941
+ throw error;
8942
+ }
8943
+ }
8944
+
8691
8945
  async function atomicRestoreFileFromBackup(
8692
8946
  backupPath: string,
8693
8947
  targetPath: string,
@@ -9205,7 +9459,14 @@ async function writeModelsJsonFixReceipt(
9205
9459
  await assertReceiptDestinationUnchanged();
9206
9460
  if (beforeRename) await beforeRename();
9207
9461
  await assertReceiptDestinationUnchanged();
9208
- await rename(tempPath, receiptPath);
9462
+ if (existingReceiptInfo) {
9463
+ await rename(tempPath, receiptPath);
9464
+ } else {
9465
+ await link(tempPath, receiptPath);
9466
+ await unlink(tempPath).catch((cleanupError) => {
9467
+ console.warn(`${LOG_PREFIX}: committed fix receipt but failed to remove its temporary hard link`, cleanupError);
9468
+ });
9469
+ }
9209
9470
  } catch (error) {
9210
9471
  try {
9211
9472
  await unlink(tempPath);
@@ -9287,20 +9548,27 @@ function locateReceiptCompatTarget(
9287
9548
  };
9288
9549
  }
9289
9550
 
9551
+ if (placement === "provider") {
9552
+ const location = locateProviderCompatInJsonc(text, provider);
9553
+ if (!location) return undefined;
9554
+ return {
9555
+ targetExists: true,
9556
+ compatBrace: location.providerCompatBrace,
9557
+ compatEnd: location.providerCompatEnd,
9558
+ };
9559
+ }
9290
9560
  const location = locateModelInJsonc(text, provider, modelId);
9291
9561
  if (!location || location.providerKeyCount !== 1) return undefined;
9292
- // A changed-file surgical rollback cannot tell which duplicate model object
9293
- // was the receipt's target. Refuse that ambiguity instead of touching a
9294
- // later user-added definition. Provider-level changes do not need to inspect
9295
- // model compat, but duplicate target ids still make the receipt identity
9296
- // ambiguous and are rejected for both placements.
9562
+ // A changed-file model-scoped rollback cannot tell which duplicate model
9563
+ // object was the receipt's target. Refuse that ambiguity instead of touching
9564
+ // a later user-added definition. Provider-level receipts returned above do
9565
+ // not depend on model-array identity.
9297
9566
  if (location.allModelIds.filter((id) => id === modelId).length !== 1) return undefined;
9298
- if (placement === "provider" && location.providerCompatKeyCount > 1) return undefined;
9299
- if (placement === "model" && location.modelCompatKeyCount > 1) return undefined;
9567
+ if (location.modelCompatKeyCount > 1) return undefined;
9300
9568
  return {
9301
9569
  targetExists: location.modelObjectBrace >= 0,
9302
- compatBrace: placement === "provider" ? location.providerCompatBrace : location.compatObjectBrace,
9303
- compatEnd: placement === "provider" ? location.providerCompatEnd : location.compatObjectEnd,
9570
+ compatBrace: location.compatObjectBrace,
9571
+ compatEnd: location.compatObjectEnd,
9304
9572
  };
9305
9573
  }
9306
9574
 
@@ -9925,6 +10193,7 @@ export const __internals_for_tests = {
9925
10193
  parsePromptCacheKeyConfigReceipt,
9926
10194
  isActionablePromptCacheKeyConfigReceipt,
9927
10195
  readPromptCacheKeyConfigReceipt,
10196
+ readPromptCacheKeyConfigReceiptSnapshot,
9928
10197
  writePromptCacheKeyConfigReceipt,
9929
10198
  applyPromptCacheKeyConfigFix,
9930
10199
  rollbackPromptCacheKeyConfig,
@@ -9985,11 +10254,13 @@ export const __internals_for_tests = {
9985
10254
  hasExplicitLongRetentionOptInFromConfig,
9986
10255
  locateModelInJsonc,
9987
10256
  locateModelOverrideInJsonc,
10257
+ locateProviderCompatInJsonc,
9988
10258
  composeFixInsertion,
9989
10259
  selfCheckFix,
9990
10260
  analyzeModelsJsonForMissingEntry,
9991
10261
  composeMissingEntryInsertion,
9992
10262
  composeModelOverrideInsertion,
10263
+ composeProviderAffinityInsertion,
9993
10264
  selfCheckMissingEntryInsertion,
9994
10265
  decideFixPlacement,
9995
10266
  chooseFixPlacement,
@@ -9998,6 +10269,7 @@ export const __internals_for_tests = {
9998
10269
  formatCompatKeysForInsertion,
9999
10270
  backupTimestamp,
10000
10271
  atomicReplaceTextFilePreservingMode,
10272
+ atomicCreateTextFileNoReplace,
10001
10273
  atomicRestoreFileFromBackup,
10002
10274
  applyModelsJsonFixTransaction,
10003
10275
  hashText,
@@ -10071,7 +10343,11 @@ export default function (pi: ExtensionAPI) {
10071
10343
  // finalized assistant message consumes the oldest completed record, falling
10072
10344
  // back to the oldest request only when a transport emitted no response hook.
10073
10345
  // This preserves response A when request B/C starts before message_end(A).
10074
- type ProviderRequestState = { model: PiModel; responseReceived: boolean };
10346
+ type ProviderRequestState = {
10347
+ model: PiModel;
10348
+ responseReceived: boolean;
10349
+ correlationAmbiguous: boolean;
10350
+ };
10075
10351
  const providerRequestStates: ProviderRequestState[] = [];
10076
10352
  let shardCreatedAt = Date.now();
10077
10353
  const PERSIST_DEBOUNCE_MS = 2000;
@@ -10094,7 +10370,12 @@ export default function (pi: ExtensionAPI) {
10094
10370
  };
10095
10371
  }
10096
10372
  if (isSessionAffinity403Applicable(model) && sendSessionAffinityHeaders403Models.has(key)) {
10097
- return { providerLabel, modelId: model.id, compatKeys: { sendSessionAffinityHeaders: false } };
10373
+ return {
10374
+ providerLabel,
10375
+ modelId: model.id,
10376
+ compatKeys: { sendSessionAffinityHeaders: false },
10377
+ forceModelLevel: true,
10378
+ };
10098
10379
  }
10099
10380
  return undefined;
10100
10381
  }
@@ -10772,10 +11053,11 @@ export default function (pi: ExtensionAPI) {
10772
11053
 
10773
11054
  pi.on("before_provider_request", (event, ctx) => {
10774
11055
  const requestModel = resolveRouteModel(ctx.model, ctx) ?? ctx.model;
10775
- if (runtimeOptimizerEnabled) {
10776
- const snapshot = snapshotProviderRequestModel(requestModel);
10777
- if (snapshot) providerRequestStates.push({ model: snapshot, responseReceived: false });
10778
- }
11056
+ // Request-local identity is also needed by the always-on Anthropic TTL
11057
+ // validity repair, so retain the credential-blind snapshot even while the
11058
+ // optional runtime optimizer features are disabled.
11059
+ const snapshot = snapshotProviderRequestModel(requestModel);
11060
+ if (snapshot) providerRequestStates.push({ model: snapshot, responseReceived: false, correlationAmbiguous: false });
10779
11061
  let requestPayload: unknown = event.payload;
10780
11062
  let toolOrderChanged = false;
10781
11063
 
@@ -10843,9 +11125,25 @@ export default function (pi: ExtensionAPI) {
10843
11125
  });
10844
11126
 
10845
11127
  pi.on("after_provider_response", async (event, ctx) => {
10846
- const responseState = providerRequestStates.find((state) => !state.responseReceived);
11128
+ const pendingStates = providerRequestStates.filter((state) => !state.responseReceived);
11129
+ let responseState: ProviderRequestState | undefined;
11130
+ if (pendingStates.length === 1) {
11131
+ responseState = pendingStates[0];
11132
+ } else if (pendingStates.length > 1) {
11133
+ const pendingModelKeys = new Set(pendingStates.map((state) => modelKey(state.model)));
11134
+ if (pendingModelKeys.size === 1) {
11135
+ // Identity is still exact when concurrent requests use the same model.
11136
+ responseState = pendingStates[0];
11137
+ } else {
11138
+ // Pi supplies no request id here, so out-of-order concurrent responses
11139
+ // cannot be assigned safely. Preserve the lifecycle records for a
11140
+ // message-local identity, but never turn ambiguous headers into a
11141
+ // model-scoped persistent-fix suggestion.
11142
+ for (const state of pendingStates) state.correlationAmbiguous = true;
11143
+ }
11144
+ }
10847
11145
  if (responseState) responseState.responseReceived = true;
10848
- const model = responseState?.model ?? (resolveRouteModel(ctx.model, ctx) ?? ctx.model);
11146
+ const model = responseState?.model ?? (pendingStates.length === 0 ? (resolveRouteModel(ctx.model, ctx) ?? ctx.model) : undefined);
10849
11147
  if (!runtimeOptimizerEnabled || !model) return;
10850
11148
 
10851
11149
  // Keep only the category, never the provider's complete error text. This
@@ -10938,21 +11236,39 @@ export default function (pi: ExtensionAPI) {
10938
11236
  pi.on("message_end", async (event, ctx) => {
10939
11237
  syncSessionHash(ctx);
10940
11238
  const msgRecord = asRecord(event.message);
10941
- const requestModelForMessage = msgRecord?.role === "assistant"
11239
+ const requestCorrelationForMessage = msgRecord?.role === "assistant"
10942
11240
  ? (() => {
11241
+ const explicitModel = modelFromAssistantMessage(event.message, undefined);
11242
+ const explicitIndex = explicitModel
11243
+ ? providerRequestStates.findIndex((state) => modelKey(state.model) === modelKey(explicitModel))
11244
+ : -1;
10943
11245
  const completedIndex = providerRequestStates.findIndex((state) => state.responseReceived);
10944
- const index = completedIndex >= 0 ? completedIndex : 0;
10945
- return providerRequestStates.splice(index, 1)[0]?.model;
11246
+ const contextModel = resolveRouteModel(ctx.model, ctx) ?? ctx.model;
11247
+ const contextIndex = contextModel && !providerRequestStates.some((state) => state.correlationAmbiguous)
11248
+ ? providerRequestStates.findIndex((state) => modelKey(state.model) === modelKey(contextModel))
11249
+ : -1;
11250
+ const index = explicitIndex >= 0
11251
+ ? explicitIndex
11252
+ : (completedIndex >= 0 ? completedIndex : (contextIndex >= 0 ? contextIndex : 0));
11253
+ const state = providerRequestStates.splice(index, 1)[0];
11254
+ if (!state) return { model: undefined, ambiguous: false };
11255
+ return {
11256
+ model: state.correlationAmbiguous && explicitIndex < 0 ? undefined : state.model,
11257
+ ambiguous: state.correlationAmbiguous && explicitIndex < 0,
11258
+ };
10946
11259
  })()
10947
- : undefined;
11260
+ : { model: undefined, ambiguous: false };
11261
+ const requestModelForMessage = requestCorrelationForMessage.model;
11262
+ const contextualFallbackForMessage = requestCorrelationForMessage.ambiguous
11263
+ ? undefined
11264
+ : (resolveRouteModel(ctx.model, ctx) ?? ctx.model);
10948
11265
 
10949
11266
  // Some providers expose an HTTP 400 error body only through the finalized
10950
11267
  // assistant error message; after_provider_response may contain the status
10951
11268
  // and no diagnostic response headers. Record only the model-scoped
10952
11269
  // reasoning-protocol category from that authoritative message identity.
10953
11270
  if (runtimeOptimizerEnabled && hasReasoningProtocolRejectionErrorMessage(event.message)) {
10954
- const fallbackModel = requestModelForMessage
10955
- ?? (resolveRouteModel(ctx.model, ctx) ?? ctx.model);
11271
+ const fallbackModel = requestModelForMessage ?? contextualFallbackForMessage;
10956
11272
  const messageModel = modelFromAssistantMessage(event.message, fallbackModel) ?? fallbackModel;
10957
11273
  const errorModel = messageModel
10958
11274
  ? findModelInRegistry(ctx.modelRegistry, messageModel.provider, messageModel.id) ?? messageModel
@@ -10967,7 +11283,7 @@ export default function (pi: ExtensionAPI) {
10967
11283
  }
10968
11284
  }
10969
11285
  if (runtimeOptimizerEnabled && hasPromptCacheKeyUnsupportedErrorMessage(event.message)) {
10970
- const fallbackModel = requestModelForMessage ?? (resolveRouteModel(ctx.model, ctx) ?? ctx.model);
11286
+ const fallbackModel = requestModelForMessage ?? contextualFallbackForMessage;
10971
11287
  const messageModel = modelFromAssistantMessage(event.message, fallbackModel) ?? fallbackModel;
10972
11288
  const errorModel = messageModel
10973
11289
  ? findModelInRegistry(ctx.modelRegistry, messageModel.provider, messageModel.id) ?? messageModel
@@ -10985,8 +11301,7 @@ export default function (pi: ExtensionAPI) {
10985
11301
  }
10986
11302
  }
10987
11303
  if (runtimeOptimizerEnabled && hasPromptCacheRetentionUnsupportedErrorMessage(event.message)) {
10988
- const fallbackModel = requestModelForMessage
10989
- ?? (resolveRouteModel(ctx.model, ctx) ?? ctx.model);
11304
+ const fallbackModel = requestModelForMessage ?? contextualFallbackForMessage;
10990
11305
  const messageModel = modelFromAssistantMessage(event.message, fallbackModel) ?? fallbackModel;
10991
11306
  const errorModel = messageModel
10992
11307
  ? findModelInRegistry(ctx.modelRegistry, messageModel.provider, messageModel.id) ?? messageModel
@@ -11010,8 +11325,7 @@ export default function (pi: ExtensionAPI) {
11010
11325
  // non-retryable 400 in Pi 0.82.1, so the fallback applies to the next
11011
11326
  // subsequent request (and to a retry only if another layer initiates one).
11012
11327
  if (hasAnthropicCacheTtlOrderError(event.message)) {
11013
- const fallbackModel = requestModelForMessage
11014
- ?? (resolveRouteModel(ctx.model, ctx) ?? ctx.model);
11328
+ const fallbackModel = requestModelForMessage ?? contextualFallbackForMessage;
11015
11329
  const errorModel = modelFromAssistantMessage(event.message, fallbackModel) ?? fallbackModel;
11016
11330
  if (errorModel && isAnthropicMessagesApi(errorModel.api)) {
11017
11331
  const key = modelKey(errorModel);
@@ -11240,12 +11554,14 @@ export default function (pi: ExtensionAPI) {
11240
11554
  cmdCtx.ui.notify("No active model selected. Select a model first with /model or pi --model.", "warning");
11241
11555
  return;
11242
11556
  }
11243
- const configReceipt = await readPromptCacheKeyConfigReceipt();
11557
+ const configReceiptSnapshot = await readPromptCacheKeyConfigReceiptSnapshot();
11558
+ const configReceipt = configReceiptSnapshot?.receipt;
11244
11559
  const modelsReceipt = await readModelsJsonFixReceipt();
11245
11560
  const useConfigReceipt = isActionablePromptCacheKeyConfigReceipt(configReceipt) &&
11246
11561
  configReceipt.provider === model.provider &&
11247
11562
  configReceipt.modelId === model.id &&
11248
- (!isActionableModelsJsonFixReceipt(modelsReceipt) || modelsReceipt.provider !== model.provider || modelsReceipt.modelId !== model.id || configReceipt.appliedAt >= modelsReceipt.appliedAt);
11563
+ (!isActionableModelsJsonFixReceipt(modelsReceipt) || modelsReceipt.provider !== model.provider ||
11564
+ (modelsReceipt.placement !== "provider" && modelsReceipt.modelId !== model.id) || configReceipt.appliedAt >= modelsReceipt.appliedAt);
11249
11565
  if (useConfigReceipt) {
11250
11566
  if (!cmdCtx.hasUI) {
11251
11567
  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");
@@ -11260,7 +11576,8 @@ export default function (pi: ExtensionAPI) {
11260
11576
  return;
11261
11577
  }
11262
11578
  try {
11263
- await rollbackPromptCacheKeyConfig(configReceipt);
11579
+ if (!configReceiptSnapshot) throw new Error("prompt-cache-key receipt changed since the rollback preview");
11580
+ await rollbackPromptCacheKeyConfig(configReceiptSnapshot);
11264
11581
  setPersistedCacheOptimizerConfig(readPersistedCacheOptimizerConfig());
11265
11582
  cmdCtx.ui.notify(`✅ Restored prompt_cache_key behavior for ${modelKey(model)}. Run /reload or restart Pi for the change to take effect.`, "info");
11266
11583
  } catch (error) {
@@ -11287,7 +11604,7 @@ export default function (pi: ExtensionAPI) {
11287
11604
  cmdCtx.ui.notify("ℹ️ No unapplied /cache-optimizer fix receipt was found.", "info");
11288
11605
  return;
11289
11606
  }
11290
- if (receipt.provider !== model.provider || receipt.modelId !== model.id) {
11607
+ if (receipt.provider !== model.provider || (receipt.placement !== "provider" && receipt.modelId !== model.id)) {
11291
11608
  cmdCtx.ui.notify(
11292
11609
  `ℹ️ The latest fix receipt is for ${receipt.provider}/${receipt.modelId}, not the active model ${model.provider}/${model.id}. ` +
11293
11610
  "Switch to the matching model before running rollback. No changes were made.",
@@ -11525,6 +11842,79 @@ export default function (pi: ExtensionAPI) {
11525
11842
  const location = locateModelInJsonc(originalText, suggestion.providerLabel, suggestion.modelId);
11526
11843
  if (!location) {
11527
11844
  const diagnosis = analyzeModelsJsonForMissingEntry(originalText, suggestion.providerLabel, suggestion.modelId);
11845
+ const parsedOriginal = (() => { try { return parseJsonc(originalText); } catch { return undefined; } })();
11846
+ const provider = asRecord(asRecord(parsedOriginal)?.providers)?.[suggestion.providerLabel];
11847
+ const explicit = resolveExplicitCompatValue(parsedOriginal, suggestion.providerLabel, suggestion.modelId, "sendSessionAffinityHeaders");
11848
+ const targetOverrideLocation = locateModelOverrideInJsonc(
11849
+ originalText, suggestion.providerLabel, suggestion.modelId,
11850
+ );
11851
+ const hasTargetOverride = (targetOverrideLocation?.modelOverrideObjectBrace ?? -1) >= 0;
11852
+ const providerPlan = diagnosis && diagnosis.scenario !== "provider_missing" &&
11853
+ isValidModelsConfigForEffectiveCompat(parsedOriginal) && asRecord(provider) &&
11854
+ Object.keys(suggestion.compatKeys).length === 1 && suggestion.compatKeys.sendSessionAffinityHeaders === true &&
11855
+ !suggestion.forceModelLevel && !hasTargetOverride && explicit === undefined &&
11856
+ getEffectiveCompatValueSource(model, parsedOriginal, "sendSessionAffinityHeaders") === undefined
11857
+ ? composeProviderAffinityInsertion(originalText, suggestion.providerLabel)
11858
+ : undefined;
11859
+ if (providerPlan) {
11860
+ const checkProvider = (writtenText: string): string | null => {
11861
+ try {
11862
+ const changed = parseJsonc(writtenText);
11863
+ if (!isValidModelsConfigForEffectiveCompat(changed)) return "provider config is invalid";
11864
+ if (resolveEffectiveCompatFromConfig(model, changed).sendSessionAffinityHeaders !== true) return "affinity flag is not effective";
11865
+ const reverted = JSON.parse(JSON.stringify(changed)) as Record<string, unknown>;
11866
+ const target = asRecord(asRecord(reverted.providers)?.[suggestion.providerLabel]);
11867
+ const compat = asRecord(target?.compat);
11868
+ if (!target || !compat || compat.sendSessionAffinityHeaders !== true) return "provider affinity key is missing";
11869
+ delete compat.sendSessionAffinityHeaders;
11870
+ const originalCompat = asRecord(asRecord(provider)?.compat);
11871
+ if (!originalCompat) delete target.compat;
11872
+ return JSON.stringify(reverted) === JSON.stringify(parsedOriginal) ? null : "unrelated configuration was altered";
11873
+ } catch { return "invalid provider JSONC"; }
11874
+ };
11875
+ const checkError = checkProvider(providerPlan.modifiedText);
11876
+ if (checkError) {
11877
+ cmdCtx.ui.notify(`❌ Provider-level self-check failed: ${checkError}. No changes were made.`, "error");
11878
+ return;
11879
+ }
11880
+ const backupPath = `${MODELS_JSON_PATH}.backup-cache-optimizer-${backupTimestamp()}`;
11881
+ const confirmed = await cmdCtx.ui.confirm("Cache Optimizer — Fix provider affinity", [
11882
+ `📝 Preview of changes to ${getModelsJsonDisplayPath()}:`,
11883
+ `Location: ${providerPlan.placementLabel}`,
11884
+ `Compat JSON to write: ${JSON.stringify(suggestion.compatKeys)}`,
11885
+ `⚠️ This affects all models using this provider across all sessions; model-level overrides remain authoritative.`,
11886
+ `A timestamped backup will be written to: ${backupPath}`,
11887
+ "Run /reload or restart Pi for the change to take effect.",
11888
+ "Apply these changes?",
11889
+ ].join("\n"));
11890
+ if (!confirmed) {
11891
+ cmdCtx.ui.notify("No changes were made. Canceled by user.", "info");
11892
+ return;
11893
+ }
11894
+ const receipt = createModelsJsonFixReceipt(
11895
+ originalText, providerPlan.modifiedText, suggestion.providerLabel, suggestion.modelId,
11896
+ "provider", suggestion.compatKeys, true, backupPath,
11897
+ );
11898
+ if (!receipt) {
11899
+ cmdCtx.ui.notify("❌ Could not create a privacy-safe fix receipt. No changes were made.", "error");
11900
+ return;
11901
+ }
11902
+ try {
11903
+ const result = await applyModelsJsonFixTransaction(providerPlan.modifiedText, backupPath, checkProvider, {
11904
+ expectedCurrentHash: hashText(originalText), purpose: "fix",
11905
+ onCommitted: async () => writeModelsJsonFixReceipt(receipt),
11906
+ });
11907
+ if ("postCheckError" in result) {
11908
+ cmdCtx.ui.notify(`❌ Post-write self-check failed: ${result.postCheckError}. Backup restored.`, "error");
11909
+ return;
11910
+ }
11911
+ invalidateModelsConfigCache();
11912
+ cmdCtx.ui.notify(`✅ Fix applied to ${getModelsJsonDisplayPath()}.\nBackup saved to: ${backupPath}\nRun /reload or restart Pi.`, "info");
11913
+ } catch (error) {
11914
+ cmdCtx.ui.notify(`❌ Write failed: ${error instanceof Error ? error.message : String(error)}. Backup may be at: ${backupPath}`, "error");
11915
+ }
11916
+ return;
11917
+ }
11528
11918
  if (diagnosis && cmdCtx.hasUI) {
11529
11919
  const overrideLocation = locateModelOverrideInJsonc(
11530
11920
  originalText, suggestion.providerLabel, suggestion.modelId,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-cache-optimizer",
3
- "version": "2.8.9",
3
+ "version": "2.8.11",
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,8 +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.85.1",
39
- "@earendil-works/pi-server": "0.85.1",
38
+ "@earendil-works/pi-coding-agent": "^0.87.1",
39
+ "@earendil-works/pi-server": "0.87.1",
40
40
  "@types/node": "^22.0.0",
41
41
  "jiti": "^2.7.0",
42
42
  "typescript": "^5.8.3"