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 +5 -5
- package/README.zh-CN.md +5 -5
- package/index.ts +75 -100
- package/package.json +3 -3
- package/src/atomic-fs.ts +29 -3
- package/src/command.ts +4 -4
- package/src/compat-advice.ts +0 -2
- package/src/compat-config.ts +1 -1
- package/src/diagnostics.ts +0 -4
- package/src/jsonc.ts +0 -1
- package/src/model-detect.ts +6 -2
- package/src/models-json-fix.ts +3 -3
- package/src/prompt-cache-key-config.ts +5 -5
- package/src/prompt-rewrite.ts +1 -1
- package/src/provider-errors.ts +0 -1
- package/src/request-payload.ts +67 -10
- package/src/retention.ts +81 -0
- package/src/routing.ts +4 -4
- package/src/stats-report.ts +0 -2
- package/src/stats-store.ts +53 -6
- package/src/tool-ordering.ts +1 -1
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
|
|
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.
|
|
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`
|
|
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
|
|
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
|
|
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
|
|
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`
|
|
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
|
|
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 {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
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
|
-
|
|
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
|
|
14
|
-
import { FIX_RECEIPT_FILE_NAME, FIX_RECEIPT_PATH, MODELS_JSON_PATH,
|
|
15
|
-
import { type
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import { getAssistantMessageModelTokenValues, getModelIdNameTokenValues,
|
|
19
|
-
import {
|
|
20
|
-
import { analyzeModelsJsonForMissingEntry, applyModelsJsonFixTransaction, chooseFixPlacement, composeFixInsertion, composeMissingEntryInsertion, composeModelOverrideInsertion, composeModelsJsonReceiptRollback, composeProviderAffinityInsertion, createModelsJsonFixReceipt, createRollbackBackupPath, decideFixPlacement, formatCompatKeysForInsertion,
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
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,
|
|
33
|
-
import { CONFIG_RECEIPT_PATH,
|
|
34
|
-
import {
|
|
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,
|
|
39
|
-
import { REASONING_PROTOCOL_FALLBACK_SYMBOL, buildReasoningProtocolFixSuggestion, getAnthropicTtlFallbackState, getReasoningProtocolFallbackState, hasExplicitDeepSeekReasoningProtocol, hasPromptCacheKeyUnsupportedErrorMessage, hasPromptCacheKeyUnsupportedText, hasPromptCacheRetentionUnsupportedErrorMessage,
|
|
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,
|
|
45
|
-
import {
|
|
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
|
-
|
|
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
|
|
1208
|
-
if (
|
|
1209
|
-
|
|
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
|
-
//
|
|
1214
|
-
//
|
|
1215
|
-
//
|
|
1216
|
-
// reject
|
|
1217
|
-
//
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
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 ?? (
|
|
1222
|
+
return omitted ?? (payloadChanged ? requestPayload : undefined);
|
|
1248
1223
|
}
|
|
1249
1224
|
|
|
1250
1225
|
if (!shouldInjectOpenAIPromptCacheKey() || !shouldInjectOpenAIPromptCacheKeyForModel(requestModel)) {
|
|
1251
|
-
return
|
|
1226
|
+
return payloadChanged ? requestPayload : undefined;
|
|
1252
1227
|
}
|
|
1253
1228
|
|
|
1254
1229
|
const withCacheKey = addOpenAIPromptCacheKey(requestPayload, getSessionPromptCacheKey(ctx));
|
|
1255
|
-
return withCacheKey ?? (
|
|
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.
|
|
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
|
|
41
|
-
"@earendil-works/pi-server": "1.0
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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();
|
package/src/compat-advice.ts
CHANGED
|
@@ -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 {
|
package/src/compat-config.ts
CHANGED
package/src/diagnostics.ts
CHANGED
|
@@ -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
package/src/model-detect.ts
CHANGED
|
@@ -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 {
|
package/src/models-json-fix.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 });
|
package/src/prompt-rewrite.ts
CHANGED
package/src/provider-errors.ts
CHANGED
|
@@ -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
|
|
package/src/request-payload.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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
|
|
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
|
-
|
|
165
|
-
|
|
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
|
-
|
|
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
|
-
|
|
175
|
-
break;
|
|
186
|
+
return true;
|
|
176
187
|
}
|
|
177
188
|
}
|
|
178
|
-
|
|
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 {
|
|
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 =
|
|
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
|
|
530
|
+
return derivePromptCacheKey(ctx.sessionManager.getSessionId());
|
|
531
531
|
}
|
|
532
532
|
|
|
533
533
|
export function isCacheHintsServiceV1(value: unknown): value is PiCacheHintsV1 {
|
package/src/stats-report.ts
CHANGED
|
@@ -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`;
|
package/src/stats-store.ts
CHANGED
|
@@ -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
|
-
|
|
335
|
-
|
|
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())
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
|
package/src/tool-ordering.ts
CHANGED
|
@@ -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):
|
|
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.
|