pi-cache-optimizer 2.8.2 → 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 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 is validated against Pi 0.84.1 and remains designed for Pi 0.82+. It uses the existing 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.
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
 
@@ -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`.
@@ -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. Falls back to manual guidance if JSONC scanner cannot confidently locate the target
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
 
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
- 本扩展已使用 Pi 0.84.1 验证,并继续面向 Pi 0.82+ 设计。它使用这些版本共有的 extension hooks、`getAgentDir()` 和 prompt options,不依赖 Pi 0.83+ 专有 API(例如 `ctx.scopedModels` 或 bundled TypeBox 1.3 aliases)。
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
 
@@ -134,7 +134,7 @@ Pi 0.84.1 还修复了内置 Fireworks 渠道对拒绝 `prompt_cache_retention`
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`。
@@ -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. 原子写入(temp + rename);写入后自我验证
232
- 6. 如果 JSONC 扫描器无法置信定位目标,回退到手动修改指引
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
 
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 { getAgentDir, type BuildSystemPromptOptions, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
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: PiModel): string {
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 hasPromptCacheRetentionUnsupportedSignal(headers: Record<string, string> | undefined): boolean {
2465
- if (!headers) return false;
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;
@@ -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 the timestamp string for backup filename.
6532
- * Format: YYYYMMDDTHHMMSSZ (UTC)
6558
+ * Generate a unique UTC timestamp component for backup filenames.
6559
+ * Milliseconds, process id, and an in-process sequence prevent collisions.
6533
6560
  */
6534
- function backupTimestamp(): string {
6535
- const now = new Date();
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, '0');
6538
- const d = String(now.getUTCDate()).padStart(2, '0');
6539
- const h = String(now.getUTCHours()).padStart(2, '0');
6540
- const min = String(now.getUTCMinutes()).padStart(2, '0');
6541
- const s = String(now.getUTCSeconds()).padStart(2, '0');
6542
- return `${y}${m}${d}T${h}${min}${s}Z`;
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
- pi.registerCommand("cache-optimizer", {
7733
- description: "Configure and diagnose Pi cache behavior",
7734
- getArgumentCompletions: getCacheOptimizerArgumentCompletions,
7735
- handler: async (args: string, cmdCtx) => {
7736
- syncSessionHash(cmdCtx);
7737
- const selectedModel = cmdCtx.model;
7738
- const model = resolveRouteModel(selectedModel, cmdCtx as unknown as ExtensionContext) ?? selectedModel;
7739
- const commandParts = args.trim().toLowerCase().split(/\s+/).filter(Boolean);
7740
- const subcommand = commandParts[0] || "help";
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 as unknown as ExtensionContext);
7746
- await publishStatus(cmdCtx as unknown as ExtensionContext, model);
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 as unknown as ExtensionContext);
7752
- await publishStatus(cmdCtx as unknown as ExtensionContext, model);
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 as unknown as ExtensionContext, model);
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 as unknown as ExtensionContext);
8004
+ await flushPersistCacheStats(cmdCtx);
7846
8005
 
7847
8006
  // Update footer to show 0/0.
7848
- await publishStatus(cmdCtx as unknown as ExtensionContext, model);
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 copyFile(MODELS_JSON_PATH, backupPath);
8002
- const tempPath = `${MODELS_JSON_PATH}.${process.pid}.${Date.now()}.fix.tmp`;
8003
- await writeFile(tempPath, plan.modifiedText, "utf8");
8004
- await rename(tempPath, MODELS_JSON_PATH);
8005
-
8006
- const writtenText = await readFile(MODELS_JSON_PATH, "utf8");
8007
- const postErr = selfCheckMissingEntryInsertion(
8008
- originalText, writtenText,
8009
- suggestion.providerLabel, suggestion.modelId, suggestion.compatKeys,
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 (postErr !== null) {
8012
- await copyFile(backupPath, MODELS_JSON_PATH);
8171
+ if ("postCheckError" in result) {
8013
8172
  cmdCtx.ui.notify(
8014
- `❌ Post-write self-check failed: ${postErr}\n` +
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
- // Backup
8151
- await copyFile(MODELS_JSON_PATH, backupPath);
8152
-
8153
- // Atomic write
8154
- const tempPath = `${MODELS_JSON_PATH}.${process.pid}.${Date.now()}.fix.tmp`;
8155
- await writeFile(tempPath, modifiedText, "utf8");
8156
- await rename(tempPath, MODELS_JSON_PATH);
8157
-
8158
- // Post-write self-check (read back)
8159
- const writtenText = await readFile(MODELS_JSON_PATH, "utf8");
8160
- const postCheckError = selfCheckFix(originalText, writtenText, suggestion.providerLabel, suggestion.modelId, suggestion.compatKeys, decision.placement);
8161
- if (postCheckError !== null) {
8162
- // Restore from backup
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
- setRuntimeOptimizerEnabled(true);
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
- setRuntimeOptimizerEnabled(false);
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
- if (!model) {
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
- if (!model) {
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
- if (!model) {
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
- // Fix — auto-fix compat issues
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
- if (!model) {
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.2",
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,20 +28,20 @@
28
28
  "#extension": "./index.ts"
29
29
  },
30
30
  "scripts": {
31
- "test": "node --import jiti/register tests/review-findings.test.ts",
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.1",
38
+ "@earendil-works/pi-coding-agent": "^0.84.2",
39
39
  "@types/node": "^22.0.0",
40
40
  "jiti": "^2.7.0",
41
41
  "typescript": "^5.8.3"
42
42
  },
43
43
  "peerDependencies": {
44
- "@earendil-works/pi-coding-agent": "*"
44
+ "@earendil-works/pi-coding-agent": ">=0.82.0"
45
45
  },
46
46
  "repository": {
47
47
  "type": "git",