pi-cache-optimizer 2.6.4 → 2.6.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -2
- package/README.zh-CN.md +4 -2
- package/index.ts +341 -297
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
Pi extension for improving provider-side KV / prompt cache hit rates. It keeps stable prompt content near the front, adds a conservative OpenAI-compatible `prompt_cache_key` fallback, warns about common proxy cache-routing gaps, and shows read-only footer cache stats.
|
|
10
10
|
|
|
11
|
-
> Renamed from `pi-deepseek-cache-optimizer`. Existing footer counters migrate automatically.
|
|
11
|
+
> Renamed from `pi-deepseek-cache-optimizer`. Existing footer counters migrate automatically. The extension does **not** touch `~/.pi/agent/models.json` during normal operation; only `/cache-optimizer fix` can edit it, and only after an explicit interactive preview + confirmation with an automatic timestamped backup.
|
|
12
12
|
|
|
13
13
|
## Contents
|
|
14
14
|
|
|
@@ -17,6 +17,8 @@ Pi extension for improving provider-side KV / prompt cache hit rates. It keeps s
|
|
|
17
17
|
- [Commands](#commands)
|
|
18
18
|
- [Persistent opt-out](#persistent-opt-out)
|
|
19
19
|
- [OpenAI-compatible proxy setup](#openai-compatible-proxy-setup)
|
|
20
|
+
- [Anthropic adaptive thinking models](#anthropic-adaptive-thinking-models)
|
|
21
|
+
- [Auto-repair with `/cache-optimizer fix`](#auto-repair-with-cache-optimizer-fix)
|
|
20
22
|
- [Footer stats](#footer-stats)
|
|
21
23
|
- [Uninstall](#uninstall)
|
|
22
24
|
- [Verify effect](#verify-effect)
|
|
@@ -161,7 +163,7 @@ Or use model-level override:
|
|
|
161
163
|
|
|
162
164
|
1. Shows full preview of changes (file path, edit location, JSON to write, risks)
|
|
163
165
|
2. Warns: ① changes affect all sessions using that channel, ② automatic backup created at `models.json.backup-cache-optimizer-<timestamp>`, ③ Pi reload required
|
|
164
|
-
3. Uses comment-preserving surgical editor — existing comments, indentation, key order preserved
|
|
166
|
+
3. Uses comment-preserving surgical editor — existing comments, indentation, and existing key order are preserved
|
|
165
167
|
4. Requires explicit user confirmation (interactive prompt or `ui.select`)
|
|
166
168
|
5. Writes atomically (temp + rename); self-validates after write
|
|
167
169
|
6. Falls back to manual guidance if JSONC scanner cannot confidently locate the target
|
package/README.zh-CN.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
用于提升 Pi 中 provider 侧 KV Cache / Prompt Cache 命中率的扩展:把稳定 prompt 内容前置,给 OpenAI-compatible 请求补保守的 `prompt_cache_key`,提示代理渠道常见缓存路由兼容问题,并在底部显示只读缓存统计。
|
|
10
10
|
|
|
11
|
-
> 本包已从 `pi-deepseek-cache-optimizer`
|
|
11
|
+
> 本包已从 `pi-deepseek-cache-optimizer` 改名。已有底部统计会自动迁移。正常运行时扩展不会触碰你的 `~/.pi/agent/models.json`;只有 `/cache-optimizer fix` 会在展示交互式预览、风险提示并得到明确确认后写入,且会先创建带时间戳的自动备份。
|
|
12
12
|
|
|
13
13
|
## 目录
|
|
14
14
|
|
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
- [命令](#命令)
|
|
18
18
|
- [持久 Opt-out](#持久-opt-out)
|
|
19
19
|
- [OpenAI-compatible 代理配置](#openai-compatible-代理配置)
|
|
20
|
+
- [Anthropic adaptive thinking 模型](#anthropic-adaptive-thinking-模型)
|
|
21
|
+
- [使用 `/cache-optimizer fix` 自动修复](#使用-cache-optimizer-fix-自动修复)
|
|
20
22
|
- [Footer 统计](#footer-统计)
|
|
21
23
|
- [卸载](#卸载)
|
|
22
24
|
- [验证效果](#验证效果)
|
|
@@ -161,7 +163,7 @@ Pi 内置 catalog 已为官方模型设置此 flag。`models.json` 中覆盖这
|
|
|
161
163
|
|
|
162
164
|
1. 显示完整变更预览(文件路径、编辑位置、要写入的 JSON、风险说明)
|
|
163
165
|
2. 警告:① 修改影响使用该渠道的所有 session,② 自动备份到 `models.json.backup-cache-optimizer-<timestamp>`,③ 需重启 Pi 或 reload
|
|
164
|
-
3. 使用保留注释的精确编辑器 ——
|
|
166
|
+
3. 使用保留注释的精确编辑器 —— 现有注释、缩进和已有 key 顺序都会保留
|
|
165
167
|
4. 需要用户明确确认(交互式提示或 `ui.select`)
|
|
166
168
|
5. 原子写入(temp + rename);写入后自我验证
|
|
167
169
|
6. 如果 JSONC 扫描器无法置信定位目标,回退到手动修改指引
|
package/index.ts
CHANGED
|
@@ -1612,7 +1612,7 @@ function hasEffectivePromptCacheKey(record: UnknownRecord): boolean {
|
|
|
1612
1612
|
return isNonEmptyString(record.prompt_cache_key) || isNonEmptyString(record.promptCacheKey);
|
|
1613
1613
|
}
|
|
1614
1614
|
|
|
1615
|
-
function isNonEmptyString(value: unknown):
|
|
1615
|
+
function isNonEmptyString(value: unknown): value is string {
|
|
1616
1616
|
return typeof value === "string" && value.trim().length > 0;
|
|
1617
1617
|
}
|
|
1618
1618
|
|
|
@@ -1637,9 +1637,6 @@ function describeMissingOpenAIFamilyProxyCompat(model: PiModel): string[] {
|
|
|
1637
1637
|
if (!isOpenAICompatibleProxyApi(model.api)) return missing;
|
|
1638
1638
|
if (isOfficialOpenAIBaseUrl(model)) return missing;
|
|
1639
1639
|
|
|
1640
|
-
if (compat.supportsLongCacheRetention !== true) {
|
|
1641
|
-
missing.push("supportsLongCacheRetention");
|
|
1642
|
-
}
|
|
1643
1640
|
if (compat.sendSessionAffinityHeaders !== true) {
|
|
1644
1641
|
missing.push("sendSessionAffinityHeaders");
|
|
1645
1642
|
}
|
|
@@ -1660,9 +1657,6 @@ function describeMissingOpenAICompatibleProxyCompat(model: PiModel): string[] {
|
|
|
1660
1657
|
if (!isOpenAICompatibleProxyApi(model.api)) return missing;
|
|
1661
1658
|
if (isOfficialOpenAIBaseUrl(model)) return missing;
|
|
1662
1659
|
|
|
1663
|
-
if (compat.supportsLongCacheRetention !== true) {
|
|
1664
|
-
missing.push("supportsLongCacheRetention");
|
|
1665
|
-
}
|
|
1666
1660
|
if (compat.sendSessionAffinityHeaders !== true) {
|
|
1667
1661
|
missing.push("sendSessionAffinityHeaders");
|
|
1668
1662
|
}
|
|
@@ -1670,6 +1664,20 @@ function describeMissingOpenAICompatibleProxyCompat(model: PiModel): string[] {
|
|
|
1670
1664
|
return missing;
|
|
1671
1665
|
}
|
|
1672
1666
|
|
|
1667
|
+
function describeOptionalOpenAICompatibleProxyCompat(model: PiModel): string[] {
|
|
1668
|
+
const compat = getCompat(model);
|
|
1669
|
+
const optional: string[] = [];
|
|
1670
|
+
|
|
1671
|
+
if (!isOpenAICompatibleProxyApi(model.api)) return optional;
|
|
1672
|
+
if (isOfficialOpenAIBaseUrl(model)) return optional;
|
|
1673
|
+
|
|
1674
|
+
if (compat.supportsLongCacheRetention !== true) {
|
|
1675
|
+
optional.push("supportsLongCacheRetention");
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
return optional;
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1673
1681
|
function buildSafeOpenAIProxyCompatSuggestion(missing: string[]): Record<string, boolean> {
|
|
1674
1682
|
const suggestion: Record<string, boolean> = {};
|
|
1675
1683
|
if (missing.includes("sendSessionAffinityHeaders")) {
|
|
@@ -1760,21 +1768,22 @@ function appendOpenAIProxyCompatAdviceLines(lines: string[], missing: string[],
|
|
|
1760
1768
|
lines.push("Safe default suggestion:");
|
|
1761
1769
|
}
|
|
1762
1770
|
lines.push(JSON.stringify(suggestion, null, 2));
|
|
1763
|
-
} else if (missing.includes("supportsLongCacheRetention")) {
|
|
1764
|
-
lines.push("No safe automatic JSON change is recommended for `supportsLongCacheRetention`.");
|
|
1765
1771
|
}
|
|
1766
1772
|
|
|
1767
1773
|
if (missing.includes("sendSessionAffinityHeaders")) {
|
|
1768
1774
|
lines.push("- sendSessionAffinityHeaders: recommended for third-party proxies when supported; it helps keep one Pi session on the same upstream/backend.");
|
|
1769
1775
|
}
|
|
1770
|
-
if (missing.includes("supportsLongCacheRetention")) {
|
|
1771
|
-
lines.push("- supportsLongCacheRetention: optional. Enable only after your endpoint/proxy explicitly supports OpenAI long prompt cache retention.");
|
|
1772
|
-
lines.push(`- ${getPromptCacheRetentionUnsupportedHint()}`);
|
|
1773
|
-
}
|
|
1774
|
-
|
|
1775
1776
|
appendCredentialSafeProviderGuidance(lines, options, suggestion);
|
|
1776
1777
|
}
|
|
1777
1778
|
|
|
1779
|
+
function appendOptionalOpenAIProxyCompatAdviceLines(lines: string[], optional: string[]): void {
|
|
1780
|
+
if (!optional.includes("supportsLongCacheRetention")) return;
|
|
1781
|
+
lines.push("");
|
|
1782
|
+
lines.push("Optional (not required, not auto-fixed):");
|
|
1783
|
+
lines.push("- supportsLongCacheRetention: enable only after your endpoint/proxy explicitly supports OpenAI long prompt cache retention.");
|
|
1784
|
+
lines.push(`- ${getPromptCacheRetentionUnsupportedHint()}`);
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1778
1787
|
/**
|
|
1779
1788
|
* Build the warning text displayed to users when an OpenAI-family third-party
|
|
1780
1789
|
* proxy is missing one or more cache/session-affinity compat flags.
|
|
@@ -3121,12 +3130,15 @@ function parseCacheStats(value: unknown): CacheStats | undefined {
|
|
|
3121
3130
|
|
|
3122
3131
|
function parsePersistedRoutedModelRef(value: unknown): PersistedRoutedModelRef | undefined {
|
|
3123
3132
|
const record = asRecord(value);
|
|
3124
|
-
|
|
3133
|
+
const provider = record?.provider;
|
|
3134
|
+
const id = record?.id;
|
|
3135
|
+
const name = record?.name;
|
|
3136
|
+
if (!isNonEmptyString(provider) || !isNonEmptyString(id)) return undefined;
|
|
3125
3137
|
|
|
3126
3138
|
return {
|
|
3127
|
-
provider:
|
|
3128
|
-
id:
|
|
3129
|
-
name: isNonEmptyString(
|
|
3139
|
+
provider: provider.trim(),
|
|
3140
|
+
id: id.trim(),
|
|
3141
|
+
name: isNonEmptyString(name) ? name.trim() : id.trim(),
|
|
3130
3142
|
};
|
|
3131
3143
|
}
|
|
3132
3144
|
|
|
@@ -3530,8 +3542,9 @@ function describeRouterChannelDiagnostics(model: PiModel): string[] {
|
|
|
3530
3542
|
provider.includes("openrouter")
|
|
3531
3543
|
) {
|
|
3532
3544
|
const compat = getCompat(model);
|
|
3533
|
-
const
|
|
3534
|
-
const
|
|
3545
|
+
const routing = asRecord((compat as Record<string, unknown>)["openRouterRouting"]);
|
|
3546
|
+
const hasOnly = !!routing?.only;
|
|
3547
|
+
const hasOrder = !!routing?.order;
|
|
3535
3548
|
|
|
3536
3549
|
notes.push(
|
|
3537
3550
|
"🔀 Router/channel: OpenRouter detected. OpenRouter is a multi-provider router; " +
|
|
@@ -3566,8 +3579,9 @@ function describeRouterChannelDiagnostics(model: PiModel): string[] {
|
|
|
3566
3579
|
provider.includes("vercel-ai-gateway")
|
|
3567
3580
|
) {
|
|
3568
3581
|
const compat = getCompat(model);
|
|
3569
|
-
const
|
|
3570
|
-
const
|
|
3582
|
+
const routing = asRecord((compat as Record<string, unknown>)["vercelGatewayRouting"]);
|
|
3583
|
+
const hasOnly = !!routing?.only;
|
|
3584
|
+
const hasOrder = !!routing?.order;
|
|
3571
3585
|
|
|
3572
3586
|
notes.push(
|
|
3573
3587
|
"🔀 Router/channel: Vercel AI Gateway detected. The gateway may route to different " +
|
|
@@ -3694,8 +3708,21 @@ function buildDoctorDiagnosis(model: PiModel, options: { promptCacheRetention400
|
|
|
3694
3708
|
const adaptiveThinkingApplicable = isAdaptiveThinkingCompatApplicable(model);
|
|
3695
3709
|
const deepSeekCompatApplicable = isDeepSeekCompatCheckApplicable(model);
|
|
3696
3710
|
const missing = describeMissingCacheCompatForModel(model);
|
|
3711
|
+
const optionalOpenAIProxyCompat = (!adaptiveThinkingApplicable && !deepSeekCompatApplicable)
|
|
3712
|
+
? describeOptionalOpenAICompatibleProxyCompat(model)
|
|
3713
|
+
: [];
|
|
3714
|
+
const fixSug = buildFixSuggestion(model);
|
|
3715
|
+
const safeFixableMissing = fixSug ? Object.keys(fixSug.compatKeys) : [];
|
|
3716
|
+
const advisoryMissing = missing.filter(m => !safeFixableMissing.includes(m));
|
|
3717
|
+
|
|
3718
|
+
if (safeFixableMissing.length > 0) {
|
|
3719
|
+
lines.push(`⚠️ Missing compat flags: ${safeFixableMissing.join(", ")}`);
|
|
3720
|
+
}
|
|
3721
|
+
if (advisoryMissing.length > 0) {
|
|
3722
|
+
lines.push(`ℹ️ Optional: ${advisoryMissing.join(", ")} (enable only if needed)`);
|
|
3723
|
+
}
|
|
3724
|
+
|
|
3697
3725
|
if (missing.length > 0) {
|
|
3698
|
-
lines.push(`⚠️ Missing compat flags: ${missing.join(", ")}`);
|
|
3699
3726
|
const key = modelKey(model);
|
|
3700
3727
|
const slashIdx = key.indexOf("/");
|
|
3701
3728
|
const providerLabel = slashIdx > 0 ? key.slice(0, slashIdx) : key;
|
|
@@ -3707,9 +3734,11 @@ function buildDoctorDiagnosis(model: PiModel, options: { promptCacheRetention400
|
|
|
3707
3734
|
appendDeepSeekCompatAdviceLines(lines, missing, { providerLabel, modelId: model.id });
|
|
3708
3735
|
} else {
|
|
3709
3736
|
appendOpenAIProxyCompatAdviceLines(lines, missing, { providerLabel, modelId: model.id });
|
|
3737
|
+
appendOptionalOpenAIProxyCompatAdviceLines(lines, optionalOpenAIProxyCompat);
|
|
3710
3738
|
}
|
|
3711
3739
|
} else if (adaptiveThinkingApplicable || deepSeekCompatApplicable || isCompatCheckApplicable(model)) {
|
|
3712
3740
|
lines.push("✅ Compat fully configured.");
|
|
3741
|
+
appendOptionalOpenAIProxyCompatAdviceLines(lines, optionalOpenAIProxyCompat);
|
|
3713
3742
|
} else {
|
|
3714
3743
|
lines.push(...getCompatCheckNotApplicableLines(model));
|
|
3715
3744
|
}
|
|
@@ -3767,7 +3796,8 @@ function buildLowHitDiagnosis(
|
|
|
3767
3796
|
const lines: string[] = [];
|
|
3768
3797
|
|
|
3769
3798
|
// 1. Missing compat flags (adapter-aware: DeepSeek has extra reasoning compat)
|
|
3770
|
-
const
|
|
3799
|
+
const fixSugLHD = buildFixSuggestion(model);
|
|
3800
|
+
const safeFixableMissingLHD = fixSugLHD ? Object.keys(fixSugLHD.compatKeys) : [];
|
|
3771
3801
|
|
|
3772
3802
|
// 2. Router/channel risk (reuse existing check)
|
|
3773
3803
|
const routerNotes = describeRouterChannelDiagnostics(model);
|
|
@@ -3785,7 +3815,7 @@ function buildLowHitDiagnosis(
|
|
|
3785
3815
|
// 5. Today's overall trend from persisted stats
|
|
3786
3816
|
const todayStats = stats ?? emptyCacheStats();
|
|
3787
3817
|
|
|
3788
|
-
const hasMissingCompat =
|
|
3818
|
+
const hasMissingCompat = safeFixableMissingLHD.length > 0;
|
|
3789
3819
|
const hasRouterRisk = routerNotes.length > 0;
|
|
3790
3820
|
const hasUsageMissing = missingUsageSamples > 0;
|
|
3791
3821
|
|
|
@@ -3814,7 +3844,7 @@ function buildLowHitDiagnosis(
|
|
|
3814
3844
|
|
|
3815
3845
|
// Priority 1: missing compat flags
|
|
3816
3846
|
if (hasMissingCompat) {
|
|
3817
|
-
lines.push(`⚠️ Missing compat flags: ${
|
|
3847
|
+
lines.push(`⚠️ Missing compat flags: ${safeFixableMissingLHD.join(", ")}`);
|
|
3818
3848
|
lines.push(" These flags enable prompt caching and session-affinity routing.");
|
|
3819
3849
|
lines.push(" Run /cache-optimizer compat for edit instructions.");
|
|
3820
3850
|
}
|
|
@@ -3867,11 +3897,17 @@ function buildLowHitDiagnosis(
|
|
|
3867
3897
|
|
|
3868
3898
|
function buildCompatDiagnosis(model: PiModel): string | undefined {
|
|
3869
3899
|
const missing = describeMissingCacheCompatForModel(model);
|
|
3900
|
+
const fixSugC = buildFixSuggestion(model);
|
|
3901
|
+
const safeFixableMissingC = fixSugC ? Object.keys(fixSugC.compatKeys) : [];
|
|
3902
|
+
const advisoryMissingC = missing.filter(m => !safeFixableMissingC.includes(m));
|
|
3870
3903
|
const adaptiveThinkingApplicable = isAdaptiveThinkingCompatApplicable(model);
|
|
3871
3904
|
const deepSeekCompatApplicable = isDeepSeekCompatCheckApplicable(model);
|
|
3905
|
+
const optionalOpenAIProxyCompat = (!adaptiveThinkingApplicable && !deepSeekCompatApplicable)
|
|
3906
|
+
? describeOptionalOpenAICompatibleProxyCompat(model)
|
|
3907
|
+
: [];
|
|
3872
3908
|
const routerNotes = describeRouterChannelDiagnostics(model);
|
|
3873
3909
|
|
|
3874
|
-
if (missing.length === 0 && routerNotes.length === 0) return undefined;
|
|
3910
|
+
if (missing.length === 0 && routerNotes.length === 0 && optionalOpenAIProxyCompat.length === 0) return undefined;
|
|
3875
3911
|
|
|
3876
3912
|
const key = modelKey(model);
|
|
3877
3913
|
const lines: string[] = [];
|
|
@@ -3881,7 +3917,12 @@ function buildCompatDiagnosis(model: PiModel): string | undefined {
|
|
|
3881
3917
|
const providerLabel = slashIdx > 0 ? key.slice(0, slashIdx) : key;
|
|
3882
3918
|
const modelsJsonPath = getModelsJsonDisplayPath();
|
|
3883
3919
|
lines.push(`Active model: ${key}`);
|
|
3884
|
-
|
|
3920
|
+
if (safeFixableMissingC.length > 0) {
|
|
3921
|
+
lines.push(`Safe-fixable: ${safeFixableMissingC.join(", ")}`);
|
|
3922
|
+
}
|
|
3923
|
+
if (advisoryMissingC.length > 0) {
|
|
3924
|
+
lines.push(`Optional: ${advisoryMissingC.join(", ")} (enable only if needed)`);
|
|
3925
|
+
}
|
|
3885
3926
|
lines.push("");
|
|
3886
3927
|
lines.push(`Edit ${modelsJsonPath} -> providers["${providerLabel}"] -> compat`);
|
|
3887
3928
|
lines.push(`(at the same level as baseUrl/api/apiKey/models).`);
|
|
@@ -3891,16 +3932,18 @@ function buildCompatDiagnosis(model: PiModel): string | undefined {
|
|
|
3891
3932
|
appendDeepSeekCompatAdviceLines(lines, missing, { providerLabel, modelId: model.id });
|
|
3892
3933
|
} else {
|
|
3893
3934
|
appendOpenAIProxyCompatAdviceLines(lines, missing, { providerLabel, modelId: model.id });
|
|
3935
|
+
appendOptionalOpenAIProxyCompatAdviceLines(lines, optionalOpenAIProxyCompat);
|
|
3894
3936
|
}
|
|
3895
3937
|
}
|
|
3896
3938
|
|
|
3897
|
-
// When compat is fully configured but router notes exist, prefix the status.
|
|
3898
|
-
if (routerNotes.length > 0 && missing.length === 0) {
|
|
3939
|
+
// When compat is fully configured but router/optional notes exist, prefix the status.
|
|
3940
|
+
if ((routerNotes.length > 0 || optionalOpenAIProxyCompat.length > 0) && missing.length === 0) {
|
|
3899
3941
|
if (adaptiveThinkingApplicable || deepSeekCompatApplicable || isCompatCheckApplicable(model)) {
|
|
3900
3942
|
lines.push("✅ Compat fully configured.");
|
|
3901
3943
|
if (isPromptCacheRetention400Applicable(model)) {
|
|
3902
3944
|
lines.push(getPromptCacheRetentionUnsupportedHint());
|
|
3903
3945
|
}
|
|
3946
|
+
appendOptionalOpenAIProxyCompatAdviceLines(lines, optionalOpenAIProxyCompat);
|
|
3904
3947
|
} else {
|
|
3905
3948
|
lines.push(...getCompatCheckNotApplicableLines(model));
|
|
3906
3949
|
}
|
|
@@ -4131,8 +4174,10 @@ function stripJsoncComments(text: string): string {
|
|
|
4131
4174
|
let i = 0;
|
|
4132
4175
|
while (i < text.length) {
|
|
4133
4176
|
const ch = text[i];
|
|
4177
|
+
|
|
4134
4178
|
if (ch === '"') {
|
|
4135
|
-
// String literal — copy until closing quote
|
|
4179
|
+
// String literal — copy byte-for-byte until the closing quote.
|
|
4180
|
+
// Escaped quotes/slashes must not be mistaken for comment delimiters.
|
|
4136
4181
|
out.push(ch);
|
|
4137
4182
|
i++;
|
|
4138
4183
|
while (i < text.length) {
|
|
@@ -4146,39 +4191,74 @@ function stripJsoncComments(text: string): string {
|
|
|
4146
4191
|
break;
|
|
4147
4192
|
}
|
|
4148
4193
|
}
|
|
4149
|
-
|
|
4150
|
-
|
|
4151
|
-
|
|
4152
|
-
|
|
4194
|
+
continue;
|
|
4195
|
+
}
|
|
4196
|
+
|
|
4197
|
+
if (ch === '/' && i + 1 < text.length && text[i + 1] === '/') {
|
|
4198
|
+
// Line comment — replace BOTH slashes and every comment byte with
|
|
4199
|
+
// spaces, but leave the newline to be copied by the normal path.
|
|
4200
|
+
out.push(' ', ' ');
|
|
4201
|
+
i += 2;
|
|
4153
4202
|
while (i < text.length && text[i] !== '\n') {
|
|
4154
4203
|
out.push(' ');
|
|
4155
4204
|
i++;
|
|
4156
4205
|
}
|
|
4157
|
-
|
|
4158
|
-
|
|
4159
|
-
|
|
4160
|
-
|
|
4161
|
-
|
|
4162
|
-
|
|
4163
|
-
|
|
4206
|
+
continue;
|
|
4207
|
+
}
|
|
4208
|
+
|
|
4209
|
+
if (ch === '/' && i + 1 < text.length && text[i + 1] === '*') {
|
|
4210
|
+
// Block comment — replace every byte with a space except newlines.
|
|
4211
|
+
// This deliberately preserves text.length and all structural offsets.
|
|
4212
|
+
out.push(' ', ' ');
|
|
4213
|
+
i += 2;
|
|
4214
|
+
while (i < text.length) {
|
|
4215
|
+
if (text[i] === '*' && i + 1 < text.length && text[i + 1] === '/') {
|
|
4216
|
+
out.push(' ', ' ');
|
|
4164
4217
|
i += 2;
|
|
4165
4218
|
break;
|
|
4166
4219
|
}
|
|
4167
|
-
|
|
4168
|
-
out.push('\n');
|
|
4169
|
-
} else {
|
|
4170
|
-
out.push(' ');
|
|
4171
|
-
}
|
|
4220
|
+
out.push(text[i] === '\n' ? '\n' : ' ');
|
|
4172
4221
|
i++;
|
|
4173
4222
|
}
|
|
4174
|
-
|
|
4175
|
-
out.push(ch);
|
|
4176
|
-
i++;
|
|
4223
|
+
continue;
|
|
4177
4224
|
}
|
|
4225
|
+
|
|
4226
|
+
out.push(ch);
|
|
4227
|
+
i++;
|
|
4178
4228
|
}
|
|
4179
4229
|
return out.join('');
|
|
4180
4230
|
}
|
|
4181
4231
|
|
|
4232
|
+
/**
|
|
4233
|
+
* Remove JSONC trailing commas from already comment-stripped text.
|
|
4234
|
+
* The returned text stays length-preserving (commas become spaces), which
|
|
4235
|
+
* gives JSON.parse a tolerant JSONC surface without affecting diagnostics.
|
|
4236
|
+
*/
|
|
4237
|
+
function stripJsoncTrailingCommas(text: string): string {
|
|
4238
|
+
const chars = text.split("");
|
|
4239
|
+
let i = 0;
|
|
4240
|
+
while (i < chars.length) {
|
|
4241
|
+
if (chars[i] === '"') {
|
|
4242
|
+
const str = readJsonStringLiteral(text, i);
|
|
4243
|
+
if (!str) break;
|
|
4244
|
+
i = str.end;
|
|
4245
|
+
continue;
|
|
4246
|
+
}
|
|
4247
|
+
|
|
4248
|
+
if (chars[i] === ',') {
|
|
4249
|
+
let j = i + 1;
|
|
4250
|
+
while (j < chars.length && isJsonWhitespace(chars[j])) j++;
|
|
4251
|
+
if (chars[j] === '}' || chars[j] === ']') chars[i] = ' ';
|
|
4252
|
+
}
|
|
4253
|
+
i++;
|
|
4254
|
+
}
|
|
4255
|
+
return chars.join('');
|
|
4256
|
+
}
|
|
4257
|
+
|
|
4258
|
+
function parseJsonc(text: string): unknown {
|
|
4259
|
+
return JSON.parse(stripJsoncTrailingCommas(stripJsoncComments(text)));
|
|
4260
|
+
}
|
|
4261
|
+
|
|
4182
4262
|
/**
|
|
4183
4263
|
* JSONC scanner: locate the provider block and model entry in models.json text.
|
|
4184
4264
|
* Returns the byte offsets for surgical insertion, or undefined if ambiguous.
|
|
@@ -4222,153 +4302,51 @@ function locateModelInJsonc(
|
|
|
4222
4302
|
// Clean text of comments first for reliable structural scanning
|
|
4223
4303
|
const clean = stripJsoncComments(text);
|
|
4224
4304
|
|
|
4225
|
-
// Strategy: find `"providers"`
|
|
4226
|
-
// provider key under it, then the `"models"`
|
|
4227
|
-
//
|
|
4228
|
-
//
|
|
4229
|
-
|
|
4230
|
-
const
|
|
4231
|
-
if (
|
|
4232
|
-
|
|
4233
|
-
|
|
4234
|
-
|
|
4235
|
-
|
|
4236
|
-
|
|
4237
|
-
|
|
4238
|
-
|
|
4239
|
-
|
|
4240
|
-
|
|
4241
|
-
|
|
4242
|
-
|
|
4243
|
-
|
|
4244
|
-
|
|
4245
|
-
|
|
4246
|
-
|
|
4247
|
-
|
|
4248
|
-
|
|
4249
|
-
if (clean[cur] === '}') break; // End of providers
|
|
4250
|
-
|
|
4251
|
-
// Try to read a string key
|
|
4252
|
-
if (clean[cur] !== '"') { cur++; continue; }
|
|
4253
|
-
const keyEnd = clean.indexOf('"', cur + 1);
|
|
4254
|
-
if (keyEnd < 0) return undefined;
|
|
4255
|
-
const key = clean.slice(cur + 1, keyEnd);
|
|
4256
|
-
cur = keyEnd + 1;
|
|
4257
|
-
|
|
4258
|
-
// Skip `:`
|
|
4259
|
-
while (cur < clean.length && clean[cur] !== ':') cur++;
|
|
4260
|
-
if (cur >= clean.length) return undefined;
|
|
4261
|
-
cur++; // Skip `:`
|
|
4262
|
-
while (cur < clean.length && (clean[cur] === ' ' || clean[cur] === '\n' || clean[cur] === '\r' || clean[cur] === '\t')) cur++;
|
|
4263
|
-
|
|
4264
|
-
if (key === providerLabel) {
|
|
4265
|
-
// Found — expect `{` starting the provider object
|
|
4266
|
-
if (clean[cur] !== '{') return undefined;
|
|
4267
|
-
providerBrace = cur;
|
|
4268
|
-
// Find matching closing `}` for the provider object (track depth)
|
|
4269
|
-
let depth = 1;
|
|
4270
|
-
let scan = cur + 1;
|
|
4271
|
-
while (scan < clean.length && depth > 0) {
|
|
4272
|
-
if (clean[scan] === '{') depth++;
|
|
4273
|
-
else if (clean[scan] === '}') depth--;
|
|
4274
|
-
if (depth > 0) scan++;
|
|
4275
|
-
}
|
|
4276
|
-
providerEndBrace = scan;
|
|
4277
|
-
break;
|
|
4278
|
-
}
|
|
4279
|
-
|
|
4280
|
-
// Skip the value
|
|
4281
|
-
if (clean[cur] === '{') {
|
|
4282
|
-
let depth = 1;
|
|
4283
|
-
cur++;
|
|
4284
|
-
while (cur < clean.length && depth > 0) {
|
|
4285
|
-
if (clean[cur] === '{') depth++;
|
|
4286
|
-
else if (clean[cur] === '}') depth--;
|
|
4287
|
-
cur++;
|
|
4288
|
-
}
|
|
4289
|
-
} else if (clean[cur] === '[') {
|
|
4290
|
-
let depth = 1;
|
|
4291
|
-
cur++;
|
|
4292
|
-
while (cur < clean.length && depth > 0) {
|
|
4293
|
-
if (clean[cur] === '[') depth++;
|
|
4294
|
-
else if (clean[cur] === ']') depth--;
|
|
4295
|
-
cur++;
|
|
4296
|
-
}
|
|
4297
|
-
} else if (clean[cur] === '"') {
|
|
4298
|
-
const strEnd = clean.indexOf('"', cur + 1);
|
|
4299
|
-
if (strEnd < 0) return undefined;
|
|
4300
|
-
cur = strEnd + 1;
|
|
4301
|
-
} else {
|
|
4302
|
-
// Number, boolean, etc.
|
|
4303
|
-
while (cur < clean.length && clean[cur] !== ',' && clean[cur] !== '}' && clean[cur] !== '\n') cur++;
|
|
4304
|
-
}
|
|
4305
|
-
// Skip comma
|
|
4306
|
-
if (cur < clean.length && clean[cur] === ',') cur++;
|
|
4307
|
-
}
|
|
4308
|
-
|
|
4309
|
-
if (providerBrace < 0 || providerEndBrace < 0) return undefined;
|
|
4310
|
-
|
|
4311
|
-
// Scan provider object at depth 1 for a provider-level "compat" object.
|
|
4312
|
-
// Depth-aware + string-aware so nested model compat objects are not confused
|
|
4313
|
-
// with the provider-level one.
|
|
4305
|
+
// Strategy: find `"providers"` as a direct root key, then find the
|
|
4306
|
+
// provider key under it, then the provider's direct `"models"` key.
|
|
4307
|
+
// All object/value traversal uses the string-aware primitives above so
|
|
4308
|
+
// braces, brackets, comment markers, or escaped quotes inside strings do
|
|
4309
|
+
// not corrupt offsets.
|
|
4310
|
+
const rootBrace = skipJsonWhitespace(clean, 0);
|
|
4311
|
+
if (clean[rootBrace] !== "{") return undefined;
|
|
4312
|
+
|
|
4313
|
+
const providersKey = findJsonObjectKey(clean, rootBrace, "providers");
|
|
4314
|
+
if (!providersKey) return undefined;
|
|
4315
|
+
const providersBrace = skipJsonWhitespace(clean, providersKey.valueStart);
|
|
4316
|
+
if (clean[providersBrace] !== "{") return undefined;
|
|
4317
|
+
const providersEnd = findMatchingBracket(clean, providersBrace);
|
|
4318
|
+
if (providersEnd === undefined) return undefined;
|
|
4319
|
+
|
|
4320
|
+
const providerKey = findJsonObjectKey(clean, providersBrace, providerLabel);
|
|
4321
|
+
if (!providerKey || providerKey.keyStart > providersEnd) return undefined;
|
|
4322
|
+
const providerBrace = skipJsonWhitespace(clean, providerKey.valueStart);
|
|
4323
|
+
if (clean[providerBrace] !== "{") return undefined;
|
|
4324
|
+
const providerEndBrace = findMatchingBracket(clean, providerBrace);
|
|
4325
|
+
if (providerEndBrace === undefined || providerEndBrace > providersEnd) return undefined;
|
|
4326
|
+
|
|
4327
|
+
// Provider-level compat is a direct provider child only. Nested model
|
|
4328
|
+
// compat objects are intentionally skipped whole by findJsonObjectKey.
|
|
4314
4329
|
let providerCompatBrace = -1;
|
|
4315
4330
|
let providerCompatEnd = -1;
|
|
4316
|
-
|
|
4317
|
-
|
|
4318
|
-
|
|
4319
|
-
|
|
4320
|
-
const
|
|
4321
|
-
if (
|
|
4322
|
-
|
|
4323
|
-
|
|
4324
|
-
if (strEnd < 0) break;
|
|
4325
|
-
const str = clean.slice(pScan + 1, strEnd);
|
|
4326
|
-
if (pDepth === 1 && str === 'compat') {
|
|
4327
|
-
// Confirm it's a key: next non-ws char must be ':'
|
|
4328
|
-
let after = strEnd + 1;
|
|
4329
|
-
while (after < providerEndBrace && (clean[after] === ' ' || clean[after] === '\n' || clean[after] === '\r' || clean[after] === '\t')) after++;
|
|
4330
|
-
if (clean[after] === ':') {
|
|
4331
|
-
after++;
|
|
4332
|
-
while (after < providerEndBrace && (clean[after] === ' ' || clean[after] === '\n' || clean[after] === '\r' || clean[after] === '\t')) after++;
|
|
4333
|
-
if (clean[after] === '{') {
|
|
4334
|
-
providerCompatBrace = after;
|
|
4335
|
-
let d = 1;
|
|
4336
|
-
let s = after + 1;
|
|
4337
|
-
while (s < clean.length && d > 0) {
|
|
4338
|
-
if (clean[s] === '"') {
|
|
4339
|
-
const e = clean.indexOf('"', s + 1);
|
|
4340
|
-
if (e < 0) break;
|
|
4341
|
-
s = e + 1;
|
|
4342
|
-
continue;
|
|
4343
|
-
}
|
|
4344
|
-
if (clean[s] === '{') d++;
|
|
4345
|
-
else if (clean[s] === '}') d--;
|
|
4346
|
-
if (d > 0) s++;
|
|
4347
|
-
}
|
|
4348
|
-
providerCompatEnd = s;
|
|
4349
|
-
pScan = s + 1;
|
|
4350
|
-
continue;
|
|
4351
|
-
}
|
|
4352
|
-
}
|
|
4353
|
-
}
|
|
4354
|
-
pScan = strEnd + 1;
|
|
4355
|
-
continue;
|
|
4331
|
+
const providerCompatKey = findJsonObjectKey(clean, providerBrace, "compat");
|
|
4332
|
+
if (providerCompatKey && providerCompatKey.keyStart < providerEndBrace) {
|
|
4333
|
+
const brace = skipJsonWhitespace(clean, providerCompatKey.valueStart);
|
|
4334
|
+
if (clean[brace] === "{") {
|
|
4335
|
+
const end = findMatchingBracket(clean, brace);
|
|
4336
|
+
if (end !== undefined && end <= providerEndBrace) {
|
|
4337
|
+
providerCompatBrace = brace;
|
|
4338
|
+
providerCompatEnd = end;
|
|
4356
4339
|
}
|
|
4357
|
-
if (ch === '{' || ch === '[') pDepth++;
|
|
4358
|
-
else if (ch === '}' || ch === ']') pDepth--;
|
|
4359
|
-
pScan++;
|
|
4360
4340
|
}
|
|
4361
4341
|
}
|
|
4362
4342
|
|
|
4363
|
-
|
|
4364
|
-
|
|
4365
|
-
const modelsIdx = providerContent.indexOf('"models"');
|
|
4366
|
-
if (modelsIdx < 0) return undefined;
|
|
4343
|
+
const modelsKey = findJsonObjectKey(clean, providerBrace, "models");
|
|
4344
|
+
if (!modelsKey || modelsKey.keyStart > providerEndBrace) return undefined;
|
|
4367
4345
|
|
|
4368
|
-
|
|
4369
|
-
|
|
4370
|
-
|
|
4371
|
-
if (
|
|
4346
|
+
let modelsScan = skipJsonWhitespace(clean, modelsKey.valueStart);
|
|
4347
|
+
if (clean[modelsScan] !== "[") return undefined;
|
|
4348
|
+
const modelsEnd = findMatchingBracket(clean, modelsScan);
|
|
4349
|
+
if (modelsEnd === undefined || modelsEnd > providerEndBrace) return undefined;
|
|
4372
4350
|
modelsScan++; // Skip `[`
|
|
4373
4351
|
|
|
4374
4352
|
// Scan ALL array elements: collect every model id, and record the target's position
|
|
@@ -4379,83 +4357,52 @@ function locateModelInJsonc(
|
|
|
4379
4357
|
let compatBrace = -1;
|
|
4380
4358
|
let compatEndBrace = -1;
|
|
4381
4359
|
|
|
4382
|
-
while (modelsScan <
|
|
4383
|
-
|
|
4384
|
-
|
|
4385
|
-
|
|
4386
|
-
|
|
4387
|
-
|
|
4388
|
-
if (clean[modelsScan]
|
|
4360
|
+
while (modelsScan < modelsEnd) {
|
|
4361
|
+
modelsScan = skipJsonWhitespace(clean, modelsScan);
|
|
4362
|
+
if (clean[modelsScan] === ',') {
|
|
4363
|
+
modelsScan++;
|
|
4364
|
+
continue;
|
|
4365
|
+
}
|
|
4366
|
+
if (modelsScan >= modelsEnd || clean[modelsScan] === ']') break;
|
|
4367
|
+
if (clean[modelsScan] !== '{') return undefined;
|
|
4389
4368
|
|
|
4390
|
-
// Found a model object `{`
|
|
4391
4369
|
const elementBrace = modelsScan;
|
|
4370
|
+
const elementEnd = findMatchingBracket(clean, elementBrace);
|
|
4371
|
+
if (elementEnd === undefined || elementEnd > modelsEnd) return undefined;
|
|
4392
4372
|
|
|
4393
|
-
|
|
4394
|
-
let depth = 1;
|
|
4395
|
-
let scan = modelsScan + 1;
|
|
4373
|
+
const idKey = findJsonObjectKey(clean, elementBrace, "id");
|
|
4396
4374
|
let elementId: string | undefined;
|
|
4397
|
-
|
|
4398
|
-
|
|
4399
|
-
|
|
4400
|
-
|
|
4401
|
-
|
|
4402
|
-
if (depth === 1 && elementId === undefined && clean.slice(scan, scan + 4) === '"id"') {
|
|
4403
|
-
// Found "id" key — find the colon and the value
|
|
4404
|
-
let afterKey = scan + 4;
|
|
4405
|
-
while (afterKey < clean.length && clean[afterKey] !== ':') afterKey++;
|
|
4406
|
-
if (afterKey < clean.length) {
|
|
4407
|
-
afterKey++; // skip ':'
|
|
4408
|
-
while (afterKey < clean.length && (clean[afterKey] === ' ' || clean[afterKey] === '\n' || clean[afterKey] === '\r' || clean[afterKey] === '\t')) afterKey++;
|
|
4409
|
-
if (afterKey < clean.length && clean[afterKey] === '"') {
|
|
4410
|
-
const idStart = afterKey + 1;
|
|
4411
|
-
const idEnd = clean.indexOf('"', idStart);
|
|
4412
|
-
if (idEnd > idStart) {
|
|
4413
|
-
elementId = clean.slice(idStart, idEnd);
|
|
4414
|
-
}
|
|
4415
|
-
}
|
|
4416
|
-
}
|
|
4417
|
-
}
|
|
4418
|
-
scan = strEnd + 1;
|
|
4419
|
-
continue;
|
|
4375
|
+
if (idKey && idKey.keyStart < elementEnd) {
|
|
4376
|
+
const idValueStart = skipJsonWhitespace(clean, idKey.valueStart);
|
|
4377
|
+
const idLiteral = readJsonStringLiteral(clean, idValueStart);
|
|
4378
|
+
if (idLiteral && idLiteral.end <= elementEnd) {
|
|
4379
|
+
elementId = idLiteral.value;
|
|
4420
4380
|
}
|
|
4421
|
-
if (clean[scan] === '{') depth++;
|
|
4422
|
-
else if (clean[scan] === '}') depth--;
|
|
4423
|
-
scan++;
|
|
4424
4381
|
}
|
|
4425
4382
|
|
|
4426
|
-
const elementEnd = scan - 1; // The `}` that closed this element
|
|
4427
|
-
|
|
4428
4383
|
if (elementId !== undefined) {
|
|
4429
4384
|
allModelIds.push(elementId);
|
|
4430
4385
|
}
|
|
4431
4386
|
|
|
4432
4387
|
if (elementId === modelId && modelBrace < 0) {
|
|
4433
|
-
// This is the target model — record its position and find its compat
|
|
4434
4388
|
modelBrace = elementBrace;
|
|
4435
4389
|
modelEndBrace = elementEnd;
|
|
4436
|
-
|
|
4437
|
-
const
|
|
4438
|
-
if (
|
|
4439
|
-
compatKeyStartClean =
|
|
4440
|
-
|
|
4441
|
-
|
|
4442
|
-
|
|
4443
|
-
|
|
4444
|
-
|
|
4445
|
-
|
|
4446
|
-
let cdepth = 1;
|
|
4447
|
-
let cscan = compatScan + 1;
|
|
4448
|
-
while (cscan < clean.length && cdepth > 0) {
|
|
4449
|
-
if (clean[cscan] === '{') cdepth++;
|
|
4450
|
-
else if (clean[cscan] === '}') cdepth--;
|
|
4451
|
-
if (cdepth > 0) cscan++;
|
|
4390
|
+
|
|
4391
|
+
const compatKey = findJsonObjectKey(clean, modelBrace, "compat");
|
|
4392
|
+
if (compatKey && compatKey.keyStart < modelEndBrace) {
|
|
4393
|
+
compatKeyStartClean = compatKey.keyStart;
|
|
4394
|
+
const brace = skipJsonWhitespace(clean, compatKey.valueStart);
|
|
4395
|
+
if (clean[brace] === "{") {
|
|
4396
|
+
const end = findMatchingBracket(clean, brace);
|
|
4397
|
+
if (end !== undefined && end <= modelEndBrace) {
|
|
4398
|
+
compatBrace = brace;
|
|
4399
|
+
compatEndBrace = end;
|
|
4452
4400
|
}
|
|
4453
|
-
compatEndBrace = cscan;
|
|
4454
4401
|
}
|
|
4455
4402
|
}
|
|
4456
4403
|
}
|
|
4457
4404
|
|
|
4458
|
-
modelsScan =
|
|
4405
|
+
modelsScan = elementEnd + 1;
|
|
4459
4406
|
}
|
|
4460
4407
|
|
|
4461
4408
|
if (modelBrace < 0 || modelEndBrace < 0) return undefined;
|
|
@@ -4596,6 +4543,48 @@ function decideFixPlacement(
|
|
|
4596
4543
|
};
|
|
4597
4544
|
}
|
|
4598
4545
|
|
|
4546
|
+
function findExistingCompatKeysInJsonc(
|
|
4547
|
+
original: string,
|
|
4548
|
+
compatBrace: number,
|
|
4549
|
+
compatEnd: number,
|
|
4550
|
+
keys: string[],
|
|
4551
|
+
): string[] {
|
|
4552
|
+
if (compatBrace < 0 || compatEnd <= compatBrace) return [];
|
|
4553
|
+
const clean = stripJsoncComments(original);
|
|
4554
|
+
return keys.filter((key) => {
|
|
4555
|
+
const found = findJsonObjectKey(clean, compatBrace, key);
|
|
4556
|
+
return !!found && found.keyStart < compatEnd;
|
|
4557
|
+
});
|
|
4558
|
+
}
|
|
4559
|
+
|
|
4560
|
+
function chooseFixPlacement(
|
|
4561
|
+
original: string,
|
|
4562
|
+
location: ModelNodeLocation,
|
|
4563
|
+
compatKeys: Record<string, unknown>,
|
|
4564
|
+
providerLabel: string,
|
|
4565
|
+
): { placement: "provider" | "model"; reason: string } {
|
|
4566
|
+
const decision = decideFixPlacement(compatKeys, providerLabel, location.allModelIds);
|
|
4567
|
+
const existingModelKeys = findExistingCompatKeysInJsonc(
|
|
4568
|
+
original,
|
|
4569
|
+
location.compatObjectBrace,
|
|
4570
|
+
location.compatObjectEnd,
|
|
4571
|
+
Object.keys(compatKeys),
|
|
4572
|
+
);
|
|
4573
|
+
|
|
4574
|
+
// Provider-level writes cannot override a model-level compat key because Pi's
|
|
4575
|
+
// merge order is provider.compat then model.compat. If the active model already
|
|
4576
|
+
// has one of the keys we need to repair (e.g. thinkingFormat: "legacy"), write
|
|
4577
|
+
// at model level even when the key would otherwise be provider-safe.
|
|
4578
|
+
if (decision.placement === "provider" && existingModelKeys.length > 0) {
|
|
4579
|
+
return {
|
|
4580
|
+
placement: "model",
|
|
4581
|
+
reason: `model-level compat already contains ${existingModelKeys.join(", ")} — repairing the active model override directly`,
|
|
4582
|
+
};
|
|
4583
|
+
}
|
|
4584
|
+
|
|
4585
|
+
return decision;
|
|
4586
|
+
}
|
|
4587
|
+
|
|
4599
4588
|
function composeFixInsertion(
|
|
4600
4589
|
original: string,
|
|
4601
4590
|
location: ModelNodeLocation,
|
|
@@ -4607,14 +4596,12 @@ function composeFixInsertion(
|
|
|
4607
4596
|
const targetCompatEnd = placement === "provider" ? location.providerCompatEnd : location.compatObjectEnd;
|
|
4608
4597
|
const containerBrace = placement === "provider" ? location.providerObjectBrace : location.modelObjectBrace;
|
|
4609
4598
|
|
|
4610
|
-
// Helper: format
|
|
4611
|
-
|
|
4612
|
-
|
|
4613
|
-
|
|
4614
|
-
|
|
4615
|
-
|
|
4616
|
-
return `${indent}${JSON.stringify(k)}: ${val}`;
|
|
4617
|
-
})
|
|
4599
|
+
// Helper: format key/value pairs as lines with the given indent,
|
|
4600
|
+
// alphabetically sorted for stable previews and deterministic edits.
|
|
4601
|
+
const sortedEntries = Object.entries(compatKeys).sort(([a], [b]) => a.localeCompare(b));
|
|
4602
|
+
const formatEntries = (indent: string, entries: Array<[string, unknown]>): string =>
|
|
4603
|
+
entries
|
|
4604
|
+
.map(([k, v]) => `${indent}${JSON.stringify(k)}: ${JSON.stringify(v)}`)
|
|
4618
4605
|
.join(',\n');
|
|
4619
4606
|
|
|
4620
4607
|
// Helper: line-start indentation of the line containing `offset` in `original`.
|
|
@@ -4627,25 +4614,52 @@ function composeFixInsertion(
|
|
|
4627
4614
|
};
|
|
4628
4615
|
|
|
4629
4616
|
if (targetCompatBrace >= 0 && targetCompatEnd > targetCompatBrace) {
|
|
4630
|
-
// ── Existing compat object: insert
|
|
4631
|
-
//
|
|
4617
|
+
// ── Existing compat object: insert absent keys and surgically replace
|
|
4618
|
+
// direct existing keys whose value is wrong (e.g. thinkingFormat: "legacy").
|
|
4619
|
+
// Unrelated interior bytes/comments/key order are preserved.
|
|
4632
4620
|
const interiorStart = targetCompatBrace + 1;
|
|
4633
4621
|
const interior = original.slice(interiorStart, targetCompatEnd);
|
|
4634
4622
|
const hasContent = interior.trim().length > 0;
|
|
4623
|
+
const clean = stripJsoncComments(original);
|
|
4635
4624
|
|
|
4636
|
-
// Indent for
|
|
4625
|
+
// Indent for inserted key lines: copy the first existing key line's indent,
|
|
4637
4626
|
// else derive one level deeper than the compat brace's own line.
|
|
4638
4627
|
const braceLineIndent = lineIndentAt(targetCompatBrace);
|
|
4639
4628
|
const innerMatch = interior.match(/\r?\n([ \t]+)\S/);
|
|
4640
4629
|
const innerIndent = innerMatch ? innerMatch[1] : braceLineIndent + ' ';
|
|
4641
|
-
const keysFormatted = formatKeys(innerIndent);
|
|
4642
4630
|
|
|
4643
|
-
|
|
4644
|
-
|
|
4645
|
-
|
|
4631
|
+
const edits: Array<{ start: number; end: number; text: string }> = [];
|
|
4632
|
+
const missingEntries: Array<[string, unknown]> = [];
|
|
4633
|
+
|
|
4634
|
+
for (const [key, value] of sortedEntries) {
|
|
4635
|
+
const existing = findJsonObjectKey(clean, targetCompatBrace, key);
|
|
4636
|
+
if (existing && existing.keyStart < targetCompatEnd) {
|
|
4637
|
+
const valueStart = skipJsonWhitespace(clean, existing.valueStart);
|
|
4638
|
+
const valueEnd = skipJsonValue(clean, valueStart);
|
|
4639
|
+
if (valueEnd !== undefined && valueEnd <= targetCompatEnd) {
|
|
4640
|
+
const nextValue = JSON.stringify(value);
|
|
4641
|
+
if (original.slice(valueStart, valueEnd) !== nextValue) {
|
|
4642
|
+
edits.push({ start: valueStart, end: valueEnd, text: nextValue });
|
|
4643
|
+
}
|
|
4644
|
+
continue;
|
|
4645
|
+
}
|
|
4646
|
+
}
|
|
4647
|
+
missingEntries.push([key, value]);
|
|
4648
|
+
}
|
|
4649
|
+
|
|
4650
|
+
if (missingEntries.length > 0) {
|
|
4651
|
+
const keysFormatted = formatEntries(innerIndent, missingEntries);
|
|
4652
|
+
if (hasContent) {
|
|
4653
|
+
edits.push({ start: interiorStart, end: interiorStart, text: `\n${keysFormatted},` });
|
|
4654
|
+
} else {
|
|
4655
|
+
edits.push({ start: interiorStart, end: targetCompatEnd, text: `\n${keysFormatted}\n${braceLineIndent}` });
|
|
4656
|
+
}
|
|
4646
4657
|
}
|
|
4647
|
-
|
|
4648
|
-
|
|
4658
|
+
|
|
4659
|
+
// Apply later edits first so earlier offsets remain valid.
|
|
4660
|
+
return edits
|
|
4661
|
+
.sort((a, b) => b.start - a.start)
|
|
4662
|
+
.reduce((text, edit) => text.slice(0, edit.start) + edit.text + text.slice(edit.end), original);
|
|
4649
4663
|
}
|
|
4650
4664
|
|
|
4651
4665
|
// ── No compat object yet: create one right after the container `{`. ──
|
|
@@ -4666,12 +4680,12 @@ function composeFixInsertion(
|
|
|
4666
4680
|
: ' ';
|
|
4667
4681
|
const innerIndent = keyIndent + unit;
|
|
4668
4682
|
|
|
4669
|
-
const compatBlock = `\n${keyIndent}"compat": {\n${
|
|
4683
|
+
const compatBlock = `\n${keyIndent}"compat": {\n${formatEntries(innerIndent, sortedEntries)}\n${keyIndent}},`;
|
|
4670
4684
|
return original.slice(0, afterBrace) + compatBlock + suffix;
|
|
4671
4685
|
}
|
|
4672
4686
|
|
|
4673
4687
|
/**
|
|
4674
|
-
* Self-check after compose: parse original and modified
|
|
4688
|
+
* Self-check after compose: parse original and modified as JSONC,
|
|
4675
4689
|
* assert target compat flags exist in the right path, and remaining structure
|
|
4676
4690
|
* is deep-equal (ignoring the inserted keys).
|
|
4677
4691
|
* Returns null on success, error message on failure.
|
|
@@ -4684,17 +4698,17 @@ function selfCheckFix(
|
|
|
4684
4698
|
compatKeys: Record<string, unknown>,
|
|
4685
4699
|
): string | null {
|
|
4686
4700
|
try {
|
|
4687
|
-
// Step 1: Parse both versions (
|
|
4688
|
-
const origParsed =
|
|
4689
|
-
const modParsed =
|
|
4701
|
+
// Step 1: Parse both versions as JSONC (comments + trailing commas allowed).
|
|
4702
|
+
const origParsed = parseJsonc(original);
|
|
4703
|
+
const modParsed = parseJsonc(modified);
|
|
4690
4704
|
|
|
4691
4705
|
// Step 2: Validate modified file has correct structure
|
|
4692
|
-
const providers = modParsed?.providers;
|
|
4693
|
-
if (!providers
|
|
4706
|
+
const providers = asRecord(asRecord(modParsed)?.providers);
|
|
4707
|
+
if (!providers) {
|
|
4694
4708
|
return "Modified file: providers object missing or invalid";
|
|
4695
4709
|
}
|
|
4696
|
-
const provider = providers[providerLabel];
|
|
4697
|
-
if (!provider
|
|
4710
|
+
const provider = asRecord(providers[providerLabel]);
|
|
4711
|
+
if (!provider) {
|
|
4698
4712
|
return `Modified file: provider "${providerLabel}" not found`;
|
|
4699
4713
|
}
|
|
4700
4714
|
|
|
@@ -4712,6 +4726,18 @@ function selfCheckFix(
|
|
|
4712
4726
|
if (!targetModel || typeof targetModel !== 'object') {
|
|
4713
4727
|
return `Modified file: model "${modelId}" not found in provider`;
|
|
4714
4728
|
}
|
|
4729
|
+
|
|
4730
|
+
// Locate the corresponding original provider/model objects. The structure
|
|
4731
|
+
// preservation check below may allow repaired compat values to differ, but
|
|
4732
|
+
// only on these exact target/provider compat objects — never on siblings.
|
|
4733
|
+
const origProviders = asRecord(asRecord(origParsed)?.providers);
|
|
4734
|
+
const origProvider = asRecord(origProviders?.[providerLabel]);
|
|
4735
|
+
const origModels = Array.isArray(origProvider?.models) ? origProvider.models : undefined;
|
|
4736
|
+
const origTargetModel = origModels?.find((m: unknown) => asRecord(m)?.id === modelId);
|
|
4737
|
+
const origTargetModelRecord = asRecord(origTargetModel);
|
|
4738
|
+
if (!origProvider || !origTargetModelRecord) {
|
|
4739
|
+
return `Original file: provider/model "${providerLabel}/${modelId}" not found`;
|
|
4740
|
+
}
|
|
4715
4741
|
|
|
4716
4742
|
// Step 5: Compute the EFFECTIVE merged compat (provider-level + model-level),
|
|
4717
4743
|
// mirroring Pi's mergeCompat behavior (model wins on conflicts). The fix may
|
|
@@ -4754,15 +4780,23 @@ function selfCheckFix(
|
|
|
4754
4780
|
for (const key of Object.keys(origObj)) {
|
|
4755
4781
|
if (!(key in modObj)) return false;
|
|
4756
4782
|
if (key === 'compat') {
|
|
4757
|
-
// For compat, allow extra keys in modified (the inserted ones)
|
|
4783
|
+
// For compat, allow extra keys in modified (the inserted ones).
|
|
4784
|
+
// Use recursive isSubset so nested objects (e.g. { deep: true })
|
|
4785
|
+
// are compared by content, not reference.
|
|
4758
4786
|
if (typeof origObj[key] !== 'object' || typeof modObj[key] !== 'object') {
|
|
4759
4787
|
if (origObj[key] !== modObj[key]) return false;
|
|
4760
4788
|
} else {
|
|
4761
|
-
// Check all original compat keys are present and equal
|
|
4762
4789
|
const origCompat = origObj[key] as Record<string, unknown>;
|
|
4763
4790
|
const modCompat = modObj[key] as Record<string, unknown>;
|
|
4791
|
+
const mayRepairThisCompat = origObj === origProvider || origObj === origTargetModelRecord;
|
|
4764
4792
|
for (const ck of Object.keys(origCompat)) {
|
|
4765
|
-
if (
|
|
4793
|
+
if (!(ck in modCompat)) return false;
|
|
4794
|
+
// The fix may repair an existing wrong compat value (for example
|
|
4795
|
+
// thinkingFormat: "legacy" -> "deepseek"), but only on the
|
|
4796
|
+
// target provider/model compat objects. Sibling compat blocks must
|
|
4797
|
+
// remain structure-equivalent.
|
|
4798
|
+
if (mayRepairThisCompat && Object.prototype.hasOwnProperty.call(compatKeys, ck)) continue;
|
|
4799
|
+
if (!isSubset(origCompat[ck], modCompat[ck], `${path}.${ck}`)) return false;
|
|
4766
4800
|
}
|
|
4767
4801
|
}
|
|
4768
4802
|
} else if (!isSubset(origObj[key], modObj[key], `${path}.${key}`)) {
|
|
@@ -4781,11 +4815,17 @@ function selfCheckFix(
|
|
|
4781
4815
|
return "Modified file: content is shorter than original (possible truncation)";
|
|
4782
4816
|
}
|
|
4783
4817
|
|
|
4784
|
-
// Step 9: Validate
|
|
4785
|
-
|
|
4786
|
-
|
|
4787
|
-
|
|
4788
|
-
|
|
4818
|
+
// Step 9: Validate root bracket integrity with the same string/comment-aware
|
|
4819
|
+
// scanner used for edits. Do not count raw braces: comments or strings may
|
|
4820
|
+
// legitimately contain unmatched `{` / `}` bytes.
|
|
4821
|
+
const modifiedClean = stripJsoncComments(modified);
|
|
4822
|
+
const rootStart = skipJsonWhitespace(modifiedClean, 0);
|
|
4823
|
+
const rootEnd = findMatchingBracket(modifiedClean, rootStart);
|
|
4824
|
+
if (rootEnd === undefined) {
|
|
4825
|
+
return "Modified file: root bracket mismatch";
|
|
4826
|
+
}
|
|
4827
|
+
if (skipJsonWhitespace(modifiedClean, rootEnd + 1) !== modifiedClean.length) {
|
|
4828
|
+
return "Modified file: trailing non-whitespace content after root object";
|
|
4789
4829
|
}
|
|
4790
4830
|
|
|
4791
4831
|
return null;
|
|
@@ -4801,8 +4841,7 @@ function selfCheckFix(
|
|
|
4801
4841
|
function formatCompatKeysForInsertion(compatKeys: Record<string, unknown>): string {
|
|
4802
4842
|
return Object.entries(compatKeys)
|
|
4803
4843
|
.map(([k, v]) => {
|
|
4804
|
-
|
|
4805
|
-
return ` ${JSON.stringify(k)}: ${val}`;
|
|
4844
|
+
return ` ${JSON.stringify(k)}: ${JSON.stringify(v)}`;
|
|
4806
4845
|
})
|
|
4807
4846
|
.join(',\n');
|
|
4808
4847
|
}
|
|
@@ -4852,6 +4891,7 @@ export const __internals_for_tests = {
|
|
|
4852
4891
|
isOpenAIFamilyToken,
|
|
4853
4892
|
describeMissingOpenAIFamilyProxyCompat,
|
|
4854
4893
|
describeMissingOpenAICompatibleProxyCompat,
|
|
4894
|
+
describeOptionalOpenAICompatibleProxyCompat,
|
|
4855
4895
|
describeMissingDeepSeekCompat,
|
|
4856
4896
|
isDeepSeekCompatCheckApplicable,
|
|
4857
4897
|
describeMissingCacheCompatForModel,
|
|
@@ -5033,10 +5073,14 @@ export const __internals_for_tests = {
|
|
|
5033
5073
|
// JSONC surgical edit helpers
|
|
5034
5074
|
MODELS_JSON_PATH,
|
|
5035
5075
|
stripJsoncComments,
|
|
5076
|
+
stripJsoncTrailingCommas,
|
|
5077
|
+
parseJsonc,
|
|
5036
5078
|
locateModelInJsonc,
|
|
5037
5079
|
composeFixInsertion,
|
|
5038
5080
|
selfCheckFix,
|
|
5039
5081
|
decideFixPlacement,
|
|
5082
|
+
chooseFixPlacement,
|
|
5083
|
+
findExistingCompatKeysInJsonc,
|
|
5040
5084
|
deepEqualIgnoringKeys,
|
|
5041
5085
|
formatCompatKeysForInsertion,
|
|
5042
5086
|
backupTimestamp,
|
|
@@ -5400,8 +5444,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
5400
5444
|
// changes and day rollovers. Redundant setStatus calls are blocked by the
|
|
5401
5445
|
// `lastStatusText` early return above.
|
|
5402
5446
|
if (runtimeOptimizerEnabled && statusText !== undefined && model) {
|
|
5403
|
-
|
|
5404
|
-
|
|
5447
|
+
// Only show ⚠️ compat when there are safe-fixable missing compat keys.
|
|
5448
|
+
// Optional/advisory-only flags (e.g. supportsLongCacheRetention on generic
|
|
5449
|
+
// OpenAI-compatible proxies) do NOT trigger the marker — the doctor/compat
|
|
5450
|
+
// commands still mention them as optional guidance.
|
|
5451
|
+
if (buildFixSuggestion(model) !== undefined) {
|
|
5405
5452
|
statusText = statusText + " ⚠️ compat";
|
|
5406
5453
|
}
|
|
5407
5454
|
}
|
|
@@ -5755,7 +5802,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
5755
5802
|
|
|
5756
5803
|
// Compose the modified text — auto-detect the best placement level:
|
|
5757
5804
|
// provider level (channel-wide) when safe for all sibling models, else model level.
|
|
5758
|
-
const decision =
|
|
5805
|
+
const decision = chooseFixPlacement(originalText, location, suggestion.compatKeys, suggestion.providerLabel);
|
|
5759
5806
|
const modifiedText = composeFixInsertion(originalText, location, suggestion.compatKeys, decision.placement);
|
|
5760
5807
|
|
|
5761
5808
|
// Self-check
|
|
@@ -5769,10 +5816,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
5769
5816
|
return;
|
|
5770
5817
|
}
|
|
5771
5818
|
|
|
5772
|
-
// Build preview snippet
|
|
5773
|
-
|
|
5774
|
-
|
|
5775
|
-
.join("\n");
|
|
5819
|
+
// Build preview snippet as copyable JSON (the surgical editor will
|
|
5820
|
+
// insert or repair these exact compat key/value pairs).
|
|
5821
|
+
const keysPreview = JSON.stringify(suggestion.compatKeys, null, 2);
|
|
5776
5822
|
const targetHasCompat = decision.placement === "provider" ? location.providerCompatBrace >= 0 : location.compatObjectBrace >= 0;
|
|
5777
5823
|
const placementDesc = targetHasCompat ? `existing "compat" object` : `new "compat" object`;
|
|
5778
5824
|
const locationDesc = decision.placement === "provider"
|
|
@@ -5791,7 +5837,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
5791
5837
|
``,
|
|
5792
5838
|
`Location: ${locationDesc}`,
|
|
5793
5839
|
`Placement: ${decision.placement} level — ${decision.reason}`,
|
|
5794
|
-
`
|
|
5840
|
+
`Compat JSON to write:`,
|
|
5795
5841
|
keysPreview,
|
|
5796
5842
|
``,
|
|
5797
5843
|
`⚠️ Risk notice:`,
|
|
@@ -5948,7 +5994,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
5948
5994
|
return;
|
|
5949
5995
|
}
|
|
5950
5996
|
|
|
5951
|
-
const menuDecision =
|
|
5997
|
+
const menuDecision = chooseFixPlacement(originalText, location, suggestion.compatKeys, suggestion.providerLabel);
|
|
5952
5998
|
const modifiedText = composeFixInsertion(originalText, location, suggestion.compatKeys, menuDecision.placement);
|
|
5953
5999
|
const checkError = selfCheckFix(originalText, modifiedText, suggestion.providerLabel, suggestion.modelId, suggestion.compatKeys);
|
|
5954
6000
|
if (checkError !== null) {
|
|
@@ -5956,9 +6002,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
5956
6002
|
return;
|
|
5957
6003
|
}
|
|
5958
6004
|
|
|
5959
|
-
const keysPreview =
|
|
5960
|
-
.map(([k, v]) => ` ${k}: ${JSON.stringify(v)}`)
|
|
5961
|
-
.join("\n");
|
|
6005
|
+
const keysPreview = JSON.stringify(suggestion.compatKeys, null, 2);
|
|
5962
6006
|
const ts = backupTimestamp();
|
|
5963
6007
|
const backupPath = `${MODELS_JSON_PATH}.backup-cache-optimizer-${ts}`;
|
|
5964
6008
|
|
|
@@ -5973,7 +6017,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
5973
6017
|
`📝 Preview of changes to ${getModelsJsonDisplayPath()}:`,
|
|
5974
6018
|
`Location: ${menuLocationDesc}`,
|
|
5975
6019
|
`Placement: ${menuDecision.placement} level — ${menuDecision.reason}`,
|
|
5976
|
-
`
|
|
6020
|
+
`Compat JSON to write:`,
|
|
5977
6021
|
keysPreview,
|
|
5978
6022
|
``,
|
|
5979
6023
|
`⚠️ Risk notice:`,
|
package/package.json
CHANGED