pi-cache-optimizer 2.8.1 → 2.8.3
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 +9 -8
- package/README.zh-CN.md +9 -8
- package/index.ts +242 -276
- package/package.json +4 -3
package/README.md
CHANGED
|
@@ -55,7 +55,7 @@ Run `/reload` in Pi after install/update/remove so extension hooks refresh.
|
|
|
55
55
|
|
|
56
56
|
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).
|
|
57
57
|
|
|
58
|
-
This extension
|
|
58
|
+
This extension requires Pi 0.82+ and is validated against Pi 0.84.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.
|
|
59
59
|
|
|
60
60
|
## Commands
|
|
61
61
|
|
|
@@ -109,7 +109,7 @@ The explicit setting is stored in `pi-cache-optimizer-config.json` under Pi's ag
|
|
|
109
109
|
|
|
110
110
|
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.
|
|
111
111
|
|
|
112
|
-
Pi 0.81+ also has a built-in `llama.cpp` provider using an OpenAI-shaped transport. Pi 0.82+ core generates a session `prompt_cache_key` for it when cache retention is enabled, so this extension preserves that key and may add the same conservative fallback when missing. The built-in provider's explicit compat fingerprint is excluded from generic proxy routing/session-affinity advice, but a custom or overridden provider that merely reuses the id `llama.cpp` is treated like any other OpenAI-compatible channel. `prompt_cache_retention` remains subject to the normal safety rule: keep it only for official OpenAI or an explicit effective `supportsLongCacheRetention: true` opt-in in `models.json`; otherwise strip it before sending. Effective values follow Pi's precedence: `modelOverrides[modelId].compat` first, then the matching `models[].compat`, then provider-level `compat`. An explicit `false` at a higher layer overrides `true` below it.
|
|
112
|
+
Pi 0.84.1 also fixes built-in Fireworks compatibility for models that reject `prompt_cache_retention`; keep the extension's diagnostics focused on the effective model compat exposed by Pi rather than adding a provider-name special case. Pi 0.81+ also has a built-in `llama.cpp` provider using an OpenAI-shaped transport. Pi 0.82+ core generates a session `prompt_cache_key` for it when cache retention is enabled, so this extension preserves that key and may add the same conservative fallback when missing. The built-in provider's explicit compat fingerprint is excluded from generic proxy routing/session-affinity advice, but a custom or overridden provider that merely reuses the id `llama.cpp` is treated like any other OpenAI-compatible channel. `prompt_cache_retention` remains subject to the normal safety rule: keep it only for official OpenAI or an explicit effective `supportsLongCacheRetention: true` opt-in in `models.json`; otherwise strip it before sending. Effective values follow Pi's precedence: `modelOverrides[modelId].compat` first, then the matching `models[].compat`, then provider-level `compat`. An explicit `false` at a higher layer overrides `true` below it.
|
|
113
113
|
|
|
114
114
|
For real proxies, start with session affinity:
|
|
115
115
|
|
|
@@ -135,7 +135,7 @@ Notes:
|
|
|
135
135
|
|
|
136
136
|
- `sendSessionAffinityHeaders: true` is the safe default when your proxy supports sticky routing.
|
|
137
137
|
- `supportsLongCacheRetention: true` is optional. Add it only when the endpoint explicitly supports OpenAI long prompt cache retention.
|
|
138
|
-
- If you see `400 Unsupported parameter: prompt_cache_retention`, remove/avoid `supportsLongCacheRetention` for that channel. Keep `sendSessionAffinityHeaders` if supported.
|
|
138
|
+
- 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.
|
|
139
139
|
- Use `/cache-optimizer compat` or `/cache-optimizer doctor` to see model-specific advice.
|
|
140
140
|
- For DeepSeek models, the Pi Mono guidance expects `compat.requiresReasoningContentOnAssistantMessages: true` and `compat.thinkingFormat: "deepseek"` alongside cache/session-affinity flags when the endpoint supports them.
|
|
141
141
|
- This extension's `doctor` and `compat` commands only advise; they do not modify `models.json`.
|
|
@@ -150,7 +150,7 @@ Some proxies rewrite or insert hidden 5-minute breakpoints after Pi's request ho
|
|
|
150
150
|
|
|
151
151
|
## Adaptive thinking models
|
|
152
152
|
|
|
153
|
-
Claude models from opus-4.6 / sonnet-4.6 (including Opus 5 and Sonnet 5) / fable-5 onwards require `forceAdaptiveThinking: true` in compat. Kimi Coding K3 (`k3`) and `kimi-for-coding` also use adaptive thinking and need `allowEmptySignature: true` so replayed empty-signature thinking blocks remain valid. Without the required compat, Pi may send a legacy thinking payload or replay thinking incorrectly. Pi 0.83
|
|
153
|
+
Claude models from opus-4.6 / sonnet-4.6 (including Opus 5 and Sonnet 5) / fable-5 onwards require `forceAdaptiveThinking: true` in compat. Kimi Coding K3 (`k3`) and `kimi-for-coding` also use adaptive thinking and need `allowEmptySignature: true` so replayed empty-signature thinking blocks remain valid. Without the required compat, Pi may send a legacy thinking payload or replay thinking incorrectly. Pi 0.83+ native Opus 5 catalogs are covered by the same adaptive-thinking detection; custom `anthropic-messages` channels still need the compat flag when Pi does not provide it.
|
|
154
154
|
|
|
155
155
|
Pi's built-in catalog already sets this flag for official models. Custom channels in `models.json` that override these models must include the flag:
|
|
156
156
|
|
|
@@ -229,8 +229,9 @@ Pi 0.80.9+ already includes Kimi K3 in built-in Kimi Coding, Moonshot AI / China
|
|
|
229
229
|
2. Warns: ① changes affect all sessions using that channel, ② automatic backup created at `models.json.backup-cache-optimizer-<timestamp>`, ③ Pi reload required
|
|
230
230
|
3. Uses comment-preserving surgical editor — existing comments, indentation, and existing key order are preserved
|
|
231
231
|
4. Requires explicit user confirmation (interactive prompt or `ui.select`)
|
|
232
|
-
5. Writes atomically (temp + rename); self-validates after write
|
|
233
|
-
6.
|
|
232
|
+
5. Writes and restores atomically (temp + rename); self-validates after write
|
|
233
|
+
6. Preserves the existing `models.json` access mode exactly — it does not tighten or loosen permissions (for example, `0600` stays `0600`, `0644` stays `0644`)
|
|
234
|
+
7. Uses unique, non-overwriting backup names and falls back to manual guidance if the JSONC scanner cannot confidently locate the target
|
|
234
235
|
|
|
235
236
|
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. Self-validation checks the effective three-layer compat result, so a lower-level edit shadowed by an override is rejected.
|
|
236
237
|
|
|
@@ -283,10 +284,10 @@ Pi 0.79+ also includes a built-in footer `CH` marker for the latest prompt cache
|
|
|
283
284
|
Example footer:
|
|
284
285
|
|
|
285
286
|
```text
|
|
286
|
-
· OpenAI cache 3/10
|
|
287
|
+
· OpenAI cache 3/10·0.002M/0.005M 40.0% ⚠️ compat
|
|
287
288
|
```
|
|
288
289
|
|
|
289
|
-
The leading `· ` is owned by this extension and separates its status from statuses published by other extensions in the same footer. It is present for normal, disabled, router-restored, and warning-suffixed statuses. The
|
|
290
|
+
The leading `· ` is owned by this extension and separates its status from statuses published by other extensions in the same footer. It is present for normal, disabled, router-restored, and warning-suffixed statuses. The compact footer format is `<label> <hit requests>/<total requests>·<cached input tokens>/<total input tokens> <token hit rate>`; token hit rate keeps one decimal place and the footer omits the redundant `tok` suffix. `/cache-optimizer stats` output is unchanged. Some adapters may also append `·write <tokens>`, and runtime diagnostics may append `⚠️ compat` or `⚠️ integrity`.
|
|
290
291
|
|
|
291
292
|
Supported footer labels include: DS, Claude, OpenAI, Gemini, Kimi, Qwen, GLM, MiniMax, Mimo, Hunyuan, Mistral, Grok, Llama, Nemotron, Cohere, Yi, Doubao, ERNIE, Baichuan, StepFun, Spark, InternLM, Gemma, Phi, Jamba, Solar, Sonar, Nova, Reka, Falcon, DBRX, MPT, StableLM, Aquila, EXAONE, HyperCLOVA, Luminous, Hermes, Granite, Arctic, Pangu, SenseNova, Zhinao, MiniCPM, XVERSE, Orion, OpenChat, Vicuna, Wizard, Zephyr, Dolphin, OpenOrca, Starling, BLOOM, RWKV, and Aya.
|
|
292
293
|
|
package/README.zh-CN.md
CHANGED
|
@@ -55,7 +55,7 @@ pi remove npm:pi-deepseek-cache-optimizer && pi install npm:pi-cache-optimizer
|
|
|
55
55
|
|
|
56
56
|
Pi 0.79.7 及之后,`pi update` 默认只更新 Pi 本体。若要更新已安装的 Pi package(包括本扩展),请运行 `pi update --extensions`(只更新 packages)或 `pi update --all`(Pi 与 packages 一起更新)。
|
|
57
57
|
|
|
58
|
-
|
|
58
|
+
本扩展要求 Pi 0.82+,并已使用 Pi 0.84.2 验证。TypeScript 校验直接使用官方 Pi package 类型,同时只使用这些版本共有的 extension hooks、`getAgentDir()` 和 prompt options;不依赖 Pi 0.83+ 专有 API(例如 `ctx.scopedModels` 或 bundled TypeBox 1.3 aliases)。
|
|
59
59
|
|
|
60
60
|
## 命令
|
|
61
61
|
|
|
@@ -108,7 +108,7 @@ Pi 0.79.7 及之后,`pi update` 默认只更新 Pi 本体。若要更新已安
|
|
|
108
108
|
|
|
109
109
|
LiteLLM / OneAPI / NewAPI / 类 OpenRouter 渠道等第三方 `openai-completions` 代理,常会把同一个 session 分散到多个上游后端,导致 provider 侧 prompt cache 被拆散。
|
|
110
110
|
|
|
111
|
-
Pi 0.81+ 也内置了使用 OpenAI-shaped transport 的 `llama.cpp` provider。Pi 0.82+ core 在启用 cache retention 时会为它生成 session `prompt_cache_key`,因此本扩展会保留该 key,并在缺失时使用同样的保守 fallback。只有符合 Pi 内置 provider 明确 compat 指纹的模型会跳过通用 proxy 路由 / session-affinity 建议;仅复用 `llama.cpp` id 的自定义或覆盖 provider 仍按普通 OpenAI-compatible 渠道处理。`prompt_cache_retention` 继续遵循统一安全规则:仅官方 OpenAI 或 `models.json` 中有效配置为 `supportsLongCacheRetention: true` 时保留,否则发送前移除。有效值遵循 Pi 的优先级:先看 `modelOverrides[modelId].compat`,再看匹配的 `models[].compat`,最后看 provider 级 `compat`;高层显式 `false` 会覆盖低层的 `true`。
|
|
111
|
+
Pi 0.84.1 还修复了内置 Fireworks 渠道对拒绝 `prompt_cache_retention` 的模型兼容性;本扩展继续依据 Pi 暴露的有效 model compat 做诊断,不按 provider 名称增加特殊分支。Pi 0.81+ 也内置了使用 OpenAI-shaped transport 的 `llama.cpp` provider。Pi 0.82+ core 在启用 cache retention 时会为它生成 session `prompt_cache_key`,因此本扩展会保留该 key,并在缺失时使用同样的保守 fallback。只有符合 Pi 内置 provider 明确 compat 指纹的模型会跳过通用 proxy 路由 / session-affinity 建议;仅复用 `llama.cpp` id 的自定义或覆盖 provider 仍按普通 OpenAI-compatible 渠道处理。`prompt_cache_retention` 继续遵循统一安全规则:仅官方 OpenAI 或 `models.json` 中有效配置为 `supportsLongCacheRetention: true` 时保留,否则发送前移除。有效值遵循 Pi 的优先级:先看 `modelOverrides[modelId].compat`,再看匹配的 `models[].compat`,最后看 provider 级 `compat`;高层显式 `false` 会覆盖低层的 `true`。
|
|
112
112
|
|
|
113
113
|
对真正的代理,建议先启用 session affinity:
|
|
114
114
|
|
|
@@ -134,7 +134,7 @@ Pi 0.81+ 也内置了使用 OpenAI-shaped transport 的 `llama.cpp` provider。P
|
|
|
134
134
|
|
|
135
135
|
- `sendSessionAffinityHeaders: true` 是安全默认项,前提是你的代理支持 sticky routing。
|
|
136
136
|
- `supportsLongCacheRetention: true` 是可选项。只有 endpoint 明确支持 OpenAI long prompt cache retention 时才添加。
|
|
137
|
-
- 如果出现 `400 Unsupported parameter: prompt_cache_retention`,请为该渠道移除 / 避免 `supportsLongCacheRetention`;如支持,可保留 `sendSessionAffinityHeaders
|
|
137
|
+
- 如果出现 `400 Unsupported parameter: prompt_cache_retention`,请为该渠道移除 / 避免 `supportsLongCacheRetention`;如支持,可保留 `sendSessionAffinityHeaders`。扩展会从响应头或最终 assistant error message 中识别这条明确错误,并在当前进程的后续请求中移除该参数。
|
|
138
138
|
- 使用 `/cache-optimizer compat` 或 `/cache-optimizer doctor` 查看当前模型的具体建议。
|
|
139
139
|
- 对 DeepSeek 模型,Pi Mono 指南期望在支持时同时设置 `compat.requiresReasoningContentOnAssistantMessages: true` 和 `compat.thinkingFormat: "deepseek"`,再配合缓存 / session-affinity 相关 compat。
|
|
140
140
|
- 本扩展的 `doctor` 和 `compat` 命令只给建议,不会修改 `models.json`。
|
|
@@ -149,7 +149,7 @@ Anthropic 按 `tools → system → messages` 顺序处理 cache breakpoint,
|
|
|
149
149
|
|
|
150
150
|
## Adaptive thinking 模型
|
|
151
151
|
|
|
152
|
-
Claude 从 opus-4.6 / sonnet-4.6(含 Opus 5、Sonnet 5)/ fable-5 开始需要在 compat 中设置 `forceAdaptiveThinking: true`。Kimi Coding K3(`k3`)和 `kimi-for-coding` 也使用 adaptive thinking,并需要 `allowEmptySignature: true`,以正确重放空 signature 的 thinking block。缺少这些 compat 时,Pi 可能发送旧版 thinking payload 或错误重放 thinking。Pi 0.83
|
|
152
|
+
Claude 从 opus-4.6 / sonnet-4.6(含 Opus 5、Sonnet 5)/ fable-5 开始需要在 compat 中设置 `forceAdaptiveThinking: true`。Kimi Coding K3(`k3`)和 `kimi-for-coding` 也使用 adaptive thinking,并需要 `allowEmptySignature: true`,以正确重放空 signature 的 thinking block。缺少这些 compat 时,Pi 可能发送旧版 thinking payload 或错误重放 thinking。Pi 0.83+ 的原生 Opus 5 catalog 已覆盖在同一 adaptive-thinking 检测中;如果自定义 `anthropic-messages` 渠道没有继承该 compat,仍需手动设置。
|
|
153
153
|
|
|
154
154
|
Pi 内置 catalog 已为官方模型设置此 flag。`models.json` 中覆盖这些模型的自定义渠道必须包含该 flag:
|
|
155
155
|
|
|
@@ -228,8 +228,9 @@ Pi 0.80.9+ 已在内置 Kimi Coding、Moonshot AI / 中国区、OpenRouter 和 V
|
|
|
228
228
|
2. 警告:① 修改影响使用该渠道的所有 session,② 自动备份到 `models.json.backup-cache-optimizer-<timestamp>`,③ 需重启 Pi 或 reload
|
|
229
229
|
3. 使用保留注释的精确编辑器 —— 现有注释、缩进和已有 key 顺序都会保留
|
|
230
230
|
4. 需要用户明确确认(交互式提示或 `ui.select`)
|
|
231
|
-
5.
|
|
232
|
-
6.
|
|
231
|
+
5. 写入与失败恢复都使用原子替换(temp + rename);写入后自我验证
|
|
232
|
+
6. 完全保留 `models.json` 原有访问权限,不主动收紧或放宽(例如 `0600` 保持 `0600`,`0644` 保持 `0644`)
|
|
233
|
+
7. 备份名唯一且不会覆盖已有备份;如果 JSONC 扫描器无法置信定位目标,则回退到手动修改指引
|
|
233
234
|
|
|
234
235
|
已有的 `modelOverrides[modelId]` 具有 Pi 的最高优先级,因此 `fix` 会直接修复该 entry。对于没有自定义 `models[]` entry 的内置模型或 API-login 模型,`fix` 会创建仅含 compat 的 `modelOverrides` entry,而不会凭空添加自定义模型定义。自检会验证三层合并后的有效 compat,因此被更高层 override 遮蔽的低层修改会被拒绝。
|
|
235
236
|
|
|
@@ -282,10 +283,10 @@ Pi 0.79+ 已内置 footer `CH` 标记,用于显示最近一次 prompt cache hi
|
|
|
282
283
|
示例 footer:
|
|
283
284
|
|
|
284
285
|
```text
|
|
285
|
-
· OpenAI cache 3/10
|
|
286
|
+
· OpenAI cache 3/10·0.002M/0.005M 40.0% ⚠️ compat
|
|
286
287
|
```
|
|
287
288
|
|
|
288
|
-
开头的 `· ` 由本扩展负责,用于把本扩展状态与同一 footer 中其他扩展发布的状态隔开。普通、disabled、router 恢复以及带 warning
|
|
289
|
+
开头的 `· ` 由本扩展负责,用于把本扩展状态与同一 footer 中其他扩展发布的状态隔开。普通、disabled、router 恢复以及带 warning 的状态都会保留此前缀。紧凑 footer 格式为 `<label> <命中请求数>/<总请求数>·<cached input tokens>/<total input tokens> <token 命中率>`;token 命中率保留一位小数,并去掉多余的 `tok` 后缀。`/cache-optimizer stats` 的输出不变。部分 adapter 还可能追加 `·write <tokens>`,运行时诊断可能追加 `⚠️ compat` 或 `⚠️ integrity`。
|
|
289
290
|
|
|
290
291
|
支持的 footer label 包括:DS、Claude、OpenAI、Gemini、Kimi、Qwen、GLM、MiniMax、Mimo、Hunyuan、Mistral、Grok、Llama、Nemotron、Cohere、Yi、Doubao、ERNIE、Baichuan、StepFun、Spark、InternLM、Gemma、Phi、Jamba、Solar、Sonar、Nova、Reka、Falcon、DBRX、MPT、StableLM、Aquila、EXAONE、HyperCLOVA、Luminous、Hermes、Granite、Arctic、Pangu、SenseNova、Zhinao、MiniCPM、XVERSE、Orion、OpenChat、Vicuna、Wizard、Zephyr、Dolphin、OpenOrca、Starling、BLOOM、RWKV、Aya。
|
|
291
292
|
|
package/index.ts
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { copyFile, mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
3
|
-
import { readFileSync } from "node:fs";
|
|
2
|
+
import { chmod, copyFile, mkdir, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import { constants as fsConstants, readFileSync } from "node:fs";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
5
|
import { dirname, join } from "node:path";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
getAgentDir,
|
|
8
|
+
type BuildSystemPromptOptions,
|
|
9
|
+
type ExtensionAPI,
|
|
10
|
+
type ExtensionCommandContext,
|
|
11
|
+
type ExtensionContext,
|
|
12
|
+
} from "@earendil-works/pi-coding-agent";
|
|
7
13
|
|
|
8
14
|
type MutableEnv = Record<string, string | undefined>;
|
|
9
15
|
|
|
@@ -85,6 +91,7 @@ const STARTUP_CACHE_RETENTION_ENV = getOrCaptureCacheRetentionBaseline();
|
|
|
85
91
|
requestLongCacheRetention();
|
|
86
92
|
|
|
87
93
|
type PiModel = NonNullable<ExtensionContext["model"]>;
|
|
94
|
+
type ModelIdentity = Pick<PiModel, "provider" | "id">;
|
|
88
95
|
type UnknownRecord = Record<string, unknown>;
|
|
89
96
|
type CacheProviderId = "deepseek" | "openai" | "claude" | "gemini";
|
|
90
97
|
|
|
@@ -2088,7 +2095,7 @@ function isAyaLikeAssistantMessage(message: unknown, model: PiModel | undefined)
|
|
|
2088
2095
|
|
|
2089
2096
|
// ── Model key ──────────────────────────────────────────────────────
|
|
2090
2097
|
|
|
2091
|
-
function modelKey(model:
|
|
2098
|
+
function modelKey(model: ModelIdentity): string {
|
|
2092
2099
|
return `${model.provider}/${model.id}`;
|
|
2093
2100
|
}
|
|
2094
2101
|
|
|
@@ -2461,12 +2468,8 @@ function getPromptCacheRetentionUnsupportedHint(): string {
|
|
|
2461
2468
|
return "If this channel returns `400 Unsupported parameter: prompt_cache_retention`, remove/avoid `supportsLongCacheRetention`; this extension does not write that field directly, but Pi may send it when long retention is requested and compat says the proxy supports it.";
|
|
2462
2469
|
}
|
|
2463
2470
|
|
|
2464
|
-
function
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
const normalized = Object.entries(headers)
|
|
2468
|
-
.map(([key, value]) => `${lower(key)}: ${lower(value)}`)
|
|
2469
|
-
.join("\n");
|
|
2471
|
+
function hasPromptCacheRetentionUnsupportedText(value: unknown): boolean {
|
|
2472
|
+
const normalized = lower(value);
|
|
2470
2473
|
if (!normalized.includes("prompt_cache_retention")) return false;
|
|
2471
2474
|
|
|
2472
2475
|
return [
|
|
@@ -2478,10 +2481,24 @@ function hasPromptCacheRetentionUnsupportedSignal(headers: Record<string, string
|
|
|
2478
2481
|
"extra inputs",
|
|
2479
2482
|
"not permitted",
|
|
2480
2483
|
"unrecognized",
|
|
2481
|
-
"bad request",
|
|
2482
2484
|
].some((needle) => normalized.includes(needle));
|
|
2483
2485
|
}
|
|
2484
2486
|
|
|
2487
|
+
function hasPromptCacheRetentionUnsupportedSignal(headers: Record<string, string> | undefined): boolean {
|
|
2488
|
+
if (!headers) return false;
|
|
2489
|
+
return hasPromptCacheRetentionUnsupportedText(
|
|
2490
|
+
Object.entries(headers)
|
|
2491
|
+
.map(([key, value]) => `${key}: ${value}`)
|
|
2492
|
+
.join("\n"),
|
|
2493
|
+
);
|
|
2494
|
+
}
|
|
2495
|
+
|
|
2496
|
+
function hasPromptCacheRetentionUnsupportedErrorMessage(message: unknown): boolean {
|
|
2497
|
+
const record = getAssistantRecord(message);
|
|
2498
|
+
return record?.stopReason === "error" &&
|
|
2499
|
+
hasPromptCacheRetentionUnsupportedText(record.errorMessage);
|
|
2500
|
+
}
|
|
2501
|
+
|
|
2485
2502
|
type CompatAdvicePlacement = {
|
|
2486
2503
|
providerLabel?: string;
|
|
2487
2504
|
modelId?: string;
|
|
@@ -3733,13 +3750,13 @@ function formatTokenCount(value: number): string {
|
|
|
3733
3750
|
|
|
3734
3751
|
function formatCacheStats(adapter: CacheProviderAdapter, stats: CacheStats): string {
|
|
3735
3752
|
const percent = stats.totalInputTokens > 0
|
|
3736
|
-
?
|
|
3737
|
-
:
|
|
3753
|
+
? (stats.cachedInputTokens / stats.totalInputTokens) * 100
|
|
3754
|
+
: 0;
|
|
3738
3755
|
const writeText = adapter.showCacheWrite && stats.cacheWriteInputTokens > 0
|
|
3739
|
-
?
|
|
3756
|
+
? `·write ${formatTokenCount(stats.cacheWriteInputTokens)}`
|
|
3740
3757
|
: "";
|
|
3741
3758
|
|
|
3742
|
-
return `${adapter.label} ${stats.hitRequests}/${stats.totalRequests}
|
|
3759
|
+
return `${adapter.label} ${stats.hitRequests}/${stats.totalRequests}·${formatTokenCount(stats.cachedInputTokens)}/${formatTokenCount(stats.totalInputTokens)} ${percent.toFixed(1)}%${writeText}`;
|
|
3743
3760
|
}
|
|
3744
3761
|
|
|
3745
3762
|
function prefixFooterStatus(statusText: string | undefined): string | undefined {
|
|
@@ -4405,6 +4422,16 @@ function isPromptCacheRetention400Applicable(model: PiModel): boolean {
|
|
|
4405
4422
|
getCompat(model).supportsLongCacheRetention === true;
|
|
4406
4423
|
}
|
|
4407
4424
|
|
|
4425
|
+
function isExplicitPromptCacheRetentionUnsupportedApplicable(model: PiModel): boolean {
|
|
4426
|
+
// A finalized assistant error with an explicit unsupported-parameter signal
|
|
4427
|
+
// proves that prompt_cache_retention reached this provider/model. Do not
|
|
4428
|
+
// require compat inherited from the active fallback model: router shells may
|
|
4429
|
+
// have no upstream compat metadata when no live routing registry is present.
|
|
4430
|
+
return isOpenAICompatibleApi(model.api) &&
|
|
4431
|
+
!isOfficialOpenAIBaseUrl(model) &&
|
|
4432
|
+
!isPiBuiltInLlamaCppModel(model);
|
|
4433
|
+
}
|
|
4434
|
+
|
|
4408
4435
|
/**
|
|
4409
4436
|
* Whether the 403 sendSessionAffinityHeaders diagnostic applies to a model.
|
|
4410
4437
|
*
|
|
@@ -6528,18 +6555,121 @@ function formatCompatKeysForInsertion(compatKeys: Record<string, unknown>): stri
|
|
|
6528
6555
|
}
|
|
6529
6556
|
|
|
6530
6557
|
/**
|
|
6531
|
-
* Generate
|
|
6532
|
-
*
|
|
6558
|
+
* Generate a unique UTC timestamp component for backup filenames.
|
|
6559
|
+
* Milliseconds, process id, and an in-process sequence prevent collisions.
|
|
6533
6560
|
*/
|
|
6534
|
-
|
|
6535
|
-
|
|
6561
|
+
let backupSequence = 0;
|
|
6562
|
+
|
|
6563
|
+
function backupTimestamp(now: Date = new Date()): string {
|
|
6536
6564
|
const y = now.getUTCFullYear();
|
|
6537
|
-
const m = String(now.getUTCMonth() + 1).padStart(2,
|
|
6538
|
-
const d = String(now.getUTCDate()).padStart(2,
|
|
6539
|
-
const h = String(now.getUTCHours()).padStart(2,
|
|
6540
|
-
const min = String(now.getUTCMinutes()).padStart(2,
|
|
6541
|
-
const s = String(now.getUTCSeconds()).padStart(2,
|
|
6542
|
-
|
|
6565
|
+
const m = String(now.getUTCMonth() + 1).padStart(2, "0");
|
|
6566
|
+
const d = String(now.getUTCDate()).padStart(2, "0");
|
|
6567
|
+
const h = String(now.getUTCHours()).padStart(2, "0");
|
|
6568
|
+
const min = String(now.getUTCMinutes()).padStart(2, "0");
|
|
6569
|
+
const s = String(now.getUTCSeconds()).padStart(2, "0");
|
|
6570
|
+
const ms = String(now.getUTCMilliseconds()).padStart(3, "0");
|
|
6571
|
+
return `${y}${m}${d}T${h}${min}${s}${ms}Z-${process.pid}-${backupSequence++}`;
|
|
6572
|
+
}
|
|
6573
|
+
|
|
6574
|
+
function uniqueTempPath(targetPath: string, purpose: string): string {
|
|
6575
|
+
return `${targetPath}.${process.pid}.${Date.now()}.${backupSequence++}.${purpose}.tmp`;
|
|
6576
|
+
}
|
|
6577
|
+
|
|
6578
|
+
async function atomicReplaceTextFilePreservingMode(
|
|
6579
|
+
targetPath: string,
|
|
6580
|
+
content: string,
|
|
6581
|
+
mode: number,
|
|
6582
|
+
purpose: string,
|
|
6583
|
+
): Promise<void> {
|
|
6584
|
+
const tempPath = uniqueTempPath(targetPath, purpose);
|
|
6585
|
+
try {
|
|
6586
|
+
await writeFile(tempPath, content, { encoding: "utf8", mode });
|
|
6587
|
+
await chmod(tempPath, mode);
|
|
6588
|
+
await rename(tempPath, targetPath);
|
|
6589
|
+
} catch (error) {
|
|
6590
|
+
try {
|
|
6591
|
+
await unlink(tempPath);
|
|
6592
|
+
} catch (cleanupError) {
|
|
6593
|
+
if (getErrorCode(cleanupError) !== "ENOENT") {
|
|
6594
|
+
console.warn(`${LOG_PREFIX}: failed to remove temporary models.json file`, cleanupError);
|
|
6595
|
+
}
|
|
6596
|
+
}
|
|
6597
|
+
throw error;
|
|
6598
|
+
}
|
|
6599
|
+
}
|
|
6600
|
+
|
|
6601
|
+
async function atomicRestoreFileFromBackup(
|
|
6602
|
+
backupPath: string,
|
|
6603
|
+
targetPath: string,
|
|
6604
|
+
mode: number,
|
|
6605
|
+
): Promise<void> {
|
|
6606
|
+
const tempPath = uniqueTempPath(targetPath, "restore");
|
|
6607
|
+
try {
|
|
6608
|
+
await copyFile(backupPath, tempPath, fsConstants.COPYFILE_EXCL);
|
|
6609
|
+
await chmod(tempPath, mode);
|
|
6610
|
+
await rename(tempPath, targetPath);
|
|
6611
|
+
} catch (error) {
|
|
6612
|
+
try {
|
|
6613
|
+
await unlink(tempPath);
|
|
6614
|
+
} catch (cleanupError) {
|
|
6615
|
+
if (getErrorCode(cleanupError) !== "ENOENT") {
|
|
6616
|
+
console.warn(`${LOG_PREFIX}: failed to remove temporary restore file`, cleanupError);
|
|
6617
|
+
}
|
|
6618
|
+
}
|
|
6619
|
+
throw error;
|
|
6620
|
+
}
|
|
6621
|
+
}
|
|
6622
|
+
|
|
6623
|
+
type ModelsJsonFixTransactionResult =
|
|
6624
|
+
| { ok: true }
|
|
6625
|
+
| { ok: false; postCheckError: string };
|
|
6626
|
+
|
|
6627
|
+
async function applyModelsJsonFixTransaction(
|
|
6628
|
+
modifiedText: string,
|
|
6629
|
+
backupPath: string,
|
|
6630
|
+
validateWrittenText: (writtenText: string) => string | null,
|
|
6631
|
+
): Promise<ModelsJsonFixTransactionResult> {
|
|
6632
|
+
const originalMode = (await stat(MODELS_JSON_PATH)).mode & 0o7777;
|
|
6633
|
+
await copyFile(MODELS_JSON_PATH, backupPath, fsConstants.COPYFILE_EXCL);
|
|
6634
|
+
await chmod(backupPath, originalMode);
|
|
6635
|
+
|
|
6636
|
+
let targetReplaced = false;
|
|
6637
|
+
try {
|
|
6638
|
+
await atomicReplaceTextFilePreservingMode(
|
|
6639
|
+
MODELS_JSON_PATH,
|
|
6640
|
+
modifiedText,
|
|
6641
|
+
originalMode,
|
|
6642
|
+
"fix",
|
|
6643
|
+
);
|
|
6644
|
+
targetReplaced = true;
|
|
6645
|
+
|
|
6646
|
+
const writtenText = await readFile(MODELS_JSON_PATH, "utf8");
|
|
6647
|
+
const postCheckError = validateWrittenText(writtenText);
|
|
6648
|
+
const writtenMode = (await stat(MODELS_JSON_PATH)).mode & 0o7777;
|
|
6649
|
+
const effectiveError = postCheckError ?? (
|
|
6650
|
+
writtenMode === originalMode
|
|
6651
|
+
? null
|
|
6652
|
+
: `models.json access mode changed from ${originalMode.toString(8)} to ${writtenMode.toString(8)}`
|
|
6653
|
+
);
|
|
6654
|
+
if (effectiveError !== null) {
|
|
6655
|
+
await atomicRestoreFileFromBackup(backupPath, MODELS_JSON_PATH, originalMode);
|
|
6656
|
+
return { ok: false, postCheckError: effectiveError };
|
|
6657
|
+
}
|
|
6658
|
+
|
|
6659
|
+
return { ok: true };
|
|
6660
|
+
} catch (error) {
|
|
6661
|
+
if (targetReplaced) {
|
|
6662
|
+
try {
|
|
6663
|
+
await atomicRestoreFileFromBackup(backupPath, MODELS_JSON_PATH, originalMode);
|
|
6664
|
+
} catch (restoreError) {
|
|
6665
|
+
throw new AggregateError(
|
|
6666
|
+
[error, restoreError],
|
|
6667
|
+
"models.json fix failed and the atomic backup restore also failed",
|
|
6668
|
+
);
|
|
6669
|
+
}
|
|
6670
|
+
}
|
|
6671
|
+
throw error;
|
|
6672
|
+
}
|
|
6543
6673
|
}
|
|
6544
6674
|
|
|
6545
6675
|
// Internal helpers exported only so the task verification script
|
|
@@ -6593,6 +6723,7 @@ export const __internals_for_tests = {
|
|
|
6593
6723
|
isSessionAffinity403Applicable,
|
|
6594
6724
|
isOpenAISdkHeader403Applicable,
|
|
6595
6725
|
hasPromptCacheRetentionUnsupportedSignal,
|
|
6726
|
+
hasPromptCacheRetentionUnsupportedErrorMessage,
|
|
6596
6727
|
// Non-GPT OpenAI-compatible model detection
|
|
6597
6728
|
isKimiLikeModel,
|
|
6598
6729
|
isKimiLikeAssistantMessage,
|
|
@@ -6816,6 +6947,9 @@ export const __internals_for_tests = {
|
|
|
6816
6947
|
deepEqualIgnoringKeys,
|
|
6817
6948
|
formatCompatKeysForInsertion,
|
|
6818
6949
|
backupTimestamp,
|
|
6950
|
+
atomicReplaceTextFilePreservingMode,
|
|
6951
|
+
atomicRestoreFileFromBackup,
|
|
6952
|
+
applyModelsJsonFixTransaction,
|
|
6819
6953
|
// Fix suggestion builder
|
|
6820
6954
|
buildFixSuggestion,
|
|
6821
6955
|
// Adaptive thinking compat helpers
|
|
@@ -7615,6 +7749,31 @@ export default function (pi: ExtensionAPI) {
|
|
|
7615
7749
|
syncSessionHash(ctx);
|
|
7616
7750
|
const msgRecord = asRecord(event.message);
|
|
7617
7751
|
|
|
7752
|
+
// Some providers expose an HTTP 400 error body only through the finalized
|
|
7753
|
+
// assistant error message; after_provider_response may contain the status
|
|
7754
|
+
// and no diagnostic response headers. Record the same model-scoped
|
|
7755
|
+
// prompt_cache_retention fallback from that authoritative message identity.
|
|
7756
|
+
if (runtimeOptimizerEnabled && hasPromptCacheRetentionUnsupportedErrorMessage(event.message)) {
|
|
7757
|
+
const fallbackModel = resolveRouteModel(ctx.model, ctx) ?? ctx.model;
|
|
7758
|
+
const messageModel = modelFromAssistantMessage(event.message, fallbackModel) ?? fallbackModel;
|
|
7759
|
+
const errorModel = messageModel
|
|
7760
|
+
? findModelInRegistry(ctx.modelRegistry, messageModel.provider, messageModel.id) ?? messageModel
|
|
7761
|
+
: undefined;
|
|
7762
|
+
if (errorModel && isExplicitPromptCacheRetentionUnsupportedApplicable(errorModel)) {
|
|
7763
|
+
const key = modelKey(errorModel);
|
|
7764
|
+
promptCacheRetention400Models.add(key);
|
|
7765
|
+
if (!warnedPromptCacheRetention400Models.has(key)) {
|
|
7766
|
+
warnedPromptCacheRetention400Models.add(key);
|
|
7767
|
+
ctx.ui.notify(
|
|
7768
|
+
`⚠️ ${LOG_PREFIX}: ${key} rejected prompt_cache_retention. ` +
|
|
7769
|
+
getPromptCacheRetentionUnsupportedHint() +
|
|
7770
|
+
` Run /cache-optimizer doctor for the exact edit location.`,
|
|
7771
|
+
"warning",
|
|
7772
|
+
);
|
|
7773
|
+
}
|
|
7774
|
+
}
|
|
7775
|
+
}
|
|
7776
|
+
|
|
7618
7777
|
// Record only Anthropic's explicit mixed-TTL ordering error. This is a
|
|
7619
7778
|
// non-retryable 400 in Pi 0.82.1, so the fallback applies to the next
|
|
7620
7779
|
// subsequent request (and to a retry only if another layer initiates one).
|
|
@@ -7729,27 +7888,27 @@ export default function (pi: ExtensionAPI) {
|
|
|
7729
7888
|
// reset — reset current provider/model footer stats bucket (local only)
|
|
7730
7889
|
// (no args) — interactive menu (with UI) or help summary
|
|
7731
7890
|
// ────────────────────────────────────────────────────────────────
|
|
7732
|
-
|
|
7733
|
-
|
|
7734
|
-
|
|
7735
|
-
|
|
7736
|
-
|
|
7737
|
-
|
|
7738
|
-
|
|
7739
|
-
|
|
7740
|
-
|
|
7891
|
+
async function handleCacheOptimizerCommand(
|
|
7892
|
+
args: string,
|
|
7893
|
+
cmdCtx: ExtensionCommandContext,
|
|
7894
|
+
): Promise<void> {
|
|
7895
|
+
syncSessionHash(cmdCtx);
|
|
7896
|
+
const selectedModel = cmdCtx.model;
|
|
7897
|
+
const model = resolveRouteModel(selectedModel, cmdCtx) ?? selectedModel;
|
|
7898
|
+
const commandParts = args.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
|
7899
|
+
const subcommand = commandParts[0] || "help";
|
|
7741
7900
|
|
|
7742
7901
|
if (subcommand === "enable") {
|
|
7743
7902
|
setRuntimeOptimizerEnabled(true);
|
|
7744
7903
|
resetCurrentSessionStats();
|
|
7745
|
-
await flushPersistCacheStats(cmdCtx
|
|
7746
|
-
await publishStatus(cmdCtx
|
|
7904
|
+
await flushPersistCacheStats(cmdCtx);
|
|
7905
|
+
await publishStatus(cmdCtx, model);
|
|
7747
7906
|
cmdCtx.ui.notify(`✅ Pi Cache Optimizer enabled for this Pi process. Local footer stats were reset for before/after comparison.\n${formatOptimizerRuntimeMode()}`, "info");
|
|
7748
7907
|
} else if (subcommand === "disable") {
|
|
7749
7908
|
setRuntimeOptimizerEnabled(false);
|
|
7750
7909
|
resetCurrentSessionStats();
|
|
7751
|
-
await flushPersistCacheStats(cmdCtx
|
|
7752
|
-
await publishStatus(cmdCtx
|
|
7910
|
+
await flushPersistCacheStats(cmdCtx);
|
|
7911
|
+
await publishStatus(cmdCtx, model);
|
|
7753
7912
|
cmdCtx.ui.notify(`⏸️ Pi Cache Optimizer disabled for this Pi process. Local footer stats were reset and will keep collecting while disabled for comparison.\n${formatOptimizerRuntimeMode()}`, "warning");
|
|
7754
7913
|
} else if (subcommand === "doctor") {
|
|
7755
7914
|
if (!model) {
|
|
@@ -7795,7 +7954,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
7795
7954
|
await writePersistedFooterMode(nextMode);
|
|
7796
7955
|
persistedFooterStatsMode = nextMode;
|
|
7797
7956
|
lastStatusText = undefined;
|
|
7798
|
-
await publishStatus(cmdCtx
|
|
7957
|
+
await publishStatus(cmdCtx, model);
|
|
7799
7958
|
const resolved = resolveFooterStatsMode(persistedFooterStatsMode);
|
|
7800
7959
|
cmdCtx.ui.notify(
|
|
7801
7960
|
`✅ Footer mode set to ${resolved.mode}. Persistent config overrides ${FOOTER_MODE_ENV}.`,
|
|
@@ -7842,10 +8001,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
7842
8001
|
resetStatsForModel(model);
|
|
7843
8002
|
|
|
7844
8003
|
// Persist immediately.
|
|
7845
|
-
await flushPersistCacheStats(cmdCtx
|
|
8004
|
+
await flushPersistCacheStats(cmdCtx);
|
|
7846
8005
|
|
|
7847
8006
|
// Update footer to show 0/0.
|
|
7848
|
-
await publishStatus(cmdCtx
|
|
8007
|
+
await publishStatus(cmdCtx, model);
|
|
7849
8008
|
|
|
7850
8009
|
cmdCtx.ui.notify(
|
|
7851
8010
|
`✅ Reset local footer cache stats for "${displayKey}". ` +
|
|
@@ -7998,20 +8157,20 @@ export default function (pi: ExtensionAPI) {
|
|
|
7998
8157
|
);
|
|
7999
8158
|
if (confirmed) {
|
|
8000
8159
|
try {
|
|
8001
|
-
await
|
|
8002
|
-
|
|
8003
|
-
|
|
8004
|
-
|
|
8005
|
-
|
|
8006
|
-
|
|
8007
|
-
|
|
8008
|
-
|
|
8009
|
-
|
|
8160
|
+
const result = await applyModelsJsonFixTransaction(
|
|
8161
|
+
plan.modifiedText,
|
|
8162
|
+
backupPath,
|
|
8163
|
+
(writtenText) => selfCheckMissingEntryInsertion(
|
|
8164
|
+
originalText,
|
|
8165
|
+
writtenText,
|
|
8166
|
+
suggestion.providerLabel,
|
|
8167
|
+
suggestion.modelId,
|
|
8168
|
+
suggestion.compatKeys,
|
|
8169
|
+
),
|
|
8010
8170
|
);
|
|
8011
|
-
if (
|
|
8012
|
-
await copyFile(backupPath, MODELS_JSON_PATH);
|
|
8171
|
+
if ("postCheckError" in result) {
|
|
8013
8172
|
cmdCtx.ui.notify(
|
|
8014
|
-
`❌ Post-write self-check failed: ${
|
|
8173
|
+
`❌ Post-write self-check failed: ${result.postCheckError}\n` +
|
|
8015
8174
|
`The backup at ${backupPath} has been restored. No changes applied.`,
|
|
8016
8175
|
"error",
|
|
8017
8176
|
);
|
|
@@ -8025,7 +8184,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
8025
8184
|
);
|
|
8026
8185
|
} catch (e) {
|
|
8027
8186
|
cmdCtx.ui.notify(
|
|
8028
|
-
`❌ Write failed: ${e instanceof Error ? e.message : String(e)}
|
|
8187
|
+
`❌ Write failed: ${e instanceof Error ? e.message : String(e)}\n` +
|
|
8188
|
+
`Backup may be at: ${backupPath}`,
|
|
8029
8189
|
"error",
|
|
8030
8190
|
);
|
|
8031
8191
|
}
|
|
@@ -8147,22 +8307,21 @@ export default function (pi: ExtensionAPI) {
|
|
|
8147
8307
|
|
|
8148
8308
|
// Write: backup → temp + rename → self-check again
|
|
8149
8309
|
try {
|
|
8150
|
-
|
|
8151
|
-
|
|
8152
|
-
|
|
8153
|
-
|
|
8154
|
-
|
|
8155
|
-
|
|
8156
|
-
|
|
8157
|
-
|
|
8158
|
-
|
|
8159
|
-
|
|
8160
|
-
|
|
8161
|
-
|
|
8162
|
-
|
|
8163
|
-
await copyFile(backupPath, MODELS_JSON_PATH);
|
|
8310
|
+
const result = await applyModelsJsonFixTransaction(
|
|
8311
|
+
modifiedText,
|
|
8312
|
+
backupPath,
|
|
8313
|
+
(writtenText) => selfCheckFix(
|
|
8314
|
+
originalText,
|
|
8315
|
+
writtenText,
|
|
8316
|
+
suggestion.providerLabel,
|
|
8317
|
+
suggestion.modelId,
|
|
8318
|
+
suggestion.compatKeys,
|
|
8319
|
+
decision.placement,
|
|
8320
|
+
),
|
|
8321
|
+
);
|
|
8322
|
+
if ("postCheckError" in result) {
|
|
8164
8323
|
cmdCtx.ui.notify(
|
|
8165
|
-
`❌ Post-write self-check failed: ${postCheckError}\n` +
|
|
8324
|
+
`❌ Post-write self-check failed: ${result.postCheckError}\n` +
|
|
8166
8325
|
`The backup at ${backupPath} has been restored. No changes applied.`,
|
|
8167
8326
|
"error",
|
|
8168
8327
|
);
|
|
@@ -8198,182 +8357,17 @@ export default function (pi: ExtensionAPI) {
|
|
|
8198
8357
|
];
|
|
8199
8358
|
const choice = await cmdCtx.ui.select("Cache Optimizer", menuOptions);
|
|
8200
8359
|
if (choice === menuOptions[0]) {
|
|
8201
|
-
|
|
8202
|
-
resetCurrentSessionStats();
|
|
8203
|
-
await flushPersistCacheStats(cmdCtx as unknown as ExtensionContext);
|
|
8204
|
-
await publishStatus(cmdCtx as unknown as ExtensionContext, model);
|
|
8205
|
-
cmdCtx.ui.notify(`✅ Pi Cache Optimizer enabled for this Pi process. Local footer stats were reset for before/after comparison.\n${formatOptimizerRuntimeMode()}`, "info");
|
|
8360
|
+
await handleCacheOptimizerCommand("enable", cmdCtx);
|
|
8206
8361
|
} else if (choice === menuOptions[1]) {
|
|
8207
|
-
|
|
8208
|
-
resetCurrentSessionStats();
|
|
8209
|
-
await flushPersistCacheStats(cmdCtx as unknown as ExtensionContext);
|
|
8210
|
-
await publishStatus(cmdCtx as unknown as ExtensionContext, model);
|
|
8211
|
-
cmdCtx.ui.notify(`⏸️ Pi Cache Optimizer disabled for this Pi process. Local footer stats were reset and will keep collecting while disabled for comparison.\n${formatOptimizerRuntimeMode()}`, "warning");
|
|
8362
|
+
await handleCacheOptimizerCommand("disable", cmdCtx);
|
|
8212
8363
|
} else if (choice === menuOptions[2]) {
|
|
8213
|
-
|
|
8214
|
-
cmdCtx.ui.notify("No active model selected. Select a model first with /model or pi --model.", "warning");
|
|
8215
|
-
} else {
|
|
8216
|
-
const diagnosis = buildDoctorDiagnosis(model, { promptCacheRetention400: promptCacheRetention400Models.has(modelKey(model)), anthropicTtlOrderError: anthropicTtlOrderErrorModels.has(modelKey(model)), sessionAffinity403: sendSessionAffinityHeaders403Models.has(modelKey(model)), openAISdkHeader403: openAISdkHeader403Models.has(modelKey(model)) });
|
|
8217
|
-
const adapter = selectAdapterForModel(model);
|
|
8218
|
-
const sk = model ? sessionModelKey(model) : undefined;
|
|
8219
|
-
const statsState = model ? cacheStatsTotalsByModel[modelKey(model)] : undefined;
|
|
8220
|
-
const samples = sk ? getRecentSamples(sk) : [];
|
|
8221
|
-
const lowHitLines = buildLowHitDiagnosis(model, adapter, statsState, samples);
|
|
8222
|
-
const fullDiagnosis = lowHitLines.length > 0
|
|
8223
|
-
? diagnosis + "\n" + lowHitLines.join("\n")
|
|
8224
|
-
: diagnosis;
|
|
8225
|
-
cmdCtx.ui.notify(fullDiagnosis, "info");
|
|
8226
|
-
}
|
|
8364
|
+
await handleCacheOptimizerCommand("doctor", cmdCtx);
|
|
8227
8365
|
} else if (choice === menuOptions[3]) {
|
|
8228
|
-
|
|
8229
|
-
cmdCtx.ui.notify("No active model selected. Select a model first with /model or pi --model.", "warning");
|
|
8230
|
-
} else {
|
|
8231
|
-
const adapter = selectAdapterForModel(model);
|
|
8232
|
-
const sk = model ? sessionModelKey(model) : undefined;
|
|
8233
|
-
const statsState = model ? cacheStatsTotalsByModel[modelKey(model)] : undefined;
|
|
8234
|
-
const samples = sk ? getRecentSamples(sk) : [];
|
|
8235
|
-
const output = buildStatsOutput(model, adapter, statsState, samples);
|
|
8236
|
-
cmdCtx.ui.notify(output, "info");
|
|
8237
|
-
}
|
|
8366
|
+
await handleCacheOptimizerCommand("stats", cmdCtx);
|
|
8238
8367
|
} else if (choice === menuOptions[4]) {
|
|
8239
|
-
|
|
8240
|
-
cmdCtx.ui.notify("No active model selected. Select a model first with /model or pi --model.", "warning");
|
|
8241
|
-
} else {
|
|
8242
|
-
const compatResult = buildCompatDiagnosis(model);
|
|
8243
|
-
if (compatResult) {
|
|
8244
|
-
cmdCtx.ui.notify(compatResult, "warning");
|
|
8245
|
-
} else {
|
|
8246
|
-
cmdCtx.ui.notify(
|
|
8247
|
-
isAdaptiveThinkingCompatApplicable(model) || isDeepSeekCompatCheckApplicable(model) || isCompatCheckApplicable(model)
|
|
8248
|
-
? "✅ Compat fully configured."
|
|
8249
|
-
: getCompatCheckNotApplicableLines(model).join("\n"),
|
|
8250
|
-
"info",
|
|
8251
|
-
);
|
|
8252
|
-
}
|
|
8253
|
-
}
|
|
8368
|
+
await handleCacheOptimizerCommand("compat", cmdCtx);
|
|
8254
8369
|
} else if (choice === menuOptions[5]) {
|
|
8255
|
-
|
|
8256
|
-
if (!model) {
|
|
8257
|
-
cmdCtx.ui.notify("No active model selected. Select a model first with /model or pi --model.", "warning");
|
|
8258
|
-
return;
|
|
8259
|
-
}
|
|
8260
|
-
const suggestion = buildCommandFixSuggestion(model);
|
|
8261
|
-
if (!suggestion) {
|
|
8262
|
-
const key = modelKey(model);
|
|
8263
|
-
cmdCtx.ui.notify(`✅ Nothing to fix for "${key}". Compat already configured.`, "info");
|
|
8264
|
-
return;
|
|
8265
|
-
}
|
|
8266
|
-
|
|
8267
|
-
// Read models.json
|
|
8268
|
-
let originalText: string;
|
|
8269
|
-
try {
|
|
8270
|
-
originalText = await readFile(MODELS_JSON_PATH, "utf8");
|
|
8271
|
-
} catch {
|
|
8272
|
-
cmdCtx.ui.notify(`❌ Could not read ${MODELS_JSON_PATH}. File may not exist.`, "error");
|
|
8273
|
-
return;
|
|
8274
|
-
}
|
|
8275
|
-
|
|
8276
|
-
const location = locateModelInJsonc(originalText, suggestion.providerLabel, suggestion.modelId);
|
|
8277
|
-
const menuDecision = location
|
|
8278
|
-
? chooseFixPlacement(
|
|
8279
|
-
originalText,
|
|
8280
|
-
location,
|
|
8281
|
-
suggestion.compatKeys,
|
|
8282
|
-
suggestion.providerLabel,
|
|
8283
|
-
suggestion.forceModelLevel,
|
|
8284
|
-
)
|
|
8285
|
-
: undefined;
|
|
8286
|
-
const overridePlan = location
|
|
8287
|
-
? undefined
|
|
8288
|
-
: composeModelOverrideInsertion(
|
|
8289
|
-
originalText,
|
|
8290
|
-
suggestion.providerLabel,
|
|
8291
|
-
suggestion.modelId,
|
|
8292
|
-
suggestion.compatKeys,
|
|
8293
|
-
);
|
|
8294
|
-
if (!location && !overridePlan) {
|
|
8295
|
-
cmdCtx.ui.notify(
|
|
8296
|
-
`❌ Could not safely locate a modelOverrides insertion point in ${getModelsJsonDisplayPath()}.\n` +
|
|
8297
|
-
`Manual edit required:\n${formatMissingEntryManualSnippet(suggestion.providerLabel, suggestion.modelId, suggestion.compatKeys)}\n` +
|
|
8298
|
-
`Then run /reload.`,
|
|
8299
|
-
"warning",
|
|
8300
|
-
);
|
|
8301
|
-
return;
|
|
8302
|
-
}
|
|
8303
|
-
const modifiedText = location && menuDecision
|
|
8304
|
-
? composeFixInsertion(originalText, location, suggestion.compatKeys, menuDecision.placement)
|
|
8305
|
-
: overridePlan!.modifiedText;
|
|
8306
|
-
const checkError = location && menuDecision
|
|
8307
|
-
? selfCheckFix(originalText, modifiedText, suggestion.providerLabel, suggestion.modelId, suggestion.compatKeys, menuDecision.placement)
|
|
8308
|
-
: selfCheckMissingEntryInsertion(originalText, modifiedText, suggestion.providerLabel, suggestion.modelId, suggestion.compatKeys);
|
|
8309
|
-
if (checkError !== null) {
|
|
8310
|
-
cmdCtx.ui.notify(`❌ Self-check failed: ${checkError}\nNo changes made.`, "error");
|
|
8311
|
-
return;
|
|
8312
|
-
}
|
|
8313
|
-
|
|
8314
|
-
const keysPreview = JSON.stringify(suggestion.compatKeys, null, 2);
|
|
8315
|
-
const ts = backupTimestamp();
|
|
8316
|
-
const backupPath = `${MODELS_JSON_PATH}.backup-cache-optimizer-${ts}`;
|
|
8317
|
-
|
|
8318
|
-
const menuLocationDesc = overridePlan?.placementLabel ?? (menuDecision?.placement === "provider"
|
|
8319
|
-
? `providers["${suggestion.providerLabel}"] -> compat (provider level)`
|
|
8320
|
-
: menuDecision?.placement === "modelOverride"
|
|
8321
|
-
? `providers["${suggestion.providerLabel}"] -> modelOverrides -> "${suggestion.modelId}" -> compat`
|
|
8322
|
-
: `providers["${suggestion.providerLabel}"] -> models -> "${suggestion.modelId}" -> compat (model level)`);
|
|
8323
|
-
const menuScopeRiskLine = menuDecision?.placement === "provider"
|
|
8324
|
-
? ` 1. This change applies to ALL ${location?.allModelIds.length || 1} model(s) in the "${suggestion.providerLabel}" provider, across all sessions.`
|
|
8325
|
-
: ` 1. This change affects ALL sessions using the "${suggestion.providerLabel}" provider/channel (scoped to model "${suggestion.modelId}").`;
|
|
8326
|
-
|
|
8327
|
-
const previewLines = [
|
|
8328
|
-
`📝 Preview of changes to ${getModelsJsonDisplayPath()}:`,
|
|
8329
|
-
`Location: ${menuLocationDesc}`,
|
|
8330
|
-
`Placement: ${menuDecision ? `${menuDecision.placement} level — ${menuDecision.reason}` : "modelOverrides — built-in/API-login-safe model override"}`,
|
|
8331
|
-
`Compat JSON to write:`,
|
|
8332
|
-
keysPreview,
|
|
8333
|
-
``,
|
|
8334
|
-
`⚠️ Risk notice:`,
|
|
8335
|
-
menuScopeRiskLine,
|
|
8336
|
-
` 2. A timestamped backup will be written to: ${backupPath}`,
|
|
8337
|
-
` 3. You must restart Pi / run /reload for the change to take effect.`,
|
|
8338
|
-
` 4. If the file contains comments, verify the result after write.`,
|
|
8339
|
-
``,
|
|
8340
|
-
`Apply these changes?`,
|
|
8341
|
-
];
|
|
8342
|
-
|
|
8343
|
-
const confirmed = await cmdCtx.ui.confirm("Cache Optimizer — Fix", previewLines.join("\n"));
|
|
8344
|
-
if (!confirmed) {
|
|
8345
|
-
cmdCtx.ui.notify("No changes were made. Canceled by user.", "info");
|
|
8346
|
-
return;
|
|
8347
|
-
}
|
|
8348
|
-
|
|
8349
|
-
try {
|
|
8350
|
-
await copyFile(MODELS_JSON_PATH, backupPath);
|
|
8351
|
-
const tempPath = `${MODELS_JSON_PATH}.${process.pid}.${Date.now()}.fix.tmp`;
|
|
8352
|
-
await writeFile(tempPath, modifiedText, "utf8");
|
|
8353
|
-
await rename(tempPath, MODELS_JSON_PATH);
|
|
8354
|
-
|
|
8355
|
-
const writtenText = await readFile(MODELS_JSON_PATH, "utf8");
|
|
8356
|
-
const postCheck = location && menuDecision
|
|
8357
|
-
? selfCheckFix(originalText, writtenText, suggestion.providerLabel, suggestion.modelId, suggestion.compatKeys, menuDecision.placement)
|
|
8358
|
-
: selfCheckMissingEntryInsertion(originalText, writtenText, suggestion.providerLabel, suggestion.modelId, suggestion.compatKeys);
|
|
8359
|
-
if (postCheck !== null) {
|
|
8360
|
-
await copyFile(backupPath, MODELS_JSON_PATH);
|
|
8361
|
-
cmdCtx.ui.notify(`❌ Post-write check failed: ${postCheck}\nBackup restored.`, "error");
|
|
8362
|
-
return;
|
|
8363
|
-
}
|
|
8364
|
-
|
|
8365
|
-
cmdCtx.ui.notify(
|
|
8366
|
-
`✅ Fix applied to ${getModelsJsonDisplayPath()}.` +
|
|
8367
|
-
`\nBackup: ${backupPath}` +
|
|
8368
|
-
`\nRun /reload or restart Pi for the change to take effect.`,
|
|
8369
|
-
"info",
|
|
8370
|
-
);
|
|
8371
|
-
} catch (writeError) {
|
|
8372
|
-
cmdCtx.ui.notify(
|
|
8373
|
-
`❌ Write failed: ${writeError instanceof Error ? writeError.message : String(writeError)}`,
|
|
8374
|
-
"error",
|
|
8375
|
-
);
|
|
8376
|
-
}
|
|
8370
|
+
await handleCacheOptimizerCommand("fix", cmdCtx);
|
|
8377
8371
|
} else if (choice === menuOptions[6]) {
|
|
8378
8372
|
const modeOptions = ["total — Daily cumulative totals (default)", "session — Current Pi conversation session", "process — Current Pi process only", "Cancel"];
|
|
8379
8373
|
const modeChoice = await cmdCtx.ui.select("Footer cache stats mode", modeOptions);
|
|
@@ -8384,42 +8378,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
8384
8378
|
: modeChoice === modeOptions[2]
|
|
8385
8379
|
? "process"
|
|
8386
8380
|
: undefined;
|
|
8387
|
-
if (nextMode) {
|
|
8388
|
-
try {
|
|
8389
|
-
await writePersistedFooterMode(nextMode);
|
|
8390
|
-
persistedFooterStatsMode = nextMode;
|
|
8391
|
-
lastStatusText = undefined;
|
|
8392
|
-
await publishStatus(cmdCtx as unknown as ExtensionContext, model);
|
|
8393
|
-
cmdCtx.ui.notify(
|
|
8394
|
-
`✅ Footer mode set to ${nextMode}. Persistent config overrides ${FOOTER_MODE_ENV}.`,
|
|
8395
|
-
"info",
|
|
8396
|
-
);
|
|
8397
|
-
} catch (error) {
|
|
8398
|
-
cmdCtx.ui.notify(
|
|
8399
|
-
`❌ Could not update footer mode config: ${error instanceof Error ? error.message : String(error)}`,
|
|
8400
|
-
"error",
|
|
8401
|
-
);
|
|
8402
|
-
}
|
|
8403
|
-
}
|
|
8381
|
+
if (nextMode) await handleCacheOptimizerCommand(`config footer-mode ${nextMode}`, cmdCtx);
|
|
8404
8382
|
} else if (choice === menuOptions[7]) {
|
|
8405
|
-
|
|
8406
|
-
cmdCtx.ui.notify("No active model selected. Select a model first with /model or pi --model.", "warning");
|
|
8407
|
-
} else {
|
|
8408
|
-
const adapter = selectAdapterForModel(model);
|
|
8409
|
-
if (!adapter) {
|
|
8410
|
-
cmdCtx.ui.notify("ℹ️ Active model does not match a cache adapter. No stats to reset.", "info");
|
|
8411
|
-
} else {
|
|
8412
|
-
const displayKey = modelKey(model);
|
|
8413
|
-
resetStatsForModel(model);
|
|
8414
|
-
await flushPersistCacheStats(cmdCtx as unknown as ExtensionContext);
|
|
8415
|
-
await publishStatus(cmdCtx as unknown as ExtensionContext, model);
|
|
8416
|
-
cmdCtx.ui.notify(
|
|
8417
|
-
`✅ Reset local footer cache stats for "${displayKey}". ` +
|
|
8418
|
-
"Upstream provider prompt cache was not modified.",
|
|
8419
|
-
"info",
|
|
8420
|
-
);
|
|
8421
|
-
}
|
|
8422
|
-
}
|
|
8383
|
+
await handleCacheOptimizerCommand("reset", cmdCtx);
|
|
8423
8384
|
}
|
|
8424
8385
|
// choice === "cancel" or undefined → no action
|
|
8425
8386
|
return;
|
|
@@ -8459,6 +8420,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
8459
8420
|
}
|
|
8460
8421
|
cmdCtx.ui.notify(diagnosis.join("\n"), "info");
|
|
8461
8422
|
}
|
|
8462
|
-
|
|
8423
|
+
}
|
|
8424
|
+
|
|
8425
|
+
pi.registerCommand("cache-optimizer", {
|
|
8426
|
+
description: "Configure and diagnose Pi cache behavior",
|
|
8427
|
+
getArgumentCompletions: getCacheOptimizerArgumentCompletions,
|
|
8428
|
+
handler: handleCacheOptimizerCommand,
|
|
8463
8429
|
});
|
|
8464
8430
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-cache-optimizer",
|
|
3
|
-
"version": "2.8.
|
|
3
|
+
"version": "2.8.3",
|
|
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",
|
|
@@ -28,19 +28,20 @@
|
|
|
28
28
|
"#extension": "./index.ts"
|
|
29
29
|
},
|
|
30
30
|
"scripts": {
|
|
31
|
-
"test": "node --import jiti/register tests
|
|
31
|
+
"test": "node --import jiti/register --test tests/*.test.ts",
|
|
32
32
|
"typecheck": "tsc --noEmit --pretty false",
|
|
33
33
|
"check:diff": "git diff --check",
|
|
34
34
|
"check:pack": "npm pack --dry-run --cache node_modules/.cache/npm",
|
|
35
35
|
"check": "npm run typecheck && npm test && npm run check:diff && npm run check:pack"
|
|
36
36
|
},
|
|
37
37
|
"devDependencies": {
|
|
38
|
+
"@earendil-works/pi-coding-agent": "^0.84.2",
|
|
38
39
|
"@types/node": "^22.0.0",
|
|
39
40
|
"jiti": "^2.7.0",
|
|
40
41
|
"typescript": "^5.8.3"
|
|
41
42
|
},
|
|
42
43
|
"peerDependencies": {
|
|
43
|
-
"@earendil-works/pi-coding-agent": "
|
|
44
|
+
"@earendil-works/pi-coding-agent": ">=0.82.0"
|
|
44
45
|
},
|
|
45
46
|
"repository": {
|
|
46
47
|
"type": "git",
|