pi-cache-optimizer 2.6.24 → 2.6.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -27,7 +27,7 @@ Pi extension for improving provider-side KV / prompt cache hit rates. It keeps s
27
27
 
28
28
  ## What it does
29
29
 
30
- - Reorders stable system-prompt content before dynamic context.
30
+ - Reorders uniquely identifiable stable system-prompt content before dynamic context. If the same candidate appears more than once (for example, quoted inside dynamic context), it is left unchanged to avoid removing the wrong occurrence.
31
31
  - Compresses Pi skill listings and strips session-overview churn.
32
32
  - Requests long cache retention when Pi/provider compat supports it.
33
33
  - Adds a session-id `prompt_cache_key` fallback for `openai-completions` / `openai-responses` payloads when no effective key exists, including Pi's built-in `llama.cpp` provider as Pi 0.82 core does.
@@ -82,7 +82,7 @@ On Pi 0.79.7 and newer, `pi update` updates Pi itself only. To update installed
82
82
 
83
83
  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.
84
84
 
85
- 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 `supportsLongCacheRetention: true` opt-in in `models.json`; otherwise strip it before sending.
85
+ 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. Effective values follow Pi's precedence: `modelOverrides[modelId].compat` first, then the matching `models[].compat`, then provider-level `compat`. An explicit `false` at a higher layer overrides `true` below it.
86
86
 
87
87
  For real proxies, start with session affinity:
88
88
 
@@ -205,6 +205,8 @@ Pi 0.80.9+ already includes Kimi K3 in built-in Kimi Coding, Moonshot AI / China
205
205
  5. Writes atomically (temp + rename); self-validates after write
206
206
  6. Falls back to manual guidance if JSONC scanner cannot confidently locate the target
207
207
 
208
+ Existing `modelOverrides[modelId]` entries have Pi's highest precedence, so `fix` repairs them directly. For built-in or API-login models without a custom `models[]` entry, `fix` creates a compat-only `modelOverrides` entry instead of inventing a custom model definition. Self-validation checks the effective three-layer compat result, so a lower-level edit shadowed by an override is rejected.
209
+
208
210
  **Non-interactive mode:** refuses to write; shows manual edit guidance instead.
209
211
 
210
212
  **Run:** `/cache-optimizer fix` when the active model has detected compat issues. The command shows "nothing to fix" when compat is already complete.
package/README.zh-CN.md CHANGED
@@ -27,7 +27,7 @@
27
27
 
28
28
  ## 功能
29
29
 
30
- - 将稳定的 system prompt 内容移动到动态上下文之前。
30
+ - 将能唯一定位的稳定 system prompt 内容移动到动态上下文之前。如果同一候选出现多次(例如动态上下文引用了它),则保持原样,避免删除错误的那一处。
31
31
  - 压缩 Pi skill 列表,并移除 session-overview 中的易变字段。
32
32
  - 在 Pi / provider compat 支持时请求长缓存保留。
33
33
  - 对 `openai-completions` / `openai-responses` 请求,在没有有效 key 时使用 Pi session id 补 `prompt_cache_key`;Pi 0.82 core 对内置 `llama.cpp` 也使用这一语义。
@@ -82,7 +82,7 @@ Pi 0.79.7 及之后,`pi update` 默认只更新 Pi 本体。若要更新已安
82
82
 
83
83
  LiteLLM / OneAPI / NewAPI / 类 OpenRouter 渠道等第三方 `openai-completions` 代理,常会把同一个 session 分散到多个上游后端,导致 provider 侧 prompt cache 被拆散。
84
84
 
85
- 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` 时保留,否则发送前移除。
85
+ 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 的优先级:先看 `modelOverrides[modelId].compat`,再看匹配的 `models[].compat`,最后看 provider 级 `compat`;高层显式 `false` 会覆盖低层的 `true`。
86
86
 
87
87
  对真正的代理,建议先启用 session affinity:
88
88
 
@@ -205,6 +205,8 @@ Pi 0.80.9+ 已在内置 Kimi Coding、Moonshot AI / 中国区、OpenRouter 和 V
205
205
  5. 原子写入(temp + rename);写入后自我验证
206
206
  6. 如果 JSONC 扫描器无法置信定位目标,回退到手动修改指引
207
207
 
208
+ 已有的 `modelOverrides[modelId]` 具有 Pi 的最高优先级,因此 `fix` 会直接修复该 entry。对于没有自定义 `models[]` entry 的内置模型或 API-login 模型,`fix` 会创建仅含 compat 的 `modelOverrides` entry,而不会凭空添加自定义模型定义。自检会验证三层合并后的有效 compat,因此被更高层 override 遮蔽的低层修改会被拒绝。
209
+
208
210
  **非交互模式:** 拒绝写入,显示手动编辑指引。
209
211
 
210
212
  **运行:** 当 active model 检测到 compat 问题时执行 `/cache-optimizer fix`。compat 已完整时,命令显示"无需修复"。
package/index.ts CHANGED
@@ -133,8 +133,8 @@ function getAnthropicTtlFallbackState(): AnthropicTtlFallbackStateV1 {
133
133
 
134
134
  let runtimeOptimizerEnabled = true;
135
135
 
136
- // WORM-flag: if optimizeSystemPrompt ever detects that its blind-replace
137
- // logic has accidentally truncated a structural marker (any XML tag or
136
+ // WORM-flag: if optimizeSystemPrompt ever detects that candidate extraction
137
+ // has accidentally truncated a structural marker (any XML tag or
138
138
  // HTML comment boundary marker present in the original prompt), we flip
139
139
  // this. publishStatus reads it once, appends a footer warning, then
140
140
  // resets it. The flag surface is kept separate from the regular
@@ -161,12 +161,10 @@ const SKILL_COMPRESSION_MIN_COUNT = 4;
161
161
 
162
162
  // Minimum trimmed length for a candidate to qualify as a stable-prefix "part".
163
163
  //
164
- // `optimizeSystemPrompt` removes each accepted candidate from the dynamic
165
- // remainder via `rest.replace(part, "")`. Short or character-class candidates
166
- // (think: `S`, `- u`, `- (`, `- }`) match the FIRST occurrence of those bytes
167
- // anywhere in `rest`, ripping unrelated text out of the prompt and yielding a
168
- // non-deterministic dynamic remainder per request. Both behaviors poison the
169
- // provider's prompt-prefix cache.
164
+ // `optimizeSystemPrompt` removes each uniquely occurring accepted candidate
165
+ // from the dynamic remainder. Short or character-class candidates (think: `S`,
166
+ // `- u`, `- (`, `- }`) are too likely to match unrelated prompt text, so they
167
+ // are never eligible for lifting.
170
168
  //
171
169
  // The threshold also caps the upstream string-vs-array regression we saw with
172
170
  // trellis 0.5.16 / 0.6.0-beta.17 (subagent tool registration passing
@@ -644,7 +642,7 @@ function stripSessionOverviewChurn(prompt: string): string {
644
642
  * Extract structural markers from a prompt for the integrity guard.
645
643
  *
646
644
  * The guard runs in `optimizeSystemPrompt` to catch cases where the
647
- * blind `rest.replace(part, "")` reorder accidentally eats text inside
645
+ * candidate extraction accidentally eats text inside
648
646
  * an extension-injected structural block (e.g., trellis
649
647
  * `<workflow-state>`, a hypothetical `<task-tracker>`, or AGENTS.md
650
648
  * `<!-- TRELLIS:START -->` markers). When the original prompt contains
@@ -704,16 +702,43 @@ function optimizeSystemPrompt(
704
702
  const seen = new Set<string>();
705
703
  let rest = original;
706
704
 
707
- // Stable layer: content likely to be identical across sessions/turns.
708
- // Short / single-char candidates are dropped: see MIN_STABLE_CANDIDATE_LENGTH.
705
+ // Classify candidate ambiguity against one immutable snapshot. Candidates
706
+ // can be nested (a full context-file block also contains its bare content),
707
+ // so recomputing occurrence counts after each removal is unsafe: deleting
708
+ // the full block can make the dynamic copy of its bare content appear unique.
709
+ const candidates: string[] = [];
709
710
  for (const candidate of buildStableCandidates(opts)) {
710
711
  const part = candidate.trim();
711
- if (!part || part.length < MIN_STABLE_CANDIDATE_LENGTH) continue;
712
- if (seen.has(part) || !rest.includes(part)) continue;
712
+ if (!part || part.length < MIN_STABLE_CANDIDATE_LENGTH || seen.has(part)) continue;
713
+ seen.add(part);
714
+ candidates.push(part);
715
+ }
716
+
717
+ const initialRemainder = rest;
718
+ const occurrenceCount = new Map<string, number>();
719
+ for (const part of candidates) {
720
+ let count = 0;
721
+ let searchFrom = 0;
722
+ while (searchFrom < initialRemainder.length) {
723
+ const occurrence = initialRemainder.indexOf(part, searchFrom);
724
+ if (occurrence < 0) break;
725
+ count++;
726
+ if (count > 1) break;
727
+ searchFrom = occurrence + 1;
728
+ }
729
+ occurrenceCount.set(part, count);
730
+ }
731
+
732
+ // Stable layer: content likely to be identical across sessions/turns.
733
+ // Short / single-char candidates are dropped: see MIN_STABLE_CANDIDATE_LENGTH.
734
+ for (const part of candidates) {
735
+ if (occurrenceCount.get(part) !== 1) continue;
736
+
737
+ const firstOccurrence = rest.indexOf(part);
738
+ if (firstOccurrence < 0) continue;
713
739
 
714
740
  stableParts.push(part);
715
- seen.add(part);
716
- rest = rest.replace(part, "");
741
+ rest = rest.slice(0, firstOccurrence) + rest.slice(firstOccurrence + part.length);
717
742
  }
718
743
 
719
744
  const stablePrefix = stableParts.join("\n\n");
@@ -731,8 +756,8 @@ function optimizeSystemPrompt(
731
756
 
732
757
  // Sanity check: scan ALL structural markers (XML tags + HTML comment
733
758
  // boundary markers) in the original and verify each one survives the
734
- // reorder. If any marker drops, the blind `rest.replace(part, "")`
735
- // logic ate something it shouldn't have — fall back to the original
759
+ // reorder. If any marker drops, candidate extraction ate something it
760
+ // shouldn't have — fall back to the original
736
761
  // prompt and flag the footer warning. This is provider-agnostic and
737
762
  // extension-agnostic: trellis `<workflow-state>`, a hypothetical
738
763
  // `<task-tracker>`, AGENTS.md `<!-- TRELLIS:START -->`, etc., are all
@@ -5040,6 +5065,63 @@ function parseJsonc(text: string): unknown {
5040
5065
  return JSON.parse(stripJsoncTrailingCommas(stripJsoncComments(text)));
5041
5066
  }
5042
5067
 
5068
+ type ExplicitCompatSource = "modelOverride" | "model" | "provider";
5069
+
5070
+ interface ExplicitCompatValue {
5071
+ source: ExplicitCompatSource;
5072
+ value: unknown;
5073
+ }
5074
+
5075
+ /**
5076
+ * Resolve one explicitly configured compat value using Pi's models.json
5077
+ * precedence. Built-in model overrides win over custom model definitions,
5078
+ * which in turn win over provider defaults.
5079
+ */
5080
+ function resolveExplicitCompatValue(
5081
+ config: unknown,
5082
+ providerLabel: string,
5083
+ modelId: string,
5084
+ compatKey: string,
5085
+ ): ExplicitCompatValue | undefined {
5086
+ const providers = asRecord(asRecord(config)?.providers);
5087
+ const provider = asRecord(providers?.[providerLabel]);
5088
+ if (!provider) return undefined;
5089
+
5090
+ const override = asRecord(asRecord(provider.modelOverrides)?.[modelId]);
5091
+ const overrideCompat = asRecord(override?.compat);
5092
+ if (overrideCompat && Object.prototype.hasOwnProperty.call(overrideCompat, compatKey)) {
5093
+ return { source: "modelOverride", value: overrideCompat[compatKey] };
5094
+ }
5095
+
5096
+ if (Array.isArray(provider.models)) {
5097
+ const model = provider.models.find((entry: unknown) => asRecord(entry)?.id === modelId);
5098
+ const modelCompat = asRecord(asRecord(model)?.compat);
5099
+ if (modelCompat && Object.prototype.hasOwnProperty.call(modelCompat, compatKey)) {
5100
+ return { source: "model", value: modelCompat[compatKey] };
5101
+ }
5102
+ }
5103
+
5104
+ const providerCompat = asRecord(provider.compat);
5105
+ if (providerCompat && Object.prototype.hasOwnProperty.call(providerCompat, compatKey)) {
5106
+ return { source: "provider", value: providerCompat[compatKey] };
5107
+ }
5108
+
5109
+ return undefined;
5110
+ }
5111
+
5112
+ function hasExplicitLongRetentionOptInFromConfig(
5113
+ config: unknown,
5114
+ providerLabel: string,
5115
+ modelId: string,
5116
+ ): boolean {
5117
+ return resolveExplicitCompatValue(
5118
+ config,
5119
+ providerLabel,
5120
+ modelId,
5121
+ "supportsLongCacheRetention",
5122
+ )?.value === true;
5123
+ }
5124
+
5043
5125
  /**
5044
5126
  * JSONC scanner: locate the provider block and model entry in models.json text.
5045
5127
  * Returns the byte offsets for surgical insertion, or undefined if ambiguous.
@@ -5065,10 +5147,99 @@ interface ModelNodeLocation {
5065
5147
  providerCompatBrace: number;
5066
5148
  /** Offset of the provider-level compat object's closing `}`, or -1 if absent */
5067
5149
  providerCompatEnd: number;
5150
+ /** Offset of the target modelOverrides entry's opening `{`, or -1 if absent */
5151
+ modelOverrideObjectBrace: number;
5152
+ /** Offset of the target modelOverrides entry's closing `}`, or -1 if absent */
5153
+ modelOverrideObjectEnd: number;
5154
+ /** Offset of the target override compat object's opening `{`, or -1 if absent */
5155
+ modelOverrideCompatBrace: number;
5156
+ /** Offset of the target override compat object's closing `}`, or -1 if absent */
5157
+ modelOverrideCompatEnd: number;
5068
5158
  /** All model ids found in this provider's models array (for placement safety analysis) */
5069
5159
  allModelIds: string[];
5070
5160
  }
5071
5161
 
5162
+ interface ModelOverrideNodeLocation {
5163
+ providerObjectBrace: number;
5164
+ providerObjectEnd: number;
5165
+ modelOverridesObjectBrace: number;
5166
+ modelOverridesObjectEnd: number;
5167
+ modelOverrideObjectBrace: number;
5168
+ modelOverrideObjectEnd: number;
5169
+ modelOverrideCompatBrace: number;
5170
+ modelOverrideCompatEnd: number;
5171
+ }
5172
+
5173
+ /** Locate a provider and optional modelOverrides entry without requiring models[]. */
5174
+ function locateModelOverrideInJsonc(
5175
+ text: string,
5176
+ providerLabel: string,
5177
+ modelId: string,
5178
+ ): ModelOverrideNodeLocation | undefined {
5179
+ const clean = stripJsoncComments(text);
5180
+ const rootBrace = skipJsonWhitespace(clean, 0);
5181
+ if (clean[rootBrace] !== "{") return undefined;
5182
+ const providersKey = findJsonObjectKey(clean, rootBrace, "providers");
5183
+ if (!providersKey) return undefined;
5184
+ const providersBrace = skipJsonWhitespace(clean, providersKey.valueStart);
5185
+ if (clean[providersBrace] !== "{") return undefined;
5186
+ const providersEnd = findMatchingBracket(clean, providersBrace);
5187
+ if (providersEnd === undefined) return undefined;
5188
+ const providerKey = findJsonObjectKey(clean, providersBrace, providerLabel);
5189
+ if (!providerKey || providerKey.keyStart > providersEnd) return undefined;
5190
+ const providerObjectBrace = skipJsonWhitespace(clean, providerKey.valueStart);
5191
+ if (clean[providerObjectBrace] !== "{") return undefined;
5192
+ const providerObjectEnd = findMatchingBracket(clean, providerObjectBrace);
5193
+ if (providerObjectEnd === undefined || providerObjectEnd > providersEnd) return undefined;
5194
+
5195
+ let modelOverridesObjectBrace = -1;
5196
+ let modelOverridesObjectEnd = -1;
5197
+ let modelOverrideObjectBrace = -1;
5198
+ let modelOverrideObjectEnd = -1;
5199
+ let modelOverrideCompatBrace = -1;
5200
+ let modelOverrideCompatEnd = -1;
5201
+ const overridesKey = findJsonObjectKey(clean, providerObjectBrace, "modelOverrides");
5202
+ if (overridesKey && overridesKey.keyStart < providerObjectEnd) {
5203
+ const brace = skipJsonWhitespace(clean, overridesKey.valueStart);
5204
+ if (clean[brace] !== "{") return undefined;
5205
+ const end = findMatchingBracket(clean, brace);
5206
+ if (end === undefined || end > providerObjectEnd) return undefined;
5207
+ modelOverridesObjectBrace = brace;
5208
+ modelOverridesObjectEnd = end;
5209
+
5210
+ const overrideKey = findJsonObjectKey(clean, brace, modelId);
5211
+ if (overrideKey && overrideKey.keyStart < end) {
5212
+ const entryBrace = skipJsonWhitespace(clean, overrideKey.valueStart);
5213
+ if (clean[entryBrace] !== "{") return undefined;
5214
+ const entryEnd = findMatchingBracket(clean, entryBrace);
5215
+ if (entryEnd === undefined || entryEnd > end) return undefined;
5216
+ modelOverrideObjectBrace = entryBrace;
5217
+ modelOverrideObjectEnd = entryEnd;
5218
+
5219
+ const compatKey = findJsonObjectKey(clean, entryBrace, "compat");
5220
+ if (compatKey && compatKey.keyStart < entryEnd) {
5221
+ const compatBrace = skipJsonWhitespace(clean, compatKey.valueStart);
5222
+ if (clean[compatBrace] !== "{") return undefined;
5223
+ const compatEnd = findMatchingBracket(clean, compatBrace);
5224
+ if (compatEnd === undefined || compatEnd > entryEnd) return undefined;
5225
+ modelOverrideCompatBrace = compatBrace;
5226
+ modelOverrideCompatEnd = compatEnd;
5227
+ }
5228
+ }
5229
+ }
5230
+
5231
+ return {
5232
+ providerObjectBrace,
5233
+ providerObjectEnd,
5234
+ modelOverridesObjectBrace,
5235
+ modelOverridesObjectEnd,
5236
+ modelOverrideObjectBrace,
5237
+ modelOverrideObjectEnd,
5238
+ modelOverrideCompatBrace,
5239
+ modelOverrideCompatEnd,
5240
+ };
5241
+ }
5242
+
5072
5243
  /**
5073
5244
  * Locate the provider + model entry in raw JSONC text.
5074
5245
  * Returns the positions needed for surgical insertion, or undefined on failure.
@@ -5121,6 +5292,12 @@ function locateModelInJsonc(
5121
5292
  }
5122
5293
  }
5123
5294
 
5295
+ const overrideLocation = locateModelOverrideInJsonc(text, providerLabel, modelId);
5296
+ const modelOverrideObjectBrace = overrideLocation?.modelOverrideObjectBrace ?? -1;
5297
+ const modelOverrideObjectEnd = overrideLocation?.modelOverrideObjectEnd ?? -1;
5298
+ const modelOverrideCompatBrace = overrideLocation?.modelOverrideCompatBrace ?? -1;
5299
+ const modelOverrideCompatEnd = overrideLocation?.modelOverrideCompatEnd ?? -1;
5300
+
5124
5301
  const modelsKey = findJsonObjectKey(clean, providerBrace, "models");
5125
5302
  if (!modelsKey || modelsKey.keyStart > providerEndBrace) return undefined;
5126
5303
 
@@ -5208,6 +5385,10 @@ function locateModelInJsonc(
5208
5385
  providerObjectEnd: providerEndBrace,
5209
5386
  providerCompatBrace,
5210
5387
  providerCompatEnd,
5388
+ modelOverrideObjectBrace,
5389
+ modelOverrideObjectEnd,
5390
+ modelOverrideCompatBrace,
5391
+ modelOverrideCompatEnd,
5211
5392
  allModelIds,
5212
5393
  };
5213
5394
  }
@@ -5217,7 +5398,7 @@ function locateModelInJsonc(
5217
5398
  * `locateModelInJsonc` cannot find the target provider/model.
5218
5399
  */
5219
5400
  type MissingEntryDiagnosis =
5220
- | { scenario: "provider_missing"; providersEnd: number }
5401
+ | { scenario: "provider_missing"; providersBrace: number; providersEnd: number }
5221
5402
  | { scenario: "model_missing"; modelsEnd: number; providerBrace: number; providerEndBrace: number }
5222
5403
  | { scenario: "provider_without_models"; providerBrace: number; providerEndBrace: number };
5223
5404
 
@@ -5248,7 +5429,7 @@ function analyzeModelsJsonForMissingEntry(
5248
5429
 
5249
5430
  const providerKey = findJsonObjectKey(clean, providersBrace, providerLabel);
5250
5431
  if (!providerKey || providerKey.keyStart > providersEnd) {
5251
- return { scenario: "provider_missing", providersEnd };
5432
+ return { scenario: "provider_missing", providersBrace, providersEnd };
5252
5433
  }
5253
5434
 
5254
5435
  // Provider exists. Check for a models array so we know where to append.
@@ -5286,20 +5467,126 @@ function formatMissingEntryManualSnippet(
5286
5467
  ): string {
5287
5468
  const lines: string[] = [];
5288
5469
  const sorted = Object.entries(compatKeys).sort(([a], [b]) => a.localeCompare(b));
5289
- const compatItems = sorted.map(([k, v]) => ` ${JSON.stringify(k)}: ${JSON.stringify(v)}`);
5470
+ const compatItems = sorted.map(([k, v]) => ` ${JSON.stringify(k)}: ${JSON.stringify(v)}`);
5290
5471
  lines.push(`"${providerLabel}": {`);
5291
- lines.push(` "models": [`);
5292
- lines.push(` {`);
5293
- lines.push(` "id": ${JSON.stringify(modelId)},`);
5472
+ lines.push(` "modelOverrides": {`);
5473
+ lines.push(` ${JSON.stringify(modelId)}: {`);
5294
5474
  lines.push(` "compat": {`);
5295
5475
  lines.push(compatItems.join(",\n"));
5296
5476
  lines.push(` }`);
5297
5477
  lines.push(` }`);
5298
- lines.push(` ]`);
5478
+ lines.push(` }`);
5299
5479
  lines.push(` }`);
5300
5480
  return lines.join("\n");
5301
5481
  }
5302
5482
 
5483
+ /**
5484
+ * Insert or repair a modelOverrides entry without creating a custom models[]
5485
+ * definition. This is the safest representation for built-in/API-login models
5486
+ * and has the same highest precedence Pi applies at runtime.
5487
+ */
5488
+ function composeModelOverrideInsertion(
5489
+ originalText: string,
5490
+ providerLabel: string,
5491
+ modelId: string,
5492
+ compatKeys: Record<string, unknown>,
5493
+ ): { modifiedText: string; placementLabel: string } | undefined {
5494
+ const location = locateModelOverrideInJsonc(originalText, providerLabel, modelId);
5495
+
5496
+ if (location?.modelOverrideObjectBrace !== undefined && location.modelOverrideObjectBrace >= 0) {
5497
+ const modelLocation: ModelNodeLocation = {
5498
+ modelObjectBrace: -1,
5499
+ modelObjectEnd: -1,
5500
+ compatKeyStart: -1,
5501
+ compatObjectBrace: -1,
5502
+ compatObjectEnd: -1,
5503
+ indent: "",
5504
+ providerObjectBrace: location.providerObjectBrace,
5505
+ providerObjectEnd: location.providerObjectEnd,
5506
+ providerCompatBrace: -1,
5507
+ providerCompatEnd: -1,
5508
+ modelOverrideObjectBrace: location.modelOverrideObjectBrace,
5509
+ modelOverrideObjectEnd: location.modelOverrideObjectEnd,
5510
+ modelOverrideCompatBrace: location.modelOverrideCompatBrace,
5511
+ modelOverrideCompatEnd: location.modelOverrideCompatEnd,
5512
+ allModelIds: [],
5513
+ };
5514
+ return {
5515
+ modifiedText: composeFixInsertion(originalText, modelLocation, compatKeys, "modelOverride"),
5516
+ placementLabel: `providers["${providerLabel}"] -> modelOverrides["${modelId}"] -> compat`,
5517
+ };
5518
+ }
5519
+
5520
+ const sortedEntries = Object.entries(compatKeys).sort(([a], [b]) => a.localeCompare(b));
5521
+ const formatOverrideEntry = (keyIndent: string, unit: string): string => {
5522
+ const propertyIndent = keyIndent + unit;
5523
+ const compatIndent = propertyIndent + unit;
5524
+ const compatLines = sortedEntries
5525
+ .map(([key, value]) => `${compatIndent}${JSON.stringify(key)}: ${JSON.stringify(value)}`)
5526
+ .join(",\n");
5527
+ return `${keyIndent}${JSON.stringify(modelId)}: {\n` +
5528
+ `${propertyIndent}"compat": {\n${compatLines}\n${propertyIndent}}\n${keyIndent}}`;
5529
+ };
5530
+ const previousTokenNeedsComma = (clean: string, closeBrace: number): boolean => {
5531
+ let pos = closeBrace - 1;
5532
+ while (pos >= 0 && isJsonWhitespace(clean[pos])) pos--;
5533
+ return clean[pos] !== "{" && clean[pos] !== ",";
5534
+ };
5535
+
5536
+ if (location) {
5537
+ const clean = stripJsoncComments(originalText);
5538
+ if (location.modelOverridesObjectBrace >= 0) {
5539
+ const containerIndent = lineIndentOf(originalText, location.modelOverridesObjectBrace);
5540
+ const keyIndent = deriveInnerIndent(
5541
+ originalText,
5542
+ location.modelOverridesObjectBrace,
5543
+ location.modelOverridesObjectEnd,
5544
+ );
5545
+ const unit = keyIndent.length > containerIndent.length
5546
+ ? keyIndent.slice(containerIndent.length)
5547
+ : " ";
5548
+ const comma = previousTokenNeedsComma(clean, location.modelOverridesObjectEnd) ? "," : "";
5549
+ const insertion = `${comma}\n${formatOverrideEntry(keyIndent, unit)}\n${containerIndent}`;
5550
+ return {
5551
+ modifiedText: originalText.slice(0, location.modelOverridesObjectEnd) + insertion + originalText.slice(location.modelOverridesObjectEnd),
5552
+ placementLabel: `providers["${providerLabel}"] -> modelOverrides -> (new entry "${modelId}")`,
5553
+ };
5554
+ }
5555
+
5556
+ const providerIndent = lineIndentOf(originalText, location.providerObjectBrace);
5557
+ const propertyIndent = deriveInnerIndent(originalText, location.providerObjectBrace, location.providerObjectEnd);
5558
+ const unit = propertyIndent.length > providerIndent.length
5559
+ ? propertyIndent.slice(providerIndent.length)
5560
+ : " ";
5561
+ const entryIndent = propertyIndent + unit;
5562
+ const block = `\n${propertyIndent}"modelOverrides": {\n` +
5563
+ `${formatOverrideEntry(entryIndent, unit)}\n${propertyIndent}},`;
5564
+ return {
5565
+ modifiedText: originalText.slice(0, location.providerObjectBrace + 1) + block + originalText.slice(location.providerObjectBrace + 1),
5566
+ placementLabel: `providers["${providerLabel}"] -> (new modelOverrides entry "${modelId}")`,
5567
+ };
5568
+ }
5569
+
5570
+ const diagnosis = analyzeModelsJsonForMissingEntry(originalText, providerLabel, modelId);
5571
+ if (!diagnosis || diagnosis.scenario !== "provider_missing") return undefined;
5572
+ const clean = stripJsoncComments(originalText);
5573
+ const providersIndent = lineIndentOf(originalText, diagnosis.providersEnd);
5574
+ const providerIndent = deriveInnerIndent(originalText, diagnosis.providersBrace, diagnosis.providersEnd);
5575
+ const unit = providerIndent.length > providersIndent.length
5576
+ ? providerIndent.slice(providersIndent.length)
5577
+ : " ";
5578
+ const overridesIndent = providerIndent + unit;
5579
+ const entryIndent = overridesIndent + unit;
5580
+ const comma = previousTokenNeedsComma(clean, diagnosis.providersEnd) ? "," : "";
5581
+ const block = `${comma}\n${providerIndent}${JSON.stringify(providerLabel)}: {\n` +
5582
+ `${overridesIndent}"modelOverrides": {\n${formatOverrideEntry(entryIndent, unit)}\n` +
5583
+ `${overridesIndent}}\n${providerIndent}}\n${providersIndent}`;
5584
+ return {
5585
+ modifiedText: originalText.slice(0, diagnosis.providersEnd) + block + originalText.slice(diagnosis.providersEnd),
5586
+ placementLabel: `providers -> (new modelOverrides-only entry "${providerLabel}/${modelId}")`,
5587
+ };
5588
+ }
5589
+
5303
5590
  /**
5304
5591
  * Surgically insert the missing provider/model entry into the original
5305
5592
  * JSONC text. Returns the modified text and placement descriptor.
@@ -5458,10 +5745,10 @@ function composeMissingEntryInsertion(
5458
5745
  }
5459
5746
 
5460
5747
  /**
5461
- * Lightweight self-check for a newly inserted entry.
5748
+ * Lightweight self-check for a newly inserted model or modelOverrides entry.
5462
5749
  * Parses the modified text as JSONC and confirms:
5463
- * 1. The target model exists under the provider.
5464
- * 2. Every compat key has the expected value (merged provider+model).
5750
+ * 1. The target exists under models[] or modelOverrides.
5751
+ * 2. Every compat key has the expected effective three-layer value.
5465
5752
  * Returns null on success, an error string on failure.
5466
5753
  */
5467
5754
  function selfCheckMissingEntryInsertion(
@@ -5472,34 +5759,88 @@ function selfCheckMissingEntryInsertion(
5472
5759
  compatKeys: Record<string, unknown>,
5473
5760
  ): string | null {
5474
5761
  try {
5762
+ const origParsed = parseJsonc(originalText);
5475
5763
  const modParsed = parseJsonc(modifiedText);
5476
5764
  const providers = asRecord(asRecord(modParsed)?.providers);
5477
5765
  if (!providers) return "Modified file: providers object missing or invalid";
5478
5766
  const provider = asRecord(providers[providerLabel]);
5479
5767
  if (!provider) return `Modified file: provider "${providerLabel}" not found`;
5480
5768
  const models = provider.models;
5481
- if (!Array.isArray(models)) return `Modified file: provider "${providerLabel}".models is not an array`;
5482
- const targetModel = models.find((m: unknown) => asRecord(m)?.id === modelId);
5483
- if (!targetModel || typeof targetModel !== "object")
5484
- return `Modified file: model "${modelId}" not found in provider after insertion`;
5769
+ const targetModel = Array.isArray(models)
5770
+ ? models.find((m: unknown) => asRecord(m)?.id === modelId)
5771
+ : undefined;
5772
+ const targetOverride = asRecord(asRecord(provider.modelOverrides)?.[modelId]);
5773
+ if (!targetModel && !targetOverride) {
5774
+ return `Modified file: model or modelOverrides entry "${modelId}" not found in provider after insertion`;
5775
+ }
5485
5776
 
5486
- // Validate effective merged compat
5487
- const provCompatRaw = (provider as Record<string, unknown>).compat;
5488
- const provCompat = (provCompatRaw && typeof provCompatRaw === "object" && !Array.isArray(provCompatRaw))
5489
- ? provCompatRaw as Record<string, unknown>
5490
- : {};
5491
- const mdlCompatRaw = (targetModel as Record<string, unknown>).compat;
5492
- const mdlCompat = (mdlCompatRaw && typeof mdlCompatRaw === "object" && !Array.isArray(mdlCompatRaw))
5493
- ? mdlCompatRaw as Record<string, unknown>
5494
- : {};
5495
- const merged = { ...provCompat, ...mdlCompat };
5496
5777
  for (const [k, v] of Object.entries(compatKeys)) {
5497
- if (!(k in merged)) return `Modified file: effective compat.${k} not found`;
5498
- if (merged[k] !== v) return `Modified file: effective compat.${k} wrong value`;
5778
+ const effective = resolveExplicitCompatValue(modParsed, providerLabel, modelId, k);
5779
+ if (!effective) return `Modified file: effective compat.${k} not found`;
5780
+ if (effective.value !== v) {
5781
+ return `Modified file: effective compat.${k} wrong value: expected ${JSON.stringify(v)}, got ${JSON.stringify(effective.value)} from ${effective.source}`;
5782
+ }
5783
+ }
5784
+
5785
+ // Normalize only the intended override edit back to its original shape,
5786
+ // then require the complete parsed structure to match. This catches data
5787
+ // loss while allowing legitimate shorter repairs such as false -> true.
5788
+ const normalized = JSON.parse(JSON.stringify(modParsed)) as Record<string, unknown>;
5789
+ const origProviders = asRecord(asRecord(origParsed)?.providers);
5790
+ const origProvider = asRecord(origProviders?.[providerLabel]);
5791
+ const normalizedProviders = asRecord(normalized.providers);
5792
+ if (!normalizedProviders) return "Modified file: normalized providers object missing";
5793
+
5794
+ if (!origProvider) {
5795
+ delete normalizedProviders[providerLabel];
5796
+ } else if (targetOverride) {
5797
+ const normalizedProvider = asRecord(normalizedProviders[providerLabel]);
5798
+ const normalizedOverrides = asRecord(normalizedProvider?.modelOverrides);
5799
+ const origOverrides = asRecord(origProvider.modelOverrides);
5800
+ const origOverride = asRecord(origOverrides?.[modelId]);
5801
+
5802
+ if (!normalizedProvider || !normalizedOverrides) {
5803
+ return `Modified file: modelOverrides["${modelId}"] missing during structure validation`;
5804
+ }
5805
+ if (!origOverride) {
5806
+ delete normalizedOverrides[modelId];
5807
+ if (!origOverrides) delete normalizedProvider.modelOverrides;
5808
+ } else {
5809
+ const normalizedOverride = asRecord(normalizedOverrides[modelId]);
5810
+ if (!normalizedOverride) return `Modified file: modelOverrides["${modelId}"] invalid`;
5811
+ const origCompat = asRecord(origOverride.compat);
5812
+ const normalizedCompat = asRecord(normalizedOverride.compat);
5813
+ if (!origCompat) {
5814
+ delete normalizedOverride.compat;
5815
+ } else {
5816
+ if (!normalizedCompat) return `Modified file: modelOverrides["${modelId}"].compat invalid`;
5817
+ for (const key of Object.keys(compatKeys)) {
5818
+ if (Object.prototype.hasOwnProperty.call(origCompat, key)) {
5819
+ normalizedCompat[key] = origCompat[key];
5820
+ } else {
5821
+ delete normalizedCompat[key];
5822
+ }
5823
+ }
5824
+ }
5825
+ }
5826
+ } else {
5827
+ // Backward-compatible validation for the legacy models[] insertion
5828
+ // helper retained in __internals_for_tests.
5829
+ const normalizedProvider = asRecord(normalizedProviders[providerLabel]);
5830
+ const normalizedModels = normalizedProvider?.models;
5831
+ const origModels = origProvider.models;
5832
+ if (!normalizedProvider || !Array.isArray(normalizedModels)) {
5833
+ return `Modified file: provider "${providerLabel}".models missing during structure validation`;
5834
+ }
5835
+ normalizedProvider.models = normalizedModels.filter(
5836
+ (entry: unknown) => asRecord(entry)?.id !== modelId,
5837
+ );
5838
+ if (!Array.isArray(origModels)) delete normalizedProvider.models;
5499
5839
  }
5500
5840
 
5501
- if (modifiedText.length < originalText.length)
5502
- return "Modified file: content is shorter than original (possible truncation)";
5841
+ if (!deepEqualIgnoringKeys(normalized, origParsed, [])) {
5842
+ return "Modified file: original structure was altered (data loss detected)";
5843
+ }
5503
5844
 
5504
5845
  const modClean = stripJsoncComments(modifiedText);
5505
5846
  const rootStart = skipJsonWhitespace(modClean, 0);
@@ -5655,7 +5996,14 @@ function chooseFixPlacement(
5655
5996
  compatKeys: Record<string, unknown>,
5656
5997
  providerLabel: string,
5657
5998
  forceModelLevel = false,
5658
- ): { placement: "provider" | "model"; reason: string } {
5999
+ ): { placement: "provider" | "model" | "modelOverride"; reason: string } {
6000
+ if (location.modelOverrideObjectBrace >= 0) {
6001
+ return {
6002
+ placement: "modelOverride",
6003
+ reason: "an existing modelOverrides entry has Pi's highest precedence — repairing it directly",
6004
+ };
6005
+ }
6006
+
5659
6007
  if (forceModelLevel) {
5660
6008
  return {
5661
6009
  placement: "model",
@@ -5689,12 +6037,24 @@ function composeFixInsertion(
5689
6037
  original: string,
5690
6038
  location: ModelNodeLocation,
5691
6039
  compatKeys: Record<string, unknown>,
5692
- placement: "provider" | "model" = "model",
6040
+ placement: "provider" | "model" | "modelOverride" = "model",
5693
6041
  ): string {
5694
6042
  // Resolve the target compat object and its container based on placement.
5695
- const targetCompatBrace = placement === "provider" ? location.providerCompatBrace : location.compatObjectBrace;
5696
- const targetCompatEnd = placement === "provider" ? location.providerCompatEnd : location.compatObjectEnd;
5697
- const containerBrace = placement === "provider" ? location.providerObjectBrace : location.modelObjectBrace;
6043
+ const targetCompatBrace = placement === "provider"
6044
+ ? location.providerCompatBrace
6045
+ : placement === "modelOverride"
6046
+ ? location.modelOverrideCompatBrace
6047
+ : location.compatObjectBrace;
6048
+ const targetCompatEnd = placement === "provider"
6049
+ ? location.providerCompatEnd
6050
+ : placement === "modelOverride"
6051
+ ? location.modelOverrideCompatEnd
6052
+ : location.compatObjectEnd;
6053
+ const containerBrace = placement === "provider"
6054
+ ? location.providerObjectBrace
6055
+ : placement === "modelOverride"
6056
+ ? location.modelOverrideObjectBrace
6057
+ : location.modelObjectBrace;
5698
6058
 
5699
6059
  // Helper: format key/value pairs as lines with the given indent,
5700
6060
  // alphabetically sorted for stable previews and deterministic edits.
@@ -5796,7 +6156,7 @@ function selfCheckFix(
5796
6156
  providerLabel: string,
5797
6157
  modelId: string,
5798
6158
  compatKeys: Record<string, unknown>,
5799
- placement: "provider" | "model" = "model",
6159
+ placement: "provider" | "model" | "modelOverride" = "model",
5800
6160
  ): string | null {
5801
6161
  try {
5802
6162
  // Step 1: Parse both versions as JSONC (comments + trailing commas allowed).
@@ -5840,9 +6200,9 @@ function selfCheckFix(
5840
6200
  return `Original file: provider/model "${providerLabel}/${modelId}" not found`;
5841
6201
  }
5842
6202
 
5843
- // Step 5: Compute the EFFECTIVE merged compat (provider-level + model-level),
5844
- // mirroring Pi's mergeCompat behavior (model wins on conflicts). The fix may
5845
- // have written either level, so validation must check the merged result.
6203
+ // Step 5: Compute the EFFECTIVE merged compat using Pi's precedence:
6204
+ // provider, custom model, then modelOverrides. The fix may have written any
6205
+ // level, so validation must check what Pi will actually use.
5846
6206
  const provCompatRaw = (provider as Record<string, unknown>).compat;
5847
6207
  const provCompat = (provCompatRaw && typeof provCompatRaw === 'object' && !Array.isArray(provCompatRaw))
5848
6208
  ? provCompatRaw as Record<string, unknown>
@@ -5852,7 +6212,13 @@ function selfCheckFix(
5852
6212
  return `Modified file: model "${modelId}" compat is not an object`;
5853
6213
  }
5854
6214
  const mdlCompat = (modelCompatRaw ?? {}) as Record<string, unknown>;
5855
- const mergedCompat: Record<string, unknown> = { ...provCompat, ...mdlCompat };
6215
+ const override = asRecord(asRecord(provider.modelOverrides)?.[modelId]);
6216
+ const overrideCompatRaw = override?.compat;
6217
+ if (overrideCompatRaw !== undefined && !asRecord(overrideCompatRaw)) {
6218
+ return `Modified file: modelOverrides["${modelId}"].compat is not an object`;
6219
+ }
6220
+ const overrideCompat = asRecord(overrideCompatRaw) ?? {};
6221
+ const mergedCompat: Record<string, unknown> = { ...provCompat, ...mdlCompat, ...overrideCompat };
5856
6222
 
5857
6223
  // Step 6: Validate all inserted keys are effective in the merged compat
5858
6224
  for (const [k, v] of Object.entries(compatKeys)) {
@@ -5900,7 +6266,8 @@ function selfCheckFix(
5900
6266
  // the placement-resolved object's own compat may be exempt.
5901
6267
  const mayRepairThisCompat =
5902
6268
  (placement === "provider" && origObj === origProvider) ||
5903
- (placement === "model" && origObj === origTargetModelRecord);
6269
+ (placement === "model" && origObj === origTargetModelRecord) ||
6270
+ (placement === "modelOverride" && origObj === asRecord(asRecord(origProvider.modelOverrides)?.[modelId]));
5904
6271
  for (const ck of Object.keys(origCompat)) {
5905
6272
  if (!(ck in modCompat)) return false;
5906
6273
  // The fix may repair an existing wrong compat value (for example
@@ -6222,11 +6589,15 @@ export const __internals_for_tests = {
6222
6589
  stripJsoncComments,
6223
6590
  stripJsoncTrailingCommas,
6224
6591
  parseJsonc,
6592
+ resolveExplicitCompatValue,
6593
+ hasExplicitLongRetentionOptInFromConfig,
6225
6594
  locateModelInJsonc,
6595
+ locateModelOverrideInJsonc,
6226
6596
  composeFixInsertion,
6227
6597
  selfCheckFix,
6228
6598
  analyzeModelsJsonForMissingEntry,
6229
6599
  composeMissingEntryInsertion,
6600
+ composeModelOverrideInsertion,
6230
6601
  selfCheckMissingEntryInsertion,
6231
6602
  decideFixPlacement,
6232
6603
  chooseFixPlacement,
@@ -6760,9 +7131,8 @@ export default function (pi: ExtensionAPI) {
6760
7131
 
6761
7132
  /**
6762
7133
  * Check whether a model has an EXPLICIT supportsLongCacheRetention: true
6763
- * opt-in in models.json (either at provider-level or model-level).
6764
- * Model-level compat takes precedence over provider-level (mirrors Pi's
6765
- * mergeCompat behaviour: model wins on conflicts).
7134
+ * opt-in in models.json. Precedence mirrors Pi's effective model config:
7135
+ * modelOverrides[model.id].compat, then models[].compat, then provider.compat.
6766
7136
  *
6767
7137
  * Returns true ONLY when the user explicitly opted in. Returns false for:
6768
7138
  * - Explicit false (opt-out)
@@ -6776,31 +7146,7 @@ export default function (pi: ExtensionAPI) {
6776
7146
  try {
6777
7147
  const text = readFileSync(MODELS_JSON_PATH, "utf8");
6778
7148
  const parsed = parseJsonc(text);
6779
- const providers = asRecord(asRecord(parsed)?.providers);
6780
- if (!providers) return false;
6781
-
6782
- const prov = asRecord(providers[model.provider]);
6783
- if (!prov) return false;
6784
-
6785
- // Check model-level first (higher priority in Pi's merge logic)
6786
- const models = prov.models;
6787
- if (Array.isArray(models)) {
6788
- const modelEntry = models.find(m => asRecord(m)?.id === model.id);
6789
- if (modelEntry) {
6790
- const modelCompat = asRecord(asRecord(modelEntry)?.compat);
6791
- if (modelCompat?.supportsLongCacheRetention !== undefined) {
6792
- return modelCompat.supportsLongCacheRetention === true;
6793
- }
6794
- }
6795
- }
6796
-
6797
- // Check provider-level
6798
- const provCompat = asRecord(prov.compat);
6799
- if (provCompat?.supportsLongCacheRetention !== undefined) {
6800
- return provCompat.supportsLongCacheRetention === true;
6801
- }
6802
-
6803
- return false;
7149
+ return hasExplicitLongRetentionOptInFromConfig(parsed, model.provider, model.id);
6804
7150
  } catch {
6805
7151
  return false;
6806
7152
  }
@@ -7318,8 +7664,8 @@ export default function (pi: ExtensionAPI) {
7318
7664
  }
7319
7665
  manualLines.push(
7320
7666
  "",
7321
- "If the provider/model already exists in models.json, add these compat keys under",
7322
- `providers["${suggestion.providerLabel}"] -> models -> entry with id "${suggestion.modelId}" -> compat:`,
7667
+ "Add these compat keys at Pi's highest-precedence model override path:",
7668
+ `providers["${suggestion.providerLabel}"] -> modelOverrides -> "${suggestion.modelId}" -> compat:`,
7323
7669
  formatCompatKeysForInsertion(suggestion.compatKeys),
7324
7670
  );
7325
7671
  if (snippet.length > 0) {
@@ -7353,11 +7699,21 @@ export default function (pi: ExtensionAPI) {
7353
7699
  if (!location) {
7354
7700
  const diagnosis = analyzeModelsJsonForMissingEntry(originalText, suggestion.providerLabel, suggestion.modelId);
7355
7701
  if (diagnosis && cmdCtx.hasUI) {
7356
- // Offer to create the missing entry.
7357
- const plan = composeMissingEntryInsertion(
7358
- originalText, diagnosis,
7359
- suggestion.providerLabel, suggestion.modelId, suggestion.compatKeys,
7702
+ const overrideLocation = locateModelOverrideInJsonc(
7703
+ originalText, suggestion.providerLabel, suggestion.modelId,
7704
+ );
7705
+ const repairsExistingOverride = (overrideLocation?.modelOverrideObjectBrace ?? -1) >= 0;
7706
+ // Prefer a modelOverrides edit when models[] has no target entry.
7707
+ const plan = composeModelOverrideInsertion(
7708
+ originalText, suggestion.providerLabel, suggestion.modelId, suggestion.compatKeys,
7360
7709
  );
7710
+ if (!plan) {
7711
+ cmdCtx.ui.notify(
7712
+ `❌ Could not safely locate a modelOverrides insertion point.\n` +
7713
+ `Falling back to manual guidance. No changes were made.`,
7714
+ "error",
7715
+ );
7716
+ } else {
7361
7717
  const checkError = selfCheckMissingEntryInsertion(
7362
7718
  originalText, plan.modifiedText,
7363
7719
  suggestion.providerLabel, suggestion.modelId, suggestion.compatKeys,
@@ -7382,7 +7738,9 @@ export default function (pi: ExtensionAPI) {
7382
7738
  keysPreview,
7383
7739
  ``,
7384
7740
  `⚠️ Risk notice:`,
7385
- ` 1. This creates a new entry in models.json. Existing auth (e.g. login API tokens) is not affected.`,
7741
+ repairsExistingOverride
7742
+ ? ` 1. This updates the existing modelOverrides entry for "${suggestion.modelId}". Existing auth is not affected.`
7743
+ : ` 1. This creates a modelOverrides entry in models.json. Existing auth (e.g. login API tokens) is not affected.`,
7386
7744
  ` 2. A timestamped backup will be written to: ${backupPath}`,
7387
7745
  ` 3. You must run /reload or restart Pi for the change to take effect.`,
7388
7746
  ` 4. If the file contains comments or unusual formatting, please verify the result after write.`,
@@ -7397,7 +7755,10 @@ export default function (pi: ExtensionAPI) {
7397
7755
  );
7398
7756
  }
7399
7757
  previewLines.push("", `Apply these changes?`);
7400
- const confirmed = await cmdCtx.ui.confirm("Cache Optimizer — Fix (new entry)", previewLines.join("\n"));
7758
+ const confirmed = await cmdCtx.ui.confirm(
7759
+ repairsExistingOverride ? "Cache Optimizer — Fix (model override)" : "Cache Optimizer — Fix (new override)",
7760
+ previewLines.join("\n"),
7761
+ );
7401
7762
  if (confirmed) {
7402
7763
  try {
7403
7764
  await copyFile(MODELS_JSON_PATH, backupPath);
@@ -7436,6 +7797,7 @@ export default function (pi: ExtensionAPI) {
7436
7797
  cmdCtx.ui.notify("No changes were made. Canceled by user.", "info");
7437
7798
  return;
7438
7799
  }
7800
+ }
7439
7801
  }
7440
7802
 
7441
7803
  // Non-interactive or no diagnosis: show manual guidance.
@@ -7448,7 +7810,7 @@ export default function (pi: ExtensionAPI) {
7448
7810
  `❌ Could not locate model "${suggestion.modelId}" or provider "${suggestion.providerLabel}" in ${getModelsJsonDisplayPath()}.`,
7449
7811
  "",
7450
7812
  "Providers that were added via Pi /login API (e.g. opencode go) do not have",
7451
- "entries in models.json. You can create a minimal compat-only entry by hand:",
7813
+ "entries in models.json. You can create a minimal modelOverrides entry by hand:",
7452
7814
  );
7453
7815
  } else if (diagnosis.scenario === "provider_missing") {
7454
7816
  adviceLines.push(
@@ -7463,7 +7825,7 @@ export default function (pi: ExtensionAPI) {
7463
7825
  `ℹ️ Model "${suggestion.modelId}" was not found in ${getModelsJsonDisplayPath()}`,
7464
7826
  `under providers["${suggestion.providerLabel}"].`,
7465
7827
  "",
7466
- "Add the following entry to the models array (keep existing auth):",
7828
+ "Add the following modelOverrides entry (keep existing auth):",
7467
7829
  );
7468
7830
  }
7469
7831
  adviceLines.push("", snippet, "", "Then save and run /reload.");
@@ -7496,11 +7858,17 @@ export default function (pi: ExtensionAPI) {
7496
7858
  // Build preview snippet as copyable JSON (the surgical editor will
7497
7859
  // insert or repair these exact compat key/value pairs).
7498
7860
  const keysPreview = JSON.stringify(suggestion.compatKeys, null, 2);
7499
- const targetHasCompat = decision.placement === "provider" ? location.providerCompatBrace >= 0 : location.compatObjectBrace >= 0;
7861
+ const targetHasCompat = decision.placement === "provider"
7862
+ ? location.providerCompatBrace >= 0
7863
+ : decision.placement === "modelOverride"
7864
+ ? location.modelOverrideCompatBrace >= 0
7865
+ : location.compatObjectBrace >= 0;
7500
7866
  const placementDesc = targetHasCompat ? `existing "compat" object` : `new "compat" object`;
7501
7867
  const locationDesc = decision.placement === "provider"
7502
7868
  ? `providers["${suggestion.providerLabel}"] -> compat (provider level, ${placementDesc})`
7503
- : `providers["${suggestion.providerLabel}"] -> models -> "${suggestion.modelId}" -> compat (model level, ${placementDesc})`;
7869
+ : decision.placement === "modelOverride"
7870
+ ? `providers["${suggestion.providerLabel}"] -> modelOverrides -> "${suggestion.modelId}" -> compat (${placementDesc})`
7871
+ : `providers["${suggestion.providerLabel}"] -> models -> "${suggestion.modelId}" -> compat (model level, ${placementDesc})`;
7504
7872
 
7505
7873
  const ts = backupTimestamp();
7506
7874
  const backupPath = `${MODELS_JSON_PATH}.backup-cache-optimizer-${ts}`;
@@ -7668,26 +8036,38 @@ export default function (pi: ExtensionAPI) {
7668
8036
  }
7669
8037
 
7670
8038
  const location = locateModelInJsonc(originalText, suggestion.providerLabel, suggestion.modelId);
7671
- if (!location) {
8039
+ const menuDecision = location
8040
+ ? chooseFixPlacement(
8041
+ originalText,
8042
+ location,
8043
+ suggestion.compatKeys,
8044
+ suggestion.providerLabel,
8045
+ suggestion.forceModelLevel,
8046
+ )
8047
+ : undefined;
8048
+ const overridePlan = location
8049
+ ? undefined
8050
+ : composeModelOverrideInsertion(
8051
+ originalText,
8052
+ suggestion.providerLabel,
8053
+ suggestion.modelId,
8054
+ suggestion.compatKeys,
8055
+ );
8056
+ if (!location && !overridePlan) {
7672
8057
  cmdCtx.ui.notify(
7673
- `❌ Could not locate model "${suggestion.modelId}" in ${getModelsJsonDisplayPath()}.\n` +
7674
- `Manual edit required: open the file and add:\n` +
7675
- `${formatCompatKeysForInsertion(suggestion.compatKeys)}\n` +
8058
+ `❌ Could not safely locate a modelOverrides insertion point in ${getModelsJsonDisplayPath()}.\n` +
8059
+ `Manual edit required:\n${formatMissingEntryManualSnippet(suggestion.providerLabel, suggestion.modelId, suggestion.compatKeys)}\n` +
7676
8060
  `Then run /reload.`,
7677
8061
  "warning",
7678
8062
  );
7679
8063
  return;
7680
8064
  }
7681
-
7682
- const menuDecision = chooseFixPlacement(
7683
- originalText,
7684
- location,
7685
- suggestion.compatKeys,
7686
- suggestion.providerLabel,
7687
- suggestion.forceModelLevel,
7688
- );
7689
- const modifiedText = composeFixInsertion(originalText, location, suggestion.compatKeys, menuDecision.placement);
7690
- const checkError = selfCheckFix(originalText, modifiedText, suggestion.providerLabel, suggestion.modelId, suggestion.compatKeys, menuDecision.placement);
8065
+ const modifiedText = location && menuDecision
8066
+ ? composeFixInsertion(originalText, location, suggestion.compatKeys, menuDecision.placement)
8067
+ : overridePlan!.modifiedText;
8068
+ const checkError = location && menuDecision
8069
+ ? selfCheckFix(originalText, modifiedText, suggestion.providerLabel, suggestion.modelId, suggestion.compatKeys, menuDecision.placement)
8070
+ : selfCheckMissingEntryInsertion(originalText, modifiedText, suggestion.providerLabel, suggestion.modelId, suggestion.compatKeys);
7691
8071
  if (checkError !== null) {
7692
8072
  cmdCtx.ui.notify(`❌ Self-check failed: ${checkError}\nNo changes made.`, "error");
7693
8073
  return;
@@ -7697,17 +8077,19 @@ export default function (pi: ExtensionAPI) {
7697
8077
  const ts = backupTimestamp();
7698
8078
  const backupPath = `${MODELS_JSON_PATH}.backup-cache-optimizer-${ts}`;
7699
8079
 
7700
- const menuLocationDesc = menuDecision.placement === "provider"
8080
+ const menuLocationDesc = overridePlan?.placementLabel ?? (menuDecision?.placement === "provider"
7701
8081
  ? `providers["${suggestion.providerLabel}"] -> compat (provider level)`
7702
- : `providers["${suggestion.providerLabel}"] -> models -> "${suggestion.modelId}" -> compat (model level)`;
7703
- const menuScopeRiskLine = menuDecision.placement === "provider"
7704
- ? ` 1. This change applies to ALL ${location.allModelIds.length || 1} model(s) in the "${suggestion.providerLabel}" provider, across all sessions.`
8082
+ : menuDecision?.placement === "modelOverride"
8083
+ ? `providers["${suggestion.providerLabel}"] -> modelOverrides -> "${suggestion.modelId}" -> compat`
8084
+ : `providers["${suggestion.providerLabel}"] -> models -> "${suggestion.modelId}" -> compat (model level)`);
8085
+ const menuScopeRiskLine = menuDecision?.placement === "provider"
8086
+ ? ` 1. This change applies to ALL ${location?.allModelIds.length || 1} model(s) in the "${suggestion.providerLabel}" provider, across all sessions.`
7705
8087
  : ` 1. This change affects ALL sessions using the "${suggestion.providerLabel}" provider/channel (scoped to model "${suggestion.modelId}").`;
7706
8088
 
7707
8089
  const previewLines = [
7708
8090
  `📝 Preview of changes to ${getModelsJsonDisplayPath()}:`,
7709
8091
  `Location: ${menuLocationDesc}`,
7710
- `Placement: ${menuDecision.placement} level — ${menuDecision.reason}`,
8092
+ `Placement: ${menuDecision ? `${menuDecision.placement} level — ${menuDecision.reason}` : "modelOverrides — built-in/API-login-safe model override"}`,
7711
8093
  `Compat JSON to write:`,
7712
8094
  keysPreview,
7713
8095
  ``,
@@ -7733,7 +8115,9 @@ export default function (pi: ExtensionAPI) {
7733
8115
  await rename(tempPath, MODELS_JSON_PATH);
7734
8116
 
7735
8117
  const writtenText = await readFile(MODELS_JSON_PATH, "utf8");
7736
- const postCheck = selfCheckFix(originalText, writtenText, suggestion.providerLabel, suggestion.modelId, suggestion.compatKeys, menuDecision.placement);
8118
+ const postCheck = location && menuDecision
8119
+ ? selfCheckFix(originalText, writtenText, suggestion.providerLabel, suggestion.modelId, suggestion.compatKeys, menuDecision.placement)
8120
+ : selfCheckMissingEntryInsertion(originalText, writtenText, suggestion.providerLabel, suggestion.modelId, suggestion.compatKeys);
7737
8121
  if (postCheck !== null) {
7738
8122
  await copyFile(backupPath, MODELS_JSON_PATH);
7739
8123
  cmdCtx.ui.notify(`❌ Post-write check failed: ${postCheck}\nBackup restored.`, "error");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-cache-optimizer",
3
- "version": "2.6.24",
3
+ "version": "2.6.25",
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",
@@ -24,6 +24,21 @@
24
24
  ],
25
25
  "image": "https://img.shields.io/badge/Pi-Cache%20Optimizer-4A90D9"
26
26
  },
27
+ "imports": {
28
+ "#extension": "./index.ts"
29
+ },
30
+ "scripts": {
31
+ "test": "node --import jiti/register tests/review-findings.test.ts",
32
+ "typecheck": "tsc --noEmit --pretty false",
33
+ "check:diff": "git diff --check",
34
+ "check:pack": "npm pack --dry-run --cache node_modules/.cache/npm",
35
+ "check": "npm run typecheck && npm test && npm run check:diff && npm run check:pack"
36
+ },
37
+ "devDependencies": {
38
+ "@types/node": "^22.0.0",
39
+ "jiti": "^2.7.0",
40
+ "typescript": "^5.8.3"
41
+ },
27
42
  "peerDependencies": {
28
43
  "@earendil-works/pi-coding-agent": "*"
29
44
  },