pi-cache-optimizer 2.8.23 → 2.8.24

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
@@ -80,6 +80,7 @@ This extension requires Pi 0.82+ and is validated against Pi 1.1.0. It uses the
80
80
  | `/cache-optimizer reset` | Resets local footer stats for the active provider/model; upstream provider cache is not modified. |
81
81
  | `/cache-optimizer config footer-mode total\|session\|process` | Persist the footer stats mode. Persistent command configuration overrides the environment variable. |
82
82
  | `/cache-optimizer fix` | Auto-repairs safe compat issues for the active model. Shows preview + risk warning, requires confirmation. It writes `models.json` only for native compat fixes, or the extension config for an evidenced `prompt_cache_key` issue. |
83
+ | `/cache-optimizer fix prompt-cache` | Adds `promptCache` lifetimes for the active model so Pi's native cache warming can run. Preview, backup, receipt, and rollback. Requires confirmation. |
83
84
  | `/cache-optimizer fix prompt-cache-key` | Explicitly configures the active OpenAI-compatible provider/model to omit `prompt_cache_key` and `promptCacheKey` from the final request body. Requires confirmation. |
84
85
  | `/cache-optimizer rollback` | Reviews the latest matching confirmed fix and, after UI confirmation, restores either the extension config or `models.json` fix safely. |
85
86
 
@@ -208,7 +209,7 @@ Notes:
208
209
 
209
210
  Pi itself can keep a prompt cache alive by re-sending the last request with a one-token output cap shortly before the entry expires (the global `cacheWarming` setting: `off`, `streaming` (default), or `idle`; change it with `/settings` → Cache warming or `"cacheWarming"` in `~/.pi/agent/settings.json`). This extension does not run a keepalive timer of its own: a second warmer would pay for duplicate refreshes and cannot replay Pi's exact request.
210
211
 
211
- Pi only warms a model that declares a cache lifetime for the retention tier in use, has input/cache prices, and is replayable (Anthropic budget-based thinking is not). Custom proxy models in `models.json` often lack the first two, so warming never starts. `/cache-optimizer doctor` now reports the mode, the tier, and what is missing, with a snippet such as `"promptCache": {"short":300,"long":3600}` for `anthropic-messages` models. Put it on the model entry (custom models) or under the provider's `modelOverrides` (built-in models). It is advice only; the extension does not write it for you.
212
+ Pi only warms a model that declares a cache lifetime for the retention tier in use, has input/cache prices, and is replayable (Anthropic budget-based thinking is not). Custom proxy models in `models.json` often lack the first two, so warming never starts. For models whose prices are all zero, Pi stops with "cache economics unavailable"; this extension answers Pi's `cache_warming_decision` with `warm` in that case only, since a refresh is just a cache read plus one output token. Models with real prices keep Pi's own decision, and Pi's mode, lifetime, replayability and time limits still apply. Each refresh is a real request, so a channel that does bill despite the zero prices pays for it. Pi's `/session` still shows "cache economics unavailable" between refreshes, and if another extension also handles `cache_warming_decision`, the one Pi runs last wins. Turn it off with `/cache-optimizer config zero-price-warming off` or `PI_CACHE_OPTIMIZER_NO_ZERO_PRICE_WARMING=1`. `/cache-optimizer doctor` now reports the mode, the tier, and what is missing, with a snippet such as `"promptCache": {"short":300,"long":3600}` for `anthropic-messages` models. Put it on the model entry (custom models) or under the provider's `modelOverrides` (built-in models), or run `/cache-optimizer fix prompt-cache` to have it written for you: it previews the edit, asks for confirmation, keeps a timestamped backup and comments, and records a receipt so `/cache-optimizer rollback` removes exactly that `promptCache` object (or restores the whole file if nothing else changed). It writes `{"short":300,"long":3600}` for `anthropic-messages` models (Anthropic's documented 5m/1h, the values Pi ships for its own Claude models) and `{"short":300}` elsewhere; when the long tier is in use and no long lifetime is documented for the API, it refuses rather than guessing. It never merges into or overwrites an existing `promptCache`, and it does not create a provider entry that models.json lacks.
212
213
 
213
214
  Streaming warming stops 60 minutes after the real request and idle warming after 30 minutes. With this extension's 1-hour retention, the refresh would fire at 54 minutes, so `idle` adds nothing; the 1-hour lifetime already covers breaks under an hour. Keep the default `streaming` unless you use 5-minute caches and often return within half an hour.
214
215
 
package/README.zh-CN.md CHANGED
@@ -80,6 +80,7 @@ Pi 0.79.7 及之后,`pi update` 默认只更新 Pi 本体。若要更新已安
80
80
  | `/cache-optimizer reset` | 重置当前 provider/model 的本地 footer 统计;不会修改上游 provider 缓存。 |
81
81
  | `/cache-optimizer config footer-mode total\|session\|process` | 持久设置 footer 统计模式;持久命令配置优先于环境变量。 |
82
82
  | `/cache-optimizer fix` | 为当前模型自动修复安全的 compat 问题。展示预览 + 风险提示,需要用户确认;原生 compat 修复写 `models.json`,已观测到的 `prompt_cache_key` 问题写扩展配置。 |
83
+ | `/cache-optimizer fix prompt-cache` | 为当前模型写入 `promptCache` 缓存时长,让 Pi 原生保温生效。带预览、备份、receipt 和回滚。需要确认。 |
83
84
  | `/cache-optimizer fix prompt-cache-key` | 明确把当前 OpenAI-compatible provider/model 配置为从最终请求体移除 `prompt_cache_key` 与 `promptCacheKey`。需要确认。 |
84
85
  | `/cache-optimizer rollback` | 查看最近一次匹配的已确认修复;经 UI 确认后安全恢复扩展配置或 `models.json` 修复。 |
85
86
 
@@ -208,7 +209,7 @@ Pi 0.84.1 还修复了内置 Fireworks 渠道对拒绝 `prompt_cache_retention`
208
209
 
209
210
  Pi 自带缓存保温:在缓存条目过期前,用一个 token 的输出上限原样重放上一次请求(全局设置 `cacheWarming`:`off`、`streaming`(默认)或 `idle`;在 `/settings` → Cache warming 中修改,或在 `~/.pi/agent/settings.json` 中设置 `"cacheWarming"`)。本扩展不自行运行保活定时器:第二个保温器会重复计费,而且无法精确重放 Pi 的请求。
210
211
 
211
- Pi 只会为满足以下条件的模型保温:为当前保留档位声明了缓存时长、有输入/缓存价格、请求可重放(Anthropic 基于 budget 的 thinking 不可重放)。`models.json` 里的自定义代理模型常常缺前两项,保温根本不会启动。`/cache-optimizer doctor` 现在会显示保温模式、档位和缺失项,并给出片段,例如 `anthropic-messages` 模型的 `"promptCache": {"short":300,"long":3600}`。自定义模型写在该模型条目中,内置模型写在 provider 的 `modelOverrides` 下。这只是建议,扩展不会替你写入。
212
+ Pi 只会为满足以下条件的模型保温:为当前保留档位声明了缓存时长、有输入/缓存价格、请求可重放(Anthropic 基于 budget 的 thinking 不可重放)。`models.json` 里的自定义代理模型常常缺前两项,保温根本不会启动。对价格全为 0 的模型,Pi 会以 “cache economics unavailable” 停止保温;本扩展仅在这种情况下通过 `cache_warming_decision` 返回 `warm`,因为一次刷新只是一次缓存读取加一个输出 token。有真实价格的模型仍按 Pi 自己的判断,Pi 的模式、缓存时长、可重放性和时间上限也照常生效。每次刷新都是一次真实请求,若渠道价格写 0 但实际计费,会产生费用。刷新间隔里 Pi 的 `/session` 仍会显示 “cache economics unavailable”;若其他扩展也处理 `cache_warming_decision`,以 Pi 最后执行的那个为准。可用 `/cache-optimizer config zero-price-warming off` 或 `PI_CACHE_OPTIMIZER_NO_ZERO_PRICE_WARMING=1` 关闭。`/cache-optimizer doctor` 现在会显示保温模式、档位和缺失项,并给出片段,例如 `anthropic-messages` 模型的 `"promptCache": {"short":300,"long":3600}`。自定义模型写在该模型条目中,内置模型写在 provider 的 `modelOverrides` 下;也可以运行 `/cache-optimizer fix prompt-cache` 一键写入:先预览、需确认、保留带时间戳的备份和注释,并记录 receipt,`/cache-optimizer rollback` 只会删除这次加入的 `promptCache` 对象(若文件之后没有改动则整文件恢复)。`anthropic-messages` 模型写入 `{"short":300,"long":3600}`(Anthropic 文档的 5 分钟/1 小时,与 Pi 内置 Claude 模型一致),其他 API 写入 `{"short":300}`;若当前使用长档位而该 API 没有文档化的长时长,则拒绝写入而不是猜测。已有 `promptCache` 时不会合并或覆盖,models.json 中不存在的 provider 条目也不会被创建。
212
213
 
213
214
  streaming 保温在真实请求后 60 分钟停止,idle 保温在 30 分钟后停止。本扩展使用 1 小时保留时,刷新会在第 54 分钟才触发,所以 `idle` 没有额外作用;1 小时的缓存本身已覆盖一小时内的中断。除非你使用 5 分钟缓存且经常在半小时内回来,否则保持默认的 `streaming` 即可。
214
215
 
package/index.ts CHANGED
@@ -16,9 +16,9 @@ import { analyzeModelsJsonForMissingEntry, applyModelsJsonFixTransaction, choose
16
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
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
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 { readCacheWarmingMode } from "./src/cache-warming.ts";
19
+ import { decideZeroPriceWarming, readCacheWarmingMode } from "./src/cache-warming.ts";
20
20
  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";
21
- import { isRuntimeOptimizerEnabled, setRuntimeOptimizerEnabled } from "./src/config.ts";
21
+ import { isRuntimeOptimizerEnabled, isZeroPriceWarmingEnabled, setRuntimeOptimizerEnabled } from "./src/config.ts";
22
22
  import { compareToolOrderEntries, getToolNameForPayload, isKnownToolOrderApi, isToolOrderingEligibleModel, isVerifiedToolForApi, normalizeToolsInPayload, sortToolsInPayload } from "./src/tool-ordering.ts";
23
23
  import { SKILL_COMPRESSION_MIN_COUNT, compressSkillsInSystemPrompt, compressSkillsViaSection, formatSkillsForPrompt, formatSkillsForPromptCompressed, stripSessionOverviewChurn, explainSkillCompressionSkip, recordSkillCompressionOutcome, getLastSkillCompressionOutcome, describeSkillCompressionOutcome } from "./src/prompt-rewrite.ts";
24
24
  import { addEffectiveSessionAffinityHeaders, addOpenAIPromptCacheKey, clampPromptCacheKey, collectAnthropicCacheControlsInWireOrder, downgradeAnthropicLongCacheControls, getEffectiveCompatValueSource, hasAnthropicCacheTtlOrderError, hasEffectivePromptCacheKey, isPromptCacheKeyOmittedForModel, normalizeAnthropicCacheControlTtlOrder, omitOpenAIPromptCacheKeys, shouldInjectOpenAIPromptCacheKeyForModel, withAnthropicCacheTtlRepair, withoutPromptCacheRetention, derivePromptCacheKey } from "./src/request-payload.ts";
@@ -988,6 +988,15 @@ export default function (pi: ExtensionAPI) {
988
988
  await publishStatus(ctx);
989
989
  });
990
990
 
991
+ // Pi 0.86+ native prompt-cache warming: keep it running for models whose
992
+ // prices are unknown (all-zero cost), where Pi would otherwise stop with
993
+ // "cache economics unavailable". Older hosts never emit this event.
994
+ pi.on("cache_warming_decision", (event, ctx) => {
995
+ if (!isZeroPriceWarmingEnabled()) return undefined;
996
+ const action = decideZeroPriceWarming(event, ctx.model);
997
+ return action ? { action } : undefined;
998
+ });
999
+
991
1000
  pi.on("agent_settled", async (_event, ctx) => {
992
1001
  // No provider request can still be in flight once the agent has settled.
993
1002
  // Drop leftover lifecycle records (a request whose message never ended, or
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-cache-optimizer",
3
- "version": "2.8.23",
3
+ "version": "2.8.24",
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",
@@ -54,6 +54,7 @@ export function suggestedPromptCacheLifetimes(model: PiModel): { short: number;
54
54
  // other APIs only the conservative short lifetime is suggested; extended
55
55
  // retention differs per provider and deployment.
56
56
  if (model.api === "anthropic-messages") return { short: 300, long: 3600 };
57
+ // Elsewhere only the short tier has a value the extension can stand behind.
57
58
  return { short: 300 };
58
59
  }
59
60
 
@@ -69,6 +70,13 @@ function hasPricing(model: PiModel): boolean {
69
70
  return ["input", "cacheRead", "cacheWrite"].some((key) => typeof cost[key] === "number" && (cost[key] as number) > 0);
70
71
  }
71
72
 
73
+ /** Every price Pi uses for warming economics is zero or missing. */
74
+ function isZeroPriced(model: PiModel): boolean {
75
+ const cost = asRecord((model as unknown as Record<string, unknown>).cost);
76
+ if (!cost) return true;
77
+ return ["input", "output", "cacheRead", "cacheWrite"].every((key) => !(typeof cost[key] === "number" && (cost[key] as number) > 0));
78
+ }
79
+
72
80
  function isReplayableForWarming(model: PiModel): boolean {
73
81
  if (!model.reasoning || model.api !== "anthropic-messages") return true;
74
82
  return asRecord(asRecord(model as unknown)?.compat)?.forceAdaptiveThinking === true;
@@ -78,6 +86,8 @@ export type CacheWarmingDiagnosisInput = {
78
86
  mode: CacheWarmingMode | "unsupported";
79
87
  /** Retention tier Pi uses for this request ("long" when PI_CACHE_RETENTION=long). */
80
88
  tier: "short" | "long";
89
+ /** Whether the extension keeps warming running for zero-price models. */
90
+ zeroPriceWarming?: boolean;
81
91
  };
82
92
 
83
93
  export type CacheWarmingDiagnosis = {
@@ -118,6 +128,9 @@ export function buildCacheWarmingDiagnosis(model: PiModel, input: CacheWarmingDi
118
128
  lines.push(`- ⚠️ The model declares no promptCache.${input.tier} lifetime, so Pi never warms its cache.`);
119
129
  lines.push(` Add to this model's entry in models.json (custom models) or under the provider's modelOverrides.${JSON.stringify(model.id)} (built-in models):`);
120
130
  lines.push(` "promptCache": ${JSON.stringify(snippet)}`);
131
+ if (input.tier === "short" || suggested.long !== undefined) {
132
+ lines.push(" Or run /cache-optimizer fix prompt-cache to add it with a preview, backup and rollback.");
133
+ }
121
134
  if (input.tier === "long" && suggested.long === undefined) {
122
135
  lines.push(" Only add a long lifetime your provider documents; use the conservative end of any range.");
123
136
  }
@@ -126,8 +139,12 @@ export function buildCacheWarmingDiagnosis(model: PiModel, input: CacheWarmingDi
126
139
  }
127
140
 
128
141
  if (!hasPricing(model)) {
129
- issues.push("pricing_missing");
130
- lines.push("- ⚠️ The model has no input/cache prices, so Pi cannot estimate savings and skips warming. Add a cost block to the model entry.");
142
+ if (input.zeroPriceWarming && isZeroPriced(model)) {
143
+ lines.push("- The model has no input/cache prices. Pi would skip warming; this extension overrides that (config zero-price-warming on).");
144
+ } else {
145
+ issues.push("pricing_missing");
146
+ lines.push("- ⚠️ The model has no input/cache prices, so Pi cannot estimate savings and skips warming. Add a cost block, or turn on /cache-optimizer config zero-price-warming.");
147
+ }
131
148
  }
132
149
 
133
150
  if (!isReplayableForWarming(model)) {
@@ -148,3 +165,25 @@ export function buildCacheWarmingDiagnosis(model: PiModel, input: CacheWarmingDi
148
165
  }
149
166
  return { eligible, issues, lines };
150
167
  }
168
+
169
+ export type CacheWarmingDecisionLike = {
170
+ action?: unknown;
171
+ warmCost?: unknown;
172
+ missCost?: unknown;
173
+ };
174
+
175
+ /**
176
+ * `cache_warming_decision` policy: Pi stops warming when it cannot price the
177
+ * refresh ("cache economics unavailable"), which is every model whose cost
178
+ * block is all zero, the common case for custom proxy channels. A refresh is a
179
+ * cache read of the prompt plus one output token, far cheaper than rewriting
180
+ * the cache, so keep warming for those models. Pi's own decision stands
181
+ * whenever the model has real prices, and Pi's mode, lifetime, replayability
182
+ * and 60m/30m safety limits still apply.
183
+ */
184
+ export function decideZeroPriceWarming(event: CacheWarmingDecisionLike, model: PiModel | undefined): "warm" | undefined {
185
+ if (!model || event.action !== "stop") return undefined;
186
+ if (event.warmCost !== 0 || event.missCost !== 0) return undefined;
187
+ if (!isZeroPriced(model)) return undefined;
188
+ return "warm";
189
+ }
@@ -18,9 +18,9 @@ export const CACHE_OPTIMIZER_COMMANDS = [
18
18
  "rollback",
19
19
  ] as const;
20
20
 
21
- export const CACHE_OPTIMIZER_CONFIG_ARGUMENTS = ["footer-mode", "prompt-rewrite", "virtual-rewrite", "skill-compression", "openai-cache-key", "tool-order", "reset"] as const;
21
+ export const CACHE_OPTIMIZER_CONFIG_ARGUMENTS = ["footer-mode", "prompt-rewrite", "virtual-rewrite", "skill-compression", "openai-cache-key", "tool-order", "zero-price-warming", "reset"] as const;
22
22
 
23
- export const CACHE_OPTIMIZER_FEATURE_COMMANDS = ["prompt-rewrite", "virtual-rewrite", "skill-compression", "openai-cache-key", "tool-order"] as const;
23
+ export const CACHE_OPTIMIZER_FEATURE_COMMANDS = ["prompt-rewrite", "virtual-rewrite", "skill-compression", "openai-cache-key", "tool-order", "zero-price-warming"] as const;
24
24
 
25
25
  export const CACHE_OPTIMIZER_FEATURE_VALUES = ["on", "off"] as const;
26
26
 
@@ -32,11 +32,12 @@ export const FEATURE_COMMAND_MAP: Record<string, PersistedCacheOptimizerFeature>
32
32
  "skill-compression": "skillCompression",
33
33
  "openai-cache-key": "openAICacheKey",
34
34
  "tool-order": "toolOrder",
35
+ "zero-price-warming": "zeroPriceWarming",
35
36
  };
36
37
 
37
38
  export const CACHE_OPTIMIZER_STATS_ARGUMENTS = ["all", "contributors"] as const;
38
39
 
39
- export const CACHE_OPTIMIZER_FIX_ARGUMENTS = ["prompt-cache-key"] as const;
40
+ export const CACHE_OPTIMIZER_FIX_ARGUMENTS = ["prompt-cache", "prompt-cache-key"] as const;
40
41
 
41
42
  export function filterCommandCompletionItems(
42
43
  values: readonly string[],
package/src/command.ts CHANGED
@@ -1,16 +1,17 @@
1
1
  import { type ExtensionCommandContext, type ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { selectAdapterForModel } from "./adapters.ts";
4
- import { backupTimestamp, hashText } from "./atomic-fs.ts";
4
+ import { backupTimestamp, hashText, readRegularTextFile } from "./atomic-fs.ts";
5
+ import { choosePromptCacheLifetimes, planPromptCacheFix, validateWrittenPromptCacheFix } from "./prompt-cache-fix.ts";
5
6
  import { FEATURE_COMMAND_MAP } from "./command-completion.ts";
6
7
  import { type PiModel, asRecord } from "./common.ts";
7
8
  import { describeMissingCacheCompatForModel, getModelsJsonDisplayPath, isAdaptiveThinkingCompatApplicable } from "./compat-advice.ts";
8
9
  import { resolveEffectiveCompatFromConfig } from "./compat-config.ts";
9
- import { FOOTER_MODE_ENV, type FooterStatsMode, formatOptimizerRuntimeMode, formatPersistentFeatureConfig, persistedFooterStatsMode, readPersistedCacheOptimizerConfig, resetPersistedFeatures, resolveFooterStatsMode, setPersistedCacheOptimizerConfig, setRuntimeOptimizerEnabled, writePersistedFeature, writePersistedFooterMode } from "./config.ts";
10
+ import { isZeroPriceWarmingEnabled, FOOTER_MODE_ENV, type FooterStatsMode, formatOptimizerRuntimeMode, formatPersistentFeatureConfig, persistedFooterStatsMode, readPersistedCacheOptimizerConfig, resetPersistedFeatures, resolveFooterStatsMode, setPersistedCacheOptimizerConfig, setRuntimeOptimizerEnabled, writePersistedFeature, writePersistedFooterMode } from "./config.ts";
10
11
  import { buildCompatDiagnosis, buildDoctorDiagnosis, buildLowHitDiagnosis, getCompatCheckNotApplicableLines, isCompatCheckApplicable, isDeepSeekCompatCheckApplicable, isPromptCacheKeyUnsupportedApplicable } from "./diagnostics.ts";
11
12
  import { locateModelOverrideInJsonc, parseJsonc } from "./jsonc.ts";
12
13
  import { invalidateModelsConfigCache, isValidModelsConfigForEffectiveCompat, modelKey } from "./model-identity.ts";
13
- import { analyzeModelsJsonForMissingEntry, applyModelsJsonFixTransaction, chooseFixPlacement, composeFixInsertion, composeModelOverrideInsertion, composeProviderAffinityInsertion, createModelsJsonFixReceipt, formatCompatKeysForInsertion, formatMissingEntryManualSnippet, isActionableModelsJsonFixReceipt, locateModelInJsonc, markModelsJsonFixReceiptRolledBack, prepareModelsJsonRollback, readModelsJsonFixReceipt, readModelsJsonFixReceiptSnapshot, resolveExplicitCompatValue, selfCheckFix, selfCheckMissingEntryInsertion, validateModelsJsonRollback, writeModelsJsonFixReceipt } from "./models-json-fix.ts";
14
+ import { analyzeModelsJsonForMissingEntry, applyModelsJsonFixTransaction, chooseFixPlacement, composeFixInsertion, composeModelOverrideInsertion, composeProviderAffinityInsertion, createModelsJsonFixReceipt, createPromptCacheFixReceipt, formatCompatKeysForInsertion, formatMissingEntryManualSnippet, isActionableModelsJsonFixReceipt, locateModelInJsonc, markModelsJsonFixReceiptRolledBack, prepareModelsJsonRollback, readModelsJsonFixReceipt, readModelsJsonFixReceiptSnapshot, resolveExplicitCompatValue, selfCheckFix, selfCheckMissingEntryInsertion, validateModelsJsonRollback, writeModelsJsonFixReceipt } from "./models-json-fix.ts";
14
15
  import { MODELS_JSON_PATH } from "./paths.ts";
15
16
  import { applyPromptCacheKeyConfigFix, isActionablePromptCacheKeyConfigReceipt, readPromptCacheKeyConfigReceiptSnapshot, rollbackPromptCacheKeyConfig } from "./prompt-cache-key-config.ts";
16
17
  import { getEffectiveCompatValueSource, isPromptCacheKeyOmittedForModel } from "./request-payload.ts";
@@ -117,6 +118,7 @@ export function createCacheOptimizerCommandHandler(runtime: CommandRuntime) {
117
118
  ? buildCacheWarmingDiagnosis(model, {
118
119
  mode: runtime.getCacheWarmingMode(),
119
120
  tier: process.env[PI_CACHE_RETENTION_ENV] === LONG_CACHE_RETENTION_VALUE ? "long" : "short",
121
+ zeroPriceWarming: isZeroPriceWarmingEnabled(),
120
122
  }).lines
121
123
  : [];
122
124
  const fullDiagnosis = [routeNote, diagnosis, describeSkillCompressionOutcome(), describePromptCacheRetentionDecision(getLastPromptCacheRetentionDecision()), ...warmingLines, ...lowHitLines].filter((line) => line !== undefined).join("\n");
@@ -169,7 +171,7 @@ export function createCacheOptimizerCommandHandler(runtime: CommandRuntime) {
169
171
  const resolved = resolveFooterStatsMode(persistedFooterStatsMode);
170
172
  cmdCtx.ui.notify(
171
173
  `Usage: /cache-optimizer config footer-mode total|session|process\n` +
172
- ` /cache-optimizer config prompt-rewrite|virtual-rewrite|skill-compression|openai-cache-key|tool-order on|off\n` +
174
+ ` /cache-optimizer config prompt-rewrite|virtual-rewrite|skill-compression|openai-cache-key|tool-order|zero-price-warming on|off\n` +
173
175
  ` /cache-optimizer config reset\n` +
174
176
  `Current footer mode: ${resolved.mode} (${resolved.source})`,
175
177
  "info",
@@ -284,7 +286,9 @@ export function createCacheOptimizerCommandHandler(runtime: CommandRuntime) {
284
286
 
285
287
  const rollbackScope = rollback.mode === "exact"
286
288
  ? "restore the exact pre-fix models.json because the file is unchanged since the fix"
287
- : "restore only the receipt-owned compat scalar keys and preserve subsequent user changes";
289
+ : rollback.receipt.promptCacheAdded
290
+ ? "remove only the promptCache object the fix added (and restore any receipt-owned compat keys), preserving subsequent user changes"
291
+ : "restore only the receipt-owned compat scalar keys and preserve subsequent user changes";
288
292
  const rollbackPreview = [
289
293
  `Rollback transaction ${rollback.receipt.transactionId}:`,
290
294
  `Model: ${rollback.receipt.provider}/${rollback.receipt.modelId}`,
@@ -378,6 +382,11 @@ export function createCacheOptimizerCommandHandler(runtime: CommandRuntime) {
378
382
  return;
379
383
  }
380
384
 
385
+ if (commandParts[1] === "prompt-cache") {
386
+ await runPromptCacheFix(cmdCtx, model);
387
+ return;
388
+ }
389
+
381
390
  const promptCacheKeyRequested = commandParts[1] === "prompt-cache-key";
382
391
  const promptCacheKeyFixRequested = promptCacheKeyRequested || promptCacheKeyFixApplies(model);
383
392
  if (promptCacheKeyRequested && !isPromptCacheKeyUnsupportedApplicable(model)) {
@@ -987,10 +996,11 @@ export function createCacheOptimizerCommandHandler(runtime: CommandRuntime) {
987
996
  diagnosis.push(" stats all — Show detailed totals for every model across all local sessions");
988
997
  diagnosis.push(" stats contributors — Show per-session contributors for the active model");
989
998
  diagnosis.push(" compat — Show compat suggestion with edit location");
990
- diagnosis.push(" config prompt-rewrite|virtual-rewrite|skill-compression|openai-cache-key|tool-order on|off — Persist feature settings");
999
+ diagnosis.push(" config prompt-rewrite|virtual-rewrite|skill-compression|openai-cache-key|tool-order|zero-price-warming on|off — Persist feature settings");
991
1000
  diagnosis.push(" config footer-mode total|session|process — Persist the footer stats mode");
992
1001
  diagnosis.push(" config reset — Remove persistent feature overrides");
993
1002
  diagnosis.push(" fix — Auto-fix compat issues (writes models.json or extension config, requires UI)");
1003
+ diagnosis.push(" fix prompt-cache — Add promptCache lifetimes so Pi's cache warming can run");
994
1004
  diagnosis.push(" fix prompt-cache-key — Explicitly omit prompt_cache_key for the active model");
995
1005
  diagnosis.push(" rollback — Undo the latest confirmed fix (requires UI confirmation)");
996
1006
  diagnosis.push(" reset — Reset local provider/model stats for current model (does not affect upstream)");
@@ -1019,3 +1029,114 @@ export function createCacheOptimizerCommandHandler(runtime: CommandRuntime) {
1019
1029
  }
1020
1030
  }
1021
1031
  }
1032
+
1033
+ function declaredPromptCacheTier(model: PiModel, tier: "short" | "long"): number | undefined {
1034
+ const value = asRecord(asRecord(model as unknown)?.promptCache)?.[tier];
1035
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined;
1036
+ }
1037
+
1038
+ /**
1039
+ * `/cache-optimizer fix prompt-cache`: add Pi's model-level promptCache
1040
+ * lifetimes so native cache warming can run. Same safety model as the compat
1041
+ * fix: preview + confirmation, timestamped backup, guarded atomic write,
1042
+ * post-write check, and a receipt that /cache-optimizer rollback understands.
1043
+ */
1044
+ async function runPromptCacheFix(cmdCtx: ExtensionCommandContext, model: PiModel): Promise<void> {
1045
+ const tier: "short" | "long" = process.env[PI_CACHE_RETENTION_ENV] === LONG_CACHE_RETENTION_VALUE ? "long" : "short";
1046
+ const declared = declaredPromptCacheTier(model, tier);
1047
+ if (declared !== undefined) {
1048
+ cmdCtx.ui.notify(`✅ ${modelKey(model)} already declares promptCache.${tier} (${declared}s). Nothing to fix.`, "info");
1049
+ return;
1050
+ }
1051
+ const choice = choosePromptCacheLifetimes(model, tier);
1052
+ if ("error" in choice) {
1053
+ cmdCtx.ui.notify(`ℹ️ ${choice.error} No changes were made.`, "warning");
1054
+ return;
1055
+ }
1056
+ const snippet = `"promptCache": ${JSON.stringify(choice.promptCache)}`;
1057
+ const manual = (reason: string): string => [
1058
+ `❌ ${reason}. No changes were made.`,
1059
+ "",
1060
+ `Add this to the model entry in ${getModelsJsonDisplayPath()} (custom models) or under`,
1061
+ `providers["${model.provider}"].modelOverrides["${model.id}"] (built-in models), then run /reload:`,
1062
+ ` ${snippet}`,
1063
+ ].join("\n");
1064
+
1065
+ if (!cmdCtx.hasUI) {
1066
+ cmdCtx.ui.notify(manual("Non-interactive terminal detected. The fix requires UI confirmation"), "warning");
1067
+ return;
1068
+ }
1069
+
1070
+ let current: { text: string; mode: number };
1071
+ try {
1072
+ current = await readRegularTextFile(MODELS_JSON_PATH);
1073
+ } catch {
1074
+ cmdCtx.ui.notify(manual(`Could not read ${getModelsJsonDisplayPath()} as a regular file`), "error");
1075
+ return;
1076
+ }
1077
+ const plan = planPromptCacheFix(current.text, model.provider, model.id, choice.promptCache);
1078
+ if ("error" in plan) {
1079
+ cmdCtx.ui.notify(manual(`Cannot edit models.json safely: ${plan.error}`), "error");
1080
+ return;
1081
+ }
1082
+
1083
+ const backupPath = `${MODELS_JSON_PATH}.backup-cache-optimizer-${backupTimestamp()}`;
1084
+ const pendingReceipt = await readModelsJsonFixReceipt();
1085
+ const confirmed = await cmdCtx.ui.confirm("Cache Optimizer — Add promptCache lifetimes", [
1086
+ `📝 Preview of changes to ${getModelsJsonDisplayPath()}:`,
1087
+ `Location: ${plan.locationLabel}`,
1088
+ `JSON to write: ${snippet}`,
1089
+ plan.targetExistedBefore ? "" : "A new modelOverrides entry will be created for this model.",
1090
+ "",
1091
+ "Why: Pi only warms a prompt cache when the model declares its lifetime for the retention tier in use",
1092
+ `(${tier} here). ${model.api === "anthropic-messages" ? "300s/3600s are Anthropic's documented 5m and 1h lifetimes, the values Pi ships for its own Claude models." : "300s is the conservative short lifetime."}`,
1093
+ "",
1094
+ `A timestamped backup will be written to: ${backupPath}`,
1095
+ "Comments and unrelated fields are preserved. Undo with /cache-optimizer rollback.",
1096
+ isActionableModelsJsonFixReceipt(pendingReceipt)
1097
+ ? `⚠️ This replaces the rollback receipt of the earlier fix for ${pendingReceipt.provider}/${pendingReceipt.modelId}; its backup ${pendingReceipt.backupFile} stays on disk.`
1098
+ : "",
1099
+ "Run /reload or restart Pi for the change to take effect.",
1100
+ "",
1101
+ "Apply this change?",
1102
+ ].filter((line, index, lines) => line !== "" || lines[index - 1] !== "").join("\n"));
1103
+ if (!confirmed) {
1104
+ cmdCtx.ui.notify("No changes were made. Canceled by user.", "info");
1105
+ return;
1106
+ }
1107
+
1108
+ const receipt = createPromptCacheFixReceipt(
1109
+ current.text, plan.modifiedText, model.provider, model.id,
1110
+ plan.placement, plan.promptCache, plan.targetExistedBefore, backupPath,
1111
+ );
1112
+ if (!receipt) {
1113
+ cmdCtx.ui.notify("❌ Could not create a fix receipt for rollback. No changes were made.", "error");
1114
+ return;
1115
+ }
1116
+ try {
1117
+ const result = await applyModelsJsonFixTransaction(
1118
+ plan.modifiedText,
1119
+ backupPath,
1120
+ (writtenText) => validateWrittenPromptCacheFix(writtenText, plan),
1121
+ {
1122
+ expectedCurrentHash: hashText(current.text),
1123
+ expectedCurrentMode: current.mode,
1124
+ purpose: "fix",
1125
+ onCommitted: async () => writeModelsJsonFixReceipt(receipt),
1126
+ },
1127
+ );
1128
+ if ("postCheckError" in result) {
1129
+ cmdCtx.ui.notify(`❌ Post-write self-check failed: ${result.postCheckError}. Backup restored.`, "error");
1130
+ return;
1131
+ }
1132
+ invalidateModelsConfigCache();
1133
+ cmdCtx.ui.notify(
1134
+ `✅ promptCache ${JSON.stringify(plan.promptCache)} added for ${modelKey(model)}.\n` +
1135
+ `Backup saved to: ${backupPath}\n` +
1136
+ "Run /reload or restart Pi, then /cache-optimizer doctor to confirm warming is eligible.",
1137
+ "info",
1138
+ );
1139
+ } catch (error) {
1140
+ cmdCtx.ui.notify(`❌ Write failed: ${error instanceof Error ? error.message : String(error)}. No changes were kept.`, "error");
1141
+ }
1142
+ }
package/src/config.ts CHANGED
@@ -15,6 +15,8 @@ export const NO_OPENAI_CACHE_KEY_ENV = "PI_CACHE_OPTIMIZER_NO_OPENAI_CACHE_KEY";
15
15
 
16
16
  export const TOOL_ORDER_ENV = "PI_CACHE_OPTIMIZER_TOOL_ORDER";
17
17
 
18
+ export const NO_ZERO_PRICE_WARMING_ENV = "PI_CACHE_OPTIMIZER_NO_ZERO_PRICE_WARMING";
19
+
18
20
  export const FOOTER_MODE_ENV = "PI_CACHE_OPTIMIZER_FOOTER_MODE";
19
21
 
20
22
  export type FooterStatsMode = "session" | "total" | "process";
@@ -26,7 +28,7 @@ export type PersistedCacheOptimizerConfigV1 = {
26
28
  footerMode?: FooterStatsMode;
27
29
  };
28
30
 
29
- export type PersistedCacheOptimizerFeature = "promptRewrite" | "virtualRewrite" | "skillCompression" | "openAICacheKey" | "toolOrder";
31
+ export type PersistedCacheOptimizerFeature = "promptRewrite" | "virtualRewrite" | "skillCompression" | "openAICacheKey" | "toolOrder" | "zeroPriceWarming";
30
32
 
31
33
  export type PersistedCacheOptimizerConfigV2 = {
32
34
  version: 2;
@@ -66,12 +68,17 @@ export function featureEnabled(
66
68
  if (feature === "openAICacheKey") {
67
69
  return !isEnabledEnv(env[envName]) && !isDisabledEnv(env[OPENAI_CACHE_KEY_ENV]);
68
70
  }
69
- if (feature === "promptRewrite" || feature === "skillCompression") {
71
+ if (feature === "promptRewrite" || feature === "skillCompression" || feature === "zeroPriceWarming") {
70
72
  return !isEnabledEnv(env[envName]);
71
73
  }
72
74
  return isEnabledEnv(env[envName]) || defaultValue;
73
75
  }
74
76
 
77
+ /** Keep Pi's cache warming running for models whose prices are unknown (all zero). */
78
+ export function isZeroPriceWarmingEnabled(): boolean {
79
+ return runtimeOptimizerEnabled && featureEnabled("zeroPriceWarming", NO_ZERO_PRICE_WARMING_ENV, true);
80
+ }
81
+
75
82
  export function isToolOrderEnabled(env: MutableEnv = process.env): boolean {
76
83
  return runtimeOptimizerEnabled && featureEnabled("toolOrder", TOOL_ORDER_ENV, false);
77
84
  }
@@ -94,7 +101,7 @@ export function parsePersistedCacheOptimizerConfig(value: unknown): PersistedCac
94
101
  const rawFeatures = record.version === 3 ? record.features : undefined;
95
102
  if (rawFeatures !== undefined && !asRecord(rawFeatures)) return undefined;
96
103
  const featuresRecord = asRecord(rawFeatures);
97
- const featureNames = new Set<PersistedCacheOptimizerFeature>(["promptRewrite", "virtualRewrite", "skillCompression", "openAICacheKey", "toolOrder"]);
104
+ const featureNames = new Set<PersistedCacheOptimizerFeature>(["promptRewrite", "virtualRewrite", "skillCompression", "openAICacheKey", "toolOrder", "zeroPriceWarming"]);
98
105
  if (featuresRecord && Object.keys(featuresRecord).some((key) => !featureNames.has(key as PersistedCacheOptimizerFeature) || typeof featuresRecord[key] !== "boolean")) return undefined;
99
106
 
100
107
  const rawPromptCacheKey = record.promptCacheKey;
@@ -354,6 +361,7 @@ export function formatPersistentFeatureConfig(): string {
354
361
  ["Skill compression", "skillCompression", NO_SKILL_COMPRESSION_ENV, true],
355
362
  ["OpenAI cache key", "openAICacheKey", NO_OPENAI_CACHE_KEY_ENV, true],
356
363
  ["Tool ordering", "toolOrder", TOOL_ORDER_ENV, false],
364
+ ["Zero-price cache warming", "zeroPriceWarming", NO_ZERO_PRICE_WARMING_ENV, true],
357
365
  ];
358
366
  for (const [label, feature, envName, defaultValue] of entries) {
359
367
  const hasEnv = process.env[envName] !== undefined || (feature === "openAICacheKey" && process.env[OPENAI_CACHE_KEY_ENV] !== undefined);
package/src/fix-types.ts CHANGED
@@ -13,8 +13,33 @@ export type FixReceiptCompatChange = {
13
13
  after: ReceiptScalarState;
14
14
  };
15
15
 
16
+ /** Pi model-level prompt-cache lifetimes in seconds (`promptCache`). */
17
+ export type PromptCacheLifetimes = { short?: number; long?: number };
18
+
19
+ export function isValidPromptCacheLifetimes(value: unknown): value is PromptCacheLifetimes {
20
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
21
+ const record = value as Record<string, unknown>;
22
+ const keys = Object.keys(record);
23
+ if (keys.length === 0 || keys.some((key) => key !== "short" && key !== "long")) return false;
24
+ return keys.every((key) => {
25
+ const seconds = record[key];
26
+ return typeof seconds === "number" && Number.isSafeInteger(seconds) && seconds > 0;
27
+ });
28
+ }
29
+
30
+ export function samePromptCacheLifetimes(left: unknown, right: unknown): boolean {
31
+ if (!isValidPromptCacheLifetimes(left) || !isValidPromptCacheLifetimes(right)) return false;
32
+ return left.short === right.short && left.long === right.long;
33
+ }
34
+
35
+ /**
36
+ * Version 1 receipts record scalar compat changes only. Version 2 receipts
37
+ * also record a `promptCache` object the fix added (it never overwrites one),
38
+ * so rollback can remove exactly that property. Older extension versions
39
+ * reject version 2 receipts instead of misreading them.
40
+ */
16
41
  export type ModelsJsonFixReceiptV1 = {
17
- version: 1;
42
+ version: 1 | 2;
18
43
  kind: "pi-cache-optimizer-fix-receipt";
19
44
  transactionId: string;
20
45
  provider: string;
@@ -22,6 +47,8 @@ export type ModelsJsonFixReceiptV1 = {
22
47
  placement: FixReceiptPlacement;
23
48
  targetExistedBefore: boolean;
24
49
  changedKeys: Record<string, FixReceiptCompatChange>;
50
+ /** Version 2 only: the promptCache object added at the target (absent before). */
51
+ promptCacheAdded?: PromptCacheLifetimes;
25
52
  beforeHash: string;
26
53
  afterHash: string;
27
54
  backupFile: string;
@@ -5,7 +5,7 @@ import { basename, dirname, join } from "node:path";
5
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
- import { type FixReceiptCompatChange, type FixReceiptPlacement, type ModelsJsonFixReceiptV1, RECEIPT_COMPAT_KEYS, type ReceiptScalar, type ReceiptScalarState, isReceiptTimestamp, isSafeReceiptText, isSha256 } from "./fix-types.ts";
8
+ import { type FixReceiptCompatChange, type FixReceiptPlacement, type ModelsJsonFixReceiptV1, type PromptCacheLifetimes, RECEIPT_COMPAT_KEYS, type ReceiptScalar, type ReceiptScalarState, isReceiptTimestamp, isValidPromptCacheLifetimes, samePromptCacheLifetimes, isSafeReceiptText, isSha256 } from "./fix-types.ts";
9
9
  import { type JsonPropertyEdit, type ModelNodeLocation, deepEqualIgnoringKeys, deriveInnerIndent, findExistingCompatKeysInJsonc, findJsonObjectKey, findMatchingBracket, isJsonWhitespace, lineIndentOf, locateJsonPropertyValueSpan, locateModelOverrideInJsonc, locateProviderCompatInJsonc, parseJsonc, readJsonStringLiteral, skipJsonValue, skipJsonWhitespace, stripJsoncComments } from "./jsonc.ts";
10
10
  import { isAdaptiveGenerationModel, isKimiCodingAdaptiveModel } from "./model-detect.ts";
11
11
  import { FIX_RECEIPT_PATH, MODELS_JSON_PATH } from "./paths.ts";
@@ -1528,10 +1528,10 @@ export function createRollbackBackupPath(modelsPath: string = MODELS_JSON_PATH):
1528
1528
 
1529
1529
  export function parseModelsJsonFixReceipt(value: unknown): ModelsJsonFixReceiptV1 | undefined {
1530
1530
  const record = asRecord(value);
1531
- if (!record || record.version !== 1 || record.kind !== "pi-cache-optimizer-fix-receipt") return undefined;
1531
+ if (!record || (record.version !== 1 && record.version !== 2) || record.kind !== "pi-cache-optimizer-fix-receipt") return undefined;
1532
1532
  const allowedKeys = new Set([
1533
1533
  "version", "kind", "transactionId", "provider", "modelId", "placement",
1534
- "targetExistedBefore", "changedKeys", "beforeHash", "afterHash", "backupFile",
1534
+ "targetExistedBefore", "changedKeys", "promptCacheAdded", "beforeHash", "afterHash", "backupFile",
1535
1535
  "createdAt", "appliedAt", "status", "rolledBackAt",
1536
1536
  ]);
1537
1537
  if (Object.keys(record).some((key) => !allowedKeys.has(key))) return undefined;
@@ -1544,8 +1544,13 @@ export function parseModelsJsonFixReceipt(value: unknown): ModelsJsonFixReceiptV
1544
1544
  const afterHash = typeof record.afterHash === "string" ? record.afterHash.toLowerCase() : "";
1545
1545
  if (beforeHash === afterHash) return undefined;
1546
1546
  if (!isReceiptTimestamp(record.createdAt) || !isReceiptTimestamp(record.appliedAt) || record.appliedAt < record.createdAt) return undefined;
1547
+ // promptCacheAdded is the version 2 marker: present exactly on v2 receipts,
1548
+ // only for model-scoped targets, and always a valid lifetime object.
1549
+ const isV2 = record.version === 2;
1550
+ if (isV2 !== (record.promptCacheAdded !== undefined)) return undefined;
1551
+ if (isV2 && (!isValidPromptCacheLifetimes(record.promptCacheAdded) || record.placement === "provider")) return undefined;
1547
1552
  const changedKeys = asRecord(record.changedKeys);
1548
- if (!changedKeys || Object.keys(changedKeys).length === 0) return undefined;
1553
+ if (!changedKeys || (Object.keys(changedKeys).length === 0 && !isV2)) return undefined;
1549
1554
 
1550
1555
  const parsedChanges: Record<string, FixReceiptCompatChange> = {};
1551
1556
  for (const [key, rawChange] of Object.entries(changedKeys)) {
@@ -1563,8 +1568,9 @@ export function parseModelsJsonFixReceipt(value: unknown): ModelsJsonFixReceiptV
1563
1568
  if (record.status === "rolled_back" && (!isReceiptTimestamp(record.rolledBackAt) || record.rolledBackAt < record.appliedAt)) return undefined;
1564
1569
  if (record.status === undefined && record.rolledBackAt !== undefined) return undefined;
1565
1570
 
1571
+ const promptCacheAdded = isV2 ? record.promptCacheAdded as PromptCacheLifetimes : undefined;
1566
1572
  return {
1567
- version: 1,
1573
+ version: isV2 ? 2 : 1,
1568
1574
  kind: "pi-cache-optimizer-fix-receipt",
1569
1575
  transactionId: record.transactionId.trim(),
1570
1576
  provider: record.provider.trim(),
@@ -1572,6 +1578,7 @@ export function parseModelsJsonFixReceipt(value: unknown): ModelsJsonFixReceiptV
1572
1578
  placement: record.placement,
1573
1579
  targetExistedBefore: record.targetExistedBefore,
1574
1580
  changedKeys: parsedChanges,
1581
+ ...(promptCacheAdded ? { promptCacheAdded: { ...(promptCacheAdded.short !== undefined ? { short: promptCacheAdded.short } : {}), ...(promptCacheAdded.long !== undefined ? { long: promptCacheAdded.long } : {}) } } : {}),
1575
1582
  beforeHash,
1576
1583
  afterHash,
1577
1584
  backupFile: record.backupFile,
@@ -1701,6 +1708,9 @@ export type ReceiptCompatTarget = {
1701
1708
  targetExists: boolean;
1702
1709
  compatBrace: number;
1703
1710
  compatEnd: number;
1711
+ /** The model object or modelOverrides entry (-1 when absent or provider-level). */
1712
+ objectBrace: number;
1713
+ objectEnd: number;
1704
1714
  };
1705
1715
 
1706
1716
  export function locateReceiptCompatTarget(
@@ -1722,6 +1732,8 @@ export function locateReceiptCompatTarget(
1722
1732
  targetExists: location.modelOverrideObjectBrace >= 0,
1723
1733
  compatBrace: location.modelOverrideCompatBrace,
1724
1734
  compatEnd: location.modelOverrideCompatEnd,
1735
+ objectBrace: location.modelOverrideObjectBrace,
1736
+ objectEnd: location.modelOverrideObjectEnd,
1725
1737
  };
1726
1738
  }
1727
1739
 
@@ -1732,6 +1744,8 @@ export function locateReceiptCompatTarget(
1732
1744
  targetExists: true,
1733
1745
  compatBrace: location.providerCompatBrace,
1734
1746
  compatEnd: location.providerCompatEnd,
1747
+ objectBrace: -1,
1748
+ objectEnd: -1,
1735
1749
  };
1736
1750
  }
1737
1751
  const location = locateModelInJsonc(text, provider, modelId);
@@ -1746,6 +1760,8 @@ export function locateReceiptCompatTarget(
1746
1760
  targetExists: location.modelObjectBrace >= 0,
1747
1761
  compatBrace: location.compatObjectBrace,
1748
1762
  compatEnd: location.compatObjectEnd,
1763
+ objectBrace: location.modelObjectBrace,
1764
+ objectEnd: location.modelObjectEnd,
1749
1765
  };
1750
1766
  }
1751
1767
 
@@ -1772,6 +1788,72 @@ export function readReceiptCompatScalarState(
1772
1788
  }
1773
1789
  }
1774
1790
 
1791
+ /**
1792
+ * Read the direct `promptCache` value of a receipt target object: the parsed
1793
+ * value, `undefined` when absent, or "invalid" when duplicated or unreadable.
1794
+ */
1795
+ export function readReceiptPromptCacheState(
1796
+ text: string,
1797
+ target: ReceiptCompatTarget | undefined,
1798
+ ): unknown {
1799
+ if (!target || target.objectBrace < 0 || target.objectEnd <= target.objectBrace) return undefined;
1800
+ const clean = stripJsoncComments(text);
1801
+ const property = findJsonObjectKey(clean, target.objectBrace, "promptCache");
1802
+ if (!property || property.keyStart >= target.objectEnd) return undefined;
1803
+ if (property.count !== 1) return "invalid";
1804
+ const valueStart = skipJsonWhitespace(clean, property.valueStart);
1805
+ const valueEnd = skipJsonValue(clean, valueStart);
1806
+ if (valueEnd === undefined || valueEnd > target.objectEnd) return "invalid";
1807
+ try {
1808
+ return parseJsonc(text.slice(valueStart, valueEnd));
1809
+ } catch {
1810
+ return "invalid";
1811
+ }
1812
+ }
1813
+
1814
+ /**
1815
+ * Receipt for `/cache-optimizer fix prompt-cache`: a version 2 receipt with no
1816
+ * compat changes and the promptCache object the fix added.
1817
+ */
1818
+ export function createPromptCacheFixReceipt(
1819
+ originalText: string,
1820
+ modifiedText: string,
1821
+ provider: string,
1822
+ modelId: string,
1823
+ placement: "model" | "modelOverride",
1824
+ promptCache: PromptCacheLifetimes,
1825
+ targetExistedBefore: boolean,
1826
+ backupPath: string,
1827
+ now: number = Date.now(),
1828
+ ): ModelsJsonFixReceiptV1 | undefined {
1829
+ const backupFile = basename(backupPath);
1830
+ if (!/^models\.json\.backup-cache-optimizer-/.test(backupFile)) return undefined;
1831
+ if (!isValidPromptCacheLifetimes(promptCache)) return undefined;
1832
+ const beforeTarget = locateReceiptCompatTarget(originalText, provider, modelId, placement);
1833
+ const afterTarget = locateReceiptCompatTarget(modifiedText, provider, modelId, placement);
1834
+ if (targetExistedBefore && !beforeTarget?.targetExists) return undefined;
1835
+ if (!targetExistedBefore && beforeTarget?.targetExists) return undefined;
1836
+ if (!afterTarget?.targetExists) return undefined;
1837
+ if (targetExistedBefore && readReceiptPromptCacheState(originalText, beforeTarget) !== undefined) return undefined;
1838
+ if (!samePromptCacheLifetimes(readReceiptPromptCacheState(modifiedText, afterTarget), promptCache)) return undefined;
1839
+ return parseModelsJsonFixReceipt({
1840
+ version: 2,
1841
+ kind: "pi-cache-optimizer-fix-receipt",
1842
+ transactionId: randomUUID(),
1843
+ provider,
1844
+ modelId,
1845
+ placement,
1846
+ targetExistedBefore,
1847
+ changedKeys: {},
1848
+ promptCacheAdded: promptCache,
1849
+ beforeHash: hashText(originalText),
1850
+ afterHash: hashText(modifiedText),
1851
+ backupFile,
1852
+ createdAt: now,
1853
+ appliedAt: now,
1854
+ });
1855
+ }
1856
+
1775
1857
  export function createModelsJsonFixReceipt(
1776
1858
  originalText: string,
1777
1859
  modifiedText: string,
@@ -1830,11 +1912,22 @@ export function composeModelsJsonReceiptRollback(
1830
1912
  if (!target?.targetExists) {
1831
1913
  return { error: "the original target entry is missing or no longer safely locatable" };
1832
1914
  }
1833
- if (target.compatBrace < 0 || target.compatEnd <= target.compatBrace) {
1915
+ const hasCompatChanges = Object.keys(receipt.changedKeys).length > 0;
1916
+ if (hasCompatChanges && (target.compatBrace < 0 || target.compatEnd <= target.compatBrace)) {
1834
1917
  return { error: "the target compat object is missing or no longer safely locatable" };
1835
1918
  }
1836
1919
 
1837
1920
  const edits: JsonPropertyEdit[] = [];
1921
+ if (receipt.promptCacheAdded) {
1922
+ const current = readReceiptPromptCacheState(currentText, target);
1923
+ if (current === "invalid") return { error: "receipt-owned promptCache is no longer safely locatable" };
1924
+ if (current === undefined || !samePromptCacheLifetimes(current, receipt.promptCacheAdded)) {
1925
+ return { error: "receipt-owned promptCache was changed after the fix; refusing to overwrite it" };
1926
+ }
1927
+ const property = locateJsonPropertyValueSpan(currentText, target.objectBrace, target.objectEnd, "promptCache");
1928
+ if (!property) return { error: "receipt-owned promptCache is no longer safely locatable" };
1929
+ edits.push(property.removal);
1930
+ }
1838
1931
  for (const [key, change] of Object.entries(receipt.changedKeys)) {
1839
1932
  const current = readReceiptCompatScalarState(currentText, target, key);
1840
1933
  if (!current || !sameReceiptScalarState(current, change.after)) {
@@ -1882,6 +1975,9 @@ export function validateModelsJsonRollback(
1882
1975
  const state = readReceiptCompatScalarState(writtenText, target, key);
1883
1976
  if (!state || !sameReceiptScalarState(state, change.before)) return `rollback result did not restore compat.${key}`;
1884
1977
  }
1978
+ if (receipt.promptCacheAdded && readReceiptPromptCacheState(writtenText, target) !== undefined) {
1979
+ return "rollback result did not remove promptCache";
1980
+ }
1885
1981
  return null;
1886
1982
  }
1887
1983
 
@@ -0,0 +1,285 @@
1
+ import { type PiModel, asRecord } from "./common.ts";
2
+ import { deriveInnerIndent, findJsonObjectKey, findMatchingBracket, isJsonWhitespace, lineIndentOf, locateModelOverrideInJsonc, parseJsonc, skipJsonWhitespace, stripJsoncComments } from "./jsonc.ts";
3
+ import { isValidModelsConfigForEffectiveCompat } from "./model-identity.ts";
4
+ import { locateModelInJsonc } from "./models-json-fix.ts";
5
+ import { suggestedPromptCacheLifetimes } from "./cache-warming.ts";
6
+ import { type PromptCacheLifetimes, isValidPromptCacheLifetimes, samePromptCacheLifetimes } from "./fix-types.ts";
7
+
8
+ /**
9
+ * `/cache-optimizer fix prompt-cache`: write Pi's model-level `promptCache`
10
+ * lifetimes into models.json so native cache warming can run.
11
+ *
12
+ * Only lifetimes this extension can stand behind are written (the same values
13
+ * Pi ships for its built-in Anthropic models): `{ short: 300, long: 3600 }` on
14
+ * the Anthropic Messages API, and `{ short: 300 }` elsewhere. When the
15
+ * retention tier in use has no documented lifetime, the fix refuses instead of
16
+ * guessing. The edit is a single new `promptCache` property; an existing
17
+ * `promptCache` object is never merged or overwritten.
18
+ */
19
+
20
+ export type { PromptCacheLifetimes };
21
+ export { isValidPromptCacheLifetimes, samePromptCacheLifetimes };
22
+
23
+ export type PromptCachePlacement = "model" | "modelOverride";
24
+
25
+ export type PromptCacheFixPlan = {
26
+ modifiedText: string;
27
+ placement: PromptCachePlacement;
28
+ /** Whether the model object or modelOverrides entry existed before the fix. */
29
+ targetExistedBefore: boolean;
30
+ promptCache: PromptCacheLifetimes;
31
+ locationLabel: string;
32
+ };
33
+
34
+ /**
35
+ * Lifetimes to write for the active retention tier, or the reason none can be
36
+ * written. `tier` is "long" when the extension requests 1h retention.
37
+ */
38
+ export function choosePromptCacheLifetimes(
39
+ model: PiModel,
40
+ tier: "short" | "long",
41
+ ): { promptCache: PromptCacheLifetimes } | { error: string } {
42
+ const suggested = suggestedPromptCacheLifetimes(model);
43
+ if (tier === "long" && suggested.long === undefined) {
44
+ return {
45
+ error: `No documented long-retention lifetime is known for the ${model.api ?? "unknown"} API, so the fix will not guess one. ` +
46
+ "Add promptCache.long manually from your provider's documentation (use the conservative end of any range).",
47
+ };
48
+ }
49
+ // Pi merges override lifetimes over the model's own, so write only the tiers
50
+ // the model does not declare yet; a built-in short lifetime stays as is.
51
+ const declared = asRecord(asRecord(model as unknown)?.promptCache);
52
+ const isDeclared = (key: "short" | "long"): boolean => typeof declared?.[key] === "number" && (declared[key] as number) > 0;
53
+ const promptCache: PromptCacheLifetimes = {};
54
+ if (!isDeclared("short")) promptCache.short = suggested.short;
55
+ if (suggested.long !== undefined && !isDeclared("long")) promptCache.long = suggested.long;
56
+ if (Object.keys(promptCache).length === 0) return { error: "The model already declares every lifetime the fix would write." };
57
+ return { promptCache };
58
+ }
59
+
60
+ /** Pretty-print `value` so its continuation lines sit at `indent`. */
61
+ function formatJsonValue(value: unknown, indent: string, unit: string, eol: string): string {
62
+ const raw = JSON.stringify(value, null, unit);
63
+ return raw.split("\n").map((line, index) => (index === 0 ? line : indent + line)).join(eol);
64
+ }
65
+
66
+ /**
67
+ * Insert `"key": value` as the last direct member of the object spanning
68
+ * `brace`..`end` (offsets valid in both `text` and its comment-stripped form).
69
+ * Comments and formatting elsewhere are untouched.
70
+ */
71
+ export function insertJsonProperty(text: string, brace: number, end: number, key: string, value: unknown): string {
72
+ const clean = stripJsoncComments(text);
73
+ if (clean[brace] !== "{" || clean[end] !== "}") throw new Error("invalid object span");
74
+ const outerIndent = lineIndentOf(text, brace);
75
+ const innerIndent = deriveInnerIndent(text, brace, end);
76
+ const unit = innerIndent.startsWith(outerIndent) && innerIndent.length > outerIndent.length
77
+ ? innerIndent.slice(outerIndent.length)
78
+ : " ";
79
+ const eol = text.includes("\r\n") ? "\r\n" : "\n";
80
+ const property = `${JSON.stringify(key)}: ${formatJsonValue(value, innerIndent, unit, eol)}`;
81
+
82
+ let last = end - 1;
83
+ while (last > brace && isJsonWhitespace(clean[last])) last--;
84
+ if (last === brace) {
85
+ // Empty object. Keep any comment inside it, and put the closing brace on
86
+ // its own line when the object held only whitespace.
87
+ if (text.slice(brace + 1, end).trim() === "") {
88
+ return `${text.slice(0, brace + 1)}${eol}${innerIndent}${property}${eol}${outerIndent}${text.slice(end)}`;
89
+ }
90
+ return `${text.slice(0, brace + 1)}${eol}${innerIndent}${property}${text.slice(brace + 1)}`;
91
+ }
92
+
93
+ const needsComma = clean[last] !== ",";
94
+ // A trailing line comment on the last member stays on that member's line.
95
+ let insertAt = last + 1;
96
+ // Only a line comment qualifies: a block comment can span the newline, and
97
+ // inserting there would put the property inside the comment.
98
+ let newline = text.indexOf("\n", last + 1);
99
+ if (newline > 0 && text[newline - 1] === "\r") newline--;
100
+ if (newline >= 0 && newline < end && clean.slice(last + 1, newline).trim() === "" && !text.slice(last + 1, newline).includes("/*")) {
101
+ insertAt = newline;
102
+ }
103
+ const insertion = `${eol}${innerIndent}${property}`;
104
+ if (!needsComma) return text.slice(0, insertAt) + insertion + text.slice(insertAt);
105
+ return text.slice(0, last + 1) + "," + text.slice(last + 1, insertAt) + insertion + text.slice(insertAt);
106
+ }
107
+
108
+ function objectSpanForKey(clean: string, parentBrace: number, parentEnd: number, key: string): { brace: number; end: number } | "absent" | "ambiguous" {
109
+ const property = findJsonObjectKey(clean, parentBrace, key);
110
+ if (!property || property.keyStart >= parentEnd) return "absent";
111
+ if (property.count !== 1) return "ambiguous";
112
+ const brace = skipJsonWhitespace(clean, property.valueStart);
113
+ if (clean[brace] !== "{") return "ambiguous";
114
+ const end = findMatchingBracket(clean, brace);
115
+ if (end === undefined || end > parentEnd) return "ambiguous";
116
+ return { brace, end };
117
+ }
118
+
119
+ function hasDirectKey(clean: string, brace: number, end: number, key: string): boolean {
120
+ const property = findJsonObjectKey(clean, brace, key);
121
+ return property !== undefined && property.keyStart < end;
122
+ }
123
+
124
+ /** Read the promptCache value at a receipt target, `undefined` when absent. */
125
+ export function readTargetPromptCache(parsed: unknown, provider: string, modelId: string, placement: PromptCachePlacement): unknown {
126
+ const providerRecord = asRecord(asRecord(asRecord(parsed)?.providers)?.[provider]);
127
+ if (!providerRecord) return undefined;
128
+ if (placement === "modelOverride") {
129
+ return asRecord(asRecord(providerRecord.modelOverrides)?.[modelId])?.promptCache;
130
+ }
131
+ const models = Array.isArray(providerRecord.models) ? providerRecord.models : [];
132
+ const matches = models.filter((entry) => asRecord(entry)?.id === modelId);
133
+ return asRecord(matches[matches.length - 1])?.promptCache;
134
+ }
135
+
136
+ /**
137
+ * Remove exactly what the plan added from a parsed copy of the modified file:
138
+ * the promptCache property, plus any modelOverrides entry or object the plan
139
+ * created. The result must equal the parsed original.
140
+ */
141
+ function revertPlanInParsed(parsed: unknown, provider: string, modelId: string, plan: Pick<PromptCacheFixPlan, "placement" | "targetExistedBefore">, createdOverrides: boolean): unknown {
142
+ const clone = JSON.parse(JSON.stringify(parsed)) as Record<string, unknown>;
143
+ const providerRecord = asRecord(asRecord(clone.providers)?.[provider]);
144
+ if (!providerRecord) return clone;
145
+ if (plan.placement === "model") {
146
+ const models = Array.isArray(providerRecord.models) ? providerRecord.models : [];
147
+ const matches = models.filter((entry) => asRecord(entry)?.id === modelId);
148
+ const target = asRecord(matches[matches.length - 1]);
149
+ if (target) delete target.promptCache;
150
+ return clone;
151
+ }
152
+ const overrides = asRecord(providerRecord.modelOverrides);
153
+ if (!overrides) return clone;
154
+ if (!plan.targetExistedBefore) {
155
+ delete overrides[modelId];
156
+ if (createdOverrides) delete providerRecord.modelOverrides;
157
+ } else {
158
+ const entry = asRecord(overrides[modelId]);
159
+ if (entry) delete entry.promptCache;
160
+ }
161
+ return clone;
162
+ }
163
+
164
+ /**
165
+ * Plan the models.json edit. Custom models defined under `models[]` get the
166
+ * property on their own object; other models (built-in or proxied) get it on
167
+ * `providers[provider].modelOverrides[modelId]`. A provider with no
168
+ * models.json entry is refused, because creating one could change how Pi
169
+ * loads that provider.
170
+ */
171
+ export function planPromptCacheFix(
172
+ originalText: string,
173
+ provider: string,
174
+ modelId: string,
175
+ promptCache: PromptCacheLifetimes,
176
+ ): PromptCacheFixPlan | { error: string } {
177
+ let parsedOriginal: unknown;
178
+ try {
179
+ parsedOriginal = parseJsonc(originalText);
180
+ } catch {
181
+ return { error: "models.json is not valid JSONC" };
182
+ }
183
+ if (!isValidModelsConfigForEffectiveCompat(parsedOriginal)) {
184
+ return { error: "models.json does not match Pi's model configuration schema" };
185
+ }
186
+ const clean = stripJsoncComments(originalText);
187
+
188
+ let plan: PromptCacheFixPlan | undefined;
189
+ let createdOverrides = false;
190
+ const modelLocation = locateModelInJsonc(originalText, provider, modelId);
191
+ if (modelLocation && modelLocation.modelObjectBrace >= 0) {
192
+ if (modelLocation.providerKeyCount !== 1 || modelLocation.allModelIds.filter((id) => id === modelId).length !== 1) {
193
+ return { error: `models.json defines ${provider}/${modelId} more than once` };
194
+ }
195
+ if (hasDirectKey(clean, modelLocation.modelObjectBrace, modelLocation.modelObjectEnd, "promptCache")) {
196
+ return { error: "the model entry already has a promptCache object; edit it manually" };
197
+ }
198
+ plan = {
199
+ modifiedText: insertJsonProperty(originalText, modelLocation.modelObjectBrace, modelLocation.modelObjectEnd, "promptCache", promptCache),
200
+ placement: "model",
201
+ targetExistedBefore: true,
202
+ promptCache,
203
+ locationLabel: `providers[${JSON.stringify(provider)}].models[id=${JSON.stringify(modelId)}].promptCache`,
204
+ };
205
+ } else {
206
+ const overrideLocation = locateModelOverrideInJsonc(originalText, provider, modelId);
207
+ if (!overrideLocation) {
208
+ return { error: `models.json has no single "${provider}" provider entry to hold a modelOverrides entry` };
209
+ }
210
+ if (overrideLocation.providerKeyCount !== 1 || overrideLocation.modelOverridesKeyCount > 1 || overrideLocation.modelOverrideKeyCount > 1) {
211
+ return { error: "the provider's modelOverrides entry is ambiguous (duplicate keys)" };
212
+ }
213
+ const locationLabel = `providers[${JSON.stringify(provider)}].modelOverrides[${JSON.stringify(modelId)}].promptCache`;
214
+ if (overrideLocation.modelOverrideObjectBrace >= 0) {
215
+ if (hasDirectKey(clean, overrideLocation.modelOverrideObjectBrace, overrideLocation.modelOverrideObjectEnd, "promptCache")) {
216
+ return { error: "the modelOverrides entry already has a promptCache object; edit it manually" };
217
+ }
218
+ plan = {
219
+ modifiedText: insertJsonProperty(originalText, overrideLocation.modelOverrideObjectBrace, overrideLocation.modelOverrideObjectEnd, "promptCache", promptCache),
220
+ placement: "modelOverride",
221
+ targetExistedBefore: true,
222
+ promptCache,
223
+ locationLabel,
224
+ };
225
+ } else if (overrideLocation.modelOverridesObjectBrace >= 0) {
226
+ plan = {
227
+ modifiedText: insertJsonProperty(originalText, overrideLocation.modelOverridesObjectBrace, overrideLocation.modelOverridesObjectEnd, modelId, { promptCache }),
228
+ placement: "modelOverride",
229
+ targetExistedBefore: false,
230
+ promptCache,
231
+ locationLabel,
232
+ };
233
+ } else {
234
+ const overrides = objectSpanForKey(clean, overrideLocation.providerObjectBrace, overrideLocation.providerObjectEnd, "modelOverrides");
235
+ if (overrides !== "absent") return { error: "the provider's modelOverrides value is not a single object" };
236
+ createdOverrides = true;
237
+ plan = {
238
+ modifiedText: insertJsonProperty(originalText, overrideLocation.providerObjectBrace, overrideLocation.providerObjectEnd, "modelOverrides", { [modelId]: { promptCache } }),
239
+ placement: "modelOverride",
240
+ targetExistedBefore: false,
241
+ promptCache,
242
+ locationLabel,
243
+ };
244
+ }
245
+ }
246
+
247
+ const checkError = selfCheckPromptCacheFix(originalText, plan, provider, modelId, createdOverrides);
248
+ if (checkError) return { error: `self-check failed: ${checkError}` };
249
+ return plan;
250
+ }
251
+
252
+ export function selfCheckPromptCacheFix(
253
+ originalText: string,
254
+ plan: PromptCacheFixPlan,
255
+ provider: string,
256
+ modelId: string,
257
+ createdOverrides: boolean,
258
+ ): string | null {
259
+ let original: unknown;
260
+ let modified: unknown;
261
+ try {
262
+ original = parseJsonc(originalText);
263
+ modified = parseJsonc(plan.modifiedText);
264
+ } catch {
265
+ return "the edited file is not valid JSONC";
266
+ }
267
+ if (!isValidModelsConfigForEffectiveCompat(modified)) return "the edited file does not match Pi's model configuration schema";
268
+ if (!samePromptCacheLifetimes(readTargetPromptCache(modified, provider, modelId, plan.placement), plan.promptCache)) {
269
+ return "promptCache was not written at the target";
270
+ }
271
+ const reverted = revertPlanInParsed(modified, provider, modelId, plan, createdOverrides);
272
+ if (JSON.stringify(reverted) !== JSON.stringify(original)) return "unrelated configuration was altered";
273
+ return null;
274
+ }
275
+
276
+ /** Validate a file written by the fix transaction (same checks, re-read from disk). */
277
+ export function validateWrittenPromptCacheFix(writtenText: string, plan: PromptCacheFixPlan): string | null {
278
+ if (writtenText !== plan.modifiedText) return "models.json content differs from the previewed edit";
279
+ try {
280
+ parseJsonc(writtenText);
281
+ } catch {
282
+ return "the written file is not valid JSONC";
283
+ }
284
+ return null;
285
+ }