pi-cache-optimizer 2.8.8 → 2.8.10
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 -4
- package/README.zh-CN.md +5 -4
- package/index.ts +328 -105
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -61,7 +61,7 @@ Run `/reload` in Pi after install/update/remove so extension hooks refresh.
|
|
|
61
61
|
|
|
62
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).
|
|
63
63
|
|
|
64
|
-
This extension requires Pi 0.82+ and is validated against Pi 0.85.
|
|
64
|
+
This extension requires Pi 0.82+ and is validated against Pi 0.85.1. 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.
|
|
65
65
|
|
|
66
66
|
## Commands
|
|
67
67
|
|
|
@@ -136,19 +136,19 @@ The explicit setting is stored in `pi-cache-optimizer-config.json` under Pi's ag
|
|
|
136
136
|
|
|
137
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
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:
|
|
139
|
+
When the extension observes an explicit field-level `prompt_cache_key` unsupported error for the exact provider/model, ordinary `/cache-optimizer fix` offers a confirmed model-scoped repair. Value-validation failures and conditional restrictions such as “not allowed when temperature is set” do not qualify. If concurrent responses from different models cannot be correlated because Pi provides no request ID, header-only evidence is ignored unless the finalized assistant message supplies exact provider/model identity. If you already know that the endpoint rejects the field, use the explicit command:
|
|
140
140
|
|
|
141
141
|
```text
|
|
142
142
|
/cache-optimizer fix prompt-cache-key
|
|
143
143
|
```
|
|
144
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
|
|
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 without overwriting a concurrently created config, 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`; it binds the previewed receipt by file identity and hash, refuses a replaced receipt, and compensates the config if receipt marking fails.
|
|
146
146
|
|
|
147
147
|
## OpenAI-compatible proxy setup
|
|
148
148
|
|
|
149
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.
|
|
150
150
|
|
|
151
|
-
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. Pi 0.82+ core generates a session `prompt_cache_key` for it when cache retention is enabled, so this extension preserves that key and may add the same conservative fallback when missing. The built-in provider's explicit compat fingerprint is excluded from generic proxy routing/session-affinity advice, but a custom or overridden provider that merely reuses the id `llama.cpp` is treated like any other OpenAI-compatible channel. `prompt_cache_retention` remains subject to the normal safety rule: keep it only for official OpenAI or an explicit effective `supportsLongCacheRetention: true` opt-in in `models.json`; otherwise strip it before sending.
|
|
151
|
+
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. Pi 0.82+ core generates a session `prompt_cache_key` for it when cache retention is enabled, so this extension preserves that key and may add the same conservative fallback when missing. The built-in provider's explicit compat fingerprint is excluded from generic proxy routing/session-affinity advice, but a custom or overridden provider that merely reuses the id `llama.cpp` is treated like any other OpenAI-compatible channel. `prompt_cache_retention` remains subject to the normal safety rule: keep it only for official OpenAI or an explicit effective `supportsLongCacheRetention: true` opt-in in `models.json`; otherwise strip it before sending. Pi 0.85.1 has no native `supportsPromptCacheKey` compat field, so the per-model key opt-out is stored in this extension's `pi-cache-optimizer-config.json` instead of `models.json`. The extension config is independent of Pi's compat precedence and only affects the exact provider/model listed there.
|
|
152
152
|
|
|
153
153
|
For real proxies, start with session affinity:
|
|
154
154
|
|
|
@@ -174,6 +174,7 @@ Notes:
|
|
|
174
174
|
|
|
175
175
|
- `sendSessionAffinityHeaders: true` is the safe default when your proxy supports sticky routing.
|
|
176
176
|
- `supportsLongCacheRetention: true` is optional. Add it only when the endpoint explicitly supports OpenAI long prompt cache retention.
|
|
177
|
+
- Do not add `supportsPromptCacheKey` to `models.json`: Pi 0.85.1 does not define that compat field. Use `/cache-optimizer fix prompt-cache-key` to store an exact provider/model omit rule in the extension-owned config; it removes both key spellings, including a key supplied by Pi.
|
|
177
178
|
- If you see `400 Unsupported parameter: prompt_cache_retention`, remove/avoid `supportsLongCacheRetention` for that channel. Keep `sendSessionAffinityHeaders` if supported. The extension detects the explicit error from response headers or the finalized assistant error message and strips the parameter from subsequent requests in the current process.
|
|
178
179
|
- Use `/cache-optimizer compat` or `/cache-optimizer doctor` to see model-specific advice.
|
|
179
180
|
- DeepSeek model names select the `DS cache` adapter only; they do not prove a reasoning wire protocol. Generic cache/routing advice remains active for absent or non-DeepSeek formats. DeepSeek replay advice is shown only when effective `compat.thinkingFormat: "deepseek"` is explicitly configured; it never treats `thinkingFormat` as a missing fix key.
|
package/README.zh-CN.md
CHANGED
|
@@ -61,7 +61,7 @@ pi remove npm:pi-deepseek-cache-optimizer && pi install npm:pi-cache-optimizer
|
|
|
61
61
|
|
|
62
62
|
Pi 0.79.7 及之后,`pi update` 默认只更新 Pi 本体。若要更新已安装的 Pi package(包括本扩展),请运行 `pi update --extensions`(只更新 packages)或 `pi update --all`(Pi 与 packages 一起更新)。
|
|
63
63
|
|
|
64
|
-
本扩展要求 Pi 0.82+,并已使用 Pi 0.85.
|
|
64
|
+
本扩展要求 Pi 0.82+,并已使用 Pi 0.85.1 验证。TypeScript 校验直接使用官方 Pi package 类型,同时只使用这些版本共有的 extension hooks、`getAgentDir()` 和 prompt options;不依赖 Pi 0.83+ 专有 API(例如 `ctx.scopedModels` 或 bundled TypeBox 1.3 aliases)。
|
|
65
65
|
|
|
66
66
|
## 命令
|
|
67
67
|
|
|
@@ -136,19 +136,19 @@ Footer 默认使用 `session`,避免另一个并行 Pi 终端使用相同 prov
|
|
|
136
136
|
|
|
137
137
|
某些 OpenAI-compatible endpoint 会因 `prompt_cache_key` 返回 HTTP 400,但同一个字段对其他 provider 可能有效。Pi 0.85.1 没有原生的 `supportsPromptCacheKey` compat 字段,**不要**把这个未知字段加入 `models.json`。`supportsLongCacheRetention` 也不是等价开关,不应借用来实现此目的。
|
|
138
138
|
|
|
139
|
-
当扩展观察到精确 provider/model
|
|
139
|
+
当扩展观察到精确 provider/model 对 `prompt_cache_key` 的明确字段级拒绝后,普通 `/cache-optimizer fix` 会提供经过确认的模型级修复。参数值校验失败,以及“设置 temperature 时不允许”这类条件限制都不构成证据。若不同模型的响应并发交错,而 Pi 又没有提供 request ID,扩展会忽略无法安全关联的 response-header 证据;只有最终 assistant message 提供精确 provider/model 身份时才恢复归因。如果你已经确定 endpoint 不支持该字段,可以主动执行:
|
|
140
140
|
|
|
141
141
|
```text
|
|
142
142
|
/cache-optimizer fix prompt-cache-key
|
|
143
143
|
```
|
|
144
144
|
|
|
145
|
-
预览会说明设置保存在扩展自有的 `pi-cache-optimizer-config.json` 中。确认后,`before_provider_request` 的最终阶段会移除 `prompt_cache_key` 和 `promptCacheKey`,包括 Pi core 之前已经放入的 key。这个精确模型可能因此失去 provider prompt-cache 命中,其他模型仍保持原有 fallback
|
|
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`;它会绑定预览时 receipt 的文件身份和 hash,在 receipt 被替换时拒绝操作,并在 receipt 状态写入失败时补偿恢复配置。
|
|
146
146
|
|
|
147
147
|
## OpenAI-compatible 代理配置
|
|
148
148
|
|
|
149
149
|
LiteLLM / OneAPI / NewAPI / 类 OpenRouter 渠道等第三方 `openai-completions` 代理,常会把同一个 session 分散到多个上游后端,导致 provider 侧 prompt cache 被拆散。
|
|
150
150
|
|
|
151
|
-
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。Pi 0.82+ core 在启用 cache retention 时会为它生成 session `prompt_cache_key`,因此本扩展会保留该 key,并在缺失时使用同样的保守 fallback。只有符合 Pi 内置 provider 明确 compat 指纹的模型会跳过通用 proxy 路由 / session-affinity 建议;仅复用 `llama.cpp` id 的自定义或覆盖 provider 仍按普通 OpenAI-compatible 渠道处理。`prompt_cache_retention` 继续遵循统一安全规则:仅官方 OpenAI 或 `models.json` 中有效配置为 `supportsLongCacheRetention: true`
|
|
151
|
+
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。Pi 0.82+ core 在启用 cache retention 时会为它生成 session `prompt_cache_key`,因此本扩展会保留该 key,并在缺失时使用同样的保守 fallback。只有符合 Pi 内置 provider 明确 compat 指纹的模型会跳过通用 proxy 路由 / session-affinity 建议;仅复用 `llama.cpp` id 的自定义或覆盖 provider 仍按普通 OpenAI-compatible 渠道处理。`prompt_cache_retention` 继续遵循统一安全规则:仅官方 OpenAI 或 `models.json` 中有效配置为 `supportsLongCacheRetention: true` 时保留,否则发送前移除。Pi 0.85.1 没有原生的 `supportsPromptCacheKey` compat 字段,因此按模型关闭 key 的策略保存在本扩展的 `pi-cache-optimizer-config.json` 中,而不是 `models.json`。扩展配置独立于 Pi 的 compat 优先级,只影响其中列出的精确 provider/model。
|
|
152
152
|
|
|
153
153
|
对真正的代理,建议先启用 session affinity:
|
|
154
154
|
|
|
@@ -174,6 +174,7 @@ Pi 0.84.1 还修复了内置 Fireworks 渠道对拒绝 `prompt_cache_retention`
|
|
|
174
174
|
|
|
175
175
|
- `sendSessionAffinityHeaders: true` 是安全默认项,前提是你的代理支持 sticky routing。
|
|
176
176
|
- `supportsLongCacheRetention: true` 是可选项。只有 endpoint 明确支持 OpenAI long prompt cache retention 时才添加。
|
|
177
|
+
- 不要把 `supportsPromptCacheKey` 加入 `models.json`:Pi 0.85.1 没有定义这个 compat 字段。请使用 `/cache-optimizer fix prompt-cache-key` 在扩展自有配置中保存精确 provider/model 的 omit 规则;它会移除两种 key 写法,包括 Pi 提供的 key。
|
|
177
178
|
- 如果出现 `400 Unsupported parameter: prompt_cache_retention`,请为该渠道移除 / 避免 `supportsLongCacheRetention`;如支持,可保留 `sendSessionAffinityHeaders`。扩展会从响应头或最终 assistant error message 中识别这条明确错误,并在当前进程的后续请求中移除该参数。
|
|
178
179
|
- 使用 `/cache-optimizer compat` 或 `/cache-optimizer doctor` 查看当前模型的具体建议。
|
|
179
180
|
- DeepSeek 模型名只用于选择 `DS cache` adapter,不能证明 reasoning wire protocol。缺少或使用非 DeepSeek format 时仍保留通用缓存 / 路由建议;只有 effective `compat.thinkingFormat: "deepseek"` 被明确配置时,才显示 DeepSeek replay 建议,且不会把 `thinkingFormat` 列为缺失修复项。
|
package/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import { chmod, copyFile, lstat, mkdir, readFile, readdir, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
|
|
2
|
+
import { chmod, copyFile, link, lstat, mkdir, readFile, readdir, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
|
|
3
3
|
import { constants as fsConstants, readFileSync, statSync, watch } from "node:fs";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
5
|
import { basename, dirname, join } from "node:path";
|
|
@@ -204,6 +204,12 @@ type PromptCacheKeyConfigReceipt = {
|
|
|
204
204
|
status?: "rolled_back";
|
|
205
205
|
rolledBackAt?: number;
|
|
206
206
|
};
|
|
207
|
+
type PromptCacheKeyConfigReceiptSnapshot = {
|
|
208
|
+
receipt: PromptCacheKeyConfigReceipt;
|
|
209
|
+
receiptPath: string;
|
|
210
|
+
hash: string;
|
|
211
|
+
identity: FileIdentity;
|
|
212
|
+
};
|
|
207
213
|
const PI_ROUTING_REGISTRY_SYMBOL = Symbol.for("pi.routing.registry.v1");
|
|
208
214
|
const PI_CACHE_HINTS_SYMBOL = Symbol.for("pi.cache.hints.v1");
|
|
209
215
|
const PI_CACHE_HINTS_OWNER_SYMBOL = Symbol.for("pi.cache.optimizer.hints-owner.v1");
|
|
@@ -1682,7 +1688,9 @@ function isValidAnthropicMessagesCompat(compat: UnknownRecord): boolean {
|
|
|
1682
1688
|
function isValidCompatRecord(value: unknown): boolean {
|
|
1683
1689
|
if (value === undefined) return true;
|
|
1684
1690
|
const compat = asRecord(value);
|
|
1685
|
-
return !!compat
|
|
1691
|
+
return !!compat
|
|
1692
|
+
&& !Object.prototype.hasOwnProperty.call(compat, "supportsPromptCacheKey")
|
|
1693
|
+
&& (
|
|
1686
1694
|
isValidOpenAICompletionsCompat(compat)
|
|
1687
1695
|
|| isValidOpenAIResponsesCompat(compat)
|
|
1688
1696
|
|| isValidAnthropicMessagesCompat(compat)
|
|
@@ -2039,21 +2047,7 @@ async function writePersistedCacheOptimizerConfigUnlocked(
|
|
|
2039
2047
|
return;
|
|
2040
2048
|
}
|
|
2041
2049
|
|
|
2042
|
-
|
|
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;
|
|
2051
|
-
}
|
|
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
|
-
}
|
|
2050
|
+
await atomicCreateTextFileNoReplace(configPath, payloadText, targetMode, "config");
|
|
2057
2051
|
}
|
|
2058
2052
|
|
|
2059
2053
|
async function writePersistedCacheOptimizerConfig(
|
|
@@ -2085,24 +2079,15 @@ async function writePersistedFooterModeUnlocked(
|
|
|
2085
2079
|
if (targetInfo && (targetInfo.isSymbolicLink() || !targetInfo.isFile())) throw new Error("optimizer config is not a regular file; no changes were made");
|
|
2086
2080
|
const targetText = targetInfo ? await readFile(configPath, "utf8") : undefined;
|
|
2087
2081
|
const targetMode = targetInfo ? targetInfo.mode & 0o7777 : 0o600;
|
|
2088
|
-
const
|
|
2089
|
-
|
|
2090
|
-
await
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
}
|
|
2095
|
-
|
|
2096
|
-
|
|
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;
|
|
2082
|
+
const footerText = JSON.stringify({ version: 1, footerMode: mode }, null, 2) + "\n";
|
|
2083
|
+
if (targetInfo && targetText !== undefined) {
|
|
2084
|
+
await atomicReplaceTextFilePreservingMode(configPath, footerText, targetMode, "config-footer", {
|
|
2085
|
+
identity: targetInfo,
|
|
2086
|
+
hash: hashText(targetText),
|
|
2087
|
+
mode: targetMode,
|
|
2088
|
+
});
|
|
2089
|
+
} else {
|
|
2090
|
+
await atomicCreateTextFileNoReplace(configPath, footerText, targetMode, "config-footer");
|
|
2106
2091
|
}
|
|
2107
2092
|
return;
|
|
2108
2093
|
}
|
|
@@ -2130,7 +2115,12 @@ function parsePromptCacheKeyConfigReceipt(value: unknown): PromptCacheKeyConfigR
|
|
|
2130
2115
|
if (![record.transactionId, record.provider, record.modelId].every(isSafeReceiptText)) return undefined;
|
|
2131
2116
|
if (!isSha256(record.beforeHash) || !isSha256(record.afterHash) || record.beforeHash === record.afterHash) return undefined;
|
|
2132
2117
|
if (!isSafeReceiptText(record.backupFile) || basename(record.backupFile) !== record.backupFile || !record.backupFile.startsWith("pi-cache-optimizer-config.backup-")) return undefined;
|
|
2133
|
-
if (
|
|
2118
|
+
if (
|
|
2119
|
+
typeof record.targetExistedBefore !== "boolean" ||
|
|
2120
|
+
!isReceiptTimestamp(record.createdAt) ||
|
|
2121
|
+
!isReceiptTimestamp(record.appliedAt) ||
|
|
2122
|
+
record.appliedAt < record.createdAt
|
|
2123
|
+
) return undefined;
|
|
2134
2124
|
const addedModelKey = `${record.provider}/${record.modelId}`;
|
|
2135
2125
|
const targetHadModelKey = record.version === 1 ? false : record.targetHadModelKey;
|
|
2136
2126
|
if (typeof targetHadModelKey !== "boolean") return undefined;
|
|
@@ -2160,25 +2150,95 @@ function isActionablePromptCacheKeyConfigReceipt(receipt: PromptCacheKeyConfigRe
|
|
|
2160
2150
|
return receipt !== undefined && receipt.status === undefined;
|
|
2161
2151
|
}
|
|
2162
2152
|
|
|
2163
|
-
async function
|
|
2153
|
+
async function assertPromptCacheKeyConfigReceiptSnapshotUnchanged(
|
|
2154
|
+
snapshot: PromptCacheKeyConfigReceiptSnapshot,
|
|
2155
|
+
): Promise<void> {
|
|
2156
|
+
const info = await lstat(snapshot.receiptPath);
|
|
2157
|
+
if (info.isSymbolicLink() || !info.isFile() || !sameFileIdentity(snapshot.identity, info)) {
|
|
2158
|
+
throw new Error("prompt-cache-key receipt changed since the rollback preview");
|
|
2159
|
+
}
|
|
2160
|
+
const text = await readFile(snapshot.receiptPath, "utf8");
|
|
2161
|
+
const afterRead = await lstat(snapshot.receiptPath);
|
|
2162
|
+
if (
|
|
2163
|
+
afterRead.isSymbolicLink() ||
|
|
2164
|
+
!afterRead.isFile() ||
|
|
2165
|
+
!sameFileIdentity(info, afterRead) ||
|
|
2166
|
+
hashText(text) !== snapshot.hash
|
|
2167
|
+
) {
|
|
2168
|
+
throw new Error("prompt-cache-key receipt changed since the rollback preview");
|
|
2169
|
+
}
|
|
2170
|
+
}
|
|
2171
|
+
|
|
2172
|
+
async function readPromptCacheKeyConfigReceiptSnapshot(
|
|
2173
|
+
receiptPath: string = CONFIG_RECEIPT_PATH,
|
|
2174
|
+
): Promise<PromptCacheKeyConfigReceiptSnapshot | undefined> {
|
|
2164
2175
|
try {
|
|
2165
2176
|
const info = await lstat(receiptPath);
|
|
2166
2177
|
if (info.isSymbolicLink() || !info.isFile()) return undefined;
|
|
2167
|
-
const
|
|
2168
|
-
|
|
2178
|
+
const text = await readFile(receiptPath, "utf8");
|
|
2179
|
+
const afterRead = await lstat(receiptPath);
|
|
2180
|
+
if (afterRead.isSymbolicLink() || !afterRead.isFile() || !sameFileIdentity(info, afterRead)) return undefined;
|
|
2181
|
+
const receipt = parsePromptCacheKeyConfigReceipt(JSON.parse(text));
|
|
2182
|
+
if (!receipt) return undefined;
|
|
2183
|
+
return { receipt, receiptPath, hash: hashText(text), identity: afterRead };
|
|
2169
2184
|
} catch {
|
|
2170
2185
|
return undefined;
|
|
2171
2186
|
}
|
|
2172
2187
|
}
|
|
2173
2188
|
|
|
2174
|
-
async function
|
|
2189
|
+
async function readPromptCacheKeyConfigReceipt(receiptPath: string = CONFIG_RECEIPT_PATH): Promise<PromptCacheKeyConfigReceipt | undefined> {
|
|
2190
|
+
return (await readPromptCacheKeyConfigReceiptSnapshot(receiptPath))?.receipt;
|
|
2191
|
+
}
|
|
2192
|
+
|
|
2193
|
+
async function writePromptCacheKeyConfigReceipt(
|
|
2194
|
+
receipt: PromptCacheKeyConfigReceipt,
|
|
2195
|
+
receiptPath: string = CONFIG_RECEIPT_PATH,
|
|
2196
|
+
expectedSnapshot?: PromptCacheKeyConfigReceiptSnapshot,
|
|
2197
|
+
/** Test-only race injector; production callers leave this undefined. */
|
|
2198
|
+
beforeRename?: () => Promise<void>,
|
|
2199
|
+
): Promise<void> {
|
|
2175
2200
|
if (!parsePromptCacheKeyConfigReceipt(receipt)) throw new Error("invalid prompt cache key config receipt");
|
|
2201
|
+
if (expectedSnapshot?.receiptPath !== undefined && expectedSnapshot.receiptPath !== receiptPath) {
|
|
2202
|
+
throw new Error("prompt-cache-key receipt path changed since the rollback preview");
|
|
2203
|
+
}
|
|
2204
|
+
if (expectedSnapshot) await assertPromptCacheKeyConfigReceiptSnapshotUnchanged(expectedSnapshot);
|
|
2176
2205
|
await mkdir(dirname(receiptPath), { recursive: true });
|
|
2206
|
+
let existingReceiptInfo: Awaited<ReturnType<typeof lstat>> | undefined;
|
|
2207
|
+
try {
|
|
2208
|
+
const info = await lstat(receiptPath);
|
|
2209
|
+
if (info.isSymbolicLink() || !info.isFile()) throw new Error("invalid prompt-cache-key receipt path");
|
|
2210
|
+
existingReceiptInfo = info;
|
|
2211
|
+
} catch (error) {
|
|
2212
|
+
if (getErrorCode(error) !== "ENOENT") throw error;
|
|
2213
|
+
}
|
|
2177
2214
|
const tempPath = uniqueTempPath(receiptPath, "config-receipt");
|
|
2178
2215
|
try {
|
|
2179
2216
|
await writeFile(tempPath, JSON.stringify(receipt, null, 2) + "\n", { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
2217
|
+
const tempInfo = await lstat(tempPath);
|
|
2218
|
+
if (tempInfo.isSymbolicLink() || !tempInfo.isFile()) throw new Error("invalid temporary prompt-cache-key receipt");
|
|
2180
2219
|
await chmod(tempPath, 0o600);
|
|
2181
|
-
|
|
2220
|
+
const assertDestinationUnchanged = async (): Promise<void> => {
|
|
2221
|
+
try {
|
|
2222
|
+
const currentInfo = await lstat(receiptPath);
|
|
2223
|
+
if (!existingReceiptInfo || !sameFileIdentity(existingReceiptInfo, currentInfo)) {
|
|
2224
|
+
throw new Error("prompt-cache-key receipt changed during atomic write");
|
|
2225
|
+
}
|
|
2226
|
+
} catch (error) {
|
|
2227
|
+
if (getErrorCode(error) !== "ENOENT" || existingReceiptInfo) throw error;
|
|
2228
|
+
}
|
|
2229
|
+
if (expectedSnapshot) await assertPromptCacheKeyConfigReceiptSnapshotUnchanged(expectedSnapshot);
|
|
2230
|
+
};
|
|
2231
|
+
await assertDestinationUnchanged();
|
|
2232
|
+
if (beforeRename) await beforeRename();
|
|
2233
|
+
await assertDestinationUnchanged();
|
|
2234
|
+
if (existingReceiptInfo) {
|
|
2235
|
+
await rename(tempPath, receiptPath);
|
|
2236
|
+
} else {
|
|
2237
|
+
await link(tempPath, receiptPath);
|
|
2238
|
+
await unlink(tempPath).catch((cleanupError) => {
|
|
2239
|
+
console.warn(`${LOG_PREFIX}: committed prompt-cache-key receipt but failed to remove its temporary hard link`, cleanupError);
|
|
2240
|
+
});
|
|
2241
|
+
}
|
|
2182
2242
|
} catch (error) {
|
|
2183
2243
|
await unlink(tempPath).catch((cleanupError) => { if (getErrorCode(cleanupError) !== "ENOENT") console.warn(`${LOG_PREFIX}: failed to remove temporary config receipt`, cleanupError); });
|
|
2184
2244
|
throw error;
|
|
@@ -2209,6 +2269,7 @@ async function applyPromptCacheKeyConfigFixUnderLock(
|
|
|
2209
2269
|
const key = modelKey(model);
|
|
2210
2270
|
const existingOmit = current.promptCacheKey?.omit ?? [];
|
|
2211
2271
|
const targetHadModelKey = existingOmit.includes(key);
|
|
2272
|
+
if (targetHadModelKey) throw new Error("prompt-cache-key is already configured; no changes were made");
|
|
2212
2273
|
const omit = [...new Set([...existingOmit, key])].sort();
|
|
2213
2274
|
const next: PersistedCacheOptimizerConfigV2 = { ...current, version: 2, promptCacheKey: { omit } };
|
|
2214
2275
|
const modifiedText = JSON.stringify(next, null, 2) + "\n";
|
|
@@ -2236,8 +2297,19 @@ async function applyPromptCacheKeyConfigFixUnderLock(
|
|
|
2236
2297
|
if (getErrorCode(error) !== "ENOENT") throw error;
|
|
2237
2298
|
}
|
|
2238
2299
|
}
|
|
2239
|
-
|
|
2240
|
-
|
|
2300
|
+
if (originalInfo) {
|
|
2301
|
+
await rename(tempPath, configPath);
|
|
2302
|
+
committedInfo = await lstat(configPath);
|
|
2303
|
+
} else {
|
|
2304
|
+
// Do not overwrite a config created after the absence check. Record the
|
|
2305
|
+
// committed inode before cleaning up its temporary hard-link name so a
|
|
2306
|
+
// cleanup failure can still compensate the config transaction.
|
|
2307
|
+
await link(tempPath, configPath);
|
|
2308
|
+
committedInfo = await lstat(configPath);
|
|
2309
|
+
await unlink(tempPath).catch((cleanupError) => {
|
|
2310
|
+
console.warn(`${LOG_PREFIX}: committed optimizer config but failed to remove its temporary hard link`, cleanupError);
|
|
2311
|
+
});
|
|
2312
|
+
}
|
|
2241
2313
|
const receipt: PromptCacheKeyConfigReceipt = {
|
|
2242
2314
|
version: 2,
|
|
2243
2315
|
kind: "pi-cache-optimizer-config-receipt",
|
|
@@ -2257,10 +2329,17 @@ async function applyPromptCacheKeyConfigFixUnderLock(
|
|
|
2257
2329
|
return { receipt, backupPath };
|
|
2258
2330
|
} catch (error) {
|
|
2259
2331
|
await unlink(tempPath).catch(() => {});
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2332
|
+
try {
|
|
2333
|
+
if (targetExistedBefore && committedInfo) {
|
|
2334
|
+
await atomicRestoreFileFromBackup(backupPath, configPath, mode, { identity: committedInfo, hash: afterHash, mode });
|
|
2335
|
+
} else if (!targetExistedBefore && committedInfo) {
|
|
2336
|
+
await validateAtomicTarget(configPath, { identity: committedInfo, hash: afterHash, mode });
|
|
2337
|
+
await unlink(configPath);
|
|
2338
|
+
}
|
|
2339
|
+
} catch (compensationError) {
|
|
2340
|
+
const writeMessage = error instanceof Error ? error.message : String(error);
|
|
2341
|
+
const compensationMessage = compensationError instanceof Error ? compensationError.message : String(compensationError);
|
|
2342
|
+
throw new Error(`prompt-cache-key fix receipt update failed (${writeMessage}) and config compensation failed (${compensationMessage})`);
|
|
2264
2343
|
}
|
|
2265
2344
|
throw error;
|
|
2266
2345
|
}
|
|
@@ -2274,15 +2353,26 @@ async function applyPromptCacheKeyConfigFix(
|
|
|
2274
2353
|
return withModelsJsonTransactionLock(() => applyPromptCacheKeyConfigFixUnderLock(model, configPath, receiptPath));
|
|
2275
2354
|
}
|
|
2276
2355
|
|
|
2356
|
+
type PromptCacheKeyRollbackOptions = {
|
|
2357
|
+
/** Test-only race injector; production callers leave this undefined. */
|
|
2358
|
+
beforeReceiptRename?: () => Promise<void>;
|
|
2359
|
+
};
|
|
2360
|
+
|
|
2277
2361
|
async function rollbackPromptCacheKeyConfigUnderLock(
|
|
2278
|
-
|
|
2362
|
+
snapshot: PromptCacheKeyConfigReceiptSnapshot,
|
|
2279
2363
|
configPath: string = CONFIG_FILE_PATH,
|
|
2280
2364
|
receiptPath: string = CONFIG_RECEIPT_PATH,
|
|
2365
|
+
options: PromptCacheKeyRollbackOptions = {},
|
|
2281
2366
|
): Promise<void> {
|
|
2367
|
+
if (snapshot.receiptPath !== receiptPath) throw new Error("prompt-cache-key receipt path changed since the rollback preview");
|
|
2368
|
+
await assertPromptCacheKeyConfigReceiptSnapshotUnchanged(snapshot);
|
|
2369
|
+
const receipt = snapshot.receipt;
|
|
2282
2370
|
const currentInfo = await lstat(configPath);
|
|
2283
2371
|
if (currentInfo.isSymbolicLink() || !currentInfo.isFile()) throw new Error("optimizer config is not a regular file; refusing to overwrite user changes");
|
|
2284
2372
|
const currentText = await readFile(configPath, "utf8");
|
|
2285
|
-
|
|
2373
|
+
const currentHash = hashText(currentText);
|
|
2374
|
+
const currentMode = currentInfo.mode & 0o7777;
|
|
2375
|
+
if (currentHash !== receipt.afterHash) throw new Error("optimizer config changed after the fix; refusing to overwrite user changes");
|
|
2286
2376
|
const current = parsePersistedCacheOptimizerConfig(JSON.parse(currentText));
|
|
2287
2377
|
if (!current) throw new Error("optimizer config is invalid; refusing to overwrite user changes");
|
|
2288
2378
|
const key = `${receipt.provider}/${receipt.modelId}`;
|
|
@@ -2290,46 +2380,90 @@ async function rollbackPromptCacheKeyConfigUnderLock(
|
|
|
2290
2380
|
if (receipt.targetHadModelKey) throw new Error("prompt-cache-key was already configured before this fix; refusing to remove user configuration");
|
|
2291
2381
|
const omit = current.version === 2 ? current.promptCacheKey?.omit ?? [] : [];
|
|
2292
2382
|
if (!omit.includes(receipt.addedModelKey)) throw new Error("prompt-cache-key opt-out is no longer present; refusing to change user config");
|
|
2383
|
+
|
|
2384
|
+
let rollbackResultText: string | undefined;
|
|
2385
|
+
let rollbackResultMode: number | undefined;
|
|
2386
|
+
let rollbackResultInfo: Awaited<ReturnType<typeof lstat>> | undefined;
|
|
2293
2387
|
const backupPath = configReceiptBackupPath(receipt, receiptPath);
|
|
2294
2388
|
if (receipt.targetExistedBefore) {
|
|
2295
2389
|
const backupInfo = await lstat(backupPath);
|
|
2296
2390
|
if (backupInfo.isSymbolicLink() || !backupInfo.isFile()) throw new Error("config backup is not a regular file");
|
|
2297
2391
|
const backupText = await readFile(backupPath, "utf8");
|
|
2298
2392
|
if (hashText(backupText) !== receipt.beforeHash) throw new Error("config backup hash does not match the fix receipt");
|
|
2393
|
+
rollbackResultText = backupText;
|
|
2394
|
+
rollbackResultMode = backupInfo.mode & 0o7777;
|
|
2299
2395
|
await atomicRestoreFileFromBackup(
|
|
2300
2396
|
backupPath,
|
|
2301
2397
|
configPath,
|
|
2302
|
-
|
|
2303
|
-
{ backupHash: receipt.beforeHash, identity: currentInfo, hash:
|
|
2398
|
+
rollbackResultMode,
|
|
2399
|
+
{ backupHash: receipt.beforeHash, identity: currentInfo, hash: currentHash, mode: currentMode },
|
|
2304
2400
|
);
|
|
2401
|
+
rollbackResultInfo = await lstat(configPath);
|
|
2402
|
+
} else if (current.footerMode || omit.length > 1) {
|
|
2403
|
+
const restored: PersistedCacheOptimizerConfigV2 = {
|
|
2404
|
+
version: 2,
|
|
2405
|
+
...(current.footerMode ? { footerMode: current.footerMode } : {}),
|
|
2406
|
+
...(omit.length > 1 ? { promptCacheKey: { omit: omit.filter((item) => item !== receipt.addedModelKey) } } : {}),
|
|
2407
|
+
};
|
|
2408
|
+
rollbackResultText = JSON.stringify(restored, null, 2) + "\n";
|
|
2409
|
+
rollbackResultMode = currentMode;
|
|
2410
|
+
await atomicReplaceTextFilePreservingMode(
|
|
2411
|
+
configPath,
|
|
2412
|
+
rollbackResultText,
|
|
2413
|
+
rollbackResultMode,
|
|
2414
|
+
"config-rollback",
|
|
2415
|
+
{ identity: currentInfo, hash: currentHash, mode: currentMode },
|
|
2416
|
+
);
|
|
2417
|
+
rollbackResultInfo = await lstat(configPath);
|
|
2305
2418
|
} else {
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2419
|
+
await validateAtomicTarget(configPath, { identity: currentInfo, hash: currentHash, mode: currentMode });
|
|
2420
|
+
await unlink(configPath);
|
|
2421
|
+
}
|
|
2422
|
+
|
|
2423
|
+
try {
|
|
2424
|
+
await writePromptCacheKeyConfigReceipt(
|
|
2425
|
+
{ ...receipt, status: "rolled_back", rolledBackAt: Date.now() },
|
|
2426
|
+
receiptPath,
|
|
2427
|
+
snapshot,
|
|
2428
|
+
options.beforeReceiptRename,
|
|
2429
|
+
);
|
|
2430
|
+
} catch (receiptError) {
|
|
2431
|
+
// Receipt marking is part of the transaction. If it fails after the config
|
|
2432
|
+
// mutation, restore the exact post-fix config rather than leaving an
|
|
2433
|
+
// actionable receipt paired with an already-rolled-back file.
|
|
2434
|
+
try {
|
|
2435
|
+
if (rollbackResultText === undefined) {
|
|
2436
|
+
await atomicCreateTextFileNoReplace(configPath, currentText, currentMode, "config-rollback-compensation");
|
|
2437
|
+
} else {
|
|
2438
|
+
if (!rollbackResultInfo || rollbackResultMode === undefined) throw new Error("missing rollback result guard");
|
|
2439
|
+
await atomicReplaceTextFilePreservingMode(
|
|
2440
|
+
configPath,
|
|
2441
|
+
currentText,
|
|
2442
|
+
currentMode,
|
|
2443
|
+
"config-rollback-compensation",
|
|
2444
|
+
{
|
|
2445
|
+
identity: rollbackResultInfo,
|
|
2446
|
+
hash: hashText(rollbackResultText),
|
|
2447
|
+
mode: rollbackResultMode,
|
|
2448
|
+
},
|
|
2449
|
+
);
|
|
2450
|
+
}
|
|
2451
|
+
} catch (compensationError) {
|
|
2452
|
+
const receiptMessage = receiptError instanceof Error ? receiptError.message : String(receiptError);
|
|
2453
|
+
const compensationMessage = compensationError instanceof Error ? compensationError.message : String(compensationError);
|
|
2454
|
+
throw new Error(`prompt-cache-key rollback receipt update failed (${receiptMessage}) and config compensation failed (${compensationMessage})`);
|
|
2322
2455
|
}
|
|
2456
|
+
throw receiptError;
|
|
2323
2457
|
}
|
|
2324
|
-
await writePromptCacheKeyConfigReceipt({ ...receipt, status: "rolled_back", rolledBackAt: Date.now() }, receiptPath);
|
|
2325
2458
|
}
|
|
2326
2459
|
|
|
2327
2460
|
async function rollbackPromptCacheKeyConfig(
|
|
2328
|
-
|
|
2461
|
+
snapshot: PromptCacheKeyConfigReceiptSnapshot,
|
|
2329
2462
|
configPath: string = CONFIG_FILE_PATH,
|
|
2330
2463
|
receiptPath: string = CONFIG_RECEIPT_PATH,
|
|
2464
|
+
options: PromptCacheKeyRollbackOptions = {},
|
|
2331
2465
|
): Promise<void> {
|
|
2332
|
-
return withModelsJsonTransactionLock(() => rollbackPromptCacheKeyConfigUnderLock(
|
|
2466
|
+
return withModelsJsonTransactionLock(() => rollbackPromptCacheKeyConfigUnderLock(snapshot, configPath, receiptPath, options));
|
|
2333
2467
|
}
|
|
2334
2468
|
|
|
2335
2469
|
function resolveFooterStatsMode(
|
|
@@ -2468,6 +2602,9 @@ function isPiBuiltInLlamaCppModel(model: PiModel | undefined): boolean {
|
|
|
2468
2602
|
}
|
|
2469
2603
|
|
|
2470
2604
|
function shouldInjectOpenAIPromptCacheKeyForModel(model: PiModel | undefined): boolean {
|
|
2605
|
+
// Pi 0.85.1 has no native supportsPromptCacheKey compat field. Per-model
|
|
2606
|
+
// opt-out is owned by this extension's promptCacheKey.omit configuration;
|
|
2607
|
+
// this helper only exposes the transport API gate for fixture consumers.
|
|
2471
2608
|
return isOpenAICompatibleApi(model?.api);
|
|
2472
2609
|
}
|
|
2473
2610
|
|
|
@@ -3674,22 +3811,25 @@ function hasPromptCacheRetentionUnsupportedErrorMessage(message: unknown): boole
|
|
|
3674
3811
|
}
|
|
3675
3812
|
|
|
3676
3813
|
function hasPromptCacheKeyUnsupportedText(value: unknown): boolean {
|
|
3677
|
-
const normalized = lower(value)
|
|
3678
|
-
|
|
3814
|
+
const normalized = lower(value)
|
|
3815
|
+
.replace(/["'`]/g, "")
|
|
3816
|
+
.replace(/[\s_-]+/g, " ")
|
|
3817
|
+
.replace(/\s+/g, " ")
|
|
3818
|
+
.trim();
|
|
3819
|
+
const key = String.raw`(?:prompt ?cache ?key|promptcachekey)`;
|
|
3820
|
+
const field = String.raw`(?:parameter|field|input|argument)`;
|
|
3821
|
+
const unsupported = String.raw`(?:unsupported|unknown|unrecognized|unexpected|not supported|not allowed|not permitted|must be omitted|should be omitted)`;
|
|
3679
3822
|
if (!new RegExp(key).test(normalized)) return false;
|
|
3680
3823
|
|
|
3681
|
-
//
|
|
3682
|
-
//
|
|
3683
|
-
// proof that the endpoint lacks support for the field.
|
|
3684
|
-
|
|
3685
|
-
|
|
3686
|
-
new RegExp(`
|
|
3687
|
-
new RegExp(`
|
|
3688
|
-
new RegExp(
|
|
3689
|
-
|
|
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));
|
|
3824
|
+
// Keep this deliberately grammar-bound. A rejected value or a conditional
|
|
3825
|
+
// restriction (for example, "not allowed when temperature is set") is not
|
|
3826
|
+
// proof that the endpoint lacks support for the field itself.
|
|
3827
|
+
const terminal = String.raw`(?=$|[}\]>,.;])`;
|
|
3828
|
+
return (
|
|
3829
|
+
new RegExp(String.raw`(?:unsupported|unknown|unrecognized|unexpected)\s+${field}\s*[:=]?\s*${key}${terminal}`).test(normalized) ||
|
|
3830
|
+
new RegExp(String.raw`(?:extra\s+inputs?|${field}\s+not\s+(?:allowed|permitted|supported))\s*[:=]\s*${key}${terminal}`).test(normalized) ||
|
|
3831
|
+
new RegExp(String.raw`${key}(?:\s+${field})?\s*[:=]?\s*(?:is\s+)?${unsupported}${terminal}`).test(normalized)
|
|
3832
|
+
);
|
|
3693
3833
|
}
|
|
3694
3834
|
|
|
3695
3835
|
function hasPromptCacheKeyUnsupportedSignal(headers: Record<string, string> | undefined): boolean {
|
|
@@ -8683,6 +8823,41 @@ async function atomicReplaceTextFilePreservingMode(
|
|
|
8683
8823
|
}
|
|
8684
8824
|
}
|
|
8685
8825
|
|
|
8826
|
+
async function atomicCreateTextFileNoReplace(
|
|
8827
|
+
targetPath: string,
|
|
8828
|
+
content: string,
|
|
8829
|
+
mode: number,
|
|
8830
|
+
purpose: string,
|
|
8831
|
+
/** Test-only race injector; production callers leave this undefined. */
|
|
8832
|
+
beforeLink?: () => Promise<void>,
|
|
8833
|
+
): Promise<void> {
|
|
8834
|
+
const tempPath = uniqueTempPath(targetPath, purpose);
|
|
8835
|
+
try {
|
|
8836
|
+
await writeFile(tempPath, content, { encoding: "utf8", mode, flag: "wx" });
|
|
8837
|
+
const tempInfo = await lstat(tempPath);
|
|
8838
|
+
if (tempInfo.isSymbolicLink() || !tempInfo.isFile()) {
|
|
8839
|
+
throw new Error("temporary creation is not a regular file");
|
|
8840
|
+
}
|
|
8841
|
+
await chmod(tempPath, mode);
|
|
8842
|
+
// A hard link gives us an atomic no-replace create. Unlike rename(), it
|
|
8843
|
+
// cannot overwrite a file that appeared after the absence check.
|
|
8844
|
+
if (beforeLink) await beforeLink();
|
|
8845
|
+
await link(tempPath, targetPath);
|
|
8846
|
+
await unlink(tempPath).catch((cleanupError) => {
|
|
8847
|
+
console.warn(`${LOG_PREFIX}: committed config file but failed to remove its temporary hard link`, cleanupError);
|
|
8848
|
+
});
|
|
8849
|
+
} catch (error) {
|
|
8850
|
+
try {
|
|
8851
|
+
await unlink(tempPath);
|
|
8852
|
+
} catch (cleanupError) {
|
|
8853
|
+
if (getErrorCode(cleanupError) !== "ENOENT") {
|
|
8854
|
+
console.warn(`${LOG_PREFIX}: failed to remove temporary config file`, cleanupError);
|
|
8855
|
+
}
|
|
8856
|
+
}
|
|
8857
|
+
throw error;
|
|
8858
|
+
}
|
|
8859
|
+
}
|
|
8860
|
+
|
|
8686
8861
|
async function atomicRestoreFileFromBackup(
|
|
8687
8862
|
backupPath: string,
|
|
8688
8863
|
targetPath: string,
|
|
@@ -9200,7 +9375,14 @@ async function writeModelsJsonFixReceipt(
|
|
|
9200
9375
|
await assertReceiptDestinationUnchanged();
|
|
9201
9376
|
if (beforeRename) await beforeRename();
|
|
9202
9377
|
await assertReceiptDestinationUnchanged();
|
|
9203
|
-
|
|
9378
|
+
if (existingReceiptInfo) {
|
|
9379
|
+
await rename(tempPath, receiptPath);
|
|
9380
|
+
} else {
|
|
9381
|
+
await link(tempPath, receiptPath);
|
|
9382
|
+
await unlink(tempPath).catch((cleanupError) => {
|
|
9383
|
+
console.warn(`${LOG_PREFIX}: committed fix receipt but failed to remove its temporary hard link`, cleanupError);
|
|
9384
|
+
});
|
|
9385
|
+
}
|
|
9204
9386
|
} catch (error) {
|
|
9205
9387
|
try {
|
|
9206
9388
|
await unlink(tempPath);
|
|
@@ -9920,6 +10102,7 @@ export const __internals_for_tests = {
|
|
|
9920
10102
|
parsePromptCacheKeyConfigReceipt,
|
|
9921
10103
|
isActionablePromptCacheKeyConfigReceipt,
|
|
9922
10104
|
readPromptCacheKeyConfigReceipt,
|
|
10105
|
+
readPromptCacheKeyConfigReceiptSnapshot,
|
|
9923
10106
|
writePromptCacheKeyConfigReceipt,
|
|
9924
10107
|
applyPromptCacheKeyConfigFix,
|
|
9925
10108
|
rollbackPromptCacheKeyConfig,
|
|
@@ -9993,6 +10176,7 @@ export const __internals_for_tests = {
|
|
|
9993
10176
|
formatCompatKeysForInsertion,
|
|
9994
10177
|
backupTimestamp,
|
|
9995
10178
|
atomicReplaceTextFilePreservingMode,
|
|
10179
|
+
atomicCreateTextFileNoReplace,
|
|
9996
10180
|
atomicRestoreFileFromBackup,
|
|
9997
10181
|
applyModelsJsonFixTransaction,
|
|
9998
10182
|
hashText,
|
|
@@ -10066,7 +10250,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
10066
10250
|
// finalized assistant message consumes the oldest completed record, falling
|
|
10067
10251
|
// back to the oldest request only when a transport emitted no response hook.
|
|
10068
10252
|
// This preserves response A when request B/C starts before message_end(A).
|
|
10069
|
-
type ProviderRequestState = {
|
|
10253
|
+
type ProviderRequestState = {
|
|
10254
|
+
model: PiModel;
|
|
10255
|
+
responseReceived: boolean;
|
|
10256
|
+
correlationAmbiguous: boolean;
|
|
10257
|
+
};
|
|
10070
10258
|
const providerRequestStates: ProviderRequestState[] = [];
|
|
10071
10259
|
let shardCreatedAt = Date.now();
|
|
10072
10260
|
const PERSIST_DEBOUNCE_MS = 2000;
|
|
@@ -10767,10 +10955,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
10767
10955
|
|
|
10768
10956
|
pi.on("before_provider_request", (event, ctx) => {
|
|
10769
10957
|
const requestModel = resolveRouteModel(ctx.model, ctx) ?? ctx.model;
|
|
10770
|
-
|
|
10771
|
-
|
|
10772
|
-
|
|
10773
|
-
|
|
10958
|
+
// Request-local identity is also needed by the always-on Anthropic TTL
|
|
10959
|
+
// validity repair, so retain the credential-blind snapshot even while the
|
|
10960
|
+
// optional runtime optimizer features are disabled.
|
|
10961
|
+
const snapshot = snapshotProviderRequestModel(requestModel);
|
|
10962
|
+
if (snapshot) providerRequestStates.push({ model: snapshot, responseReceived: false, correlationAmbiguous: false });
|
|
10774
10963
|
let requestPayload: unknown = event.payload;
|
|
10775
10964
|
let toolOrderChanged = false;
|
|
10776
10965
|
|
|
@@ -10838,9 +11027,25 @@ export default function (pi: ExtensionAPI) {
|
|
|
10838
11027
|
});
|
|
10839
11028
|
|
|
10840
11029
|
pi.on("after_provider_response", async (event, ctx) => {
|
|
10841
|
-
const
|
|
11030
|
+
const pendingStates = providerRequestStates.filter((state) => !state.responseReceived);
|
|
11031
|
+
let responseState: ProviderRequestState | undefined;
|
|
11032
|
+
if (pendingStates.length === 1) {
|
|
11033
|
+
responseState = pendingStates[0];
|
|
11034
|
+
} else if (pendingStates.length > 1) {
|
|
11035
|
+
const pendingModelKeys = new Set(pendingStates.map((state) => modelKey(state.model)));
|
|
11036
|
+
if (pendingModelKeys.size === 1) {
|
|
11037
|
+
// Identity is still exact when concurrent requests use the same model.
|
|
11038
|
+
responseState = pendingStates[0];
|
|
11039
|
+
} else {
|
|
11040
|
+
// Pi supplies no request id here, so out-of-order concurrent responses
|
|
11041
|
+
// cannot be assigned safely. Preserve the lifecycle records for a
|
|
11042
|
+
// message-local identity, but never turn ambiguous headers into a
|
|
11043
|
+
// model-scoped persistent-fix suggestion.
|
|
11044
|
+
for (const state of pendingStates) state.correlationAmbiguous = true;
|
|
11045
|
+
}
|
|
11046
|
+
}
|
|
10842
11047
|
if (responseState) responseState.responseReceived = true;
|
|
10843
|
-
const model = responseState?.model ?? (resolveRouteModel(ctx.model, ctx) ?? ctx.model);
|
|
11048
|
+
const model = responseState?.model ?? (pendingStates.length === 0 ? (resolveRouteModel(ctx.model, ctx) ?? ctx.model) : undefined);
|
|
10844
11049
|
if (!runtimeOptimizerEnabled || !model) return;
|
|
10845
11050
|
|
|
10846
11051
|
// Keep only the category, never the provider's complete error text. This
|
|
@@ -10933,21 +11138,39 @@ export default function (pi: ExtensionAPI) {
|
|
|
10933
11138
|
pi.on("message_end", async (event, ctx) => {
|
|
10934
11139
|
syncSessionHash(ctx);
|
|
10935
11140
|
const msgRecord = asRecord(event.message);
|
|
10936
|
-
const
|
|
11141
|
+
const requestCorrelationForMessage = msgRecord?.role === "assistant"
|
|
10937
11142
|
? (() => {
|
|
11143
|
+
const explicitModel = modelFromAssistantMessage(event.message, undefined);
|
|
11144
|
+
const explicitIndex = explicitModel
|
|
11145
|
+
? providerRequestStates.findIndex((state) => modelKey(state.model) === modelKey(explicitModel))
|
|
11146
|
+
: -1;
|
|
10938
11147
|
const completedIndex = providerRequestStates.findIndex((state) => state.responseReceived);
|
|
10939
|
-
const
|
|
10940
|
-
|
|
11148
|
+
const contextModel = resolveRouteModel(ctx.model, ctx) ?? ctx.model;
|
|
11149
|
+
const contextIndex = contextModel && !providerRequestStates.some((state) => state.correlationAmbiguous)
|
|
11150
|
+
? providerRequestStates.findIndex((state) => modelKey(state.model) === modelKey(contextModel))
|
|
11151
|
+
: -1;
|
|
11152
|
+
const index = explicitIndex >= 0
|
|
11153
|
+
? explicitIndex
|
|
11154
|
+
: (completedIndex >= 0 ? completedIndex : (contextIndex >= 0 ? contextIndex : 0));
|
|
11155
|
+
const state = providerRequestStates.splice(index, 1)[0];
|
|
11156
|
+
if (!state) return { model: undefined, ambiguous: false };
|
|
11157
|
+
return {
|
|
11158
|
+
model: state.correlationAmbiguous && explicitIndex < 0 ? undefined : state.model,
|
|
11159
|
+
ambiguous: state.correlationAmbiguous && explicitIndex < 0,
|
|
11160
|
+
};
|
|
10941
11161
|
})()
|
|
10942
|
-
: undefined;
|
|
11162
|
+
: { model: undefined, ambiguous: false };
|
|
11163
|
+
const requestModelForMessage = requestCorrelationForMessage.model;
|
|
11164
|
+
const contextualFallbackForMessage = requestCorrelationForMessage.ambiguous
|
|
11165
|
+
? undefined
|
|
11166
|
+
: (resolveRouteModel(ctx.model, ctx) ?? ctx.model);
|
|
10943
11167
|
|
|
10944
11168
|
// Some providers expose an HTTP 400 error body only through the finalized
|
|
10945
11169
|
// assistant error message; after_provider_response may contain the status
|
|
10946
11170
|
// and no diagnostic response headers. Record only the model-scoped
|
|
10947
11171
|
// reasoning-protocol category from that authoritative message identity.
|
|
10948
11172
|
if (runtimeOptimizerEnabled && hasReasoningProtocolRejectionErrorMessage(event.message)) {
|
|
10949
|
-
const fallbackModel = requestModelForMessage
|
|
10950
|
-
?? (resolveRouteModel(ctx.model, ctx) ?? ctx.model);
|
|
11173
|
+
const fallbackModel = requestModelForMessage ?? contextualFallbackForMessage;
|
|
10951
11174
|
const messageModel = modelFromAssistantMessage(event.message, fallbackModel) ?? fallbackModel;
|
|
10952
11175
|
const errorModel = messageModel
|
|
10953
11176
|
? findModelInRegistry(ctx.modelRegistry, messageModel.provider, messageModel.id) ?? messageModel
|
|
@@ -10962,7 +11185,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
10962
11185
|
}
|
|
10963
11186
|
}
|
|
10964
11187
|
if (runtimeOptimizerEnabled && hasPromptCacheKeyUnsupportedErrorMessage(event.message)) {
|
|
10965
|
-
const fallbackModel = requestModelForMessage ??
|
|
11188
|
+
const fallbackModel = requestModelForMessage ?? contextualFallbackForMessage;
|
|
10966
11189
|
const messageModel = modelFromAssistantMessage(event.message, fallbackModel) ?? fallbackModel;
|
|
10967
11190
|
const errorModel = messageModel
|
|
10968
11191
|
? findModelInRegistry(ctx.modelRegistry, messageModel.provider, messageModel.id) ?? messageModel
|
|
@@ -10980,8 +11203,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
10980
11203
|
}
|
|
10981
11204
|
}
|
|
10982
11205
|
if (runtimeOptimizerEnabled && hasPromptCacheRetentionUnsupportedErrorMessage(event.message)) {
|
|
10983
|
-
const fallbackModel = requestModelForMessage
|
|
10984
|
-
?? (resolveRouteModel(ctx.model, ctx) ?? ctx.model);
|
|
11206
|
+
const fallbackModel = requestModelForMessage ?? contextualFallbackForMessage;
|
|
10985
11207
|
const messageModel = modelFromAssistantMessage(event.message, fallbackModel) ?? fallbackModel;
|
|
10986
11208
|
const errorModel = messageModel
|
|
10987
11209
|
? findModelInRegistry(ctx.modelRegistry, messageModel.provider, messageModel.id) ?? messageModel
|
|
@@ -11005,8 +11227,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
11005
11227
|
// non-retryable 400 in Pi 0.82.1, so the fallback applies to the next
|
|
11006
11228
|
// subsequent request (and to a retry only if another layer initiates one).
|
|
11007
11229
|
if (hasAnthropicCacheTtlOrderError(event.message)) {
|
|
11008
|
-
const fallbackModel = requestModelForMessage
|
|
11009
|
-
?? (resolveRouteModel(ctx.model, ctx) ?? ctx.model);
|
|
11230
|
+
const fallbackModel = requestModelForMessage ?? contextualFallbackForMessage;
|
|
11010
11231
|
const errorModel = modelFromAssistantMessage(event.message, fallbackModel) ?? fallbackModel;
|
|
11011
11232
|
if (errorModel && isAnthropicMessagesApi(errorModel.api)) {
|
|
11012
11233
|
const key = modelKey(errorModel);
|
|
@@ -11235,7 +11456,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
11235
11456
|
cmdCtx.ui.notify("No active model selected. Select a model first with /model or pi --model.", "warning");
|
|
11236
11457
|
return;
|
|
11237
11458
|
}
|
|
11238
|
-
const
|
|
11459
|
+
const configReceiptSnapshot = await readPromptCacheKeyConfigReceiptSnapshot();
|
|
11460
|
+
const configReceipt = configReceiptSnapshot?.receipt;
|
|
11239
11461
|
const modelsReceipt = await readModelsJsonFixReceipt();
|
|
11240
11462
|
const useConfigReceipt = isActionablePromptCacheKeyConfigReceipt(configReceipt) &&
|
|
11241
11463
|
configReceipt.provider === model.provider &&
|
|
@@ -11255,7 +11477,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
11255
11477
|
return;
|
|
11256
11478
|
}
|
|
11257
11479
|
try {
|
|
11258
|
-
|
|
11480
|
+
if (!configReceiptSnapshot) throw new Error("prompt-cache-key receipt changed since the rollback preview");
|
|
11481
|
+
await rollbackPromptCacheKeyConfig(configReceiptSnapshot);
|
|
11259
11482
|
setPersistedCacheOptimizerConfig(readPersistedCacheOptimizerConfig());
|
|
11260
11483
|
cmdCtx.ui.notify(`✅ Restored prompt_cache_key behavior for ${modelKey(model)}. Run /reload or restart Pi for the change to take effect.`, "info");
|
|
11261
11484
|
} catch (error) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-cache-optimizer",
|
|
3
|
-
"version": "2.8.
|
|
3
|
+
"version": "2.8.10",
|
|
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,8 +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.85.
|
|
39
|
-
"@earendil-works/pi-server": "0.85.
|
|
38
|
+
"@earendil-works/pi-coding-agent": "^0.85.1",
|
|
39
|
+
"@earendil-works/pi-server": "0.85.1",
|
|
40
40
|
"@types/node": "^22.0.0",
|
|
41
41
|
"jiti": "^2.7.0",
|
|
42
42
|
"typescript": "^5.8.3"
|