pi-cache-optimizer 2.5.7 → 2.6.1

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 +1304 -10
  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
  /**
@@ -816,6 +831,78 @@ function isGeminiLikeAssistantMessage(message: unknown, model: PiModel | undefin
816
831
  return modelOrAssistantMessageHas(message, model, ["gemini", "vertex"]);
817
832
  }
818
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
+
819
906
  // ── Non-GPT OpenAI-compatible model detection ──────────────────────
820
907
 
821
908
  function isKimiLikeModel(model: PiModel | undefined): boolean {
@@ -1706,6 +1793,9 @@ function isDeepSeekCompatCheckApplicable(model: PiModel): boolean {
1706
1793
  }
1707
1794
 
1708
1795
  function describeMissingCacheCompatForModel(model: PiModel): string[] {
1796
+ if (isAdaptiveThinkingCompatApplicable(model)) {
1797
+ return describeMissingAdaptiveThinkingCompat(model);
1798
+ }
1709
1799
  if (isDeepSeekCompatCheckApplicable(model)) {
1710
1800
  return describeMissingDeepSeekCompat(model);
1711
1801
  }
@@ -2755,6 +2845,22 @@ function notifyCacheCompatIfNeeded(
2755
2845
  ): void {
2756
2846
  if (!model) return;
2757
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
+
2758
2864
  const adapter = selectAdapterForModel(model);
2759
2865
  const text = adapter?.warningText?.(model);
2760
2866
  if (!adapter || !text) return;
@@ -3456,6 +3562,7 @@ function buildDoctorDiagnosis(model: PiModel, options: { promptCacheRetention400
3456
3562
  const compat = getCompat(model);
3457
3563
  lines.push(`Compat: ${JSON.stringify(compat)}`);
3458
3564
 
3565
+ const adaptiveThinkingApplicable = isAdaptiveThinkingCompatApplicable(model);
3459
3566
  const deepSeekCompatApplicable = isDeepSeekCompatCheckApplicable(model);
3460
3567
  const missing = describeMissingCacheCompatForModel(model);
3461
3568
  if (missing.length > 0) {
@@ -3465,12 +3572,14 @@ function buildDoctorDiagnosis(model: PiModel, options: { promptCacheRetention400
3465
3572
  const providerLabel = slashIdx > 0 ? key.slice(0, slashIdx) : key;
3466
3573
  const modelsJsonPath = getModelsJsonDisplayPath();
3467
3574
  lines.push(`Edit ${modelsJsonPath} -> providers["${providerLabel}"] -> compat (same level as baseUrl/api/apiKey/models).`);
3468
- if (deepSeekCompatApplicable) {
3575
+ if (adaptiveThinkingApplicable) {
3576
+ appendAdaptiveThinkingCompatAdviceLines(lines, missing, { providerLabel, modelId: model.id });
3577
+ } else if (deepSeekCompatApplicable) {
3469
3578
  appendDeepSeekCompatAdviceLines(lines, missing, { providerLabel, modelId: model.id });
3470
3579
  } else {
3471
3580
  appendOpenAIProxyCompatAdviceLines(lines, missing, { providerLabel, modelId: model.id });
3472
3581
  }
3473
- } else if (deepSeekCompatApplicable || isCompatCheckApplicable(model)) {
3582
+ } else if (adaptiveThinkingApplicable || deepSeekCompatApplicable || isCompatCheckApplicable(model)) {
3474
3583
  lines.push("✅ Compat fully configured.");
3475
3584
  } else {
3476
3585
  lines.push(...getCompatCheckNotApplicableLines(model));
@@ -3629,6 +3738,7 @@ function buildLowHitDiagnosis(
3629
3738
 
3630
3739
  function buildCompatDiagnosis(model: PiModel): string | undefined {
3631
3740
  const missing = describeMissingCacheCompatForModel(model);
3741
+ const adaptiveThinkingApplicable = isAdaptiveThinkingCompatApplicable(model);
3632
3742
  const deepSeekCompatApplicable = isDeepSeekCompatCheckApplicable(model);
3633
3743
  const routerNotes = describeRouterChannelDiagnostics(model);
3634
3744
 
@@ -3646,7 +3756,9 @@ function buildCompatDiagnosis(model: PiModel): string | undefined {
3646
3756
  lines.push("");
3647
3757
  lines.push(`Edit ${modelsJsonPath} -> providers["${providerLabel}"] -> compat`);
3648
3758
  lines.push(`(at the same level as baseUrl/api/apiKey/models).`);
3649
- if (deepSeekCompatApplicable) {
3759
+ if (adaptiveThinkingApplicable) {
3760
+ appendAdaptiveThinkingCompatAdviceLines(lines, missing, { providerLabel, modelId: model.id });
3761
+ } else if (deepSeekCompatApplicable) {
3650
3762
  appendDeepSeekCompatAdviceLines(lines, missing, { providerLabel, modelId: model.id });
3651
3763
  } else {
3652
3764
  appendOpenAIProxyCompatAdviceLines(lines, missing, { providerLabel, modelId: model.id });
@@ -3655,7 +3767,7 @@ function buildCompatDiagnosis(model: PiModel): string | undefined {
3655
3767
 
3656
3768
  // When compat is fully configured but router notes exist, prefix the status.
3657
3769
  if (routerNotes.length > 0 && missing.length === 0) {
3658
- if (deepSeekCompatApplicable || isCompatCheckApplicable(model)) {
3770
+ if (adaptiveThinkingApplicable || deepSeekCompatApplicable || isCompatCheckApplicable(model)) {
3659
3771
  lines.push("✅ Compat fully configured.");
3660
3772
  if (isPromptCacheRetention400Applicable(model)) {
3661
3773
  lines.push(getPromptCacheRetentionUnsupportedHint());
@@ -3676,6 +3788,911 @@ function buildCompatDiagnosis(model: PiModel): string | undefined {
3676
3788
  return lines.join("\n");
3677
3789
  }
3678
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
+
3679
4696
  // Internal helpers exported only so the task verification script
3680
4697
  // (.trellis/tasks/.../verify.ts) can exercise them. They are not part of the
3681
4698
  // extension's public API; pi only invokes the default export below.
@@ -3849,7 +4866,6 @@ export const __internals_for_tests = {
3849
4866
  // Integrity diagnostics
3850
4867
  getLastPromptIntegrityWarningAt,
3851
4868
  // Diagnostic command helpers
3852
- isCompatCheckApplicable,
3853
4869
  buildDoctorDiagnosis,
3854
4870
  buildCompatDiagnosis,
3855
4871
  describeRouterChannelDiagnostics,
@@ -3881,6 +4897,25 @@ export const __internals_for_tests = {
3881
4897
  STATE_FILE_PATH,
3882
4898
  LEGACY_STATE_FILE_PATH,
3883
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,
3884
4919
  };
3885
4920
 
3886
4921
  export default function (pi: ExtensionAPI) {
@@ -4322,6 +5357,7 @@ export default function (pi: ExtensionAPI) {
4322
5357
  // with low-hit diagnosis
4323
5358
  // stats — show active model stats bucket, recent trend, usage
4324
5359
  // compat — show compat suggestion with file path
5360
+ // fix — auto-fix compat issues (writes models.json, requires UI)
4325
5361
  // reset — reset current session model stats bucket (local only)
4326
5362
  // (no args) — interactive menu (with UI) or help summary
4327
5363
  // ────────────────────────────────────────────────────────────────
@@ -4380,7 +5416,7 @@ export default function (pi: ExtensionAPI) {
4380
5416
  cmdCtx.ui.notify(compatResult, "warning");
4381
5417
  } else {
4382
5418
  cmdCtx.ui.notify(
4383
- isDeepSeekCompatCheckApplicable(model) || isCompatCheckApplicable(model)
5419
+ isAdaptiveThinkingCompatApplicable(model) || isDeepSeekCompatCheckApplicable(model) || isCompatCheckApplicable(model)
4384
5420
  ? "✅ Compat fully configured."
4385
5421
  : getCompatCheckNotApplicableLines(model).join("\n"),
4386
5422
  "info",
@@ -4418,6 +5454,159 @@ export default function (pi: ExtensionAPI) {
4418
5454
  "New requests will start a fresh stats bucket for this Pi session.",
4419
5455
  "info",
4420
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
+ }
4421
5610
  } else {
4422
5611
  // Try interactive selection menu when UI supports it
4423
5612
  if (cmdCtx.hasUI) {
@@ -4427,6 +5616,7 @@ export default function (pi: ExtensionAPI) {
4427
5616
  "Doctor — Show cache configuration",
4428
5617
  "Stats — Show cache stats and trend",
4429
5618
  "Compat — Show compat suggestion",
5619
+ "Fix — Auto-fix compat issues (writes models.json)",
4430
5620
  "Reset — Reset local session stats",
4431
5621
  "Cancel",
4432
5622
  ];
@@ -4478,7 +5668,7 @@ export default function (pi: ExtensionAPI) {
4478
5668
  cmdCtx.ui.notify(compatResult, "warning");
4479
5669
  } else {
4480
5670
  cmdCtx.ui.notify(
4481
- isDeepSeekCompatCheckApplicable(model) || isCompatCheckApplicable(model)
5671
+ isAdaptiveThinkingCompatApplicable(model) || isDeepSeekCompatCheckApplicable(model) || isCompatCheckApplicable(model)
4482
5672
  ? "✅ Compat fully configured."
4483
5673
  : getCompatCheckNotApplicableLines(model).join("\n"),
4484
5674
  "info",
@@ -4486,6 +5676,109 @@ export default function (pi: ExtensionAPI) {
4486
5676
  }
4487
5677
  }
4488
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]) {
4489
5782
  if (!model) {
4490
5783
  cmdCtx.ui.notify("No active model selected. Select a model first with /model or pi --model.", "warning");
4491
5784
  } else {
@@ -4519,6 +5812,7 @@ export default function (pi: ExtensionAPI) {
4519
5812
  diagnosis.push(" doctor — Show current model/provider/api/baseUrl/compat and low-hit diagnosis");
4520
5813
  diagnosis.push(" stats — Show active model stats bucket and recent trend");
4521
5814
  diagnosis.push(" compat — Show compat suggestion with edit location");
5815
+ diagnosis.push(" fix — Auto-fix compat issues (writes models.json, requires UI)");
4522
5816
  diagnosis.push(" reset — Reset local session stats for current model (does not affect upstream)");
4523
5817
  diagnosis.push("");
4524
5818
  diagnosis.push(formatOptimizerRuntimeMode());
@@ -4529,7 +5823,7 @@ export default function (pi: ExtensionAPI) {
4529
5823
  if (missing.length > 0) {
4530
5824
  diagnosis.push(`⚠️ Active model "${displayKey}" missing compat: ${missing.join(", ")}`);
4531
5825
  diagnosis.push('Run "/cache-optimizer compat" for edit instructions.');
4532
- } else if (isDeepSeekCompatCheckApplicable(model) || isCompatCheckApplicable(model)) {
5826
+ } else if (isAdaptiveThinkingCompatApplicable(model) || isDeepSeekCompatCheckApplicable(model) || isCompatCheckApplicable(model)) {
4533
5827
  diagnosis.push(`✅ Active model "${displayKey}": compat fully configured.`);
4534
5828
  } else {
4535
5829
  diagnosis.push(`ℹ️ Active model "${displayKey}": compat check not applicable.`);