pi-cache-optimizer 2.5.7 → 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.
- package/index.ts +1304 -10
- 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
|
-
|
|
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 (
|
|
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 (
|
|
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.`);
|