pi-cache-optimizer 2.8.19 → 2.8.21

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
@@ -35,7 +35,7 @@ Pi extension for improving provider-side KV / prompt cache hit rates. It keeps s
35
35
 
36
36
  - Compresses Pi's skill list into a grouped Markdown list that keeps every skill name and description (only the XML envelope and repeated paths are removed), and strips session-overview churn. Both are in-place edits that never move content between Pi's prompt sections, and they also apply to OpenAI Responses/Codex models. (Earlier versions also lifted "stable" content to the front of the prompt; Pi >= 0.86 already orders its sections from stable to variable, and measured prefix stability was identical, so that step was removed.)
37
37
  - Requests long cache retention when Pi/provider compat supports it.
38
- - Adds a conservative session-id `prompt_cache_key` fallback for `openai-completions` proxy payloads when no effective key exists. Pi 1.0+ owns this field for Responses/Codex transports.
38
+ - Adds a conservative per-session `prompt_cache_key` fallback for `openai-completions` proxy payloads when no effective key exists. The values this extension adds itself (that key fallback, the session-affinity header bridge, and cache hints) are a one-way hash of the Pi session id (`pi-<32 hex>`); keys and headers that Pi core sets on its own are left untouched, and an existing key is never overwritten. Pi 1.0+ owns this field for Responses/Codex transports.
39
39
  - Warns once for third-party OpenAI-compatible proxies missing cache/session-affinity compat flags.
40
40
  - Detects adaptive-thinking compat for Claude (opus-4.6+ including Opus 5, sonnet-4.6+ including Sonnet 5, fable-5+) and Kimi Coding K3 / `kimi-for-coding` custom channels.
41
41
  - Stores cache statistics in per-extension-instance atomic shards, so parent sessions, child Pi agents, and parallel Pi processes cannot overwrite one another.
@@ -62,7 +62,7 @@ Run `/reload` in Pi after install/update/remove so extension hooks refresh.
62
62
 
63
63
  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).
64
64
 
65
- This extension requires Pi 0.82+ and is validated against Pi 1.0.2. 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. Native virtual model support and codemode nested-call coalescing activate only on Pi 0.99+ hosts that produce them; older hosts keep the previous behavior. The local development baseline uses Node.js 22.19.0 or newer, matching Pi 1.0.2's engine requirement.
65
+ This extension requires Pi 0.82+ and is validated against Pi 1.0.3. 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. Native virtual model support and codemode nested-call coalescing activate only on Pi 0.99+ hosts that produce them; older hosts keep the previous behavior. The local development baseline uses Node.js 22.19.0 or newer, matching Pi 1.0.3's engine requirement.
66
66
 
67
67
  ## Commands
68
68
 
@@ -156,9 +156,9 @@ The explicit setting is stored in `pi-cache-optimizer-config.json` under Pi's ag
156
156
 
157
157
  ## Per-model `prompt_cache_key` opt-out
158
158
 
159
- Some OpenAI-compatible Completions endpoints reject `prompt_cache_key` with HTTP 400 even though the same field is valid for other providers. Pi 1.0.2 owns the key for Responses/Codex transports; this extension's key opt-out and fallback apply only to `openai-completions`. Pi 1.0.2 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.
159
+ Some OpenAI-compatible Completions endpoints reject `prompt_cache_key` with HTTP 400 even though the same field is valid for other providers. Pi 1.0.3 owns the key for Responses/Codex transports; this extension's key opt-out and fallback apply only to `openai-completions`. Pi 1.0.3 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.
160
160
 
161
- 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:
161
+ When the extension observes an explicit field-level `prompt_cache_key` unsupported error for the exact provider/model (including recognized Chinese-localized forms such as `未知请求字段:prompt_cache_key`), 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:
162
162
 
163
163
  ```text
164
164
  /cache-optimizer fix prompt-cache-key
@@ -170,7 +170,7 @@ The preview explains that the setting is stored in the extension-owned `pi-cache
170
170
 
171
171
  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.
172
172
 
173
- 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. The extension keeps its session-id key fallback for third-party `openai-completions` channels, while Pi 1.0+ owns the key for Responses/Codex transports. 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. The extension-owned `pi-cache-optimizer-config.json` stores per-model key opt-outs only for `openai-completions`.
173
+ 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. The extension keeps its session-id key fallback for third-party `openai-completions` channels, while Pi 1.0+ owns the key for Responses/Codex transports. 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` follows one safety rule: the extension only undoes the side effect of its own `PI_CACHE_RETENTION=long`. In order: a model that already rejected the parameter in this process never gets it again; official OpenAI keeps it; an explicit `supportsLongCacheRetention: true` (from `models.json`, or on the runtime model from a provider extension; an explicit `false` in `models.json` still wins) keeps it; a `PI_CACHE_RETENTION=long` you set yourself before Pi started keeps it; otherwise it is removed before sending. Azure OpenAI is not allow-listed because extended retention there depends on the deployed model. `/cache-optimizer doctor` shows the latest decision and its reason. The extension-owned `pi-cache-optimizer-config.json` stores per-model key opt-outs only for `openai-completions`.
174
174
 
175
175
  For real proxies, start with session affinity:
176
176
 
@@ -196,7 +196,7 @@ Notes:
196
196
 
197
197
  - `sendSessionAffinityHeaders: true` is the safe default when your proxy supports sticky routing.
198
198
  - `supportsLongCacheRetention: true` is optional. Add it only when the endpoint explicitly supports OpenAI long prompt cache retention.
199
- - Do not add `supportsPromptCacheKey` to `models.json`: Pi 1.0.2 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.
199
+ - Do not add `supportsPromptCacheKey` to `models.json`: Pi 1.0.3 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.
200
200
  - 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.
201
201
  - Use `/cache-optimizer compat` or `/cache-optimizer doctor` to see model-specific advice.
202
202
  - 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.
@@ -354,7 +354,7 @@ Pi's model-family name and its reasoning wire protocol are separate. A DeepSeek-
354
354
  The novice-safe workflow is staged:
355
355
 
356
356
  1. Run `/cache-optimizer fix` for protocol-neutral cache/routing repairs such as session affinity. It shows the exact placement and requires confirmation.
357
- 2. Make a normal request. If an OpenAI-compatible DeepSeek-like model explicitly rejects the `thinking` parameter in favor of `reasoning_effort`, the extension keeps only a model-scoped, process-local category. It does not persist or display the complete provider error, send a hidden probe, or edit configuration from a response hook.
357
+ 2. Make a normal request. If an OpenAI-compatible DeepSeek-like model explicitly rejects the `thinking` parameter in favor of `reasoning_effort` (including recognized Chinese-localized rejection and recommendation text), the extension keeps only a model-scoped, process-local category. It does not persist or display the complete provider error, send a hidden probe, or edit configuration from a response hook.
358
358
  3. Run `/cache-optimizer fix` again to review an evidence-based model-level protocol repair. It is written through the highest-precedence `modelOverrides[modelId].compat` layer and validated against runtime compat, so an extension-provided model cannot silently shadow it. A provider-level change is never broadened from a model name alone. Explicit `openai`, `qwen`, `openrouter`, and `together` formats remain respected.
359
359
  4. If the latest confirmed fix caused the problem, run `/cache-optimizer rollback`. Rollback always requires UI confirmation.
360
360
 
package/README.zh-CN.md CHANGED
@@ -35,7 +35,7 @@
35
35
 
36
36
  - 将 Pi 的 skill 列表压缩为按目录分组的 Markdown 列表,保留每个 skill 的名称和完整描述(只去掉 XML 外壳和重复路径),并移除 session-overview 中的易变字段。这两项都是原位修改,不会在 Pi 的提示段之间搬移内容,对 OpenAI Responses/Codex 模型同样生效。(早期版本还会把“稳定”内容提到提示最前面;Pi 0.86 起已按从稳定到易变的顺序拼段,实测前缀稳定性没有差别,因此移除了这一步。)
37
37
  - 在 Pi / provider compat 支持时请求长缓存保留。
38
- - 仅对 `openai-completions` 代理请求,在没有有效 key 时使用 Pi session id 保守补 `prompt_cache_key`;Pi 1.0+ 已负责 Responses/Codex transport 的 key。
38
+ - 仅对 `openai-completions` 代理请求,在没有有效 key 时按会话保守补 `prompt_cache_key`;本扩展自己补的值(该 key、session-affinity 头补齐、cache hints)使用 Pi session id 的单向哈希(`pi-<32 位 hex>`);Pi 核心自己设置的 key 和头保持不变,已有 key 也绝不覆盖;Pi 1.0+ 已负责 Responses/Codex transport 的 key。
39
39
  - 对缺少缓存 / session-affinity compat 的第三方 OpenAI-compatible 代理给出一次性提醒。
40
40
  - 检测 Claude(opus-4.6+ 含 Opus 5、sonnet-4.6+ 含 Sonnet 5、fable-5+)以及 Kimi Coding K3 / `kimi-for-coding` 自定义渠道的 adaptive-thinking compat。
41
41
  - 使用每个 extension instance 独占的原子 shard 保存缓存统计,避免父会话、子 Pi agent 和并行 Pi 进程互相覆盖。
@@ -62,7 +62,7 @@ pi remove npm:pi-deepseek-cache-optimizer && pi install npm:pi-cache-optimizer
62
62
 
63
63
  Pi 0.79.7 及之后,`pi update` 默认只更新 Pi 本体。若要更新已安装的 Pi package(包括本扩展),请运行 `pi update --extensions`(只更新 packages)或 `pi update --all`(Pi 与 packages 一起更新)。
64
64
 
65
- 本扩展要求 Pi 0.82+,并已使用 Pi 1.0.2 验证。TypeScript 校验直接使用官方 Pi package 类型,同时只使用这些版本共有的 extension hooks、`getAgentDir()` 和 prompt options;不依赖 Pi 0.83+ 专有 API(例如 `ctx.scopedModels` 或 bundled TypeBox 1.3 aliases)。原生虚拟模型支持与 codemode 嵌套工具调用合并只在会产生它们的 Pi 0.99+ 上生效,较早版本保持原有行为。本地开发基线需要 Node.js 22.19.0 或更高版本,与 Pi 1.0.2 的 engine 要求一致。
65
+ 本扩展要求 Pi 0.82+,并已使用 Pi 1.0.3 验证。TypeScript 校验直接使用官方 Pi package 类型,同时只使用这些版本共有的 extension hooks、`getAgentDir()` 和 prompt options;不依赖 Pi 0.83+ 专有 API(例如 `ctx.scopedModels` 或 bundled TypeBox 1.3 aliases)。原生虚拟模型支持与 codemode 嵌套工具调用合并只在会产生它们的 Pi 0.99+ 上生效,较早版本保持原有行为。本地开发基线需要 Node.js 22.19.0 或更高版本,与 Pi 1.0.3 的 engine 要求一致。
66
66
 
67
67
  ## 命令
68
68
 
@@ -156,9 +156,9 @@ Footer 默认使用 `session`,避免另一个并行 Pi 终端使用相同 prov
156
156
 
157
157
  ## 按模型关闭 `prompt_cache_key`
158
158
 
159
- 某些 OpenAI-compatible Completions endpoint 会因 `prompt_cache_key` 返回 HTTP 400,但同一个字段对其他 provider 可能有效。Pi 1.0+ 已负责 Responses/Codex transport 的 key;本扩展的 fallback 和 opt-out 只作用于 `openai-completions`。Pi 1.0.2 没有原生的 `supportsPromptCacheKey` compat 字段,**不要**把这个未知字段加入 `models.json`。`supportsLongCacheRetention` 也不是等价开关,不应借用来实现此目的。
159
+ 某些 OpenAI-compatible Completions endpoint 会因 `prompt_cache_key` 返回 HTTP 400,但同一个字段对其他 provider 可能有效。Pi 1.0+ 已负责 Responses/Codex transport 的 key;本扩展的 fallback 和 opt-out 只作用于 `openai-completions`。Pi 1.0.3 没有原生的 `supportsPromptCacheKey` compat 字段,**不要**把这个未知字段加入 `models.json`。`supportsLongCacheRetention` 也不是等价开关,不应借用来实现此目的。
160
160
 
161
- 当扩展观察到精确 provider/model 对 `prompt_cache_key` 的明确字段级拒绝后,普通 `/cache-optimizer fix` 会提供经过确认的模型级修复。参数值校验失败,以及“设置 temperature 时不允许”这类条件限制都不构成证据。若不同模型的响应并发交错,而 Pi 又没有提供 request ID,扩展会忽略无法安全关联的 response-header 证据;只有最终 assistant message 提供精确 provider/model 身份时才恢复归因。如果你已经确定 endpoint 不支持该字段,可以主动执行:
161
+ 当扩展观察到精确 provider/model 对 `prompt_cache_key` 的明确字段级拒绝后(包括 `未知请求字段:prompt_cache_key` 这类已识别的中文错误),普通 `/cache-optimizer fix` 会提供经过确认的模型级修复。参数值校验失败,以及“设置 temperature 时不允许”这类条件限制都不构成证据。若不同模型的响应并发交错,而 Pi 又没有提供 request ID,扩展会忽略无法安全关联的 response-header 证据;只有最终 assistant message 提供精确 provider/model 身份时才恢复归因。如果你已经确定 endpoint 不支持该字段,可以主动执行:
162
162
 
163
163
  ```text
164
164
  /cache-optimizer fix prompt-cache-key
@@ -170,7 +170,7 @@ Footer 默认使用 `session`,避免另一个并行 Pi 终端使用相同 prov
170
170
 
171
171
  LiteLLM / OneAPI / NewAPI / 类 OpenRouter 渠道等第三方 `openai-completions` 代理,常会把同一个 session 分散到多个上游后端,导致 provider 侧 prompt cache 被拆散。
172
172
 
173
- 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。本扩展只对第三方 `openai-completions` 保留 session-id key fallback;Pi 1.0+ 已负责 Responses/Codex transport 的 key。只有符合 Pi 内置 provider 明确 compat 指纹的模型会跳过通用 proxy 路由 / session-affinity 建议;仅复用 `llama.cpp` id 的自定义或覆盖 provider 仍按普通 OpenAI-compatible 渠道处理。`prompt_cache_retention` 继续遵循统一安全规则:仅官方 OpenAI 或 `models.json` 中有效配置为 `supportsLongCacheRetention: true` 时保留,否则发送前移除。Pi 1.0.2 没有原生的 `supportsPromptCacheKey` compat 字段,因此仅对 `openai-completions` 按模型关闭 key 的策略保存在本扩展的 `pi-cache-optimizer-config.json` 中,而不是 `models.json`。扩展配置独立于 Pi 的 compat 优先级,只影响其中列出的精确 provider/model。
173
+ 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。本扩展只对第三方 `openai-completions` 保留 session-id key fallback;Pi 1.0+ 已负责 Responses/Codex transport 的 key。只有符合 Pi 内置 provider 明确 compat 指纹的模型会跳过通用 proxy 路由 / session-affinity 建议;仅复用 `llama.cpp` id 的自定义或覆盖 provider 仍按普通 OpenAI-compatible 渠道处理。`prompt_cache_retention` 遵循统一安全规则:扩展只撤销自己设置 `PI_CACHE_RETENTION=long` 带来的副作用。依次判断:本进程内该模型已明确拒绝过该参数 → 移除;官方 OpenAI → 保留;显式 `supportsLongCacheRetention: true`(来自 `models.json`,或 provider 扩展注册的 runtime model;`models.json` 中显式 `false` 仍优先)→ 保留;用户在 Pi 启动前自行设置了 `PI_CACHE_RETENTION=long` → 保留;其余情况发送前移除。Azure OpenAI 不在白名单中,因为其长缓存支持取决于具体部署的模型。`/cache-optimizer doctor` 会显示最近一次判断及原因。Pi 1.0.3 没有原生的 `supportsPromptCacheKey` compat 字段,因此仅对 `openai-completions` 按模型关闭 key 的策略保存在本扩展的 `pi-cache-optimizer-config.json` 中,而不是 `models.json`。扩展配置独立于 Pi 的 compat 优先级,只影响其中列出的精确 provider/model。
174
174
 
175
175
  对真正的代理,建议先启用 session affinity:
176
176
 
@@ -196,7 +196,7 @@ Pi 0.84.1 还修复了内置 Fireworks 渠道对拒绝 `prompt_cache_retention`
196
196
 
197
197
  - `sendSessionAffinityHeaders: true` 是安全默认项,前提是你的代理支持 sticky routing。
198
198
  - `supportsLongCacheRetention: true` 是可选项。只有 endpoint 明确支持 OpenAI long prompt cache retention 时才添加。
199
- - 不要把 `supportsPromptCacheKey` 加入 `models.json`:Pi 1.0.2 没有定义这个 compat 字段。请使用 `/cache-optimizer fix prompt-cache-key` 在扩展自有配置中保存精确 provider/model 的 omit 规则;它会移除两种 key 写法,包括 Pi 提供的 key。
199
+ - 不要把 `supportsPromptCacheKey` 加入 `models.json`:Pi 1.0.3 没有定义这个 compat 字段。请使用 `/cache-optimizer fix prompt-cache-key` 在扩展自有配置中保存精确 provider/model 的 omit 规则;它会移除两种 key 写法,包括 Pi 提供的 key。
200
200
  - 如果出现 `400 Unsupported parameter: prompt_cache_retention`,请为该渠道移除 / 避免 `supportsLongCacheRetention`;如支持,可保留 `sendSessionAffinityHeaders`。扩展会从响应头或最终 assistant error message 中识别这条明确错误,并在当前进程的后续请求中移除该参数。
201
201
  - 使用 `/cache-optimizer compat` 或 `/cache-optimizer doctor` 查看当前模型的具体建议。
202
202
  - DeepSeek 模型名只用于选择 `DS cache` adapter,不能证明 reasoning wire protocol。缺少或使用非 DeepSeek format 时仍保留通用缓存 / 路由建议;只有 effective `compat.thinkingFormat: "deepseek"` 被明确配置时,才显示 DeepSeek replay 建议,且不会把 `thinkingFormat` 列为缺失修复项。
@@ -353,7 +353,7 @@ Pi Cache Optimizer 按 Pi 的优先级解析有效 compat(`provider.compat`
353
353
  面向新手的安全流程是分阶段的:
354
354
 
355
355
  1. 先运行 `/cache-optimizer fix`,只处理协议无关的缓存 / 路由修复,例如 session affinity;命令会展示具体位置并要求确认。
356
- 2. 发起一次普通请求。如果 OpenAI-compatible 的 DeepSeek-like 模型明确拒绝 `thinking` 并要求使用 `reasoning_effort`,扩展只在当前进程保留 model-scoped 分类,不会持久化或显示完整错误,不会发送隐藏探测请求,也不会在 response hook 中自动修改配置。
356
+ 2. 发起一次普通请求。如果 OpenAI-compatible 的 DeepSeek-like 模型明确拒绝 `thinking` 并要求使用 `reasoning_effort`(包括已识别的中文拒绝和建议文本),扩展只在当前进程保留 model-scoped 分类,不会持久化或显示完整错误,不会发送隐藏探测请求,也不会在 response hook 中自动修改配置。
357
357
  3. 再次运行 `/cache-optimizer fix`,查看基于证据的 model-level 协议修复;该修复写入最高优先级的 `modelOverrides[modelId].compat`,并结合 runtime compat 自检,避免 extension-provided model 静默遮挡。不会仅凭共享 provider 或模型名字扩大修改范围,显式 `openai`、`qwen`、`openrouter`、`together` format 会被尊重。
358
358
  4. 如果最近一次已确认修复导致问题,运行 `/cache-optimizer rollback`;回滚始终需要 UI 确认。
359
359
 
package/index.ts CHANGED
@@ -1,48 +1,40 @@
1
- import { createHash, randomUUID } from "node:crypto";
2
- import { chmod, copyFile, link, lstat, mkdir, readFile, readdir, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
3
- import { constants as fsConstants, readFileSync, statSync, watch } from "node:fs";
4
- import { homedir } from "node:os";
5
- import { basename, dirname, join } from "node:path";
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdir } from "node:fs/promises";
3
+ import { watch } from "node:fs";
4
+ import { join } from "node:path";
6
5
  import {
7
- getAgentDir,
8
- type BuildSystemPromptOptions,
9
- type ExtensionAPI,
10
- type ExtensionCommandContext,
11
- type ExtensionContext,
6
+ type ExtensionAPI, type ExtensionContext
12
7
  } from "@earendil-works/pi-coding-agent";
13
- import { LOG_PREFIX, type ModelIdentity, type PiModel, type UnknownRecord, asRecord, getErrorCode, isNonEmptyString, isProcessAlive, lower, type MutableEnv } from "./src/common.ts";
14
- import { FIX_RECEIPT_FILE_NAME, FIX_RECEIPT_PATH, MODELS_JSON_PATH, MODELS_TRANSACTION_LOCK_PATH, MODELS_TRANSACTION_LOCK_STALE_MS, MODELS_TRANSACTION_LOCK_WAIT_MS, STATE_DIR } from "./src/paths.ts";
15
- import { type FixReceiptCompatChange, type FixReceiptPlacement, type FixSuggestion, type ModelsJsonFixReceiptV1, RECEIPT_COMPAT_KEYS, type ReceiptScalar, type ReceiptScalarState, isReceiptTimestamp, isSafeReceiptText, isSha256 } from "./src/fix-types.ts";
16
- import { type JsonPropertyEdit, type ModelNodeLocation, deepEqualIgnoringKeys, deriveInnerIndent, findExistingCompatKeysInJsonc, findJsonObjectKey, findMatchingBracket, isJsonWhitespace, lineIndentOf, locateJsonPropertyValueSpan, locateModelOverrideInJsonc, locateProviderCompatInJsonc, parseJsonc, readJsonStringLiteral, skipJsonValue, skipJsonWhitespace, stripJsoncComments, stripJsoncTrailingCommas } from "./src/jsonc.ts";
17
- import { type FileIdentity, atomicCreateTextFileNoReplace, atomicReplaceTextFilePreservingMode, atomicRestoreFileFromBackup, backupTimestamp, hashText, readRegularTextFile, sameFileIdentity, uniqueTempPath, validateAtomicTarget, withModelsJsonTransactionLock } from "./src/atomic-fs.ts";
18
- import { getAssistantMessageModelTokenValues, getModelIdNameTokenValues, hasAnyTokenContaining, isAdaptiveGenerationModel, isKimiCodingAdaptiveModel, modelOrAssistantMessageHas } from "./src/model-detect.ts";
19
- import { type CacheCompat, NESTED_COMPAT_KEYS, findLastExactModelDefinition, getEffectiveCompatSources, mergeCacheCompat, resolveEffectiveCompatFromConfig } from "./src/compat-config.ts";
20
- import { analyzeModelsJsonForMissingEntry, applyModelsJsonFixTransaction, chooseFixPlacement, composeFixInsertion, composeMissingEntryInsertion, composeModelOverrideInsertion, composeModelsJsonReceiptRollback, composeProviderAffinityInsertion, createModelsJsonFixReceipt, createRollbackBackupPath, decideFixPlacement, formatCompatKeysForInsertion, formatMissingEntryManualSnippet, hasExplicitLongRetentionOptInFromConfig, hasReceiptReasoningProtocolChange, isActionableModelsJsonFixReceipt, locateModelInJsonc, markModelsJsonFixReceiptRolledBack, parseModelsJsonFixReceipt, prepareModelsJsonRollback, readModelsJsonFixReceipt, readModelsJsonFixReceiptSnapshot, receiptBackupPath, resolveExplicitCompatValue, selfCheckFix, selfCheckMissingEntryInsertion, validateModelsJsonRollback, writeModelsJsonFixReceipt } from "./src/models-json-fix.ts";
21
- import { getNonNegativeNumber, getNumber } from "./src/common.ts";
22
- import { CACHE_PROVIDER_IDS, type CacheProviderId, type CacheStats, LEGACY_STATE_FILE_PATH, type PersistedRoutedModelRef, type PersistedStatsShardV7, SHARD_FILES_DIR, SHARD_GLOBAL_EPOCH_PATH, SHARD_STATE_DIR, STATE_FILE_PATH, type ShardAggregate, type UsageSnapshot, addUsageToCacheStats, advanceGlobalStatsEpoch, advanceModelStatsEpoch, aggregateStatsShardsV7, cleanupStatsShardsV7, cloneCacheStats, currentLocalDay, emptyAllCacheStats, emptyCacheStats, initialEpoch, loadStatsShardAggregateV7, maybeCleanupStatsShardsV7, mergeCacheStatsForTotal, modelEpochPath, parseCacheStats, parsePersistedRoutedModelRef, parsePersistedStatsShardV7, readGlobalStatsEpoch, readModelStatsEpoch, readValidStatsShardsV7, removeLegacyStatsFiles, writeStatsShardV7 } from "./src/stats-store.ts";
23
- import { ALEPH_MODEL_PATTERN, ARCTIC_MODEL_PATTERN, AYA_MODEL_PATTERN, DOUBAO_SEED_PATTERN, MIMO_MODEL_PATTERN, MPT_MODEL_PATTERN, NOVA_MODEL_PATTERN, ORION_MODEL_PATTERN, PHI_MODEL_PATTERN, PI_VIRTUAL_MODEL_API, PPLX_MODEL_PATTERN, ROUTED_FALLBACK_MODEL_SYMBOL, XAI_MODEL_PATTERN, YI_MODEL_PATTERN, getAssistantRecord, getCompat, isAnthropicMessagesApi, isAssistantMessage, isClaudeLikeAssistantMessage, isClaudeLikeModel, isDeepSeekLikeAssistantMessage, isDeepSeekLikeModel, isGeminiLikeAssistantMessage, isGeminiLikeModel, isKimiCodingEmptySignatureModel, isKnownThirdPartyOpenAIEndpoint, isMistralConversationsApi, isNativeVirtualModel, isOfficialOpenAIBaseUrl, isOpenAICompatibleApi, isOpenAICompatibleProxyApi, isOpenAIFamilyAssistantMessage, isOpenAIFamilyModel, isOpenAIFamilyToken, isPiBuiltInLlamaCppModel, isResponsesPromptRewriteBypassApi, isRoutedFallbackModel, isValidModelsConfigForEffectiveCompat, modelKey, readEffectiveCompatConfig } from "./src/model-identity.ts";
24
- import { invalidateModelsConfigCache } from "./src/model-identity.ts";
25
- import { getAnthropicRawUsage, getDeepSeekRawUsage, getGeminiRawUsage, getOpenAIRawUsage, normalizeWithFallback, usageRecordFromAssistant } from "./src/usage.ts";
26
- import { CONFIG_FILE_PATH, NO_SKILL_COMPRESSION_ENV, VIRTUAL_REWRITE_ENV, FOOTER_MODE_ENV, type FooterStatsMode, NO_OPENAI_CACHE_KEY_ENV, type PersistedCacheOptimizerConfig, type PersistedCacheOptimizerConfigV2, type PersistedCacheOptimizerConfigV3, type PersistedCacheOptimizerFeature, TOOL_ORDER_ENV, featureEnabled, footerStatsMode, isEnabledEnv, isToolOrderEnabled, normalizePersistedCacheOptimizerConfig, parseFooterStatsMode, parsePersistedCacheOptimizerConfig, persistedCacheOptimizerConfig, persistedFooterStatsMode, readPersistedCacheOptimizerConfig, resolveFooterStatsMode, runtimeOptimizerEnabled, setPersistedCacheOptimizerConfig, shouldInjectOpenAIPromptCacheKey, writePersistedCacheOptimizerConfig, writePersistedFeature, writePersistedFooterMode } from "./src/config.ts";
27
- import { LONG_CACHE_RETENTION_VALUE, PI_CACHE_RETENTION_BASELINE_SYMBOL, PI_CACHE_RETENTION_ENV, STARTUP_CACHE_RETENTION_ENV, captureCacheRetentionEnv, getOrCaptureCacheRetentionBaseline, requestLongCacheRetention, restoreCacheRetentionEnv } from "./src/retention.ts";
8
+ import { LOG_PREFIX, type PiModel, asRecord, isNonEmptyString } from "./src/common.ts";
9
+ import { FIX_RECEIPT_FILE_NAME, FIX_RECEIPT_PATH, MODELS_JSON_PATH, STATE_DIR } from "./src/paths.ts";
10
+ import { type FixSuggestion } from "./src/fix-types.ts";
11
+ import { deepEqualIgnoringKeys, findExistingCompatKeysInJsonc, locateModelOverrideInJsonc, locateProviderCompatInJsonc, parseJsonc, stripJsoncComments, stripJsoncTrailingCommas } from "./src/jsonc.ts";
12
+ import { atomicCreateTextFileNoReplace, atomicReplaceTextFilePreservingMode, atomicRestoreFileFromBackup, backupTimestamp, hashText } from "./src/atomic-fs.ts";
13
+ import { getAssistantMessageModelTokenValues, getModelIdNameTokenValues, isAdaptiveGenerationModel, isKimiCodingAdaptiveModel } from "./src/model-detect.ts";
14
+ import { findLastExactModelDefinition, mergeCacheCompat, resolveEffectiveCompatFromConfig } from "./src/compat-config.ts";
15
+ import { analyzeModelsJsonForMissingEntry, applyModelsJsonFixTransaction, chooseFixPlacement, composeFixInsertion, composeMissingEntryInsertion, composeModelOverrideInsertion, composeModelsJsonReceiptRollback, composeProviderAffinityInsertion, createModelsJsonFixReceipt, createRollbackBackupPath, decideFixPlacement, formatCompatKeysForInsertion, hasExplicitLongRetentionOptInFromConfig, hasReceiptReasoningProtocolChange, isActionableModelsJsonFixReceipt, locateModelInJsonc, markModelsJsonFixReceiptRolledBack, parseModelsJsonFixReceipt, prepareModelsJsonRollback, readModelsJsonFixReceipt, readModelsJsonFixReceiptSnapshot, receiptBackupPath, resolveExplicitCompatValue, selfCheckFix, selfCheckMissingEntryInsertion, validateModelsJsonRollback, writeModelsJsonFixReceipt } from "./src/models-json-fix.ts";
16
+ import { type CacheProviderId, type CacheStats, LEGACY_STATE_FILE_PATH, type PersistedRoutedModelRef, type PersistedStatsShardV7, SHARD_FILES_DIR, SHARD_GLOBAL_EPOCH_PATH, SHARD_STATE_DIR, STATE_FILE_PATH, type ShardAggregate, type UsageSnapshot, addUsageToCacheStats, advanceGlobalStatsEpoch, advanceModelStatsEpoch, aggregateStatsShardsV7, cleanupStatsShardsV7, cloneCacheStats, currentLocalDay, emptyAllCacheStats, emptyCacheStats, initialEpoch, loadStatsShardAggregateV7, maybeCleanupStatsShardsV7, modelEpochPath, parseCacheStats, parsePersistedRoutedModelRef, parsePersistedStatsShardV7, readGlobalStatsEpoch, readModelStatsEpoch, readValidStatsShardsV7, removeLegacyStatsFiles, writeStatsShardV7 } from "./src/stats-store.ts";
17
+ import { PI_VIRTUAL_MODEL_API, getCompat, isAnthropicMessagesApi, isKimiCodingEmptySignatureModel, isKnownThirdPartyOpenAIEndpoint, isMistralConversationsApi, isNativeVirtualModel, isOfficialOpenAIBaseUrl, isOpenAICompatibleApi, isOpenAICompatibleProxyApi, isOpenAIFamilyAssistantMessage, isOpenAIFamilyModel, isOpenAIFamilyToken, isPiBuiltInLlamaCppModel, isResponsesPromptRewriteBypassApi, isRoutedFallbackModel, isValidModelsConfigForEffectiveCompat, modelKey, readEffectiveCompatConfig } from "./src/model-identity.ts";
18
+ import { CONFIG_FILE_PATH, VIRTUAL_REWRITE_ENV, FOOTER_MODE_ENV, TOOL_ORDER_ENV, featureEnabled, footerStatsMode, isEnabledEnv, isToolOrderEnabled, parseFooterStatsMode, parsePersistedCacheOptimizerConfig, readPersistedCacheOptimizerConfig, resolveFooterStatsMode, runtimeOptimizerEnabled, setPersistedCacheOptimizerConfig, shouldInjectOpenAIPromptCacheKey, writePersistedCacheOptimizerConfig, writePersistedFeature, writePersistedFooterMode } from "./src/config.ts";
19
+ import { LONG_CACHE_RETENTION_VALUE, PI_CACHE_RETENTION_BASELINE_SYMBOL, PI_CACHE_RETENTION_ENV, STARTUP_CACHE_RETENTION_ENV, captureCacheRetentionEnv, getOrCaptureCacheRetentionBaseline, decidePromptCacheRetention, recordPromptCacheRetentionDecision, requestLongCacheRetention, restoreCacheRetentionEnv, userRequestedLongCacheRetention } from "./src/retention.ts";
28
20
  import { isRuntimeOptimizerEnabled, setRuntimeOptimizerEnabled } from "./src/config.ts";
29
21
  import { compareToolOrderEntries, getToolNameForPayload, isKnownToolOrderApi, isToolOrderingEligibleModel, isVerifiedToolForApi, normalizeToolsInPayload, sortToolsInPayload } from "./src/tool-ordering.ts";
30
22
  import { SKILL_COMPRESSION_MIN_COUNT, compressSkillsInSystemPrompt, compressSkillsViaSection, formatSkillsForPrompt, formatSkillsForPromptCompressed, stripSessionOverviewChurn, explainSkillCompressionSkip, recordSkillCompressionOutcome, getLastSkillCompressionOutcome, describeSkillCompressionOutcome } from "./src/prompt-rewrite.ts";
31
- import { addEffectiveSessionAffinityHeaders, addOpenAIPromptCacheKey, clampPromptCacheKey, collectAnthropicCacheControlsInWireOrder, downgradeAnthropicLongCacheControls, getEffectiveCompatValueSource, hasAnthropicCacheTtlOrderError, hasEffectivePromptCacheKey, isPromptCacheKeyOmittedForModel, normalizeAnthropicCacheControlTtlOrder, omitOpenAIPromptCacheKeys, shouldInjectOpenAIPromptCacheKeyForModel } from "./src/request-payload.ts";
32
- import { PI_CACHE_HINTS_SYMBOL, PI_ROUTING_REGISTRY_SYMBOL, type PiCacheHintsInput, type PiCacheHintsOutput, type PiCacheHintsV1, applyConfiguredTransportToModel, canRewriteNativeVirtualPrompt, describeNativeVirtualRouteNote, ensureRoutingRegistry, findModelInRegistry, findNativeVirtualDispatches, firstNonEmptyString, getProtocolGlobal, getProviderPayloadModelId, getRoutingRegistry, hashSessionId, installCacheHintsService, isRouterModel, nativeVirtualDispatchFromMessage, nativeVirtualDispatchToModel, parseRouteSnapshot, resolveActiveRouteSnapshot, resolveNativeVirtualRequestModel, resolveNativeVirtualRouteModel, resolveRouteModel, routeSnapshotToPiModel, sessionHashFromContext } from "./src/routing.ts";
33
- import { CONFIG_RECEIPT_PATH, type PromptCacheKeyConfigReceipt, type PromptCacheKeyConfigReceiptSnapshot, applyPromptCacheKeyConfigFix, configReceiptBackupPath, parsePromptCacheKeyConfigReceipt, rollbackPromptCacheKeyConfig, writePromptCacheKeyConfigReceipt } from "./src/prompt-cache-key-config.ts";
34
- import { type CompatAdvicePlacement, appendCredentialSafeProviderGuidance, appendDeepSeekCompatAdviceLines, appendOpenAIProxyCompatAdviceLines, buildDeepSeekCompatSuggestion, buildDeepSeekCompatWarningText, buildModelCompatOverride, buildOpenAIProxyCompatWarningText, buildProviderCompatOverride, buildSafeOpenAIProxyCompatSuggestion, describeMissingAdaptiveThinkingCompat, describeMissingCacheCompatForModel, describeMissingDeepSeekCompat, describeMissingOpenAICompatibleProxyCompat, getAgentDirDisplayPath, getModelsJsonDisplayPath, isAdaptiveThinkingCompatApplicable, isDeepSeekWireCompatApplicable } from "./src/compat-advice.ts";
23
+ import { addEffectiveSessionAffinityHeaders, addOpenAIPromptCacheKey, clampPromptCacheKey, collectAnthropicCacheControlsInWireOrder, downgradeAnthropicLongCacheControls, getEffectiveCompatValueSource, hasAnthropicCacheTtlOrderError, hasEffectivePromptCacheKey, isPromptCacheKeyOmittedForModel, normalizeAnthropicCacheControlTtlOrder, omitOpenAIPromptCacheKeys, shouldInjectOpenAIPromptCacheKeyForModel, withAnthropicCacheTtlRepair, withoutPromptCacheRetention, derivePromptCacheKey } from "./src/request-payload.ts";
24
+ import { PI_CACHE_HINTS_SYMBOL, PI_ROUTING_REGISTRY_SYMBOL, type PiCacheHintsInput, type PiCacheHintsOutput, applyConfiguredTransportToModel, canRewriteNativeVirtualPrompt, describeNativeVirtualRouteNote, ensureRoutingRegistry, findModelInRegistry, findNativeVirtualDispatches, getProtocolGlobal, getProviderPayloadModelId, getRoutingRegistry, hashSessionId, installCacheHintsService, nativeVirtualDispatchFromMessage, nativeVirtualDispatchToModel, parseRouteSnapshot, resolveActiveRouteSnapshot, resolveNativeVirtualRequestModel, resolveNativeVirtualRouteModel, resolveRouteModel, routeSnapshotToPiModel, sessionHashFromContext } from "./src/routing.ts";
25
+ import { CONFIG_RECEIPT_PATH, applyPromptCacheKeyConfigFix, configReceiptBackupPath, parsePromptCacheKeyConfigReceipt, rollbackPromptCacheKeyConfig, writePromptCacheKeyConfigReceipt } from "./src/prompt-cache-key-config.ts";
26
+ import { buildDeepSeekCompatSuggestion, buildDeepSeekCompatWarningText, buildModelCompatOverride, buildOpenAIProxyCompatWarningText, buildProviderCompatOverride, buildSafeOpenAIProxyCompatSuggestion, describeMissingAdaptiveThinkingCompat, describeMissingCacheCompatForModel, describeMissingDeepSeekCompat, describeMissingOpenAICompatibleProxyCompat, getAgentDirDisplayPath, getModelsJsonDisplayPath, isAdaptiveThinkingCompatApplicable, isDeepSeekWireCompatApplicable } from "./src/compat-advice.ts";
35
27
  import { type CacheProviderAdapter, isVirtualRoutingModel, modelFromAssistantMessage, selectAdapterForAssistantMessage, selectAdapterForModel } from "./src/adapters.ts";
36
28
  import { describeMissingOpenAIFamilyProxyCompat } from "./src/compat-advice.ts";
37
29
  import { type CacheUsageSample, buildAllStatsOutput, buildContributorsStatsOutput, buildSessionStatsOutput, buildStatsOutput, deriveTotalsByModelFromSessionStats, filterRestorableStatsForSession, formatCacheStats, formatCompactStats, formatHitRatio, formatRecentTrendSummary, formatTokenM, hasMissingUsageFields, makeSessionModelKey, mergeCacheSessions, mergeCacheTotals, mergeLastRoutedModels, modelKeyFromSessionKey, parsePersistedCacheStats, parsePersistedTotalsByModel, prefixFooterStatus, readPersistedCacheStats, routedModelRefToPiModel, selectFooterStatsForModel, writePersistedCacheStats } from "./src/stats-report.ts";
38
- import { appendAdaptiveThinkingCompatAdviceLines, buildAdaptiveThinkingCompatSuggestion, buildCompatDiagnosis, buildDoctorDiagnosis, buildFixSuggestion, buildLowHitDiagnosis, describeOptionalOpenAICompatibleProxyCompat, describeRouterChannelDiagnostics, getCompatCheckNotApplicableLines, getOptionalAssistantHttpStatus, getPromptCacheRetentionUnsupportedHint, isCompatCheckApplicable, isDeepSeekCompatCheckApplicable, isOpenAISdkHeader403Applicable, isPromptCacheKeyUnsupportedApplicable, isPromptCacheRetention400Applicable, isSessionAffinity403Applicable } from "./src/diagnostics.ts";
39
- import { REASONING_PROTOCOL_FALLBACK_SYMBOL, buildReasoningProtocolFixSuggestion, getAnthropicTtlFallbackState, getReasoningProtocolFallbackState, hasExplicitDeepSeekReasoningProtocol, hasPromptCacheKeyUnsupportedErrorMessage, hasPromptCacheKeyUnsupportedText, hasPromptCacheRetentionUnsupportedErrorMessage, hasPromptCacheRetentionUnsupportedText, hasReasoningProtocolRejectionErrorMessage, hasReasoningProtocolRejectionText, isExplicitPromptCacheRetentionUnsupportedApplicable, isReasoningProtocolRejectionForModel, isReasoningProtocolRejectionSignalApplicable, mergeFixSuggestions, notifyReasoningProtocolObservation } from "./src/provider-errors.ts";
30
+ import { appendAdaptiveThinkingCompatAdviceLines, buildAdaptiveThinkingCompatSuggestion, buildCompatDiagnosis, buildDoctorDiagnosis, buildFixSuggestion, buildLowHitDiagnosis, describeOptionalOpenAICompatibleProxyCompat, describeRouterChannelDiagnostics, getCompatCheckNotApplicableLines, getPromptCacheRetentionUnsupportedHint, isCompatCheckApplicable, isDeepSeekCompatCheckApplicable, isOpenAISdkHeader403Applicable, isPromptCacheKeyUnsupportedApplicable, isPromptCacheRetention400Applicable, isSessionAffinity403Applicable } from "./src/diagnostics.ts";
31
+ import { REASONING_PROTOCOL_FALLBACK_SYMBOL, buildReasoningProtocolFixSuggestion, getAnthropicTtlFallbackState, getReasoningProtocolFallbackState, hasExplicitDeepSeekReasoningProtocol, hasPromptCacheKeyUnsupportedErrorMessage, hasPromptCacheKeyUnsupportedText, hasPromptCacheRetentionUnsupportedErrorMessage, hasReasoningProtocolRejectionErrorMessage, hasReasoningProtocolRejectionText, isExplicitPromptCacheRetentionUnsupportedApplicable, isReasoningProtocolRejectionForModel, isReasoningProtocolRejectionSignalApplicable, mergeFixSuggestions, notifyReasoningProtocolObservation } from "./src/provider-errors.ts";
40
32
  import { hasPromptCacheKeyUnsupportedSignal, hasPromptCacheRetentionUnsupportedSignal, hasReasoningProtocolRejectionSignal } from "./src/provider-errors.ts";
41
33
  import { MAX_RECENT_SAMPLES, buildExactRouterStatusEntry, consolidateDirectProviderStatsModel, createSerializedAsyncRunner, findBestRouterModelStats, keyForModelExt } from "./src/stats-report.ts";
42
34
  import { type PiCacheHintSnapshot, getCacheHintsService, getSessionPromptCacheKey, isOptimizerOwnedCacheHintsService, markOptimizerOwnedCacheHintsService } from "./src/routing.ts";
43
35
  import { isActionablePromptCacheKeyConfigReceipt, readPromptCacheKeyConfigReceipt, readPromptCacheKeyConfigReceiptSnapshot } from "./src/prompt-cache-key-config.ts";
44
- import { formatOptimizerRuntimeMode, formatPersistentFeatureConfig, getOptimizerRuntimeModeLines, readPersistedFooterMode } from "./src/config.ts";
45
- import { FEATURE_COMMAND_MAP, getCacheOptimizerArgumentCompletions } from "./src/command-completion.ts";
36
+ import { formatOptimizerRuntimeMode, getOptimizerRuntimeModeLines, readPersistedFooterMode } from "./src/config.ts";
37
+ import { getCacheOptimizerArgumentCompletions } from "./src/command-completion.ts";
46
38
  import { MAX_PROVIDER_REQUEST_STATES, pruneProviderRequestStates, snapshotProviderRequestModel } from "./src/request-state.ts";
47
39
  import { NO_PROMPT_REWRITE_ENV } from "./src/config.ts";
48
40
  import { createCacheOptimizerCommandHandler } from "./src/command.ts";
@@ -50,37 +42,6 @@ import { type ProviderRequestState } from "./src/request-state.ts";
50
42
 
51
43
  const STATUS_KEY = "pi-cache-stats";
52
44
 
53
- type PersistedCacheStatsV2 = {
54
- version: 2;
55
- statsByProvider: Partial<Record<CacheProviderId, CacheStats>>;
56
- };
57
-
58
- type PersistedCacheStatsV3 = {
59
- version: 3;
60
- statsByModel: Record<string, CacheStats>;
61
- legacyFamily: Partial<Record<CacheProviderId, CacheStats>>;
62
- };
63
-
64
- /**
65
- * V4 format: session-scoped stats buckets.
66
- * Each Pi process/session gets its own stats isolated by a hashed session id.
67
- *
68
- * sessions: sessionHash → modelKey (provider/id) → CacheStats
69
- * legacyFamily: unchanged from v3 (migration/fallback when ctx.model is unknown)
70
- */
71
- type PersistedCacheStatsV4 = {
72
- version: 4;
73
- sessions: Record<string, Record<string, CacheStats>>;
74
- legacyFamily: Partial<Record<CacheProviderId, CacheStats>>;
75
- };
76
-
77
- type PersistedCacheStatsV5 = {
78
- version: 5;
79
- sessions: Record<string, Record<string, CacheStats>>;
80
- legacyFamily: Partial<Record<CacheProviderId, CacheStats>>;
81
- lastRoutedModelBySession?: Record<string, PersistedRoutedModelRef>;
82
- };
83
-
84
45
  function buildAdaptiveThinkingCompatWarningText(key: string, missing: string[]): string {
85
46
  const slashIdx = key.indexOf("/");
86
47
  const providerLabel = slashIdx > 0 ? key.slice(0, slashIdx) : key;
@@ -143,6 +104,11 @@ function notifyCacheCompatIfNeeded(
143
104
  // extension's public API; pi only invokes the default export below.
144
105
  export const __internals_for_tests = {
145
106
  getLastSkillCompressionOutcome,
107
+ decidePromptCacheRetention,
108
+ userRequestedLongCacheRetention,
109
+ derivePromptCacheKey,
110
+ withAnthropicCacheTtlRepair,
111
+ withoutPromptCacheRetention,
146
112
  describeSkillCompressionOutcome,
147
113
  stripSessionOverviewChurn,
148
114
  formatSkillsForPrompt,
@@ -197,6 +163,7 @@ export const __internals_for_tests = {
197
163
  isOpenAISdkHeader403Applicable,
198
164
  hasPromptCacheRetentionUnsupportedSignal,
199
165
  hasPromptCacheRetentionUnsupportedErrorMessage,
166
+ hasPromptCacheKeyUnsupportedText,
200
167
  hasPromptCacheKeyUnsupportedSignal,
201
168
  hasPromptCacheKeyUnsupportedErrorMessage,
202
169
  isPromptCacheKeyUnsupportedApplicable,
@@ -965,11 +932,19 @@ export default function (pi: ExtensionAPI) {
965
932
  * The caller strips prompt_cache_retention when this returns false.
966
933
  */
967
934
  function hasExplicitLongRetentionOptIn(model: PiModel): boolean {
968
- return hasExplicitLongRetentionOptInFromConfig(
935
+ // Pi's built-in catalog only ever sets supportsLongCacheRetention: false, so
936
+ // an explicit true on the runtime model (provider extension or merged
937
+ // models.json) is as deliberate as one read from models.json directly.
938
+ // A value resolved from models.json (modelOverrides > model > provider)
939
+ // still wins, so an explicit false there is never overridden.
940
+ const configured = resolveExplicitCompatValue(
969
941
  readEffectiveCompatConfig(),
970
942
  model.provider,
971
943
  model.id,
944
+ "supportsLongCacheRetention",
972
945
  );
946
+ if (configured !== undefined) return configured.value === true;
947
+ return asRecord(model.compat)?.supportsLongCacheRetention === true;
973
948
  }
974
949
 
975
950
  /**
@@ -1013,6 +988,11 @@ export default function (pi: ExtensionAPI) {
1013
988
  });
1014
989
 
1015
990
  pi.on("agent_settled", async (_event, ctx) => {
991
+ // No provider request can still be in flight once the agent has settled.
992
+ // Drop leftover lifecycle records (a request whose message never ended, or
993
+ // a record consumed out of order) so they cannot be correlated with the
994
+ // next run's messages.
995
+ providerRequestStates.length = 0;
1016
996
  await refreshShardAggregate();
1017
997
  await publishStatus(ctx);
1018
998
  });
@@ -1197,61 +1177,57 @@ export default function (pi: ExtensionAPI) {
1197
1177
  toolOrderChanged = normalized.changed;
1198
1178
  }
1199
1179
 
1180
+ // Every mutation below works on a copy and is returned explicitly: Pi's
1181
+ // event.payload is never modified in place, so the hook does not depend on
1182
+ // the host honouring in-place edits.
1183
+ let payloadChanged = toolOrderChanged;
1184
+
1200
1185
  // Anthropic rejects mixed cache breakpoints when a 1h block appears after
1201
1186
  // a 5m/default block in wire order (tools → system → messages). Repair any
1202
1187
  // conflict visible in Pi's final payload immediately. Some proxies inject
1203
1188
  // hidden short breakpoints after this hook; only models that have actually
1204
1189
  // returned the explicit TTL-order error receive the process-local 5m fallback.
1205
1190
  if (requestModel && isAnthropicMessagesApi(requestModel.api)) {
1206
- const visibleConflictFixed = normalizeAnthropicCacheControlTtlOrder(requestPayload);
1207
- if (!visibleConflictFixed && anthropicTtlOrderErrorModels.has(modelKey(requestModel))) {
1208
- downgradeAnthropicLongCacheControls(requestPayload);
1191
+ const repaired = withAnthropicCacheTtlRepair(requestPayload, anthropicTtlOrderErrorModels.has(modelKey(requestModel)));
1192
+ if (repaired !== undefined) {
1193
+ requestPayload = repaired;
1194
+ payloadChanged = true;
1209
1195
  }
1210
1196
  }
1211
1197
 
1212
- // ── Safety: strip prompt_cache_retention from payload for models that
1213
- // are not authorised to send it. Pi defaults supportsLongCacheRetention
1214
- // to true for all openai-completions models, but most third-party APIs
1215
- // reject the parameter with 400 “Extra inputs are not permitted”.
1216
- //
1217
- // Gate order (first match wins):
1218
- // 1. Official OpenAI → keep (trusted to support it)
1219
- // 2. 400 history → strip (empirical evidence overrides user config)
1220
- // 3. Explicit opt-in in models.json → keep (user explicitly wants it)
1221
- // 4. Everything else → strip (safe default for third-party APIs)
1222
- //
1223
- // Gate 2 before Gate 3 is critical: if a user explicitly opted in but
1224
- // the API returned 400, we must strip — otherwise the 400 repeats forever.
1225
- if (runtimeOptimizerEnabled) {
1226
- const payloadRecord = asRecord(requestPayload);
1227
- if (payloadRecord && typeof payloadRecord.prompt_cache_retention === "string") {
1228
- if (requestModel) {
1229
- if (isOfficialOpenAIBaseUrl(requestModel)) {
1230
- // Gate 1: Official OpenAI → keep
1231
- } else if (promptCacheRetention400Models.has(modelKey(requestModel))) {
1232
- // Gate 2: 400 history → strip (overrides user opt-in)
1233
- delete payloadRecord.prompt_cache_retention;
1234
- } else if (hasExplicitLongRetentionOptIn(requestModel)) {
1235
- // Gate 3: Explicit user opt-in → keep
1236
- } else {
1237
- // Gate 4: Safe default → strip
1238
- delete payloadRecord.prompt_cache_retention;
1239
- }
1198
+ // prompt_cache_retention gate. This extension sets PI_CACHE_RETENTION=long,
1199
+ // which makes Pi emit prompt_cache_retention: "24h" for every endpoint whose
1200
+ // detected compat does not explicitly disable it, including third-party
1201
+ // proxies that reject unknown fields with HTTP 400. The gate undoes only that
1202
+ // side effect; see decidePromptCacheRetention for the order and reasons.
1203
+ if (runtimeOptimizerEnabled && typeof asRecord(requestPayload)?.prompt_cache_retention === "string") {
1204
+ const decision = decidePromptCacheRetention({
1205
+ providerRejected: !!requestModel && promptCacheRetention400Models.has(modelKey(requestModel)),
1206
+ officialOpenAI: !!requestModel && isOfficialOpenAIBaseUrl(requestModel),
1207
+ explicitOptIn: !!requestModel && hasExplicitLongRetentionOptIn(requestModel),
1208
+ userRequestedLong: userRequestedLongCacheRetention(),
1209
+ });
1210
+ recordPromptCacheRetentionDecision(requestModel ? modelKey(requestModel) : undefined, decision);
1211
+ if (!decision.keep) {
1212
+ const stripped = withoutPromptCacheRetention(requestPayload);
1213
+ if (stripped !== undefined) {
1214
+ requestPayload = stripped;
1215
+ payloadChanged = true;
1240
1216
  }
1241
1217
  }
1242
1218
  }
1243
1219
 
1244
1220
  if (isPromptCacheKeyOmittedForModel(requestModel)) {
1245
1221
  const omitted = omitOpenAIPromptCacheKeys(requestPayload);
1246
- return omitted ?? (toolOrderChanged ? requestPayload : undefined);
1222
+ return omitted ?? (payloadChanged ? requestPayload : undefined);
1247
1223
  }
1248
1224
 
1249
1225
  if (!shouldInjectOpenAIPromptCacheKey() || !shouldInjectOpenAIPromptCacheKeyForModel(requestModel)) {
1250
- return toolOrderChanged ? requestPayload : undefined;
1226
+ return payloadChanged ? requestPayload : undefined;
1251
1227
  }
1252
1228
 
1253
1229
  const withCacheKey = addOpenAIPromptCacheKey(requestPayload, getSessionPromptCacheKey(ctx));
1254
- return withCacheKey ?? (toolOrderChanged ? requestPayload : undefined);
1230
+ return withCacheKey ?? (payloadChanged ? requestPayload : undefined);
1255
1231
  });
1256
1232
 
1257
1233
  pi.on("after_provider_response", async (event, ctx) => {
@@ -1411,7 +1387,7 @@ export default function (pi: ExtensionAPI) {
1411
1387
  const errorModel = messageModel
1412
1388
  ? findModelInRegistry(ctx.modelRegistry, messageModel.provider, messageModel.id) ?? messageModel
1413
1389
  : undefined;
1414
- if (isReasoningProtocolRejectionForModel(event.message, errorModel)) {
1390
+ if (errorModel && isReasoningProtocolRejectionForModel(event.message, errorModel)) {
1415
1391
  await notifyReasoningProtocolObservation(
1416
1392
  errorModel,
1417
1393
  ctx,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-cache-optimizer",
3
- "version": "2.8.19",
3
+ "version": "2.8.21",
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",
@@ -37,8 +37,8 @@
37
37
  "check:modules": "node scripts/check-module-graph.mjs"
38
38
  },
39
39
  "devDependencies": {
40
- "@earendil-works/pi-coding-agent": "^1.0.2",
41
- "@earendil-works/pi-server": "1.0.2",
40
+ "@earendil-works/pi-coding-agent": "^1.0.3",
41
+ "@earendil-works/pi-server": "1.0.3",
42
42
  "@types/node": "^22.0.0",
43
43
  "jiti": "^2.7.0",
44
44
  "typescript": "^5.8.3"
package/src/atomic-fs.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import { constants as fsConstants } from "node:fs";
3
- import { chmod, copyFile, link, lstat, mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
3
+ import { chmod, copyFile, link, lstat, mkdir, open, readFile, rename, unlink, writeFile } from "node:fs/promises";
4
4
  import { LOG_PREFIX, asRecord, getErrorCode, isProcessAlive } from "./common.ts";
5
5
  import { MODELS_TRANSACTION_LOCK_PATH, MODELS_TRANSACTION_LOCK_STALE_MS, MODELS_TRANSACTION_LOCK_WAIT_MS, STATE_DIR } from "./paths.ts";
6
6
 
@@ -25,6 +25,31 @@ export function uniqueTempPath(targetPath: string, purpose: string): string {
25
25
  return `${targetPath}.${process.pid}.${Date.now()}.${backupSequence++}.${purpose}.tmp`;
26
26
  }
27
27
 
28
+ /**
29
+ * Create `path` exclusively (`wx`) and flush it to stable storage before the
30
+ * caller renames or links it into place, so a crash right after the commit
31
+ * cannot leave an empty or truncated models.json / config file.
32
+ */
33
+ export async function writeFileExclusiveDurable(path: string, content: string, mode: number): Promise<void> {
34
+ const handle = await open(path, "wx", mode);
35
+ try {
36
+ await handle.writeFile(content, "utf8");
37
+ await handle.sync();
38
+ } finally {
39
+ await handle.close();
40
+ }
41
+ }
42
+
43
+ /** Flush an already-written file (for example a copyFile result) to stable storage. */
44
+ export async function syncFile(path: string): Promise<void> {
45
+ const handle = await open(path, "r+");
46
+ try {
47
+ await handle.sync();
48
+ } finally {
49
+ await handle.close();
50
+ }
51
+ }
52
+
28
53
  export type FileIdentity = { dev: number | bigint; ino: number | bigint };
29
54
 
30
55
  export function sameFileIdentity(left: FileIdentity, right: FileIdentity): boolean {
@@ -80,7 +105,7 @@ export async function atomicReplaceTextFilePreservingMode(
80
105
  try {
81
106
  // `wx` makes the unique temporary path non-overwriting even if a hostile
82
107
  // or stale file appears between name generation and the write.
83
- await writeFile(tempPath, content, { encoding: "utf8", mode, flag: "wx" });
108
+ await writeFileExclusiveDurable(tempPath, content, mode);
84
109
  const tempInfo = await lstat(tempPath);
85
110
  if (tempInfo.isSymbolicLink() || !tempInfo.isFile()) {
86
111
  throw new Error("temporary replacement is not a regular file");
@@ -120,7 +145,7 @@ export async function atomicCreateTextFileNoReplace(
120
145
  ): Promise<void> {
121
146
  const tempPath = uniqueTempPath(targetPath, purpose);
122
147
  try {
123
- await writeFile(tempPath, content, { encoding: "utf8", mode, flag: "wx" });
148
+ await writeFileExclusiveDurable(tempPath, content, mode);
124
149
  const tempInfo = await lstat(tempPath);
125
150
  if (tempInfo.isSymbolicLink() || !tempInfo.isFile()) {
126
151
  throw new Error("temporary creation is not a regular file");
@@ -167,6 +192,7 @@ export async function atomicRestoreFileFromBackup(
167
192
  const tempPath = uniqueTempPath(targetPath, "restore");
168
193
  try {
169
194
  await copyFile(backupPath, tempPath, fsConstants.COPYFILE_EXCL);
195
+ await syncFile(tempPath);
170
196
  const tempInfo = await lstat(tempPath);
171
197
  if (tempInfo.isSymbolicLink() || !tempInfo.isFile()) {
172
198
  throw new Error("temporary restore is not a regular file");
package/src/command.ts CHANGED
@@ -1,6 +1,5 @@
1
1
  import { type ExtensionCommandContext, type ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import { readFile, rename } from "node:fs/promises";
3
- import { join } from "node:path";
2
+ import { readFile } from "node:fs/promises";
4
3
  import { selectAdapterForModel } from "./adapters.ts";
5
4
  import { backupTimestamp, hashText } from "./atomic-fs.ts";
6
5
  import { FEATURE_COMMAND_MAP } from "./command-completion.ts";
@@ -22,6 +21,7 @@ import { type ProviderRequestState } from "./request-state.ts";
22
21
  import { type CacheStats, type ShardAggregate } from "./stats-store.ts";
23
22
  import { type CacheUsageSample } from "./stats-report.ts";
24
23
  import { describeSkillCompressionOutcome } from "./prompt-rewrite.ts";
24
+ import { describePromptCacheRetentionDecision, getLastPromptCacheRetentionDecision } from "./retention.ts";
25
25
 
26
26
  /**
27
27
  * Everything the command needs from the extension instance. The extension keeps this state in closures
@@ -72,7 +72,7 @@ export function createCacheOptimizerCommandHandler(runtime: CommandRuntime) {
72
72
  sessionModelKey,
73
73
  syncSessionHash,
74
74
  } = runtime;
75
- const { getCacheStatsTotalsByModel, getCurrentSessionHashSet, getCurrentSessionHash, getLastStatusText, clearLastStatusText } = runtime;
75
+ const { getCacheStatsTotalsByModel, getCurrentSessionHashSet, getCurrentSessionHash, clearLastStatusText } = runtime;
76
76
 
77
77
  return async function handleCacheOptimizerCommand(
78
78
  args: string,
@@ -110,7 +110,7 @@ export function createCacheOptimizerCommandHandler(runtime: CommandRuntime) {
110
110
  const samples = sk ? getRecentSamples(sk) : [];
111
111
  const lowHitLines = buildLowHitDiagnosis(model, adapter, statsState, samples);
112
112
  const routeNote = describeNativeVirtualRouteNote(selectedModel, model);
113
- const fullDiagnosis = [routeNote, diagnosis, describeSkillCompressionOutcome(), ...lowHitLines].filter((line) => line !== undefined).join("\n");
113
+ const fullDiagnosis = [routeNote, diagnosis, describeSkillCompressionOutcome(), describePromptCacheRetentionDecision(getLastPromptCacheRetentionDecision()), ...lowHitLines].filter((line) => line !== undefined).join("\n");
114
114
  cmdCtx.ui.notify(fullDiagnosis, "info");
115
115
  } else if (subcommand === "stats") {
116
116
  const aggregate = await refreshShardAggregate();
@@ -1,10 +1,8 @@
1
1
  import { type PiModel, isNonEmptyString, lower } from "./common.ts";
2
2
  import { isAdaptiveGenerationModel, isKimiCodingAdaptiveModel } from "./model-detect.ts";
3
3
  import { getCompat, isDeepSeekLikeModel, isKimiCodingEmptySignatureModel, isKnownThirdPartyOpenAIEndpoint, isOpenAICompatibleProxyApi, isOpenAIFamilyModel, isPiBuiltInLlamaCppModel, isRoutedFallbackModel } from "./model-identity.ts";
4
- import { MODELS_JSON_PATH, STATE_DIR } from "./paths.ts";
5
4
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
6
5
  import { homedir } from "node:os";
7
- import { join } from "node:path";
8
6
 
9
7
  /** Join display-only path fragments without resolving them for I/O. */
10
8
  export function joinDisplayPath(base: string, child: string, platform: string = process.platform): string {
@@ -1,4 +1,4 @@
1
- import { type PiModel, type UnknownRecord, asRecord, lower } from "./common.ts";
1
+ import { type PiModel, type UnknownRecord, asRecord } from "./common.ts";
2
2
 
3
3
  export type CacheCompat = {
4
4
  supportsStore?: boolean;
@@ -1,4 +1,3 @@
1
- import { join } from "node:path";
2
1
  import { type CacheProviderAdapter } from "./adapters.ts";
3
2
  import { type PiModel, type UnknownRecord, asRecord, isNonEmptyString, lower } from "./common.ts";
4
3
  import { type CompatAdvicePlacement, appendCredentialSafeProviderGuidance, appendDeepSeekCompatAdviceLines, appendOpenAIProxyCompatAdviceLines, buildDeepSeekCompatSuggestion, buildSafeOpenAIProxyCompatSuggestion, describeMissingCacheCompatForModel, getModelsJsonDisplayPath, isAdaptiveThinkingCompatApplicable, isDeepSeekWireCompatApplicable } from "./compat-advice.ts";
@@ -555,8 +554,6 @@ export function buildLowHitDiagnosis(
555
554
  const recent10 = samples.slice(-10);
556
555
  const recent10Hits = recent10.filter((s) => s.hit).length;
557
556
  const recent10Total = recent10.length;
558
- const recent10Cached = recent10.reduce((sum, s) => sum + s.cachedInputTokens, 0);
559
- const recent10Input = recent10.reduce((sum, s) => sum + s.totalInputTokens, 0);
560
557
 
561
558
  // 5. Today's overall trend from persisted stats
562
559
  const todayStats = stats ?? emptyCacheStats();
@@ -611,7 +608,6 @@ export function buildLowHitDiagnosis(
611
608
 
612
609
  // Priority 4: recent trend low
613
610
  if (recent10Total > 0) {
614
- const hitRatio = recent10Input > 0 ? Math.round((recent10Cached / recent10Input) * 100) : 0;
615
611
  if (recent10Hits === 0 && todayStats.totalRequests > 3 && todayHitRatio < 30) {
616
612
  lines.push(`📉 Cache hit rate is low: ${todayHitRatio}% today (${recent10Total} recent samples).`);
617
613
  lines.push(" Likely causes: proxy routing to different backends per request,");
package/src/jsonc.ts CHANGED
@@ -1,4 +1,3 @@
1
- import { join } from "node:path";
2
1
 
3
2
  // ── String-aware JSONC scanning primitives ─────────────────────────
4
3
  //
@@ -1,8 +1,8 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { constants as fsConstants } from "node:fs";
3
- import { chmod, copyFile, link, lstat, mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
3
+ import { chmod, copyFile, link, lstat, mkdir, readFile, rename, unlink } from "node:fs/promises";
4
4
  import { basename, dirname, join } from "node:path";
5
- import { type FileIdentity, atomicReplaceTextFilePreservingMode, atomicRestoreFileFromBackup, backupTimestamp, hashText, readRegularTextFile, sameFileIdentity, uniqueTempPath, validateAtomicTarget, withModelsJsonTransactionLock } from "./atomic-fs.ts";
5
+ import { type FileIdentity, atomicReplaceTextFilePreservingMode, atomicRestoreFileFromBackup, backupTimestamp, hashText, readRegularTextFile, sameFileIdentity, uniqueTempPath, validateAtomicTarget, withModelsJsonTransactionLock, writeFileExclusiveDurable } from "./atomic-fs.ts";
6
6
  import { LOG_PREFIX, type PiModel, asRecord, getErrorCode } from "./common.ts";
7
7
  import { findLastExactModelDefinition, resolveEffectiveCompatFromConfig } from "./compat-config.ts";
8
8
  import { type FixReceiptCompatChange, type FixReceiptPlacement, type ModelsJsonFixReceiptV1, RECEIPT_COMPAT_KEYS, type ReceiptScalar, type ReceiptScalarState, isReceiptTimestamp, isSafeReceiptText, isSha256 } from "./fix-types.ts";
@@ -1610,7 +1610,7 @@ export async function writeModelsJsonFixReceipt(
1610
1610
 
1611
1611
  const tempPath = uniqueTempPath(receiptPath, "receipt");
1612
1612
  try {
1613
- await writeFile(tempPath, JSON.stringify(receipt, null, 2) + "\n", { encoding: "utf8", mode, flag: "wx" });
1613
+ await writeFileExclusiveDurable(tempPath, JSON.stringify(receipt, null, 2) + "\n", mode);
1614
1614
  const tempInfo = await lstat(tempPath);
1615
1615
  if (tempInfo.isSymbolicLink() || !tempInfo.isFile()) throw new Error("invalid temporary fix receipt");
1616
1616
  await chmod(tempPath, mode);
@@ -1,12 +1,12 @@
1
- import { type FileIdentity, atomicCreateTextFileNoReplace, atomicReplaceTextFilePreservingMode, atomicRestoreFileFromBackup, backupTimestamp, hashText, sameFileIdentity, uniqueTempPath, validateAtomicTarget, withModelsJsonTransactionLock } from "./atomic-fs.ts";
1
+ import { type FileIdentity, atomicCreateTextFileNoReplace, atomicReplaceTextFilePreservingMode, atomicRestoreFileFromBackup, backupTimestamp, hashText, sameFileIdentity, uniqueTempPath, validateAtomicTarget, withModelsJsonTransactionLock, writeFileExclusiveDurable } from "./atomic-fs.ts";
2
2
  import { LOG_PREFIX, type PiModel, asRecord, getErrorCode } from "./common.ts";
3
- import { CONFIG_FILE_PATH, type PersistedCacheOptimizerConfigV2, type PersistedCacheOptimizerConfigV3, normalizePersistedCacheOptimizerConfig, parsePersistedCacheOptimizerConfig } from "./config.ts";
3
+ import { CONFIG_FILE_PATH, type PersistedCacheOptimizerConfigV3, normalizePersistedCacheOptimizerConfig, parsePersistedCacheOptimizerConfig } from "./config.ts";
4
4
  import { isReceiptTimestamp, isSafeReceiptText, isSha256 } from "./fix-types.ts";
5
5
  import { modelKey } from "./model-identity.ts";
6
6
  import { STATE_DIR } from "./paths.ts";
7
7
  import { randomUUID } from "node:crypto";
8
8
  import { constants as fsConstants } from "node:fs";
9
- import { chmod, copyFile, link, lstat, mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
9
+ import { chmod, copyFile, link, lstat, mkdir, readFile, rename, unlink } from "node:fs/promises";
10
10
  import { basename, dirname, join } from "node:path";
11
11
 
12
12
  export const CONFIG_RECEIPT_FILE_NAME = "pi-cache-optimizer-config-receipt.json";
@@ -125,7 +125,7 @@ export async function writePromptCacheKeyConfigReceipt(
125
125
  }
126
126
  const tempPath = uniqueTempPath(receiptPath, "config-receipt");
127
127
  try {
128
- await writeFile(tempPath, JSON.stringify(receipt, null, 2) + "\n", { encoding: "utf8", mode: 0o600, flag: "wx" });
128
+ await writeFileExclusiveDurable(tempPath, JSON.stringify(receipt, null, 2) + "\n", 0o600);
129
129
  const tempInfo = await lstat(tempPath);
130
130
  if (tempInfo.isSymbolicLink() || !tempInfo.isFile()) throw new Error("invalid temporary prompt-cache-key receipt");
131
131
  await chmod(tempPath, 0o600);
@@ -199,7 +199,7 @@ export async function applyPromptCacheKeyConfigFixUnderLock(
199
199
  const tempPath = uniqueTempPath(configPath, "config-fix");
200
200
  let committedInfo: Awaited<ReturnType<typeof lstat>> | undefined;
201
201
  try {
202
- await writeFile(tempPath, modifiedText, { encoding: "utf8", mode, flag: "wx" });
202
+ await writeFileExclusiveDurable(tempPath, modifiedText, mode);
203
203
  await chmod(tempPath, mode);
204
204
  if (originalInfo) {
205
205
  await validateAtomicTarget(configPath, { identity: originalInfo, hash: beforeHash, mode });
@@ -1,5 +1,5 @@
1
1
  import { type BuildSystemPromptOptions } from "@earendil-works/pi-coding-agent";
2
- import { basename, dirname, join } from "node:path";
2
+ import { basename, dirname } from "node:path";
3
3
  import { NO_SKILL_COMPRESSION_ENV, featureEnabled } from "./config.ts";
4
4
 
5
5
 
@@ -6,7 +6,6 @@ import { getAssistantRecord, getCompat, isDeepSeekLikeModel, isOfficialOpenAIBas
6
6
  import { isActionableModelsJsonFixReceipt, readModelsJsonFixReceipt } from "./models-json-fix.ts";
7
7
  import { firstNonEmptyString } from "./routing.ts";
8
8
  import { type ExtensionContext } from "@earendil-works/pi-coding-agent";
9
- import { join } from "node:path";
10
9
 
11
10
  export const ANTHROPIC_TTL_FALLBACK_SYMBOL = Symbol.for("pi.cache.optimizer.anthropic-ttl-fallback.v1");
12
11
 
@@ -84,6 +83,15 @@ export function hasPromptCacheRetentionUnsupportedErrorMessage(message: unknown)
84
83
  hasPromptCacheRetentionUnsupportedText(record.errorMessage);
85
84
  }
86
85
 
86
+ // A Chinese rejection must start a diagnostic clause, not occur inside a
87
+ // negation or condition. Keep comma-separated conditions in the same sentence.
88
+ function hasChineseRejectionContext(value: string, index: number): boolean {
89
+ const prefix = value.slice(0, index).split(/[;!\n。;!]/).at(-1) ?? "";
90
+ const suffix = value.slice(index).split(/[;!\n。;!]/)[0];
91
+ return !/(?:如果|若|当|仅在|只有|模式下|情况下|设置.{0,40}时)/.test(prefix + suffix) &&
92
+ !/(?:并非|并不|并未|不是)/.test(prefix);
93
+ }
94
+
87
95
  export function hasPromptCacheKeyUnsupportedText(value: unknown): boolean {
88
96
  const normalized = lower(value)
89
97
  .replace(/["'`]/g, "")
@@ -98,11 +106,13 @@ export function hasPromptCacheKeyUnsupportedText(value: unknown): boolean {
98
106
  // Keep this deliberately grammar-bound. A rejected value or a conditional
99
107
  // restriction (for example, "not allowed when temperature is set") is not
100
108
  // proof that the endpoint lacks support for the field itself.
101
- const terminal = String.raw`(?=$|[}\]>,.;])`;
109
+ const terminal = String.raw`(?=$|[}\]>,.;,。;])`;
102
110
  return (
103
111
  new RegExp(String.raw`(?:unsupported|unknown|unrecognized|unexpected)\s+${field}\s*[:=]?\s*${key}${terminal}`).test(normalized) ||
104
112
  new RegExp(String.raw`(?:extra\s+inputs?|${field}\s+not\s+(?:allowed|permitted|supported))\s*[:=]\s*${key}${terminal}`).test(normalized) ||
105
- new RegExp(String.raw`${key}(?:\s+${field})?\s*[:=]?\s*(?:is\s+)?${unsupported}${terminal}`).test(normalized)
113
+ new RegExp(String.raw`${key}(?:\s+${field})?\s*[:=]?\s*(?:is\s+)?${unsupported}${terminal}`).test(normalized) ||
114
+ Array.from(normalized.matchAll(new RegExp(String.raw`(?:^|[^a-z0-9_\u4e00-\u9fff])(?:未知|未识别|不支持|不允许)(?:请求)?(?:字段|参数)\s*[::]\s*${key}${terminal}`, "g")))
115
+ .some((match) => hasChineseRejectionContext(normalized, match.index))
106
116
  );
107
117
  }
108
118
 
@@ -126,6 +136,15 @@ export function hasReasoningProtocolRejectionText(value: unknown): boolean {
126
136
  /(?:^|[^a-z0-9_])(?:unsupported|unknown|unrecognized|invalid|disallowed|rejected|not\s+(?:a\s+)?valid|not\s+accepted|not\s+allowed|not\s+permitted)(?:[_ ](?:parameter|field|argument))?\s*[:=]?\s*["'`]?thinking["'`]?(?![a-z0-9_])/,
127
137
  /(?:^|[^a-z0-9_])(?:extra\s+inputs?|additional\s+(?:inputs?|parameters?))\s+(?:are\s+)?(?:not permitted|not allowed|unsupported)\s*[:=]?\s*["'`]?thinking["'`]?(?![a-z0-9_])/,
128
138
  ];
139
+ const chineseStart = String.raw`(?:^|[^a-z0-9_\u4e00-\u9fff])`;
140
+ const chineseEnd = String.raw`(?=\s*(?:$|["'\x60}\]>,.;!?,。;!?]))`;
141
+ const chineseRejection = String.raw`(?:未知|未识别|无法识别|不支持|不被支持|不允许|不被允许|不接受|不被接受|无效|不合法|拒绝|被拒绝)`;
142
+ const chineseThinking = String.raw`["'\x60]?thinking["'\x60]?`;
143
+ const chinesePatterns = [
144
+ new RegExp(String.raw`${chineseStart}${chineseRejection}(?:请求)?(?:参数|字段)\s*[::]?\s*${chineseThinking}${chineseEnd}`, "g"),
145
+ new RegExp(String.raw`${chineseStart}${chineseRejection}\s*${chineseThinking}(?:\s*(?:请求)?(?:参数|字段))?${chineseEnd}`, "g"),
146
+ new RegExp(String.raw`${chineseStart}${chineseThinking}\s*(?:(?:请求)?(?:参数|字段)\s*)?${chineseRejection}${chineseEnd}`, "g"),
147
+ ];
129
148
  let rejectionEnd = -1;
130
149
  for (const pattern of thinkingParameterRejectionPatterns) {
131
150
  const match = pattern.exec(normalized);
@@ -133,6 +152,13 @@ export function hasReasoningProtocolRejectionText(value: unknown): boolean {
133
152
  rejectionEnd = match.index + match[0].length;
134
153
  }
135
154
  }
155
+ for (const pattern of chinesePatterns) {
156
+ for (const match of normalized.matchAll(pattern)) {
157
+ if (hasChineseRejectionContext(normalized, match.index)) {
158
+ rejectionEnd = Math.max(rejectionEnd, match.index + match[0].length);
159
+ }
160
+ }
161
+ }
136
162
  if (rejectionEnd < 0) return false;
137
163
 
138
164
  // The provider must direct the caller to reasoning_effort after the
@@ -142,7 +168,7 @@ export function hasReasoningProtocolRejectionText(value: unknown): boolean {
142
168
  const recommendation = normalized.slice(rejectionEnd, rejectionEnd + 260);
143
169
  if (!/\breasoning[_\.]effort\b/.test(recommendation)) return false;
144
170
 
145
- const recommendationClauses = recommendation.split(/[.;!?\n]/);
171
+ const recommendationClauses = recommendation.split(/[.;!?\n,。;!?]/);
146
172
  return recommendationClauses.some((clause) => {
147
173
  if (!/\breasoning[_\.]effort\b/.test(clause)) return false;
148
174
  // A target mentioned inside a negated/disabled clause is not positive
@@ -150,7 +176,9 @@ export function hasReasoningProtocolRejectionText(value: unknown): boolean {
150
176
  if (
151
177
  /\b(?:do\s+not|don't|never|avoid)\s+(?:use|set|send|pass|provide)?\s*["'`]?reasoning[_\.]effort\b/.test(clause) ||
152
178
  /\breasoning[_\.]effort\b[^.;]{0,80}\b(?:must|should|may|do)\s+(?:not|never)\b/.test(clause) ||
153
- /\breasoning[_\.]effort\b[^.;]{0,80}\b(?:unsupported|disabled|unavailable|not\s+(?:supported|accepted|allowed|available|enabled|required|recommended|expected))\b/.test(clause)
179
+ /\breasoning[_\.]effort\b[^.;]{0,80}\b(?:unsupported|disabled|unavailable|not\s+(?:supported|accepted|allowed|available|enabled|required|recommended|expected))\b/.test(clause) ||
180
+ /(?:请)?(?:不要|勿|不应|不该|不得|不可|不能|不建议|不推荐|禁止|无需|不必|不需要)\s*(?:再)?(?:使用|设置|发送|传入|提供|改用|采用|把)?\s*(?:参数\s*)?["'`]?reasoning[_\.]effort\b/.test(clause) ||
181
+ /\breasoning[_\.]effort\b[^.;,。;!?]{0,80}(?:不支持|不允许|不可用|不接受|不能|不建议|不推荐|不需要|无需|不必|不得|不应|不该|禁止)/.test(clause)
154
182
  ) return false;
155
183
 
156
184
  return [
@@ -159,6 +187,9 @@ export function hasReasoningProtocolRejectionText(value: unknown): boolean {
159
187
  /(?:parameter|field|option)\s+(?:is|should be)\s+["'`]?reasoning[_\.]effort["'`]?(?![a-z0-9_])/,
160
188
  /instead[^.;]{0,120}(?:use|try|set|send|pass|provide)\s+(?:the\s+)?["'`]?reasoning[_\.]effort["'`]?(?![a-z0-9_])/,
161
189
  /(?:replace|change|switch)\s+(?:the\s+)?["'`]?thinking["'`]?\s+(?:with|to)\s+["'`]?reasoning[_\.]effort["'`]?(?![a-z0-9_])/,
190
+ /(?:请)?(?:使用|改用|采用|设置|发送|传入|提供|切换到|改为|用)\s*(?:参数\s*)?["'`]?reasoning[_\.]effort["'`]?(?![a-z0-9_])/,
191
+ /(?:将|把)\s*["'`]?thinking["'`]?(?:\s*(?:请求)?参数)?\s*(?:替换为|改为|换成|切换为|替代为)\s*["'`]?reasoning[_\.]effort["'`]?(?![a-z0-9_])/,
192
+ /reasoning[_\.]effort\b[^.;,。;!?]{0,80}(?:必须|必需|应当|应该|推荐|受支持|可用)/,
162
193
  ].some((pattern) => pattern.test(clause));
163
194
  });
164
195
  }
@@ -1,4 +1,4 @@
1
- import { join } from "node:path";
1
+ import { createHash } from "node:crypto";
2
2
  import { type PiModel, type UnknownRecord, asRecord, isNonEmptyString, lower } from "./common.ts";
3
3
  import { type CacheCompat, getEffectiveCompatSources, resolveEffectiveCompatFromConfig } from "./compat-config.ts";
4
4
  import { type PersistedCacheOptimizerConfig, type PersistedCacheOptimizerConfigV3, persistedCacheOptimizerConfig, runtimeOptimizerEnabled } from "./config.ts";
@@ -15,6 +15,19 @@ export function clampPromptCacheKey(key: string | undefined): string | undefined
15
15
  return chars.slice(0, OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH).join("");
16
16
  }
17
17
 
18
+ /**
19
+ * Opaque, stable per-session identifier sent to providers and proxies for
20
+ * cache routing (`prompt_cache_key` and the session-affinity header bridge).
21
+ * The raw Pi session id never leaves the process: a one-way hash is just as
22
+ * stable for sticky routing and does not expose the local session identity to
23
+ * third-party endpoints.
24
+ */
25
+ export function derivePromptCacheKey(sessionId: string | undefined): string | undefined {
26
+ const normalized = sessionId?.trim();
27
+ if (!normalized) return undefined;
28
+ return `pi-${createHash("sha256").update(normalized, "utf8").digest("hex").slice(0, 32)}`;
29
+ }
30
+
18
31
  export function getEffectiveCompatValueSource(
19
32
  model: PiModel,
20
33
  config: unknown,
@@ -71,7 +84,8 @@ export function addEffectiveSessionAffinityHeaders(
71
84
  ? "openrouter"
72
85
  : "openai"
73
86
  );
74
- const value = sessionId.trim();
87
+ const value = derivePromptCacheKey(sessionId);
88
+ if (!value) return false;
75
89
  let changed = false;
76
90
  if (format === "openrouter") {
77
91
  return setProviderHeaderIfMissing(headers, "x-session-id", value);
@@ -161,25 +175,68 @@ export function hasAnthropicCacheTtlOrderError(message: unknown): boolean {
161
175
  error.includes("must not come after");
162
176
  }
163
177
 
164
- export function normalizeAnthropicCacheControlTtlOrder(payload: unknown): boolean {
165
- const controls = collectAnthropicCacheControlsInWireOrder(payload);
178
+ /** Pure check: does the payload put a 1h breakpoint after a 5m/default one? */
179
+ export function hasAnthropicCacheTtlOrderConflict(payload: unknown): boolean {
166
180
  let seenShort = false;
167
- let hasInvalidLongAfterShort = false;
168
-
169
- for (const control of controls) {
181
+ for (const control of collectAnthropicCacheControlsInWireOrder(payload)) {
170
182
  const ttl = control.ttl;
171
183
  if (ttl === undefined || ttl === "5m") {
172
184
  seenShort = true;
173
185
  } else if (ttl === "1h" && seenShort) {
174
- hasInvalidLongAfterShort = true;
175
- break;
186
+ return true;
176
187
  }
177
188
  }
178
- if (!hasInvalidLongAfterShort) return false;
189
+ return false;
190
+ }
191
+
192
+ /** Pure check: does the payload carry any 1h breakpoint? */
193
+ export function hasAnthropicLongCacheControl(payload: unknown): boolean {
194
+ return collectAnthropicCacheControlsInWireOrder(payload).some((control) => control.ttl === "1h");
195
+ }
179
196
 
197
+ /**
198
+ * In-place repair, kept for callers that own the object. The request hook uses
199
+ * {@link withAnthropicCacheTtlRepair}, which never mutates Pi's payload.
200
+ */
201
+ export function normalizeAnthropicCacheControlTtlOrder(payload: unknown): boolean {
202
+ if (!hasAnthropicCacheTtlOrderConflict(payload)) return false;
180
203
  return downgradeAnthropicLongCacheControls(payload);
181
204
  }
182
205
 
206
+ function clonePayload(payload: unknown): unknown {
207
+ try {
208
+ return structuredClone(payload);
209
+ } catch {
210
+ // Non-cloneable payloads are not produced by Pi's built-in transports; keep
211
+ // the request valid by falling back to a JSON round-trip.
212
+ return JSON.parse(JSON.stringify(payload));
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Returns a repaired copy when the Anthropic payload needs its 1h breakpoints
218
+ * downgraded, or undefined when it is already valid. `forceDowngrade` applies
219
+ * the process-local fallback for models that already returned the TTL-order
220
+ * error (proxies may inject hidden short breakpoints after this hook).
221
+ */
222
+ export function withAnthropicCacheTtlRepair(payload: unknown, forceDowngrade: boolean): unknown | undefined {
223
+ const needsRepair = hasAnthropicCacheTtlOrderConflict(payload) ||
224
+ (forceDowngrade && hasAnthropicLongCacheControl(payload));
225
+ if (!needsRepair) return undefined;
226
+ const copy = clonePayload(payload);
227
+ downgradeAnthropicLongCacheControls(copy);
228
+ return copy;
229
+ }
230
+
231
+ /** Returns a copy without `prompt_cache_retention`, or undefined when absent. */
232
+ export function withoutPromptCacheRetention(payload: unknown): unknown | undefined {
233
+ const record = asRecord(payload);
234
+ if (!record || !Object.prototype.hasOwnProperty.call(record, "prompt_cache_retention")) return undefined;
235
+ const copy = { ...record };
236
+ delete copy.prompt_cache_retention;
237
+ return copy;
238
+ }
239
+
183
240
  export function addOpenAIPromptCacheKey(payload: unknown, cacheKey: string | undefined): unknown | undefined {
184
241
  const record = asRecord(payload);
185
242
  const normalizedCacheKey = clampPromptCacheKey(cacheKey);
package/src/retention.ts CHANGED
@@ -37,6 +37,87 @@ export function restoreCacheRetentionEnv(snapshot: CacheRetentionEnvSnapshot, en
37
37
  }
38
38
  }
39
39
 
40
+ /**
41
+ * Why `prompt_cache_retention` was kept on, or removed from, a provider payload.
42
+ *
43
+ * - `provider-rejected`: this model already answered with an explicit
44
+ * unsupported-parameter error in this process; never resend it.
45
+ * - `official-openai`: api.openai.com documents the parameter.
46
+ * - `explicit-opt-in`: models.json or the runtime model compat explicitly sets
47
+ * `supportsLongCacheRetention: true` (Pi's built-in catalog only ever sets
48
+ * `false`, so `true` is always a deliberate choice).
49
+ * - `user-requested-long`: the user started Pi with `PI_CACHE_RETENTION=long`
50
+ * themselves, so the field is vanilla Pi behaviour, not an optimizer side effect.
51
+ * - `unverified-endpoint`: the field exists only because this extension forced
52
+ * `PI_CACHE_RETENTION=long`; third-party endpoints (including Azure, whose
53
+ * support is per deployed model) are not known to accept it.
54
+ */
55
+ export type PromptCacheRetentionGateReason =
56
+ | "provider-rejected"
57
+ | "official-openai"
58
+ | "explicit-opt-in"
59
+ | "user-requested-long"
60
+ | "unverified-endpoint";
61
+
62
+ export type PromptCacheRetentionGateInput = {
63
+ providerRejected: boolean;
64
+ officialOpenAI: boolean;
65
+ explicitOptIn: boolean;
66
+ userRequestedLong: boolean;
67
+ };
68
+
69
+ export type PromptCacheRetentionGateDecision = {
70
+ keep: boolean;
71
+ reason: PromptCacheRetentionGateReason;
72
+ };
73
+
74
+ /**
75
+ * Pure decision for the `prompt_cache_retention` gate. The optimizer only undoes
76
+ * its own side effect: it never strips a field the user or the provider config
77
+ * asked for, except after the provider has explicitly rejected it.
78
+ */
79
+ export function decidePromptCacheRetention(input: PromptCacheRetentionGateInput): PromptCacheRetentionGateDecision {
80
+ if (input.providerRejected) return { keep: false, reason: "provider-rejected" };
81
+ if (input.officialOpenAI) return { keep: true, reason: "official-openai" };
82
+ if (input.explicitOptIn) return { keep: true, reason: "explicit-opt-in" };
83
+ if (input.userRequestedLong) return { keep: true, reason: "user-requested-long" };
84
+ return { keep: false, reason: "unverified-endpoint" };
85
+ }
86
+
87
+ /** True when PI_CACHE_RETENTION=long was already set before this extension loaded. */
88
+ export function userRequestedLongCacheRetention(snapshot: CacheRetentionEnvSnapshot = STARTUP_CACHE_RETENTION_ENV): boolean {
89
+ return snapshot.wasSet && snapshot.value === LONG_CACHE_RETENTION_VALUE;
90
+ }
91
+
92
+ export type PromptCacheRetentionDecisionRecord = PromptCacheRetentionGateDecision & {
93
+ modelKey?: string;
94
+ at: number;
95
+ };
96
+
97
+ let lastPromptCacheRetentionDecision: PromptCacheRetentionDecisionRecord | undefined;
98
+
99
+ /** Remember the latest gate decision so `/cache-optimizer doctor` can explain it. */
100
+ export function recordPromptCacheRetentionDecision(modelKey: string | undefined, decision: PromptCacheRetentionGateDecision): void {
101
+ lastPromptCacheRetentionDecision = { ...decision, modelKey, at: Date.now() };
102
+ }
103
+
104
+ export function getLastPromptCacheRetentionDecision(): PromptCacheRetentionDecisionRecord | undefined {
105
+ return lastPromptCacheRetentionDecision ? { ...lastPromptCacheRetentionDecision } : undefined;
106
+ }
107
+
108
+ const PROMPT_CACHE_RETENTION_REASON_TEXT: Record<PromptCacheRetentionGateReason, string> = {
109
+ "provider-rejected": "removed: this model rejected the parameter earlier in this process",
110
+ "official-openai": "kept: official OpenAI endpoint",
111
+ "explicit-opt-in": "kept: supportsLongCacheRetention: true is set explicitly",
112
+ "user-requested-long": "kept: PI_CACHE_RETENTION=long was set before Pi started",
113
+ "unverified-endpoint": "removed: added only by the optimizer and the endpoint is not known to accept it (set supportsLongCacheRetention: true to opt in)",
114
+ };
115
+
116
+ export function describePromptCacheRetentionDecision(record: PromptCacheRetentionDecisionRecord | undefined): string | undefined {
117
+ if (!record) return undefined;
118
+ return `prompt_cache_retention ${PROMPT_CACHE_RETENTION_REASON_TEXT[record.reason]}${record.modelKey ? ` (${record.modelKey})` : ""}`;
119
+ }
120
+
40
121
  export function isCacheRetentionBaselineV1(value: unknown): value is CacheRetentionBaselineV1 {
41
122
  if (typeof value !== "object" || value === null) return false;
42
123
  const record = value as { version?: unknown; snapshot?: unknown };
package/src/routing.ts CHANGED
@@ -2,7 +2,7 @@ import { LOG_PREFIX, type PiModel, asRecord, getNumber, isNonEmptyString, lower
2
2
  import { findLastExactModelDefinition } from "./compat-config.ts";
3
3
  import { VIRTUAL_REWRITE_ENV, featureEnabled } from "./config.ts";
4
4
  import { ROUTED_FALLBACK_MODEL_SYMBOL, getAssistantRecord, isNativeVirtualModel, isResponsesPromptRewriteBypassApi, modelKey, readEffectiveCompatConfig } from "./model-identity.ts";
5
- import { clampPromptCacheKey } from "./request-payload.ts";
5
+ import { derivePromptCacheKey } from "./request-payload.ts";
6
6
  import { type ExtensionContext } from "@earendil-works/pi-coding-agent";
7
7
  import { createHash } from "node:crypto";
8
8
 
@@ -229,8 +229,8 @@ export function resolveNativeVirtualCandidateModels(
229
229
  model: PiModel | undefined,
230
230
  ctx?: ContextWithOptionalModelRegistry,
231
231
  ): PiModel[] | undefined {
232
- if (!isNativeVirtualModel(model)) return undefined;
233
- const adapter = model ? getRoutingRegistry()?.getRouter(model.provider) : undefined;
232
+ if (!model || !isNativeVirtualModel(model)) return undefined;
233
+ const adapter = getRoutingRegistry()?.getRouter(model.provider);
234
234
  if (!adapter?.resolveCandidateRoutes) return undefined;
235
235
 
236
236
  try {
@@ -527,7 +527,7 @@ export type PiCacheHintSnapshot = PiCacheHintsInput & PiCacheHintsOutput & {
527
527
  };
528
528
 
529
529
  export function getSessionPromptCacheKey(ctx: ExtensionContext): string | undefined {
530
- return clampPromptCacheKey(ctx.sessionManager.getSessionId());
530
+ return derivePromptCacheKey(ctx.sessionManager.getSessionId());
531
531
  }
532
532
 
533
533
  export function isCacheHintsServiceV1(value: unknown): value is PiCacheHintsV1 {
@@ -7,7 +7,6 @@ import { CACHE_PROVIDER_IDS, type CacheProviderId, type CacheStats, LEGACY_STATE
7
7
  import { usageRecordFromAssistant } from "./usage.ts";
8
8
  import { type ExtensionContext } from "@earendil-works/pi-coding-agent";
9
9
  import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
10
- import { join } from "node:path";
11
10
 
12
11
  export type CacheStatsState = {
13
12
  statsByModel: Record<string, CacheStats>;
@@ -138,7 +137,6 @@ export function formatRecentTrendSummary(samples: CacheUsageSample[], maxCount:
138
137
  const totalInput = recent.reduce((sum, s) => sum + s.totalInputTokens, 0);
139
138
  const missingCount = recent.filter((s) => s.missingUsageFields).length;
140
139
 
141
- const hitRatio = formatHitRatio(hits, recent.length);
142
140
  const tokenRatio = totalInput > 0 ? formatHitRatio(totalCached, totalInput) : "N/A";
143
141
 
144
142
  let result = `Recent ${recent.length}/${maxCount}: ${hits}/${recent.length} hits · ${tokenRatio} tok cached`;
@@ -331,8 +331,36 @@ export function parsePersistedStatsShardV7(value: unknown): PersistedStatsShardV
331
331
  export async function writeStatsShardV7(path: string, shard: PersistedStatsShardV7): Promise<void> {
332
332
  await mkdir(dirname(path), { recursive: true });
333
333
  const tempPath = `${path}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`;
334
- await writeFile(tempPath, JSON.stringify(shard, null, 2) + "\n", "utf8");
335
- await rename(tempPath, path);
334
+ try {
335
+ await writeFile(tempPath, JSON.stringify(shard, null, 2) + "\n", "utf8");
336
+ await rename(tempPath, path);
337
+ } catch (error) {
338
+ // Do not leave an orphaned temp file behind until the next cleanup pass.
339
+ await unlink(tempPath).catch(() => undefined);
340
+ throw error;
341
+ }
342
+ }
343
+
344
+ type CachedShardRead = {
345
+ ino: number | bigint;
346
+ size: number;
347
+ mtimeMs: number;
348
+ shard: PersistedStatsShardV7 | undefined;
349
+ };
350
+
351
+ /**
352
+ * Parsed-shard cache keyed by path and validated by inode, size, and mtime.
353
+ * Writers always publish through temp-file + rename, so an updated shard gets
354
+ * a new inode; unchanged shards are not re-read or re-parsed on every refresh.
355
+ */
356
+ const shardReadCache = new Map<string, CachedShardRead>();
357
+
358
+ export function clearStatsShardReadCache(): void {
359
+ shardReadCache.clear();
360
+ }
361
+
362
+ function cloneShard(shard: PersistedStatsShardV7): PersistedStatsShardV7 {
363
+ return structuredClone(shard);
336
364
  }
337
365
 
338
366
  export async function readValidStatsShardsV7(directory: string = SHARD_FILES_DIR): Promise<PersistedStatsShardV7[]> {
@@ -344,20 +372,39 @@ export async function readValidStatsShardsV7(directory: string = SHARD_FILES_DIR
344
372
  throw error;
345
373
  }
346
374
  const shards: PersistedStatsShardV7[] = [];
375
+ const seenPaths = new Set<string>();
347
376
  for (const name of names) {
348
377
  if (!/^[0-9a-f-]+\.json$/i.test(name)) continue;
349
378
  const path = join(directory, name);
379
+ seenPaths.add(path);
350
380
  try {
351
381
  const info = await lstat(path);
352
- if (!info.isFile() || info.isSymbolicLink()) continue;
353
- const parsed = parsePersistedStatsShardV7(JSON.parse(await readFile(path, "utf8")));
354
- const filenameInstanceId = name.slice(0, -".json".length);
355
- if (parsed && parsed.instanceId === filenameInstanceId) shards.push(parsed);
382
+ if (!info.isFile() || info.isSymbolicLink()) {
383
+ shardReadCache.delete(path);
384
+ continue;
385
+ }
386
+ const cached = shardReadCache.get(path);
387
+ let parsed: PersistedStatsShardV7 | undefined;
388
+ if (cached && cached.ino === info.ino && cached.size === info.size && cached.mtimeMs === info.mtimeMs) {
389
+ parsed = cached.shard;
390
+ } else {
391
+ const filenameInstanceId = name.slice(0, -".json".length);
392
+ const candidate = parsePersistedStatsShardV7(JSON.parse(await readFile(path, "utf8")));
393
+ parsed = candidate && candidate.instanceId === filenameInstanceId ? candidate : undefined;
394
+ shardReadCache.set(path, { ino: info.ino, size: info.size, mtimeMs: info.mtimeMs, shard: parsed });
395
+ }
396
+ // Callers may mutate the aggregate they build; hand out copies so the
397
+ // cache stays authoritative.
398
+ if (parsed) shards.push(cloneShard(parsed));
356
399
  } catch {
357
400
  // Ignore malformed or transiently unavailable shards. Atomic writers will
358
401
  // publish a complete replacement on the next successful update.
402
+ shardReadCache.delete(path);
359
403
  }
360
404
  }
405
+ for (const path of Array.from(shardReadCache.keys())) {
406
+ if (!seenPaths.has(path) && path.startsWith(directory)) shardReadCache.delete(path);
407
+ }
361
408
  return shards;
362
409
  }
363
410
 
@@ -15,7 +15,7 @@ export function isKnownToolOrderApi(api: unknown): api is ToolOrderApi {
15
15
  api === "google-vertex" || api === "bedrock-converse-stream";
16
16
  }
17
17
 
18
- export function isToolOrderingEligibleModel(model: PiModel | undefined): boolean {
18
+ export function isToolOrderingEligibleModel(model: PiModel | undefined): model is PiModel {
19
19
  // Responses/Codex prompt bypasses remain untouched. The pure helper still
20
20
  // supports the verified Responses shape for fixture use, but the request
21
21
  // hook must preserve Pi's server-managed/safety-sensitive bypass.