pi-cache-optimizer 2.8.7 → 2.8.8
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 +18 -4
- package/README.zh-CN.md +18 -4
- package/index.ts +639 -59
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -17,6 +17,7 @@ Pi extension for improving provider-side KV / prompt cache hit rates. It keeps s
|
|
|
17
17
|
- [Install](#install)
|
|
18
18
|
- [Commands](#commands)
|
|
19
19
|
- [Persistent opt-out](#persistent-opt-out)
|
|
20
|
+
- [Per-model `prompt_cache_key` opt-out](#per-model-prompt_cache_key-opt-out)
|
|
20
21
|
- [Opt-in deterministic tool ordering](#opt-in-deterministic-tool-ordering)
|
|
21
22
|
- [Footer cache stats mode](#footer-cache-stats-mode)
|
|
22
23
|
- [OpenAI-compatible proxy setup](#openai-compatible-proxy-setup)
|
|
@@ -60,7 +61,7 @@ Run `/reload` in Pi after install/update/remove so extension hooks refresh.
|
|
|
60
61
|
|
|
61
62
|
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).
|
|
62
63
|
|
|
63
|
-
This extension requires Pi 0.82+ and is validated against Pi 0.
|
|
64
|
+
This extension requires Pi 0.82+ and is validated against Pi 0.85.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.
|
|
64
65
|
|
|
65
66
|
## Commands
|
|
66
67
|
|
|
@@ -76,10 +77,11 @@ This extension requires Pi 0.82+ and is validated against Pi 0.84.4. It uses the
|
|
|
76
77
|
| `/cache-optimizer stats contributors` | Shows current/other contributing sessions for the active exact provider/model without exposing session ids. |
|
|
77
78
|
| `/cache-optimizer reset` | Resets local footer stats for the active provider/model; upstream provider cache is not modified. |
|
|
78
79
|
| `/cache-optimizer config footer-mode total\|session\|process` | Persist the footer stats mode. Persistent command configuration overrides the environment variable. |
|
|
79
|
-
| `/cache-optimizer fix` | Auto-repairs safe compat issues for the active model. Shows preview + risk warning, requires confirmation.
|
|
80
|
-
| `/cache-optimizer
|
|
80
|
+
| `/cache-optimizer fix` | Auto-repairs safe compat issues for the active model. Shows preview + risk warning, requires confirmation. It writes `models.json` only for native compat fixes, or the extension config for an evidenced `prompt_cache_key` issue. |
|
|
81
|
+
| `/cache-optimizer fix prompt-cache-key` | Explicitly configures the active OpenAI-compatible provider/model to omit `prompt_cache_key` and `promptCacheKey` from the final request body. Requires confirmation. |
|
|
82
|
+
| `/cache-optimizer rollback` | Reviews the latest matching confirmed fix and, after UI confirmation, restores either the extension config or `models.json` fix safely. |
|
|
81
83
|
|
|
82
|
-
`/cache-optimizer` uses Pi's native Tab completion. Type `/cache-optimizer <Tab>` for the supported subcommands, `/cache-optimizer stats <Tab>` for `all` or `contributors`, `/cache-optimizer c<Tab>` for `config`, `/cache-optimizer config <Tab>` for `footer-mode`, and `/cache-optimizer config footer-mode <Tab>` for `total`, `session`, or `process`. Suggestions are prefix-filtered and invalid prefixes are left to Pi's normal fallback behavior.
|
|
84
|
+
`/cache-optimizer` uses Pi's native Tab completion. Type `/cache-optimizer <Tab>` for the supported subcommands, `/cache-optimizer stats <Tab>` for `all` or `contributors`, `/cache-optimizer fix <Tab>` for `prompt-cache-key`, `/cache-optimizer c<Tab>` for `config`, `/cache-optimizer config <Tab>` for `footer-mode`, and `/cache-optimizer config footer-mode <Tab>` for `total`, `session`, or `process`. Suggestions are prefix-filtered and invalid prefixes are left to Pi's normal fallback behavior.
|
|
83
85
|
|
|
84
86
|
The interactive `/cache-optimizer` menu includes `Footer mode`, where you can choose `total`, `session`, or `process`. `enable` / `disable` are current-process switches. For a persistent opt-out, use environment variables below.
|
|
85
87
|
|
|
@@ -130,6 +132,18 @@ Persistent command configuration takes precedence over the environment variable:
|
|
|
130
132
|
|
|
131
133
|
The explicit setting is stored in `pi-cache-optimizer-config.json` under Pi's agent directory. If no command override exists, `PI_CACHE_OPTIMIZER_FOOTER_MODE=total|session|process` is used; values are case-insensitive, and missing or invalid values fall back to `session`. To return an existing installation to environment-controlled behavior, manually delete `pi-cache-optimizer-config.json` and run `/reload`.
|
|
132
134
|
|
|
135
|
+
## Per-model `prompt_cache_key` opt-out
|
|
136
|
+
|
|
137
|
+
Some OpenAI-compatible endpoints reject `prompt_cache_key` with HTTP 400 even though the same field is valid for other providers. Pi 0.85.1 has no native `supportsPromptCacheKey` compat field; do **not** add that unknown field to `models.json`. `supportsLongCacheRetention` is not an equivalent switch and should not be used for this purpose.
|
|
138
|
+
|
|
139
|
+
When the extension observes an explicit `prompt_cache_key` unsupported error for the exact provider/model, ordinary `/cache-optimizer fix` offers a confirmed model-scoped repair. If you already know that the endpoint rejects the field, use the explicit command:
|
|
140
|
+
|
|
141
|
+
```text
|
|
142
|
+
/cache-optimizer fix prompt-cache-key
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
The preview explains that the setting is stored in the extension-owned `pi-cache-optimizer-config.json`. After confirmation, the final `before_provider_request` stage removes both `prompt_cache_key` and `promptCacheKey`, including a key that Pi core supplied earlier. This can reduce provider prompt-cache hits for that exact model, while other models retain the existing fallback behavior. The file is updated atomically, a privacy-safe backup/receipt is created, and `/reload` or a Pi restart is required. `/cache-optimizer rollback` restores the previous extension configuration without resetting `footerMode`.
|
|
146
|
+
|
|
133
147
|
## OpenAI-compatible proxy setup
|
|
134
148
|
|
|
135
149
|
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.
|
package/README.zh-CN.md
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
- [安装](#安装)
|
|
18
18
|
- [命令](#命令)
|
|
19
19
|
- [持久 Opt-out](#持久-opt-out)
|
|
20
|
+
- [按模型关闭 `prompt_cache_key`](#按模型关闭-prompt_cache_key)
|
|
20
21
|
- [Opt-in 确定性工具排序](#opt-in-确定性工具排序)
|
|
21
22
|
- [Footer 缓存统计模式](#footer-缓存统计模式)
|
|
22
23
|
- [OpenAI-compatible 代理配置](#openai-compatible-代理配置)
|
|
@@ -60,7 +61,7 @@ pi remove npm:pi-deepseek-cache-optimizer && pi install npm:pi-cache-optimizer
|
|
|
60
61
|
|
|
61
62
|
Pi 0.79.7 及之后,`pi update` 默认只更新 Pi 本体。若要更新已安装的 Pi package(包括本扩展),请运行 `pi update --extensions`(只更新 packages)或 `pi update --all`(Pi 与 packages 一起更新)。
|
|
62
63
|
|
|
63
|
-
本扩展要求 Pi 0.82+,并已使用 Pi 0.
|
|
64
|
+
本扩展要求 Pi 0.82+,并已使用 Pi 0.85.0 验证。TypeScript 校验直接使用官方 Pi package 类型,同时只使用这些版本共有的 extension hooks、`getAgentDir()` 和 prompt options;不依赖 Pi 0.83+ 专有 API(例如 `ctx.scopedModels` 或 bundled TypeBox 1.3 aliases)。
|
|
64
65
|
|
|
65
66
|
## 命令
|
|
66
67
|
|
|
@@ -76,10 +77,11 @@ Pi 0.79.7 及之后,`pi update` 默认只更新 Pi 本体。若要更新已安
|
|
|
76
77
|
| `/cache-optimizer stats contributors` | 显示当前精确 provider/model 的当前/其他贡献 session,不暴露 session id。 |
|
|
77
78
|
| `/cache-optimizer reset` | 重置当前 provider/model 的本地 footer 统计;不会修改上游 provider 缓存。 |
|
|
78
79
|
| `/cache-optimizer config footer-mode total\|session\|process` | 持久设置 footer 统计模式;持久命令配置优先于环境变量。 |
|
|
79
|
-
| `/cache-optimizer fix` | 为当前模型自动修复安全的 compat 问题。展示预览 +
|
|
80
|
-
| `/cache-optimizer
|
|
80
|
+
| `/cache-optimizer fix` | 为当前模型自动修复安全的 compat 问题。展示预览 + 风险提示,需要用户确认;原生 compat 修复写 `models.json`,已观测到的 `prompt_cache_key` 问题写扩展配置。 |
|
|
81
|
+
| `/cache-optimizer fix prompt-cache-key` | 明确把当前 OpenAI-compatible provider/model 配置为从最终请求体移除 `prompt_cache_key` 与 `promptCacheKey`。需要确认。 |
|
|
82
|
+
| `/cache-optimizer rollback` | 查看最近一次匹配的已确认修复;经 UI 确认后安全恢复扩展配置或 `models.json` 修复。 |
|
|
81
83
|
|
|
82
|
-
`/cache-optimizer` 使用 Pi 原生 Tab 补全:输入 `/cache-optimizer <Tab>` 查看支持的子命令,输入 `/cache-optimizer stats <Tab>` 补全 `all` 或 `contributors`,输入 `/cache-optimizer c<Tab>` 补全 `config`,输入 `/cache-optimizer config <Tab>` 补全 `footer-mode`,输入 `/cache-optimizer config footer-mode <Tab>` 补全 `total`、`session` 或 `process`。建议会按当前前缀过滤;无效前缀返回空结果,由 Pi 正常回退处理。
|
|
84
|
+
`/cache-optimizer` 使用 Pi 原生 Tab 补全:输入 `/cache-optimizer <Tab>` 查看支持的子命令,输入 `/cache-optimizer stats <Tab>` 补全 `all` 或 `contributors`,输入 `/cache-optimizer fix <Tab>` 补全 `prompt-cache-key`,输入 `/cache-optimizer c<Tab>` 补全 `config`,输入 `/cache-optimizer config <Tab>` 补全 `footer-mode`,输入 `/cache-optimizer config footer-mode <Tab>` 补全 `total`、`session` 或 `process`。建议会按当前前缀过滤;无效前缀返回空结果,由 Pi 正常回退处理。
|
|
83
85
|
|
|
84
86
|
交互式 `/cache-optimizer` 菜单包含 `Footer mode`,可以选择 `total`、`session` 或 `process`。`enable` / `disable` 是当前进程内开关。若要持久关闭某些能力,请使用下面的环境变量。
|
|
85
87
|
|
|
@@ -130,6 +132,18 @@ Footer 默认使用 `session`,避免另一个并行 Pi 终端使用相同 prov
|
|
|
130
132
|
|
|
131
133
|
显式设置保存在 Pi agent 目录下的 `pi-cache-optimizer-config.json`。没有命令覆盖时,读取 `PI_CACHE_OPTIMIZER_FOOTER_MODE=total|session|process`;值不区分大小写,缺失或非法值均回退到 `session`。如需让已有安装重新由环境变量控制,请手动删除 `pi-cache-optimizer-config.json`,然后运行 `/reload`。
|
|
132
134
|
|
|
135
|
+
## 按模型关闭 `prompt_cache_key`
|
|
136
|
+
|
|
137
|
+
某些 OpenAI-compatible endpoint 会因 `prompt_cache_key` 返回 HTTP 400,但同一个字段对其他 provider 可能有效。Pi 0.85.1 没有原生的 `supportsPromptCacheKey` compat 字段,**不要**把这个未知字段加入 `models.json`。`supportsLongCacheRetention` 也不是等价开关,不应借用来实现此目的。
|
|
138
|
+
|
|
139
|
+
当扩展观察到精确 provider/model 明确拒绝 `prompt_cache_key` 后,普通 `/cache-optimizer fix` 会提供经过确认的模型级修复。如果你已经确定 endpoint 不支持该字段,可以主动执行:
|
|
140
|
+
|
|
141
|
+
```text
|
|
142
|
+
/cache-optimizer fix prompt-cache-key
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
预览会说明设置保存在扩展自有的 `pi-cache-optimizer-config.json` 中。确认后,`before_provider_request` 的最终阶段会移除 `prompt_cache_key` 和 `promptCacheKey`,包括 Pi core 之前已经放入的 key。这个精确模型可能因此失去 provider prompt-cache 命中,其他模型仍保持原有 fallback 行为。配置会使用原子替换写入,并生成不含敏感内容的备份/receipt;需要 `/reload` 或重启 Pi。`/cache-optimizer rollback` 可恢复之前的扩展配置,且不会重置 `footerMode`。
|
|
146
|
+
|
|
133
147
|
## OpenAI-compatible 代理配置
|
|
134
148
|
|
|
135
149
|
LiteLLM / OneAPI / NewAPI / 类 OpenRouter 渠道等第三方 `openai-completions` 代理,常会把同一个 session 分散到多个上游后端,导致 provider 侧 prompt cache 被拆散。
|
package/index.ts
CHANGED
|
@@ -114,6 +114,8 @@ const SHARD_GLOBAL_EPOCH_PATH = join(SHARD_EPOCH_DIR, "global.json");
|
|
|
114
114
|
const SHARD_CLEANUP_LOCK_PATH = join(SHARD_MAINTENANCE_DIR, "cleanup.lock");
|
|
115
115
|
const SHARD_CLEANUP_MARKER_PATH = join(SHARD_MAINTENANCE_DIR, "last-cleanup");
|
|
116
116
|
const CONFIG_FILE_PATH = join(STATE_DIR, "pi-cache-optimizer-config.json");
|
|
117
|
+
const CONFIG_RECEIPT_FILE_NAME = "pi-cache-optimizer-config-receipt.json";
|
|
118
|
+
const CONFIG_RECEIPT_PATH = join(STATE_DIR, CONFIG_RECEIPT_FILE_NAME);
|
|
117
119
|
const FIX_RECEIPT_FILE_NAME = "pi-cache-optimizer-fix-receipt.json";
|
|
118
120
|
const FIX_RECEIPT_PATH = join(STATE_DIR, FIX_RECEIPT_FILE_NAME);
|
|
119
121
|
const MODELS_TRANSACTION_LOCK_PATH = join(STATE_DIR, "pi-cache-optimizer-models-transaction.lock");
|
|
@@ -177,6 +179,31 @@ type PersistedCacheOptimizerConfigV1 = {
|
|
|
177
179
|
version: 1;
|
|
178
180
|
footerMode?: FooterStatsMode;
|
|
179
181
|
};
|
|
182
|
+
type PersistedCacheOptimizerConfigV2 = {
|
|
183
|
+
version: 2;
|
|
184
|
+
footerMode?: FooterStatsMode;
|
|
185
|
+
promptCacheKey?: {
|
|
186
|
+
omit?: string[];
|
|
187
|
+
};
|
|
188
|
+
};
|
|
189
|
+
type PersistedCacheOptimizerConfig = PersistedCacheOptimizerConfigV1 | PersistedCacheOptimizerConfigV2;
|
|
190
|
+
type PromptCacheKeyConfigReceipt = {
|
|
191
|
+
version: 2;
|
|
192
|
+
kind: "pi-cache-optimizer-config-receipt";
|
|
193
|
+
transactionId: string;
|
|
194
|
+
provider: string;
|
|
195
|
+
modelId: string;
|
|
196
|
+
beforeHash: string;
|
|
197
|
+
afterHash: string;
|
|
198
|
+
backupFile: string;
|
|
199
|
+
targetExistedBefore: boolean;
|
|
200
|
+
targetHadModelKey: boolean;
|
|
201
|
+
addedModelKey: string;
|
|
202
|
+
createdAt: number;
|
|
203
|
+
appliedAt: number;
|
|
204
|
+
status?: "rolled_back";
|
|
205
|
+
rolledBackAt?: number;
|
|
206
|
+
};
|
|
180
207
|
const PI_ROUTING_REGISTRY_SYMBOL = Symbol.for("pi.routing.registry.v1");
|
|
181
208
|
const PI_CACHE_HINTS_SYMBOL = Symbol.for("pi.cache.hints.v1");
|
|
182
209
|
const PI_CACHE_HINTS_OWNER_SYMBOL = Symbol.for("pi.cache.optimizer.hints-owner.v1");
|
|
@@ -1867,6 +1894,7 @@ const CACHE_OPTIMIZER_COMMANDS = [
|
|
|
1867
1894
|
const CACHE_OPTIMIZER_CONFIG_ARGUMENTS = ["footer-mode"] as const;
|
|
1868
1895
|
const CACHE_OPTIMIZER_FOOTER_MODES = ["total", "session", "process"] as const;
|
|
1869
1896
|
const CACHE_OPTIMIZER_STATS_ARGUMENTS = ["all", "contributors"] as const;
|
|
1897
|
+
const CACHE_OPTIMIZER_FIX_ARGUMENTS = ["prompt-cache-key"] as const;
|
|
1870
1898
|
|
|
1871
1899
|
function filterCommandCompletionItems(
|
|
1872
1900
|
values: readonly string[],
|
|
@@ -1917,6 +1945,12 @@ function getCacheOptimizerArgumentCompletions(argumentPrefix: string): CommandCo
|
|
|
1917
1945
|
: null;
|
|
1918
1946
|
}
|
|
1919
1947
|
|
|
1948
|
+
if (parts[0].toLowerCase() === "fix") {
|
|
1949
|
+
return parts.length === 2
|
|
1950
|
+
? filterCommandCompletionItems(CACHE_OPTIMIZER_FIX_ARGUMENTS, parts[1], "fix")
|
|
1951
|
+
: null;
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1920
1954
|
if (parts[0].toLowerCase() !== "config") return null;
|
|
1921
1955
|
|
|
1922
1956
|
if (parts.length === 2) {
|
|
@@ -1934,36 +1968,368 @@ function getCacheOptimizerArgumentCompletions(argumentPrefix: string): CommandCo
|
|
|
1934
1968
|
return null;
|
|
1935
1969
|
}
|
|
1936
1970
|
|
|
1937
|
-
function parsePersistedCacheOptimizerConfig(value: unknown):
|
|
1971
|
+
function parsePersistedCacheOptimizerConfig(value: unknown): PersistedCacheOptimizerConfig | undefined {
|
|
1938
1972
|
const record = asRecord(value);
|
|
1939
|
-
if (!record || record.version !== 1) return undefined;
|
|
1973
|
+
if (!record || (record.version !== 1 && record.version !== 2)) return undefined;
|
|
1974
|
+
const allowedTopLevel = new Set(record.version === 1 ? ["version", "footerMode"] : ["version", "footerMode", "promptCacheKey"]);
|
|
1975
|
+
if (Object.keys(record).some((key) => !allowedTopLevel.has(key))) return undefined;
|
|
1940
1976
|
const footerMode = parseFooterStatsMode(record.footerMode);
|
|
1941
1977
|
if (record.footerMode !== undefined && !footerMode) return undefined;
|
|
1942
|
-
return { version: 1, ...(footerMode ? { footerMode } : {}) };
|
|
1978
|
+
if (record.version === 1) return { version: 1, ...(footerMode ? { footerMode } : {}) };
|
|
1979
|
+
|
|
1980
|
+
const rawPromptCacheKey = record.promptCacheKey;
|
|
1981
|
+
if (rawPromptCacheKey !== undefined && !asRecord(rawPromptCacheKey)) return undefined;
|
|
1982
|
+
const promptCacheKey = asRecord(rawPromptCacheKey);
|
|
1983
|
+
if (promptCacheKey && Object.keys(promptCacheKey).some((key) => key !== "omit")) return undefined;
|
|
1984
|
+
const omit = promptCacheKey?.omit;
|
|
1985
|
+
if (omit !== undefined && (!Array.isArray(omit) || omit.some((value): value is string => !isNonEmptyString(value)))) return undefined;
|
|
1986
|
+
const stringOmit = omit as string[] | undefined;
|
|
1987
|
+
const uniqueOmit = stringOmit ? [...new Set(stringOmit.map((value) => value.trim()))].sort() : undefined;
|
|
1988
|
+
return {
|
|
1989
|
+
version: 2,
|
|
1990
|
+
...(footerMode ? { footerMode } : {}),
|
|
1991
|
+
...(uniqueOmit && uniqueOmit.length > 0 ? { promptCacheKey: { omit: uniqueOmit } } : {}),
|
|
1992
|
+
};
|
|
1993
|
+
}
|
|
1994
|
+
|
|
1995
|
+
function normalizePersistedCacheOptimizerConfig(value: PersistedCacheOptimizerConfig | undefined): PersistedCacheOptimizerConfigV2 {
|
|
1996
|
+
if (!value) return { version: 2 };
|
|
1997
|
+
return value.version === 2
|
|
1998
|
+
? value
|
|
1999
|
+
: { version: 2, ...(value.footerMode ? { footerMode: value.footerMode } : {}) };
|
|
2000
|
+
}
|
|
2001
|
+
|
|
2002
|
+
function readPersistedCacheOptimizerConfig(configPath: string = CONFIG_FILE_PATH): PersistedCacheOptimizerConfigV2 {
|
|
2003
|
+
try {
|
|
2004
|
+
return normalizePersistedCacheOptimizerConfig(parsePersistedCacheOptimizerConfig(JSON.parse(readFileSync(configPath, "utf8"))));
|
|
2005
|
+
} catch (error) {
|
|
2006
|
+
if (getErrorCode(error) !== "ENOENT") console.warn(`${LOG_PREFIX}: failed to read optimizer config; using defaults`, error);
|
|
2007
|
+
return { version: 2 };
|
|
2008
|
+
}
|
|
1943
2009
|
}
|
|
1944
2010
|
|
|
1945
2011
|
function readPersistedFooterMode(configPath: string = CONFIG_FILE_PATH): FooterStatsMode | undefined {
|
|
2012
|
+
return readPersistedCacheOptimizerConfig(configPath).footerMode;
|
|
2013
|
+
}
|
|
2014
|
+
|
|
2015
|
+
async function writePersistedCacheOptimizerConfigUnlocked(
|
|
2016
|
+
config: PersistedCacheOptimizerConfigV2,
|
|
2017
|
+
configPath: string,
|
|
2018
|
+
): Promise<void> {
|
|
2019
|
+
await mkdir(dirname(configPath), { recursive: true });
|
|
2020
|
+
const payloadText = JSON.stringify(normalizePersistedCacheOptimizerConfig(config), null, 2) + "\n";
|
|
2021
|
+
let targetInfo: Awaited<ReturnType<typeof lstat>> | undefined;
|
|
2022
|
+
let targetMode = 0o600;
|
|
2023
|
+
let targetHash: string | undefined;
|
|
1946
2024
|
try {
|
|
1947
|
-
|
|
1948
|
-
if (!
|
|
1949
|
-
|
|
2025
|
+
targetInfo = await lstat(configPath);
|
|
2026
|
+
if (targetInfo.isSymbolicLink() || !targetInfo.isFile()) throw new Error("optimizer config is not a regular file; no changes were made");
|
|
2027
|
+
const targetText = await readFile(configPath, "utf8");
|
|
2028
|
+
targetMode = targetInfo.mode & 0o7777;
|
|
2029
|
+
targetHash = hashText(targetText);
|
|
1950
2030
|
} catch (error) {
|
|
1951
|
-
if (getErrorCode(error) !== "ENOENT")
|
|
1952
|
-
|
|
2031
|
+
if (getErrorCode(error) !== "ENOENT") throw error;
|
|
2032
|
+
}
|
|
2033
|
+
if (targetInfo) {
|
|
2034
|
+
await atomicReplaceTextFilePreservingMode(configPath, payloadText, targetMode, "config", {
|
|
2035
|
+
identity: targetInfo,
|
|
2036
|
+
hash: targetHash,
|
|
2037
|
+
mode: targetMode,
|
|
2038
|
+
});
|
|
2039
|
+
return;
|
|
2040
|
+
}
|
|
2041
|
+
|
|
2042
|
+
const tempPath = uniqueTempPath(configPath, "config");
|
|
2043
|
+
try {
|
|
2044
|
+
await writeFile(tempPath, payloadText, { encoding: "utf8", mode: targetMode, flag: "wx" });
|
|
2045
|
+
await chmod(tempPath, targetMode);
|
|
2046
|
+
try {
|
|
2047
|
+
await lstat(configPath);
|
|
2048
|
+
throw new Error("optimizer config appeared during write; no changes were made");
|
|
2049
|
+
} catch (error) {
|
|
2050
|
+
if (getErrorCode(error) !== "ENOENT") throw error;
|
|
1953
2051
|
}
|
|
1954
|
-
|
|
2052
|
+
await rename(tempPath, configPath);
|
|
2053
|
+
} catch (error) {
|
|
2054
|
+
await unlink(tempPath).catch((cleanupError) => { if (getErrorCode(cleanupError) !== "ENOENT") console.warn(`${LOG_PREFIX}: failed to remove temporary config`, cleanupError); });
|
|
2055
|
+
throw error;
|
|
2056
|
+
}
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
async function writePersistedCacheOptimizerConfig(
|
|
2060
|
+
config: PersistedCacheOptimizerConfigV2,
|
|
2061
|
+
configPath: string = CONFIG_FILE_PATH,
|
|
2062
|
+
): Promise<void> {
|
|
2063
|
+
await withModelsJsonTransactionLock(() => writePersistedCacheOptimizerConfigUnlocked(config, configPath));
|
|
2064
|
+
}
|
|
2065
|
+
|
|
2066
|
+
async function writePersistedFooterModeUnlocked(
|
|
2067
|
+
mode: FooterStatsMode,
|
|
2068
|
+
configPath: string,
|
|
2069
|
+
): Promise<void> {
|
|
2070
|
+
let version: 1 | 2 = 1;
|
|
2071
|
+
let raw: PersistedCacheOptimizerConfig | undefined;
|
|
2072
|
+
let targetExists = false;
|
|
2073
|
+
try {
|
|
2074
|
+
raw = parsePersistedCacheOptimizerConfig(JSON.parse(readFileSync(configPath, "utf8")));
|
|
2075
|
+
targetExists = true;
|
|
2076
|
+
if (!raw) throw new Error("invalid footer config schema");
|
|
2077
|
+
version = raw.version === 2 ? 2 : 1;
|
|
2078
|
+
} catch (error) {
|
|
2079
|
+
if (getErrorCode(error) !== "ENOENT") throw new Error("invalid footer config schema");
|
|
2080
|
+
}
|
|
2081
|
+
const current = normalizePersistedCacheOptimizerConfig(raw);
|
|
2082
|
+
if (version === 1 && !current.promptCacheKey) {
|
|
2083
|
+
await mkdir(dirname(configPath), { recursive: true });
|
|
2084
|
+
const targetInfo = targetExists ? await lstat(configPath) : undefined;
|
|
2085
|
+
if (targetInfo && (targetInfo.isSymbolicLink() || !targetInfo.isFile())) throw new Error("optimizer config is not a regular file; no changes were made");
|
|
2086
|
+
const targetText = targetInfo ? await readFile(configPath, "utf8") : undefined;
|
|
2087
|
+
const targetMode = targetInfo ? targetInfo.mode & 0o7777 : 0o600;
|
|
2088
|
+
const tempPath = uniqueTempPath(configPath, "config-footer");
|
|
2089
|
+
try {
|
|
2090
|
+
await writeFile(tempPath, JSON.stringify({ version: 1, footerMode: mode }, null, 2) + "\n", { encoding: "utf8", mode: targetMode, flag: "wx" });
|
|
2091
|
+
await chmod(tempPath, targetMode);
|
|
2092
|
+
if (targetInfo && targetText !== undefined) {
|
|
2093
|
+
await validateAtomicTarget(configPath, { identity: targetInfo, hash: hashText(targetText), mode: targetMode });
|
|
2094
|
+
} else {
|
|
2095
|
+
try {
|
|
2096
|
+
await lstat(configPath);
|
|
2097
|
+
throw new Error("optimizer config appeared during footer write; no changes were made");
|
|
2098
|
+
} catch (error) {
|
|
2099
|
+
if (getErrorCode(error) !== "ENOENT") throw error;
|
|
2100
|
+
}
|
|
2101
|
+
}
|
|
2102
|
+
await rename(tempPath, configPath);
|
|
2103
|
+
} catch (error) {
|
|
2104
|
+
await unlink(tempPath).catch(() => {});
|
|
2105
|
+
throw error;
|
|
2106
|
+
}
|
|
2107
|
+
return;
|
|
1955
2108
|
}
|
|
2109
|
+
await writePersistedCacheOptimizerConfigUnlocked({ ...current, version: 2, footerMode: mode }, configPath);
|
|
1956
2110
|
}
|
|
1957
2111
|
|
|
1958
2112
|
async function writePersistedFooterMode(
|
|
1959
2113
|
mode: FooterStatsMode,
|
|
1960
2114
|
configPath: string = CONFIG_FILE_PATH,
|
|
1961
2115
|
): Promise<void> {
|
|
2116
|
+
await withModelsJsonTransactionLock(() => writePersistedFooterModeUnlocked(mode, configPath));
|
|
2117
|
+
}
|
|
2118
|
+
|
|
2119
|
+
function configReceiptBackupPath(receipt: PromptCacheKeyConfigReceipt, receiptPath: string = CONFIG_RECEIPT_PATH): string {
|
|
2120
|
+
return join(dirname(receiptPath), receipt.backupFile);
|
|
2121
|
+
}
|
|
2122
|
+
|
|
2123
|
+
function parsePromptCacheKeyConfigReceipt(value: unknown): PromptCacheKeyConfigReceipt | undefined {
|
|
2124
|
+
const record = asRecord(value);
|
|
2125
|
+
if (!record || (record.version !== 1 && record.version !== 2) || record.kind !== "pi-cache-optimizer-config-receipt") return undefined;
|
|
2126
|
+
const allowed = new Set(record.version === 1
|
|
2127
|
+
? ["version", "kind", "transactionId", "provider", "modelId", "beforeHash", "afterHash", "backupFile", "targetExistedBefore", "createdAt", "appliedAt", "status", "rolledBackAt"]
|
|
2128
|
+
: ["version", "kind", "transactionId", "provider", "modelId", "beforeHash", "afterHash", "backupFile", "targetExistedBefore", "targetHadModelKey", "addedModelKey", "createdAt", "appliedAt", "status", "rolledBackAt"]);
|
|
2129
|
+
if (Object.keys(record).some((key) => !allowed.has(key))) return undefined;
|
|
2130
|
+
if (![record.transactionId, record.provider, record.modelId].every(isSafeReceiptText)) return undefined;
|
|
2131
|
+
if (!isSha256(record.beforeHash) || !isSha256(record.afterHash) || record.beforeHash === record.afterHash) return undefined;
|
|
2132
|
+
if (!isSafeReceiptText(record.backupFile) || basename(record.backupFile) !== record.backupFile || !record.backupFile.startsWith("pi-cache-optimizer-config.backup-")) return undefined;
|
|
2133
|
+
if (typeof record.targetExistedBefore !== "boolean" || !isReceiptTimestamp(record.createdAt) || !isReceiptTimestamp(record.appliedAt)) return undefined;
|
|
2134
|
+
const addedModelKey = `${record.provider}/${record.modelId}`;
|
|
2135
|
+
const targetHadModelKey = record.version === 1 ? false : record.targetHadModelKey;
|
|
2136
|
+
if (typeof targetHadModelKey !== "boolean") return undefined;
|
|
2137
|
+
if (record.version === 2 && (!isSafeReceiptText(record.addedModelKey) || record.addedModelKey !== addedModelKey)) return undefined;
|
|
2138
|
+
if (record.status !== undefined && record.status !== "rolled_back") return undefined;
|
|
2139
|
+
if (record.status === "rolled_back" && (!isReceiptTimestamp(record.rolledBackAt) || record.rolledBackAt < record.appliedAt)) return undefined;
|
|
2140
|
+
if (record.status === undefined && record.rolledBackAt !== undefined) return undefined;
|
|
2141
|
+
return {
|
|
2142
|
+
version: 2,
|
|
2143
|
+
kind: "pi-cache-optimizer-config-receipt",
|
|
2144
|
+
transactionId: String(record.transactionId),
|
|
2145
|
+
provider: String(record.provider),
|
|
2146
|
+
modelId: String(record.modelId),
|
|
2147
|
+
beforeHash: String(record.beforeHash).toLowerCase(),
|
|
2148
|
+
afterHash: String(record.afterHash).toLowerCase(),
|
|
2149
|
+
backupFile: String(record.backupFile),
|
|
2150
|
+
targetExistedBefore: record.targetExistedBefore,
|
|
2151
|
+
targetHadModelKey,
|
|
2152
|
+
addedModelKey,
|
|
2153
|
+
createdAt: Number(record.createdAt),
|
|
2154
|
+
appliedAt: Number(record.appliedAt),
|
|
2155
|
+
...(record.status === "rolled_back" ? { status: "rolled_back", rolledBackAt: Number(record.rolledBackAt) } : {}),
|
|
2156
|
+
};
|
|
2157
|
+
}
|
|
2158
|
+
|
|
2159
|
+
function isActionablePromptCacheKeyConfigReceipt(receipt: PromptCacheKeyConfigReceipt | undefined): receipt is PromptCacheKeyConfigReceipt {
|
|
2160
|
+
return receipt !== undefined && receipt.status === undefined;
|
|
2161
|
+
}
|
|
2162
|
+
|
|
2163
|
+
async function readPromptCacheKeyConfigReceipt(receiptPath: string = CONFIG_RECEIPT_PATH): Promise<PromptCacheKeyConfigReceipt | undefined> {
|
|
2164
|
+
try {
|
|
2165
|
+
const info = await lstat(receiptPath);
|
|
2166
|
+
if (info.isSymbolicLink() || !info.isFile()) return undefined;
|
|
2167
|
+
const receipt = parsePromptCacheKeyConfigReceipt(JSON.parse(await readFile(receiptPath, "utf8")));
|
|
2168
|
+
return receipt;
|
|
2169
|
+
} catch {
|
|
2170
|
+
return undefined;
|
|
2171
|
+
}
|
|
2172
|
+
}
|
|
2173
|
+
|
|
2174
|
+
async function writePromptCacheKeyConfigReceipt(receipt: PromptCacheKeyConfigReceipt, receiptPath: string = CONFIG_RECEIPT_PATH): Promise<void> {
|
|
2175
|
+
if (!parsePromptCacheKeyConfigReceipt(receipt)) throw new Error("invalid prompt cache key config receipt");
|
|
2176
|
+
await mkdir(dirname(receiptPath), { recursive: true });
|
|
2177
|
+
const tempPath = uniqueTempPath(receiptPath, "config-receipt");
|
|
2178
|
+
try {
|
|
2179
|
+
await writeFile(tempPath, JSON.stringify(receipt, null, 2) + "\n", { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
2180
|
+
await chmod(tempPath, 0o600);
|
|
2181
|
+
await rename(tempPath, receiptPath);
|
|
2182
|
+
} catch (error) {
|
|
2183
|
+
await unlink(tempPath).catch((cleanupError) => { if (getErrorCode(cleanupError) !== "ENOENT") console.warn(`${LOG_PREFIX}: failed to remove temporary config receipt`, cleanupError); });
|
|
2184
|
+
throw error;
|
|
2185
|
+
}
|
|
2186
|
+
}
|
|
2187
|
+
|
|
2188
|
+
async function applyPromptCacheKeyConfigFixUnderLock(
|
|
2189
|
+
model: PiModel,
|
|
2190
|
+
configPath: string = CONFIG_FILE_PATH,
|
|
2191
|
+
receiptPath: string = CONFIG_RECEIPT_PATH,
|
|
2192
|
+
): Promise<{ receipt: PromptCacheKeyConfigReceipt; backupPath: string }> {
|
|
1962
2193
|
await mkdir(dirname(configPath), { recursive: true });
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
2194
|
+
let originalText = "";
|
|
2195
|
+
let targetExistedBefore = false;
|
|
2196
|
+
let mode = 0o600;
|
|
2197
|
+
try {
|
|
2198
|
+
const info = await lstat(configPath);
|
|
2199
|
+
if (info.isSymbolicLink() || !info.isFile()) throw new Error("optimizer config is not a regular file; no changes were made");
|
|
2200
|
+
originalText = await readFile(configPath, "utf8");
|
|
2201
|
+
targetExistedBefore = true;
|
|
2202
|
+
mode = info.mode & 0o7777;
|
|
2203
|
+
} catch (error) {
|
|
2204
|
+
if (getErrorCode(error) !== "ENOENT") throw error;
|
|
2205
|
+
}
|
|
2206
|
+
const parsedCurrent = targetExistedBefore ? parsePersistedCacheOptimizerConfig(JSON.parse(originalText)) : undefined;
|
|
2207
|
+
if (targetExistedBefore && !parsedCurrent) throw new Error("optimizer config is invalid; no changes were made");
|
|
2208
|
+
const current = normalizePersistedCacheOptimizerConfig(parsedCurrent);
|
|
2209
|
+
const key = modelKey(model);
|
|
2210
|
+
const existingOmit = current.promptCacheKey?.omit ?? [];
|
|
2211
|
+
const targetHadModelKey = existingOmit.includes(key);
|
|
2212
|
+
const omit = [...new Set([...existingOmit, key])].sort();
|
|
2213
|
+
const next: PersistedCacheOptimizerConfigV2 = { ...current, version: 2, promptCacheKey: { omit } };
|
|
2214
|
+
const modifiedText = JSON.stringify(next, null, 2) + "\n";
|
|
2215
|
+
const beforeHash = hashText(originalText);
|
|
2216
|
+
const afterHash = hashText(modifiedText);
|
|
2217
|
+
const originalInfo = targetExistedBefore ? await lstat(configPath) : undefined;
|
|
2218
|
+
const backupFile = `pi-cache-optimizer-config.backup-${backupTimestamp()}`;
|
|
2219
|
+
const backupPath = join(dirname(configPath), backupFile);
|
|
2220
|
+
if (targetExistedBefore) {
|
|
2221
|
+
await copyFile(configPath, backupPath, fsConstants.COPYFILE_EXCL);
|
|
2222
|
+
await chmod(backupPath, mode);
|
|
2223
|
+
}
|
|
2224
|
+
const tempPath = uniqueTempPath(configPath, "config-fix");
|
|
2225
|
+
let committedInfo: Awaited<ReturnType<typeof lstat>> | undefined;
|
|
2226
|
+
try {
|
|
2227
|
+
await writeFile(tempPath, modifiedText, { encoding: "utf8", mode, flag: "wx" });
|
|
2228
|
+
await chmod(tempPath, mode);
|
|
2229
|
+
if (originalInfo) {
|
|
2230
|
+
await validateAtomicTarget(configPath, { identity: originalInfo, hash: beforeHash, mode });
|
|
2231
|
+
} else {
|
|
2232
|
+
try {
|
|
2233
|
+
await lstat(configPath);
|
|
2234
|
+
throw new Error("optimizer config appeared during the fix; no changes were made");
|
|
2235
|
+
} catch (error) {
|
|
2236
|
+
if (getErrorCode(error) !== "ENOENT") throw error;
|
|
2237
|
+
}
|
|
2238
|
+
}
|
|
2239
|
+
await rename(tempPath, configPath);
|
|
2240
|
+
committedInfo = await lstat(configPath);
|
|
2241
|
+
const receipt: PromptCacheKeyConfigReceipt = {
|
|
2242
|
+
version: 2,
|
|
2243
|
+
kind: "pi-cache-optimizer-config-receipt",
|
|
2244
|
+
transactionId: randomUUID(),
|
|
2245
|
+
provider: model.provider,
|
|
2246
|
+
modelId: model.id,
|
|
2247
|
+
beforeHash,
|
|
2248
|
+
afterHash,
|
|
2249
|
+
backupFile,
|
|
2250
|
+
targetExistedBefore,
|
|
2251
|
+
targetHadModelKey,
|
|
2252
|
+
addedModelKey: key,
|
|
2253
|
+
createdAt: Date.now(),
|
|
2254
|
+
appliedAt: Date.now(),
|
|
2255
|
+
};
|
|
2256
|
+
await writePromptCacheKeyConfigReceipt(receipt, receiptPath);
|
|
2257
|
+
return { receipt, backupPath };
|
|
2258
|
+
} catch (error) {
|
|
2259
|
+
await unlink(tempPath).catch(() => {});
|
|
2260
|
+
if (targetExistedBefore && committedInfo) {
|
|
2261
|
+
await atomicRestoreFileFromBackup(backupPath, configPath, mode, { identity: committedInfo, hash: afterHash, mode }).catch(() => {});
|
|
2262
|
+
} else if (!targetExistedBefore && committedInfo) {
|
|
2263
|
+
await validateAtomicTarget(configPath, { identity: committedInfo, hash: afterHash, mode }).then(() => unlink(configPath)).catch(() => {});
|
|
2264
|
+
}
|
|
2265
|
+
throw error;
|
|
2266
|
+
}
|
|
2267
|
+
}
|
|
2268
|
+
|
|
2269
|
+
async function applyPromptCacheKeyConfigFix(
|
|
2270
|
+
model: PiModel,
|
|
2271
|
+
configPath: string = CONFIG_FILE_PATH,
|
|
2272
|
+
receiptPath: string = CONFIG_RECEIPT_PATH,
|
|
2273
|
+
): Promise<{ receipt: PromptCacheKeyConfigReceipt; backupPath: string }> {
|
|
2274
|
+
return withModelsJsonTransactionLock(() => applyPromptCacheKeyConfigFixUnderLock(model, configPath, receiptPath));
|
|
2275
|
+
}
|
|
2276
|
+
|
|
2277
|
+
async function rollbackPromptCacheKeyConfigUnderLock(
|
|
2278
|
+
receipt: PromptCacheKeyConfigReceipt,
|
|
2279
|
+
configPath: string = CONFIG_FILE_PATH,
|
|
2280
|
+
receiptPath: string = CONFIG_RECEIPT_PATH,
|
|
2281
|
+
): Promise<void> {
|
|
2282
|
+
const currentInfo = await lstat(configPath);
|
|
2283
|
+
if (currentInfo.isSymbolicLink() || !currentInfo.isFile()) throw new Error("optimizer config is not a regular file; refusing to overwrite user changes");
|
|
2284
|
+
const currentText = await readFile(configPath, "utf8");
|
|
2285
|
+
if (hashText(currentText) !== receipt.afterHash) throw new Error("optimizer config changed after the fix; refusing to overwrite user changes");
|
|
2286
|
+
const current = parsePersistedCacheOptimizerConfig(JSON.parse(currentText));
|
|
2287
|
+
if (!current) throw new Error("optimizer config is invalid; refusing to overwrite user changes");
|
|
2288
|
+
const key = `${receipt.provider}/${receipt.modelId}`;
|
|
2289
|
+
if (receipt.addedModelKey !== key) throw new Error("prompt-cache-key receipt identity does not match; refusing to change user config");
|
|
2290
|
+
if (receipt.targetHadModelKey) throw new Error("prompt-cache-key was already configured before this fix; refusing to remove user configuration");
|
|
2291
|
+
const omit = current.version === 2 ? current.promptCacheKey?.omit ?? [] : [];
|
|
2292
|
+
if (!omit.includes(receipt.addedModelKey)) throw new Error("prompt-cache-key opt-out is no longer present; refusing to change user config");
|
|
2293
|
+
const backupPath = configReceiptBackupPath(receipt, receiptPath);
|
|
2294
|
+
if (receipt.targetExistedBefore) {
|
|
2295
|
+
const backupInfo = await lstat(backupPath);
|
|
2296
|
+
if (backupInfo.isSymbolicLink() || !backupInfo.isFile()) throw new Error("config backup is not a regular file");
|
|
2297
|
+
const backupText = await readFile(backupPath, "utf8");
|
|
2298
|
+
if (hashText(backupText) !== receipt.beforeHash) throw new Error("config backup hash does not match the fix receipt");
|
|
2299
|
+
await atomicRestoreFileFromBackup(
|
|
2300
|
+
backupPath,
|
|
2301
|
+
configPath,
|
|
2302
|
+
backupInfo.mode & 0o7777,
|
|
2303
|
+
{ backupHash: receipt.beforeHash, identity: currentInfo, hash: hashText(currentText), mode: currentInfo.mode & 0o7777 },
|
|
2304
|
+
);
|
|
2305
|
+
} else {
|
|
2306
|
+
if (current.footerMode || omit.length > 1) {
|
|
2307
|
+
const restored: PersistedCacheOptimizerConfigV2 = {
|
|
2308
|
+
version: 2,
|
|
2309
|
+
...(current.footerMode ? { footerMode: current.footerMode } : {}),
|
|
2310
|
+
...(omit.length > 1 ? { promptCacheKey: { omit: omit.filter((item) => item !== receipt.addedModelKey) } } : {}),
|
|
2311
|
+
};
|
|
2312
|
+
await atomicReplaceTextFilePreservingMode(
|
|
2313
|
+
configPath,
|
|
2314
|
+
JSON.stringify(restored, null, 2) + "\n",
|
|
2315
|
+
currentInfo.mode & 0o7777,
|
|
2316
|
+
"config-rollback",
|
|
2317
|
+
{ identity: currentInfo, hash: hashText(currentText), mode: currentInfo.mode & 0o7777 },
|
|
2318
|
+
);
|
|
2319
|
+
} else {
|
|
2320
|
+
await validateAtomicTarget(configPath, { identity: currentInfo, hash: hashText(currentText), mode: currentInfo.mode & 0o7777 });
|
|
2321
|
+
await unlink(configPath);
|
|
2322
|
+
}
|
|
2323
|
+
}
|
|
2324
|
+
await writePromptCacheKeyConfigReceipt({ ...receipt, status: "rolled_back", rolledBackAt: Date.now() }, receiptPath);
|
|
2325
|
+
}
|
|
2326
|
+
|
|
2327
|
+
async function rollbackPromptCacheKeyConfig(
|
|
2328
|
+
receipt: PromptCacheKeyConfigReceipt,
|
|
2329
|
+
configPath: string = CONFIG_FILE_PATH,
|
|
2330
|
+
receiptPath: string = CONFIG_RECEIPT_PATH,
|
|
2331
|
+
): Promise<void> {
|
|
2332
|
+
return withModelsJsonTransactionLock(() => rollbackPromptCacheKeyConfigUnderLock(receipt, configPath, receiptPath));
|
|
1967
2333
|
}
|
|
1968
2334
|
|
|
1969
2335
|
function resolveFooterStatsMode(
|
|
@@ -1982,7 +2348,8 @@ function footerStatsMode(
|
|
|
1982
2348
|
return resolveFooterStatsMode(configuredMode, env).mode;
|
|
1983
2349
|
}
|
|
1984
2350
|
|
|
1985
|
-
let
|
|
2351
|
+
let persistedCacheOptimizerConfig = readPersistedCacheOptimizerConfig();
|
|
2352
|
+
let persistedFooterStatsMode = persistedCacheOptimizerConfig.footerMode;
|
|
1986
2353
|
|
|
1987
2354
|
function isDisabledEnv(value: string | undefined): boolean {
|
|
1988
2355
|
if (!value) return false;
|
|
@@ -2104,6 +2471,25 @@ function shouldInjectOpenAIPromptCacheKeyForModel(model: PiModel | undefined): b
|
|
|
2104
2471
|
return isOpenAICompatibleApi(model?.api);
|
|
2105
2472
|
}
|
|
2106
2473
|
|
|
2474
|
+
function isPromptCacheKeyOmittedForModel(model: PiModel | undefined, config: PersistedCacheOptimizerConfigV2 = persistedCacheOptimizerConfig): boolean {
|
|
2475
|
+
if (!model || !isOpenAICompatibleApi(model.api)) return false;
|
|
2476
|
+
return config.promptCacheKey?.omit?.includes(modelKey(model)) === true;
|
|
2477
|
+
}
|
|
2478
|
+
|
|
2479
|
+
function setPersistedCacheOptimizerConfig(config: PersistedCacheOptimizerConfigV2): void {
|
|
2480
|
+
persistedCacheOptimizerConfig = normalizePersistedCacheOptimizerConfig(config);
|
|
2481
|
+
persistedFooterStatsMode = persistedCacheOptimizerConfig.footerMode;
|
|
2482
|
+
}
|
|
2483
|
+
|
|
2484
|
+
function omitOpenAIPromptCacheKeys(payload: unknown): unknown | undefined {
|
|
2485
|
+
const record = asRecord(payload);
|
|
2486
|
+
if (!record || (!Object.prototype.hasOwnProperty.call(record, "prompt_cache_key") && !Object.prototype.hasOwnProperty.call(record, "promptCacheKey"))) return undefined;
|
|
2487
|
+
const copy = { ...record };
|
|
2488
|
+
delete copy.prompt_cache_key;
|
|
2489
|
+
delete copy.promptCacheKey;
|
|
2490
|
+
return copy;
|
|
2491
|
+
}
|
|
2492
|
+
|
|
2107
2493
|
function collectAnthropicCacheControlsInWireOrder(payload: unknown): UnknownRecord[] {
|
|
2108
2494
|
const record = asRecord(payload);
|
|
2109
2495
|
if (!record) return [];
|
|
@@ -3287,6 +3673,41 @@ function hasPromptCacheRetentionUnsupportedErrorMessage(message: unknown): boole
|
|
|
3287
3673
|
hasPromptCacheRetentionUnsupportedText(record.errorMessage);
|
|
3288
3674
|
}
|
|
3289
3675
|
|
|
3676
|
+
function hasPromptCacheKeyUnsupportedText(value: unknown): boolean {
|
|
3677
|
+
const normalized = lower(value).replace(/["'`]/g, "").replace(/[_-]+/g, "_").replace(/\s+/g, " ").trim();
|
|
3678
|
+
const key = "(?:prompt ?cache ?key|prompt_cache_key)";
|
|
3679
|
+
if (!new RegExp(key).test(normalized)) return false;
|
|
3680
|
+
|
|
3681
|
+
// Only classify a field-level capability rejection. Value validation,
|
|
3682
|
+
// conditional restrictions, and generic invalid-parameter messages are not
|
|
3683
|
+
// proof that the endpoint lacks support for the field.
|
|
3684
|
+
return [
|
|
3685
|
+
new RegExp(`unsupported (?:parameter|field|input|argument)[: ]+.*${key}`),
|
|
3686
|
+
new RegExp(`unknown (?:parameter|field|input|argument)[: ]+.*${key}`),
|
|
3687
|
+
new RegExp(`unrecognized (?:parameter|field|input|argument)[: ]+.*${key}`),
|
|
3688
|
+
new RegExp(`(?:parameter|field|input|argument)[: ]+${key} (?:is )?(?:unsupported|unknown|unrecognized|not supported|not allowed|not permitted)`),
|
|
3689
|
+
new RegExp(`${key} (?:is )?(?:unsupported|unknown|unrecognized|not supported|not allowed|not permitted)`),
|
|
3690
|
+
new RegExp(`extra inputs?(?: are)? (?:not permitted|not allowed)[: ]+.*${key}`),
|
|
3691
|
+
new RegExp(`(?:not supported|not permitted|not allowed)[: ]+.*${key}`),
|
|
3692
|
+
].some((pattern) => pattern.test(normalized));
|
|
3693
|
+
}
|
|
3694
|
+
|
|
3695
|
+
function hasPromptCacheKeyUnsupportedSignal(headers: Record<string, string> | undefined): boolean {
|
|
3696
|
+
if (!headers) return false;
|
|
3697
|
+
return Object.entries(headers).some(([key, value]) => hasPromptCacheKeyUnsupportedText(`${key}: ${value}`));
|
|
3698
|
+
}
|
|
3699
|
+
|
|
3700
|
+
function hasPromptCacheKeyUnsupportedErrorMessage(message: unknown): boolean {
|
|
3701
|
+
const record = getAssistantRecord(message);
|
|
3702
|
+
return record?.stopReason === "error" &&
|
|
3703
|
+
getOptionalAssistantHttpStatus(record) === 400 &&
|
|
3704
|
+
hasPromptCacheKeyUnsupportedText(record.errorMessage);
|
|
3705
|
+
}
|
|
3706
|
+
|
|
3707
|
+
function isPromptCacheKeyUnsupportedApplicable(model: PiModel): boolean {
|
|
3708
|
+
return isOpenAICompatibleApi(model.api);
|
|
3709
|
+
}
|
|
3710
|
+
|
|
3290
3711
|
function hasReasoningProtocolRejectionText(value: unknown): boolean {
|
|
3291
3712
|
const normalized = lower(value).replace(/\s+/g, " ").trim();
|
|
3292
3713
|
if (!normalized) return false;
|
|
@@ -6107,7 +6528,7 @@ function getCompatCheckNotApplicableLines(model: PiModel): string[] {
|
|
|
6107
6528
|
return ["ℹ️ Compat check not applicable for this model."];
|
|
6108
6529
|
}
|
|
6109
6530
|
|
|
6110
|
-
function buildDoctorDiagnosis(model: PiModel, options: { promptCacheRetention400?: boolean; anthropicTtlOrderError?: boolean; sessionAffinity403?: boolean; openAISdkHeader403?: boolean } = {}): string {
|
|
6531
|
+
function buildDoctorDiagnosis(model: PiModel, options: { promptCacheRetention400?: boolean; promptCacheKey400?: boolean; anthropicTtlOrderError?: boolean; sessionAffinity403?: boolean; openAISdkHeader403?: boolean } = {}): string {
|
|
6111
6532
|
const lines: string[] = [];
|
|
6112
6533
|
lines.push(`Provider: ${model.provider}`);
|
|
6113
6534
|
lines.push(`Model: ${model.id}`);
|
|
@@ -6157,6 +6578,13 @@ function buildDoctorDiagnosis(model: PiModel, options: { promptCacheRetention400
|
|
|
6157
6578
|
lines.push(...getCompatCheckNotApplicableLines(model));
|
|
6158
6579
|
}
|
|
6159
6580
|
|
|
6581
|
+
if (options.promptCacheKey400 && isPromptCacheKeyUnsupportedApplicable(model)) {
|
|
6582
|
+
lines.push("");
|
|
6583
|
+
lines.push("⚠️ This model previously rejected prompt_cache_key with an explicit HTTP 400 signal.");
|
|
6584
|
+
lines.push(" /cache-optimizer fix will offer a precise model-scoped opt-out.");
|
|
6585
|
+
lines.push(" The opt-out removes both prompt_cache_key and promptCacheKey from the final request body.");
|
|
6586
|
+
}
|
|
6587
|
+
|
|
6160
6588
|
if (isPromptCacheRetention400Applicable(model)) {
|
|
6161
6589
|
lines.push("");
|
|
6162
6590
|
if (options.promptCacheRetention400) {
|
|
@@ -9245,6 +9673,8 @@ export const __internals_for_tests = {
|
|
|
9245
9673
|
addOpenAIPromptCacheKey,
|
|
9246
9674
|
clampPromptCacheKey,
|
|
9247
9675
|
hasEffectivePromptCacheKey,
|
|
9676
|
+
omitOpenAIPromptCacheKeys,
|
|
9677
|
+
isPromptCacheKeyOmittedForModel,
|
|
9248
9678
|
isNonEmptyString,
|
|
9249
9679
|
shouldInjectOpenAIPromptCacheKey,
|
|
9250
9680
|
shouldInjectOpenAIPromptCacheKeyForModel,
|
|
@@ -9281,6 +9711,9 @@ export const __internals_for_tests = {
|
|
|
9281
9711
|
isOpenAISdkHeader403Applicable,
|
|
9282
9712
|
hasPromptCacheRetentionUnsupportedSignal,
|
|
9283
9713
|
hasPromptCacheRetentionUnsupportedErrorMessage,
|
|
9714
|
+
hasPromptCacheKeyUnsupportedSignal,
|
|
9715
|
+
hasPromptCacheKeyUnsupportedErrorMessage,
|
|
9716
|
+
isPromptCacheKeyUnsupportedApplicable,
|
|
9284
9717
|
hasReasoningProtocolRejectionText,
|
|
9285
9718
|
hasReasoningProtocolRejectionSignal,
|
|
9286
9719
|
hasReasoningProtocolRejectionErrorMessage,
|
|
@@ -9475,11 +9908,22 @@ export const __internals_for_tests = {
|
|
|
9475
9908
|
selectFooterStatsForModel,
|
|
9476
9909
|
parseFooterStatsMode,
|
|
9477
9910
|
parsePersistedCacheOptimizerConfig,
|
|
9911
|
+
readPersistedCacheOptimizerConfig,
|
|
9912
|
+
writePersistedCacheOptimizerConfig,
|
|
9478
9913
|
readPersistedFooterMode,
|
|
9479
9914
|
writePersistedFooterMode,
|
|
9915
|
+
setPersistedCacheOptimizerConfig,
|
|
9480
9916
|
resolveFooterStatsMode,
|
|
9481
9917
|
footerStatsMode,
|
|
9482
9918
|
CONFIG_FILE_PATH,
|
|
9919
|
+
CONFIG_RECEIPT_PATH,
|
|
9920
|
+
parsePromptCacheKeyConfigReceipt,
|
|
9921
|
+
isActionablePromptCacheKeyConfigReceipt,
|
|
9922
|
+
readPromptCacheKeyConfigReceipt,
|
|
9923
|
+
writePromptCacheKeyConfigReceipt,
|
|
9924
|
+
applyPromptCacheKeyConfigFix,
|
|
9925
|
+
rollbackPromptCacheKeyConfig,
|
|
9926
|
+
configReceiptBackupPath,
|
|
9483
9927
|
FOOTER_MODE_ENV,
|
|
9484
9928
|
// Routing-provider protocol helpers
|
|
9485
9929
|
PI_ROUTING_REGISTRY_SYMBOL,
|
|
@@ -9592,6 +10036,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
9592
10036
|
const reasoningProtocolFallbackState = getReasoningProtocolFallbackState();
|
|
9593
10037
|
const reasoningProtocolRejectedModels = reasoningProtocolFallbackState.modelKeys;
|
|
9594
10038
|
const warnedReasoningProtocolRejectedModels = reasoningProtocolFallbackState.warnedModelKeys;
|
|
10039
|
+
const promptCacheKeyRejectedModels = new Set<string>();
|
|
10040
|
+
const warnedPromptCacheKeyRejectedModels = new Set<string>();
|
|
9595
10041
|
let cacheStatsByModel: Record<string, CacheStats> = {};
|
|
9596
10042
|
let cacheStatsProcessByModel: Record<string, CacheStats> = {};
|
|
9597
10043
|
let cacheStatsTotalsByModel: Record<string, CacheStats> = {};
|
|
@@ -9615,7 +10061,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
9615
10061
|
let currentSessionHashSet = false;
|
|
9616
10062
|
let lastActualRoutedModel: PersistedRoutedModelRef | undefined;
|
|
9617
10063
|
let latestCacheHint: PiCacheHintSnapshot | undefined;
|
|
9618
|
-
|
|
10064
|
+
// Pi's response hooks do not expose a request id. Track one FIFO lifecycle
|
|
10065
|
+
// record per request and mark records when their response hook arrives. A
|
|
10066
|
+
// finalized assistant message consumes the oldest completed record, falling
|
|
10067
|
+
// back to the oldest request only when a transport emitted no response hook.
|
|
10068
|
+
// This preserves response A when request B/C starts before message_end(A).
|
|
10069
|
+
type ProviderRequestState = { model: PiModel; responseReceived: boolean };
|
|
10070
|
+
const providerRequestStates: ProviderRequestState[] = [];
|
|
9619
10071
|
let shardCreatedAt = Date.now();
|
|
9620
10072
|
const PERSIST_DEBOUNCE_MS = 2000;
|
|
9621
10073
|
const SHARD_REFRESH_DEBOUNCE_MS = 250;
|
|
@@ -9661,6 +10113,21 @@ export default function (pi: ExtensionAPI) {
|
|
|
9661
10113
|
forceModelLevel: regular.forceModelLevel || observed.forceModelLevel,
|
|
9662
10114
|
};
|
|
9663
10115
|
}
|
|
10116
|
+
|
|
10117
|
+
function promptCacheKeyFixApplies(model: PiModel): boolean {
|
|
10118
|
+
return isPromptCacheKeyUnsupportedApplicable(model) && promptCacheKeyRejectedModels.has(modelKey(model));
|
|
10119
|
+
}
|
|
10120
|
+
|
|
10121
|
+
function buildPromptCacheKeyConfigPreview(model: PiModel): string[] {
|
|
10122
|
+
const key = modelKey(model);
|
|
10123
|
+
return [
|
|
10124
|
+
`Target: ${key}`,
|
|
10125
|
+
`Action: persist prompt_cache_key mode = omit in ${CONFIG_FILE_PATH}.`,
|
|
10126
|
+
"The final request body will omit both prompt_cache_key and promptCacheKey.",
|
|
10127
|
+
"This can reduce provider-side prompt-cache reuse for this model; it does not change prompts, credentials, headers, or other models.",
|
|
10128
|
+
"The setting applies after Pi core has built the payload, including when Pi already supplied a key.",
|
|
10129
|
+
];
|
|
10130
|
+
}
|
|
9664
10131
|
/** In-memory recent usage samples per model key (not persisted, cleared on reload). */
|
|
9665
10132
|
const recentSamplesByModelKey = new Map<string, CacheUsageSample[]>();
|
|
9666
10133
|
|
|
@@ -10158,7 +10625,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
10158
10625
|
shardWatcher?.close();
|
|
10159
10626
|
shardWatcher = undefined;
|
|
10160
10627
|
latestCacheHint = undefined;
|
|
10161
|
-
|
|
10628
|
+
providerRequestStates.length = 0;
|
|
10162
10629
|
delete getProtocolGlobal().__piCacheOptimizerCacheKey__;
|
|
10163
10630
|
uninstallCacheHintsService();
|
|
10164
10631
|
restoreCacheRetentionEnv(STARTUP_CACHE_RETENTION_ENV);
|
|
@@ -10300,9 +10767,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
10300
10767
|
|
|
10301
10768
|
pi.on("before_provider_request", (event, ctx) => {
|
|
10302
10769
|
const requestModel = resolveRouteModel(ctx.model, ctx) ?? ctx.model;
|
|
10303
|
-
|
|
10304
|
-
|
|
10305
|
-
:
|
|
10770
|
+
if (runtimeOptimizerEnabled) {
|
|
10771
|
+
const snapshot = snapshotProviderRequestModel(requestModel);
|
|
10772
|
+
if (snapshot) providerRequestStates.push({ model: snapshot, responseReceived: false });
|
|
10773
|
+
}
|
|
10306
10774
|
let requestPayload: unknown = event.payload;
|
|
10307
10775
|
let toolOrderChanged = false;
|
|
10308
10776
|
|
|
@@ -10356,6 +10824,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
10356
10824
|
}
|
|
10357
10825
|
}
|
|
10358
10826
|
|
|
10827
|
+
if (isPromptCacheKeyOmittedForModel(requestModel)) {
|
|
10828
|
+
const omitted = omitOpenAIPromptCacheKeys(requestPayload);
|
|
10829
|
+
return omitted ?? (toolOrderChanged ? requestPayload : undefined);
|
|
10830
|
+
}
|
|
10831
|
+
|
|
10359
10832
|
if (!shouldInjectOpenAIPromptCacheKey() || !shouldInjectOpenAIPromptCacheKeyForModel(requestModel)) {
|
|
10360
10833
|
return toolOrderChanged ? requestPayload : undefined;
|
|
10361
10834
|
}
|
|
@@ -10365,16 +10838,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
10365
10838
|
});
|
|
10366
10839
|
|
|
10367
10840
|
pi.on("after_provider_response", async (event, ctx) => {
|
|
10368
|
-
|
|
10369
|
-
|
|
10370
|
-
|
|
10371
|
-
|
|
10372
|
-
const correlatedModel = pendingProviderResponseModel;
|
|
10373
|
-
const model = correlatedModel ?? (resolveRouteModel(ctx.model, ctx) ?? ctx.model);
|
|
10374
|
-
if (!runtimeOptimizerEnabled || !model) {
|
|
10375
|
-
pendingProviderResponseModel = undefined;
|
|
10376
|
-
return;
|
|
10377
|
-
}
|
|
10841
|
+
const responseState = providerRequestStates.find((state) => !state.responseReceived);
|
|
10842
|
+
if (responseState) responseState.responseReceived = true;
|
|
10843
|
+
const model = responseState?.model ?? (resolveRouteModel(ctx.model, ctx) ?? ctx.model);
|
|
10844
|
+
if (!runtimeOptimizerEnabled || !model) return;
|
|
10378
10845
|
|
|
10379
10846
|
// Keep only the category, never the provider's complete error text. This
|
|
10380
10847
|
// is evidence for a later, model-scoped `/fix`; it never edits config from
|
|
@@ -10392,22 +10859,36 @@ export default function (pi: ExtensionAPI) {
|
|
|
10392
10859
|
);
|
|
10393
10860
|
}
|
|
10394
10861
|
|
|
10395
|
-
// ── 400:
|
|
10396
|
-
if (event.status === 400) {
|
|
10397
|
-
|
|
10398
|
-
|
|
10862
|
+
// ── 400: prompt_cache_key unsupported ──
|
|
10863
|
+
if (event.status === 400 && isPromptCacheKeyUnsupportedApplicable(model) && hasPromptCacheKeyUnsupportedSignal(event.headers)) {
|
|
10864
|
+
const key = modelKey(model);
|
|
10865
|
+
promptCacheKeyRejectedModels.add(key);
|
|
10866
|
+
if (!warnedPromptCacheKeyRejectedModels.has(key)) {
|
|
10867
|
+
warnedPromptCacheKeyRejectedModels.add(key);
|
|
10868
|
+
ctx.ui.notify(
|
|
10869
|
+
`⚠️ ${LOG_PREFIX}: ${key} rejected prompt_cache_key. Run /cache-optimizer fix to review a precise model-scoped repair. No configuration was changed automatically.`,
|
|
10870
|
+
"warning",
|
|
10871
|
+
);
|
|
10872
|
+
}
|
|
10873
|
+
}
|
|
10399
10874
|
|
|
10875
|
+
// ── 400: prompt_cache_retention unsupported ──
|
|
10876
|
+
if (
|
|
10877
|
+
event.status === 400 &&
|
|
10878
|
+
isPromptCacheRetention400Applicable(model) &&
|
|
10879
|
+
hasPromptCacheRetentionUnsupportedSignal(event.headers)
|
|
10880
|
+
) {
|
|
10400
10881
|
const key = modelKey(model);
|
|
10401
10882
|
promptCacheRetention400Models.add(key);
|
|
10402
|
-
if (warnedPromptCacheRetention400Models.has(key))
|
|
10403
|
-
|
|
10404
|
-
|
|
10405
|
-
|
|
10406
|
-
|
|
10407
|
-
|
|
10408
|
-
|
|
10409
|
-
|
|
10410
|
-
|
|
10883
|
+
if (!warnedPromptCacheRetention400Models.has(key)) {
|
|
10884
|
+
warnedPromptCacheRetention400Models.add(key);
|
|
10885
|
+
ctx.ui.notify(
|
|
10886
|
+
`⚠️ ${LOG_PREFIX}: ${key} returned HTTP 400 while supportsLongCacheRetention is enabled. ` +
|
|
10887
|
+
getPromptCacheRetentionUnsupportedHint() +
|
|
10888
|
+
` Run /cache-optimizer doctor for the exact edit location.`,
|
|
10889
|
+
"warning",
|
|
10890
|
+
);
|
|
10891
|
+
}
|
|
10411
10892
|
}
|
|
10412
10893
|
|
|
10413
10894
|
// ── 403: proxy/CDN/WAF blocking ──
|
|
@@ -10453,14 +10934,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
10453
10934
|
syncSessionHash(ctx);
|
|
10454
10935
|
const msgRecord = asRecord(event.message);
|
|
10455
10936
|
const requestModelForMessage = msgRecord?.role === "assistant"
|
|
10456
|
-
?
|
|
10937
|
+
? (() => {
|
|
10938
|
+
const completedIndex = providerRequestStates.findIndex((state) => state.responseReceived);
|
|
10939
|
+
const index = completedIndex >= 0 ? completedIndex : 0;
|
|
10940
|
+
return providerRequestStates.splice(index, 1)[0]?.model;
|
|
10941
|
+
})()
|
|
10457
10942
|
: undefined;
|
|
10458
|
-
if (msgRecord?.role === "assistant") {
|
|
10459
|
-
// If a transport failed before emitting after_provider_response, do not
|
|
10460
|
-
// let its request snapshot be consumed by a later response. Normal
|
|
10461
|
-
// responses already clear this in after_provider_response.
|
|
10462
|
-
pendingProviderResponseModel = undefined;
|
|
10463
|
-
}
|
|
10464
10943
|
|
|
10465
10944
|
// Some providers expose an HTTP 400 error body only through the finalized
|
|
10466
10945
|
// assistant error message; after_provider_response may contain the status
|
|
@@ -10482,6 +10961,24 @@ export default function (pi: ExtensionAPI) {
|
|
|
10482
10961
|
);
|
|
10483
10962
|
}
|
|
10484
10963
|
}
|
|
10964
|
+
if (runtimeOptimizerEnabled && hasPromptCacheKeyUnsupportedErrorMessage(event.message)) {
|
|
10965
|
+
const fallbackModel = requestModelForMessage ?? (resolveRouteModel(ctx.model, ctx) ?? ctx.model);
|
|
10966
|
+
const messageModel = modelFromAssistantMessage(event.message, fallbackModel) ?? fallbackModel;
|
|
10967
|
+
const errorModel = messageModel
|
|
10968
|
+
? findModelInRegistry(ctx.modelRegistry, messageModel.provider, messageModel.id) ?? messageModel
|
|
10969
|
+
: undefined;
|
|
10970
|
+
if (errorModel && isPromptCacheKeyUnsupportedApplicable(errorModel)) {
|
|
10971
|
+
const key = modelKey(errorModel);
|
|
10972
|
+
promptCacheKeyRejectedModels.add(key);
|
|
10973
|
+
if (!warnedPromptCacheKeyRejectedModels.has(key)) {
|
|
10974
|
+
warnedPromptCacheKeyRejectedModels.add(key);
|
|
10975
|
+
ctx.ui.notify(
|
|
10976
|
+
`⚠️ ${LOG_PREFIX}: ${key} rejected prompt_cache_key. Run /cache-optimizer fix to review a precise model-scoped repair. No configuration was changed automatically.`,
|
|
10977
|
+
"warning",
|
|
10978
|
+
);
|
|
10979
|
+
}
|
|
10980
|
+
}
|
|
10981
|
+
}
|
|
10485
10982
|
if (runtimeOptimizerEnabled && hasPromptCacheRetentionUnsupportedErrorMessage(event.message)) {
|
|
10486
10983
|
const fallbackModel = requestModelForMessage
|
|
10487
10984
|
?? (resolveRouteModel(ctx.model, ctx) ?? ctx.model);
|
|
@@ -10630,7 +11127,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
10630
11127
|
// stats — show detailed current-session models; `stats all` shows all local shards
|
|
10631
11128
|
// compat — show compat suggestion with file path
|
|
10632
11129
|
// config footer-mode total|session|process — persist footer mode override
|
|
10633
|
-
// fix — auto-fix compat issues (writes models.json, requires UI)
|
|
11130
|
+
// fix — auto-fix compat issues (writes models.json or extension config, requires UI)
|
|
10634
11131
|
// rollback — undo the latest confirmed fix for the active model
|
|
10635
11132
|
// reset — reset current provider/model footer stats bucket (local only)
|
|
10636
11133
|
// (no args) — interactive menu (with UI) or help summary
|
|
@@ -10653,7 +11150,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
10653
11150
|
cmdCtx.ui.notify(`✅ Pi Cache Optimizer enabled for this Pi process. Local footer stats were reset for before/after comparison.\n${formatOptimizerRuntimeMode()}`, "info");
|
|
10654
11151
|
} else if (subcommand === "disable") {
|
|
10655
11152
|
setRuntimeOptimizerEnabled(false);
|
|
10656
|
-
|
|
11153
|
+
providerRequestStates.length = 0;
|
|
10657
11154
|
await resetCurrentSessionStats();
|
|
10658
11155
|
await flushPersistCacheStats(cmdCtx);
|
|
10659
11156
|
await publishStatus(cmdCtx, model);
|
|
@@ -10664,7 +11161,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
10664
11161
|
cmdCtx.ui.notify("No active model selected. Select a model first with /model or pi --model.", "warning");
|
|
10665
11162
|
return;
|
|
10666
11163
|
}
|
|
10667
|
-
const diagnosis = buildDoctorDiagnosis(model, { promptCacheRetention400: promptCacheRetention400Models.has(modelKey(model)), anthropicTtlOrderError: anthropicTtlOrderErrorModels.has(modelKey(model)), sessionAffinity403: sendSessionAffinityHeaders403Models.has(modelKey(model)), openAISdkHeader403: openAISdkHeader403Models.has(modelKey(model)) });
|
|
11164
|
+
const diagnosis = buildDoctorDiagnosis(model, { promptCacheRetention400: promptCacheRetention400Models.has(modelKey(model)), promptCacheKey400: promptCacheKeyRejectedModels.has(modelKey(model)), anthropicTtlOrderError: anthropicTtlOrderErrorModels.has(modelKey(model)), sessionAffinity403: sendSessionAffinityHeaders403Models.has(modelKey(model)), openAISdkHeader403: openAISdkHeader403Models.has(modelKey(model)) });
|
|
10668
11165
|
const adapter = selectAdapterForModel(model);
|
|
10669
11166
|
const sk = model ? sessionModelKey(model) : undefined;
|
|
10670
11167
|
const statsState = model ? cacheStatsTotalsByModel[modelKey(model)] : undefined;
|
|
@@ -10703,7 +11200,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
10703
11200
|
const nextMode = requestedMode as FooterStatsMode;
|
|
10704
11201
|
try {
|
|
10705
11202
|
await writePersistedFooterMode(nextMode);
|
|
10706
|
-
|
|
11203
|
+
setPersistedCacheOptimizerConfig(readPersistedCacheOptimizerConfig());
|
|
10707
11204
|
lastStatusText = undefined;
|
|
10708
11205
|
await publishStatus(cmdCtx, model);
|
|
10709
11206
|
const resolved = resolveFooterStatsMode(persistedFooterStatsMode);
|
|
@@ -10738,6 +11235,34 @@ export default function (pi: ExtensionAPI) {
|
|
|
10738
11235
|
cmdCtx.ui.notify("No active model selected. Select a model first with /model or pi --model.", "warning");
|
|
10739
11236
|
return;
|
|
10740
11237
|
}
|
|
11238
|
+
const configReceipt = await readPromptCacheKeyConfigReceipt();
|
|
11239
|
+
const modelsReceipt = await readModelsJsonFixReceipt();
|
|
11240
|
+
const useConfigReceipt = isActionablePromptCacheKeyConfigReceipt(configReceipt) &&
|
|
11241
|
+
configReceipt.provider === model.provider &&
|
|
11242
|
+
configReceipt.modelId === model.id &&
|
|
11243
|
+
(!isActionableModelsJsonFixReceipt(modelsReceipt) || modelsReceipt.provider !== model.provider || modelsReceipt.modelId !== model.id || configReceipt.appliedAt >= modelsReceipt.appliedAt);
|
|
11244
|
+
if (useConfigReceipt) {
|
|
11245
|
+
if (!cmdCtx.hasUI) {
|
|
11246
|
+
cmdCtx.ui.notify("❌ Rollback requires interactive confirmation. No changes were made.\nRun /cache-optimizer rollback in Pi's interactive UI to restore the prompt-cache-key setting.", "warning");
|
|
11247
|
+
return;
|
|
11248
|
+
}
|
|
11249
|
+
const confirmed = await cmdCtx.ui.confirm(
|
|
11250
|
+
"Cache Optimizer — Rollback prompt_cache_key opt-out",
|
|
11251
|
+
`Model: ${modelKey(model)}\nAction: restore the extension config before the confirmed opt-out.\nFooter mode and unrelated configuration will be preserved.\nAfterward, run /reload or restart Pi.\n\nProceed with rollback?`,
|
|
11252
|
+
);
|
|
11253
|
+
if (!confirmed) {
|
|
11254
|
+
cmdCtx.ui.notify("No changes were made. Rollback canceled by user.", "info");
|
|
11255
|
+
return;
|
|
11256
|
+
}
|
|
11257
|
+
try {
|
|
11258
|
+
await rollbackPromptCacheKeyConfig(configReceipt);
|
|
11259
|
+
setPersistedCacheOptimizerConfig(readPersistedCacheOptimizerConfig());
|
|
11260
|
+
cmdCtx.ui.notify(`✅ Restored prompt_cache_key behavior for ${modelKey(model)}. Run /reload or restart Pi for the change to take effect.`, "info");
|
|
11261
|
+
} catch (error) {
|
|
11262
|
+
cmdCtx.ui.notify(`❌ Prompt cache key rollback refused: ${error instanceof Error ? error.message : String(error)}. No changes were made.`, "error");
|
|
11263
|
+
}
|
|
11264
|
+
return;
|
|
11265
|
+
}
|
|
10741
11266
|
if (!cmdCtx.hasUI) {
|
|
10742
11267
|
const receipt = await readModelsJsonFixReceipt();
|
|
10743
11268
|
const backupHint = receipt && isActionableModelsJsonFixReceipt(receipt)
|
|
@@ -10868,7 +11393,58 @@ export default function (pi: ExtensionAPI) {
|
|
|
10868
11393
|
return;
|
|
10869
11394
|
}
|
|
10870
11395
|
|
|
10871
|
-
const
|
|
11396
|
+
const promptCacheKeyRequested = commandParts[1] === "prompt-cache-key";
|
|
11397
|
+
const promptCacheKeyFixRequested = promptCacheKeyRequested || promptCacheKeyFixApplies(model);
|
|
11398
|
+
if (promptCacheKeyRequested && !isPromptCacheKeyUnsupportedApplicable(model)) {
|
|
11399
|
+
cmdCtx.ui.notify("ℹ️ Prompt cache key opt-out applies only to a known OpenAI-compatible provider/model endpoint. No changes were made.", "info");
|
|
11400
|
+
return;
|
|
11401
|
+
}
|
|
11402
|
+
const suggestion = promptCacheKeyFixRequested ? undefined : buildCommandFixSuggestion(model);
|
|
11403
|
+
|
|
11404
|
+
if (promptCacheKeyFixRequested) {
|
|
11405
|
+
if (isPromptCacheKeyOmittedForModel(model)) {
|
|
11406
|
+
cmdCtx.ui.notify(`✅ prompt_cache_key is already omitted for "${modelKey(model)}".`, "info");
|
|
11407
|
+
return;
|
|
11408
|
+
}
|
|
11409
|
+
if (!cmdCtx.hasUI) {
|
|
11410
|
+
cmdCtx.ui.notify(
|
|
11411
|
+
"❌ Non-interactive terminal detected. Prompt cache key opt-out requires UI confirmation. No changes were made.\n" +
|
|
11412
|
+
`Run /cache-optimizer fix prompt-cache-key in Pi's interactive UI for ${modelKey(model)}.`,
|
|
11413
|
+
"warning",
|
|
11414
|
+
);
|
|
11415
|
+
return;
|
|
11416
|
+
}
|
|
11417
|
+
const preview = [
|
|
11418
|
+
"📝 Preview of extension configuration change:",
|
|
11419
|
+
...buildPromptCacheKeyConfigPreview(model),
|
|
11420
|
+
"",
|
|
11421
|
+
"⚠️ Risk notice:",
|
|
11422
|
+
" 1. This affects all sessions using this exact provider/model.",
|
|
11423
|
+
" 2. Provider prompt-cache reuse may decrease because both key spellings are removed.",
|
|
11424
|
+
" 3. This does not modify Pi's models.json, credentials, prompts, headers, or other models.",
|
|
11425
|
+
" 4. The extension config will be backed up and Pi must be reloaded/restarted.",
|
|
11426
|
+
"",
|
|
11427
|
+
"Apply this persistent opt-out?",
|
|
11428
|
+
].join("\n");
|
|
11429
|
+
const confirmed = await cmdCtx.ui.confirm("Cache Optimizer — Omit prompt_cache_key", preview);
|
|
11430
|
+
if (!confirmed) {
|
|
11431
|
+
cmdCtx.ui.notify("No changes were made. Canceled by user.", "info");
|
|
11432
|
+
return;
|
|
11433
|
+
}
|
|
11434
|
+
try {
|
|
11435
|
+
const result = await applyPromptCacheKeyConfigFix(model);
|
|
11436
|
+
setPersistedCacheOptimizerConfig(readPersistedCacheOptimizerConfig());
|
|
11437
|
+
cmdCtx.ui.notify(
|
|
11438
|
+
`✅ Prompt cache key opt-out saved for ${modelKey(model)}.\n` +
|
|
11439
|
+
`Config backup saved to: ${result.backupPath}\n` +
|
|
11440
|
+
"Run /reload or restart Pi for the change to take effect. Use /cache-optimizer rollback to restore it.",
|
|
11441
|
+
"info",
|
|
11442
|
+
);
|
|
11443
|
+
} catch (error) {
|
|
11444
|
+
cmdCtx.ui.notify(`❌ Could not save prompt cache key opt-out: ${error instanceof Error ? error.message : String(error)}. No changes were made.`, "error");
|
|
11445
|
+
}
|
|
11446
|
+
return;
|
|
11447
|
+
}
|
|
10872
11448
|
|
|
10873
11449
|
if (!suggestion) {
|
|
10874
11450
|
const key = modelKey(model);
|
|
@@ -11301,7 +11877,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
11301
11877
|
"Doctor — Show cache configuration",
|
|
11302
11878
|
"Stats — Show current-session model statistics",
|
|
11303
11879
|
"Compat — Show compat suggestion",
|
|
11304
|
-
"Fix — Auto-fix compat issues (writes models.json)",
|
|
11880
|
+
"Fix — Auto-fix compat issues (writes models.json or extension config)",
|
|
11881
|
+
"Disable prompt_cache_key — Omit it for the active model",
|
|
11305
11882
|
"Rollback — Undo the latest confirmed fix",
|
|
11306
11883
|
"Footer mode — Choose total, session, or process stats",
|
|
11307
11884
|
"Reset — Reset local provider/model stats",
|
|
@@ -11321,8 +11898,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
11321
11898
|
} else if (choice === menuOptions[5]) {
|
|
11322
11899
|
await handleCacheOptimizerCommand("fix", cmdCtx);
|
|
11323
11900
|
} else if (choice === menuOptions[6]) {
|
|
11324
|
-
await handleCacheOptimizerCommand("
|
|
11901
|
+
await handleCacheOptimizerCommand("fix prompt-cache-key", cmdCtx);
|
|
11325
11902
|
} else if (choice === menuOptions[7]) {
|
|
11903
|
+
await handleCacheOptimizerCommand("rollback", cmdCtx);
|
|
11904
|
+
} else if (choice === menuOptions[8]) {
|
|
11326
11905
|
const modeOptions = ["session — Current Pi conversation session (default)", "total — All local sessions today", "process — Current extension instance only", "Cancel"];
|
|
11327
11906
|
const modeChoice = await cmdCtx.ui.select("Footer cache stats mode", modeOptions);
|
|
11328
11907
|
const nextMode = modeChoice === modeOptions[0]
|
|
@@ -11333,7 +11912,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
11333
11912
|
? "process"
|
|
11334
11913
|
: undefined;
|
|
11335
11914
|
if (nextMode) await handleCacheOptimizerCommand(`config footer-mode ${nextMode}`, cmdCtx);
|
|
11336
|
-
} else if (choice === menuOptions[
|
|
11915
|
+
} else if (choice === menuOptions[9]) {
|
|
11337
11916
|
await handleCacheOptimizerCommand("reset", cmdCtx);
|
|
11338
11917
|
}
|
|
11339
11918
|
// choice === "cancel" or undefined → no action
|
|
@@ -11351,7 +11930,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
11351
11930
|
diagnosis.push(" stats contributors — Show per-session contributors for the active model");
|
|
11352
11931
|
diagnosis.push(" compat — Show compat suggestion with edit location");
|
|
11353
11932
|
diagnosis.push(" config footer-mode total|session|process — Persist the footer stats mode");
|
|
11354
|
-
diagnosis.push(" fix — Auto-fix compat issues (writes models.json, requires UI)");
|
|
11933
|
+
diagnosis.push(" fix — Auto-fix compat issues (writes models.json or extension config, requires UI)");
|
|
11934
|
+
diagnosis.push(" fix prompt-cache-key — Explicitly omit prompt_cache_key for the active model");
|
|
11355
11935
|
diagnosis.push(" rollback — Undo the latest confirmed fix (requires UI confirmation)");
|
|
11356
11936
|
diagnosis.push(" reset — Reset local provider/model stats for current model (does not affect upstream)");
|
|
11357
11937
|
diagnosis.push("");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-cache-optimizer",
|
|
3
|
-
"version": "2.8.
|
|
3
|
+
"version": "2.8.8",
|
|
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",
|
|
@@ -35,7 +35,8 @@
|
|
|
35
35
|
"check": "npm run typecheck && npm test && npm run check:diff && npm run check:pack"
|
|
36
36
|
},
|
|
37
37
|
"devDependencies": {
|
|
38
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
38
|
+
"@earendil-works/pi-coding-agent": "^0.85.0",
|
|
39
|
+
"@earendil-works/pi-server": "0.85.0",
|
|
39
40
|
"@types/node": "^22.0.0",
|
|
40
41
|
"jiti": "^2.7.0",
|
|
41
42
|
"typescript": "^5.8.3"
|