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