pi-cache-optimizer 2.8.21 → 2.8.23

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
@@ -21,6 +21,7 @@ Pi extension for improving provider-side KV / prompt cache hit rates. It keeps s
21
21
  - [Opt-in deterministic tool ordering](#opt-in-deterministic-tool-ordering)
22
22
  - [Footer cache stats mode](#footer-cache-stats-mode)
23
23
  - [OpenAI-compatible proxy setup](#openai-compatible-proxy-setup)
24
+ - [Prompt cache warming (Pi 0.86+)](#prompt-cache-warming-pi-086)
24
25
  - [Adaptive thinking models](#adaptive-thinking-models)
25
26
  - [Auto-repair with `/cache-optimizer fix`](#auto-repair-with-cache-optimizer-fix)
26
27
  - [DeepSeek protocol safety and rollback](#deepseek-protocol-safety-and-rollback)
@@ -37,7 +38,7 @@ Pi extension for improving provider-side KV / prompt cache hit rates. It keeps s
37
38
  - Requests long cache retention when Pi/provider compat supports it.
38
39
  - Adds a conservative per-session `prompt_cache_key` fallback for `openai-completions` proxy payloads when no effective key exists. The values this extension adds itself (that key fallback, the session-affinity header bridge, and cache hints) are a one-way hash of the Pi session id (`pi-<32 hex>`); keys and headers that Pi core sets on its own are left untouched, and an existing key is never overwritten. Pi 1.0+ owns this field for Responses/Codex transports.
39
40
  - Warns once for third-party OpenAI-compatible proxies missing cache/session-affinity compat flags.
40
- - Detects adaptive-thinking compat for Claude (opus-4.6+ including Opus 5, sonnet-4.6+ including Sonnet 5, fable-5+) and Kimi Coding K3 / `kimi-for-coding` custom channels.
41
+ - Detects adaptive-thinking compat for Claude (opus-4.6+ including Opus 5, sonnet-4.6+ including Sonnet 5, fable-5+, haiku-5+ including Haiku 5.5) and Kimi Coding K3 / `kimi-for-coding` custom channels.
41
42
  - Stores cache statistics in per-extension-instance atomic shards, so parent sessions, child Pi agents, and parallel Pi processes cannot overwrite one another.
42
43
  - Shows current conversation-session provider/model footer stats by default; `total` aggregates all valid local shards for the exact provider/model.
43
44
  - Supports optional router-extension integration through versioned global protocols (`Symbol.for("pi.routing.registry.v1")` and `Symbol.for("pi.cache.hints.v1")`) without importing router packages.
@@ -62,7 +63,7 @@ Run `/reload` in Pi after install/update/remove so extension hooks refresh.
62
63
 
63
64
  On Pi 0.79.7 and newer, `pi update` updates Pi itself only. To update installed Pi packages such as this extension, run `pi update --extensions` (packages only) or `pi update --all` (Pi + packages).
64
65
 
65
- This extension requires Pi 0.82+ and is validated against Pi 1.0.3. It uses the official Pi package types directly for type-checking, along with extension hooks, `getAgentDir()`, and prompt options shared by those versions; it does not depend on Pi 0.83+ APIs such as `ctx.scopedModels` or the bundled TypeBox 1.3 aliases. Native virtual model support and codemode nested-call coalescing activate only on Pi 0.99+ hosts that produce them; older hosts keep the previous behavior. The local development baseline uses Node.js 22.19.0 or newer, matching Pi 1.0.3's engine requirement.
66
+ This extension requires Pi 0.82+ and is validated against Pi 1.1.0. It uses the official Pi package types directly for type-checking, along with extension hooks, `getAgentDir()`, and prompt options shared by those versions; it does not depend on Pi 0.83+ APIs such as `ctx.scopedModels` or the bundled TypeBox 1.3 aliases. Native virtual model support and codemode nested-call coalescing activate only on Pi 0.99+ hosts that produce them; older hosts keep the previous behavior. The local development baseline uses Node.js 22.19.0 or newer, matching Pi 1.0.3's engine requirement.
66
67
 
67
68
  ## Commands
68
69
 
@@ -71,7 +72,7 @@ This extension requires Pi 0.82+ and is validated against Pi 1.0.3. It uses the
71
72
  | `/cache-optimizer` | Interactive menu when UI supports it; otherwise prints help and current state. |
72
73
  | `/cache-optimizer enable` | Enables runtime optimizations for the current Pi process, resets local footer stats, and starts a fresh “enabled” measurement. |
73
74
  | `/cache-optimizer disable` | Disables optimization for the current Pi process, resets local footer stats, and keeps collecting footer stats in disabled comparison mode. Run `/reload` or restart Pi to return to startup behavior. |
74
- | `/cache-optimizer doctor` | Shows active model/provider/API/base URL/compat, whether the skill list was compressed on the last prompt (and why not, e.g. an unrecognised Pi format), plus low-hit diagnosis. |
75
+ | `/cache-optimizer doctor` | Shows active model/provider/API/base URL/compat, whether the skill list was compressed on the last prompt (and why not, e.g. an unrecognised Pi format), plus Pi cache-warming eligibility and low-hit diagnosis. |
75
76
  | `/cache-optimizer compat` | Shows copyable compat advice for the active model, if applicable. |
76
77
  | `/cache-optimizer stats` | Shows detailed counters for every cache-adapter-matched model used by the current conversation session today. |
77
78
  | `/cache-optimizer stats all` | Shows detailed per-model totals across all valid local sessions/shards today, including request and token counts. |
@@ -203,6 +204,14 @@ Notes:
203
204
  - Do not add `thinkingFormat: "deepseek"` merely because a model id contains `deepseek`; `openai`, `qwen`, `openrouter`, `together`, or no explicit format are all valid catalog cases.
204
205
  - This extension's `doctor` and `compat` commands only advise; they do not modify `models.json`.
205
206
 
207
+ ## Prompt cache warming (Pi 0.86+)
208
+
209
+ 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
+ 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
+
213
+ 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
+
206
215
  ## Anthropic cache TTL compatibility
207
216
 
208
217
  Anthropic processes cache breakpoints in `tools → system → messages` order and rejects a `ttl: "1h"` breakpoint that appears after a 5-minute breakpoint. An ephemeral `cache_control` without `ttl` uses the default 5-minute retention.
@@ -213,7 +222,7 @@ Some proxies rewrite or insert hidden 5-minute breakpoints after Pi's request ho
213
222
 
214
223
  ## Adaptive thinking models
215
224
 
216
- Claude models from opus-4.6 / sonnet-4.6 (including Opus 5 and Sonnet 5) / fable-5 onwards require `forceAdaptiveThinking: true` in compat. Kimi Coding K3 (`k3`) and `kimi-for-coding` also use adaptive thinking and need `allowEmptySignature: true` so replayed empty-signature thinking blocks remain valid. Without the required compat, Pi may send a legacy thinking payload or replay thinking incorrectly. Pi 0.83+ native Opus 5 catalogs are covered by the same adaptive-thinking detection; custom `anthropic-messages` channels still need the compat flag when Pi does not provide it.
225
+ Claude models from opus-4.6 / sonnet-4.6 (including Opus 5 and Sonnet 5) / fable-5 / haiku-5 (Haiku 5.5, Pi 1.1.0+) onwards require `forceAdaptiveThinking: true` in compat. Kimi Coding K3 (`k3`) and `kimi-for-coding` also use adaptive thinking and need `allowEmptySignature: true` so replayed empty-signature thinking blocks remain valid. Without the required compat, Pi may send a legacy thinking payload or replay thinking incorrectly. Pi 0.83+ native Opus 5 catalogs are covered by the same adaptive-thinking detection; custom `anthropic-messages` channels still need the compat flag when Pi does not provide it.
217
226
 
218
227
  Pi's built-in catalog already sets this flag for official models. Custom channels in `models.json` that override these models must include the flag:
219
228
 
package/README.zh-CN.md CHANGED
@@ -21,6 +21,7 @@
21
21
  - [Opt-in 确定性工具排序](#opt-in-确定性工具排序)
22
22
  - [Footer 缓存统计模式](#footer-缓存统计模式)
23
23
  - [OpenAI-compatible 代理配置](#openai-compatible-代理配置)
24
+ - [Prompt cache 保温(Pi 0.86+)](#prompt-cache-保温pi-086)
24
25
  - [Adaptive thinking 模型](#adaptive-thinking-模型)
25
26
  - [使用 `/cache-optimizer fix` 自动修复](#使用-cache-optimizer-fix-自动修复)
26
27
  - [DeepSeek 协议安全与回滚](#deepseek-协议安全与回滚)
@@ -37,7 +38,7 @@
37
38
  - 在 Pi / provider compat 支持时请求长缓存保留。
38
39
  - 仅对 `openai-completions` 代理请求,在没有有效 key 时按会话保守补 `prompt_cache_key`;本扩展自己补的值(该 key、session-affinity 头补齐、cache hints)使用 Pi session id 的单向哈希(`pi-<32 位 hex>`);Pi 核心自己设置的 key 和头保持不变,已有 key 也绝不覆盖;Pi 1.0+ 已负责 Responses/Codex transport 的 key。
39
40
  - 对缺少缓存 / session-affinity compat 的第三方 OpenAI-compatible 代理给出一次性提醒。
40
- - 检测 Claude(opus-4.6+ 含 Opus 5、sonnet-4.6+ 含 Sonnet 5、fable-5+)以及 Kimi Coding K3 / `kimi-for-coding` 自定义渠道的 adaptive-thinking compat。
41
+ - 检测 Claude(opus-4.6+ 含 Opus 5、sonnet-4.6+ 含 Sonnet 5、fable-5+、haiku-5+ 含 Haiku 5.5)以及 Kimi Coding K3 / `kimi-for-coding` 自定义渠道的 adaptive-thinking compat。
41
42
  - 使用每个 extension instance 独占的原子 shard 保存缓存统计,避免父会话、子 Pi agent 和并行 Pi 进程互相覆盖。
42
43
  - Footer 默认显示当前 conversation session 的 provider/model 统计;`total` 可聚合同一精确 provider/model 的所有有效本地 shard。
43
44
  - 通过版本化全局协议(`Symbol.for("pi.routing.registry.v1")` 与 `Symbol.for("pi.cache.hints.v1")`)支持可选的 router extension 集成,而不导入任何 router 包。
@@ -62,7 +63,7 @@ pi remove npm:pi-deepseek-cache-optimizer && pi install npm:pi-cache-optimizer
62
63
 
63
64
  Pi 0.79.7 及之后,`pi update` 默认只更新 Pi 本体。若要更新已安装的 Pi package(包括本扩展),请运行 `pi update --extensions`(只更新 packages)或 `pi update --all`(Pi 与 packages 一起更新)。
64
65
 
65
- 本扩展要求 Pi 0.82+,并已使用 Pi 1.0.3 验证。TypeScript 校验直接使用官方 Pi package 类型,同时只使用这些版本共有的 extension hooks、`getAgentDir()` 和 prompt options;不依赖 Pi 0.83+ 专有 API(例如 `ctx.scopedModels` 或 bundled TypeBox 1.3 aliases)。原生虚拟模型支持与 codemode 嵌套工具调用合并只在会产生它们的 Pi 0.99+ 上生效,较早版本保持原有行为。本地开发基线需要 Node.js 22.19.0 或更高版本,与 Pi 1.0.3 的 engine 要求一致。
66
+ 本扩展要求 Pi 0.82+,并已使用 Pi 1.1.0 验证。TypeScript 校验直接使用官方 Pi package 类型,同时只使用这些版本共有的 extension hooks、`getAgentDir()` 和 prompt options;不依赖 Pi 0.83+ 专有 API(例如 `ctx.scopedModels` 或 bundled TypeBox 1.3 aliases)。原生虚拟模型支持与 codemode 嵌套工具调用合并只在会产生它们的 Pi 0.99+ 上生效,较早版本保持原有行为。本地开发基线需要 Node.js 22.19.0 或更高版本,与 Pi 1.0.3 的 engine 要求一致。
66
67
 
67
68
  ## 命令
68
69
 
@@ -71,7 +72,7 @@ Pi 0.79.7 及之后,`pi update` 默认只更新 Pi 本体。若要更新已安
71
72
  | `/cache-optimizer` | UI 支持时打开交互菜单;否则打印帮助和当前状态。 |
72
73
  | `/cache-optimizer enable` | 在当前 Pi 进程中开启运行时优化,清零本地 footer 统计,并开始新的“开启状态”测量。 |
73
74
  | `/cache-optimizer disable` | 在当前 Pi 进程中关闭优化,清零本地 footer 统计,并继续以 disabled 对比模式采集 footer 统计。运行 `/reload` 或重启 Pi 后回到启动时行为。 |
74
- | `/cache-optimizer doctor` | 显示当前模型 / provider / API / base URL / compat、上一次提示中 skill 列表是否被压缩(未压缩时给出原因,例如无法识别 Pi 的格式)以及低命中诊断。 |
75
+ | `/cache-optimizer doctor` | 显示当前模型 / provider / API / base URL / compat、上一次提示中 skill 列表是否被压缩(未压缩时给出原因,例如无法识别 Pi 的格式)、Pi 缓存保温是否生效以及低命中诊断。 |
75
76
  | `/cache-optimizer compat` | 对当前模型显示可复制的 compat 建议(如适用)。 |
76
77
  | `/cache-optimizer stats` | 显示当前 conversation session 今天使用过的各 cache-adapter-matched 模型详细统计。 |
77
78
  | `/cache-optimizer stats all` | 显示所有有效本地 session/shard 今天的逐模型详细总计,包括请求数与 token 数。 |
@@ -203,6 +204,14 @@ Pi 0.84.1 还修复了内置 Fireworks 渠道对拒绝 `prompt_cache_retention`
203
204
  - 不要因为模型 id 含有 `deepseek` 就添加 `thinkingFormat: "deepseek"`;catalog 中也存在 `openai`、`qwen`、`openrouter`、`together` 或没有显式 format 的情况。
204
205
  - 本扩展的 `doctor` 和 `compat` 命令只给建议,不会修改 `models.json`。
205
206
 
207
+ ## Prompt cache 保温(Pi 0.86+)
208
+
209
+ Pi 自带缓存保温:在缓存条目过期前,用一个 token 的输出上限原样重放上一次请求(全局设置 `cacheWarming`:`off`、`streaming`(默认)或 `idle`;在 `/settings` → Cache warming 中修改,或在 `~/.pi/agent/settings.json` 中设置 `"cacheWarming"`)。本扩展不自行运行保活定时器:第二个保温器会重复计费,而且无法精确重放 Pi 的请求。
210
+
211
+ Pi 只会为满足以下条件的模型保温:为当前保留档位声明了缓存时长、有输入/缓存价格、请求可重放(Anthropic 基于 budget 的 thinking 不可重放)。`models.json` 里的自定义代理模型常常缺前两项,保温根本不会启动。`/cache-optimizer doctor` 现在会显示保温模式、档位和缺失项,并给出片段,例如 `anthropic-messages` 模型的 `"promptCache": {"short":300,"long":3600}`。自定义模型写在该模型条目中,内置模型写在 provider 的 `modelOverrides` 下。这只是建议,扩展不会替你写入。
212
+
213
+ streaming 保温在真实请求后 60 分钟停止,idle 保温在 30 分钟后停止。本扩展使用 1 小时保留时,刷新会在第 54 分钟才触发,所以 `idle` 没有额外作用;1 小时的缓存本身已覆盖一小时内的中断。除非你使用 5 分钟缓存且经常在半小时内回来,否则保持默认的 `streaming` 即可。
214
+
206
215
  ## Anthropic 缓存 TTL 兼容
207
216
 
208
217
  Anthropic 按 `tools → system → messages` 顺序处理 cache breakpoint,并拒绝位于 5 分钟 breakpoint 之后的 `ttl: "1h"` breakpoint。省略 `ttl` 的 ephemeral `cache_control` 使用默认 5 分钟保留时间。
@@ -213,7 +222,7 @@ Anthropic 按 `tools → system → messages` 顺序处理 cache breakpoint,
213
222
 
214
223
  ## Adaptive thinking 模型
215
224
 
216
- Claude 从 opus-4.6 / sonnet-4.6(含 Opus 5、Sonnet 5)/ fable-5 开始需要在 compat 中设置 `forceAdaptiveThinking: true`。Kimi Coding K3(`k3`)和 `kimi-for-coding` 也使用 adaptive thinking,并需要 `allowEmptySignature: true`,以正确重放空 signature 的 thinking block。缺少这些 compat 时,Pi 可能发送旧版 thinking payload 或错误重放 thinking。Pi 0.83+ 的原生 Opus 5 catalog 已覆盖在同一 adaptive-thinking 检测中;如果自定义 `anthropic-messages` 渠道没有继承该 compat,仍需手动设置。
225
+ Claude 从 opus-4.6 / sonnet-4.6(含 Opus 5、Sonnet 5)/ fable-5 / haiku-5(Haiku 5.5,Pi 1.1.0+)开始需要在 compat 中设置 `forceAdaptiveThinking: true`。Kimi Coding K3(`k3`)和 `kimi-for-coding` 也使用 adaptive thinking,并需要 `allowEmptySignature: true`,以正确重放空 signature 的 thinking block。缺少这些 compat 时,Pi 可能发送旧版 thinking payload 或错误重放 thinking。Pi 0.83+ 的原生 Opus 5 catalog 已覆盖在同一 adaptive-thinking 检测中;如果自定义 `anthropic-messages` 渠道没有继承该 compat,仍需手动设置。
217
226
 
218
227
  Pi 内置 catalog 已为官方模型设置此 flag。`models.json` 中覆盖这些模型的自定义渠道必须包含该 flag:
219
228
 
package/index.ts CHANGED
@@ -16,6 +16,7 @@ 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
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";
20
21
  import { isRuntimeOptimizerEnabled, setRuntimeOptimizerEnabled } from "./src/config.ts";
21
22
  import { compareToolOrderEntries, getToolNameForPayload, isKnownToolOrderApi, isToolOrderingEligibleModel, isVerifiedToolForApi, normalizeToolsInPayload, sortToolsInPayload } from "./src/tool-ordering.ts";
@@ -1611,6 +1612,7 @@ export default function (pi: ExtensionAPI) {
1611
1612
  getCurrentSessionHash: () => currentSessionHash,
1612
1613
  getLastStatusText: () => lastStatusText,
1613
1614
  clearLastStatusText: () => { lastStatusText = undefined; },
1615
+ getCacheWarmingMode: () => readCacheWarmingMode(typeof pi.getSettings === "function" ? () => pi.getSettings() : undefined),
1614
1616
  });
1615
1617
 
1616
1618
  pi.registerCommand("cache-optimizer", {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-cache-optimizer",
3
- "version": "2.8.21",
3
+ "version": "2.8.23",
4
4
  "description": "Improve Pi prompt/KV cache hit rates with stable prompts, OpenAI-compatible cache keys, proxy compat warnings, and footer cache stats.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -37,8 +37,8 @@
37
37
  "check:modules": "node scripts/check-module-graph.mjs"
38
38
  },
39
39
  "devDependencies": {
40
- "@earendil-works/pi-coding-agent": "^1.0.3",
41
- "@earendil-works/pi-server": "1.0.3",
40
+ "@earendil-works/pi-coding-agent": "^1.1.0",
41
+ "@earendil-works/pi-server": "^1.1.0",
42
42
  "@types/node": "^22.0.0",
43
43
  "jiti": "^2.7.0",
44
44
  "typescript": "^5.8.3"
@@ -0,0 +1,150 @@
1
+ import { type PiModel, asRecord } from "./common.ts";
2
+
3
+ /**
4
+ * Diagnostics for Pi's native prompt-cache warming (Pi 0.86+, `cacheWarming`
5
+ * setting). Pi re-sends the last request with a one-token output cap at 90% of
6
+ * the cache lifetime, but only when:
7
+ * - the global `cacheWarming` setting is not "off";
8
+ * - the model declares `promptCache.<tier>` for the retention tier in use;
9
+ * - the model's prices let Pi estimate the savings (cost.cacheRead/cacheWrite/input);
10
+ * - the request is replayable (Anthropic budget-based thinking is not).
11
+ * Streaming warming stops 60 minutes after the real request and idle warming
12
+ * after 30 minutes, so idle mode never refreshes a 1-hour cache entry (its
13
+ * refresh would fire at 54 minutes).
14
+ *
15
+ * This module only reads state and explains it; it never changes settings or
16
+ * models.json. The extension does not run its own keepalive timer, because a
17
+ * second warmer would double-bill refreshes and could not replay Pi's exact
18
+ * request context.
19
+ */
20
+
21
+ export type CacheWarmingMode = "off" | "streaming" | "idle";
22
+
23
+ export const CACHE_WARMING_DEFAULT_MODE: CacheWarmingMode = "streaming";
24
+ export const CACHE_WARMING_STREAMING_MAX_MS = 60 * 60_000;
25
+ export const CACHE_WARMING_IDLE_MAX_MS = 30 * 60_000;
26
+
27
+ /** Same schedule Pi uses: 90% of the TTL, keeping at least ten seconds of margin. */
28
+ export function cacheWarmingRefreshDelayMs(ttlMs: number): number | undefined {
29
+ if (ttlMs <= 10_000) return undefined;
30
+ return Math.max(1, Math.floor(Math.min(ttlMs * 0.9, ttlMs - 10_000)));
31
+ }
32
+
33
+ export type CacheWarmingSettingsReader = () => unknown;
34
+
35
+ /**
36
+ * Read the `cacheWarming` mode from `pi.getSettings()` (Pi 0.86+). Returns
37
+ * undefined when the host has no settings API or no warming support.
38
+ */
39
+ export function readCacheWarmingMode(getSettings: CacheWarmingSettingsReader | undefined): CacheWarmingMode | "unsupported" {
40
+ if (typeof getSettings !== "function") return "unsupported";
41
+ let settings: unknown;
42
+ try {
43
+ settings = getSettings();
44
+ } catch {
45
+ return "unsupported";
46
+ }
47
+ const value = asRecord(settings)?.cacheWarming;
48
+ if (value === "off" || value === "streaming" || value === "idle") return value;
49
+ return CACHE_WARMING_DEFAULT_MODE;
50
+ }
51
+
52
+ export function suggestedPromptCacheLifetimes(model: PiModel): { short: number; long?: number } {
53
+ // Anthropic documents a 5-minute default and an optional 1-hour TTL. For
54
+ // other APIs only the conservative short lifetime is suggested; extended
55
+ // retention differs per provider and deployment.
56
+ if (model.api === "anthropic-messages") return { short: 300, long: 3600 };
57
+ return { short: 300 };
58
+ }
59
+
60
+ function readPromptCacheTier(model: PiModel, tier: "short" | "long"): number | undefined {
61
+ const promptCache = asRecord((model as unknown as Record<string, unknown>).promptCache);
62
+ const value = promptCache?.[tier];
63
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined;
64
+ }
65
+
66
+ function hasPricing(model: PiModel): boolean {
67
+ const cost = asRecord((model as unknown as Record<string, unknown>).cost);
68
+ if (!cost) return false;
69
+ return ["input", "cacheRead", "cacheWrite"].some((key) => typeof cost[key] === "number" && (cost[key] as number) > 0);
70
+ }
71
+
72
+ function isReplayableForWarming(model: PiModel): boolean {
73
+ if (!model.reasoning || model.api !== "anthropic-messages") return true;
74
+ return asRecord(asRecord(model as unknown)?.compat)?.forceAdaptiveThinking === true;
75
+ }
76
+
77
+ export type CacheWarmingDiagnosisInput = {
78
+ mode: CacheWarmingMode | "unsupported";
79
+ /** Retention tier Pi uses for this request ("long" when PI_CACHE_RETENTION=long). */
80
+ tier: "short" | "long";
81
+ };
82
+
83
+ export type CacheWarmingDiagnosis = {
84
+ eligible: boolean;
85
+ issues: string[];
86
+ lines: string[];
87
+ };
88
+
89
+ function formatSeconds(seconds: number): string {
90
+ if (seconds % 3600 === 0) return `${seconds / 3600}h`;
91
+ if (seconds % 60 === 0) return `${seconds / 60}m`;
92
+ return `${seconds}s`;
93
+ }
94
+
95
+ export function buildCacheWarmingDiagnosis(model: PiModel, input: CacheWarmingDiagnosisInput): CacheWarmingDiagnosis {
96
+ const lines: string[] = ["", "🔥 Prompt cache warming (Pi cacheWarming):"];
97
+ const issues: string[] = [];
98
+
99
+ if (input.mode === "unsupported") {
100
+ lines.push("- This Pi host does not expose cache warming settings (requires Pi 0.86+). Nothing to check.");
101
+ return { eligible: false, issues: ["unsupported"], lines };
102
+ }
103
+
104
+ lines.push(`- Mode: ${input.mode}${input.mode === CACHE_WARMING_DEFAULT_MODE ? " (default)" : ""} — global setting, change with /settings → Cache warming or ~/.pi/agent/settings.json`);
105
+ lines.push(`- Retention tier in use: ${input.tier}`);
106
+
107
+ if (input.mode === "off") {
108
+ issues.push("mode_off");
109
+ lines.push("- Warming is off: long tool runs can outlive the cache entry and the next request rewrites it.");
110
+ }
111
+
112
+ const ttl = readPromptCacheTier(model, input.tier);
113
+ if (ttl === undefined) {
114
+ issues.push("prompt_cache_missing");
115
+ const suggested = suggestedPromptCacheLifetimes(model);
116
+ const snippet: Record<string, number> = { short: suggested.short };
117
+ if (suggested.long !== undefined) snippet.long = suggested.long;
118
+ lines.push(`- ⚠️ The model declares no promptCache.${input.tier} lifetime, so Pi never warms its cache.`);
119
+ 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
+ lines.push(` "promptCache": ${JSON.stringify(snippet)}`);
121
+ if (input.tier === "long" && suggested.long === undefined) {
122
+ lines.push(" Only add a long lifetime your provider documents; use the conservative end of any range.");
123
+ }
124
+ } else {
125
+ lines.push(`- promptCache.${input.tier}: ${formatSeconds(ttl)}`);
126
+ }
127
+
128
+ 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.");
131
+ }
132
+
133
+ if (!isReplayableForWarming(model)) {
134
+ issues.push("not_replayable");
135
+ lines.push("- ⚠️ Anthropic budget-based thinking cannot be replayed with a one-token cap, so Pi skips warming. Set compat.forceAdaptiveThinking only if the model supports adaptive thinking.");
136
+ }
137
+
138
+ if (ttl !== undefined && input.mode === "idle") {
139
+ const delay = cacheWarmingRefreshDelayMs(ttl * 1000);
140
+ if (delay !== undefined && delay > CACHE_WARMING_IDLE_MAX_MS) {
141
+ lines.push(`- Note: idle warming stops after 30m, but a ${formatSeconds(ttl)} entry would refresh at ${Math.round(delay / 60_000)}m, so idle mode adds nothing for this tier. The ${formatSeconds(ttl)} lifetime already covers shorter breaks.`);
142
+ }
143
+ }
144
+
145
+ const eligible = input.mode !== "off" && issues.length === 0;
146
+ if (eligible) {
147
+ lines.push(`- ✓ Eligible: Pi keeps this cache warm ${input.mode === "idle" ? "during runs and briefly between runs" : "during agent runs"} when the expected savings are at least $0.05.`);
148
+ }
149
+ return { eligible, issues, lines };
150
+ }
package/src/command.ts CHANGED
@@ -21,7 +21,8 @@ import { type ProviderRequestState } from "./request-state.ts";
21
21
  import { type CacheStats, type ShardAggregate } from "./stats-store.ts";
22
22
  import { type CacheUsageSample } from "./stats-report.ts";
23
23
  import { describeSkillCompressionOutcome } from "./prompt-rewrite.ts";
24
- import { describePromptCacheRetentionDecision, getLastPromptCacheRetentionDecision } from "./retention.ts";
24
+ import { describePromptCacheRetentionDecision, getLastPromptCacheRetentionDecision, LONG_CACHE_RETENTION_VALUE, PI_CACHE_RETENTION_ENV } from "./retention.ts";
25
+ import { buildCacheWarmingDiagnosis, type CacheWarmingMode } from "./cache-warming.ts";
25
26
 
26
27
  /**
27
28
  * Everything the command needs from the extension instance. The extension keeps this state in closures
@@ -50,6 +51,8 @@ export type CommandRuntime = {
50
51
  getCurrentSessionHash(): string;
51
52
  getLastStatusText(): string | undefined;
52
53
  clearLastStatusText(): void;
54
+ /** Pi `cacheWarming` mode, or "unsupported" on hosts without the settings API. */
55
+ getCacheWarmingMode?(): CacheWarmingMode | "unsupported";
53
56
  };
54
57
 
55
58
  export function createCacheOptimizerCommandHandler(runtime: CommandRuntime) {
@@ -110,7 +113,13 @@ export function createCacheOptimizerCommandHandler(runtime: CommandRuntime) {
110
113
  const samples = sk ? getRecentSamples(sk) : [];
111
114
  const lowHitLines = buildLowHitDiagnosis(model, adapter, statsState, samples);
112
115
  const routeNote = describeNativeVirtualRouteNote(selectedModel, model);
113
- const fullDiagnosis = [routeNote, diagnosis, describeSkillCompressionOutcome(), describePromptCacheRetentionDecision(getLastPromptCacheRetentionDecision()), ...lowHitLines].filter((line) => line !== undefined).join("\n");
116
+ const warmingLines = runtime.getCacheWarmingMode
117
+ ? buildCacheWarmingDiagnosis(model, {
118
+ mode: runtime.getCacheWarmingMode(),
119
+ tier: process.env[PI_CACHE_RETENTION_ENV] === LONG_CACHE_RETENTION_VALUE ? "long" : "short",
120
+ }).lines
121
+ : [];
122
+ const fullDiagnosis = [routeNote, diagnosis, describeSkillCompressionOutcome(), describePromptCacheRetentionDecision(getLastPromptCacheRetentionDecision()), ...warmingLines, ...lowHitLines].filter((line) => line !== undefined).join("\n");
114
123
  cmdCtx.ui.notify(fullDiagnosis, "info");
115
124
  } else if (subcommand === "stats") {
116
125
  const aggregate = await refreshShardAggregate();
@@ -32,8 +32,10 @@ export function modelOrAssistantMessageHas(message: unknown, model: PiModel | un
32
32
  * claude-opus-4-6, claude-opus-4-7, claude-opus-4-8 (also dotted 4.6/4.7/4.8)
33
33
  * claude-sonnet-4-6, claude-sonnet-5
34
34
  * claude-fable-5
35
+ * claude-haiku-5-5 (Pi 1.1.0+)
35
36
  *
36
- * We match broadly: opus >= 4-6, sonnet >= 4-6, fable >= 5.
37
+ * We match broadly: opus >= 4-6, sonnet >= 4-6, fable >= 5, haiku >= 5
38
+ * (Pi's own Bedrock/Anthropic detection treats every haiku-5* as adaptive).
37
39
  * Ids may carry date-stamp or size suffixes like "[1M]".
38
40
  */
39
41
  export const ADAPTIVE_OPUS_PATTERN = /(^|[\/\s:_-])(opus-4[.-][6-9]|opus-4-[1-9][0-9]|opus-([5-9]|[1-9][0-9]))($|[-_.:\/\s\[])/i;
@@ -42,10 +44,12 @@ export const ADAPTIVE_SONNET_PATTERN = /(^|[\/\s:_-])(sonnet-4[.-][6-9]|sonnet-4
42
44
 
43
45
  export const ADAPTIVE_FABLE_PATTERN = /(^|[\/\s:_-])fable-([5-9]|[1-9][0-9])($|[-_.:\/\s\[])/i;
44
46
 
47
+ export const ADAPTIVE_HAIKU_PATTERN = /(^|[\/\s:_-])haiku-([5-9]|[1-9][0-9])($|[-_.:\/\s\[])/i;
48
+
45
49
  export function isAdaptiveGenerationModel(model: PiModel | undefined): boolean {
46
50
  if (!model) return false;
47
51
  const tokens = getModelIdNameTokenValues(model);
48
- return tokens.some((t) => ADAPTIVE_OPUS_PATTERN.test(t) || ADAPTIVE_SONNET_PATTERN.test(t) || ADAPTIVE_FABLE_PATTERN.test(t));
52
+ return tokens.some((t) => ADAPTIVE_OPUS_PATTERN.test(t) || ADAPTIVE_SONNET_PATTERN.test(t) || ADAPTIVE_FABLE_PATTERN.test(t) || ADAPTIVE_HAIKU_PATTERN.test(t));
49
53
  }
50
54
 
51
55
  export function isKimiCodingAdaptiveModel(model: PiModel | undefined): boolean {