pi-cache-optimizer 2.6.3 → 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/index.ts CHANGED
@@ -162,9 +162,16 @@ type PersistedCacheStatsV2 = {
162
162
  };
163
163
 
164
164
  /** Per-model-key scoped state. Used in memory and for v3 persistence. */
165
+ type PersistedRoutedModelRef = {
166
+ provider: string;
167
+ id: string;
168
+ name?: string;
169
+ };
170
+
165
171
  type CacheStatsState = {
166
172
  statsByModel: Record<string, CacheStats>;
167
173
  legacyFamily: Partial<Record<CacheProviderId, CacheStats>>;
174
+ lastRoutedModelBySession?: Record<string, PersistedRoutedModelRef>;
168
175
  };
169
176
 
170
177
  type PersistedCacheStatsV3 = {
@@ -186,6 +193,13 @@ type PersistedCacheStatsV4 = {
186
193
  legacyFamily: Partial<Record<CacheProviderId, CacheStats>>;
187
194
  };
188
195
 
196
+ type PersistedCacheStatsV5 = {
197
+ version: 5;
198
+ sessions: Record<string, Record<string, CacheStats>>;
199
+ legacyFamily: Partial<Record<CacheProviderId, CacheStats>>;
200
+ lastRoutedModelBySession?: Record<string, PersistedRoutedModelRef>;
201
+ };
202
+
189
203
  type UsageSnapshot = {
190
204
  cacheRead: number;
191
205
  cacheWrite: number;
@@ -1598,7 +1612,7 @@ function hasEffectivePromptCacheKey(record: UnknownRecord): boolean {
1598
1612
  return isNonEmptyString(record.prompt_cache_key) || isNonEmptyString(record.promptCacheKey);
1599
1613
  }
1600
1614
 
1601
- function isNonEmptyString(value: unknown): boolean {
1615
+ function isNonEmptyString(value: unknown): value is string {
1602
1616
  return typeof value === "string" && value.trim().length > 0;
1603
1617
  }
1604
1618
 
@@ -1623,9 +1637,6 @@ function describeMissingOpenAIFamilyProxyCompat(model: PiModel): string[] {
1623
1637
  if (!isOpenAICompatibleProxyApi(model.api)) return missing;
1624
1638
  if (isOfficialOpenAIBaseUrl(model)) return missing;
1625
1639
 
1626
- if (compat.supportsLongCacheRetention !== true) {
1627
- missing.push("supportsLongCacheRetention");
1628
- }
1629
1640
  if (compat.sendSessionAffinityHeaders !== true) {
1630
1641
  missing.push("sendSessionAffinityHeaders");
1631
1642
  }
@@ -1646,9 +1657,6 @@ function describeMissingOpenAICompatibleProxyCompat(model: PiModel): string[] {
1646
1657
  if (!isOpenAICompatibleProxyApi(model.api)) return missing;
1647
1658
  if (isOfficialOpenAIBaseUrl(model)) return missing;
1648
1659
 
1649
- if (compat.supportsLongCacheRetention !== true) {
1650
- missing.push("supportsLongCacheRetention");
1651
- }
1652
1660
  if (compat.sendSessionAffinityHeaders !== true) {
1653
1661
  missing.push("sendSessionAffinityHeaders");
1654
1662
  }
@@ -1656,6 +1664,20 @@ function describeMissingOpenAICompatibleProxyCompat(model: PiModel): string[] {
1656
1664
  return missing;
1657
1665
  }
1658
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
+
1659
1681
  function buildSafeOpenAIProxyCompatSuggestion(missing: string[]): Record<string, boolean> {
1660
1682
  const suggestion: Record<string, boolean> = {};
1661
1683
  if (missing.includes("sendSessionAffinityHeaders")) {
@@ -1746,21 +1768,22 @@ function appendOpenAIProxyCompatAdviceLines(lines: string[], missing: string[],
1746
1768
  lines.push("Safe default suggestion:");
1747
1769
  }
1748
1770
  lines.push(JSON.stringify(suggestion, null, 2));
1749
- } else if (missing.includes("supportsLongCacheRetention")) {
1750
- lines.push("No safe automatic JSON change is recommended for `supportsLongCacheRetention`.");
1751
1771
  }
1752
1772
 
1753
1773
  if (missing.includes("sendSessionAffinityHeaders")) {
1754
1774
  lines.push("- sendSessionAffinityHeaders: recommended for third-party proxies when supported; it helps keep one Pi session on the same upstream/backend.");
1755
1775
  }
1756
- if (missing.includes("supportsLongCacheRetention")) {
1757
- lines.push("- supportsLongCacheRetention: optional. Enable only after your endpoint/proxy explicitly supports OpenAI long prompt cache retention.");
1758
- lines.push(`- ${getPromptCacheRetentionUnsupportedHint()}`);
1759
- }
1760
-
1761
1776
  appendCredentialSafeProviderGuidance(lines, options, suggestion);
1762
1777
  }
1763
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
+
1764
1787
  /**
1765
1788
  * Build the warning text displayed to users when an OpenAI-family third-party
1766
1789
  * proxy is missing one or more cache/session-affinity compat flags.
@@ -3105,12 +3128,58 @@ function parseCacheStats(value: unknown): CacheStats | undefined {
3105
3128
  };
3106
3129
  }
3107
3130
 
3131
+ function parsePersistedRoutedModelRef(value: unknown): PersistedRoutedModelRef | undefined {
3132
+ const record = asRecord(value);
3133
+ const provider = record?.provider;
3134
+ const id = record?.id;
3135
+ const name = record?.name;
3136
+ if (!isNonEmptyString(provider) || !isNonEmptyString(id)) return undefined;
3137
+
3138
+ return {
3139
+ provider: provider.trim(),
3140
+ id: id.trim(),
3141
+ name: isNonEmptyString(name) ? name.trim() : id.trim(),
3142
+ };
3143
+ }
3144
+
3145
+ function routedModelRefToPiModel(ref: PersistedRoutedModelRef): PiModel {
3146
+ return {
3147
+ id: ref.id,
3148
+ name: ref.name ?? ref.id,
3149
+ provider: ref.provider,
3150
+ api: "",
3151
+ baseUrl: "",
3152
+ reasoning: false,
3153
+ input: ["text"],
3154
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
3155
+ contextWindow: 0,
3156
+ maxTokens: 0,
3157
+ } as PiModel;
3158
+ }
3159
+
3160
+ function buildExactRouterStatusEntry(
3161
+ sessionHash: string | undefined,
3162
+ statsByModel: Record<string, CacheStats>,
3163
+ lastRoutedModel: PersistedRoutedModelRef | undefined,
3164
+ ): { adapter: CacheProviderAdapter; stats: CacheStats } | undefined {
3165
+ if (!sessionHash || !lastRoutedModel) return undefined;
3166
+
3167
+ const model = routedModelRefToPiModel(lastRoutedModel);
3168
+ const adapter = selectAdapterForModel(model);
3169
+ if (!adapter) return undefined;
3170
+
3171
+ const key = makeSessionModelKey(sessionHash, lastRoutedModel.provider, lastRoutedModel.id);
3172
+ return { adapter, stats: statsByModel[key] ?? emptyCacheStats() };
3173
+ }
3174
+
3108
3175
  function parsePersistedCacheStats(value: unknown): CacheStatsState | undefined {
3109
3176
  const record = asRecord(value);
3110
3177
  if (!record) return undefined;
3111
3178
 
3112
- // version 4: session-scoped stats + legacy family fallback
3113
- if (record.version === 4) {
3179
+ // version 4/5: session-scoped stats + legacy family fallback.
3180
+ // v5 additionally persists the last actual routed model per session so
3181
+ // router/auto can restore the exact upstream footer after /reload.
3182
+ if (record.version === 4 || record.version === 5) {
3114
3183
  const legacyFamily: Partial<Record<CacheProviderId, CacheStats>> = {};
3115
3184
  const rawFamily = asRecord(record.legacyFamily);
3116
3185
  if (rawFamily) {
@@ -3138,10 +3207,19 @@ function parsePersistedCacheStats(value: unknown): CacheStatsState | undefined {
3138
3207
  }
3139
3208
  }
3140
3209
 
3141
- return { statsByModel, legacyFamily };
3210
+ const lastRoutedModelBySession: Record<string, PersistedRoutedModelRef> = {};
3211
+ const rawLastRoutedModels = asRecord(record.lastRoutedModelBySession);
3212
+ if (rawLastRoutedModels) {
3213
+ for (const [sessionHash, rawModel] of Object.entries(rawLastRoutedModels)) {
3214
+ const parsed = parsePersistedRoutedModelRef(rawModel);
3215
+ if (parsed) lastRoutedModelBySession[sessionHash] = parsed;
3216
+ }
3217
+ }
3218
+
3219
+ return { statsByModel, legacyFamily, lastRoutedModelBySession };
3142
3220
  }
3143
3221
 
3144
- // version 3: migrate to v4 semantics by wrapping statsByModel into sessions
3222
+ // version 3: migrate to v4/v5 semantics by wrapping statsByModel into sessions
3145
3223
  if (record.version === 3) {
3146
3224
  const statsByModel: Record<string, CacheStats> = {};
3147
3225
  const rawModelMap = asRecord(record.statsByModel);
@@ -3335,11 +3413,38 @@ function mergeCacheSessions(
3335
3413
  return sessions;
3336
3414
  }
3337
3415
 
3416
+ function mergeLastRoutedModels(
3417
+ existingLastRoutedModelBySession: Record<string, PersistedRoutedModelRef>,
3418
+ state: CacheStatsState,
3419
+ currentSessionHash?: string,
3420
+ ): Record<string, PersistedRoutedModelRef> {
3421
+ const merged: Record<string, PersistedRoutedModelRef> = { ...existingLastRoutedModelBySession };
3422
+ const incoming = state.lastRoutedModelBySession ?? {};
3423
+
3424
+ if (currentSessionHash !== undefined) {
3425
+ const current = incoming[currentSessionHash];
3426
+ if (current) {
3427
+ merged[currentSessionHash] = current;
3428
+ } else {
3429
+ // Explicit deletion: when incoming state has no entry for current session,
3430
+ // remove any existing stale entry to reflect intentional reset.
3431
+ delete merged[currentSessionHash];
3432
+ }
3433
+ return merged;
3434
+ }
3435
+
3436
+ for (const [sessionHash, ref] of Object.entries(incoming)) {
3437
+ merged[sessionHash] = ref;
3438
+ }
3439
+ return merged;
3440
+ }
3441
+
3338
3442
  async function writePersistedCacheStats(state: CacheStatsState, currentSessionHash?: string): Promise<void> {
3339
3443
  await mkdir(STATE_DIR, { recursive: true });
3340
3444
 
3341
3445
  // Read existing file to preserve other sessions' data.
3342
3446
  let existingSessions: Record<string, Record<string, CacheStats>> = {};
3447
+ let existingLastRoutedModelBySession: Record<string, PersistedRoutedModelRef> = {};
3343
3448
  try {
3344
3449
  const raw = await readFile(STATE_FILE_PATH, "utf8");
3345
3450
  const parsed = parsePersistedCacheStats(JSON.parse(raw));
@@ -3355,17 +3460,24 @@ async function writePersistedCacheStats(state: CacheStatsState, currentSessionHa
3355
3460
  existingSessions[hash][modelKey] = stats;
3356
3461
  }
3357
3462
  }
3463
+ existingLastRoutedModelBySession = { ...(parsed.lastRoutedModelBySession ?? {}) };
3358
3464
  }
3359
3465
  } catch {
3360
3466
  // Ignore read errors (file may not exist yet).
3361
3467
  }
3362
3468
 
3363
3469
  const sessions = mergeCacheSessions(existingSessions, state, currentSessionHash);
3470
+ const lastRoutedModelBySession = mergeLastRoutedModels(
3471
+ existingLastRoutedModelBySession,
3472
+ state,
3473
+ currentSessionHash,
3474
+ );
3364
3475
 
3365
- const payload: PersistedCacheStatsV4 = {
3366
- version: 4,
3476
+ const payload: PersistedCacheStatsV5 = {
3477
+ version: 5,
3367
3478
  sessions,
3368
3479
  legacyFamily: state.legacyFamily,
3480
+ ...(Object.keys(lastRoutedModelBySession).length > 0 ? { lastRoutedModelBySession } : {}),
3369
3481
  };
3370
3482
  const tempPath = `${STATE_FILE_PATH}.${process.pid}.${Date.now()}.tmp`;
3371
3483
 
@@ -3430,8 +3542,9 @@ function describeRouterChannelDiagnostics(model: PiModel): string[] {
3430
3542
  provider.includes("openrouter")
3431
3543
  ) {
3432
3544
  const compat = getCompat(model);
3433
- const hasOnly = !!(compat as Record<string, unknown>)["openRouterRouting"]?.only;
3434
- 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;
3435
3548
 
3436
3549
  notes.push(
3437
3550
  "🔀 Router/channel: OpenRouter detected. OpenRouter is a multi-provider router; " +
@@ -3466,8 +3579,9 @@ function describeRouterChannelDiagnostics(model: PiModel): string[] {
3466
3579
  provider.includes("vercel-ai-gateway")
3467
3580
  ) {
3468
3581
  const compat = getCompat(model);
3469
- const hasOnly = !!(compat as Record<string, unknown>)["vercelGatewayRouting"]?.only;
3470
- 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;
3471
3585
 
3472
3586
  notes.push(
3473
3587
  "🔀 Router/channel: Vercel AI Gateway detected. The gateway may route to different " +
@@ -3594,8 +3708,21 @@ function buildDoctorDiagnosis(model: PiModel, options: { promptCacheRetention400
3594
3708
  const adaptiveThinkingApplicable = isAdaptiveThinkingCompatApplicable(model);
3595
3709
  const deepSeekCompatApplicable = isDeepSeekCompatCheckApplicable(model);
3596
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
+
3597
3725
  if (missing.length > 0) {
3598
- lines.push(`⚠️ Missing compat flags: ${missing.join(", ")}`);
3599
3726
  const key = modelKey(model);
3600
3727
  const slashIdx = key.indexOf("/");
3601
3728
  const providerLabel = slashIdx > 0 ? key.slice(0, slashIdx) : key;
@@ -3607,9 +3734,11 @@ function buildDoctorDiagnosis(model: PiModel, options: { promptCacheRetention400
3607
3734
  appendDeepSeekCompatAdviceLines(lines, missing, { providerLabel, modelId: model.id });
3608
3735
  } else {
3609
3736
  appendOpenAIProxyCompatAdviceLines(lines, missing, { providerLabel, modelId: model.id });
3737
+ appendOptionalOpenAIProxyCompatAdviceLines(lines, optionalOpenAIProxyCompat);
3610
3738
  }
3611
3739
  } else if (adaptiveThinkingApplicable || deepSeekCompatApplicable || isCompatCheckApplicable(model)) {
3612
3740
  lines.push("✅ Compat fully configured.");
3741
+ appendOptionalOpenAIProxyCompatAdviceLines(lines, optionalOpenAIProxyCompat);
3613
3742
  } else {
3614
3743
  lines.push(...getCompatCheckNotApplicableLines(model));
3615
3744
  }
@@ -3667,7 +3796,8 @@ function buildLowHitDiagnosis(
3667
3796
  const lines: string[] = [];
3668
3797
 
3669
3798
  // 1. Missing compat flags (adapter-aware: DeepSeek has extra reasoning compat)
3670
- const missingCompat = describeMissingCacheCompatForModel(model);
3799
+ const fixSugLHD = buildFixSuggestion(model);
3800
+ const safeFixableMissingLHD = fixSugLHD ? Object.keys(fixSugLHD.compatKeys) : [];
3671
3801
 
3672
3802
  // 2. Router/channel risk (reuse existing check)
3673
3803
  const routerNotes = describeRouterChannelDiagnostics(model);
@@ -3685,7 +3815,7 @@ function buildLowHitDiagnosis(
3685
3815
  // 5. Today's overall trend from persisted stats
3686
3816
  const todayStats = stats ?? emptyCacheStats();
3687
3817
 
3688
- const hasMissingCompat = missingCompat.length > 0;
3818
+ const hasMissingCompat = safeFixableMissingLHD.length > 0;
3689
3819
  const hasRouterRisk = routerNotes.length > 0;
3690
3820
  const hasUsageMissing = missingUsageSamples > 0;
3691
3821
 
@@ -3714,7 +3844,7 @@ function buildLowHitDiagnosis(
3714
3844
 
3715
3845
  // Priority 1: missing compat flags
3716
3846
  if (hasMissingCompat) {
3717
- lines.push(`⚠️ Missing compat flags: ${missingCompat.join(", ")}`);
3847
+ lines.push(`⚠️ Missing compat flags: ${safeFixableMissingLHD.join(", ")}`);
3718
3848
  lines.push(" These flags enable prompt caching and session-affinity routing.");
3719
3849
  lines.push(" Run /cache-optimizer compat for edit instructions.");
3720
3850
  }
@@ -3767,11 +3897,17 @@ function buildLowHitDiagnosis(
3767
3897
 
3768
3898
  function buildCompatDiagnosis(model: PiModel): string | undefined {
3769
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));
3770
3903
  const adaptiveThinkingApplicable = isAdaptiveThinkingCompatApplicable(model);
3771
3904
  const deepSeekCompatApplicable = isDeepSeekCompatCheckApplicable(model);
3905
+ const optionalOpenAIProxyCompat = (!adaptiveThinkingApplicable && !deepSeekCompatApplicable)
3906
+ ? describeOptionalOpenAICompatibleProxyCompat(model)
3907
+ : [];
3772
3908
  const routerNotes = describeRouterChannelDiagnostics(model);
3773
3909
 
3774
- if (missing.length === 0 && routerNotes.length === 0) return undefined;
3910
+ if (missing.length === 0 && routerNotes.length === 0 && optionalOpenAIProxyCompat.length === 0) return undefined;
3775
3911
 
3776
3912
  const key = modelKey(model);
3777
3913
  const lines: string[] = [];
@@ -3781,7 +3917,12 @@ function buildCompatDiagnosis(model: PiModel): string | undefined {
3781
3917
  const providerLabel = slashIdx > 0 ? key.slice(0, slashIdx) : key;
3782
3918
  const modelsJsonPath = getModelsJsonDisplayPath();
3783
3919
  lines.push(`Active model: ${key}`);
3784
- 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
+ }
3785
3926
  lines.push("");
3786
3927
  lines.push(`Edit ${modelsJsonPath} -> providers["${providerLabel}"] -> compat`);
3787
3928
  lines.push(`(at the same level as baseUrl/api/apiKey/models).`);
@@ -3791,16 +3932,18 @@ function buildCompatDiagnosis(model: PiModel): string | undefined {
3791
3932
  appendDeepSeekCompatAdviceLines(lines, missing, { providerLabel, modelId: model.id });
3792
3933
  } else {
3793
3934
  appendOpenAIProxyCompatAdviceLines(lines, missing, { providerLabel, modelId: model.id });
3935
+ appendOptionalOpenAIProxyCompatAdviceLines(lines, optionalOpenAIProxyCompat);
3794
3936
  }
3795
3937
  }
3796
3938
 
3797
- // When compat is fully configured but router notes exist, prefix the status.
3798
- 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) {
3799
3941
  if (adaptiveThinkingApplicable || deepSeekCompatApplicable || isCompatCheckApplicable(model)) {
3800
3942
  lines.push("✅ Compat fully configured.");
3801
3943
  if (isPromptCacheRetention400Applicable(model)) {
3802
3944
  lines.push(getPromptCacheRetentionUnsupportedHint());
3803
3945
  }
3946
+ appendOptionalOpenAIProxyCompatAdviceLines(lines, optionalOpenAIProxyCompat);
3804
3947
  } else {
3805
3948
  lines.push(...getCompatCheckNotApplicableLines(model));
3806
3949
  }
@@ -4031,8 +4174,10 @@ function stripJsoncComments(text: string): string {
4031
4174
  let i = 0;
4032
4175
  while (i < text.length) {
4033
4176
  const ch = text[i];
4177
+
4034
4178
  if (ch === '"') {
4035
- // 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.
4036
4181
  out.push(ch);
4037
4182
  i++;
4038
4183
  while (i < text.length) {
@@ -4046,39 +4191,74 @@ function stripJsoncComments(text: string): string {
4046
4191
  break;
4047
4192
  }
4048
4193
  }
4049
- } else if (ch === '/' && i + 1 < text.length && text[i + 1] === '/') {
4050
- // Line comment — replace with spaces until newline
4051
- out.push(' ');
4052
- 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;
4053
4202
  while (i < text.length && text[i] !== '\n') {
4054
4203
  out.push(' ');
4055
4204
  i++;
4056
4205
  }
4057
- } else if (ch === '/' && i + 1 < text.length && text[i + 1] === '*') {
4058
- // Block comment — replace with spaces (preserve newlines)
4059
- out.push(' ');
4060
- i++;
4061
- while (i + 1 < text.length) {
4062
- if (text[i] === '*' && text[i + 1] === '/') {
4063
- 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(' ', ' ');
4064
4217
  i += 2;
4065
4218
  break;
4066
4219
  }
4067
- if (text[i] === '\n') {
4068
- out.push('\n');
4069
- } else {
4070
- out.push(' ');
4071
- }
4220
+ out.push(text[i] === '\n' ? '\n' : ' ');
4072
4221
  i++;
4073
4222
  }
4074
- } else {
4075
- out.push(ch);
4076
- i++;
4223
+ continue;
4077
4224
  }
4225
+
4226
+ out.push(ch);
4227
+ i++;
4078
4228
  }
4079
4229
  return out.join('');
4080
4230
  }
4081
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
+
4082
4262
  /**
4083
4263
  * JSONC scanner: locate the provider block and model entry in models.json text.
4084
4264
  * Returns the byte offsets for surgical insertion, or undefined if ambiguous.
@@ -4122,153 +4302,51 @@ function locateModelInJsonc(
4122
4302
  // Clean text of comments first for reliable structural scanning
4123
4303
  const clean = stripJsoncComments(text);
4124
4304
 
4125
- // Strategy: find `"providers"` key in the root object, then find the
4126
- // provider key under it, then the `"models"` array, then the array
4127
- // element whose `"id"` matches. We map via the stripped text (comment
4128
- // removal replaces comment chars with spaces, preserving offsets).
4129
-
4130
- const pos = clean.indexOf('"providers"');
4131
- if (pos < 0) return undefined;
4132
-
4133
- // Scan from `"providers"` to find the `{` of the provider block
4134
- let cur = pos + '"providers"'.length;
4135
- // Skip `:`, whitespace, etc.
4136
- while (cur < clean.length && clean[cur] !== '{') cur++;
4137
- if (cur >= clean.length) return undefined;
4138
- cur++; // Skip `{`
4139
-
4140
- // Now scan key-value pairs in the providers object to find the matching providerLabel
4141
- const providerLabelJson = JSON.stringify(providerLabel);
4142
- let providerBrace = -1;
4143
- let providerEndBrace = -1;
4144
-
4145
- while (cur < clean.length) {
4146
- // Skip whitespace/comments
4147
- while (cur < clean.length && (clean[cur] === ' ' || clean[cur] === '\n' || clean[cur] === '\r' || clean[cur] === '\t')) cur++;
4148
- if (cur >= clean.length) break;
4149
- if (clean[cur] === '}') break; // End of providers
4150
-
4151
- // Try to read a string key
4152
- if (clean[cur] !== '"') { cur++; continue; }
4153
- const keyEnd = clean.indexOf('"', cur + 1);
4154
- if (keyEnd < 0) return undefined;
4155
- const key = clean.slice(cur + 1, keyEnd);
4156
- cur = keyEnd + 1;
4157
-
4158
- // Skip `:`
4159
- while (cur < clean.length && clean[cur] !== ':') cur++;
4160
- if (cur >= clean.length) return undefined;
4161
- cur++; // Skip `:`
4162
- while (cur < clean.length && (clean[cur] === ' ' || clean[cur] === '\n' || clean[cur] === '\r' || clean[cur] === '\t')) cur++;
4163
-
4164
- if (key === providerLabel) {
4165
- // Found — expect `{` starting the provider object
4166
- if (clean[cur] !== '{') return undefined;
4167
- providerBrace = cur;
4168
- // Find matching closing `}` for the provider object (track depth)
4169
- let depth = 1;
4170
- let scan = cur + 1;
4171
- while (scan < clean.length && depth > 0) {
4172
- if (clean[scan] === '{') depth++;
4173
- else if (clean[scan] === '}') depth--;
4174
- if (depth > 0) scan++;
4175
- }
4176
- providerEndBrace = scan;
4177
- break;
4178
- }
4179
-
4180
- // Skip the value
4181
- if (clean[cur] === '{') {
4182
- let depth = 1;
4183
- cur++;
4184
- while (cur < clean.length && depth > 0) {
4185
- if (clean[cur] === '{') depth++;
4186
- else if (clean[cur] === '}') depth--;
4187
- cur++;
4188
- }
4189
- } else if (clean[cur] === '[') {
4190
- let depth = 1;
4191
- cur++;
4192
- while (cur < clean.length && depth > 0) {
4193
- if (clean[cur] === '[') depth++;
4194
- else if (clean[cur] === ']') depth--;
4195
- cur++;
4196
- }
4197
- } else if (clean[cur] === '"') {
4198
- const strEnd = clean.indexOf('"', cur + 1);
4199
- if (strEnd < 0) return undefined;
4200
- cur = strEnd + 1;
4201
- } else {
4202
- // Number, boolean, etc.
4203
- while (cur < clean.length && clean[cur] !== ',' && clean[cur] !== '}' && clean[cur] !== '\n') cur++;
4204
- }
4205
- // Skip comma
4206
- if (cur < clean.length && clean[cur] === ',') cur++;
4207
- }
4208
-
4209
- if (providerBrace < 0 || providerEndBrace < 0) return undefined;
4210
-
4211
- // Scan provider object at depth 1 for a provider-level "compat" object.
4212
- // Depth-aware + string-aware so nested model compat objects are not confused
4213
- // 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.
4214
4329
  let providerCompatBrace = -1;
4215
4330
  let providerCompatEnd = -1;
4216
- {
4217
- let pScan = providerBrace + 1;
4218
- let pDepth = 1;
4219
- while (pScan < providerEndBrace && pDepth > 0) {
4220
- const ch = clean[pScan];
4221
- if (ch === '"') {
4222
- // Read the string (key or value) fully
4223
- const strEnd = clean.indexOf('"', pScan + 1);
4224
- if (strEnd < 0) break;
4225
- const str = clean.slice(pScan + 1, strEnd);
4226
- if (pDepth === 1 && str === 'compat') {
4227
- // Confirm it's a key: next non-ws char must be ':'
4228
- let after = strEnd + 1;
4229
- while (after < providerEndBrace && (clean[after] === ' ' || clean[after] === '\n' || clean[after] === '\r' || clean[after] === '\t')) after++;
4230
- if (clean[after] === ':') {
4231
- after++;
4232
- while (after < providerEndBrace && (clean[after] === ' ' || clean[after] === '\n' || clean[after] === '\r' || clean[after] === '\t')) after++;
4233
- if (clean[after] === '{') {
4234
- providerCompatBrace = after;
4235
- let d = 1;
4236
- let s = after + 1;
4237
- while (s < clean.length && d > 0) {
4238
- if (clean[s] === '"') {
4239
- const e = clean.indexOf('"', s + 1);
4240
- if (e < 0) break;
4241
- s = e + 1;
4242
- continue;
4243
- }
4244
- if (clean[s] === '{') d++;
4245
- else if (clean[s] === '}') d--;
4246
- if (d > 0) s++;
4247
- }
4248
- providerCompatEnd = s;
4249
- pScan = s + 1;
4250
- continue;
4251
- }
4252
- }
4253
- }
4254
- pScan = strEnd + 1;
4255
- 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;
4256
4339
  }
4257
- if (ch === '{' || ch === '[') pDepth++;
4258
- else if (ch === '}' || ch === ']') pDepth--;
4259
- pScan++;
4260
4340
  }
4261
4341
  }
4262
4342
 
4263
- // Now find the `"models"` array within the provider
4264
- const providerContent = clean.slice(providerBrace + 1, providerEndBrace);
4265
- const modelsIdx = providerContent.indexOf('"models"');
4266
- if (modelsIdx < 0) return undefined;
4343
+ const modelsKey = findJsonObjectKey(clean, providerBrace, "models");
4344
+ if (!modelsKey || modelsKey.keyStart > providerEndBrace) return undefined;
4267
4345
 
4268
- // Find the `[` of the models array
4269
- let modelsScan = providerBrace + 1 + modelsIdx + '"models"'.length;
4270
- while (modelsScan < clean.length && clean[modelsScan] !== '[') modelsScan++;
4271
- 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;
4272
4350
  modelsScan++; // Skip `[`
4273
4351
 
4274
4352
  // Scan ALL array elements: collect every model id, and record the target's position
@@ -4279,83 +4357,52 @@ function locateModelInJsonc(
4279
4357
  let compatBrace = -1;
4280
4358
  let compatEndBrace = -1;
4281
4359
 
4282
- while (modelsScan < clean.length) {
4283
- // Skip whitespace/comma
4284
- while (modelsScan < clean.length && (clean[modelsScan] === ' ' || clean[modelsScan] === '\n' || clean[modelsScan] === '\r' || clean[modelsScan] === '\t' || clean[modelsScan] === ',')) modelsScan++;
4285
- if (modelsScan >= clean.length) break;
4286
- if (clean[modelsScan] === ']') break; // End of array
4287
-
4288
- 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;
4289
4368
 
4290
- // Found a model object `{`
4291
4369
  const elementBrace = modelsScan;
4370
+ const elementEnd = findMatchingBracket(clean, elementBrace);
4371
+ if (elementEnd === undefined || elementEnd > modelsEnd) return undefined;
4292
4372
 
4293
- // Find the matching closing `}` and extract this element's `"id"` at depth 1
4294
- let depth = 1;
4295
- let scan = modelsScan + 1;
4373
+ const idKey = findJsonObjectKey(clean, elementBrace, "id");
4296
4374
  let elementId: string | undefined;
4297
-
4298
- while (scan < clean.length && depth > 0) {
4299
- if (clean[scan] === '"') {
4300
- const strEnd = clean.indexOf('"', scan + 1);
4301
- if (strEnd < 0) break;
4302
- if (depth === 1 && elementId === undefined && clean.slice(scan, scan + 4) === '"id"') {
4303
- // Found "id" key — find the colon and the value
4304
- let afterKey = scan + 4;
4305
- while (afterKey < clean.length && clean[afterKey] !== ':') afterKey++;
4306
- if (afterKey < clean.length) {
4307
- afterKey++; // skip ':'
4308
- while (afterKey < clean.length && (clean[afterKey] === ' ' || clean[afterKey] === '\n' || clean[afterKey] === '\r' || clean[afterKey] === '\t')) afterKey++;
4309
- if (afterKey < clean.length && clean[afterKey] === '"') {
4310
- const idStart = afterKey + 1;
4311
- const idEnd = clean.indexOf('"', idStart);
4312
- if (idEnd > idStart) {
4313
- elementId = clean.slice(idStart, idEnd);
4314
- }
4315
- }
4316
- }
4317
- }
4318
- scan = strEnd + 1;
4319
- 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;
4320
4380
  }
4321
- if (clean[scan] === '{') depth++;
4322
- else if (clean[scan] === '}') depth--;
4323
- scan++;
4324
4381
  }
4325
4382
 
4326
- const elementEnd = scan - 1; // The `}` that closed this element
4327
-
4328
4383
  if (elementId !== undefined) {
4329
4384
  allModelIds.push(elementId);
4330
4385
  }
4331
4386
 
4332
4387
  if (elementId === modelId && modelBrace < 0) {
4333
- // This is the target model — record its position and find its compat
4334
4388
  modelBrace = elementBrace;
4335
4389
  modelEndBrace = elementEnd;
4336
- const modelContent = clean.slice(modelBrace + 1, modelEndBrace);
4337
- const compatIdx = modelContent.indexOf('"compat"');
4338
- if (compatIdx >= 0) {
4339
- compatKeyStartClean = modelBrace + 1 + compatIdx;
4340
- let compatScan = compatKeyStartClean + '"compat"'.length;
4341
- while (compatScan < clean.length && clean[compatScan] !== ':') compatScan++;
4342
- compatScan++;
4343
- while (compatScan < clean.length && (clean[compatScan] === ' ' || clean[compatScan] === '\n' || clean[compatScan] === '\r' || clean[compatScan] === '\t')) compatScan++;
4344
- if (compatScan < clean.length && clean[compatScan] === '{') {
4345
- compatBrace = compatScan;
4346
- let cdepth = 1;
4347
- let cscan = compatScan + 1;
4348
- while (cscan < clean.length && cdepth > 0) {
4349
- if (clean[cscan] === '{') cdepth++;
4350
- else if (clean[cscan] === '}') cdepth--;
4351
- 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;
4352
4400
  }
4353
- compatEndBrace = cscan;
4354
4401
  }
4355
4402
  }
4356
4403
  }
4357
4404
 
4358
- modelsScan = scan;
4405
+ modelsScan = elementEnd + 1;
4359
4406
  }
4360
4407
 
4361
4408
  if (modelBrace < 0 || modelEndBrace < 0) return undefined;
@@ -4496,6 +4543,48 @@ function decideFixPlacement(
4496
4543
  };
4497
4544
  }
4498
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
+
4499
4588
  function composeFixInsertion(
4500
4589
  original: string,
4501
4590
  location: ModelNodeLocation,
@@ -4507,14 +4596,12 @@ function composeFixInsertion(
4507
4596
  const targetCompatEnd = placement === "provider" ? location.providerCompatEnd : location.compatObjectEnd;
4508
4597
  const containerBrace = placement === "provider" ? location.providerObjectBrace : location.modelObjectBrace;
4509
4598
 
4510
- // Helper: format the new keys as lines with the given indent, alphabetically sorted.
4511
- const formatKeys = (indent: string): string =>
4512
- Object.entries(compatKeys)
4513
- .sort(([a], [b]) => a.localeCompare(b))
4514
- .map(([k, v]) => {
4515
- const val = typeof v === 'string' ? `"${v}"` : JSON.stringify(v);
4516
- return `${indent}${JSON.stringify(k)}: ${val}`;
4517
- })
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)}`)
4518
4605
  .join(',\n');
4519
4606
 
4520
4607
  // Helper: line-start indentation of the line containing `offset` in `original`.
@@ -4527,25 +4614,52 @@ function composeFixInsertion(
4527
4614
  };
4528
4615
 
4529
4616
  if (targetCompatBrace >= 0 && targetCompatEnd > targetCompatBrace) {
4530
- // ── Existing compat object: insert new key lines right after `{`. ──
4531
- // 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.
4532
4620
  const interiorStart = targetCompatBrace + 1;
4533
4621
  const interior = original.slice(interiorStart, targetCompatEnd);
4534
4622
  const hasContent = interior.trim().length > 0;
4623
+ const clean = stripJsoncComments(original);
4535
4624
 
4536
- // 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,
4537
4626
  // else derive one level deeper than the compat brace's own line.
4538
4627
  const braceLineIndent = lineIndentAt(targetCompatBrace);
4539
4628
  const innerMatch = interior.match(/\r?\n([ \t]+)\S/);
4540
4629
  const innerIndent = innerMatch ? innerMatch[1] : braceLineIndent + ' ';
4541
- const keysFormatted = formatKeys(innerIndent);
4542
4630
 
4543
- if (hasContent) {
4544
- // `{` + "\n<new keys>," + <original interior untouched> + `}`
4545
- 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
+ }
4546
4657
  }
4547
- // Empty compat `{}` (or whitespace only): write keys + put `}` back on its own line.
4548
- 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);
4549
4663
  }
4550
4664
 
4551
4665
  // ── No compat object yet: create one right after the container `{`. ──
@@ -4566,12 +4680,12 @@ function composeFixInsertion(
4566
4680
  : ' ';
4567
4681
  const innerIndent = keyIndent + unit;
4568
4682
 
4569
- const compatBlock = `\n${keyIndent}"compat": {\n${formatKeys(innerIndent)}\n${keyIndent}},`;
4683
+ const compatBlock = `\n${keyIndent}"compat": {\n${formatEntries(innerIndent, sortedEntries)}\n${keyIndent}},`;
4570
4684
  return original.slice(0, afterBrace) + compatBlock + suffix;
4571
4685
  }
4572
4686
 
4573
4687
  /**
4574
- * Self-check after compose: parse original and modified via stripJsoncComments,
4688
+ * Self-check after compose: parse original and modified as JSONC,
4575
4689
  * assert target compat flags exist in the right path, and remaining structure
4576
4690
  * is deep-equal (ignoring the inserted keys).
4577
4691
  * Returns null on success, error message on failure.
@@ -4584,17 +4698,17 @@ function selfCheckFix(
4584
4698
  compatKeys: Record<string, unknown>,
4585
4699
  ): string | null {
4586
4700
  try {
4587
- // Step 1: Parse both versions (this validates JSON syntax)
4588
- const origParsed = JSON.parse(stripJsoncComments(original));
4589
- 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);
4590
4704
 
4591
4705
  // Step 2: Validate modified file has correct structure
4592
- const providers = modParsed?.providers;
4593
- if (!providers || typeof providers !== 'object') {
4706
+ const providers = asRecord(asRecord(modParsed)?.providers);
4707
+ if (!providers) {
4594
4708
  return "Modified file: providers object missing or invalid";
4595
4709
  }
4596
- const provider = providers[providerLabel];
4597
- if (!provider || typeof provider !== 'object') {
4710
+ const provider = asRecord(providers[providerLabel]);
4711
+ if (!provider) {
4598
4712
  return `Modified file: provider "${providerLabel}" not found`;
4599
4713
  }
4600
4714
 
@@ -4612,6 +4726,18 @@ function selfCheckFix(
4612
4726
  if (!targetModel || typeof targetModel !== 'object') {
4613
4727
  return `Modified file: model "${modelId}" not found in provider`;
4614
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
+ }
4615
4741
 
4616
4742
  // Step 5: Compute the EFFECTIVE merged compat (provider-level + model-level),
4617
4743
  // mirroring Pi's mergeCompat behavior (model wins on conflicts). The fix may
@@ -4654,15 +4780,23 @@ function selfCheckFix(
4654
4780
  for (const key of Object.keys(origObj)) {
4655
4781
  if (!(key in modObj)) return false;
4656
4782
  if (key === 'compat') {
4657
- // 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.
4658
4786
  if (typeof origObj[key] !== 'object' || typeof modObj[key] !== 'object') {
4659
4787
  if (origObj[key] !== modObj[key]) return false;
4660
4788
  } else {
4661
- // Check all original compat keys are present and equal
4662
4789
  const origCompat = origObj[key] as Record<string, unknown>;
4663
4790
  const modCompat = modObj[key] as Record<string, unknown>;
4791
+ const mayRepairThisCompat = origObj === origProvider || origObj === origTargetModelRecord;
4664
4792
  for (const ck of Object.keys(origCompat)) {
4665
- 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;
4666
4800
  }
4667
4801
  }
4668
4802
  } else if (!isSubset(origObj[key], modObj[key], `${path}.${key}`)) {
@@ -4681,11 +4815,17 @@ function selfCheckFix(
4681
4815
  return "Modified file: content is shorter than original (possible truncation)";
4682
4816
  }
4683
4817
 
4684
- // Step 9: Validate no syntax issues by checking brackets balance
4685
- const openBraces = (modified.match(/{/g) || []).length;
4686
- const closeBraces = (modified.match(/}/g) || []).length;
4687
- if (openBraces !== closeBraces) {
4688
- 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";
4689
4829
  }
4690
4830
 
4691
4831
  return null;
@@ -4701,8 +4841,7 @@ function selfCheckFix(
4701
4841
  function formatCompatKeysForInsertion(compatKeys: Record<string, unknown>): string {
4702
4842
  return Object.entries(compatKeys)
4703
4843
  .map(([k, v]) => {
4704
- const val = typeof v === 'string' ? `"${v}"` : String(v);
4705
- return ` ${JSON.stringify(k)}: ${val}`;
4844
+ return ` ${JSON.stringify(k)}: ${JSON.stringify(v)}`;
4706
4845
  })
4707
4846
  .join(',\n');
4708
4847
  }
@@ -4752,6 +4891,7 @@ export const __internals_for_tests = {
4752
4891
  isOpenAIFamilyToken,
4753
4892
  describeMissingOpenAIFamilyProxyCompat,
4754
4893
  describeMissingOpenAICompatibleProxyCompat,
4894
+ describeOptionalOpenAICompatibleProxyCompat,
4755
4895
  describeMissingDeepSeekCompat,
4756
4896
  isDeepSeekCompatCheckApplicable,
4757
4897
  describeMissingCacheCompatForModel,
@@ -4919,8 +5059,12 @@ export const __internals_for_tests = {
4919
5059
  makeSessionModelKey,
4920
5060
  modelKeyFromSessionKey,
4921
5061
  filterRestorableStatsForSession,
5062
+ parsePersistedRoutedModelRef,
5063
+ routedModelRefToPiModel,
5064
+ buildExactRouterStatusEntry,
4922
5065
  // Persistence helpers (for reload/reset tests)
4923
5066
  mergeCacheSessions,
5067
+ mergeLastRoutedModels,
4924
5068
  writePersistedCacheStats,
4925
5069
  readPersistedCacheStats,
4926
5070
  STATE_FILE_PATH,
@@ -4929,10 +5073,14 @@ export const __internals_for_tests = {
4929
5073
  // JSONC surgical edit helpers
4930
5074
  MODELS_JSON_PATH,
4931
5075
  stripJsoncComments,
5076
+ stripJsoncTrailingCommas,
5077
+ parseJsonc,
4932
5078
  locateModelInJsonc,
4933
5079
  composeFixInsertion,
4934
5080
  selfCheckFix,
4935
5081
  decideFixPlacement,
5082
+ chooseFixPlacement,
5083
+ findExistingCompatKeysInJsonc,
4936
5084
  deepEqualIgnoringKeys,
4937
5085
  formatCompatKeysForInsertion,
4938
5086
  backupTimestamp,
@@ -4960,6 +5108,7 @@ export default function (pi: ExtensionAPI) {
4960
5108
  let currentSessionId = "";
4961
5109
  let currentSessionHash = "";
4962
5110
  let currentSessionHashSet = false;
5111
+ let lastActualRoutedModel: PersistedRoutedModelRef | undefined;
4963
5112
  const PERSIST_DEBOUNCE_MS = 2000;
4964
5113
  /** In-memory recent usage samples per model key (not persisted, cleared on reload). */
4965
5114
  const recentSamplesByModelKey = new Map<string, CacheUsageSample[]>();
@@ -4970,6 +5119,7 @@ export default function (pi: ExtensionAPI) {
4970
5119
  currentSessionId = sid;
4971
5120
  currentSessionHash = hashSessionId(sid);
4972
5121
  currentSessionHashSet = true;
5122
+ lastActualRoutedModel = undefined;
4973
5123
  }
4974
5124
  }
4975
5125
 
@@ -5019,7 +5169,13 @@ export default function (pi: ExtensionAPI) {
5019
5169
  }
5020
5170
 
5021
5171
  function getCacheStatsState(): CacheStatsState {
5022
- return { statsByModel: cacheStatsByModel, legacyFamily: cacheStatsLegacyFamily };
5172
+ return {
5173
+ statsByModel: cacheStatsByModel,
5174
+ legacyFamily: cacheStatsLegacyFamily,
5175
+ ...(currentSessionHashSet && lastActualRoutedModel
5176
+ ? { lastRoutedModelBySession: { [currentSessionHash]: lastActualRoutedModel } }
5177
+ : {}),
5178
+ };
5023
5179
  }
5024
5180
 
5025
5181
  /** Look up active stats for a model, falling back to legacy family. */
@@ -5146,6 +5302,9 @@ export default function (pi: ExtensionAPI) {
5146
5302
  currentSessionHashSet ? currentSessionHash : undefined,
5147
5303
  );
5148
5304
  cacheStatsLegacyFamily = persisted?.legacyFamily ?? emptyAllCacheStats();
5305
+ lastActualRoutedModel = currentSessionHashSet
5306
+ ? persisted?.lastRoutedModelBySession?.[currentSessionHash]
5307
+ : undefined;
5149
5308
 
5150
5309
  await rollOverStatsIfNeeded(ctx);
5151
5310
  return;
@@ -5160,10 +5319,63 @@ export default function (pi: ExtensionAPI) {
5160
5319
  currentSessionHashSet ? currentSessionHash : undefined,
5161
5320
  );
5162
5321
  cacheStatsLegacyFamily = persisted?.legacyFamily ?? emptyAllCacheStats();
5322
+ lastActualRoutedModel = currentSessionHashSet
5323
+ ? persisted?.lastRoutedModelBySession?.[currentSessionHash]
5324
+ : undefined;
5163
5325
  lastStatusText = undefined;
5164
5326
  await rollOverStatsIfNeeded(ctx);
5165
5327
  }
5166
5328
 
5329
+ /**
5330
+ * Fallback for older persisted files that do not yet carry exact
5331
+ * last-routed-model metadata. When the current model is a router channel
5332
+ * (e.g. router/auto), restorable stats are stored under the real upstream
5333
+ * model's provider/id key, not under router/auto. Find the best valid entry
5334
+ * (highest totalRequests among adapter-detectable model keys) so we can show
5335
+ * meaningful footer content on session_start after reload.
5336
+ */
5337
+ function findBestRouterModelStats(): { adapter: CacheProviderAdapter; stats: CacheStats } | undefined {
5338
+ if (!currentSessionHash) return undefined;
5339
+ const prefix = `${currentSessionHash}:`;
5340
+ let best: { adapter: CacheProviderAdapter; stats: CacheStats; total: number } | undefined;
5341
+
5342
+ for (const [key, stats] of Object.entries(cacheStatsByModel)) {
5343
+ if (!key.startsWith(prefix)) continue;
5344
+
5345
+ // Extract provider/id from key like "abc123:run-claude/claude-opus-4-8"
5346
+ const modelKeyPart = key.slice(prefix.length);
5347
+ const slashIdx = modelKeyPart.indexOf("/");
5348
+ if (slashIdx < 0 || slashIdx >= modelKeyPart.length - 1) continue;
5349
+ const modelId = modelKeyPart.slice(slashIdx + 1);
5350
+ const providerName = modelKeyPart.slice(0, slashIdx);
5351
+
5352
+ // Construct a minimal model for adapter detection.
5353
+ // Every is*LikeModel function only accesses model.id and model.name
5354
+ // via getModelIdNameTokenValues, so { id, name } is sufficient.
5355
+ const mockModel = {
5356
+ id: modelId,
5357
+ name: modelId,
5358
+ provider: providerName,
5359
+ api: "",
5360
+ baseUrl: "",
5361
+ reasoning: false,
5362
+ input: ["text"],
5363
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
5364
+ contextWindow: 0,
5365
+ maxTokens: 0,
5366
+ } as PiModel;
5367
+
5368
+ const adapter = selectAdapterForModel(mockModel);
5369
+ if (!adapter) continue;
5370
+
5371
+ if (!best || stats.totalRequests > best.total) {
5372
+ best = { adapter, stats, total: stats.totalRequests };
5373
+ }
5374
+ }
5375
+
5376
+ return best ? { adapter: best.adapter, stats: best.stats } : undefined;
5377
+ }
5378
+
5167
5379
  async function publishStatus(ctx: ExtensionContext, model: PiModel | undefined = ctx.model): Promise<void> {
5168
5380
  syncSessionHash(ctx);
5169
5381
  await rollOverStatsIfNeeded(ctx);
@@ -5171,11 +5383,23 @@ export default function (pi: ExtensionAPI) {
5171
5383
  const adapter = selectAdapterForModel(model);
5172
5384
  let statusText: string | undefined;
5173
5385
  if (!adapter && isRouterModel(model)) {
5174
- // router/auto has no stable target family before the first successful
5175
- // routed response. Keep the existing cache footer visible instead of
5176
- // clearing it on model_select; message_end will switch to the real
5177
- // upstream model/provider after pi-router relays the response metadata.
5178
- return;
5386
+ // On model_select (existing footer), keep the existing cache footer
5387
+ // visible instead of clearing it. On session_start (no footer yet
5388
+ // after reload/fresh start), restore the exact last actual routed model
5389
+ // for this session when available; fall back to older best-effort
5390
+ // heuristics only when no exact metadata exists.
5391
+ if (lastStatusText !== undefined) return;
5392
+ const realEntry = buildExactRouterStatusEntry(
5393
+ currentSessionHashSet ? currentSessionHash : undefined,
5394
+ cacheStatsByModel,
5395
+ lastActualRoutedModel,
5396
+ ) ?? findBestRouterModelStats();
5397
+ if (realEntry) {
5398
+ const statsText = formatCacheStats(realEntry.adapter, realEntry.stats);
5399
+ statusText = runtimeOptimizerEnabled
5400
+ ? statsText
5401
+ : `Cache Optimizer disabled · ${statsText}`;
5402
+ }
5179
5403
  }
5180
5404
 
5181
5405
  if (adapter) {
@@ -5220,8 +5444,11 @@ export default function (pi: ExtensionAPI) {
5220
5444
  // changes and day rollovers. Redundant setStatus calls are blocked by the
5221
5445
  // `lastStatusText` early return above.
5222
5446
  if (runtimeOptimizerEnabled && statusText !== undefined && model) {
5223
- const compatMissing = describeMissingCacheCompatForModel(model);
5224
- 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) {
5225
5452
  statusText = statusText + " ⚠️ compat";
5226
5453
  }
5227
5454
  }
@@ -5360,6 +5587,23 @@ export default function (pi: ExtensionAPI) {
5360
5587
  const usage = adapter.normalizeUsage(event.message);
5361
5588
 
5362
5589
  const statsModel = isRouterModel(ctx.model) ? modelFromAssistantMessage(event.message, ctx.model) : ctx.model;
5590
+ let routedModelChanged = false;
5591
+ if (isRouterModel(ctx.model) && statsModel && !isRouterModel(statsModel)) {
5592
+ const nextRoutedModel: PersistedRoutedModelRef = {
5593
+ provider: statsModel.provider,
5594
+ id: statsModel.id,
5595
+ name: statsModel.name || statsModel.id,
5596
+ };
5597
+ if (
5598
+ !lastActualRoutedModel ||
5599
+ lastActualRoutedModel.provider !== nextRoutedModel.provider ||
5600
+ lastActualRoutedModel.id !== nextRoutedModel.id ||
5601
+ (lastActualRoutedModel.name || lastActualRoutedModel.id) !== (nextRoutedModel.name || nextRoutedModel.id)
5602
+ ) {
5603
+ lastActualRoutedModel = nextRoutedModel;
5604
+ routedModelChanged = true;
5605
+ }
5606
+ }
5363
5607
 
5364
5608
  // Record recent sample (even when usage is missing, for trend diagnosis)
5365
5609
  if (statsModel) {
@@ -5370,7 +5614,10 @@ export default function (pi: ExtensionAPI) {
5370
5614
  recordRecentSample(sk, usage ?? { cacheRead: 0, cacheWrite: 0, totalInput: 0 }, missingFields);
5371
5615
  }
5372
5616
 
5373
- if (!usage) return;
5617
+ if (!usage) {
5618
+ if (routedModelChanged) schedulePersistCacheStats(ctx);
5619
+ return;
5620
+ }
5374
5621
 
5375
5622
  await rollOverStatsIfNeeded(ctx);
5376
5623
 
@@ -5555,7 +5802,7 @@ export default function (pi: ExtensionAPI) {
5555
5802
 
5556
5803
  // Compose the modified text — auto-detect the best placement level:
5557
5804
  // provider level (channel-wide) when safe for all sibling models, else model level.
5558
- const decision = decideFixPlacement(suggestion.compatKeys, suggestion.providerLabel, location.allModelIds);
5805
+ const decision = chooseFixPlacement(originalText, location, suggestion.compatKeys, suggestion.providerLabel);
5559
5806
  const modifiedText = composeFixInsertion(originalText, location, suggestion.compatKeys, decision.placement);
5560
5807
 
5561
5808
  // Self-check
@@ -5569,10 +5816,9 @@ export default function (pi: ExtensionAPI) {
5569
5816
  return;
5570
5817
  }
5571
5818
 
5572
- // Build preview snippet
5573
- const keysPreview = Object.entries(suggestion.compatKeys)
5574
- .map(([k, v]) => ` ${k}: ${JSON.stringify(v)}`)
5575
- .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);
5576
5822
  const targetHasCompat = decision.placement === "provider" ? location.providerCompatBrace >= 0 : location.compatObjectBrace >= 0;
5577
5823
  const placementDesc = targetHasCompat ? `existing "compat" object` : `new "compat" object`;
5578
5824
  const locationDesc = decision.placement === "provider"
@@ -5591,7 +5837,7 @@ export default function (pi: ExtensionAPI) {
5591
5837
  ``,
5592
5838
  `Location: ${locationDesc}`,
5593
5839
  `Placement: ${decision.placement} level — ${decision.reason}`,
5594
- `Keys to insert:`,
5840
+ `Compat JSON to write:`,
5595
5841
  keysPreview,
5596
5842
  ``,
5597
5843
  `⚠️ Risk notice:`,
@@ -5748,7 +5994,7 @@ export default function (pi: ExtensionAPI) {
5748
5994
  return;
5749
5995
  }
5750
5996
 
5751
- const menuDecision = decideFixPlacement(suggestion.compatKeys, suggestion.providerLabel, location.allModelIds);
5997
+ const menuDecision = chooseFixPlacement(originalText, location, suggestion.compatKeys, suggestion.providerLabel);
5752
5998
  const modifiedText = composeFixInsertion(originalText, location, suggestion.compatKeys, menuDecision.placement);
5753
5999
  const checkError = selfCheckFix(originalText, modifiedText, suggestion.providerLabel, suggestion.modelId, suggestion.compatKeys);
5754
6000
  if (checkError !== null) {
@@ -5756,9 +6002,7 @@ export default function (pi: ExtensionAPI) {
5756
6002
  return;
5757
6003
  }
5758
6004
 
5759
- const keysPreview = Object.entries(suggestion.compatKeys)
5760
- .map(([k, v]) => ` ${k}: ${JSON.stringify(v)}`)
5761
- .join("\n");
6005
+ const keysPreview = JSON.stringify(suggestion.compatKeys, null, 2);
5762
6006
  const ts = backupTimestamp();
5763
6007
  const backupPath = `${MODELS_JSON_PATH}.backup-cache-optimizer-${ts}`;
5764
6008
 
@@ -5773,7 +6017,7 @@ export default function (pi: ExtensionAPI) {
5773
6017
  `📝 Preview of changes to ${getModelsJsonDisplayPath()}:`,
5774
6018
  `Location: ${menuLocationDesc}`,
5775
6019
  `Placement: ${menuDecision.placement} level — ${menuDecision.reason}`,
5776
- `Keys to insert:`,
6020
+ `Compat JSON to write:`,
5777
6021
  keysPreview,
5778
6022
  ``,
5779
6023
  `⚠️ Risk notice:`,