pi-cache-optimizer 2.6.4 → 2.6.5

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
@@ -8,7 +8,7 @@
8
8
 
9
9
  Pi extension for improving provider-side KV / prompt cache hit rates. It keeps stable prompt content near the front, adds a conservative OpenAI-compatible `prompt_cache_key` fallback, warns about common proxy cache-routing gaps, and shows read-only footer cache stats.
10
10
 
11
- > Renamed from `pi-deepseek-cache-optimizer`. Existing footer counters migrate automatically. This package never creates, edits, backs up, or deletes your `~/.pi/agent/models.json`.
11
+ > Renamed from `pi-deepseek-cache-optimizer`. Existing footer counters migrate automatically. The extension does **not** touch `~/.pi/agent/models.json` during normal operation; only `/cache-optimizer fix` can edit it, and only after an explicit interactive preview + confirmation with an automatic timestamped backup.
12
12
 
13
13
  ## Contents
14
14
 
@@ -17,6 +17,8 @@ Pi extension for improving provider-side KV / prompt cache hit rates. It keeps s
17
17
  - [Commands](#commands)
18
18
  - [Persistent opt-out](#persistent-opt-out)
19
19
  - [OpenAI-compatible proxy setup](#openai-compatible-proxy-setup)
20
+ - [Anthropic adaptive thinking models](#anthropic-adaptive-thinking-models)
21
+ - [Auto-repair with `/cache-optimizer fix`](#auto-repair-with-cache-optimizer-fix)
20
22
  - [Footer stats](#footer-stats)
21
23
  - [Uninstall](#uninstall)
22
24
  - [Verify effect](#verify-effect)
@@ -161,7 +163,7 @@ Or use model-level override:
161
163
 
162
164
  1. Shows full preview of changes (file path, edit location, JSON to write, risks)
163
165
  2. Warns: ① changes affect all sessions using that channel, ② automatic backup created at `models.json.backup-cache-optimizer-<timestamp>`, ③ Pi reload required
164
- 3. Uses comment-preserving surgical editor — existing comments, indentation, key order preserved
166
+ 3. Uses comment-preserving surgical editor — existing comments, indentation, and existing key order are preserved
165
167
  4. Requires explicit user confirmation (interactive prompt or `ui.select`)
166
168
  5. Writes atomically (temp + rename); self-validates after write
167
169
  6. Falls back to manual guidance if JSONC scanner cannot confidently locate the target
package/README.zh-CN.md CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  用于提升 Pi 中 provider 侧 KV Cache / Prompt Cache 命中率的扩展:把稳定 prompt 内容前置,给 OpenAI-compatible 请求补保守的 `prompt_cache_key`,提示代理渠道常见缓存路由兼容问题,并在底部显示只读缓存统计。
10
10
 
11
- > 本包已从 `pi-deepseek-cache-optimizer` 改名。已有底部统计会自动迁移。本扩展绝不会创建、修改、备份或删除你的 `~/.pi/agent/models.json`。
11
+ > 本包已从 `pi-deepseek-cache-optimizer` 改名。已有底部统计会自动迁移。正常运行时扩展不会触碰你的 `~/.pi/agent/models.json`;只有 `/cache-optimizer fix` 会在展示交互式预览、风险提示并得到明确确认后写入,且会先创建带时间戳的自动备份。
12
12
 
13
13
  ## 目录
14
14
 
@@ -17,6 +17,8 @@
17
17
  - [命令](#命令)
18
18
  - [持久 Opt-out](#持久-opt-out)
19
19
  - [OpenAI-compatible 代理配置](#openai-compatible-代理配置)
20
+ - [Anthropic adaptive thinking 模型](#anthropic-adaptive-thinking-模型)
21
+ - [使用 `/cache-optimizer fix` 自动修复](#使用-cache-optimizer-fix-自动修复)
20
22
  - [Footer 统计](#footer-统计)
21
23
  - [卸载](#卸载)
22
24
  - [验证效果](#验证效果)
@@ -161,7 +163,7 @@ Pi 内置 catalog 已为官方模型设置此 flag。`models.json` 中覆盖这
161
163
 
162
164
  1. 显示完整变更预览(文件路径、编辑位置、要写入的 JSON、风险说明)
163
165
  2. 警告:① 修改影响使用该渠道的所有 session,② 自动备份到 `models.json.backup-cache-optimizer-<timestamp>`,③ 需重启 Pi 或 reload
164
- 3. 使用保留注释的精确编辑器 —— 现有注释、缩进、key 顺序全部保留
166
+ 3. 使用保留注释的精确编辑器 —— 现有注释、缩进和已有 key 顺序都会保留
165
167
  4. 需要用户明确确认(交互式提示或 `ui.select`)
166
168
  5. 原子写入(temp + rename);写入后自我验证
167
169
  6. 如果 JSONC 扫描器无法置信定位目标,回退到手动修改指引
package/index.ts CHANGED
@@ -1612,7 +1612,7 @@ function hasEffectivePromptCacheKey(record: UnknownRecord): boolean {
1612
1612
  return isNonEmptyString(record.prompt_cache_key) || isNonEmptyString(record.promptCacheKey);
1613
1613
  }
1614
1614
 
1615
- function isNonEmptyString(value: unknown): boolean {
1615
+ function isNonEmptyString(value: unknown): value is string {
1616
1616
  return typeof value === "string" && value.trim().length > 0;
1617
1617
  }
1618
1618
 
@@ -1637,9 +1637,6 @@ function describeMissingOpenAIFamilyProxyCompat(model: PiModel): string[] {
1637
1637
  if (!isOpenAICompatibleProxyApi(model.api)) return missing;
1638
1638
  if (isOfficialOpenAIBaseUrl(model)) return missing;
1639
1639
 
1640
- if (compat.supportsLongCacheRetention !== true) {
1641
- missing.push("supportsLongCacheRetention");
1642
- }
1643
1640
  if (compat.sendSessionAffinityHeaders !== true) {
1644
1641
  missing.push("sendSessionAffinityHeaders");
1645
1642
  }
@@ -1660,9 +1657,6 @@ function describeMissingOpenAICompatibleProxyCompat(model: PiModel): string[] {
1660
1657
  if (!isOpenAICompatibleProxyApi(model.api)) return missing;
1661
1658
  if (isOfficialOpenAIBaseUrl(model)) return missing;
1662
1659
 
1663
- if (compat.supportsLongCacheRetention !== true) {
1664
- missing.push("supportsLongCacheRetention");
1665
- }
1666
1660
  if (compat.sendSessionAffinityHeaders !== true) {
1667
1661
  missing.push("sendSessionAffinityHeaders");
1668
1662
  }
@@ -1670,6 +1664,20 @@ function describeMissingOpenAICompatibleProxyCompat(model: PiModel): string[] {
1670
1664
  return missing;
1671
1665
  }
1672
1666
 
1667
+ function describeOptionalOpenAICompatibleProxyCompat(model: PiModel): string[] {
1668
+ const compat = getCompat(model);
1669
+ const optional: string[] = [];
1670
+
1671
+ if (!isOpenAICompatibleProxyApi(model.api)) return optional;
1672
+ if (isOfficialOpenAIBaseUrl(model)) return optional;
1673
+
1674
+ if (compat.supportsLongCacheRetention !== true) {
1675
+ optional.push("supportsLongCacheRetention");
1676
+ }
1677
+
1678
+ return optional;
1679
+ }
1680
+
1673
1681
  function buildSafeOpenAIProxyCompatSuggestion(missing: string[]): Record<string, boolean> {
1674
1682
  const suggestion: Record<string, boolean> = {};
1675
1683
  if (missing.includes("sendSessionAffinityHeaders")) {
@@ -1760,21 +1768,22 @@ function appendOpenAIProxyCompatAdviceLines(lines: string[], missing: string[],
1760
1768
  lines.push("Safe default suggestion:");
1761
1769
  }
1762
1770
  lines.push(JSON.stringify(suggestion, null, 2));
1763
- } else if (missing.includes("supportsLongCacheRetention")) {
1764
- lines.push("No safe automatic JSON change is recommended for `supportsLongCacheRetention`.");
1765
1771
  }
1766
1772
 
1767
1773
  if (missing.includes("sendSessionAffinityHeaders")) {
1768
1774
  lines.push("- sendSessionAffinityHeaders: recommended for third-party proxies when supported; it helps keep one Pi session on the same upstream/backend.");
1769
1775
  }
1770
- if (missing.includes("supportsLongCacheRetention")) {
1771
- lines.push("- supportsLongCacheRetention: optional. Enable only after your endpoint/proxy explicitly supports OpenAI long prompt cache retention.");
1772
- lines.push(`- ${getPromptCacheRetentionUnsupportedHint()}`);
1773
- }
1774
-
1775
1776
  appendCredentialSafeProviderGuidance(lines, options, suggestion);
1776
1777
  }
1777
1778
 
1779
+ function appendOptionalOpenAIProxyCompatAdviceLines(lines: string[], optional: string[]): void {
1780
+ if (!optional.includes("supportsLongCacheRetention")) return;
1781
+ lines.push("");
1782
+ lines.push("Optional (not required, not auto-fixed):");
1783
+ lines.push("- supportsLongCacheRetention: enable only after your endpoint/proxy explicitly supports OpenAI long prompt cache retention.");
1784
+ lines.push(`- ${getPromptCacheRetentionUnsupportedHint()}`);
1785
+ }
1786
+
1778
1787
  /**
1779
1788
  * Build the warning text displayed to users when an OpenAI-family third-party
1780
1789
  * proxy is missing one or more cache/session-affinity compat flags.
@@ -3121,12 +3130,15 @@ function parseCacheStats(value: unknown): CacheStats | undefined {
3121
3130
 
3122
3131
  function parsePersistedRoutedModelRef(value: unknown): PersistedRoutedModelRef | undefined {
3123
3132
  const record = asRecord(value);
3124
- if (!record || !isNonEmptyString(record.provider) || !isNonEmptyString(record.id)) return undefined;
3133
+ const provider = record?.provider;
3134
+ const id = record?.id;
3135
+ const name = record?.name;
3136
+ if (!isNonEmptyString(provider) || !isNonEmptyString(id)) return undefined;
3125
3137
 
3126
3138
  return {
3127
- provider: record.provider.trim(),
3128
- id: record.id.trim(),
3129
- name: isNonEmptyString(record.name) ? record.name.trim() : record.id.trim(),
3139
+ provider: provider.trim(),
3140
+ id: id.trim(),
3141
+ name: isNonEmptyString(name) ? name.trim() : id.trim(),
3130
3142
  };
3131
3143
  }
3132
3144
 
@@ -3530,8 +3542,9 @@ function describeRouterChannelDiagnostics(model: PiModel): string[] {
3530
3542
  provider.includes("openrouter")
3531
3543
  ) {
3532
3544
  const compat = getCompat(model);
3533
- const hasOnly = !!(compat as Record<string, unknown>)["openRouterRouting"]?.only;
3534
- const hasOrder = !!(compat as Record<string, unknown>)["openRouterRouting"]?.order;
3545
+ const routing = asRecord((compat as Record<string, unknown>)["openRouterRouting"]);
3546
+ const hasOnly = !!routing?.only;
3547
+ const hasOrder = !!routing?.order;
3535
3548
 
3536
3549
  notes.push(
3537
3550
  "🔀 Router/channel: OpenRouter detected. OpenRouter is a multi-provider router; " +
@@ -3566,8 +3579,9 @@ function describeRouterChannelDiagnostics(model: PiModel): string[] {
3566
3579
  provider.includes("vercel-ai-gateway")
3567
3580
  ) {
3568
3581
  const compat = getCompat(model);
3569
- const hasOnly = !!(compat as Record<string, unknown>)["vercelGatewayRouting"]?.only;
3570
- const hasOrder = !!(compat as Record<string, unknown>)["vercelGatewayRouting"]?.order;
3582
+ const routing = asRecord((compat as Record<string, unknown>)["vercelGatewayRouting"]);
3583
+ const hasOnly = !!routing?.only;
3584
+ const hasOrder = !!routing?.order;
3571
3585
 
3572
3586
  notes.push(
3573
3587
  "🔀 Router/channel: Vercel AI Gateway detected. The gateway may route to different " +
@@ -3694,8 +3708,21 @@ function buildDoctorDiagnosis(model: PiModel, options: { promptCacheRetention400
3694
3708
  const adaptiveThinkingApplicable = isAdaptiveThinkingCompatApplicable(model);
3695
3709
  const deepSeekCompatApplicable = isDeepSeekCompatCheckApplicable(model);
3696
3710
  const missing = describeMissingCacheCompatForModel(model);
3711
+ const optionalOpenAIProxyCompat = (!adaptiveThinkingApplicable && !deepSeekCompatApplicable)
3712
+ ? describeOptionalOpenAICompatibleProxyCompat(model)
3713
+ : [];
3714
+ const fixSug = buildFixSuggestion(model);
3715
+ const safeFixableMissing = fixSug ? Object.keys(fixSug.compatKeys) : [];
3716
+ const advisoryMissing = missing.filter(m => !safeFixableMissing.includes(m));
3717
+
3718
+ if (safeFixableMissing.length > 0) {
3719
+ lines.push(`⚠️ Missing compat flags: ${safeFixableMissing.join(", ")}`);
3720
+ }
3721
+ if (advisoryMissing.length > 0) {
3722
+ lines.push(`ℹ️ Optional: ${advisoryMissing.join(", ")} (enable only if needed)`);
3723
+ }
3724
+
3697
3725
  if (missing.length > 0) {
3698
- lines.push(`⚠️ Missing compat flags: ${missing.join(", ")}`);
3699
3726
  const key = modelKey(model);
3700
3727
  const slashIdx = key.indexOf("/");
3701
3728
  const providerLabel = slashIdx > 0 ? key.slice(0, slashIdx) : key;
@@ -3707,9 +3734,11 @@ function buildDoctorDiagnosis(model: PiModel, options: { promptCacheRetention400
3707
3734
  appendDeepSeekCompatAdviceLines(lines, missing, { providerLabel, modelId: model.id });
3708
3735
  } else {
3709
3736
  appendOpenAIProxyCompatAdviceLines(lines, missing, { providerLabel, modelId: model.id });
3737
+ appendOptionalOpenAIProxyCompatAdviceLines(lines, optionalOpenAIProxyCompat);
3710
3738
  }
3711
3739
  } else if (adaptiveThinkingApplicable || deepSeekCompatApplicable || isCompatCheckApplicable(model)) {
3712
3740
  lines.push("✅ Compat fully configured.");
3741
+ appendOptionalOpenAIProxyCompatAdviceLines(lines, optionalOpenAIProxyCompat);
3713
3742
  } else {
3714
3743
  lines.push(...getCompatCheckNotApplicableLines(model));
3715
3744
  }
@@ -3767,7 +3796,8 @@ function buildLowHitDiagnosis(
3767
3796
  const lines: string[] = [];
3768
3797
 
3769
3798
  // 1. Missing compat flags (adapter-aware: DeepSeek has extra reasoning compat)
3770
- const missingCompat = describeMissingCacheCompatForModel(model);
3799
+ const fixSugLHD = buildFixSuggestion(model);
3800
+ const safeFixableMissingLHD = fixSugLHD ? Object.keys(fixSugLHD.compatKeys) : [];
3771
3801
 
3772
3802
  // 2. Router/channel risk (reuse existing check)
3773
3803
  const routerNotes = describeRouterChannelDiagnostics(model);
@@ -3785,7 +3815,7 @@ function buildLowHitDiagnosis(
3785
3815
  // 5. Today's overall trend from persisted stats
3786
3816
  const todayStats = stats ?? emptyCacheStats();
3787
3817
 
3788
- const hasMissingCompat = missingCompat.length > 0;
3818
+ const hasMissingCompat = safeFixableMissingLHD.length > 0;
3789
3819
  const hasRouterRisk = routerNotes.length > 0;
3790
3820
  const hasUsageMissing = missingUsageSamples > 0;
3791
3821
 
@@ -3814,7 +3844,7 @@ function buildLowHitDiagnosis(
3814
3844
 
3815
3845
  // Priority 1: missing compat flags
3816
3846
  if (hasMissingCompat) {
3817
- lines.push(`⚠️ Missing compat flags: ${missingCompat.join(", ")}`);
3847
+ lines.push(`⚠️ Missing compat flags: ${safeFixableMissingLHD.join(", ")}`);
3818
3848
  lines.push(" These flags enable prompt caching and session-affinity routing.");
3819
3849
  lines.push(" Run /cache-optimizer compat for edit instructions.");
3820
3850
  }
@@ -3867,11 +3897,17 @@ function buildLowHitDiagnosis(
3867
3897
 
3868
3898
  function buildCompatDiagnosis(model: PiModel): string | undefined {
3869
3899
  const missing = describeMissingCacheCompatForModel(model);
3900
+ const fixSugC = buildFixSuggestion(model);
3901
+ const safeFixableMissingC = fixSugC ? Object.keys(fixSugC.compatKeys) : [];
3902
+ const advisoryMissingC = missing.filter(m => !safeFixableMissingC.includes(m));
3870
3903
  const adaptiveThinkingApplicable = isAdaptiveThinkingCompatApplicable(model);
3871
3904
  const deepSeekCompatApplicable = isDeepSeekCompatCheckApplicable(model);
3905
+ const optionalOpenAIProxyCompat = (!adaptiveThinkingApplicable && !deepSeekCompatApplicable)
3906
+ ? describeOptionalOpenAICompatibleProxyCompat(model)
3907
+ : [];
3872
3908
  const routerNotes = describeRouterChannelDiagnostics(model);
3873
3909
 
3874
- if (missing.length === 0 && routerNotes.length === 0) return undefined;
3910
+ if (missing.length === 0 && routerNotes.length === 0 && optionalOpenAIProxyCompat.length === 0) return undefined;
3875
3911
 
3876
3912
  const key = modelKey(model);
3877
3913
  const lines: string[] = [];
@@ -3881,7 +3917,12 @@ function buildCompatDiagnosis(model: PiModel): string | undefined {
3881
3917
  const providerLabel = slashIdx > 0 ? key.slice(0, slashIdx) : key;
3882
3918
  const modelsJsonPath = getModelsJsonDisplayPath();
3883
3919
  lines.push(`Active model: ${key}`);
3884
- lines.push(`Missing: ${missing.join(", ")}`);
3920
+ if (safeFixableMissingC.length > 0) {
3921
+ lines.push(`Safe-fixable: ${safeFixableMissingC.join(", ")}`);
3922
+ }
3923
+ if (advisoryMissingC.length > 0) {
3924
+ lines.push(`Optional: ${advisoryMissingC.join(", ")} (enable only if needed)`);
3925
+ }
3885
3926
  lines.push("");
3886
3927
  lines.push(`Edit ${modelsJsonPath} -> providers["${providerLabel}"] -> compat`);
3887
3928
  lines.push(`(at the same level as baseUrl/api/apiKey/models).`);
@@ -3891,16 +3932,18 @@ function buildCompatDiagnosis(model: PiModel): string | undefined {
3891
3932
  appendDeepSeekCompatAdviceLines(lines, missing, { providerLabel, modelId: model.id });
3892
3933
  } else {
3893
3934
  appendOpenAIProxyCompatAdviceLines(lines, missing, { providerLabel, modelId: model.id });
3935
+ appendOptionalOpenAIProxyCompatAdviceLines(lines, optionalOpenAIProxyCompat);
3894
3936
  }
3895
3937
  }
3896
3938
 
3897
- // When compat is fully configured but router notes exist, prefix the status.
3898
- if (routerNotes.length > 0 && missing.length === 0) {
3939
+ // When compat is fully configured but router/optional notes exist, prefix the status.
3940
+ if ((routerNotes.length > 0 || optionalOpenAIProxyCompat.length > 0) && missing.length === 0) {
3899
3941
  if (adaptiveThinkingApplicable || deepSeekCompatApplicable || isCompatCheckApplicable(model)) {
3900
3942
  lines.push("✅ Compat fully configured.");
3901
3943
  if (isPromptCacheRetention400Applicable(model)) {
3902
3944
  lines.push(getPromptCacheRetentionUnsupportedHint());
3903
3945
  }
3946
+ appendOptionalOpenAIProxyCompatAdviceLines(lines, optionalOpenAIProxyCompat);
3904
3947
  } else {
3905
3948
  lines.push(...getCompatCheckNotApplicableLines(model));
3906
3949
  }
@@ -4131,8 +4174,10 @@ function stripJsoncComments(text: string): string {
4131
4174
  let i = 0;
4132
4175
  while (i < text.length) {
4133
4176
  const ch = text[i];
4177
+
4134
4178
  if (ch === '"') {
4135
- // String literal — copy until closing quote (handle escapes)
4179
+ // String literal — copy byte-for-byte until the closing quote.
4180
+ // Escaped quotes/slashes must not be mistaken for comment delimiters.
4136
4181
  out.push(ch);
4137
4182
  i++;
4138
4183
  while (i < text.length) {
@@ -4146,39 +4191,74 @@ function stripJsoncComments(text: string): string {
4146
4191
  break;
4147
4192
  }
4148
4193
  }
4149
- } else if (ch === '/' && i + 1 < text.length && text[i + 1] === '/') {
4150
- // Line comment — replace with spaces until newline
4151
- out.push(' ');
4152
- i++;
4194
+ continue;
4195
+ }
4196
+
4197
+ if (ch === '/' && i + 1 < text.length && text[i + 1] === '/') {
4198
+ // Line comment — replace BOTH slashes and every comment byte with
4199
+ // spaces, but leave the newline to be copied by the normal path.
4200
+ out.push(' ', ' ');
4201
+ i += 2;
4153
4202
  while (i < text.length && text[i] !== '\n') {
4154
4203
  out.push(' ');
4155
4204
  i++;
4156
4205
  }
4157
- } else if (ch === '/' && i + 1 < text.length && text[i + 1] === '*') {
4158
- // Block comment — replace with spaces (preserve newlines)
4159
- out.push(' ');
4160
- i++;
4161
- while (i + 1 < text.length) {
4162
- if (text[i] === '*' && text[i + 1] === '/') {
4163
- out.push(' ');
4206
+ continue;
4207
+ }
4208
+
4209
+ if (ch === '/' && i + 1 < text.length && text[i + 1] === '*') {
4210
+ // Block comment replace every byte with a space except newlines.
4211
+ // This deliberately preserves text.length and all structural offsets.
4212
+ out.push(' ', ' ');
4213
+ i += 2;
4214
+ while (i < text.length) {
4215
+ if (text[i] === '*' && i + 1 < text.length && text[i + 1] === '/') {
4216
+ out.push(' ', ' ');
4164
4217
  i += 2;
4165
4218
  break;
4166
4219
  }
4167
- if (text[i] === '\n') {
4168
- out.push('\n');
4169
- } else {
4170
- out.push(' ');
4171
- }
4220
+ out.push(text[i] === '\n' ? '\n' : ' ');
4172
4221
  i++;
4173
4222
  }
4174
- } else {
4175
- out.push(ch);
4176
- i++;
4223
+ continue;
4177
4224
  }
4225
+
4226
+ out.push(ch);
4227
+ i++;
4178
4228
  }
4179
4229
  return out.join('');
4180
4230
  }
4181
4231
 
4232
+ /**
4233
+ * Remove JSONC trailing commas from already comment-stripped text.
4234
+ * The returned text stays length-preserving (commas become spaces), which
4235
+ * gives JSON.parse a tolerant JSONC surface without affecting diagnostics.
4236
+ */
4237
+ function stripJsoncTrailingCommas(text: string): string {
4238
+ const chars = text.split("");
4239
+ let i = 0;
4240
+ while (i < chars.length) {
4241
+ if (chars[i] === '"') {
4242
+ const str = readJsonStringLiteral(text, i);
4243
+ if (!str) break;
4244
+ i = str.end;
4245
+ continue;
4246
+ }
4247
+
4248
+ if (chars[i] === ',') {
4249
+ let j = i + 1;
4250
+ while (j < chars.length && isJsonWhitespace(chars[j])) j++;
4251
+ if (chars[j] === '}' || chars[j] === ']') chars[i] = ' ';
4252
+ }
4253
+ i++;
4254
+ }
4255
+ return chars.join('');
4256
+ }
4257
+
4258
+ function parseJsonc(text: string): unknown {
4259
+ return JSON.parse(stripJsoncTrailingCommas(stripJsoncComments(text)));
4260
+ }
4261
+
4182
4262
  /**
4183
4263
  * JSONC scanner: locate the provider block and model entry in models.json text.
4184
4264
  * Returns the byte offsets for surgical insertion, or undefined if ambiguous.
@@ -4222,153 +4302,51 @@ function locateModelInJsonc(
4222
4302
  // Clean text of comments first for reliable structural scanning
4223
4303
  const clean = stripJsoncComments(text);
4224
4304
 
4225
- // Strategy: find `"providers"` key in the root object, then find the
4226
- // provider key under it, then the `"models"` array, then the array
4227
- // element whose `"id"` matches. We map via the stripped text (comment
4228
- // removal replaces comment chars with spaces, preserving offsets).
4229
-
4230
- const pos = clean.indexOf('"providers"');
4231
- if (pos < 0) return undefined;
4232
-
4233
- // Scan from `"providers"` to find the `{` of the provider block
4234
- let cur = pos + '"providers"'.length;
4235
- // Skip `:`, whitespace, etc.
4236
- while (cur < clean.length && clean[cur] !== '{') cur++;
4237
- if (cur >= clean.length) return undefined;
4238
- cur++; // Skip `{`
4239
-
4240
- // Now scan key-value pairs in the providers object to find the matching providerLabel
4241
- const providerLabelJson = JSON.stringify(providerLabel);
4242
- let providerBrace = -1;
4243
- let providerEndBrace = -1;
4244
-
4245
- while (cur < clean.length) {
4246
- // Skip whitespace/comments
4247
- while (cur < clean.length && (clean[cur] === ' ' || clean[cur] === '\n' || clean[cur] === '\r' || clean[cur] === '\t')) cur++;
4248
- if (cur >= clean.length) break;
4249
- if (clean[cur] === '}') break; // End of providers
4250
-
4251
- // Try to read a string key
4252
- if (clean[cur] !== '"') { cur++; continue; }
4253
- const keyEnd = clean.indexOf('"', cur + 1);
4254
- if (keyEnd < 0) return undefined;
4255
- const key = clean.slice(cur + 1, keyEnd);
4256
- cur = keyEnd + 1;
4257
-
4258
- // Skip `:`
4259
- while (cur < clean.length && clean[cur] !== ':') cur++;
4260
- if (cur >= clean.length) return undefined;
4261
- cur++; // Skip `:`
4262
- while (cur < clean.length && (clean[cur] === ' ' || clean[cur] === '\n' || clean[cur] === '\r' || clean[cur] === '\t')) cur++;
4263
-
4264
- if (key === providerLabel) {
4265
- // Found — expect `{` starting the provider object
4266
- if (clean[cur] !== '{') return undefined;
4267
- providerBrace = cur;
4268
- // Find matching closing `}` for the provider object (track depth)
4269
- let depth = 1;
4270
- let scan = cur + 1;
4271
- while (scan < clean.length && depth > 0) {
4272
- if (clean[scan] === '{') depth++;
4273
- else if (clean[scan] === '}') depth--;
4274
- if (depth > 0) scan++;
4275
- }
4276
- providerEndBrace = scan;
4277
- break;
4278
- }
4279
-
4280
- // Skip the value
4281
- if (clean[cur] === '{') {
4282
- let depth = 1;
4283
- cur++;
4284
- while (cur < clean.length && depth > 0) {
4285
- if (clean[cur] === '{') depth++;
4286
- else if (clean[cur] === '}') depth--;
4287
- cur++;
4288
- }
4289
- } else if (clean[cur] === '[') {
4290
- let depth = 1;
4291
- cur++;
4292
- while (cur < clean.length && depth > 0) {
4293
- if (clean[cur] === '[') depth++;
4294
- else if (clean[cur] === ']') depth--;
4295
- cur++;
4296
- }
4297
- } else if (clean[cur] === '"') {
4298
- const strEnd = clean.indexOf('"', cur + 1);
4299
- if (strEnd < 0) return undefined;
4300
- cur = strEnd + 1;
4301
- } else {
4302
- // Number, boolean, etc.
4303
- while (cur < clean.length && clean[cur] !== ',' && clean[cur] !== '}' && clean[cur] !== '\n') cur++;
4304
- }
4305
- // Skip comma
4306
- if (cur < clean.length && clean[cur] === ',') cur++;
4307
- }
4308
-
4309
- if (providerBrace < 0 || providerEndBrace < 0) return undefined;
4310
-
4311
- // Scan provider object at depth 1 for a provider-level "compat" object.
4312
- // Depth-aware + string-aware so nested model compat objects are not confused
4313
- // with the provider-level one.
4305
+ // Strategy: find `"providers"` as a direct root key, then find the
4306
+ // provider key under it, then the provider's direct `"models"` key.
4307
+ // All object/value traversal uses the string-aware primitives above so
4308
+ // braces, brackets, comment markers, or escaped quotes inside strings do
4309
+ // not corrupt offsets.
4310
+ const rootBrace = skipJsonWhitespace(clean, 0);
4311
+ if (clean[rootBrace] !== "{") return undefined;
4312
+
4313
+ const providersKey = findJsonObjectKey(clean, rootBrace, "providers");
4314
+ if (!providersKey) return undefined;
4315
+ const providersBrace = skipJsonWhitespace(clean, providersKey.valueStart);
4316
+ if (clean[providersBrace] !== "{") return undefined;
4317
+ const providersEnd = findMatchingBracket(clean, providersBrace);
4318
+ if (providersEnd === undefined) return undefined;
4319
+
4320
+ const providerKey = findJsonObjectKey(clean, providersBrace, providerLabel);
4321
+ if (!providerKey || providerKey.keyStart > providersEnd) return undefined;
4322
+ const providerBrace = skipJsonWhitespace(clean, providerKey.valueStart);
4323
+ if (clean[providerBrace] !== "{") return undefined;
4324
+ const providerEndBrace = findMatchingBracket(clean, providerBrace);
4325
+ if (providerEndBrace === undefined || providerEndBrace > providersEnd) return undefined;
4326
+
4327
+ // Provider-level compat is a direct provider child only. Nested model
4328
+ // compat objects are intentionally skipped whole by findJsonObjectKey.
4314
4329
  let providerCompatBrace = -1;
4315
4330
  let providerCompatEnd = -1;
4316
- {
4317
- let pScan = providerBrace + 1;
4318
- let pDepth = 1;
4319
- while (pScan < providerEndBrace && pDepth > 0) {
4320
- const ch = clean[pScan];
4321
- if (ch === '"') {
4322
- // Read the string (key or value) fully
4323
- const strEnd = clean.indexOf('"', pScan + 1);
4324
- if (strEnd < 0) break;
4325
- const str = clean.slice(pScan + 1, strEnd);
4326
- if (pDepth === 1 && str === 'compat') {
4327
- // Confirm it's a key: next non-ws char must be ':'
4328
- let after = strEnd + 1;
4329
- while (after < providerEndBrace && (clean[after] === ' ' || clean[after] === '\n' || clean[after] === '\r' || clean[after] === '\t')) after++;
4330
- if (clean[after] === ':') {
4331
- after++;
4332
- while (after < providerEndBrace && (clean[after] === ' ' || clean[after] === '\n' || clean[after] === '\r' || clean[after] === '\t')) after++;
4333
- if (clean[after] === '{') {
4334
- providerCompatBrace = after;
4335
- let d = 1;
4336
- let s = after + 1;
4337
- while (s < clean.length && d > 0) {
4338
- if (clean[s] === '"') {
4339
- const e = clean.indexOf('"', s + 1);
4340
- if (e < 0) break;
4341
- s = e + 1;
4342
- continue;
4343
- }
4344
- if (clean[s] === '{') d++;
4345
- else if (clean[s] === '}') d--;
4346
- if (d > 0) s++;
4347
- }
4348
- providerCompatEnd = s;
4349
- pScan = s + 1;
4350
- continue;
4351
- }
4352
- }
4353
- }
4354
- pScan = strEnd + 1;
4355
- continue;
4331
+ const providerCompatKey = findJsonObjectKey(clean, providerBrace, "compat");
4332
+ if (providerCompatKey && providerCompatKey.keyStart < providerEndBrace) {
4333
+ const brace = skipJsonWhitespace(clean, providerCompatKey.valueStart);
4334
+ if (clean[brace] === "{") {
4335
+ const end = findMatchingBracket(clean, brace);
4336
+ if (end !== undefined && end <= providerEndBrace) {
4337
+ providerCompatBrace = brace;
4338
+ providerCompatEnd = end;
4356
4339
  }
4357
- if (ch === '{' || ch === '[') pDepth++;
4358
- else if (ch === '}' || ch === ']') pDepth--;
4359
- pScan++;
4360
4340
  }
4361
4341
  }
4362
4342
 
4363
- // Now find the `"models"` array within the provider
4364
- const providerContent = clean.slice(providerBrace + 1, providerEndBrace);
4365
- const modelsIdx = providerContent.indexOf('"models"');
4366
- if (modelsIdx < 0) return undefined;
4343
+ const modelsKey = findJsonObjectKey(clean, providerBrace, "models");
4344
+ if (!modelsKey || modelsKey.keyStart > providerEndBrace) return undefined;
4367
4345
 
4368
- // Find the `[` of the models array
4369
- let modelsScan = providerBrace + 1 + modelsIdx + '"models"'.length;
4370
- while (modelsScan < clean.length && clean[modelsScan] !== '[') modelsScan++;
4371
- if (modelsScan >= clean.length) return undefined;
4346
+ let modelsScan = skipJsonWhitespace(clean, modelsKey.valueStart);
4347
+ if (clean[modelsScan] !== "[") return undefined;
4348
+ const modelsEnd = findMatchingBracket(clean, modelsScan);
4349
+ if (modelsEnd === undefined || modelsEnd > providerEndBrace) return undefined;
4372
4350
  modelsScan++; // Skip `[`
4373
4351
 
4374
4352
  // Scan ALL array elements: collect every model id, and record the target's position
@@ -4379,83 +4357,52 @@ function locateModelInJsonc(
4379
4357
  let compatBrace = -1;
4380
4358
  let compatEndBrace = -1;
4381
4359
 
4382
- while (modelsScan < clean.length) {
4383
- // Skip whitespace/comma
4384
- while (modelsScan < clean.length && (clean[modelsScan] === ' ' || clean[modelsScan] === '\n' || clean[modelsScan] === '\r' || clean[modelsScan] === '\t' || clean[modelsScan] === ',')) modelsScan++;
4385
- if (modelsScan >= clean.length) break;
4386
- if (clean[modelsScan] === ']') break; // End of array
4387
-
4388
- if (clean[modelsScan] !== '{') { modelsScan++; continue; }
4360
+ while (modelsScan < modelsEnd) {
4361
+ modelsScan = skipJsonWhitespace(clean, modelsScan);
4362
+ if (clean[modelsScan] === ',') {
4363
+ modelsScan++;
4364
+ continue;
4365
+ }
4366
+ if (modelsScan >= modelsEnd || clean[modelsScan] === ']') break;
4367
+ if (clean[modelsScan] !== '{') return undefined;
4389
4368
 
4390
- // Found a model object `{`
4391
4369
  const elementBrace = modelsScan;
4370
+ const elementEnd = findMatchingBracket(clean, elementBrace);
4371
+ if (elementEnd === undefined || elementEnd > modelsEnd) return undefined;
4392
4372
 
4393
- // Find the matching closing `}` and extract this element's `"id"` at depth 1
4394
- let depth = 1;
4395
- let scan = modelsScan + 1;
4373
+ const idKey = findJsonObjectKey(clean, elementBrace, "id");
4396
4374
  let elementId: string | undefined;
4397
-
4398
- while (scan < clean.length && depth > 0) {
4399
- if (clean[scan] === '"') {
4400
- const strEnd = clean.indexOf('"', scan + 1);
4401
- if (strEnd < 0) break;
4402
- if (depth === 1 && elementId === undefined && clean.slice(scan, scan + 4) === '"id"') {
4403
- // Found "id" key — find the colon and the value
4404
- let afterKey = scan + 4;
4405
- while (afterKey < clean.length && clean[afterKey] !== ':') afterKey++;
4406
- if (afterKey < clean.length) {
4407
- afterKey++; // skip ':'
4408
- while (afterKey < clean.length && (clean[afterKey] === ' ' || clean[afterKey] === '\n' || clean[afterKey] === '\r' || clean[afterKey] === '\t')) afterKey++;
4409
- if (afterKey < clean.length && clean[afterKey] === '"') {
4410
- const idStart = afterKey + 1;
4411
- const idEnd = clean.indexOf('"', idStart);
4412
- if (idEnd > idStart) {
4413
- elementId = clean.slice(idStart, idEnd);
4414
- }
4415
- }
4416
- }
4417
- }
4418
- scan = strEnd + 1;
4419
- continue;
4375
+ if (idKey && idKey.keyStart < elementEnd) {
4376
+ const idValueStart = skipJsonWhitespace(clean, idKey.valueStart);
4377
+ const idLiteral = readJsonStringLiteral(clean, idValueStart);
4378
+ if (idLiteral && idLiteral.end <= elementEnd) {
4379
+ elementId = idLiteral.value;
4420
4380
  }
4421
- if (clean[scan] === '{') depth++;
4422
- else if (clean[scan] === '}') depth--;
4423
- scan++;
4424
4381
  }
4425
4382
 
4426
- const elementEnd = scan - 1; // The `}` that closed this element
4427
-
4428
4383
  if (elementId !== undefined) {
4429
4384
  allModelIds.push(elementId);
4430
4385
  }
4431
4386
 
4432
4387
  if (elementId === modelId && modelBrace < 0) {
4433
- // This is the target model — record its position and find its compat
4434
4388
  modelBrace = elementBrace;
4435
4389
  modelEndBrace = elementEnd;
4436
- const modelContent = clean.slice(modelBrace + 1, modelEndBrace);
4437
- const compatIdx = modelContent.indexOf('"compat"');
4438
- if (compatIdx >= 0) {
4439
- compatKeyStartClean = modelBrace + 1 + compatIdx;
4440
- let compatScan = compatKeyStartClean + '"compat"'.length;
4441
- while (compatScan < clean.length && clean[compatScan] !== ':') compatScan++;
4442
- compatScan++;
4443
- while (compatScan < clean.length && (clean[compatScan] === ' ' || clean[compatScan] === '\n' || clean[compatScan] === '\r' || clean[compatScan] === '\t')) compatScan++;
4444
- if (compatScan < clean.length && clean[compatScan] === '{') {
4445
- compatBrace = compatScan;
4446
- let cdepth = 1;
4447
- let cscan = compatScan + 1;
4448
- while (cscan < clean.length && cdepth > 0) {
4449
- if (clean[cscan] === '{') cdepth++;
4450
- else if (clean[cscan] === '}') cdepth--;
4451
- if (cdepth > 0) cscan++;
4390
+
4391
+ const compatKey = findJsonObjectKey(clean, modelBrace, "compat");
4392
+ if (compatKey && compatKey.keyStart < modelEndBrace) {
4393
+ compatKeyStartClean = compatKey.keyStart;
4394
+ const brace = skipJsonWhitespace(clean, compatKey.valueStart);
4395
+ if (clean[brace] === "{") {
4396
+ const end = findMatchingBracket(clean, brace);
4397
+ if (end !== undefined && end <= modelEndBrace) {
4398
+ compatBrace = brace;
4399
+ compatEndBrace = end;
4452
4400
  }
4453
- compatEndBrace = cscan;
4454
4401
  }
4455
4402
  }
4456
4403
  }
4457
4404
 
4458
- modelsScan = scan;
4405
+ modelsScan = elementEnd + 1;
4459
4406
  }
4460
4407
 
4461
4408
  if (modelBrace < 0 || modelEndBrace < 0) return undefined;
@@ -4596,6 +4543,48 @@ function decideFixPlacement(
4596
4543
  };
4597
4544
  }
4598
4545
 
4546
+ function findExistingCompatKeysInJsonc(
4547
+ original: string,
4548
+ compatBrace: number,
4549
+ compatEnd: number,
4550
+ keys: string[],
4551
+ ): string[] {
4552
+ if (compatBrace < 0 || compatEnd <= compatBrace) return [];
4553
+ const clean = stripJsoncComments(original);
4554
+ return keys.filter((key) => {
4555
+ const found = findJsonObjectKey(clean, compatBrace, key);
4556
+ return !!found && found.keyStart < compatEnd;
4557
+ });
4558
+ }
4559
+
4560
+ function chooseFixPlacement(
4561
+ original: string,
4562
+ location: ModelNodeLocation,
4563
+ compatKeys: Record<string, unknown>,
4564
+ providerLabel: string,
4565
+ ): { placement: "provider" | "model"; reason: string } {
4566
+ const decision = decideFixPlacement(compatKeys, providerLabel, location.allModelIds);
4567
+ const existingModelKeys = findExistingCompatKeysInJsonc(
4568
+ original,
4569
+ location.compatObjectBrace,
4570
+ location.compatObjectEnd,
4571
+ Object.keys(compatKeys),
4572
+ );
4573
+
4574
+ // Provider-level writes cannot override a model-level compat key because Pi's
4575
+ // merge order is provider.compat then model.compat. If the active model already
4576
+ // has one of the keys we need to repair (e.g. thinkingFormat: "legacy"), write
4577
+ // at model level even when the key would otherwise be provider-safe.
4578
+ if (decision.placement === "provider" && existingModelKeys.length > 0) {
4579
+ return {
4580
+ placement: "model",
4581
+ reason: `model-level compat already contains ${existingModelKeys.join(", ")} — repairing the active model override directly`,
4582
+ };
4583
+ }
4584
+
4585
+ return decision;
4586
+ }
4587
+
4599
4588
  function composeFixInsertion(
4600
4589
  original: string,
4601
4590
  location: ModelNodeLocation,
@@ -4607,14 +4596,12 @@ function composeFixInsertion(
4607
4596
  const targetCompatEnd = placement === "provider" ? location.providerCompatEnd : location.compatObjectEnd;
4608
4597
  const containerBrace = placement === "provider" ? location.providerObjectBrace : location.modelObjectBrace;
4609
4598
 
4610
- // Helper: format the new keys as lines with the given indent, alphabetically sorted.
4611
- const formatKeys = (indent: string): string =>
4612
- Object.entries(compatKeys)
4613
- .sort(([a], [b]) => a.localeCompare(b))
4614
- .map(([k, v]) => {
4615
- const val = typeof v === 'string' ? `"${v}"` : JSON.stringify(v);
4616
- return `${indent}${JSON.stringify(k)}: ${val}`;
4617
- })
4599
+ // Helper: format key/value pairs as lines with the given indent,
4600
+ // alphabetically sorted for stable previews and deterministic edits.
4601
+ const sortedEntries = Object.entries(compatKeys).sort(([a], [b]) => a.localeCompare(b));
4602
+ const formatEntries = (indent: string, entries: Array<[string, unknown]>): string =>
4603
+ entries
4604
+ .map(([k, v]) => `${indent}${JSON.stringify(k)}: ${JSON.stringify(v)}`)
4618
4605
  .join(',\n');
4619
4606
 
4620
4607
  // Helper: line-start indentation of the line containing `offset` in `original`.
@@ -4627,25 +4614,52 @@ function composeFixInsertion(
4627
4614
  };
4628
4615
 
4629
4616
  if (targetCompatBrace >= 0 && targetCompatEnd > targetCompatBrace) {
4630
- // ── Existing compat object: insert new key lines right after `{`. ──
4631
- // The existing interior is preserved BYTE-FOR-BYTE (no reflow, no re-indent).
4617
+ // ── Existing compat object: insert absent keys and surgically replace
4618
+ // direct existing keys whose value is wrong (e.g. thinkingFormat: "legacy").
4619
+ // Unrelated interior bytes/comments/key order are preserved.
4632
4620
  const interiorStart = targetCompatBrace + 1;
4633
4621
  const interior = original.slice(interiorStart, targetCompatEnd);
4634
4622
  const hasContent = interior.trim().length > 0;
4623
+ const clean = stripJsoncComments(original);
4635
4624
 
4636
- // Indent for the new key lines: copy the first existing key line's indent,
4625
+ // Indent for inserted key lines: copy the first existing key line's indent,
4637
4626
  // else derive one level deeper than the compat brace's own line.
4638
4627
  const braceLineIndent = lineIndentAt(targetCompatBrace);
4639
4628
  const innerMatch = interior.match(/\r?\n([ \t]+)\S/);
4640
4629
  const innerIndent = innerMatch ? innerMatch[1] : braceLineIndent + ' ';
4641
- const keysFormatted = formatKeys(innerIndent);
4642
4630
 
4643
- if (hasContent) {
4644
- // `{` + "\n<new keys>," + <original interior untouched> + `}`
4645
- return original.slice(0, interiorStart) + `\n${keysFormatted},` + original.slice(interiorStart);
4631
+ const edits: Array<{ start: number; end: number; text: string }> = [];
4632
+ const missingEntries: Array<[string, unknown]> = [];
4633
+
4634
+ for (const [key, value] of sortedEntries) {
4635
+ const existing = findJsonObjectKey(clean, targetCompatBrace, key);
4636
+ if (existing && existing.keyStart < targetCompatEnd) {
4637
+ const valueStart = skipJsonWhitespace(clean, existing.valueStart);
4638
+ const valueEnd = skipJsonValue(clean, valueStart);
4639
+ if (valueEnd !== undefined && valueEnd <= targetCompatEnd) {
4640
+ const nextValue = JSON.stringify(value);
4641
+ if (original.slice(valueStart, valueEnd) !== nextValue) {
4642
+ edits.push({ start: valueStart, end: valueEnd, text: nextValue });
4643
+ }
4644
+ continue;
4645
+ }
4646
+ }
4647
+ missingEntries.push([key, value]);
4648
+ }
4649
+
4650
+ if (missingEntries.length > 0) {
4651
+ const keysFormatted = formatEntries(innerIndent, missingEntries);
4652
+ if (hasContent) {
4653
+ edits.push({ start: interiorStart, end: interiorStart, text: `\n${keysFormatted},` });
4654
+ } else {
4655
+ edits.push({ start: interiorStart, end: targetCompatEnd, text: `\n${keysFormatted}\n${braceLineIndent}` });
4656
+ }
4646
4657
  }
4647
- // Empty compat `{}` (or whitespace only): write keys + put `}` back on its own line.
4648
- return original.slice(0, interiorStart) + `\n${keysFormatted}\n${braceLineIndent}` + original.slice(targetCompatEnd);
4658
+
4659
+ // Apply later edits first so earlier offsets remain valid.
4660
+ return edits
4661
+ .sort((a, b) => b.start - a.start)
4662
+ .reduce((text, edit) => text.slice(0, edit.start) + edit.text + text.slice(edit.end), original);
4649
4663
  }
4650
4664
 
4651
4665
  // ── No compat object yet: create one right after the container `{`. ──
@@ -4666,12 +4680,12 @@ function composeFixInsertion(
4666
4680
  : ' ';
4667
4681
  const innerIndent = keyIndent + unit;
4668
4682
 
4669
- const compatBlock = `\n${keyIndent}"compat": {\n${formatKeys(innerIndent)}\n${keyIndent}},`;
4683
+ const compatBlock = `\n${keyIndent}"compat": {\n${formatEntries(innerIndent, sortedEntries)}\n${keyIndent}},`;
4670
4684
  return original.slice(0, afterBrace) + compatBlock + suffix;
4671
4685
  }
4672
4686
 
4673
4687
  /**
4674
- * Self-check after compose: parse original and modified via stripJsoncComments,
4688
+ * Self-check after compose: parse original and modified as JSONC,
4675
4689
  * assert target compat flags exist in the right path, and remaining structure
4676
4690
  * is deep-equal (ignoring the inserted keys).
4677
4691
  * Returns null on success, error message on failure.
@@ -4684,17 +4698,17 @@ function selfCheckFix(
4684
4698
  compatKeys: Record<string, unknown>,
4685
4699
  ): string | null {
4686
4700
  try {
4687
- // Step 1: Parse both versions (this validates JSON syntax)
4688
- const origParsed = JSON.parse(stripJsoncComments(original));
4689
- const modParsed = JSON.parse(stripJsoncComments(modified));
4701
+ // Step 1: Parse both versions as JSONC (comments + trailing commas allowed).
4702
+ const origParsed = parseJsonc(original);
4703
+ const modParsed = parseJsonc(modified);
4690
4704
 
4691
4705
  // Step 2: Validate modified file has correct structure
4692
- const providers = modParsed?.providers;
4693
- if (!providers || typeof providers !== 'object') {
4706
+ const providers = asRecord(asRecord(modParsed)?.providers);
4707
+ if (!providers) {
4694
4708
  return "Modified file: providers object missing or invalid";
4695
4709
  }
4696
- const provider = providers[providerLabel];
4697
- if (!provider || typeof provider !== 'object') {
4710
+ const provider = asRecord(providers[providerLabel]);
4711
+ if (!provider) {
4698
4712
  return `Modified file: provider "${providerLabel}" not found`;
4699
4713
  }
4700
4714
 
@@ -4712,6 +4726,18 @@ function selfCheckFix(
4712
4726
  if (!targetModel || typeof targetModel !== 'object') {
4713
4727
  return `Modified file: model "${modelId}" not found in provider`;
4714
4728
  }
4729
+
4730
+ // Locate the corresponding original provider/model objects. The structure
4731
+ // preservation check below may allow repaired compat values to differ, but
4732
+ // only on these exact target/provider compat objects — never on siblings.
4733
+ const origProviders = asRecord(asRecord(origParsed)?.providers);
4734
+ const origProvider = asRecord(origProviders?.[providerLabel]);
4735
+ const origModels = Array.isArray(origProvider?.models) ? origProvider.models : undefined;
4736
+ const origTargetModel = origModels?.find((m: unknown) => asRecord(m)?.id === modelId);
4737
+ const origTargetModelRecord = asRecord(origTargetModel);
4738
+ if (!origProvider || !origTargetModelRecord) {
4739
+ return `Original file: provider/model "${providerLabel}/${modelId}" not found`;
4740
+ }
4715
4741
 
4716
4742
  // Step 5: Compute the EFFECTIVE merged compat (provider-level + model-level),
4717
4743
  // mirroring Pi's mergeCompat behavior (model wins on conflicts). The fix may
@@ -4754,15 +4780,23 @@ function selfCheckFix(
4754
4780
  for (const key of Object.keys(origObj)) {
4755
4781
  if (!(key in modObj)) return false;
4756
4782
  if (key === 'compat') {
4757
- // For compat, allow extra keys in modified (the inserted ones)
4783
+ // For compat, allow extra keys in modified (the inserted ones).
4784
+ // Use recursive isSubset so nested objects (e.g. { deep: true })
4785
+ // are compared by content, not reference.
4758
4786
  if (typeof origObj[key] !== 'object' || typeof modObj[key] !== 'object') {
4759
4787
  if (origObj[key] !== modObj[key]) return false;
4760
4788
  } else {
4761
- // Check all original compat keys are present and equal
4762
4789
  const origCompat = origObj[key] as Record<string, unknown>;
4763
4790
  const modCompat = modObj[key] as Record<string, unknown>;
4791
+ const mayRepairThisCompat = origObj === origProvider || origObj === origTargetModelRecord;
4764
4792
  for (const ck of Object.keys(origCompat)) {
4765
- if (origCompat[ck] !== modCompat[ck]) return false;
4793
+ if (!(ck in modCompat)) return false;
4794
+ // The fix may repair an existing wrong compat value (for example
4795
+ // thinkingFormat: "legacy" -> "deepseek"), but only on the
4796
+ // target provider/model compat objects. Sibling compat blocks must
4797
+ // remain structure-equivalent.
4798
+ if (mayRepairThisCompat && Object.prototype.hasOwnProperty.call(compatKeys, ck)) continue;
4799
+ if (!isSubset(origCompat[ck], modCompat[ck], `${path}.${ck}`)) return false;
4766
4800
  }
4767
4801
  }
4768
4802
  } else if (!isSubset(origObj[key], modObj[key], `${path}.${key}`)) {
@@ -4781,11 +4815,17 @@ function selfCheckFix(
4781
4815
  return "Modified file: content is shorter than original (possible truncation)";
4782
4816
  }
4783
4817
 
4784
- // Step 9: Validate no syntax issues by checking brackets balance
4785
- const openBraces = (modified.match(/{/g) || []).length;
4786
- const closeBraces = (modified.match(/}/g) || []).length;
4787
- if (openBraces !== closeBraces) {
4788
- return `Modified file: bracket mismatch (${openBraces} open, ${closeBraces} close)`;
4818
+ // Step 9: Validate root bracket integrity with the same string/comment-aware
4819
+ // scanner used for edits. Do not count raw braces: comments or strings may
4820
+ // legitimately contain unmatched `{` / `}` bytes.
4821
+ const modifiedClean = stripJsoncComments(modified);
4822
+ const rootStart = skipJsonWhitespace(modifiedClean, 0);
4823
+ const rootEnd = findMatchingBracket(modifiedClean, rootStart);
4824
+ if (rootEnd === undefined) {
4825
+ return "Modified file: root bracket mismatch";
4826
+ }
4827
+ if (skipJsonWhitespace(modifiedClean, rootEnd + 1) !== modifiedClean.length) {
4828
+ return "Modified file: trailing non-whitespace content after root object";
4789
4829
  }
4790
4830
 
4791
4831
  return null;
@@ -4801,8 +4841,7 @@ function selfCheckFix(
4801
4841
  function formatCompatKeysForInsertion(compatKeys: Record<string, unknown>): string {
4802
4842
  return Object.entries(compatKeys)
4803
4843
  .map(([k, v]) => {
4804
- const val = typeof v === 'string' ? `"${v}"` : String(v);
4805
- return ` ${JSON.stringify(k)}: ${val}`;
4844
+ return ` ${JSON.stringify(k)}: ${JSON.stringify(v)}`;
4806
4845
  })
4807
4846
  .join(',\n');
4808
4847
  }
@@ -4852,6 +4891,7 @@ export const __internals_for_tests = {
4852
4891
  isOpenAIFamilyToken,
4853
4892
  describeMissingOpenAIFamilyProxyCompat,
4854
4893
  describeMissingOpenAICompatibleProxyCompat,
4894
+ describeOptionalOpenAICompatibleProxyCompat,
4855
4895
  describeMissingDeepSeekCompat,
4856
4896
  isDeepSeekCompatCheckApplicable,
4857
4897
  describeMissingCacheCompatForModel,
@@ -5033,10 +5073,14 @@ export const __internals_for_tests = {
5033
5073
  // JSONC surgical edit helpers
5034
5074
  MODELS_JSON_PATH,
5035
5075
  stripJsoncComments,
5076
+ stripJsoncTrailingCommas,
5077
+ parseJsonc,
5036
5078
  locateModelInJsonc,
5037
5079
  composeFixInsertion,
5038
5080
  selfCheckFix,
5039
5081
  decideFixPlacement,
5082
+ chooseFixPlacement,
5083
+ findExistingCompatKeysInJsonc,
5040
5084
  deepEqualIgnoringKeys,
5041
5085
  formatCompatKeysForInsertion,
5042
5086
  backupTimestamp,
@@ -5400,8 +5444,11 @@ export default function (pi: ExtensionAPI) {
5400
5444
  // changes and day rollovers. Redundant setStatus calls are blocked by the
5401
5445
  // `lastStatusText` early return above.
5402
5446
  if (runtimeOptimizerEnabled && statusText !== undefined && model) {
5403
- const compatMissing = describeMissingCacheCompatForModel(model);
5404
- if (compatMissing.length > 0) {
5447
+ // Only show ⚠️ compat when there are safe-fixable missing compat keys.
5448
+ // Optional/advisory-only flags (e.g. supportsLongCacheRetention on generic
5449
+ // OpenAI-compatible proxies) do NOT trigger the marker — the doctor/compat
5450
+ // commands still mention them as optional guidance.
5451
+ if (buildFixSuggestion(model) !== undefined) {
5405
5452
  statusText = statusText + " ⚠️ compat";
5406
5453
  }
5407
5454
  }
@@ -5755,7 +5802,7 @@ export default function (pi: ExtensionAPI) {
5755
5802
 
5756
5803
  // Compose the modified text — auto-detect the best placement level:
5757
5804
  // provider level (channel-wide) when safe for all sibling models, else model level.
5758
- const decision = decideFixPlacement(suggestion.compatKeys, suggestion.providerLabel, location.allModelIds);
5805
+ const decision = chooseFixPlacement(originalText, location, suggestion.compatKeys, suggestion.providerLabel);
5759
5806
  const modifiedText = composeFixInsertion(originalText, location, suggestion.compatKeys, decision.placement);
5760
5807
 
5761
5808
  // Self-check
@@ -5769,10 +5816,9 @@ export default function (pi: ExtensionAPI) {
5769
5816
  return;
5770
5817
  }
5771
5818
 
5772
- // Build preview snippet
5773
- const keysPreview = Object.entries(suggestion.compatKeys)
5774
- .map(([k, v]) => ` ${k}: ${JSON.stringify(v)}`)
5775
- .join("\n");
5819
+ // Build preview snippet as copyable JSON (the surgical editor will
5820
+ // insert or repair these exact compat key/value pairs).
5821
+ const keysPreview = JSON.stringify(suggestion.compatKeys, null, 2);
5776
5822
  const targetHasCompat = decision.placement === "provider" ? location.providerCompatBrace >= 0 : location.compatObjectBrace >= 0;
5777
5823
  const placementDesc = targetHasCompat ? `existing "compat" object` : `new "compat" object`;
5778
5824
  const locationDesc = decision.placement === "provider"
@@ -5791,7 +5837,7 @@ export default function (pi: ExtensionAPI) {
5791
5837
  ``,
5792
5838
  `Location: ${locationDesc}`,
5793
5839
  `Placement: ${decision.placement} level — ${decision.reason}`,
5794
- `Keys to insert:`,
5840
+ `Compat JSON to write:`,
5795
5841
  keysPreview,
5796
5842
  ``,
5797
5843
  `⚠️ Risk notice:`,
@@ -5948,7 +5994,7 @@ export default function (pi: ExtensionAPI) {
5948
5994
  return;
5949
5995
  }
5950
5996
 
5951
- const menuDecision = decideFixPlacement(suggestion.compatKeys, suggestion.providerLabel, location.allModelIds);
5997
+ const menuDecision = chooseFixPlacement(originalText, location, suggestion.compatKeys, suggestion.providerLabel);
5952
5998
  const modifiedText = composeFixInsertion(originalText, location, suggestion.compatKeys, menuDecision.placement);
5953
5999
  const checkError = selfCheckFix(originalText, modifiedText, suggestion.providerLabel, suggestion.modelId, suggestion.compatKeys);
5954
6000
  if (checkError !== null) {
@@ -5956,9 +6002,7 @@ export default function (pi: ExtensionAPI) {
5956
6002
  return;
5957
6003
  }
5958
6004
 
5959
- const keysPreview = Object.entries(suggestion.compatKeys)
5960
- .map(([k, v]) => ` ${k}: ${JSON.stringify(v)}`)
5961
- .join("\n");
6005
+ const keysPreview = JSON.stringify(suggestion.compatKeys, null, 2);
5962
6006
  const ts = backupTimestamp();
5963
6007
  const backupPath = `${MODELS_JSON_PATH}.backup-cache-optimizer-${ts}`;
5964
6008
 
@@ -5973,7 +6017,7 @@ export default function (pi: ExtensionAPI) {
5973
6017
  `📝 Preview of changes to ${getModelsJsonDisplayPath()}:`,
5974
6018
  `Location: ${menuLocationDesc}`,
5975
6019
  `Placement: ${menuDecision.placement} level — ${menuDecision.reason}`,
5976
- `Keys to insert:`,
6020
+ `Compat JSON to write:`,
5977
6021
  keysPreview,
5978
6022
  ``,
5979
6023
  `⚠️ Risk notice:`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-cache-optimizer",
3
- "version": "2.6.4",
3
+ "version": "2.6.5",
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",