pi-cache-optimizer 2.6.6 → 2.6.9
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 +111 -1
- package/README.zh-CN.md +111 -1
- package/index.ts +568 -38
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -20,6 +20,7 @@ Pi extension for improving provider-side KV / prompt cache hit rates. It keeps s
|
|
|
20
20
|
- [Anthropic adaptive thinking models](#anthropic-adaptive-thinking-models)
|
|
21
21
|
- [Auto-repair with `/cache-optimizer fix`](#auto-repair-with-cache-optimizer-fix)
|
|
22
22
|
- [Footer stats](#footer-stats)
|
|
23
|
+
- [For router / virtual-channel extension authors](#for-router--virtual-channel-extension-authors)
|
|
23
24
|
- [Uninstall](#uninstall)
|
|
24
25
|
- [Verify effect](#verify-effect)
|
|
25
26
|
- [License](#license)
|
|
@@ -51,6 +52,8 @@ pi remove npm:pi-deepseek-cache-optimizer && pi install npm:pi-cache-optimizer
|
|
|
51
52
|
|
|
52
53
|
Run `/reload` in Pi after install/update/remove so extension hooks refresh.
|
|
53
54
|
|
|
55
|
+
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).
|
|
56
|
+
|
|
54
57
|
## Commands
|
|
55
58
|
|
|
56
59
|
| Command | Effect |
|
|
@@ -213,7 +216,7 @@ If only one model should change, use `modelOverrides`:
|
|
|
213
216
|
|
|
214
217
|
Stats are read-only local counters stored at `~/.pi/agent/pi-cache-optimizer-stats.json` and scoped by Pi session + provider/model. They contain only dates and numeric counters — no API keys, prompts, payloads, headers, responses, or model output.
|
|
215
218
|
|
|
216
|
-
|
|
219
|
+
Pi 0.79+ also includes a built-in footer `CH` marker for the latest prompt cache hit rate. This extension complements that marker with persisted, provider/model/session-scoped counters plus proxy compat diagnostics.
|
|
217
220
|
|
|
218
221
|
Example footer:
|
|
219
222
|
|
|
@@ -227,6 +230,113 @@ Supported footer labels include: DS, Claude, OpenAI, Gemini, Kimi, Qwen, GLM, Mi
|
|
|
227
230
|
|
|
228
231
|
Adapter selection uses only model id/name (plus assistant message model/name on message end). Generic OpenAI-shaped APIs are not treated as OpenAI-family unless the model id/name matches a supported family.
|
|
229
232
|
|
|
233
|
+
## For router / virtual-channel extension authors
|
|
234
|
+
|
|
235
|
+
If your Pi extension provides a virtual routing provider (for example `router/auto`, `router/smart`, or a profile/channel that forwards to a real upstream), this extension can show cache stats for the real upstream provider/model instead of the virtual shell. Integration is optional, versioned, and does **not** require importing this package.
|
|
236
|
+
|
|
237
|
+
### Minimum integration: final assistant message metadata
|
|
238
|
+
|
|
239
|
+
For seamless final cache-stat attribution, relay the real upstream identity on completed assistant messages:
|
|
240
|
+
|
|
241
|
+
```ts
|
|
242
|
+
{
|
|
243
|
+
role: "assistant",
|
|
244
|
+
provider: "anthropic", // real upstream provider
|
|
245
|
+
responseModel: "claude-opus-4-8", // or model: "..."
|
|
246
|
+
api: "anthropic-messages", // upstream Pi API id when known
|
|
247
|
+
usage: {
|
|
248
|
+
input: 1200, // Pi-normalized uncached input tokens, if available
|
|
249
|
+
cacheRead: 8000, // tokens read from provider prompt cache
|
|
250
|
+
cacheWrite: 500, // tokens newly written to provider prompt cache
|
|
251
|
+
},
|
|
252
|
+
}
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
`message_end` treats these assistant-message fields as authoritative. If `provider` + `model`/`responseModel` + cache usage are present, stats update the upstream bucket even when the active model is still `router/auto`. If upstream usage does not expose cache fields, leave them absent/zero; this extension will not fake cache hits.
|
|
256
|
+
|
|
257
|
+
### Optional: live route registry for pre-response UX
|
|
258
|
+
|
|
259
|
+
Final message metadata is enough for post-response stats. For pre-response flows — footer display before the first response, `/cache-optimizer doctor`, `/cache-optimizer compat`, `/cache-optimizer reset`, and OpenAI-compatible `prompt_cache_key` fallback — register a live route adapter under `Symbol.for("pi.routing.registry.v1")`.
|
|
260
|
+
|
|
261
|
+
Protocol shape:
|
|
262
|
+
|
|
263
|
+
```ts
|
|
264
|
+
type PiRouteSnapshot = {
|
|
265
|
+
virtualProvider: string;
|
|
266
|
+
virtualModelId: string;
|
|
267
|
+
provider: string;
|
|
268
|
+
modelId: string;
|
|
269
|
+
api?: string;
|
|
270
|
+
canonicalModelId?: string;
|
|
271
|
+
routeLabel?: string;
|
|
272
|
+
status?: "planned" | "trying" | "selected" | "success" | "failed";
|
|
273
|
+
sessionIdHash?: string;
|
|
274
|
+
requestId?: string;
|
|
275
|
+
timestamp: number;
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
type PiRouterAdapterV1 = {
|
|
279
|
+
virtualProvider: string;
|
|
280
|
+
resolveActiveRoute(
|
|
281
|
+
virtualModelId: string,
|
|
282
|
+
hint?: { sessionIdHash?: string; requestId?: string },
|
|
283
|
+
): PiRouteSnapshot | undefined;
|
|
284
|
+
resolveCandidateRoutes?(virtualModelId: string): PiRouteSnapshot[];
|
|
285
|
+
subscribe?(listener: (event: PiRouteSnapshot) => void): () => void;
|
|
286
|
+
};
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
Registration pattern:
|
|
290
|
+
|
|
291
|
+
```ts
|
|
292
|
+
const ROUTING = Symbol.for("pi.routing.registry.v1");
|
|
293
|
+
const registry = (globalThis as Record<symbol, unknown>)[ROUTING] as
|
|
294
|
+
| { version: 1; registerRouter(adapter: PiRouterAdapterV1): () => void }
|
|
295
|
+
| undefined;
|
|
296
|
+
|
|
297
|
+
registry?.registerRouter({
|
|
298
|
+
virtualProvider: "router",
|
|
299
|
+
resolveActiveRoute(virtualModelId, hint) {
|
|
300
|
+
return {
|
|
301
|
+
virtualProvider: "router",
|
|
302
|
+
virtualModelId,
|
|
303
|
+
provider: "deepseek",
|
|
304
|
+
modelId: "deepseek-v4",
|
|
305
|
+
api: "openai-completions",
|
|
306
|
+
sessionIdHash: hint?.sessionIdHash,
|
|
307
|
+
timestamp: Date.now(),
|
|
308
|
+
};
|
|
309
|
+
},
|
|
310
|
+
});
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
Do not overwrite an existing registry. If your extension loads before this optimizer, retry registration on `session_start` or create the same V1 registry shape only if no registry exists.
|
|
314
|
+
|
|
315
|
+
### Optional: query-scoped cache hints
|
|
316
|
+
|
|
317
|
+
Routers that forward to an inner Pi request path can read query-scoped hints from `Symbol.for("pi.cache.hints.v1")`:
|
|
318
|
+
|
|
319
|
+
```ts
|
|
320
|
+
const CACHE_HINTS = Symbol.for("pi.cache.hints.v1");
|
|
321
|
+
const hints = (globalThis as Record<symbol, any>)[CACHE_HINTS]?.getHints?.({
|
|
322
|
+
sessionIdHash,
|
|
323
|
+
virtualProvider: "router",
|
|
324
|
+
virtualModelId: "auto",
|
|
325
|
+
upstreamProvider: "deepseek",
|
|
326
|
+
upstreamModelId: "deepseek-v4",
|
|
327
|
+
api: "openai-completions",
|
|
328
|
+
});
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
When the query matches the current session/route, `hints` may contain `systemPrompt`, `promptCacheKey`, and `cacheRetention: "long"`. Treat these as advisory and sensitive: do not log them, do not expose prompt text, and do not overwrite an existing request-level `prompt_cache_key` / `promptCacheKey`.
|
|
332
|
+
|
|
333
|
+
### Security and correctness rules
|
|
334
|
+
|
|
335
|
+
- Do not import `pi-cache-optimizer`; use `Symbol.for(...)` discovery only.
|
|
336
|
+
- Do not expose API keys, prompts, payloads, headers, response bodies, or model output in route snapshots or logs.
|
|
337
|
+
- Use assistant-message metadata for final attribution; live registry data is advisory and may be stale by response time.
|
|
338
|
+
- Preserve truthful usage. Missing cache usage should show as 0/under-reported, not as synthetic hits.
|
|
339
|
+
|
|
230
340
|
## Uninstall
|
|
231
341
|
|
|
232
342
|
```bash
|
package/README.zh-CN.md
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
- [Anthropic adaptive thinking 模型](#anthropic-adaptive-thinking-模型)
|
|
21
21
|
- [使用 `/cache-optimizer fix` 自动修复](#使用-cache-optimizer-fix-自动修复)
|
|
22
22
|
- [Footer 统计](#footer-统计)
|
|
23
|
+
- [Router / Virtual-channel 扩展作者指南](#router--virtual-channel-扩展作者指南)
|
|
23
24
|
- [卸载](#卸载)
|
|
24
25
|
- [验证效果](#验证效果)
|
|
25
26
|
- [License](#license)
|
|
@@ -51,6 +52,8 @@ pi remove npm:pi-deepseek-cache-optimizer && pi install npm:pi-cache-optimizer
|
|
|
51
52
|
|
|
52
53
|
安装、更新或移除后,在 Pi 中运行 `/reload`,让 extension hooks 刷新。
|
|
53
54
|
|
|
55
|
+
Pi 0.79.7 及之后,`pi update` 默认只更新 Pi 本体。若要更新已安装的 Pi package(包括本扩展),请运行 `pi update --extensions`(只更新 packages)或 `pi update --all`(Pi 与 packages 一起更新)。
|
|
56
|
+
|
|
54
57
|
## 命令
|
|
55
58
|
|
|
56
59
|
| 命令 | 作用 |
|
|
@@ -213,7 +216,7 @@ Provider 级最小 override:
|
|
|
213
216
|
|
|
214
217
|
统计是只读本地计数,保存在 `~/.pi/agent/pi-cache-optimizer-stats.json`,按 Pi session + provider/model 隔离。文件只包含日期和数字计数,不包含 API key、prompt、payload、headers、响应或模型输出。
|
|
215
218
|
|
|
216
|
-
|
|
219
|
+
Pi 0.79+ 已内置 footer `CH` 标记,用于显示最近一次 prompt cache hit rate。本扩展在此基础上补充持久化的 provider/model/session-scoped 计数,以及代理 compat 诊断。
|
|
217
220
|
|
|
218
221
|
示例 footer:
|
|
219
222
|
|
|
@@ -227,6 +230,113 @@ OpenAI cache 3/10 · 0.002M/0.005M tok (40%) ⚠️ compat
|
|
|
227
230
|
|
|
228
231
|
Adapter 选择只看模型 id/name(以及 message_end 时 assistant message 的 model/name)。仅使用 OpenAI-shaped API 不会被当作 OpenAI-family,除非模型 id/name 匹配受支持的家族。
|
|
229
232
|
|
|
233
|
+
## Router / Virtual-channel 扩展作者指南
|
|
234
|
+
|
|
235
|
+
如果你的 Pi 扩展提供虚拟 routing provider(例如 `router/auto`、`router/smart`,或会转发到真实上游的 profile/channel),本扩展可以为真实上游 provider/model 显示缓存统计,而不是把统计记到虚拟外壳上。集成是可选、版本化的,并且**不需要导入本包**。
|
|
236
|
+
|
|
237
|
+
### 最小集成:最终 assistant message metadata
|
|
238
|
+
|
|
239
|
+
要无缝获得最终缓存统计归因,请在完成的 assistant message 上透传真实上游身份:
|
|
240
|
+
|
|
241
|
+
```ts
|
|
242
|
+
{
|
|
243
|
+
role: "assistant",
|
|
244
|
+
provider: "anthropic", // 真实上游 provider
|
|
245
|
+
responseModel: "claude-opus-4-8", // 或 model: "..."
|
|
246
|
+
api: "anthropic-messages", // 已知时填写上游 Pi API id
|
|
247
|
+
usage: {
|
|
248
|
+
input: 1200, // Pi-normalized 未缓存 input tokens,如可用
|
|
249
|
+
cacheRead: 8000, // 从 provider prompt cache 读取的 tokens
|
|
250
|
+
cacheWrite: 500, // 本次新写入 provider prompt cache 的 tokens
|
|
251
|
+
},
|
|
252
|
+
}
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
`message_end` 会把这些 assistant-message 字段视为权威来源。只要存在 `provider` + `model`/`responseModel` + cache usage,即使 active model 仍是 `router/auto`,统计也会更新真实上游桶。如果上游 usage 没有 cache 字段,请保持缺失或为 0;本扩展不会伪造 cache hit。
|
|
256
|
+
|
|
257
|
+
### 可选:用于预响应 UX 的实时路由注册表
|
|
258
|
+
|
|
259
|
+
最终 message metadata 足以支持响应后的统计。若要支持响应前流程——首次响应前的 footer 显示、`/cache-optimizer doctor`、`/cache-optimizer compat`、`/cache-optimizer reset` 和 OpenAI-compatible `prompt_cache_key` fallback——请在 `Symbol.for("pi.routing.registry.v1")` 下注册 live route adapter。
|
|
260
|
+
|
|
261
|
+
协议形状:
|
|
262
|
+
|
|
263
|
+
```ts
|
|
264
|
+
type PiRouteSnapshot = {
|
|
265
|
+
virtualProvider: string;
|
|
266
|
+
virtualModelId: string;
|
|
267
|
+
provider: string;
|
|
268
|
+
modelId: string;
|
|
269
|
+
api?: string;
|
|
270
|
+
canonicalModelId?: string;
|
|
271
|
+
routeLabel?: string;
|
|
272
|
+
status?: "planned" | "trying" | "selected" | "success" | "failed";
|
|
273
|
+
sessionIdHash?: string;
|
|
274
|
+
requestId?: string;
|
|
275
|
+
timestamp: number;
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
type PiRouterAdapterV1 = {
|
|
279
|
+
virtualProvider: string;
|
|
280
|
+
resolveActiveRoute(
|
|
281
|
+
virtualModelId: string,
|
|
282
|
+
hint?: { sessionIdHash?: string; requestId?: string },
|
|
283
|
+
): PiRouteSnapshot | undefined;
|
|
284
|
+
resolveCandidateRoutes?(virtualModelId: string): PiRouteSnapshot[];
|
|
285
|
+
subscribe?(listener: (event: PiRouteSnapshot) => void): () => void;
|
|
286
|
+
};
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
注册模式:
|
|
290
|
+
|
|
291
|
+
```ts
|
|
292
|
+
const ROUTING = Symbol.for("pi.routing.registry.v1");
|
|
293
|
+
const registry = (globalThis as Record<symbol, unknown>)[ROUTING] as
|
|
294
|
+
| { version: 1; registerRouter(adapter: PiRouterAdapterV1): () => void }
|
|
295
|
+
| undefined;
|
|
296
|
+
|
|
297
|
+
registry?.registerRouter({
|
|
298
|
+
virtualProvider: "router",
|
|
299
|
+
resolveActiveRoute(virtualModelId, hint) {
|
|
300
|
+
return {
|
|
301
|
+
virtualProvider: "router",
|
|
302
|
+
virtualModelId,
|
|
303
|
+
provider: "deepseek",
|
|
304
|
+
modelId: "deepseek-v4",
|
|
305
|
+
api: "openai-completions",
|
|
306
|
+
sessionIdHash: hint?.sessionIdHash,
|
|
307
|
+
timestamp: Date.now(),
|
|
308
|
+
};
|
|
309
|
+
},
|
|
310
|
+
});
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
不要覆盖已有 registry。如果你的扩展比本优化器更早加载,请在 `session_start` 时重试注册,或仅在 registry 不存在时创建同样的 V1 registry 形状。
|
|
314
|
+
|
|
315
|
+
### 可选:按查询过滤的缓存提示
|
|
316
|
+
|
|
317
|
+
会转发到内部 Pi 请求路径的 router,可以从 `Symbol.for("pi.cache.hints.v1")` 读取按查询过滤的提示:
|
|
318
|
+
|
|
319
|
+
```ts
|
|
320
|
+
const CACHE_HINTS = Symbol.for("pi.cache.hints.v1");
|
|
321
|
+
const hints = (globalThis as Record<symbol, any>)[CACHE_HINTS]?.getHints?.({
|
|
322
|
+
sessionIdHash,
|
|
323
|
+
virtualProvider: "router",
|
|
324
|
+
virtualModelId: "auto",
|
|
325
|
+
upstreamProvider: "deepseek",
|
|
326
|
+
upstreamModelId: "deepseek-v4",
|
|
327
|
+
api: "openai-completions",
|
|
328
|
+
});
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
当查询匹配当前 session/route 时,`hints` 可能包含 `systemPrompt`、`promptCacheKey` 和 `cacheRetention: "long"`。这些提示是参考信息且可能敏感:不要记录日志,不要暴露 prompt 文本,也不要覆盖已有 request-level `prompt_cache_key` / `promptCacheKey`。
|
|
332
|
+
|
|
333
|
+
### 安全与正确性规则
|
|
334
|
+
|
|
335
|
+
- 不要导入 `pi-cache-optimizer`;只使用 `Symbol.for(...)` 发现协议。
|
|
336
|
+
- 不要在 route snapshot 或日志中暴露 API key、prompt、payload、headers、response body 或模型输出。
|
|
337
|
+
- 最终归因使用 assistant-message metadata;live registry 只是参考信息,到响应完成时可能已经过期。
|
|
338
|
+
- 保持 usage 真实。缺失 cache usage 时应该显示 0 或低报,而不是合成命中。
|
|
339
|
+
|
|
230
340
|
## 卸载
|
|
231
341
|
|
|
232
342
|
```bash
|
package/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { copyFile, mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
3
4
|
import { homedir } from "node:os";
|
|
4
5
|
import { dirname, join } from "node:path";
|
|
5
6
|
import type { BuildSystemPromptOptions, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
@@ -958,12 +959,12 @@ function getNonNegativeNumber(record: UnknownRecord, key: string): number | unde
|
|
|
958
959
|
*/
|
|
959
960
|
function getCompat(model: PiModel | undefined): CacheCompat {
|
|
960
961
|
if (!model) return {} as CacheCompat;
|
|
961
|
-
|
|
962
|
+
|
|
962
963
|
// Pi merges provider.compat with model.compat (model wins on conflicts)
|
|
963
964
|
// We approximate this by reading from ctx.model which should already have merged compat
|
|
964
965
|
// However, for safety, we check both levels if available
|
|
965
966
|
const modelCompat = (model.compat ?? {}) as CacheCompat;
|
|
966
|
-
|
|
967
|
+
|
|
967
968
|
// Note: ctx.model from Pi should already contain merged compat,
|
|
968
969
|
// but we document the two-level structure for clarity
|
|
969
970
|
return modelCompat;
|
|
@@ -1941,6 +1942,13 @@ function describeMissingOpenAICompatibleProxyCompat(model: PiModel): string[] {
|
|
|
1941
1942
|
missing.push("sendSessionAffinityHeaders");
|
|
1942
1943
|
}
|
|
1943
1944
|
|
|
1945
|
+
// NOTE: supportsLongCacheRetention is intentionally NOT checked here.
|
|
1946
|
+
// Per spec, it is optional/risky advisory text only and must NOT trigger
|
|
1947
|
+
// the ⚠️ compat marker. The before_provider_request hook proactively
|
|
1948
|
+
// strips prompt_cache_retention for models without explicit opt-in,
|
|
1949
|
+
// so 400 errors are prevented regardless of this compat flag.
|
|
1950
|
+
// Doctor/compat may mention it as optional guidance separately.
|
|
1951
|
+
|
|
1944
1952
|
return missing;
|
|
1945
1953
|
}
|
|
1946
1954
|
|
|
@@ -1963,6 +1971,9 @@ function buildSafeOpenAIProxyCompatSuggestion(missing: string[]): Record<string,
|
|
|
1963
1971
|
if (missing.includes("sendSessionAffinityHeaders")) {
|
|
1964
1972
|
suggestion.sendSessionAffinityHeaders = true;
|
|
1965
1973
|
}
|
|
1974
|
+
// supportsLongCacheRetention is NOT suggested here — per spec it is
|
|
1975
|
+
// optional/risky and must not appear in the copyable safe snippet.
|
|
1976
|
+
// The proactive stripping in before_provider_request handles 400 prevention.
|
|
1966
1977
|
return suggestion;
|
|
1967
1978
|
}
|
|
1968
1979
|
|
|
@@ -1984,6 +1995,10 @@ function hasPromptCacheRetentionUnsupportedSignal(headers: Record<string, string
|
|
|
1984
1995
|
"unknown parameter",
|
|
1985
1996
|
"not supported",
|
|
1986
1997
|
"unsupported field",
|
|
1998
|
+
"extra inputs",
|
|
1999
|
+
"not permitted",
|
|
2000
|
+
"unrecognized",
|
|
2001
|
+
"bad request",
|
|
1987
2002
|
].some((needle) => normalized.includes(needle));
|
|
1988
2003
|
}
|
|
1989
2004
|
|
|
@@ -4714,6 +4729,291 @@ function locateModelInJsonc(
|
|
|
4714
4729
|
};
|
|
4715
4730
|
}
|
|
4716
4731
|
|
|
4732
|
+
/**
|
|
4733
|
+
* Scan produced by `analyzeModelsJsonForMissingEntry` when
|
|
4734
|
+
* `locateModelInJsonc` cannot find the target provider/model.
|
|
4735
|
+
*/
|
|
4736
|
+
type MissingEntryDiagnosis =
|
|
4737
|
+
| { scenario: "provider_missing"; providersEnd: number }
|
|
4738
|
+
| { scenario: "model_missing"; modelsEnd: number; providerBrace: number; providerEndBrace: number }
|
|
4739
|
+
| { scenario: "provider_without_models"; providerBrace: number; providerEndBrace: number };
|
|
4740
|
+
|
|
4741
|
+
/**
|
|
4742
|
+
* Light second-pass scan that determines *why* `locateModelInJsonc` failed.
|
|
4743
|
+
* Returns structured diagnostic so the fix handler can compose targeted
|
|
4744
|
+
* guidance and an optional surgical insertion for API-logged-in models
|
|
4745
|
+
* (e.g. opencode go) that never appear in `models.json`.
|
|
4746
|
+
*/
|
|
4747
|
+
function analyzeModelsJsonForMissingEntry(
|
|
4748
|
+
text: string,
|
|
4749
|
+
providerLabel: string,
|
|
4750
|
+
modelId: string,
|
|
4751
|
+
): MissingEntryDiagnosis | undefined {
|
|
4752
|
+
const clean = stripJsoncComments(text);
|
|
4753
|
+
const rootBrace = skipJsonWhitespace(clean, 0);
|
|
4754
|
+
if (clean[rootBrace] !== "{") return undefined;
|
|
4755
|
+
|
|
4756
|
+
const providersKey = findJsonObjectKey(clean, rootBrace, "providers");
|
|
4757
|
+
if (!providersKey) {
|
|
4758
|
+
// Root has no "providers" key at all — we don't auto-create one.
|
|
4759
|
+
return undefined;
|
|
4760
|
+
}
|
|
4761
|
+
const providersBrace = skipJsonWhitespace(clean, providersKey.valueStart);
|
|
4762
|
+
if (clean[providersBrace] !== "{") return undefined;
|
|
4763
|
+
const providersEnd = findMatchingBracket(clean, providersBrace);
|
|
4764
|
+
if (providersEnd === undefined) return undefined;
|
|
4765
|
+
|
|
4766
|
+
const providerKey = findJsonObjectKey(clean, providersBrace, providerLabel);
|
|
4767
|
+
if (!providerKey || providerKey.keyStart > providersEnd) {
|
|
4768
|
+
return { scenario: "provider_missing", providersEnd };
|
|
4769
|
+
}
|
|
4770
|
+
|
|
4771
|
+
// Provider exists. Check for a models array so we know where to append.
|
|
4772
|
+
const providerBrace = skipJsonWhitespace(clean, providerKey.valueStart);
|
|
4773
|
+
if (clean[providerBrace] !== "{") return undefined;
|
|
4774
|
+
const providerEndBrace = findMatchingBracket(clean, providerBrace);
|
|
4775
|
+
if (providerEndBrace === undefined || providerEndBrace > providersEnd) return undefined;
|
|
4776
|
+
|
|
4777
|
+
const modelsKey = findJsonObjectKey(clean, providerBrace, "models");
|
|
4778
|
+
if (modelsKey && modelsKey.keyStart < providerEndBrace) {
|
|
4779
|
+
let mScan = skipJsonWhitespace(clean, modelsKey.valueStart);
|
|
4780
|
+
if (clean[mScan] === "[") {
|
|
4781
|
+
const modelsEnd = findMatchingBracket(clean, mScan);
|
|
4782
|
+
if (modelsEnd !== undefined && modelsEnd <= providerEndBrace) {
|
|
4783
|
+
return { scenario: "model_missing", modelsEnd, providerBrace, providerEndBrace };
|
|
4784
|
+
}
|
|
4785
|
+
}
|
|
4786
|
+
}
|
|
4787
|
+
|
|
4788
|
+
// Provider exists, but there's no discoverable models array — treat as
|
|
4789
|
+
// a provider that needs one.
|
|
4790
|
+
return { scenario: "provider_without_models", providerBrace, providerEndBrace };
|
|
4791
|
+
}
|
|
4792
|
+
|
|
4793
|
+
/**
|
|
4794
|
+
* Build a copyable manual-edit snippet for the missing entry. Used when the
|
|
4795
|
+
* terminal is non-interactive or the user chooses to edit by hand.
|
|
4796
|
+
* Returns a complete provider→model→compat JSON block that the user can
|
|
4797
|
+
* paste into `models.json` under `providers`.
|
|
4798
|
+
*/
|
|
4799
|
+
function formatMissingEntryManualSnippet(
|
|
4800
|
+
providerLabel: string,
|
|
4801
|
+
modelId: string,
|
|
4802
|
+
compatKeys: Record<string, unknown>,
|
|
4803
|
+
): string {
|
|
4804
|
+
const lines: string[] = [];
|
|
4805
|
+
const sorted = Object.entries(compatKeys).sort(([a], [b]) => a.localeCompare(b));
|
|
4806
|
+
const compatItems = sorted.map(([k, v]) => ` ${JSON.stringify(k)}: ${JSON.stringify(v)}`);
|
|
4807
|
+
lines.push(`"${providerLabel}": {`);
|
|
4808
|
+
lines.push(` "models": [`);
|
|
4809
|
+
lines.push(` {`);
|
|
4810
|
+
lines.push(` "id": ${JSON.stringify(modelId)},`);
|
|
4811
|
+
lines.push(` "compat": {`);
|
|
4812
|
+
lines.push(compatItems.join(",\n"));
|
|
4813
|
+
lines.push(` }`);
|
|
4814
|
+
lines.push(` }`);
|
|
4815
|
+
lines.push(` ]`);
|
|
4816
|
+
lines.push(` }`);
|
|
4817
|
+
return lines.join("\n");
|
|
4818
|
+
}
|
|
4819
|
+
|
|
4820
|
+
/**
|
|
4821
|
+
* Surgically insert the missing provider/model entry into the original
|
|
4822
|
+
* JSONC text. Returns the modified text and placement descriptor.
|
|
4823
|
+
*
|
|
4824
|
+
* Handles three scenarios:
|
|
4825
|
+
* - `model_missing`: append a new model object to the provider's `models` array.
|
|
4826
|
+
* - `provider_missing`: append a new provider block to the root `providers` object.
|
|
4827
|
+
* - `provider_without_models`: inject a `"models": [...]` key into the existing provider.
|
|
4828
|
+
*/
|
|
4829
|
+
function composeMissingEntryInsertion(
|
|
4830
|
+
originalText: string,
|
|
4831
|
+
diagnosis: MissingEntryDiagnosis,
|
|
4832
|
+
providerLabel: string,
|
|
4833
|
+
modelId: string,
|
|
4834
|
+
compatKeys: Record<string, unknown>,
|
|
4835
|
+
): { modifiedText: string; placementLabel: string } {
|
|
4836
|
+
// Resolve a sensible indentation step from an arbitrary byte offset in
|
|
4837
|
+
// the original file.
|
|
4838
|
+
const indentUnitAt = (offset: number): string => {
|
|
4839
|
+
const ls = originalText.lastIndexOf("\n", offset);
|
|
4840
|
+
const line = originalText.slice(ls < 0 ? 0 : ls + 1, offset);
|
|
4841
|
+
const m = line.match(/^(\s+)/);
|
|
4842
|
+
return m ? m[1] : " ";
|
|
4843
|
+
};
|
|
4844
|
+
|
|
4845
|
+
// Figure out the base indent from the insertion point's own line.
|
|
4846
|
+
// Then derive inner indents (+1 and +2 levels).
|
|
4847
|
+
const sorted = Object.entries(compatKeys).sort(([a], [b]) => a.localeCompare(b));
|
|
4848
|
+
const formatCompactCompat = (indent: string): string => {
|
|
4849
|
+
// Single-line compact when there's only one key, multi-line otherwise.
|
|
4850
|
+
if (sorted.length === 1) {
|
|
4851
|
+
const [k, v] = sorted[0];
|
|
4852
|
+
return `{ ${JSON.stringify(k)}: ${JSON.stringify(v)} }`;
|
|
4853
|
+
}
|
|
4854
|
+
return (
|
|
4855
|
+
"{\n" +
|
|
4856
|
+
sorted.map(([k, v]) => `${indent}${JSON.stringify(k)}: ${JSON.stringify(v)}`).join(",\n") +
|
|
4857
|
+
"\n" +
|
|
4858
|
+
indent.slice(0, -2) +
|
|
4859
|
+
"}"
|
|
4860
|
+
);
|
|
4861
|
+
};
|
|
4862
|
+
|
|
4863
|
+
if (diagnosis.scenario === "model_missing") {
|
|
4864
|
+
// Append to the provider's models array, right before `]`.
|
|
4865
|
+
const unit = indentUnitAt(diagnosis.modelsEnd);
|
|
4866
|
+
const inner0 = unit + unit; // indent of model object's own keys
|
|
4867
|
+
const inner1 = inner0 + unit; // indent of compat keys inside the model
|
|
4868
|
+
const inner2 = inner1 + unit; // indent of compat values
|
|
4869
|
+
|
|
4870
|
+
// Determine whether the array is empty (need to skip the leading newline).
|
|
4871
|
+
const arrayInterior = originalText.slice(
|
|
4872
|
+
originalText.lastIndexOf("[", diagnosis.modelsEnd) + 1,
|
|
4873
|
+
diagnosis.modelsEnd,
|
|
4874
|
+
).trim();
|
|
4875
|
+
const hasExistingElements = arrayInterior.length > 0;
|
|
4876
|
+
|
|
4877
|
+
const compatBlock = formatCompactCompat(inner2);
|
|
4878
|
+
const modelBlock = [
|
|
4879
|
+
hasExistingElements ? "," : "",
|
|
4880
|
+
inner0 + "{",
|
|
4881
|
+
inner1 + `"id": ${JSON.stringify(modelId)},`,
|
|
4882
|
+
inner1 + `"compat": ` + compatBlock,
|
|
4883
|
+
inner0 + "}",
|
|
4884
|
+
unit,
|
|
4885
|
+
].filter(Boolean).join("\n");
|
|
4886
|
+
|
|
4887
|
+
const insertionPoint = diagnosis.modelsEnd;
|
|
4888
|
+
const prefix = originalText.slice(0, insertionPoint);
|
|
4889
|
+
const suffix = originalText.slice(insertionPoint); // starts with `]`
|
|
4890
|
+
return {
|
|
4891
|
+
modifiedText: prefix + modelBlock + suffix,
|
|
4892
|
+
placementLabel: `providers["${providerLabel}"] -> models -> (new entry for "${modelId}")`,
|
|
4893
|
+
};
|
|
4894
|
+
}
|
|
4895
|
+
|
|
4896
|
+
if (diagnosis.scenario === "provider_missing") {
|
|
4897
|
+
// Append a new provider entry to the root `providers` object, right
|
|
4898
|
+
// before its closing `}`.
|
|
4899
|
+
const unit = indentUnitAt(diagnosis.providersEnd);
|
|
4900
|
+
const inner0 = unit + unit;
|
|
4901
|
+
const inner1 = inner0 + unit;
|
|
4902
|
+
const inner2 = inner1 + unit;
|
|
4903
|
+
const inner3 = inner2 + unit;
|
|
4904
|
+
|
|
4905
|
+
const compatBlock = formatCompactCompat(inner3);
|
|
4906
|
+
const providersInterior = originalText.slice(
|
|
4907
|
+
originalText.lastIndexOf("{", diagnosis.providersEnd) + 1,
|
|
4908
|
+
diagnosis.providersEnd,
|
|
4909
|
+
).trim();
|
|
4910
|
+
const hasExisting = providersInterior.length > 0;
|
|
4911
|
+
|
|
4912
|
+
const providerBlock = [
|
|
4913
|
+
hasExisting ? "," : "",
|
|
4914
|
+
inner0 + `"${providerLabel}": {`,
|
|
4915
|
+
inner1 + `"models": [`,
|
|
4916
|
+
inner2 + "{",
|
|
4917
|
+
inner3 + `"id": ${JSON.stringify(modelId)},`,
|
|
4918
|
+
inner3 + `"compat": ` + compatBlock,
|
|
4919
|
+
inner2 + "}",
|
|
4920
|
+
inner1 + "]",
|
|
4921
|
+
inner0 + "}",
|
|
4922
|
+
unit,
|
|
4923
|
+
].filter(Boolean).join("\n");
|
|
4924
|
+
|
|
4925
|
+
const insertionPoint = diagnosis.providersEnd;
|
|
4926
|
+
const prefix = originalText.slice(0, insertionPoint);
|
|
4927
|
+
const suffix = originalText.slice(insertionPoint);
|
|
4928
|
+
return {
|
|
4929
|
+
modifiedText: prefix + providerBlock + suffix,
|
|
4930
|
+
placementLabel: `providers -> (new entry "${providerLabel}")`,
|
|
4931
|
+
};
|
|
4932
|
+
}
|
|
4933
|
+
|
|
4934
|
+
// `provider_without_models`: inject a models array key into the
|
|
4935
|
+
// existing provider block, right after the provider's opening `{`.
|
|
4936
|
+
const unit = indentUnitAt(diagnosis.providerBrace);
|
|
4937
|
+
const inner0 = unit + unit;
|
|
4938
|
+
const inner1 = inner0 + unit;
|
|
4939
|
+
const inner2 = inner1 + unit;
|
|
4940
|
+
|
|
4941
|
+
const compatBlock = formatCompactCompat(inner2);
|
|
4942
|
+
const afterBrace = diagnosis.providerBrace + 1;
|
|
4943
|
+
const modelsBlock = [
|
|
4944
|
+
"",
|
|
4945
|
+
inner0 + `"models": [`,
|
|
4946
|
+
inner1 + "{",
|
|
4947
|
+
inner2 + `"id": ${JSON.stringify(modelId)},`,
|
|
4948
|
+
inner2 + `"compat": ` + compatBlock,
|
|
4949
|
+
inner1 + "}",
|
|
4950
|
+
inner0 + "],",
|
|
4951
|
+
unit,
|
|
4952
|
+
].join("\n");
|
|
4953
|
+
|
|
4954
|
+
return {
|
|
4955
|
+
modifiedText: originalText.slice(0, afterBrace) + modelsBlock + originalText.slice(afterBrace),
|
|
4956
|
+
placementLabel: `providers["${providerLabel}"] -> (new "models" array with "${modelId}")`,
|
|
4957
|
+
};
|
|
4958
|
+
}
|
|
4959
|
+
|
|
4960
|
+
/**
|
|
4961
|
+
* Lightweight self-check for a newly inserted entry.
|
|
4962
|
+
* Parses the modified text as JSONC and confirms:
|
|
4963
|
+
* 1. The target model exists under the provider.
|
|
4964
|
+
* 2. Every compat key has the expected value (merged provider+model).
|
|
4965
|
+
* Returns null on success, an error string on failure.
|
|
4966
|
+
*/
|
|
4967
|
+
function selfCheckMissingEntryInsertion(
|
|
4968
|
+
originalText: string,
|
|
4969
|
+
modifiedText: string,
|
|
4970
|
+
providerLabel: string,
|
|
4971
|
+
modelId: string,
|
|
4972
|
+
compatKeys: Record<string, unknown>,
|
|
4973
|
+
): string | null {
|
|
4974
|
+
try {
|
|
4975
|
+
const modParsed = parseJsonc(modifiedText);
|
|
4976
|
+
const providers = asRecord(asRecord(modParsed)?.providers);
|
|
4977
|
+
if (!providers) return "Modified file: providers object missing or invalid";
|
|
4978
|
+
const provider = asRecord(providers[providerLabel]);
|
|
4979
|
+
if (!provider) return `Modified file: provider "${providerLabel}" not found`;
|
|
4980
|
+
const models = provider.models;
|
|
4981
|
+
if (!Array.isArray(models)) return `Modified file: provider "${providerLabel}".models is not an array`;
|
|
4982
|
+
const targetModel = models.find((m: unknown) => asRecord(m)?.id === modelId);
|
|
4983
|
+
if (!targetModel || typeof targetModel !== "object")
|
|
4984
|
+
return `Modified file: model "${modelId}" not found in provider after insertion`;
|
|
4985
|
+
|
|
4986
|
+
// Validate effective merged compat
|
|
4987
|
+
const provCompatRaw = (provider as Record<string, unknown>).compat;
|
|
4988
|
+
const provCompat = (provCompatRaw && typeof provCompatRaw === "object" && !Array.isArray(provCompatRaw))
|
|
4989
|
+
? provCompatRaw as Record<string, unknown>
|
|
4990
|
+
: {};
|
|
4991
|
+
const mdlCompatRaw = (targetModel as Record<string, unknown>).compat;
|
|
4992
|
+
const mdlCompat = (mdlCompatRaw && typeof mdlCompatRaw === "object" && !Array.isArray(mdlCompatRaw))
|
|
4993
|
+
? mdlCompatRaw as Record<string, unknown>
|
|
4994
|
+
: {};
|
|
4995
|
+
const merged = { ...provCompat, ...mdlCompat };
|
|
4996
|
+
for (const [k, v] of Object.entries(compatKeys)) {
|
|
4997
|
+
if (!(k in merged)) return `Modified file: effective compat.${k} not found`;
|
|
4998
|
+
if (merged[k] !== v) return `Modified file: effective compat.${k} wrong value`;
|
|
4999
|
+
}
|
|
5000
|
+
|
|
5001
|
+
if (modifiedText.length < originalText.length)
|
|
5002
|
+
return "Modified file: content is shorter than original (possible truncation)";
|
|
5003
|
+
|
|
5004
|
+
const modClean = stripJsoncComments(modifiedText);
|
|
5005
|
+
const rootStart = skipJsonWhitespace(modClean, 0);
|
|
5006
|
+
const rootEnd = findMatchingBracket(modClean, rootStart);
|
|
5007
|
+
if (rootEnd === undefined) return "Modified file: root bracket mismatch";
|
|
5008
|
+
if (skipJsonWhitespace(modClean, rootEnd + 1) !== modClean.length)
|
|
5009
|
+
return "Modified file: trailing content after root object";
|
|
5010
|
+
|
|
5011
|
+
return null;
|
|
5012
|
+
} catch (e) {
|
|
5013
|
+
return `Self-check error: ${e instanceof Error ? e.message : String(e)}`;
|
|
5014
|
+
}
|
|
5015
|
+
}
|
|
5016
|
+
|
|
4717
5017
|
/**
|
|
4718
5018
|
* Deep-equal comparison of two values, used for post-write self-check.
|
|
4719
5019
|
* Compares all keys recursively, allowing `extraKeys` to be present in `a` but not in `b`.
|
|
@@ -5003,7 +5303,7 @@ function selfCheckFix(
|
|
|
5003
5303
|
if (models.length === 0) {
|
|
5004
5304
|
return `Modified file: provider "${providerLabel}".models is empty`;
|
|
5005
5305
|
}
|
|
5006
|
-
|
|
5306
|
+
|
|
5007
5307
|
// Step 4: Find and validate target model
|
|
5008
5308
|
const targetModel = models.find((m: Record<string, unknown>) => m.id === modelId);
|
|
5009
5309
|
if (!targetModel || typeof targetModel !== 'object') {
|
|
@@ -5021,7 +5321,7 @@ function selfCheckFix(
|
|
|
5021
5321
|
if (!origProvider || !origTargetModelRecord) {
|
|
5022
5322
|
return `Original file: provider/model "${providerLabel}/${modelId}" not found`;
|
|
5023
5323
|
}
|
|
5024
|
-
|
|
5324
|
+
|
|
5025
5325
|
// Step 5: Compute the EFFECTIVE merged compat (provider-level + model-level),
|
|
5026
5326
|
// mirroring Pi's mergeCompat behavior (model wins on conflicts). The fix may
|
|
5027
5327
|
// have written either level, so validation must check the merged result.
|
|
@@ -5045,7 +5345,7 @@ function selfCheckFix(
|
|
|
5045
5345
|
return `Modified file: effective compat.${k} has wrong value: expected ${JSON.stringify(v)}, got ${JSON.stringify(mergedCompat[k])}`;
|
|
5046
5346
|
}
|
|
5047
5347
|
}
|
|
5048
|
-
|
|
5348
|
+
|
|
5049
5349
|
// Step 7: Validate original structure is preserved (no accidental deletions/changes)
|
|
5050
5350
|
|
|
5051
5351
|
function isSubset(origVal: unknown, modVal: unknown, path = ''): boolean {
|
|
@@ -5092,12 +5392,12 @@ function selfCheckFix(
|
|
|
5092
5392
|
if (!isSubset(origParsed, modParsed)) {
|
|
5093
5393
|
return "Modified file: original structure was altered (data loss detected)";
|
|
5094
5394
|
}
|
|
5095
|
-
|
|
5395
|
+
|
|
5096
5396
|
// Step 8: Basic format sanity checks
|
|
5097
5397
|
if (modified.length < original.length) {
|
|
5098
5398
|
return "Modified file: content is shorter than original (possible truncation)";
|
|
5099
5399
|
}
|
|
5100
|
-
|
|
5400
|
+
|
|
5101
5401
|
// Step 9: Validate root bracket integrity with the same string/comment-aware
|
|
5102
5402
|
// scanner used for edits. Do not count raw braces: comments or strings may
|
|
5103
5403
|
// legitimately contain unmatched `{` / `}` bytes.
|
|
@@ -5791,9 +6091,56 @@ export default function (pi: ExtensionAPI) {
|
|
|
5791
6091
|
|
|
5792
6092
|
ensureRoutingRegistry();
|
|
5793
6093
|
|
|
6094
|
+
/**
|
|
6095
|
+
* Check whether a model has an EXPLICIT supportsLongCacheRetention: true
|
|
6096
|
+
* opt-in in models.json (either at provider-level or model-level).
|
|
6097
|
+
* Model-level compat takes precedence over provider-level (mirrors Pi's
|
|
6098
|
+
* mergeCompat behaviour: model wins on conflicts).
|
|
6099
|
+
*
|
|
6100
|
+
* Returns true ONLY when the user explicitly opted in. Returns false for:
|
|
6101
|
+
* - Explicit false (opt-out)
|
|
6102
|
+
* - In models.json but field absent (Pi defaults to true — unsafe)
|
|
6103
|
+
* - Not in models.json at all (API-logged-in providers)
|
|
6104
|
+
* - File missing/unreadable
|
|
6105
|
+
*
|
|
6106
|
+
* The caller strips prompt_cache_retention when this returns false.
|
|
6107
|
+
*/
|
|
6108
|
+
function hasExplicitLongRetentionOptIn(model: PiModel): boolean {
|
|
6109
|
+
try {
|
|
6110
|
+
const text = readFileSync(MODELS_JSON_PATH, "utf8");
|
|
6111
|
+
const parsed = parseJsonc(text);
|
|
6112
|
+
const providers = asRecord(asRecord(parsed)?.providers);
|
|
6113
|
+
if (!providers) return false;
|
|
6114
|
+
|
|
6115
|
+
const prov = asRecord(providers[model.provider]);
|
|
6116
|
+
if (!prov) return false;
|
|
6117
|
+
|
|
6118
|
+
// Check model-level first (higher priority in Pi's merge logic)
|
|
6119
|
+
const models = prov.models;
|
|
6120
|
+
if (Array.isArray(models)) {
|
|
6121
|
+
const modelEntry = models.find(m => asRecord(m)?.id === model.id);
|
|
6122
|
+
if (modelEntry) {
|
|
6123
|
+
const modelCompat = asRecord(asRecord(modelEntry)?.compat);
|
|
6124
|
+
if (modelCompat?.supportsLongCacheRetention !== undefined) {
|
|
6125
|
+
return modelCompat.supportsLongCacheRetention === true;
|
|
6126
|
+
}
|
|
6127
|
+
}
|
|
6128
|
+
}
|
|
6129
|
+
|
|
6130
|
+
// Check provider-level
|
|
6131
|
+
const provCompat = asRecord(prov.compat);
|
|
6132
|
+
if (provCompat?.supportsLongCacheRetention !== undefined) {
|
|
6133
|
+
return provCompat.supportsLongCacheRetention === true;
|
|
6134
|
+
}
|
|
6135
|
+
|
|
6136
|
+
return false;
|
|
6137
|
+
} catch {
|
|
6138
|
+
return false;
|
|
6139
|
+
}
|
|
6140
|
+
}
|
|
6141
|
+
|
|
5794
6142
|
pi.on("session_start", async (event, ctx) => {
|
|
5795
6143
|
await restoreCacheStats(event.reason, ctx);
|
|
5796
|
-
if (runtimeOptimizerEnabled) notifyCacheCompatIfNeeded(resolveRouteModel(ctx.model, ctx) ?? ctx.model, ctx, warnedModels);
|
|
5797
6144
|
await publishStatus(ctx);
|
|
5798
6145
|
});
|
|
5799
6146
|
|
|
@@ -5915,6 +6262,39 @@ export default function (pi: ExtensionAPI) {
|
|
|
5915
6262
|
});
|
|
5916
6263
|
|
|
5917
6264
|
pi.on("before_provider_request", (event, ctx) => {
|
|
6265
|
+
// ── Safety: strip prompt_cache_retention from payload for models that
|
|
6266
|
+
// are not authorised to send it. Pi defaults supportsLongCacheRetention
|
|
6267
|
+
// to true for all openai-completions models, but most third-party APIs
|
|
6268
|
+
// reject the parameter with 400 “Extra inputs are not permitted”.
|
|
6269
|
+
//
|
|
6270
|
+
// Gate order (first match wins):
|
|
6271
|
+
// 1. Official OpenAI → keep (trusted to support it)
|
|
6272
|
+
// 2. 400 history → strip (empirical evidence overrides user config)
|
|
6273
|
+
// 3. Explicit opt-in in models.json → keep (user explicitly wants it)
|
|
6274
|
+
// 4. Everything else → strip (safe default for third-party APIs)
|
|
6275
|
+
//
|
|
6276
|
+
// Gate 2 before Gate 3 is critical: if a user explicitly opted in but
|
|
6277
|
+
// the API returned 400, we must strip — otherwise the 400 repeats forever.
|
|
6278
|
+
if (runtimeOptimizerEnabled) {
|
|
6279
|
+
const payload = event.payload as UnknownRecord;
|
|
6280
|
+
if (payload && typeof payload.prompt_cache_retention === 'string') {
|
|
6281
|
+
const rModel = resolveRouteModel(ctx.model, ctx) ?? ctx.model;
|
|
6282
|
+
if (rModel) {
|
|
6283
|
+
if (isOfficialOpenAIBaseUrl(rModel)) {
|
|
6284
|
+
// Gate 1: Official OpenAI → keep
|
|
6285
|
+
} else if (promptCacheRetention400Models.has(modelKey(rModel))) {
|
|
6286
|
+
// Gate 2: 400 history → strip (overrides user opt-in)
|
|
6287
|
+
delete payload.prompt_cache_retention;
|
|
6288
|
+
} else if (hasExplicitLongRetentionOptIn(rModel)) {
|
|
6289
|
+
// Gate 3: Explicit user opt-in → keep
|
|
6290
|
+
} else {
|
|
6291
|
+
// Gate 4: Safe default → strip
|
|
6292
|
+
delete payload.prompt_cache_retention;
|
|
6293
|
+
}
|
|
6294
|
+
}
|
|
6295
|
+
}
|
|
6296
|
+
}
|
|
6297
|
+
|
|
5918
6298
|
if (!shouldInjectOpenAIPromptCacheKey()) return undefined;
|
|
5919
6299
|
const requestModel = resolveRouteModel(ctx.model, ctx) ?? ctx.model;
|
|
5920
6300
|
if (!isOpenAICompatibleApi(requestModel?.api)) return undefined;
|
|
@@ -6110,7 +6490,23 @@ export default function (pi: ExtensionAPI) {
|
|
|
6110
6490
|
return;
|
|
6111
6491
|
}
|
|
6112
6492
|
|
|
6113
|
-
|
|
6493
|
+
let suggestion = buildFixSuggestion(model);
|
|
6494
|
+
|
|
6495
|
+
// If no regular missing compat flags but the model has a recorded
|
|
6496
|
+
// prompt_cache_retention 400 (Pi sent `prompt_cache_retention` and
|
|
6497
|
+
// the provider rejected it), offer to override
|
|
6498
|
+
// `supportsLongCacheRetention` to false in models.json.
|
|
6499
|
+
if (!suggestion && isPromptCacheRetention400Applicable(model) && promptCacheRetention400Models.has(modelKey(model))) {
|
|
6500
|
+
const key = modelKey(model);
|
|
6501
|
+
const slashIdx = key.indexOf("/");
|
|
6502
|
+
const providerLabel = slashIdx > 0 ? key.slice(0, slashIdx) : key;
|
|
6503
|
+
suggestion = {
|
|
6504
|
+
providerLabel,
|
|
6505
|
+
modelId: model.id,
|
|
6506
|
+
compatKeys: { supportsLongCacheRetention: false },
|
|
6507
|
+
};
|
|
6508
|
+
}
|
|
6509
|
+
|
|
6114
6510
|
if (!suggestion) {
|
|
6115
6511
|
const key = modelKey(model);
|
|
6116
6512
|
cmdCtx.ui.notify(`✅ Nothing to fix for "${key}". Compat already configured.`, "info");
|
|
@@ -6120,24 +6516,40 @@ export default function (pi: ExtensionAPI) {
|
|
|
6120
6516
|
if (!cmdCtx.hasUI) {
|
|
6121
6517
|
// No UI — refuse to write, show manual guidance instead.
|
|
6122
6518
|
const compatResult = buildCompatDiagnosis(model);
|
|
6123
|
-
|
|
6124
|
-
|
|
6125
|
-
|
|
6126
|
-
|
|
6127
|
-
|
|
6128
|
-
|
|
6129
|
-
|
|
6130
|
-
|
|
6131
|
-
|
|
6132
|
-
|
|
6519
|
+
const snippet = formatMissingEntryManualSnippet(
|
|
6520
|
+
suggestion.providerLabel, suggestion.modelId, suggestion.compatKeys,
|
|
6521
|
+
);
|
|
6522
|
+
const manualLines = [
|
|
6523
|
+
`❌ Non-interactive terminal detected. Auto-fix requires UI confirmation.`,
|
|
6524
|
+
"",
|
|
6525
|
+
`Edit ${getModelsJsonDisplayPath()} and run /reload.`,
|
|
6526
|
+
];
|
|
6527
|
+
if (promptCacheRetention400Models.has(modelKey(model))) {
|
|
6528
|
+
manualLines.push(
|
|
6529
|
+
"",
|
|
6530
|
+
"💡 This model returned HTTP 400 for prompt_cache_retention.",
|
|
6531
|
+
"Create or edit the entry below to override supportsLongCacheRetention to false.",
|
|
6133
6532
|
);
|
|
6134
|
-
}
|
|
6135
|
-
|
|
6136
|
-
|
|
6137
|
-
|
|
6138
|
-
|
|
6533
|
+
}
|
|
6534
|
+
manualLines.push(
|
|
6535
|
+
"",
|
|
6536
|
+
"If the provider/model already exists in models.json, add these compat keys under",
|
|
6537
|
+
`providers["${suggestion.providerLabel}"] -> models -> entry with id "${suggestion.modelId}" -> compat:`,
|
|
6538
|
+
formatCompatKeysForInsertion(suggestion.compatKeys),
|
|
6539
|
+
);
|
|
6540
|
+
if (snippet.length > 0) {
|
|
6541
|
+
manualLines.push(
|
|
6542
|
+
"",
|
|
6543
|
+
"If the provider/model is missing (common for API-logged-in channels such as",
|
|
6544
|
+
`opencode go), add a minimal entry under "providers" (keep existing auth as-is):`,
|
|
6545
|
+
"",
|
|
6546
|
+
snippet,
|
|
6139
6547
|
);
|
|
6140
6548
|
}
|
|
6549
|
+
if (compatResult) {
|
|
6550
|
+
manualLines.push("", compatResult);
|
|
6551
|
+
}
|
|
6552
|
+
cmdCtx.ui.notify(manualLines.join("\n"), "warning");
|
|
6141
6553
|
return;
|
|
6142
6554
|
}
|
|
6143
6555
|
|
|
@@ -6150,17 +6562,127 @@ export default function (pi: ExtensionAPI) {
|
|
|
6150
6562
|
return;
|
|
6151
6563
|
}
|
|
6152
6564
|
|
|
6153
|
-
// Locate the model entry
|
|
6565
|
+
// Locate the model entry. API-logged-in providers (e.g. opencode go)
|
|
6566
|
+
// may not appear in models.json at all.
|
|
6154
6567
|
const location = locateModelInJsonc(originalText, suggestion.providerLabel, suggestion.modelId);
|
|
6155
6568
|
if (!location) {
|
|
6156
|
-
|
|
6157
|
-
|
|
6158
|
-
|
|
6159
|
-
|
|
6160
|
-
|
|
6161
|
-
|
|
6162
|
-
|
|
6163
|
-
|
|
6569
|
+
const diagnosis = analyzeModelsJsonForMissingEntry(originalText, suggestion.providerLabel, suggestion.modelId);
|
|
6570
|
+
if (diagnosis && cmdCtx.hasUI) {
|
|
6571
|
+
// Offer to create the missing entry.
|
|
6572
|
+
const plan = composeMissingEntryInsertion(
|
|
6573
|
+
originalText, diagnosis,
|
|
6574
|
+
suggestion.providerLabel, suggestion.modelId, suggestion.compatKeys,
|
|
6575
|
+
);
|
|
6576
|
+
const checkError = selfCheckMissingEntryInsertion(
|
|
6577
|
+
originalText, plan.modifiedText,
|
|
6578
|
+
suggestion.providerLabel, suggestion.modelId, suggestion.compatKeys,
|
|
6579
|
+
);
|
|
6580
|
+
if (checkError !== null) {
|
|
6581
|
+
// Fall through to manual guidance.
|
|
6582
|
+
cmdCtx.ui.notify(
|
|
6583
|
+
`❌ Self-check would fail for auto-created entry: ${checkError}\n` +
|
|
6584
|
+
`Falling back to manual guidance. No changes were made.`,
|
|
6585
|
+
"error",
|
|
6586
|
+
);
|
|
6587
|
+
// Continue to manual guidance below.
|
|
6588
|
+
} else {
|
|
6589
|
+
const keysPreview = JSON.stringify(suggestion.compatKeys, null, 2);
|
|
6590
|
+
const ts = backupTimestamp();
|
|
6591
|
+
const backupPath = `${MODELS_JSON_PATH}.backup-cache-optimizer-${ts}`;
|
|
6592
|
+
const previewLines = [
|
|
6593
|
+
`📝 Preview of changes to ${getModelsJsonDisplayPath()}:`,
|
|
6594
|
+
``,
|
|
6595
|
+
`Location: ${plan.placementLabel}`,
|
|
6596
|
+
`Compat JSON to write:`,
|
|
6597
|
+
keysPreview,
|
|
6598
|
+
``,
|
|
6599
|
+
`⚠️ Risk notice:`,
|
|
6600
|
+
` 1. This creates a new entry in models.json. Existing auth (e.g. login API tokens) is not affected.`,
|
|
6601
|
+
` 2. A timestamped backup will be written to: ${backupPath}`,
|
|
6602
|
+
` 3. You must run /reload or restart Pi for the change to take effect.`,
|
|
6603
|
+
` 4. If the file contains comments or unusual formatting, please verify the result after write.`,
|
|
6604
|
+
];
|
|
6605
|
+
if (promptCacheRetention400Models.has(modelKey(model))) {
|
|
6606
|
+
previewLines.push(
|
|
6607
|
+
"",
|
|
6608
|
+
"💡 This fix overrides supportsLongCacheRetention to false because",
|
|
6609
|
+
"a 400 prompt_cache_retention error was observed for this model.",
|
|
6610
|
+
"After applying and reloading, Pi will no longer send the",
|
|
6611
|
+
"prompt_cache_retention parameter to this provider.",
|
|
6612
|
+
);
|
|
6613
|
+
}
|
|
6614
|
+
previewLines.push("", `Apply these changes?`);
|
|
6615
|
+
const confirmed = await cmdCtx.ui.confirm("Cache Optimizer — Fix (new entry)", previewLines.join("\n"));
|
|
6616
|
+
if (confirmed) {
|
|
6617
|
+
try {
|
|
6618
|
+
await copyFile(MODELS_JSON_PATH, backupPath);
|
|
6619
|
+
const tempPath = `${MODELS_JSON_PATH}.${process.pid}.${Date.now()}.fix.tmp`;
|
|
6620
|
+
await writeFile(tempPath, plan.modifiedText, "utf8");
|
|
6621
|
+
await rename(tempPath, MODELS_JSON_PATH);
|
|
6622
|
+
|
|
6623
|
+
const writtenText = await readFile(MODELS_JSON_PATH, "utf8");
|
|
6624
|
+
const postErr = selfCheckMissingEntryInsertion(
|
|
6625
|
+
originalText, writtenText,
|
|
6626
|
+
suggestion.providerLabel, suggestion.modelId, suggestion.compatKeys,
|
|
6627
|
+
);
|
|
6628
|
+
if (postErr !== null) {
|
|
6629
|
+
await copyFile(backupPath, MODELS_JSON_PATH);
|
|
6630
|
+
cmdCtx.ui.notify(
|
|
6631
|
+
`❌ Post-write self-check failed: ${postErr}\n` +
|
|
6632
|
+
`The backup at ${backupPath} has been restored. No changes applied.`,
|
|
6633
|
+
"error",
|
|
6634
|
+
);
|
|
6635
|
+
return;
|
|
6636
|
+
}
|
|
6637
|
+
cmdCtx.ui.notify(
|
|
6638
|
+
`✅ Fix applied to ${getModelsJsonDisplayPath()}.\n` +
|
|
6639
|
+
`Backup saved to: ${backupPath}\n` +
|
|
6640
|
+
`Run /reload or restart Pi for the change to take effect.`,
|
|
6641
|
+
"info",
|
|
6642
|
+
);
|
|
6643
|
+
} catch (e) {
|
|
6644
|
+
cmdCtx.ui.notify(
|
|
6645
|
+
`❌ Write failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
6646
|
+
"error",
|
|
6647
|
+
);
|
|
6648
|
+
}
|
|
6649
|
+
return;
|
|
6650
|
+
}
|
|
6651
|
+
cmdCtx.ui.notify("No changes were made. Canceled by user.", "info");
|
|
6652
|
+
return;
|
|
6653
|
+
}
|
|
6654
|
+
}
|
|
6655
|
+
|
|
6656
|
+
// Non-interactive or no diagnosis: show manual guidance.
|
|
6657
|
+
const snippet = diagnosis
|
|
6658
|
+
? formatMissingEntryManualSnippet(suggestion.providerLabel, suggestion.modelId, suggestion.compatKeys)
|
|
6659
|
+
: formatCompatKeysForInsertion(suggestion.compatKeys);
|
|
6660
|
+
const adviceLines: string[] = [];
|
|
6661
|
+
if (!diagnosis) {
|
|
6662
|
+
adviceLines.push(
|
|
6663
|
+
`❌ Could not locate model "${suggestion.modelId}" or provider "${suggestion.providerLabel}" in ${getModelsJsonDisplayPath()}.`,
|
|
6664
|
+
"",
|
|
6665
|
+
"Providers that were added via Pi /login API (e.g. opencode go) do not have",
|
|
6666
|
+
"entries in models.json. You can create a minimal compat-only entry by hand:",
|
|
6667
|
+
);
|
|
6668
|
+
} else if (diagnosis.scenario === "provider_missing") {
|
|
6669
|
+
adviceLines.push(
|
|
6670
|
+
`ℹ️ Provider "${suggestion.providerLabel}" does not exist in ${getModelsJsonDisplayPath()}.`,
|
|
6671
|
+
`This is common for API-logged-in providers (e.g. /login ...).`,
|
|
6672
|
+
"",
|
|
6673
|
+
"Add the following minimal block under the \"providers\" key (keep your",
|
|
6674
|
+
"existing authentication as-is):",
|
|
6675
|
+
);
|
|
6676
|
+
} else {
|
|
6677
|
+
adviceLines.push(
|
|
6678
|
+
`ℹ️ Model "${suggestion.modelId}" was not found in ${getModelsJsonDisplayPath()}`,
|
|
6679
|
+
`under providers["${suggestion.providerLabel}"].`,
|
|
6680
|
+
"",
|
|
6681
|
+
"Add the following entry to the models array (keep existing auth):",
|
|
6682
|
+
);
|
|
6683
|
+
}
|
|
6684
|
+
adviceLines.push("", snippet, "", "Then save and run /reload.");
|
|
6685
|
+
cmdCtx.ui.notify(adviceLines.join("\n"), "warning");
|
|
6164
6686
|
return;
|
|
6165
6687
|
}
|
|
6166
6688
|
|
|
@@ -6203,15 +6725,23 @@ export default function (pi: ExtensionAPI) {
|
|
|
6203
6725
|
`Placement: ${decision.placement} level — ${decision.reason}`,
|
|
6204
6726
|
`Compat JSON to write:`,
|
|
6205
6727
|
keysPreview,
|
|
6206
|
-
``,
|
|
6728
|
+
``,
|
|
6207
6729
|
`⚠️ Risk notice:`,
|
|
6208
6730
|
scopeRiskLine,
|
|
6209
6731
|
` 2. A timestamped backup will be written to: ${backupPath}`,
|
|
6210
6732
|
` 3. You must restart Pi / run /reload for the change to take effect.`,
|
|
6211
6733
|
` 4. If the file contains comments or unusual formatting, please verify the result after write.`,
|
|
6212
|
-
``,
|
|
6213
|
-
`Apply these changes?`,
|
|
6214
6734
|
];
|
|
6735
|
+
if (promptCacheRetention400Models.has(modelKey(model))) {
|
|
6736
|
+
previewLines.push(
|
|
6737
|
+
"",
|
|
6738
|
+
"💡 This fix overrides supportsLongCacheRetention to false because",
|
|
6739
|
+
"a 400 prompt_cache_retention error was observed for this model.",
|
|
6740
|
+
"After applying and reloading, Pi will no longer send the",
|
|
6741
|
+
"prompt_cache_retention parameter to this provider.",
|
|
6742
|
+
);
|
|
6743
|
+
}
|
|
6744
|
+
previewLines.push("", `Apply these changes?`);
|
|
6215
6745
|
|
|
6216
6746
|
const confirmed = await cmdCtx.ui.confirm("Cache Optimizer — Fix", previewLines.join("\n"));
|
|
6217
6747
|
if (!confirmed) {
|
|
@@ -6383,13 +6913,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
6383
6913
|
`Placement: ${menuDecision.placement} level — ${menuDecision.reason}`,
|
|
6384
6914
|
`Compat JSON to write:`,
|
|
6385
6915
|
keysPreview,
|
|
6386
|
-
``,
|
|
6916
|
+
``,
|
|
6387
6917
|
`⚠️ Risk notice:`,
|
|
6388
6918
|
menuScopeRiskLine,
|
|
6389
6919
|
` 2. A timestamped backup will be written to: ${backupPath}`,
|
|
6390
6920
|
` 3. You must restart Pi / run /reload for the change to take effect.`,
|
|
6391
6921
|
` 4. If the file contains comments, verify the result after write.`,
|
|
6392
|
-
``,
|
|
6922
|
+
``,
|
|
6393
6923
|
`Apply these changes?`,
|
|
6394
6924
|
];
|
|
6395
6925
|
|
package/package.json
CHANGED