pi-cache-optimizer 2.8.20 → 2.8.22

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