pi-cache-optimizer 2.5.6 → 2.6.0

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.
Files changed (2) hide show
  1. package/index.ts +1363 -27
  2. package/package.json +1 -1
package/index.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
- import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
2
+ import { copyFile, mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
3
3
  import { homedir } from "node:os";
4
4
  import { dirname, join } from "node:path";
5
5
  import type { BuildSystemPromptOptions, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
@@ -144,6 +144,7 @@ type CacheCompat = {
144
144
  thinkingFormat?: string;
145
145
  requiresReasoningContentOnAssistantMessages?: boolean;
146
146
  cacheControlFormat?: string;
147
+ forceAdaptiveThinking?: boolean;
147
148
  };
148
149
 
149
150
  type CacheStats = {
@@ -656,8 +657,22 @@ function getNonNegativeNumber(record: UnknownRecord, key: string): number | unde
656
657
  return value !== undefined && value >= 0 ? value : undefined;
657
658
  }
658
659
 
660
+ /**
661
+ * Get effective compat for a model by merging provider-level and model-level compat.
662
+ * Model-level compat takes precedence over provider-level compat for overlapping keys.
663
+ * This matches Pi's model-registry.js mergeCompat behavior.
664
+ */
659
665
  function getCompat(model: PiModel | undefined): CacheCompat {
660
- return (model?.compat ?? {}) as CacheCompat;
666
+ if (!model) return {} as CacheCompat;
667
+
668
+ // Pi merges provider.compat with model.compat (model wins on conflicts)
669
+ // We approximate this by reading from ctx.model which should already have merged compat
670
+ // However, for safety, we check both levels if available
671
+ const modelCompat = (model.compat ?? {}) as CacheCompat;
672
+
673
+ // Note: ctx.model from Pi should already contain merged compat,
674
+ // but we document the two-level structure for clarity
675
+ return modelCompat;
661
676
  }
662
677
 
663
678
  /**
@@ -775,6 +790,19 @@ function isOpenAICompatibleApi(api: unknown): boolean {
775
790
  return value === "openai-completions" || value === "openai-responses";
776
791
  }
777
792
 
793
+ function isOpenAICompatibleProxyApi(api: unknown): boolean {
794
+ return lower(api) === "openai-completions";
795
+ }
796
+
797
+ function isResponsesPromptRewriteBypassApi(api: unknown): boolean {
798
+ const value = lower(api);
799
+ return value === "openai-codex-responses" || value === "openai-responses" || value === "azure-openai-responses";
800
+ }
801
+
802
+ function isMistralConversationsApi(api: unknown): boolean {
803
+ return lower(api) === "mistral-conversations";
804
+ }
805
+
778
806
  function isOpenAIFamilyToken(token: string): boolean {
779
807
  return token.includes("gpt-") || token.includes("chatgpt") || OPENAI_REASONING_MODEL_PATTERN.test(token);
780
808
  }
@@ -803,6 +831,78 @@ function isGeminiLikeAssistantMessage(message: unknown, model: PiModel | undefin
803
831
  return modelOrAssistantMessageHas(message, model, ["gemini", "vertex"]);
804
832
  }
805
833
 
834
+ // ── Adaptive generation model detection ────────────────────────────
835
+
836
+ /**
837
+ * Check whether the model id uses Anthropic's adaptive generation (thinking)
838
+ * that requires `forceAdaptiveThinking: true` in compat.
839
+ *
840
+ * Adaptive-generation models (from pi-ai built-in catalog) include:
841
+ * claude-opus-4-6, claude-opus-4-7, claude-opus-4-8 (also dotted 4.6/4.7/4.8)
842
+ * claude-sonnet-4-6
843
+ * claude-fable-5
844
+ *
845
+ * We match broadly: opus >= 4-6, sonnet >= 4-6, fable >= 5.
846
+ * Ids may carry date-stamp or size suffixes like "[1M]".
847
+ */
848
+ const ADAPTIVE_OPUS_PATTERN = /(^|[\/\s:_-])(opus-4[.-][6-9]|opus-4-[1-9][0-9])($|[-_.:\/\s\[])/i;
849
+ const ADAPTIVE_SONNET_PATTERN = /(^|[\/\s:_-])(sonnet-4[.-][6-9]|sonnet-4-[1-9][0-9])($|[-_.:\/\s\[])/i;
850
+ const ADAPTIVE_FABLE_PATTERN = /(^|[\/\s:_-])fable-([5-9]|[1-9][0-9])($|[-_.:\/\s\[])/i;
851
+
852
+ function isAdaptiveGenerationModel(model: PiModel | undefined): boolean {
853
+ if (!model) return false;
854
+ const tokens = getModelIdNameTokenValues(model);
855
+ return tokens.some((t) => ADAPTIVE_OPUS_PATTERN.test(t) || ADAPTIVE_SONNET_PATTERN.test(t) || ADAPTIVE_FABLE_PATTERN.test(t));
856
+ }
857
+
858
+ function isAdaptiveThinkingCompatApplicable(model: PiModel): boolean {
859
+ return lower(model.api) === "anthropic-messages" && isAdaptiveGenerationModel(model);
860
+ }
861
+
862
+ function describeMissingAdaptiveThinkingCompat(model: PiModel): string[] {
863
+ const compat = getCompat(model);
864
+ const missing: string[] = [];
865
+ if (compat.forceAdaptiveThinking !== true) {
866
+ missing.push("forceAdaptiveThinking");
867
+ }
868
+ return missing;
869
+ }
870
+
871
+ function buildAdaptiveThinkingCompatSuggestion(missing: string[]): Record<string, unknown> {
872
+ const suggestion: Record<string, unknown> = {};
873
+ if (missing.includes("forceAdaptiveThinking")) {
874
+ suggestion.forceAdaptiveThinking = true;
875
+ }
876
+ return suggestion;
877
+ }
878
+
879
+ function appendAdaptiveThinkingCompatAdviceLines(lines: string[], missing: string[], placement: CompatAdvicePlacement = {}): void {
880
+ const suggestion = buildAdaptiveThinkingCompatSuggestion(missing);
881
+ if (Object.keys(suggestion).length > 0) {
882
+ lines.push("Suggested fix:");
883
+ lines.push(JSON.stringify(suggestion, null, 2));
884
+ }
885
+ lines.push("- forceAdaptiveThinking: true tells Pi to use adaptive thinking format");
886
+ lines.push(" (thinking: {type: 'adaptive'}) instead of legacy budget tokens format.");
887
+ lines.push(" Without this flag, Pi sends legacy thinking which adaptive-only upstreams reject.");
888
+ appendCredentialSafeProviderGuidance(lines, placement, suggestion);
889
+ }
890
+
891
+ function buildAdaptiveThinkingCompatWarningText(key: string, missing: string[]): string {
892
+ const slashIdx = key.indexOf("/");
893
+ const providerLabel = slashIdx > 0 ? key.slice(0, slashIdx) : key;
894
+ const modelId = slashIdx > 0 ? key.slice(slashIdx + 1) : undefined;
895
+ const modelsJsonPath = getModelsJsonDisplayPath();
896
+ const lines: string[] = [
897
+ `💡 pi-cache-optimizer: ${key} is an adaptive-generation Claude model but merged compat lacks ${missing.join(" and ")}.`,
898
+ `Without this flag, Pi sends legacy thinking format that may be rejected by the upstream.`,
899
+ `Edit ${modelsJsonPath} -> providers["${providerLabel}"] -> compat (at the same level as baseUrl/api/apiKey/models).`,
900
+ "",
901
+ ];
902
+ appendAdaptiveThinkingCompatAdviceLines(lines, missing, { providerLabel, modelId });
903
+ return lines.join("\n");
904
+ }
905
+
806
906
  // ── Non-GPT OpenAI-compatible model detection ──────────────────────
807
907
 
808
908
  function isKimiLikeModel(model: PiModel | undefined): boolean {
@@ -1492,7 +1592,7 @@ function describeMissingOpenAIFamilyProxyCompat(model: PiModel): string[] {
1492
1592
  const missing: string[] = [];
1493
1593
 
1494
1594
  if (!isOpenAIFamilyModel(model)) return missing;
1495
- if (lower(model.api) !== "openai-completions") return missing;
1595
+ if (!isOpenAICompatibleProxyApi(model.api)) return missing;
1496
1596
  if (isOfficialOpenAIBaseUrl(model)) return missing;
1497
1597
 
1498
1598
  if (compat.supportsLongCacheRetention !== true) {
@@ -1515,7 +1615,7 @@ function describeMissingOpenAICompatibleProxyCompat(model: PiModel): string[] {
1515
1615
  const compat = getCompat(model);
1516
1616
  const missing: string[] = [];
1517
1617
 
1518
- if (lower(model.api) !== "openai-completions") return missing;
1618
+ if (!isOpenAICompatibleProxyApi(model.api)) return missing;
1519
1619
  if (isOfficialOpenAIBaseUrl(model)) return missing;
1520
1620
 
1521
1621
  if (compat.supportsLongCacheRetention !== true) {
@@ -1693,6 +1793,9 @@ function isDeepSeekCompatCheckApplicable(model: PiModel): boolean {
1693
1793
  }
1694
1794
 
1695
1795
  function describeMissingCacheCompatForModel(model: PiModel): string[] {
1796
+ if (isAdaptiveThinkingCompatApplicable(model)) {
1797
+ return describeMissingAdaptiveThinkingCompat(model);
1798
+ }
1696
1799
  if (isDeepSeekCompatCheckApplicable(model)) {
1697
1800
  return describeMissingDeepSeekCompat(model);
1698
1801
  }
@@ -2742,6 +2845,22 @@ function notifyCacheCompatIfNeeded(
2742
2845
  ): void {
2743
2846
  if (!model) return;
2744
2847
 
2848
+ // Native anthropic-messages adaptive thinking compat check.
2849
+ // The Claude adapter's warningText only fires for OpenAI-compatible APIs,
2850
+ // so native anthropic-messages models need a separate check.
2851
+ if (lower(model.api) === "anthropic-messages" && isAdaptiveGenerationModel(model)) {
2852
+ const compat = getCompat(model);
2853
+ if (compat.forceAdaptiveThinking !== true) {
2854
+ const key = `adaptive-thinking:${modelKey(model)}`;
2855
+ if (!warnedModels.has(key)) {
2856
+ warnedModels.add(key);
2857
+ const missing = describeMissingAdaptiveThinkingCompat(model);
2858
+ ctx.ui.notify(buildAdaptiveThinkingCompatWarningText(modelKey(model), missing), "warning");
2859
+ }
2860
+ }
2861
+ // Still check adapter warnings for other compat issues.
2862
+ }
2863
+
2745
2864
  const adapter = selectAdapterForModel(model);
2746
2865
  const text = adapter?.warningText?.(model);
2747
2866
  if (!adapter || !text) return;
@@ -3228,7 +3347,7 @@ async function writePersistedCacheStats(state: CacheStatsState, currentSessionHa
3228
3347
 
3229
3348
 
3230
3349
  function isCompatCheckApplicable(model: PiModel): boolean {
3231
- return lower(model.api) === "openai-completions" && !isOfficialOpenAIBaseUrl(model);
3350
+ return isOpenAICompatibleProxyApi(model.api) && !isOfficialOpenAIBaseUrl(model);
3232
3351
  }
3233
3352
 
3234
3353
  function isPromptCacheRetention400Applicable(model: PiModel): boolean {
@@ -3263,10 +3382,10 @@ function describeRouterChannelDiagnostics(model: PiModel): string[] {
3263
3382
  const baseUrl = lower(model.baseUrl || "");
3264
3383
  const provider = lower(model.provider);
3265
3384
 
3266
- // Only OpenAI-compatible APIs are applicable for router/channel diagnostics.
3267
- // Custom transports like kiro-api, anthropic-messages, bedrock-converse-stream
3268
- // or non-OpenAI APIs are excluded.
3269
- if (api !== "openai-completions" && api !== "openai-responses") {
3385
+ // Router/channel diagnostics only apply to OpenAI-compatible proxy APIs.
3386
+ // Native APIs like mistral-conversations, azure-openai-responses,
3387
+ // anthropic-messages, or bedrock-converse-stream are intentionally excluded.
3388
+ if (api === "azure-openai-responses" || isMistralConversationsApi(api) || !isOpenAICompatibleApi(api)) {
3270
3389
  return notes;
3271
3390
  }
3272
3391
 
@@ -3405,6 +3524,33 @@ function describeRouterChannelDiagnostics(model: PiModel): string[] {
3405
3524
  return notes;
3406
3525
  }
3407
3526
 
3527
+ function getCompatCheckNotApplicableLines(model: PiModel): string[] {
3528
+ const api = lower(model.api);
3529
+
3530
+ if (isMistralConversationsApi(api)) {
3531
+ return [
3532
+ "ℹ️ Compat check not applicable for this model.",
3533
+ " Native Mistral `mistral-conversations` uses provider-native transport; OpenAI-compatible proxy compat flags do not apply.",
3534
+ ];
3535
+ }
3536
+
3537
+ if (api === "azure-openai-responses") {
3538
+ return [
3539
+ "ℹ️ Compat check not applicable for this model.",
3540
+ " Native Azure OpenAI Responses uses the Responses transport; OpenAI-compatible proxy compat flags do not apply.",
3541
+ ];
3542
+ }
3543
+
3544
+ if (api === "openai-codex-responses" || (api === "openai-responses" && isOfficialOpenAIBaseUrl(model))) {
3545
+ return [
3546
+ "ℹ️ Compat check not applicable for this model.",
3547
+ " Native Responses transports already use Pi core request handling; OpenAI-compatible proxy compat flags do not apply.",
3548
+ ];
3549
+ }
3550
+
3551
+ return ["ℹ️ Compat check not applicable for this model."];
3552
+ }
3553
+
3408
3554
  function buildDoctorDiagnosis(model: PiModel, options: { promptCacheRetention400?: boolean } = {}): string {
3409
3555
  const lines: string[] = [];
3410
3556
  lines.push(`Provider: ${model.provider}`);
@@ -3416,6 +3562,7 @@ function buildDoctorDiagnosis(model: PiModel, options: { promptCacheRetention400
3416
3562
  const compat = getCompat(model);
3417
3563
  lines.push(`Compat: ${JSON.stringify(compat)}`);
3418
3564
 
3565
+ const adaptiveThinkingApplicable = isAdaptiveThinkingCompatApplicable(model);
3419
3566
  const deepSeekCompatApplicable = isDeepSeekCompatCheckApplicable(model);
3420
3567
  const missing = describeMissingCacheCompatForModel(model);
3421
3568
  if (missing.length > 0) {
@@ -3425,15 +3572,17 @@ function buildDoctorDiagnosis(model: PiModel, options: { promptCacheRetention400
3425
3572
  const providerLabel = slashIdx > 0 ? key.slice(0, slashIdx) : key;
3426
3573
  const modelsJsonPath = getModelsJsonDisplayPath();
3427
3574
  lines.push(`Edit ${modelsJsonPath} -> providers["${providerLabel}"] -> compat (same level as baseUrl/api/apiKey/models).`);
3428
- if (deepSeekCompatApplicable) {
3575
+ if (adaptiveThinkingApplicable) {
3576
+ appendAdaptiveThinkingCompatAdviceLines(lines, missing, { providerLabel, modelId: model.id });
3577
+ } else if (deepSeekCompatApplicable) {
3429
3578
  appendDeepSeekCompatAdviceLines(lines, missing, { providerLabel, modelId: model.id });
3430
3579
  } else {
3431
3580
  appendOpenAIProxyCompatAdviceLines(lines, missing, { providerLabel, modelId: model.id });
3432
3581
  }
3433
- } else if (deepSeekCompatApplicable || isCompatCheckApplicable(model)) {
3582
+ } else if (adaptiveThinkingApplicable || deepSeekCompatApplicable || isCompatCheckApplicable(model)) {
3434
3583
  lines.push("✅ Compat fully configured.");
3435
3584
  } else {
3436
- lines.push("ℹ️ Compat check not applicable for this model.");
3585
+ lines.push(...getCompatCheckNotApplicableLines(model));
3437
3586
  }
3438
3587
 
3439
3588
  if (isPromptCacheRetention400Applicable(model)) {
@@ -3589,6 +3738,7 @@ function buildLowHitDiagnosis(
3589
3738
 
3590
3739
  function buildCompatDiagnosis(model: PiModel): string | undefined {
3591
3740
  const missing = describeMissingCacheCompatForModel(model);
3741
+ const adaptiveThinkingApplicable = isAdaptiveThinkingCompatApplicable(model);
3592
3742
  const deepSeekCompatApplicable = isDeepSeekCompatCheckApplicable(model);
3593
3743
  const routerNotes = describeRouterChannelDiagnostics(model);
3594
3744
 
@@ -3606,7 +3756,9 @@ function buildCompatDiagnosis(model: PiModel): string | undefined {
3606
3756
  lines.push("");
3607
3757
  lines.push(`Edit ${modelsJsonPath} -> providers["${providerLabel}"] -> compat`);
3608
3758
  lines.push(`(at the same level as baseUrl/api/apiKey/models).`);
3609
- if (deepSeekCompatApplicable) {
3759
+ if (adaptiveThinkingApplicable) {
3760
+ appendAdaptiveThinkingCompatAdviceLines(lines, missing, { providerLabel, modelId: model.id });
3761
+ } else if (deepSeekCompatApplicable) {
3610
3762
  appendDeepSeekCompatAdviceLines(lines, missing, { providerLabel, modelId: model.id });
3611
3763
  } else {
3612
3764
  appendOpenAIProxyCompatAdviceLines(lines, missing, { providerLabel, modelId: model.id });
@@ -3615,13 +3767,13 @@ function buildCompatDiagnosis(model: PiModel): string | undefined {
3615
3767
 
3616
3768
  // When compat is fully configured but router notes exist, prefix the status.
3617
3769
  if (routerNotes.length > 0 && missing.length === 0) {
3618
- if (deepSeekCompatApplicable || isCompatCheckApplicable(model)) {
3770
+ if (adaptiveThinkingApplicable || deepSeekCompatApplicable || isCompatCheckApplicable(model)) {
3619
3771
  lines.push("✅ Compat fully configured.");
3620
3772
  if (isPromptCacheRetention400Applicable(model)) {
3621
3773
  lines.push(getPromptCacheRetentionUnsupportedHint());
3622
3774
  }
3623
3775
  } else {
3624
- lines.push("ℹ️ Compat check not applicable for this model.");
3776
+ lines.push(...getCompatCheckNotApplicableLines(model));
3625
3777
  }
3626
3778
  lines.push("");
3627
3779
  }
@@ -3636,6 +3788,911 @@ function buildCompatDiagnosis(model: PiModel): string | undefined {
3636
3788
  return lines.join("\n");
3637
3789
  }
3638
3790
 
3791
+ // ============================================================
3792
+ // JSONC comment-preserving surgical edit helpers for /cache-optimizer fix
3793
+ // ============================================================
3794
+
3795
+ /** The real models.json path used for I/O. */
3796
+ const MODELS_JSON_PATH = join(STATE_DIR, "models.json");
3797
+
3798
+ // ── String-aware JSONC scanning primitives ─────────────────────────
3799
+ //
3800
+ // These operate on comment-stripped text produced by stripJsoncComments()
3801
+ // (which preserves byte offsets), so every offset they return is also valid
3802
+ // in the original text. All scanning skips string literals, so braces or
3803
+ // brackets inside string values (e.g. apiKeyCommand shell snippets) cannot
3804
+ // corrupt depth tracking.
3805
+
3806
+ function isJsonWhitespace(ch: string): boolean {
3807
+ return ch === " " || ch === "\n" || ch === "\r" || ch === "\t";
3808
+ }
3809
+
3810
+ function skipJsonWhitespace(text: string, pos: number): number {
3811
+ while (pos < text.length && isJsonWhitespace(text[pos])) pos++;
3812
+ return pos;
3813
+ }
3814
+
3815
+ /**
3816
+ * Read a JSON string literal starting at `pos` (which must be `"`).
3817
+ * Returns the decoded value and the offset just past the closing quote,
3818
+ * or undefined when the literal is unterminated/malformed.
3819
+ */
3820
+ function readJsonStringLiteral(text: string, pos: number): { value: string; end: number } | undefined {
3821
+ if (text[pos] !== '"') return undefined;
3822
+ let i = pos + 1;
3823
+ let value = "";
3824
+ while (i < text.length) {
3825
+ const ch = text[i];
3826
+ if (ch === "\\") {
3827
+ const next = text[i + 1];
3828
+ if (next === undefined) return undefined;
3829
+ if (next === "u") {
3830
+ const hex = text.slice(i + 2, i + 6);
3831
+ if (!/^[0-9a-fA-F]{4}$/.test(hex)) return undefined;
3832
+ value += String.fromCharCode(parseInt(hex, 16));
3833
+ i += 6;
3834
+ } else {
3835
+ if (next === "n") value += "\n";
3836
+ else if (next === "t") value += "\t";
3837
+ else if (next === "r") value += "\r";
3838
+ else if (next === "b") value += "\b";
3839
+ else if (next === "f") value += "\f";
3840
+ else value += next; // ", \\, / and lenient passthrough
3841
+ i += 2;
3842
+ }
3843
+ continue;
3844
+ }
3845
+ if (ch === '"') return { value, end: i + 1 };
3846
+ value += ch;
3847
+ i++;
3848
+ }
3849
+ return undefined;
3850
+ }
3851
+
3852
+ /**
3853
+ * Find the offset of the `}` / `]` matching the opener at `openPos`,
3854
+ * skipping string literals. Returns undefined on imbalance.
3855
+ */
3856
+ function findMatchingBracket(text: string, openPos: number): number | undefined {
3857
+ const open = text[openPos];
3858
+ if (open !== "{" && open !== "[") return undefined;
3859
+ let depth = 0;
3860
+ let i = openPos;
3861
+ while (i < text.length) {
3862
+ const ch = text[i];
3863
+ if (ch === '"') {
3864
+ const str = readJsonStringLiteral(text, i);
3865
+ if (!str) return undefined;
3866
+ i = str.end;
3867
+ continue;
3868
+ }
3869
+ if (ch === "{" || ch === "[") depth++;
3870
+ else if (ch === "}" || ch === "]") {
3871
+ depth--;
3872
+ if (depth === 0) return i;
3873
+ }
3874
+ i++;
3875
+ }
3876
+ return undefined;
3877
+ }
3878
+
3879
+ /** Skip one JSON value starting at/after `pos`; returns the offset just past it. */
3880
+ function skipJsonValue(text: string, pos: number): number | undefined {
3881
+ pos = skipJsonWhitespace(text, pos);
3882
+ const ch = text[pos];
3883
+ if (ch === '"') {
3884
+ const str = readJsonStringLiteral(text, pos);
3885
+ return str?.end;
3886
+ }
3887
+ if (ch === "{" || ch === "[") {
3888
+ const end = findMatchingBracket(text, pos);
3889
+ return end === undefined ? undefined : end + 1;
3890
+ }
3891
+ let i = pos;
3892
+ while (i < text.length && !",}]".includes(text[i]) && !isJsonWhitespace(text[i])) i++;
3893
+ return i > pos ? i : undefined;
3894
+ }
3895
+
3896
+ /**
3897
+ * Find a top-level key in the object whose `{` is at `openBracePos`.
3898
+ * Only direct children are considered (nested values are skipped whole).
3899
+ * Returns the key's opening-quote offset and its value's start offset,
3900
+ * or undefined when the key is absent or the object is malformed.
3901
+ */
3902
+ function findJsonObjectKey(
3903
+ text: string,
3904
+ openBracePos: number,
3905
+ targetKey: string,
3906
+ ): { keyStart: number; valueStart: number } | undefined {
3907
+ if (text[openBracePos] !== "{") return undefined;
3908
+ let i = openBracePos + 1;
3909
+ while (i < text.length) {
3910
+ i = skipJsonWhitespace(text, i);
3911
+ if (i >= text.length || text[i] === "}") return undefined;
3912
+ if (text[i] === ",") {
3913
+ i++;
3914
+ continue;
3915
+ }
3916
+ if (text[i] !== '"') return undefined; // unexpected token — refuse to guess
3917
+ const keyStart = i;
3918
+ const key = readJsonStringLiteral(text, i);
3919
+ if (!key) return undefined;
3920
+ i = skipJsonWhitespace(text, key.end);
3921
+ if (text[i] !== ":") return undefined;
3922
+ i = skipJsonWhitespace(text, i + 1);
3923
+ if (key.value === targetKey) return { keyStart, valueStart: i };
3924
+ const after = skipJsonValue(text, i);
3925
+ if (after === undefined) return undefined;
3926
+ i = after;
3927
+ }
3928
+ return undefined;
3929
+ }
3930
+
3931
+ /** Leading whitespace of the line containing offset `pos` (up to `pos`). */
3932
+ function lineIndentOf(text: string, pos: number): string {
3933
+ let lineStart = text.lastIndexOf("\n", pos - 1);
3934
+ lineStart = lineStart < 0 ? 0 : lineStart + 1;
3935
+ const m = text.slice(lineStart, pos).match(/^[ \t]*/);
3936
+ return m ? m[0] : "";
3937
+ }
3938
+
3939
+ /**
3940
+ * Indentation used by the first line inside the object spanning
3941
+ * `openBrace`..`closeBrace` in the ORIGINAL text. Falls back to the
3942
+ * opener's line indent plus two spaces for single-line objects.
3943
+ */
3944
+ function deriveInnerIndent(text: string, openBrace: number, closeBrace: number): string {
3945
+ const nl = text.indexOf("\n", openBrace + 1);
3946
+ if (nl >= 0 && nl < closeBrace) {
3947
+ let i = nl + 1;
3948
+ let ws = "";
3949
+ while (i < text.length && (text[i] === " " || text[i] === "\t")) {
3950
+ ws += text[i];
3951
+ i++;
3952
+ }
3953
+ if (ws.length > 0) return ws;
3954
+ }
3955
+ return lineIndentOf(text, openBrace) + " ";
3956
+ }
3957
+
3958
+ interface FixSuggestion {
3959
+ providerLabel: string;
3960
+ modelId: string;
3961
+ compatKeys: Record<string, unknown>;
3962
+ }
3963
+
3964
+ /**
3965
+ * Build the fix suggestion for the current active model.
3966
+ * Returns undefined if there is nothing to fix.
3967
+ */
3968
+ function buildFixSuggestion(model: PiModel): FixSuggestion | undefined {
3969
+ const missing = describeMissingCacheCompatForModel(model);
3970
+ if (missing.length === 0) return undefined;
3971
+
3972
+ let compatKeys: Record<string, unknown> = {};
3973
+
3974
+ if (isAdaptiveThinkingCompatApplicable(model)) {
3975
+ compatKeys = buildAdaptiveThinkingCompatSuggestion(missing);
3976
+ } else if (isDeepSeekCompatCheckApplicable(model)) {
3977
+ compatKeys = buildDeepSeekCompatSuggestion(missing);
3978
+ } else {
3979
+ compatKeys = buildSafeOpenAIProxyCompatSuggestion(missing);
3980
+ }
3981
+
3982
+ if (Object.keys(compatKeys).length === 0) return undefined;
3983
+
3984
+ const key = modelKey(model);
3985
+ const slashIdx = key.indexOf("/");
3986
+ const providerLabel = slashIdx > 0 ? key.slice(0, slashIdx) : key;
3987
+
3988
+ return {
3989
+ providerLabel,
3990
+ modelId: model.id,
3991
+ compatKeys,
3992
+ };
3993
+ }
3994
+
3995
+ /**
3996
+ * Strip JSONC comments from text, replacing them with spaces.
3997
+ * Handles string literals, escaped quotes, // line comments, /* block comments *\/.
3998
+ * Returns the cleaned text with same line/column positions.
3999
+ */
4000
+ function stripJsoncComments(text: string): string {
4001
+ const out: string[] = [];
4002
+ let i = 0;
4003
+ while (i < text.length) {
4004
+ const ch = text[i];
4005
+ if (ch === '"') {
4006
+ // String literal — copy until closing quote (handle escapes)
4007
+ out.push(ch);
4008
+ i++;
4009
+ while (i < text.length) {
4010
+ const sc = text[i];
4011
+ out.push(sc);
4012
+ i++;
4013
+ if (sc === '\\' && i < text.length) {
4014
+ out.push(text[i]);
4015
+ i++;
4016
+ } else if (sc === '"') {
4017
+ break;
4018
+ }
4019
+ }
4020
+ } else if (ch === '/' && i + 1 < text.length && text[i + 1] === '/') {
4021
+ // Line comment — replace with spaces until newline
4022
+ out.push(' ');
4023
+ i++;
4024
+ while (i < text.length && text[i] !== '\n') {
4025
+ out.push(' ');
4026
+ i++;
4027
+ }
4028
+ } else if (ch === '/' && i + 1 < text.length && text[i + 1] === '*') {
4029
+ // Block comment — replace with spaces (preserve newlines)
4030
+ out.push(' ');
4031
+ i++;
4032
+ while (i + 1 < text.length) {
4033
+ if (text[i] === '*' && text[i + 1] === '/') {
4034
+ out.push(' ');
4035
+ i += 2;
4036
+ break;
4037
+ }
4038
+ if (text[i] === '\n') {
4039
+ out.push('\n');
4040
+ } else {
4041
+ out.push(' ');
4042
+ }
4043
+ i++;
4044
+ }
4045
+ } else {
4046
+ out.push(ch);
4047
+ i++;
4048
+ }
4049
+ }
4050
+ return out.join('');
4051
+ }
4052
+
4053
+ /**
4054
+ * JSONC scanner: locate the provider block and model entry in models.json text.
4055
+ * Returns the byte offsets for surgical insertion, or undefined if ambiguous.
4056
+ */
4057
+ interface ModelNodeLocation {
4058
+ /** Offset of the model object's opening `{` */
4059
+ modelObjectBrace: number;
4060
+ /** Offset of the model object's closing `}` */
4061
+ modelObjectEnd: number;
4062
+ /** Offset of the "compat" key start (the `"`), or -1 if compat doesn't exist */
4063
+ compatKeyStart: number;
4064
+ /** Offset of the compat object's opening `{`, or -1 if compat doesn't exist */
4065
+ compatObjectBrace: number;
4066
+ /** Offset of the compat object's closing `}`, or -1 */
4067
+ compatObjectEnd: number;
4068
+ /** Indentation string to use for inserted lines (derived from surrounding context) */
4069
+ indent: string;
4070
+ /** Offset of the provider object's opening `{` */
4071
+ providerObjectBrace: number;
4072
+ /** Offset of the provider object's closing `}` */
4073
+ providerObjectEnd: number;
4074
+ /** Offset of the provider-level compat object's opening `{`, or -1 if absent */
4075
+ providerCompatBrace: number;
4076
+ /** Offset of the provider-level compat object's closing `}`, or -1 if absent */
4077
+ providerCompatEnd: number;
4078
+ /** All model ids found in this provider's models array (for placement safety analysis) */
4079
+ allModelIds: string[];
4080
+ }
4081
+
4082
+ /**
4083
+ * Locate the provider + model entry in raw JSONC text.
4084
+ * Returns the positions needed for surgical insertion, or undefined on failure.
4085
+ *
4086
+ * This is a scan-only pass — no AST build, no regex reliance.
4087
+ */
4088
+ function locateModelInJsonc(
4089
+ text: string,
4090
+ providerLabel: string,
4091
+ modelId: string,
4092
+ ): ModelNodeLocation | undefined {
4093
+ // Clean text of comments first for reliable structural scanning
4094
+ const clean = stripJsoncComments(text);
4095
+
4096
+ // Strategy: find `"providers"` key in the root object, then find the
4097
+ // provider key under it, then the `"models"` array, then the array
4098
+ // element whose `"id"` matches. We map via the stripped text (comment
4099
+ // removal replaces comment chars with spaces, preserving offsets).
4100
+
4101
+ const pos = clean.indexOf('"providers"');
4102
+ if (pos < 0) return undefined;
4103
+
4104
+ // Scan from `"providers"` to find the `{` of the provider block
4105
+ let cur = pos + '"providers"'.length;
4106
+ // Skip `:`, whitespace, etc.
4107
+ while (cur < clean.length && clean[cur] !== '{') cur++;
4108
+ if (cur >= clean.length) return undefined;
4109
+ cur++; // Skip `{`
4110
+
4111
+ // Now scan key-value pairs in the providers object to find the matching providerLabel
4112
+ const providerLabelJson = JSON.stringify(providerLabel);
4113
+ let providerBrace = -1;
4114
+ let providerEndBrace = -1;
4115
+
4116
+ while (cur < clean.length) {
4117
+ // Skip whitespace/comments
4118
+ while (cur < clean.length && (clean[cur] === ' ' || clean[cur] === '\n' || clean[cur] === '\r' || clean[cur] === '\t')) cur++;
4119
+ if (cur >= clean.length) break;
4120
+ if (clean[cur] === '}') break; // End of providers
4121
+
4122
+ // Try to read a string key
4123
+ if (clean[cur] !== '"') { cur++; continue; }
4124
+ const keyEnd = clean.indexOf('"', cur + 1);
4125
+ if (keyEnd < 0) return undefined;
4126
+ const key = clean.slice(cur + 1, keyEnd);
4127
+ cur = keyEnd + 1;
4128
+
4129
+ // Skip `:`
4130
+ while (cur < clean.length && clean[cur] !== ':') cur++;
4131
+ if (cur >= clean.length) return undefined;
4132
+ cur++; // Skip `:`
4133
+ while (cur < clean.length && (clean[cur] === ' ' || clean[cur] === '\n' || clean[cur] === '\r' || clean[cur] === '\t')) cur++;
4134
+
4135
+ if (key === providerLabel) {
4136
+ // Found — expect `{` starting the provider object
4137
+ if (clean[cur] !== '{') return undefined;
4138
+ providerBrace = cur;
4139
+ // Find matching closing `}` for the provider object (track depth)
4140
+ let depth = 1;
4141
+ let scan = cur + 1;
4142
+ while (scan < clean.length && depth > 0) {
4143
+ if (clean[scan] === '{') depth++;
4144
+ else if (clean[scan] === '}') depth--;
4145
+ if (depth > 0) scan++;
4146
+ }
4147
+ providerEndBrace = scan;
4148
+ break;
4149
+ }
4150
+
4151
+ // Skip the value
4152
+ if (clean[cur] === '{') {
4153
+ let depth = 1;
4154
+ cur++;
4155
+ while (cur < clean.length && depth > 0) {
4156
+ if (clean[cur] === '{') depth++;
4157
+ else if (clean[cur] === '}') depth--;
4158
+ cur++;
4159
+ }
4160
+ } else if (clean[cur] === '[') {
4161
+ let depth = 1;
4162
+ cur++;
4163
+ while (cur < clean.length && depth > 0) {
4164
+ if (clean[cur] === '[') depth++;
4165
+ else if (clean[cur] === ']') depth--;
4166
+ cur++;
4167
+ }
4168
+ } else if (clean[cur] === '"') {
4169
+ const strEnd = clean.indexOf('"', cur + 1);
4170
+ if (strEnd < 0) return undefined;
4171
+ cur = strEnd + 1;
4172
+ } else {
4173
+ // Number, boolean, etc.
4174
+ while (cur < clean.length && clean[cur] !== ',' && clean[cur] !== '}' && clean[cur] !== '\n') cur++;
4175
+ }
4176
+ // Skip comma
4177
+ if (cur < clean.length && clean[cur] === ',') cur++;
4178
+ }
4179
+
4180
+ if (providerBrace < 0 || providerEndBrace < 0) return undefined;
4181
+
4182
+ // Scan provider object at depth 1 for a provider-level "compat" object.
4183
+ // Depth-aware + string-aware so nested model compat objects are not confused
4184
+ // with the provider-level one.
4185
+ let providerCompatBrace = -1;
4186
+ let providerCompatEnd = -1;
4187
+ {
4188
+ let pScan = providerBrace + 1;
4189
+ let pDepth = 1;
4190
+ while (pScan < providerEndBrace && pDepth > 0) {
4191
+ const ch = clean[pScan];
4192
+ if (ch === '"') {
4193
+ // Read the string (key or value) fully
4194
+ const strEnd = clean.indexOf('"', pScan + 1);
4195
+ if (strEnd < 0) break;
4196
+ const str = clean.slice(pScan + 1, strEnd);
4197
+ if (pDepth === 1 && str === 'compat') {
4198
+ // Confirm it's a key: next non-ws char must be ':'
4199
+ let after = strEnd + 1;
4200
+ while (after < providerEndBrace && (clean[after] === ' ' || clean[after] === '\n' || clean[after] === '\r' || clean[after] === '\t')) after++;
4201
+ if (clean[after] === ':') {
4202
+ after++;
4203
+ while (after < providerEndBrace && (clean[after] === ' ' || clean[after] === '\n' || clean[after] === '\r' || clean[after] === '\t')) after++;
4204
+ if (clean[after] === '{') {
4205
+ providerCompatBrace = after;
4206
+ let d = 1;
4207
+ let s = after + 1;
4208
+ while (s < clean.length && d > 0) {
4209
+ if (clean[s] === '"') {
4210
+ const e = clean.indexOf('"', s + 1);
4211
+ if (e < 0) break;
4212
+ s = e + 1;
4213
+ continue;
4214
+ }
4215
+ if (clean[s] === '{') d++;
4216
+ else if (clean[s] === '}') d--;
4217
+ if (d > 0) s++;
4218
+ }
4219
+ providerCompatEnd = s;
4220
+ pScan = s + 1;
4221
+ continue;
4222
+ }
4223
+ }
4224
+ }
4225
+ pScan = strEnd + 1;
4226
+ continue;
4227
+ }
4228
+ if (ch === '{' || ch === '[') pDepth++;
4229
+ else if (ch === '}' || ch === ']') pDepth--;
4230
+ pScan++;
4231
+ }
4232
+ }
4233
+
4234
+ // Now find the `"models"` array within the provider
4235
+ const providerContent = clean.slice(providerBrace + 1, providerEndBrace);
4236
+ const modelsIdx = providerContent.indexOf('"models"');
4237
+ if (modelsIdx < 0) return undefined;
4238
+
4239
+ // Find the `[` of the models array
4240
+ let modelsScan = providerBrace + 1 + modelsIdx + '"models"'.length;
4241
+ while (modelsScan < clean.length && clean[modelsScan] !== '[') modelsScan++;
4242
+ if (modelsScan >= clean.length) return undefined;
4243
+ modelsScan++; // Skip `[`
4244
+
4245
+ // Scan ALL array elements: collect every model id, and record the target's position
4246
+ const allModelIds: string[] = [];
4247
+ let modelBrace = -1;
4248
+ let modelEndBrace = -1;
4249
+ let compatKeyStartClean = -1;
4250
+ let compatBrace = -1;
4251
+ let compatEndBrace = -1;
4252
+
4253
+ while (modelsScan < clean.length) {
4254
+ // Skip whitespace/comma
4255
+ while (modelsScan < clean.length && (clean[modelsScan] === ' ' || clean[modelsScan] === '\n' || clean[modelsScan] === '\r' || clean[modelsScan] === '\t' || clean[modelsScan] === ',')) modelsScan++;
4256
+ if (modelsScan >= clean.length) break;
4257
+ if (clean[modelsScan] === ']') break; // End of array
4258
+
4259
+ if (clean[modelsScan] !== '{') { modelsScan++; continue; }
4260
+
4261
+ // Found a model object `{`
4262
+ const elementBrace = modelsScan;
4263
+
4264
+ // Find the matching closing `}` and extract this element's `"id"` at depth 1
4265
+ let depth = 1;
4266
+ let scan = modelsScan + 1;
4267
+ let elementId: string | undefined;
4268
+
4269
+ while (scan < clean.length && depth > 0) {
4270
+ if (clean[scan] === '"') {
4271
+ const strEnd = clean.indexOf('"', scan + 1);
4272
+ if (strEnd < 0) break;
4273
+ if (depth === 1 && elementId === undefined && clean.slice(scan, scan + 4) === '"id"') {
4274
+ // Found "id" key — find the colon and the value
4275
+ let afterKey = scan + 4;
4276
+ while (afterKey < clean.length && clean[afterKey] !== ':') afterKey++;
4277
+ if (afterKey < clean.length) {
4278
+ afterKey++; // skip ':'
4279
+ while (afterKey < clean.length && (clean[afterKey] === ' ' || clean[afterKey] === '\n' || clean[afterKey] === '\r' || clean[afterKey] === '\t')) afterKey++;
4280
+ if (afterKey < clean.length && clean[afterKey] === '"') {
4281
+ const idStart = afterKey + 1;
4282
+ const idEnd = clean.indexOf('"', idStart);
4283
+ if (idEnd > idStart) {
4284
+ elementId = clean.slice(idStart, idEnd);
4285
+ }
4286
+ }
4287
+ }
4288
+ }
4289
+ scan = strEnd + 1;
4290
+ continue;
4291
+ }
4292
+ if (clean[scan] === '{') depth++;
4293
+ else if (clean[scan] === '}') depth--;
4294
+ scan++;
4295
+ }
4296
+
4297
+ const elementEnd = scan - 1; // The `}` that closed this element
4298
+
4299
+ if (elementId !== undefined) {
4300
+ allModelIds.push(elementId);
4301
+ }
4302
+
4303
+ if (elementId === modelId && modelBrace < 0) {
4304
+ // This is the target model — record its position and find its compat
4305
+ modelBrace = elementBrace;
4306
+ modelEndBrace = elementEnd;
4307
+ const modelContent = clean.slice(modelBrace + 1, modelEndBrace);
4308
+ const compatIdx = modelContent.indexOf('"compat"');
4309
+ if (compatIdx >= 0) {
4310
+ compatKeyStartClean = modelBrace + 1 + compatIdx;
4311
+ let compatScan = compatKeyStartClean + '"compat"'.length;
4312
+ while (compatScan < clean.length && clean[compatScan] !== ':') compatScan++;
4313
+ compatScan++;
4314
+ while (compatScan < clean.length && (clean[compatScan] === ' ' || clean[compatScan] === '\n' || clean[compatScan] === '\r' || clean[compatScan] === '\t')) compatScan++;
4315
+ if (compatScan < clean.length && clean[compatScan] === '{') {
4316
+ compatBrace = compatScan;
4317
+ let cdepth = 1;
4318
+ let cscan = compatScan + 1;
4319
+ while (cscan < clean.length && cdepth > 0) {
4320
+ if (clean[cscan] === '{') cdepth++;
4321
+ else if (clean[cscan] === '}') cdepth--;
4322
+ if (cdepth > 0) cscan++;
4323
+ }
4324
+ compatEndBrace = cscan;
4325
+ }
4326
+ }
4327
+ }
4328
+
4329
+ modelsScan = scan;
4330
+ }
4331
+
4332
+ if (modelBrace < 0 || modelEndBrace < 0) return undefined;
4333
+
4334
+ // Derive indentation from the model object's opening `{` line in original text
4335
+ // Look backwards to find the line start
4336
+ let lineStart = text.lastIndexOf('\n', modelBrace);
4337
+ if (lineStart < 0) lineStart = 0;
4338
+ const lineBefore = text.slice(lineStart, modelBrace);
4339
+ const indentMatch = lineBefore.match(/^(\s*)/);
4340
+ const baseIndent = indentMatch ? indentMatch[1] : ' ';
4341
+ const indent = baseIndent + ' '; // +2 for one level deeper
4342
+
4343
+ return {
4344
+ modelObjectBrace: modelBrace,
4345
+ modelObjectEnd: modelEndBrace,
4346
+ compatKeyStart: compatKeyStartClean >= 0 ? compatKeyStartClean : -1,
4347
+ compatObjectBrace: compatBrace,
4348
+ compatObjectEnd: compatEndBrace,
4349
+ indent,
4350
+ providerObjectBrace: providerBrace,
4351
+ providerObjectEnd: providerEndBrace,
4352
+ providerCompatBrace,
4353
+ providerCompatEnd,
4354
+ allModelIds,
4355
+ };
4356
+ }
4357
+
4358
+ /**
4359
+ * Deep-equal comparison of two values, used for post-write self-check.
4360
+ * Compares all keys recursively, allowing `extraKeys` to be present in `a` but not in `b`.
4361
+ */
4362
+ function deepEqualIgnoringKeys(a: unknown, b: unknown, extraKeys: string[]): boolean {
4363
+ if (a === b) return true;
4364
+ if (typeof a !== typeof b) return false;
4365
+ if (Array.isArray(a) && Array.isArray(b)) {
4366
+ if (a.length !== b.length) return false;
4367
+ for (let i = 0; i < a.length; i++) {
4368
+ if (!deepEqualIgnoringKeys(a[i], b[i], extraKeys)) return false;
4369
+ }
4370
+ return true;
4371
+ }
4372
+ if (typeof a === 'object' && a !== null && typeof b === 'object' && b !== null) {
4373
+ const aKeys = Object.keys(a as Record<string, unknown>).filter(k => !extraKeys.includes(k));
4374
+ const bKeys = Object.keys(b as Record<string, unknown>);
4375
+ if (aKeys.length !== bKeys.length) return false;
4376
+ for (const k of aKeys) {
4377
+ if (!(k in (b as Record<string, unknown>))) return false;
4378
+ if (!deepEqualIgnoringKeys(
4379
+ (a as Record<string, unknown>)[k],
4380
+ (b as Record<string, unknown>)[k],
4381
+ extraKeys,
4382
+ )) return false;
4383
+ }
4384
+ return true;
4385
+ }
4386
+ return false;
4387
+ }
4388
+
4389
+ /**
4390
+ * Compose the fix: produce the modified text with compat keys inserted.
4391
+ *
4392
+ * Strategy:
4393
+ * - If compat object exists: replace its interior (between `{` and `}`)
4394
+ * with new keys + existing content, preserving surrounding bytes.
4395
+ * - If compat doesn't exist: insert `"compat": { keys }` after model `{`.
4396
+ *
4397
+ * Uses the raw original text; only the inserted/compat region changes.
4398
+ */
4399
+ /**
4400
+ * Compat keys that describe CHANNEL capabilities (routing, endpoint features).
4401
+ * These are always safe at the provider level because they do not change
4402
+ * per-model request semantics.
4403
+ */
4404
+ const PROVIDER_LEVEL_SAFE_COMPAT_KEYS = new Set<string>([
4405
+ "sendSessionAffinityHeaders",
4406
+ "sendSessionIdHeader",
4407
+ "supportsLongCacheRetention",
4408
+ ]);
4409
+
4410
+ function syntheticModelForId(providerLabel: string, id: string): PiModel {
4411
+ return { provider: providerLabel, id, name: id } as PiModel;
4412
+ }
4413
+
4414
+ /**
4415
+ * Decide whether the fix should write provider-level or model-level compat.
4416
+ *
4417
+ * Strategy (auto-detect, prefer provider level when safe):
4418
+ * - Channel-capability keys (session affinity / long retention) are always
4419
+ * provider-safe.
4420
+ * - Model-behavior keys (forceAdaptiveThinking, thinkingFormat, ...) are
4421
+ * provider-safe ONLY when every sibling model in the provider also matches
4422
+ * the same detection (all adaptive-generation / all DeepSeek-like).
4423
+ * - Single-model providers: provider level is equivalent — prefer it.
4424
+ * - Any unsafe key → fall back to model level (single write, smallest blast radius).
4425
+ */
4426
+ function decideFixPlacement(
4427
+ compatKeys: Record<string, unknown>,
4428
+ providerLabel: string,
4429
+ allModelIds: string[],
4430
+ ): { placement: "provider" | "model"; reason: string } {
4431
+ const siblings = allModelIds.filter(Boolean);
4432
+
4433
+ if (siblings.length <= 1) {
4434
+ return {
4435
+ placement: "provider",
4436
+ reason: "this provider has only one model — provider-level compat is equivalent and easier to maintain",
4437
+ };
4438
+ }
4439
+
4440
+ const unsafeKeys: string[] = [];
4441
+ for (const key of Object.keys(compatKeys)) {
4442
+ if (PROVIDER_LEVEL_SAFE_COMPAT_KEYS.has(key)) continue;
4443
+
4444
+ if (key === "forceAdaptiveThinking") {
4445
+ const allAdaptive = siblings.every((id) => isAdaptiveGenerationModel(syntheticModelForId(providerLabel, id)));
4446
+ if (!allAdaptive) unsafeKeys.push(key);
4447
+ continue;
4448
+ }
4449
+ if (key === "thinkingFormat" || key === "requiresReasoningContentOnAssistantMessages") {
4450
+ const allDeepSeek = siblings.every((id) => isDeepSeekLikeModel(syntheticModelForId(providerLabel, id)));
4451
+ if (!allDeepSeek) unsafeKeys.push(key);
4452
+ continue;
4453
+ }
4454
+ // Unknown model-behavior key — be conservative, keep it model-scoped.
4455
+ unsafeKeys.push(key);
4456
+ }
4457
+
4458
+ if (unsafeKeys.length === 0) {
4459
+ return {
4460
+ placement: "provider",
4461
+ reason: `all ${siblings.length} models in this provider are compatible with these flags`,
4462
+ };
4463
+ }
4464
+ return {
4465
+ placement: "model",
4466
+ reason: `${unsafeKeys.join(", ")} could break sibling models in this provider (${siblings.length} models total) — scoping to this model only`,
4467
+ };
4468
+ }
4469
+
4470
+ function composeFixInsertion(
4471
+ original: string,
4472
+ location: ModelNodeLocation,
4473
+ compatKeys: Record<string, unknown>,
4474
+ placement: "provider" | "model" = "model",
4475
+ ): string {
4476
+ // Resolve the target compat object and its container based on placement.
4477
+ const targetCompatBrace = placement === "provider" ? location.providerCompatBrace : location.compatObjectBrace;
4478
+ const targetCompatEnd = placement === "provider" ? location.providerCompatEnd : location.compatObjectEnd;
4479
+ const containerBrace = placement === "provider" ? location.providerObjectBrace : location.modelObjectBrace;
4480
+
4481
+ // Helper: format the new keys as lines with the given indent, alphabetically sorted.
4482
+ const formatKeys = (indent: string): string =>
4483
+ Object.entries(compatKeys)
4484
+ .sort(([a], [b]) => a.localeCompare(b))
4485
+ .map(([k, v]) => {
4486
+ const val = typeof v === 'string' ? `"${v}"` : JSON.stringify(v);
4487
+ return `${indent}${JSON.stringify(k)}: ${val}`;
4488
+ })
4489
+ .join(',\n');
4490
+
4491
+ // Helper: line-start indentation of the line containing `offset` in `original`.
4492
+ const lineIndentAt = (offset: number): string => {
4493
+ let ls = original.lastIndexOf('\n', offset);
4494
+ if (ls < 0) ls = -1;
4495
+ const line = original.slice(ls + 1, offset);
4496
+ const m = line.match(/^(\s*)/);
4497
+ return m ? m[1] : '';
4498
+ };
4499
+
4500
+ if (targetCompatBrace >= 0 && targetCompatEnd > targetCompatBrace) {
4501
+ // ── Existing compat object: insert new key lines right after `{`. ──
4502
+ // The existing interior is preserved BYTE-FOR-BYTE (no reflow, no re-indent).
4503
+ const interiorStart = targetCompatBrace + 1;
4504
+ const interior = original.slice(interiorStart, targetCompatEnd);
4505
+ const hasContent = interior.trim().length > 0;
4506
+
4507
+ // Indent for the new key lines: copy the first existing key line's indent,
4508
+ // else derive one level deeper than the compat brace's own line.
4509
+ const braceLineIndent = lineIndentAt(targetCompatBrace);
4510
+ const innerMatch = interior.match(/\r?\n([ \t]+)\S/);
4511
+ const innerIndent = innerMatch ? innerMatch[1] : braceLineIndent + ' ';
4512
+ const keysFormatted = formatKeys(innerIndent);
4513
+
4514
+ if (hasContent) {
4515
+ // `{` + "\n<new keys>," + <original interior untouched> + `}`
4516
+ return original.slice(0, interiorStart) + `\n${keysFormatted},` + original.slice(interiorStart);
4517
+ }
4518
+ // Empty compat `{}` (or whitespace only): write keys + put `}` back on its own line.
4519
+ return original.slice(0, interiorStart) + `\n${keysFormatted}\n${braceLineIndent}` + original.slice(targetCompatEnd);
4520
+ }
4521
+
4522
+ // ── No compat object yet: create one right after the container `{`. ──
4523
+ // Everything after the brace (including the next line's indentation) is
4524
+ // preserved byte-for-byte; we only prepend a complete `"compat": {...},` block.
4525
+ const afterBrace = containerBrace + 1;
4526
+ const suffix = original.slice(afterBrace);
4527
+
4528
+ // Key indent: copy the first sibling key line's indent from the suffix,
4529
+ // else one level deeper than the container brace's line.
4530
+ const containerLineIndent = lineIndentAt(containerBrace);
4531
+ const siblingMatch = suffix.match(/^\r?\n([ \t]+)\S/);
4532
+ const keyIndent = siblingMatch ? siblingMatch[1] : containerLineIndent + ' ';
4533
+
4534
+ // One more level for keys inside compat: reuse the file's own indent unit.
4535
+ const unit = keyIndent.startsWith(containerLineIndent) && keyIndent.length > containerLineIndent.length
4536
+ ? keyIndent.slice(containerLineIndent.length)
4537
+ : ' ';
4538
+ const innerIndent = keyIndent + unit;
4539
+
4540
+ const compatBlock = `\n${keyIndent}"compat": {\n${formatKeys(innerIndent)}\n${keyIndent}},`;
4541
+ return original.slice(0, afterBrace) + compatBlock + suffix;
4542
+ }
4543
+
4544
+ /**
4545
+ * Self-check after compose: parse original and modified via stripJsoncComments,
4546
+ * assert target compat flags exist in the right path, and remaining structure
4547
+ * is deep-equal (ignoring the inserted keys).
4548
+ * Returns null on success, error message on failure.
4549
+ */
4550
+ function selfCheckFix(
4551
+ original: string,
4552
+ modified: string,
4553
+ providerLabel: string,
4554
+ modelId: string,
4555
+ compatKeys: Record<string, unknown>,
4556
+ ): string | null {
4557
+ try {
4558
+ // Step 1: Parse both versions (this validates JSON syntax)
4559
+ const origParsed = JSON.parse(stripJsoncComments(original));
4560
+ const modParsed = JSON.parse(stripJsoncComments(modified));
4561
+
4562
+ // Step 2: Validate modified file has correct structure
4563
+ const providers = modParsed?.providers;
4564
+ if (!providers || typeof providers !== 'object') {
4565
+ return "Modified file: providers object missing or invalid";
4566
+ }
4567
+ const provider = providers[providerLabel];
4568
+ if (!provider || typeof provider !== 'object') {
4569
+ return `Modified file: provider "${providerLabel}" not found`;
4570
+ }
4571
+
4572
+ // Step 3: Validate models array structure
4573
+ const models = provider.models;
4574
+ if (!Array.isArray(models)) {
4575
+ return `Modified file: provider "${providerLabel}".models is not an array`;
4576
+ }
4577
+ if (models.length === 0) {
4578
+ return `Modified file: provider "${providerLabel}".models is empty`;
4579
+ }
4580
+
4581
+ // Step 4: Find and validate target model
4582
+ const targetModel = models.find((m: Record<string, unknown>) => m.id === modelId);
4583
+ if (!targetModel || typeof targetModel !== 'object') {
4584
+ return `Modified file: model "${modelId}" not found in provider`;
4585
+ }
4586
+
4587
+ // Step 5: Compute the EFFECTIVE merged compat (provider-level + model-level),
4588
+ // mirroring Pi's mergeCompat behavior (model wins on conflicts). The fix may
4589
+ // have written either level, so validation must check the merged result.
4590
+ const provCompatRaw = (provider as Record<string, unknown>).compat;
4591
+ const provCompat = (provCompatRaw && typeof provCompatRaw === 'object' && !Array.isArray(provCompatRaw))
4592
+ ? provCompatRaw as Record<string, unknown>
4593
+ : {};
4594
+ const modelCompatRaw = (targetModel as Record<string, unknown>).compat;
4595
+ if (modelCompatRaw !== undefined && (typeof modelCompatRaw !== 'object' || modelCompatRaw === null || Array.isArray(modelCompatRaw))) {
4596
+ return `Modified file: model "${modelId}" compat is not an object`;
4597
+ }
4598
+ const mdlCompat = (modelCompatRaw ?? {}) as Record<string, unknown>;
4599
+ const mergedCompat: Record<string, unknown> = { ...provCompat, ...mdlCompat };
4600
+
4601
+ // Step 6: Validate all inserted keys are effective in the merged compat
4602
+ for (const [k, v] of Object.entries(compatKeys)) {
4603
+ if (!(k in mergedCompat)) {
4604
+ return `Modified file: compat.${k} not found at provider or model level (insertion failed)`;
4605
+ }
4606
+ if (mergedCompat[k] !== v) {
4607
+ return `Modified file: effective compat.${k} has wrong value: expected ${JSON.stringify(v)}, got ${JSON.stringify(mergedCompat[k])}`;
4608
+ }
4609
+ }
4610
+
4611
+ // Step 7: Validate original structure is preserved (no accidental deletions/changes)
4612
+
4613
+ function isSubset(origVal: unknown, modVal: unknown, path = ''): boolean {
4614
+ if (origVal === modVal) return true;
4615
+ if (typeof origVal !== typeof modVal) return false;
4616
+ if (typeof origVal !== 'object' || origVal === null || modVal === null) return false;
4617
+ if (Array.isArray(origVal) !== Array.isArray(modVal)) return false;
4618
+ if (Array.isArray(origVal) && Array.isArray(modVal)) {
4619
+ if (origVal.length !== modVal.length) return false;
4620
+ return origVal.every((_, i) => isSubset(origVal[i], modVal[i], `${path}[${i}]`));
4621
+ }
4622
+ // Both objects: check that every key in orig is in mod with same value
4623
+ const origObj = origVal as Record<string, unknown>;
4624
+ const modObj = modVal as Record<string, unknown>;
4625
+ for (const key of Object.keys(origObj)) {
4626
+ if (!(key in modObj)) return false;
4627
+ if (key === 'compat') {
4628
+ // For compat, allow extra keys in modified (the inserted ones)
4629
+ if (typeof origObj[key] !== 'object' || typeof modObj[key] !== 'object') {
4630
+ if (origObj[key] !== modObj[key]) return false;
4631
+ } else {
4632
+ // Check all original compat keys are present and equal
4633
+ const origCompat = origObj[key] as Record<string, unknown>;
4634
+ const modCompat = modObj[key] as Record<string, unknown>;
4635
+ for (const ck of Object.keys(origCompat)) {
4636
+ if (origCompat[ck] !== modCompat[ck]) return false;
4637
+ }
4638
+ }
4639
+ } else if (!isSubset(origObj[key], modObj[key], `${path}.${key}`)) {
4640
+ return false;
4641
+ }
4642
+ }
4643
+ return true;
4644
+ }
4645
+
4646
+ if (!isSubset(origParsed, modParsed)) {
4647
+ return "Modified file: original structure was altered (data loss detected)";
4648
+ }
4649
+
4650
+ // Step 8: Basic format sanity checks
4651
+ if (modified.length < original.length) {
4652
+ return "Modified file: content is shorter than original (possible truncation)";
4653
+ }
4654
+
4655
+ // Step 9: Validate no syntax issues by checking brackets balance
4656
+ const openBraces = (modified.match(/{/g) || []).length;
4657
+ const closeBraces = (modified.match(/}/g) || []).length;
4658
+ if (openBraces !== closeBraces) {
4659
+ return `Modified file: bracket mismatch (${openBraces} open, ${closeBraces} close)`;
4660
+ }
4661
+
4662
+ return null;
4663
+ } catch (e) {
4664
+ return `Self-check error: ${e instanceof Error ? e.message : String(e)}`;
4665
+ }
4666
+ }
4667
+
4668
+ /**
4669
+ * Serialize a compat suggestion to the JSON text that will be inserted.
4670
+ * Returns the exact key-value pairs as a formatted JSON string without outer braces.
4671
+ */
4672
+ function formatCompatKeysForInsertion(compatKeys: Record<string, unknown>): string {
4673
+ return Object.entries(compatKeys)
4674
+ .map(([k, v]) => {
4675
+ const val = typeof v === 'string' ? `"${v}"` : String(v);
4676
+ return ` ${JSON.stringify(k)}: ${val}`;
4677
+ })
4678
+ .join(',\n');
4679
+ }
4680
+
4681
+ /**
4682
+ * Generate the timestamp string for backup filename.
4683
+ * Format: YYYYMMDDTHHMMSSZ (UTC)
4684
+ */
4685
+ function backupTimestamp(): string {
4686
+ const now = new Date();
4687
+ const y = now.getUTCFullYear();
4688
+ const m = String(now.getUTCMonth() + 1).padStart(2, '0');
4689
+ const d = String(now.getUTCDate()).padStart(2, '0');
4690
+ const h = String(now.getUTCHours()).padStart(2, '0');
4691
+ const min = String(now.getUTCMinutes()).padStart(2, '0');
4692
+ const s = String(now.getUTCSeconds()).padStart(2, '0');
4693
+ return `${y}${m}${d}T${h}${min}${s}Z`;
4694
+ }
4695
+
3639
4696
  // Internal helpers exported only so the task verification script
3640
4697
  // (.trellis/tasks/.../verify.ts) can exercise them. They are not part of the
3641
4698
  // extension's public API; pi only invokes the default export below.
@@ -3658,6 +4715,9 @@ export const __internals_for_tests = {
3658
4715
  isNonEmptyString,
3659
4716
  shouldInjectOpenAIPromptCacheKey,
3660
4717
  isOpenAICompatibleApi,
4718
+ isOpenAICompatibleProxyApi,
4719
+ isResponsesPromptRewriteBypassApi,
4720
+ isMistralConversationsApi,
3661
4721
  isOpenAIFamilyModel,
3662
4722
  isOpenAIFamilyAssistantMessage,
3663
4723
  isOpenAIFamilyToken,
@@ -3806,7 +4866,6 @@ export const __internals_for_tests = {
3806
4866
  // Integrity diagnostics
3807
4867
  getLastPromptIntegrityWarningAt,
3808
4868
  // Diagnostic command helpers
3809
- isCompatCheckApplicable,
3810
4869
  buildDoctorDiagnosis,
3811
4870
  buildCompatDiagnosis,
3812
4871
  describeRouterChannelDiagnostics,
@@ -3838,6 +4897,25 @@ export const __internals_for_tests = {
3838
4897
  STATE_FILE_PATH,
3839
4898
  LEGACY_STATE_FILE_PATH,
3840
4899
  STATE_DIR,
4900
+ // JSONC surgical edit helpers
4901
+ MODELS_JSON_PATH,
4902
+ stripJsoncComments,
4903
+ locateModelInJsonc,
4904
+ composeFixInsertion,
4905
+ selfCheckFix,
4906
+ decideFixPlacement,
4907
+ deepEqualIgnoringKeys,
4908
+ formatCompatKeysForInsertion,
4909
+ backupTimestamp,
4910
+ // Fix suggestion builder
4911
+ buildFixSuggestion,
4912
+ // Adaptive thinking compat helpers
4913
+ isAdaptiveGenerationModel,
4914
+ isAdaptiveThinkingCompatApplicable,
4915
+ describeMissingAdaptiveThinkingCompat,
4916
+ buildAdaptiveThinkingCompatSuggestion,
4917
+ buildAdaptiveThinkingCompatWarningText,
4918
+ appendAdaptiveThinkingCompatAdviceLines,
3841
4919
  };
3842
4920
 
3843
4921
  export default function (pi: ExtensionAPI) {
@@ -4130,7 +5208,7 @@ export default function (pi: ExtensionAPI) {
4130
5208
 
4131
5209
  pi.on("before_agent_start", async (event, _ctx) => {
4132
5210
  // ────────────────────────────────────────────────────────────────
4133
- // OpenAI Responses API bypass (codex-responses + responses)
5211
+ // OpenAI Responses-family bypass (codex-responses + responses + azure responses)
4134
5212
  //
4135
5213
  // OpenAI's Responses API endpoints — both the Codex backend
4136
5214
  // (openai-codex-responses, chatgpt.com) and the public
@@ -4156,11 +5234,8 @@ export default function (pi: ExtensionAPI) {
4156
5234
  // that use openai-completions are unaffected.
4157
5235
  // ────────────────────────────────────────────────────────────────
4158
5236
  const model = _ctx.model;
4159
- if (model) {
4160
- const api = lower(model.api);
4161
- if (api === "openai-codex-responses" || api === "openai-responses") {
4162
- return {};
4163
- }
5237
+ if (model && isResponsesPromptRewriteBypassApi(model.api)) {
5238
+ return {};
4164
5239
  }
4165
5240
 
4166
5241
  if (!runtimeOptimizerEnabled) return {};
@@ -4282,6 +5357,7 @@ export default function (pi: ExtensionAPI) {
4282
5357
  // with low-hit diagnosis
4283
5358
  // stats — show active model stats bucket, recent trend, usage
4284
5359
  // compat — show compat suggestion with file path
5360
+ // fix — auto-fix compat issues (writes models.json, requires UI)
4285
5361
  // reset — reset current session model stats bucket (local only)
4286
5362
  // (no args) — interactive menu (with UI) or help summary
4287
5363
  // ────────────────────────────────────────────────────────────────
@@ -4340,9 +5416,9 @@ export default function (pi: ExtensionAPI) {
4340
5416
  cmdCtx.ui.notify(compatResult, "warning");
4341
5417
  } else {
4342
5418
  cmdCtx.ui.notify(
4343
- isDeepSeekCompatCheckApplicable(model) || isCompatCheckApplicable(model)
5419
+ isAdaptiveThinkingCompatApplicable(model) || isDeepSeekCompatCheckApplicable(model) || isCompatCheckApplicable(model)
4344
5420
  ? "✅ Compat fully configured."
4345
- : "ℹ️ Compat check not applicable for this model.",
5421
+ : getCompatCheckNotApplicableLines(model).join("\n"),
4346
5422
  "info",
4347
5423
  );
4348
5424
  }
@@ -4378,6 +5454,159 @@ export default function (pi: ExtensionAPI) {
4378
5454
  "New requests will start a fresh stats bucket for this Pi session.",
4379
5455
  "info",
4380
5456
  );
5457
+ } else if (subcommand === "fix") {
5458
+ if (!model) {
5459
+ cmdCtx.ui.notify("No active model selected. Select a model first with /model or pi --model.", "warning");
5460
+ return;
5461
+ }
5462
+
5463
+ const suggestion = buildFixSuggestion(model);
5464
+ if (!suggestion) {
5465
+ const key = modelKey(model);
5466
+ cmdCtx.ui.notify(`✅ Nothing to fix for "${key}". Compat already configured.`, "info");
5467
+ return;
5468
+ }
5469
+
5470
+ if (!cmdCtx.hasUI) {
5471
+ // No UI — refuse to write, show manual guidance instead.
5472
+ const compatResult = buildCompatDiagnosis(model);
5473
+ if (compatResult) {
5474
+ cmdCtx.ui.notify(
5475
+ `❌ Non-interactive terminal detected. Auto-fix requires UI confirmation.\n\n` +
5476
+ `Manual steps:\n` +
5477
+ `1. Open ${getModelsJsonDisplayPath()} in your editor.\n` +
5478
+ `2. Go to providers["${suggestion.providerLabel}"] -> models -> entry with id "${suggestion.modelId}" -> compat.\n` +
5479
+ `3. Add the missing keys:\n${formatCompatKeysForInsertion(suggestion.compatKeys)}\n` +
5480
+ `4. Save and run /reload.\n\n` +
5481
+ compatResult,
5482
+ "warning",
5483
+ );
5484
+ } else {
5485
+ cmdCtx.ui.notify(
5486
+ `❌ Non-interactive terminal detected. Auto-fix requires UI confirmation.\n` +
5487
+ `Edit ${getModelsJsonDisplayPath()} manually and run /reload.`,
5488
+ "warning",
5489
+ );
5490
+ }
5491
+ return;
5492
+ }
5493
+
5494
+ // Read the models.json file
5495
+ let originalText: string;
5496
+ try {
5497
+ originalText = await readFile(MODELS_JSON_PATH, "utf8");
5498
+ } catch {
5499
+ cmdCtx.ui.notify(`❌ Could not read ${MODELS_JSON_PATH}. File may not exist.`, "error");
5500
+ return;
5501
+ }
5502
+
5503
+ // Locate the model entry
5504
+ const location = locateModelInJsonc(originalText, suggestion.providerLabel, suggestion.modelId);
5505
+ if (!location) {
5506
+ cmdCtx.ui.notify(
5507
+ `❌ Could not locate model "${suggestion.modelId}" in ${getModelsJsonDisplayPath()}.\n` +
5508
+ `The JSONC scanner could not confidently find the target entry.\n` +
5509
+ `Manual edit required: open the file, find providers["${suggestion.providerLabel}"] -> models, and add:\n` +
5510
+ `${formatCompatKeysForInsertion(suggestion.compatKeys)}\n` +
5511
+ `Then run /reload.`,
5512
+ "warning",
5513
+ );
5514
+ return;
5515
+ }
5516
+
5517
+ // Compose the modified text — auto-detect the best placement level:
5518
+ // provider level (channel-wide) when safe for all sibling models, else model level.
5519
+ const decision = decideFixPlacement(suggestion.compatKeys, suggestion.providerLabel, location.allModelIds);
5520
+ const modifiedText = composeFixInsertion(originalText, location, suggestion.compatKeys, decision.placement);
5521
+
5522
+ // Self-check
5523
+ const checkError = selfCheckFix(originalText, modifiedText, suggestion.providerLabel, suggestion.modelId, suggestion.compatKeys);
5524
+ if (checkError !== null) {
5525
+ cmdCtx.ui.notify(
5526
+ `❌ Self-check failed before write: ${checkError}\n` +
5527
+ `No changes were made. Manual edit required.`,
5528
+ "error",
5529
+ );
5530
+ return;
5531
+ }
5532
+
5533
+ // Build preview snippet
5534
+ const keysPreview = Object.entries(suggestion.compatKeys)
5535
+ .map(([k, v]) => ` ${k}: ${JSON.stringify(v)}`)
5536
+ .join("\n");
5537
+ const targetHasCompat = decision.placement === "provider" ? location.providerCompatBrace >= 0 : location.compatObjectBrace >= 0;
5538
+ const placementDesc = targetHasCompat ? `existing "compat" object` : `new "compat" object`;
5539
+ const locationDesc = decision.placement === "provider"
5540
+ ? `providers["${suggestion.providerLabel}"] -> compat (provider level, ${placementDesc})`
5541
+ : `providers["${suggestion.providerLabel}"] -> models -> "${suggestion.modelId}" -> compat (model level, ${placementDesc})`;
5542
+
5543
+ const ts = backupTimestamp();
5544
+ const backupPath = `${MODELS_JSON_PATH}.backup-cache-optimizer-${ts}`;
5545
+
5546
+ const scopeRiskLine = decision.placement === "provider"
5547
+ ? ` 1. This change applies to ALL ${location.allModelIds.length || 1} model(s) in the "${suggestion.providerLabel}" provider, across all sessions.`
5548
+ : ` 1. This change affects ALL sessions using the "${suggestion.providerLabel}" provider/channel (scoped to model "${suggestion.modelId}").`;
5549
+
5550
+ const previewLines = [
5551
+ `📝 Preview of changes to ${getModelsJsonDisplayPath()}:`,
5552
+ ``,
5553
+ `Location: ${locationDesc}`,
5554
+ `Placement: ${decision.placement} level — ${decision.reason}`,
5555
+ `Keys to insert:`,
5556
+ keysPreview,
5557
+ ``,
5558
+ `⚠️ Risk notice:`,
5559
+ scopeRiskLine,
5560
+ ` 2. A timestamped backup will be written to: ${backupPath}`,
5561
+ ` 3. You must restart Pi / run /reload for the change to take effect.`,
5562
+ ` 4. If the file contains comments or unusual formatting, please verify the result after write.`,
5563
+ ``,
5564
+ `Apply these changes?`,
5565
+ ];
5566
+
5567
+ const confirmed = await cmdCtx.ui.confirm("Cache Optimizer — Fix", previewLines.join("\n"));
5568
+ if (!confirmed) {
5569
+ cmdCtx.ui.notify("No changes were made. Canceled by user.", "info");
5570
+ return;
5571
+ }
5572
+
5573
+ // Write: backup → temp + rename → self-check again
5574
+ try {
5575
+ // Backup
5576
+ await copyFile(MODELS_JSON_PATH, backupPath);
5577
+
5578
+ // Atomic write
5579
+ const tempPath = `${MODELS_JSON_PATH}.${process.pid}.${Date.now()}.fix.tmp`;
5580
+ await writeFile(tempPath, modifiedText, "utf8");
5581
+ await rename(tempPath, MODELS_JSON_PATH);
5582
+
5583
+ // Post-write self-check (read back)
5584
+ const writtenText = await readFile(MODELS_JSON_PATH, "utf8");
5585
+ const postCheckError = selfCheckFix(originalText, writtenText, suggestion.providerLabel, suggestion.modelId, suggestion.compatKeys);
5586
+ if (postCheckError !== null) {
5587
+ // Restore from backup
5588
+ await copyFile(backupPath, MODELS_JSON_PATH);
5589
+ cmdCtx.ui.notify(
5590
+ `❌ Post-write self-check failed: ${postCheckError}\n` +
5591
+ `The backup at ${backupPath} has been restored. No changes applied.`,
5592
+ "error",
5593
+ );
5594
+ return;
5595
+ }
5596
+
5597
+ cmdCtx.ui.notify(
5598
+ `✅ Fix applied to ${getModelsJsonDisplayPath()}.\n` +
5599
+ `Backup saved to: ${backupPath}\n` +
5600
+ `Run /reload or restart Pi for the change to take effect.`,
5601
+ "info",
5602
+ );
5603
+ } catch (writeError) {
5604
+ cmdCtx.ui.notify(
5605
+ `❌ Write failed: ${writeError instanceof Error ? writeError.message : String(writeError)}\n` +
5606
+ `Backup may be at: ${backupPath}`,
5607
+ "error",
5608
+ );
5609
+ }
4381
5610
  } else {
4382
5611
  // Try interactive selection menu when UI supports it
4383
5612
  if (cmdCtx.hasUI) {
@@ -4387,6 +5616,7 @@ export default function (pi: ExtensionAPI) {
4387
5616
  "Doctor — Show cache configuration",
4388
5617
  "Stats — Show cache stats and trend",
4389
5618
  "Compat — Show compat suggestion",
5619
+ "Fix — Auto-fix compat issues (writes models.json)",
4390
5620
  "Reset — Reset local session stats",
4391
5621
  "Cancel",
4392
5622
  ];
@@ -4438,14 +5668,117 @@ export default function (pi: ExtensionAPI) {
4438
5668
  cmdCtx.ui.notify(compatResult, "warning");
4439
5669
  } else {
4440
5670
  cmdCtx.ui.notify(
4441
- isDeepSeekCompatCheckApplicable(model) || isCompatCheckApplicable(model)
5671
+ isAdaptiveThinkingCompatApplicable(model) || isDeepSeekCompatCheckApplicable(model) || isCompatCheckApplicable(model)
4442
5672
  ? "✅ Compat fully configured."
4443
- : "ℹ️ Compat check not applicable for this model.",
5673
+ : getCompatCheckNotApplicableLines(model).join("\n"),
4444
5674
  "info",
4445
5675
  );
4446
5676
  }
4447
5677
  }
4448
5678
  } else if (choice === menuOptions[5]) {
5679
+ // Fix — auto-fix compat issues
5680
+ if (!model) {
5681
+ cmdCtx.ui.notify("No active model selected. Select a model first with /model or pi --model.", "warning");
5682
+ return;
5683
+ }
5684
+ const suggestion = buildFixSuggestion(model);
5685
+ if (!suggestion) {
5686
+ const key = modelKey(model);
5687
+ cmdCtx.ui.notify(`✅ Nothing to fix for "${key}". Compat already configured.`, "info");
5688
+ return;
5689
+ }
5690
+
5691
+ // Read models.json
5692
+ let originalText: string;
5693
+ try {
5694
+ originalText = await readFile(MODELS_JSON_PATH, "utf8");
5695
+ } catch {
5696
+ cmdCtx.ui.notify(`❌ Could not read ${MODELS_JSON_PATH}. File may not exist.`, "error");
5697
+ return;
5698
+ }
5699
+
5700
+ const location = locateModelInJsonc(originalText, suggestion.providerLabel, suggestion.modelId);
5701
+ if (!location) {
5702
+ cmdCtx.ui.notify(
5703
+ `❌ Could not locate model "${suggestion.modelId}" in ${getModelsJsonDisplayPath()}.\n` +
5704
+ `Manual edit required: open the file and add:\n` +
5705
+ `${formatCompatKeysForInsertion(suggestion.compatKeys)}\n` +
5706
+ `Then run /reload.`,
5707
+ "warning",
5708
+ );
5709
+ return;
5710
+ }
5711
+
5712
+ const menuDecision = decideFixPlacement(suggestion.compatKeys, suggestion.providerLabel, location.allModelIds);
5713
+ const modifiedText = composeFixInsertion(originalText, location, suggestion.compatKeys, menuDecision.placement);
5714
+ const checkError = selfCheckFix(originalText, modifiedText, suggestion.providerLabel, suggestion.modelId, suggestion.compatKeys);
5715
+ if (checkError !== null) {
5716
+ cmdCtx.ui.notify(`❌ Self-check failed: ${checkError}\nNo changes made.`, "error");
5717
+ return;
5718
+ }
5719
+
5720
+ const keysPreview = Object.entries(suggestion.compatKeys)
5721
+ .map(([k, v]) => ` ${k}: ${JSON.stringify(v)}`)
5722
+ .join("\n");
5723
+ const ts = backupTimestamp();
5724
+ const backupPath = `${MODELS_JSON_PATH}.backup-cache-optimizer-${ts}`;
5725
+
5726
+ const menuLocationDesc = menuDecision.placement === "provider"
5727
+ ? `providers["${suggestion.providerLabel}"] -> compat (provider level)`
5728
+ : `providers["${suggestion.providerLabel}"] -> models -> "${suggestion.modelId}" -> compat (model level)`;
5729
+ const menuScopeRiskLine = menuDecision.placement === "provider"
5730
+ ? ` 1. This change applies to ALL ${location.allModelIds.length || 1} model(s) in the "${suggestion.providerLabel}" provider, across all sessions.`
5731
+ : ` 1. This change affects ALL sessions using the "${suggestion.providerLabel}" provider/channel (scoped to model "${suggestion.modelId}").`;
5732
+
5733
+ const previewLines = [
5734
+ `📝 Preview of changes to ${getModelsJsonDisplayPath()}:`,
5735
+ `Location: ${menuLocationDesc}`,
5736
+ `Placement: ${menuDecision.placement} level — ${menuDecision.reason}`,
5737
+ `Keys to insert:`,
5738
+ keysPreview,
5739
+ ``,
5740
+ `⚠️ Risk notice:`,
5741
+ menuScopeRiskLine,
5742
+ ` 2. A timestamped backup will be written to: ${backupPath}`,
5743
+ ` 3. You must restart Pi / run /reload for the change to take effect.`,
5744
+ ` 4. If the file contains comments, verify the result after write.`,
5745
+ ``,
5746
+ `Apply these changes?`,
5747
+ ];
5748
+
5749
+ const confirmed = await cmdCtx.ui.confirm("Cache Optimizer — Fix", previewLines.join("\n"));
5750
+ if (!confirmed) {
5751
+ cmdCtx.ui.notify("No changes were made. Canceled by user.", "info");
5752
+ return;
5753
+ }
5754
+
5755
+ try {
5756
+ await copyFile(MODELS_JSON_PATH, backupPath);
5757
+ const tempPath = `${MODELS_JSON_PATH}.${process.pid}.${Date.now()}.fix.tmp`;
5758
+ await writeFile(tempPath, modifiedText, "utf8");
5759
+ await rename(tempPath, MODELS_JSON_PATH);
5760
+
5761
+ const writtenText = await readFile(MODELS_JSON_PATH, "utf8");
5762
+ const postCheck = selfCheckFix(originalText, writtenText, suggestion.providerLabel, suggestion.modelId, suggestion.compatKeys);
5763
+ if (postCheck !== null) {
5764
+ await copyFile(backupPath, MODELS_JSON_PATH);
5765
+ cmdCtx.ui.notify(`❌ Post-write check failed: ${postCheck}\nBackup restored.`, "error");
5766
+ return;
5767
+ }
5768
+
5769
+ cmdCtx.ui.notify(
5770
+ `✅ Fix applied to ${getModelsJsonDisplayPath()}.` +
5771
+ `\nBackup: ${backupPath}` +
5772
+ `\nRun /reload or restart Pi for the change to take effect.`,
5773
+ "info",
5774
+ );
5775
+ } catch (writeError) {
5776
+ cmdCtx.ui.notify(
5777
+ `❌ Write failed: ${writeError instanceof Error ? writeError.message : String(writeError)}`,
5778
+ "error",
5779
+ );
5780
+ }
5781
+ } else if (choice === menuOptions[6]) {
4449
5782
  if (!model) {
4450
5783
  cmdCtx.ui.notify("No active model selected. Select a model first with /model or pi --model.", "warning");
4451
5784
  } else {
@@ -4479,6 +5812,7 @@ export default function (pi: ExtensionAPI) {
4479
5812
  diagnosis.push(" doctor — Show current model/provider/api/baseUrl/compat and low-hit diagnosis");
4480
5813
  diagnosis.push(" stats — Show active model stats bucket and recent trend");
4481
5814
  diagnosis.push(" compat — Show compat suggestion with edit location");
5815
+ diagnosis.push(" fix — Auto-fix compat issues (writes models.json, requires UI)");
4482
5816
  diagnosis.push(" reset — Reset local session stats for current model (does not affect upstream)");
4483
5817
  diagnosis.push("");
4484
5818
  diagnosis.push(formatOptimizerRuntimeMode());
@@ -4489,10 +5823,12 @@ export default function (pi: ExtensionAPI) {
4489
5823
  if (missing.length > 0) {
4490
5824
  diagnosis.push(`⚠️ Active model "${displayKey}" missing compat: ${missing.join(", ")}`);
4491
5825
  diagnosis.push('Run "/cache-optimizer compat" for edit instructions.');
4492
- } else if (isDeepSeekCompatCheckApplicable(model) || isCompatCheckApplicable(model)) {
5826
+ } else if (isAdaptiveThinkingCompatApplicable(model) || isDeepSeekCompatCheckApplicable(model) || isCompatCheckApplicable(model)) {
4493
5827
  diagnosis.push(`✅ Active model "${displayKey}": compat fully configured.`);
4494
5828
  } else {
4495
5829
  diagnosis.push(`ℹ️ Active model "${displayKey}": compat check not applicable.`);
5830
+ const detailLines = getCompatCheckNotApplicableLines(model).slice(1);
5831
+ for (const line of detailLines) diagnosis.push(line);
4496
5832
  }
4497
5833
  } else {
4498
5834
  diagnosis.push("No active model selected.");