pi-cache-optimizer 2.6.3 → 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 +550 -306
- package/package.json +1 -1
package/index.ts
CHANGED
|
@@ -162,9 +162,16 @@ type PersistedCacheStatsV2 = {
|
|
|
162
162
|
};
|
|
163
163
|
|
|
164
164
|
/** Per-model-key scoped state. Used in memory and for v3 persistence. */
|
|
165
|
+
type PersistedRoutedModelRef = {
|
|
166
|
+
provider: string;
|
|
167
|
+
id: string;
|
|
168
|
+
name?: string;
|
|
169
|
+
};
|
|
170
|
+
|
|
165
171
|
type CacheStatsState = {
|
|
166
172
|
statsByModel: Record<string, CacheStats>;
|
|
167
173
|
legacyFamily: Partial<Record<CacheProviderId, CacheStats>>;
|
|
174
|
+
lastRoutedModelBySession?: Record<string, PersistedRoutedModelRef>;
|
|
168
175
|
};
|
|
169
176
|
|
|
170
177
|
type PersistedCacheStatsV3 = {
|
|
@@ -186,6 +193,13 @@ type PersistedCacheStatsV4 = {
|
|
|
186
193
|
legacyFamily: Partial<Record<CacheProviderId, CacheStats>>;
|
|
187
194
|
};
|
|
188
195
|
|
|
196
|
+
type PersistedCacheStatsV5 = {
|
|
197
|
+
version: 5;
|
|
198
|
+
sessions: Record<string, Record<string, CacheStats>>;
|
|
199
|
+
legacyFamily: Partial<Record<CacheProviderId, CacheStats>>;
|
|
200
|
+
lastRoutedModelBySession?: Record<string, PersistedRoutedModelRef>;
|
|
201
|
+
};
|
|
202
|
+
|
|
189
203
|
type UsageSnapshot = {
|
|
190
204
|
cacheRead: number;
|
|
191
205
|
cacheWrite: number;
|
|
@@ -1598,7 +1612,7 @@ function hasEffectivePromptCacheKey(record: UnknownRecord): boolean {
|
|
|
1598
1612
|
return isNonEmptyString(record.prompt_cache_key) || isNonEmptyString(record.promptCacheKey);
|
|
1599
1613
|
}
|
|
1600
1614
|
|
|
1601
|
-
function isNonEmptyString(value: unknown):
|
|
1615
|
+
function isNonEmptyString(value: unknown): value is string {
|
|
1602
1616
|
return typeof value === "string" && value.trim().length > 0;
|
|
1603
1617
|
}
|
|
1604
1618
|
|
|
@@ -1623,9 +1637,6 @@ function describeMissingOpenAIFamilyProxyCompat(model: PiModel): string[] {
|
|
|
1623
1637
|
if (!isOpenAICompatibleProxyApi(model.api)) return missing;
|
|
1624
1638
|
if (isOfficialOpenAIBaseUrl(model)) return missing;
|
|
1625
1639
|
|
|
1626
|
-
if (compat.supportsLongCacheRetention !== true) {
|
|
1627
|
-
missing.push("supportsLongCacheRetention");
|
|
1628
|
-
}
|
|
1629
1640
|
if (compat.sendSessionAffinityHeaders !== true) {
|
|
1630
1641
|
missing.push("sendSessionAffinityHeaders");
|
|
1631
1642
|
}
|
|
@@ -1646,9 +1657,6 @@ function describeMissingOpenAICompatibleProxyCompat(model: PiModel): string[] {
|
|
|
1646
1657
|
if (!isOpenAICompatibleProxyApi(model.api)) return missing;
|
|
1647
1658
|
if (isOfficialOpenAIBaseUrl(model)) return missing;
|
|
1648
1659
|
|
|
1649
|
-
if (compat.supportsLongCacheRetention !== true) {
|
|
1650
|
-
missing.push("supportsLongCacheRetention");
|
|
1651
|
-
}
|
|
1652
1660
|
if (compat.sendSessionAffinityHeaders !== true) {
|
|
1653
1661
|
missing.push("sendSessionAffinityHeaders");
|
|
1654
1662
|
}
|
|
@@ -1656,6 +1664,20 @@ function describeMissingOpenAICompatibleProxyCompat(model: PiModel): string[] {
|
|
|
1656
1664
|
return missing;
|
|
1657
1665
|
}
|
|
1658
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
|
+
|
|
1659
1681
|
function buildSafeOpenAIProxyCompatSuggestion(missing: string[]): Record<string, boolean> {
|
|
1660
1682
|
const suggestion: Record<string, boolean> = {};
|
|
1661
1683
|
if (missing.includes("sendSessionAffinityHeaders")) {
|
|
@@ -1746,21 +1768,22 @@ function appendOpenAIProxyCompatAdviceLines(lines: string[], missing: string[],
|
|
|
1746
1768
|
lines.push("Safe default suggestion:");
|
|
1747
1769
|
}
|
|
1748
1770
|
lines.push(JSON.stringify(suggestion, null, 2));
|
|
1749
|
-
} else if (missing.includes("supportsLongCacheRetention")) {
|
|
1750
|
-
lines.push("No safe automatic JSON change is recommended for `supportsLongCacheRetention`.");
|
|
1751
1771
|
}
|
|
1752
1772
|
|
|
1753
1773
|
if (missing.includes("sendSessionAffinityHeaders")) {
|
|
1754
1774
|
lines.push("- sendSessionAffinityHeaders: recommended for third-party proxies when supported; it helps keep one Pi session on the same upstream/backend.");
|
|
1755
1775
|
}
|
|
1756
|
-
if (missing.includes("supportsLongCacheRetention")) {
|
|
1757
|
-
lines.push("- supportsLongCacheRetention: optional. Enable only after your endpoint/proxy explicitly supports OpenAI long prompt cache retention.");
|
|
1758
|
-
lines.push(`- ${getPromptCacheRetentionUnsupportedHint()}`);
|
|
1759
|
-
}
|
|
1760
|
-
|
|
1761
1776
|
appendCredentialSafeProviderGuidance(lines, options, suggestion);
|
|
1762
1777
|
}
|
|
1763
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
|
+
|
|
1764
1787
|
/**
|
|
1765
1788
|
* Build the warning text displayed to users when an OpenAI-family third-party
|
|
1766
1789
|
* proxy is missing one or more cache/session-affinity compat flags.
|
|
@@ -3105,12 +3128,58 @@ function parseCacheStats(value: unknown): CacheStats | undefined {
|
|
|
3105
3128
|
};
|
|
3106
3129
|
}
|
|
3107
3130
|
|
|
3131
|
+
function parsePersistedRoutedModelRef(value: unknown): PersistedRoutedModelRef | undefined {
|
|
3132
|
+
const record = asRecord(value);
|
|
3133
|
+
const provider = record?.provider;
|
|
3134
|
+
const id = record?.id;
|
|
3135
|
+
const name = record?.name;
|
|
3136
|
+
if (!isNonEmptyString(provider) || !isNonEmptyString(id)) return undefined;
|
|
3137
|
+
|
|
3138
|
+
return {
|
|
3139
|
+
provider: provider.trim(),
|
|
3140
|
+
id: id.trim(),
|
|
3141
|
+
name: isNonEmptyString(name) ? name.trim() : id.trim(),
|
|
3142
|
+
};
|
|
3143
|
+
}
|
|
3144
|
+
|
|
3145
|
+
function routedModelRefToPiModel(ref: PersistedRoutedModelRef): PiModel {
|
|
3146
|
+
return {
|
|
3147
|
+
id: ref.id,
|
|
3148
|
+
name: ref.name ?? ref.id,
|
|
3149
|
+
provider: ref.provider,
|
|
3150
|
+
api: "",
|
|
3151
|
+
baseUrl: "",
|
|
3152
|
+
reasoning: false,
|
|
3153
|
+
input: ["text"],
|
|
3154
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
3155
|
+
contextWindow: 0,
|
|
3156
|
+
maxTokens: 0,
|
|
3157
|
+
} as PiModel;
|
|
3158
|
+
}
|
|
3159
|
+
|
|
3160
|
+
function buildExactRouterStatusEntry(
|
|
3161
|
+
sessionHash: string | undefined,
|
|
3162
|
+
statsByModel: Record<string, CacheStats>,
|
|
3163
|
+
lastRoutedModel: PersistedRoutedModelRef | undefined,
|
|
3164
|
+
): { adapter: CacheProviderAdapter; stats: CacheStats } | undefined {
|
|
3165
|
+
if (!sessionHash || !lastRoutedModel) return undefined;
|
|
3166
|
+
|
|
3167
|
+
const model = routedModelRefToPiModel(lastRoutedModel);
|
|
3168
|
+
const adapter = selectAdapterForModel(model);
|
|
3169
|
+
if (!adapter) return undefined;
|
|
3170
|
+
|
|
3171
|
+
const key = makeSessionModelKey(sessionHash, lastRoutedModel.provider, lastRoutedModel.id);
|
|
3172
|
+
return { adapter, stats: statsByModel[key] ?? emptyCacheStats() };
|
|
3173
|
+
}
|
|
3174
|
+
|
|
3108
3175
|
function parsePersistedCacheStats(value: unknown): CacheStatsState | undefined {
|
|
3109
3176
|
const record = asRecord(value);
|
|
3110
3177
|
if (!record) return undefined;
|
|
3111
3178
|
|
|
3112
|
-
// version 4: session-scoped stats + legacy family fallback
|
|
3113
|
-
|
|
3179
|
+
// version 4/5: session-scoped stats + legacy family fallback.
|
|
3180
|
+
// v5 additionally persists the last actual routed model per session so
|
|
3181
|
+
// router/auto can restore the exact upstream footer after /reload.
|
|
3182
|
+
if (record.version === 4 || record.version === 5) {
|
|
3114
3183
|
const legacyFamily: Partial<Record<CacheProviderId, CacheStats>> = {};
|
|
3115
3184
|
const rawFamily = asRecord(record.legacyFamily);
|
|
3116
3185
|
if (rawFamily) {
|
|
@@ -3138,10 +3207,19 @@ function parsePersistedCacheStats(value: unknown): CacheStatsState | undefined {
|
|
|
3138
3207
|
}
|
|
3139
3208
|
}
|
|
3140
3209
|
|
|
3141
|
-
|
|
3210
|
+
const lastRoutedModelBySession: Record<string, PersistedRoutedModelRef> = {};
|
|
3211
|
+
const rawLastRoutedModels = asRecord(record.lastRoutedModelBySession);
|
|
3212
|
+
if (rawLastRoutedModels) {
|
|
3213
|
+
for (const [sessionHash, rawModel] of Object.entries(rawLastRoutedModels)) {
|
|
3214
|
+
const parsed = parsePersistedRoutedModelRef(rawModel);
|
|
3215
|
+
if (parsed) lastRoutedModelBySession[sessionHash] = parsed;
|
|
3216
|
+
}
|
|
3217
|
+
}
|
|
3218
|
+
|
|
3219
|
+
return { statsByModel, legacyFamily, lastRoutedModelBySession };
|
|
3142
3220
|
}
|
|
3143
3221
|
|
|
3144
|
-
// version 3: migrate to v4 semantics by wrapping statsByModel into sessions
|
|
3222
|
+
// version 3: migrate to v4/v5 semantics by wrapping statsByModel into sessions
|
|
3145
3223
|
if (record.version === 3) {
|
|
3146
3224
|
const statsByModel: Record<string, CacheStats> = {};
|
|
3147
3225
|
const rawModelMap = asRecord(record.statsByModel);
|
|
@@ -3335,11 +3413,38 @@ function mergeCacheSessions(
|
|
|
3335
3413
|
return sessions;
|
|
3336
3414
|
}
|
|
3337
3415
|
|
|
3416
|
+
function mergeLastRoutedModels(
|
|
3417
|
+
existingLastRoutedModelBySession: Record<string, PersistedRoutedModelRef>,
|
|
3418
|
+
state: CacheStatsState,
|
|
3419
|
+
currentSessionHash?: string,
|
|
3420
|
+
): Record<string, PersistedRoutedModelRef> {
|
|
3421
|
+
const merged: Record<string, PersistedRoutedModelRef> = { ...existingLastRoutedModelBySession };
|
|
3422
|
+
const incoming = state.lastRoutedModelBySession ?? {};
|
|
3423
|
+
|
|
3424
|
+
if (currentSessionHash !== undefined) {
|
|
3425
|
+
const current = incoming[currentSessionHash];
|
|
3426
|
+
if (current) {
|
|
3427
|
+
merged[currentSessionHash] = current;
|
|
3428
|
+
} else {
|
|
3429
|
+
// Explicit deletion: when incoming state has no entry for current session,
|
|
3430
|
+
// remove any existing stale entry to reflect intentional reset.
|
|
3431
|
+
delete merged[currentSessionHash];
|
|
3432
|
+
}
|
|
3433
|
+
return merged;
|
|
3434
|
+
}
|
|
3435
|
+
|
|
3436
|
+
for (const [sessionHash, ref] of Object.entries(incoming)) {
|
|
3437
|
+
merged[sessionHash] = ref;
|
|
3438
|
+
}
|
|
3439
|
+
return merged;
|
|
3440
|
+
}
|
|
3441
|
+
|
|
3338
3442
|
async function writePersistedCacheStats(state: CacheStatsState, currentSessionHash?: string): Promise<void> {
|
|
3339
3443
|
await mkdir(STATE_DIR, { recursive: true });
|
|
3340
3444
|
|
|
3341
3445
|
// Read existing file to preserve other sessions' data.
|
|
3342
3446
|
let existingSessions: Record<string, Record<string, CacheStats>> = {};
|
|
3447
|
+
let existingLastRoutedModelBySession: Record<string, PersistedRoutedModelRef> = {};
|
|
3343
3448
|
try {
|
|
3344
3449
|
const raw = await readFile(STATE_FILE_PATH, "utf8");
|
|
3345
3450
|
const parsed = parsePersistedCacheStats(JSON.parse(raw));
|
|
@@ -3355,17 +3460,24 @@ async function writePersistedCacheStats(state: CacheStatsState, currentSessionHa
|
|
|
3355
3460
|
existingSessions[hash][modelKey] = stats;
|
|
3356
3461
|
}
|
|
3357
3462
|
}
|
|
3463
|
+
existingLastRoutedModelBySession = { ...(parsed.lastRoutedModelBySession ?? {}) };
|
|
3358
3464
|
}
|
|
3359
3465
|
} catch {
|
|
3360
3466
|
// Ignore read errors (file may not exist yet).
|
|
3361
3467
|
}
|
|
3362
3468
|
|
|
3363
3469
|
const sessions = mergeCacheSessions(existingSessions, state, currentSessionHash);
|
|
3470
|
+
const lastRoutedModelBySession = mergeLastRoutedModels(
|
|
3471
|
+
existingLastRoutedModelBySession,
|
|
3472
|
+
state,
|
|
3473
|
+
currentSessionHash,
|
|
3474
|
+
);
|
|
3364
3475
|
|
|
3365
|
-
const payload:
|
|
3366
|
-
version:
|
|
3476
|
+
const payload: PersistedCacheStatsV5 = {
|
|
3477
|
+
version: 5,
|
|
3367
3478
|
sessions,
|
|
3368
3479
|
legacyFamily: state.legacyFamily,
|
|
3480
|
+
...(Object.keys(lastRoutedModelBySession).length > 0 ? { lastRoutedModelBySession } : {}),
|
|
3369
3481
|
};
|
|
3370
3482
|
const tempPath = `${STATE_FILE_PATH}.${process.pid}.${Date.now()}.tmp`;
|
|
3371
3483
|
|
|
@@ -3430,8 +3542,9 @@ function describeRouterChannelDiagnostics(model: PiModel): string[] {
|
|
|
3430
3542
|
provider.includes("openrouter")
|
|
3431
3543
|
) {
|
|
3432
3544
|
const compat = getCompat(model);
|
|
3433
|
-
const
|
|
3434
|
-
const
|
|
3545
|
+
const routing = asRecord((compat as Record<string, unknown>)["openRouterRouting"]);
|
|
3546
|
+
const hasOnly = !!routing?.only;
|
|
3547
|
+
const hasOrder = !!routing?.order;
|
|
3435
3548
|
|
|
3436
3549
|
notes.push(
|
|
3437
3550
|
"🔀 Router/channel: OpenRouter detected. OpenRouter is a multi-provider router; " +
|
|
@@ -3466,8 +3579,9 @@ function describeRouterChannelDiagnostics(model: PiModel): string[] {
|
|
|
3466
3579
|
provider.includes("vercel-ai-gateway")
|
|
3467
3580
|
) {
|
|
3468
3581
|
const compat = getCompat(model);
|
|
3469
|
-
const
|
|
3470
|
-
const
|
|
3582
|
+
const routing = asRecord((compat as Record<string, unknown>)["vercelGatewayRouting"]);
|
|
3583
|
+
const hasOnly = !!routing?.only;
|
|
3584
|
+
const hasOrder = !!routing?.order;
|
|
3471
3585
|
|
|
3472
3586
|
notes.push(
|
|
3473
3587
|
"🔀 Router/channel: Vercel AI Gateway detected. The gateway may route to different " +
|
|
@@ -3594,8 +3708,21 @@ function buildDoctorDiagnosis(model: PiModel, options: { promptCacheRetention400
|
|
|
3594
3708
|
const adaptiveThinkingApplicable = isAdaptiveThinkingCompatApplicable(model);
|
|
3595
3709
|
const deepSeekCompatApplicable = isDeepSeekCompatCheckApplicable(model);
|
|
3596
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
|
+
|
|
3597
3725
|
if (missing.length > 0) {
|
|
3598
|
-
lines.push(`⚠️ Missing compat flags: ${missing.join(", ")}`);
|
|
3599
3726
|
const key = modelKey(model);
|
|
3600
3727
|
const slashIdx = key.indexOf("/");
|
|
3601
3728
|
const providerLabel = slashIdx > 0 ? key.slice(0, slashIdx) : key;
|
|
@@ -3607,9 +3734,11 @@ function buildDoctorDiagnosis(model: PiModel, options: { promptCacheRetention400
|
|
|
3607
3734
|
appendDeepSeekCompatAdviceLines(lines, missing, { providerLabel, modelId: model.id });
|
|
3608
3735
|
} else {
|
|
3609
3736
|
appendOpenAIProxyCompatAdviceLines(lines, missing, { providerLabel, modelId: model.id });
|
|
3737
|
+
appendOptionalOpenAIProxyCompatAdviceLines(lines, optionalOpenAIProxyCompat);
|
|
3610
3738
|
}
|
|
3611
3739
|
} else if (adaptiveThinkingApplicable || deepSeekCompatApplicable || isCompatCheckApplicable(model)) {
|
|
3612
3740
|
lines.push("✅ Compat fully configured.");
|
|
3741
|
+
appendOptionalOpenAIProxyCompatAdviceLines(lines, optionalOpenAIProxyCompat);
|
|
3613
3742
|
} else {
|
|
3614
3743
|
lines.push(...getCompatCheckNotApplicableLines(model));
|
|
3615
3744
|
}
|
|
@@ -3667,7 +3796,8 @@ function buildLowHitDiagnosis(
|
|
|
3667
3796
|
const lines: string[] = [];
|
|
3668
3797
|
|
|
3669
3798
|
// 1. Missing compat flags (adapter-aware: DeepSeek has extra reasoning compat)
|
|
3670
|
-
const
|
|
3799
|
+
const fixSugLHD = buildFixSuggestion(model);
|
|
3800
|
+
const safeFixableMissingLHD = fixSugLHD ? Object.keys(fixSugLHD.compatKeys) : [];
|
|
3671
3801
|
|
|
3672
3802
|
// 2. Router/channel risk (reuse existing check)
|
|
3673
3803
|
const routerNotes = describeRouterChannelDiagnostics(model);
|
|
@@ -3685,7 +3815,7 @@ function buildLowHitDiagnosis(
|
|
|
3685
3815
|
// 5. Today's overall trend from persisted stats
|
|
3686
3816
|
const todayStats = stats ?? emptyCacheStats();
|
|
3687
3817
|
|
|
3688
|
-
const hasMissingCompat =
|
|
3818
|
+
const hasMissingCompat = safeFixableMissingLHD.length > 0;
|
|
3689
3819
|
const hasRouterRisk = routerNotes.length > 0;
|
|
3690
3820
|
const hasUsageMissing = missingUsageSamples > 0;
|
|
3691
3821
|
|
|
@@ -3714,7 +3844,7 @@ function buildLowHitDiagnosis(
|
|
|
3714
3844
|
|
|
3715
3845
|
// Priority 1: missing compat flags
|
|
3716
3846
|
if (hasMissingCompat) {
|
|
3717
|
-
lines.push(`⚠️ Missing compat flags: ${
|
|
3847
|
+
lines.push(`⚠️ Missing compat flags: ${safeFixableMissingLHD.join(", ")}`);
|
|
3718
3848
|
lines.push(" These flags enable prompt caching and session-affinity routing.");
|
|
3719
3849
|
lines.push(" Run /cache-optimizer compat for edit instructions.");
|
|
3720
3850
|
}
|
|
@@ -3767,11 +3897,17 @@ function buildLowHitDiagnosis(
|
|
|
3767
3897
|
|
|
3768
3898
|
function buildCompatDiagnosis(model: PiModel): string | undefined {
|
|
3769
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));
|
|
3770
3903
|
const adaptiveThinkingApplicable = isAdaptiveThinkingCompatApplicable(model);
|
|
3771
3904
|
const deepSeekCompatApplicable = isDeepSeekCompatCheckApplicable(model);
|
|
3905
|
+
const optionalOpenAIProxyCompat = (!adaptiveThinkingApplicable && !deepSeekCompatApplicable)
|
|
3906
|
+
? describeOptionalOpenAICompatibleProxyCompat(model)
|
|
3907
|
+
: [];
|
|
3772
3908
|
const routerNotes = describeRouterChannelDiagnostics(model);
|
|
3773
3909
|
|
|
3774
|
-
if (missing.length === 0 && routerNotes.length === 0) return undefined;
|
|
3910
|
+
if (missing.length === 0 && routerNotes.length === 0 && optionalOpenAIProxyCompat.length === 0) return undefined;
|
|
3775
3911
|
|
|
3776
3912
|
const key = modelKey(model);
|
|
3777
3913
|
const lines: string[] = [];
|
|
@@ -3781,7 +3917,12 @@ function buildCompatDiagnosis(model: PiModel): string | undefined {
|
|
|
3781
3917
|
const providerLabel = slashIdx > 0 ? key.slice(0, slashIdx) : key;
|
|
3782
3918
|
const modelsJsonPath = getModelsJsonDisplayPath();
|
|
3783
3919
|
lines.push(`Active model: ${key}`);
|
|
3784
|
-
|
|
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
|
+
}
|
|
3785
3926
|
lines.push("");
|
|
3786
3927
|
lines.push(`Edit ${modelsJsonPath} -> providers["${providerLabel}"] -> compat`);
|
|
3787
3928
|
lines.push(`(at the same level as baseUrl/api/apiKey/models).`);
|
|
@@ -3791,16 +3932,18 @@ function buildCompatDiagnosis(model: PiModel): string | undefined {
|
|
|
3791
3932
|
appendDeepSeekCompatAdviceLines(lines, missing, { providerLabel, modelId: model.id });
|
|
3792
3933
|
} else {
|
|
3793
3934
|
appendOpenAIProxyCompatAdviceLines(lines, missing, { providerLabel, modelId: model.id });
|
|
3935
|
+
appendOptionalOpenAIProxyCompatAdviceLines(lines, optionalOpenAIProxyCompat);
|
|
3794
3936
|
}
|
|
3795
3937
|
}
|
|
3796
3938
|
|
|
3797
|
-
// When compat is fully configured but router notes exist, prefix the status.
|
|
3798
|
-
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) {
|
|
3799
3941
|
if (adaptiveThinkingApplicable || deepSeekCompatApplicable || isCompatCheckApplicable(model)) {
|
|
3800
3942
|
lines.push("✅ Compat fully configured.");
|
|
3801
3943
|
if (isPromptCacheRetention400Applicable(model)) {
|
|
3802
3944
|
lines.push(getPromptCacheRetentionUnsupportedHint());
|
|
3803
3945
|
}
|
|
3946
|
+
appendOptionalOpenAIProxyCompatAdviceLines(lines, optionalOpenAIProxyCompat);
|
|
3804
3947
|
} else {
|
|
3805
3948
|
lines.push(...getCompatCheckNotApplicableLines(model));
|
|
3806
3949
|
}
|
|
@@ -4031,8 +4174,10 @@ function stripJsoncComments(text: string): string {
|
|
|
4031
4174
|
let i = 0;
|
|
4032
4175
|
while (i < text.length) {
|
|
4033
4176
|
const ch = text[i];
|
|
4177
|
+
|
|
4034
4178
|
if (ch === '"') {
|
|
4035
|
-
// 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.
|
|
4036
4181
|
out.push(ch);
|
|
4037
4182
|
i++;
|
|
4038
4183
|
while (i < text.length) {
|
|
@@ -4046,39 +4191,74 @@ function stripJsoncComments(text: string): string {
|
|
|
4046
4191
|
break;
|
|
4047
4192
|
}
|
|
4048
4193
|
}
|
|
4049
|
-
|
|
4050
|
-
|
|
4051
|
-
|
|
4052
|
-
|
|
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;
|
|
4053
4202
|
while (i < text.length && text[i] !== '\n') {
|
|
4054
4203
|
out.push(' ');
|
|
4055
4204
|
i++;
|
|
4056
4205
|
}
|
|
4057
|
-
|
|
4058
|
-
|
|
4059
|
-
|
|
4060
|
-
|
|
4061
|
-
|
|
4062
|
-
|
|
4063
|
-
|
|
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(' ', ' ');
|
|
4064
4217
|
i += 2;
|
|
4065
4218
|
break;
|
|
4066
4219
|
}
|
|
4067
|
-
|
|
4068
|
-
out.push('\n');
|
|
4069
|
-
} else {
|
|
4070
|
-
out.push(' ');
|
|
4071
|
-
}
|
|
4220
|
+
out.push(text[i] === '\n' ? '\n' : ' ');
|
|
4072
4221
|
i++;
|
|
4073
4222
|
}
|
|
4074
|
-
|
|
4075
|
-
out.push(ch);
|
|
4076
|
-
i++;
|
|
4223
|
+
continue;
|
|
4077
4224
|
}
|
|
4225
|
+
|
|
4226
|
+
out.push(ch);
|
|
4227
|
+
i++;
|
|
4078
4228
|
}
|
|
4079
4229
|
return out.join('');
|
|
4080
4230
|
}
|
|
4081
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
|
+
|
|
4082
4262
|
/**
|
|
4083
4263
|
* JSONC scanner: locate the provider block and model entry in models.json text.
|
|
4084
4264
|
* Returns the byte offsets for surgical insertion, or undefined if ambiguous.
|
|
@@ -4122,153 +4302,51 @@ function locateModelInJsonc(
|
|
|
4122
4302
|
// Clean text of comments first for reliable structural scanning
|
|
4123
4303
|
const clean = stripJsoncComments(text);
|
|
4124
4304
|
|
|
4125
|
-
// Strategy: find `"providers"`
|
|
4126
|
-
// provider key under it, then the `"models"`
|
|
4127
|
-
//
|
|
4128
|
-
//
|
|
4129
|
-
|
|
4130
|
-
const
|
|
4131
|
-
if (
|
|
4132
|
-
|
|
4133
|
-
|
|
4134
|
-
|
|
4135
|
-
|
|
4136
|
-
|
|
4137
|
-
|
|
4138
|
-
|
|
4139
|
-
|
|
4140
|
-
|
|
4141
|
-
|
|
4142
|
-
|
|
4143
|
-
|
|
4144
|
-
|
|
4145
|
-
|
|
4146
|
-
|
|
4147
|
-
|
|
4148
|
-
|
|
4149
|
-
if (clean[cur] === '}') break; // End of providers
|
|
4150
|
-
|
|
4151
|
-
// Try to read a string key
|
|
4152
|
-
if (clean[cur] !== '"') { cur++; continue; }
|
|
4153
|
-
const keyEnd = clean.indexOf('"', cur + 1);
|
|
4154
|
-
if (keyEnd < 0) return undefined;
|
|
4155
|
-
const key = clean.slice(cur + 1, keyEnd);
|
|
4156
|
-
cur = keyEnd + 1;
|
|
4157
|
-
|
|
4158
|
-
// Skip `:`
|
|
4159
|
-
while (cur < clean.length && clean[cur] !== ':') cur++;
|
|
4160
|
-
if (cur >= clean.length) return undefined;
|
|
4161
|
-
cur++; // Skip `:`
|
|
4162
|
-
while (cur < clean.length && (clean[cur] === ' ' || clean[cur] === '\n' || clean[cur] === '\r' || clean[cur] === '\t')) cur++;
|
|
4163
|
-
|
|
4164
|
-
if (key === providerLabel) {
|
|
4165
|
-
// Found — expect `{` starting the provider object
|
|
4166
|
-
if (clean[cur] !== '{') return undefined;
|
|
4167
|
-
providerBrace = cur;
|
|
4168
|
-
// Find matching closing `}` for the provider object (track depth)
|
|
4169
|
-
let depth = 1;
|
|
4170
|
-
let scan = cur + 1;
|
|
4171
|
-
while (scan < clean.length && depth > 0) {
|
|
4172
|
-
if (clean[scan] === '{') depth++;
|
|
4173
|
-
else if (clean[scan] === '}') depth--;
|
|
4174
|
-
if (depth > 0) scan++;
|
|
4175
|
-
}
|
|
4176
|
-
providerEndBrace = scan;
|
|
4177
|
-
break;
|
|
4178
|
-
}
|
|
4179
|
-
|
|
4180
|
-
// Skip the value
|
|
4181
|
-
if (clean[cur] === '{') {
|
|
4182
|
-
let depth = 1;
|
|
4183
|
-
cur++;
|
|
4184
|
-
while (cur < clean.length && depth > 0) {
|
|
4185
|
-
if (clean[cur] === '{') depth++;
|
|
4186
|
-
else if (clean[cur] === '}') depth--;
|
|
4187
|
-
cur++;
|
|
4188
|
-
}
|
|
4189
|
-
} else if (clean[cur] === '[') {
|
|
4190
|
-
let depth = 1;
|
|
4191
|
-
cur++;
|
|
4192
|
-
while (cur < clean.length && depth > 0) {
|
|
4193
|
-
if (clean[cur] === '[') depth++;
|
|
4194
|
-
else if (clean[cur] === ']') depth--;
|
|
4195
|
-
cur++;
|
|
4196
|
-
}
|
|
4197
|
-
} else if (clean[cur] === '"') {
|
|
4198
|
-
const strEnd = clean.indexOf('"', cur + 1);
|
|
4199
|
-
if (strEnd < 0) return undefined;
|
|
4200
|
-
cur = strEnd + 1;
|
|
4201
|
-
} else {
|
|
4202
|
-
// Number, boolean, etc.
|
|
4203
|
-
while (cur < clean.length && clean[cur] !== ',' && clean[cur] !== '}' && clean[cur] !== '\n') cur++;
|
|
4204
|
-
}
|
|
4205
|
-
// Skip comma
|
|
4206
|
-
if (cur < clean.length && clean[cur] === ',') cur++;
|
|
4207
|
-
}
|
|
4208
|
-
|
|
4209
|
-
if (providerBrace < 0 || providerEndBrace < 0) return undefined;
|
|
4210
|
-
|
|
4211
|
-
// Scan provider object at depth 1 for a provider-level "compat" object.
|
|
4212
|
-
// Depth-aware + string-aware so nested model compat objects are not confused
|
|
4213
|
-
// 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.
|
|
4214
4329
|
let providerCompatBrace = -1;
|
|
4215
4330
|
let providerCompatEnd = -1;
|
|
4216
|
-
|
|
4217
|
-
|
|
4218
|
-
|
|
4219
|
-
|
|
4220
|
-
const
|
|
4221
|
-
if (
|
|
4222
|
-
|
|
4223
|
-
|
|
4224
|
-
if (strEnd < 0) break;
|
|
4225
|
-
const str = clean.slice(pScan + 1, strEnd);
|
|
4226
|
-
if (pDepth === 1 && str === 'compat') {
|
|
4227
|
-
// Confirm it's a key: next non-ws char must be ':'
|
|
4228
|
-
let after = strEnd + 1;
|
|
4229
|
-
while (after < providerEndBrace && (clean[after] === ' ' || clean[after] === '\n' || clean[after] === '\r' || clean[after] === '\t')) after++;
|
|
4230
|
-
if (clean[after] === ':') {
|
|
4231
|
-
after++;
|
|
4232
|
-
while (after < providerEndBrace && (clean[after] === ' ' || clean[after] === '\n' || clean[after] === '\r' || clean[after] === '\t')) after++;
|
|
4233
|
-
if (clean[after] === '{') {
|
|
4234
|
-
providerCompatBrace = after;
|
|
4235
|
-
let d = 1;
|
|
4236
|
-
let s = after + 1;
|
|
4237
|
-
while (s < clean.length && d > 0) {
|
|
4238
|
-
if (clean[s] === '"') {
|
|
4239
|
-
const e = clean.indexOf('"', s + 1);
|
|
4240
|
-
if (e < 0) break;
|
|
4241
|
-
s = e + 1;
|
|
4242
|
-
continue;
|
|
4243
|
-
}
|
|
4244
|
-
if (clean[s] === '{') d++;
|
|
4245
|
-
else if (clean[s] === '}') d--;
|
|
4246
|
-
if (d > 0) s++;
|
|
4247
|
-
}
|
|
4248
|
-
providerCompatEnd = s;
|
|
4249
|
-
pScan = s + 1;
|
|
4250
|
-
continue;
|
|
4251
|
-
}
|
|
4252
|
-
}
|
|
4253
|
-
}
|
|
4254
|
-
pScan = strEnd + 1;
|
|
4255
|
-
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;
|
|
4256
4339
|
}
|
|
4257
|
-
if (ch === '{' || ch === '[') pDepth++;
|
|
4258
|
-
else if (ch === '}' || ch === ']') pDepth--;
|
|
4259
|
-
pScan++;
|
|
4260
4340
|
}
|
|
4261
4341
|
}
|
|
4262
4342
|
|
|
4263
|
-
|
|
4264
|
-
|
|
4265
|
-
const modelsIdx = providerContent.indexOf('"models"');
|
|
4266
|
-
if (modelsIdx < 0) return undefined;
|
|
4343
|
+
const modelsKey = findJsonObjectKey(clean, providerBrace, "models");
|
|
4344
|
+
if (!modelsKey || modelsKey.keyStart > providerEndBrace) return undefined;
|
|
4267
4345
|
|
|
4268
|
-
|
|
4269
|
-
|
|
4270
|
-
|
|
4271
|
-
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;
|
|
4272
4350
|
modelsScan++; // Skip `[`
|
|
4273
4351
|
|
|
4274
4352
|
// Scan ALL array elements: collect every model id, and record the target's position
|
|
@@ -4279,83 +4357,52 @@ function locateModelInJsonc(
|
|
|
4279
4357
|
let compatBrace = -1;
|
|
4280
4358
|
let compatEndBrace = -1;
|
|
4281
4359
|
|
|
4282
|
-
while (modelsScan <
|
|
4283
|
-
|
|
4284
|
-
|
|
4285
|
-
|
|
4286
|
-
|
|
4287
|
-
|
|
4288
|
-
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;
|
|
4289
4368
|
|
|
4290
|
-
// Found a model object `{`
|
|
4291
4369
|
const elementBrace = modelsScan;
|
|
4370
|
+
const elementEnd = findMatchingBracket(clean, elementBrace);
|
|
4371
|
+
if (elementEnd === undefined || elementEnd > modelsEnd) return undefined;
|
|
4292
4372
|
|
|
4293
|
-
|
|
4294
|
-
let depth = 1;
|
|
4295
|
-
let scan = modelsScan + 1;
|
|
4373
|
+
const idKey = findJsonObjectKey(clean, elementBrace, "id");
|
|
4296
4374
|
let elementId: string | undefined;
|
|
4297
|
-
|
|
4298
|
-
|
|
4299
|
-
|
|
4300
|
-
|
|
4301
|
-
|
|
4302
|
-
if (depth === 1 && elementId === undefined && clean.slice(scan, scan + 4) === '"id"') {
|
|
4303
|
-
// Found "id" key — find the colon and the value
|
|
4304
|
-
let afterKey = scan + 4;
|
|
4305
|
-
while (afterKey < clean.length && clean[afterKey] !== ':') afterKey++;
|
|
4306
|
-
if (afterKey < clean.length) {
|
|
4307
|
-
afterKey++; // skip ':'
|
|
4308
|
-
while (afterKey < clean.length && (clean[afterKey] === ' ' || clean[afterKey] === '\n' || clean[afterKey] === '\r' || clean[afterKey] === '\t')) afterKey++;
|
|
4309
|
-
if (afterKey < clean.length && clean[afterKey] === '"') {
|
|
4310
|
-
const idStart = afterKey + 1;
|
|
4311
|
-
const idEnd = clean.indexOf('"', idStart);
|
|
4312
|
-
if (idEnd > idStart) {
|
|
4313
|
-
elementId = clean.slice(idStart, idEnd);
|
|
4314
|
-
}
|
|
4315
|
-
}
|
|
4316
|
-
}
|
|
4317
|
-
}
|
|
4318
|
-
scan = strEnd + 1;
|
|
4319
|
-
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;
|
|
4320
4380
|
}
|
|
4321
|
-
if (clean[scan] === '{') depth++;
|
|
4322
|
-
else if (clean[scan] === '}') depth--;
|
|
4323
|
-
scan++;
|
|
4324
4381
|
}
|
|
4325
4382
|
|
|
4326
|
-
const elementEnd = scan - 1; // The `}` that closed this element
|
|
4327
|
-
|
|
4328
4383
|
if (elementId !== undefined) {
|
|
4329
4384
|
allModelIds.push(elementId);
|
|
4330
4385
|
}
|
|
4331
4386
|
|
|
4332
4387
|
if (elementId === modelId && modelBrace < 0) {
|
|
4333
|
-
// This is the target model — record its position and find its compat
|
|
4334
4388
|
modelBrace = elementBrace;
|
|
4335
4389
|
modelEndBrace = elementEnd;
|
|
4336
|
-
|
|
4337
|
-
const
|
|
4338
|
-
if (
|
|
4339
|
-
compatKeyStartClean =
|
|
4340
|
-
|
|
4341
|
-
|
|
4342
|
-
|
|
4343
|
-
|
|
4344
|
-
|
|
4345
|
-
|
|
4346
|
-
let cdepth = 1;
|
|
4347
|
-
let cscan = compatScan + 1;
|
|
4348
|
-
while (cscan < clean.length && cdepth > 0) {
|
|
4349
|
-
if (clean[cscan] === '{') cdepth++;
|
|
4350
|
-
else if (clean[cscan] === '}') cdepth--;
|
|
4351
|
-
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;
|
|
4352
4400
|
}
|
|
4353
|
-
compatEndBrace = cscan;
|
|
4354
4401
|
}
|
|
4355
4402
|
}
|
|
4356
4403
|
}
|
|
4357
4404
|
|
|
4358
|
-
modelsScan =
|
|
4405
|
+
modelsScan = elementEnd + 1;
|
|
4359
4406
|
}
|
|
4360
4407
|
|
|
4361
4408
|
if (modelBrace < 0 || modelEndBrace < 0) return undefined;
|
|
@@ -4496,6 +4543,48 @@ function decideFixPlacement(
|
|
|
4496
4543
|
};
|
|
4497
4544
|
}
|
|
4498
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
|
+
|
|
4499
4588
|
function composeFixInsertion(
|
|
4500
4589
|
original: string,
|
|
4501
4590
|
location: ModelNodeLocation,
|
|
@@ -4507,14 +4596,12 @@ function composeFixInsertion(
|
|
|
4507
4596
|
const targetCompatEnd = placement === "provider" ? location.providerCompatEnd : location.compatObjectEnd;
|
|
4508
4597
|
const containerBrace = placement === "provider" ? location.providerObjectBrace : location.modelObjectBrace;
|
|
4509
4598
|
|
|
4510
|
-
// Helper: format
|
|
4511
|
-
|
|
4512
|
-
|
|
4513
|
-
|
|
4514
|
-
|
|
4515
|
-
|
|
4516
|
-
return `${indent}${JSON.stringify(k)}: ${val}`;
|
|
4517
|
-
})
|
|
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)}`)
|
|
4518
4605
|
.join(',\n');
|
|
4519
4606
|
|
|
4520
4607
|
// Helper: line-start indentation of the line containing `offset` in `original`.
|
|
@@ -4527,25 +4614,52 @@ function composeFixInsertion(
|
|
|
4527
4614
|
};
|
|
4528
4615
|
|
|
4529
4616
|
if (targetCompatBrace >= 0 && targetCompatEnd > targetCompatBrace) {
|
|
4530
|
-
// ── Existing compat object: insert
|
|
4531
|
-
//
|
|
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.
|
|
4532
4620
|
const interiorStart = targetCompatBrace + 1;
|
|
4533
4621
|
const interior = original.slice(interiorStart, targetCompatEnd);
|
|
4534
4622
|
const hasContent = interior.trim().length > 0;
|
|
4623
|
+
const clean = stripJsoncComments(original);
|
|
4535
4624
|
|
|
4536
|
-
// Indent for
|
|
4625
|
+
// Indent for inserted key lines: copy the first existing key line's indent,
|
|
4537
4626
|
// else derive one level deeper than the compat brace's own line.
|
|
4538
4627
|
const braceLineIndent = lineIndentAt(targetCompatBrace);
|
|
4539
4628
|
const innerMatch = interior.match(/\r?\n([ \t]+)\S/);
|
|
4540
4629
|
const innerIndent = innerMatch ? innerMatch[1] : braceLineIndent + ' ';
|
|
4541
|
-
const keysFormatted = formatKeys(innerIndent);
|
|
4542
4630
|
|
|
4543
|
-
|
|
4544
|
-
|
|
4545
|
-
|
|
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
|
+
}
|
|
4546
4657
|
}
|
|
4547
|
-
|
|
4548
|
-
|
|
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);
|
|
4549
4663
|
}
|
|
4550
4664
|
|
|
4551
4665
|
// ── No compat object yet: create one right after the container `{`. ──
|
|
@@ -4566,12 +4680,12 @@ function composeFixInsertion(
|
|
|
4566
4680
|
: ' ';
|
|
4567
4681
|
const innerIndent = keyIndent + unit;
|
|
4568
4682
|
|
|
4569
|
-
const compatBlock = `\n${keyIndent}"compat": {\n${
|
|
4683
|
+
const compatBlock = `\n${keyIndent}"compat": {\n${formatEntries(innerIndent, sortedEntries)}\n${keyIndent}},`;
|
|
4570
4684
|
return original.slice(0, afterBrace) + compatBlock + suffix;
|
|
4571
4685
|
}
|
|
4572
4686
|
|
|
4573
4687
|
/**
|
|
4574
|
-
* Self-check after compose: parse original and modified
|
|
4688
|
+
* Self-check after compose: parse original and modified as JSONC,
|
|
4575
4689
|
* assert target compat flags exist in the right path, and remaining structure
|
|
4576
4690
|
* is deep-equal (ignoring the inserted keys).
|
|
4577
4691
|
* Returns null on success, error message on failure.
|
|
@@ -4584,17 +4698,17 @@ function selfCheckFix(
|
|
|
4584
4698
|
compatKeys: Record<string, unknown>,
|
|
4585
4699
|
): string | null {
|
|
4586
4700
|
try {
|
|
4587
|
-
// Step 1: Parse both versions (
|
|
4588
|
-
const origParsed =
|
|
4589
|
-
const modParsed =
|
|
4701
|
+
// Step 1: Parse both versions as JSONC (comments + trailing commas allowed).
|
|
4702
|
+
const origParsed = parseJsonc(original);
|
|
4703
|
+
const modParsed = parseJsonc(modified);
|
|
4590
4704
|
|
|
4591
4705
|
// Step 2: Validate modified file has correct structure
|
|
4592
|
-
const providers = modParsed?.providers;
|
|
4593
|
-
if (!providers
|
|
4706
|
+
const providers = asRecord(asRecord(modParsed)?.providers);
|
|
4707
|
+
if (!providers) {
|
|
4594
4708
|
return "Modified file: providers object missing or invalid";
|
|
4595
4709
|
}
|
|
4596
|
-
const provider = providers[providerLabel];
|
|
4597
|
-
if (!provider
|
|
4710
|
+
const provider = asRecord(providers[providerLabel]);
|
|
4711
|
+
if (!provider) {
|
|
4598
4712
|
return `Modified file: provider "${providerLabel}" not found`;
|
|
4599
4713
|
}
|
|
4600
4714
|
|
|
@@ -4612,6 +4726,18 @@ function selfCheckFix(
|
|
|
4612
4726
|
if (!targetModel || typeof targetModel !== 'object') {
|
|
4613
4727
|
return `Modified file: model "${modelId}" not found in provider`;
|
|
4614
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
|
+
}
|
|
4615
4741
|
|
|
4616
4742
|
// Step 5: Compute the EFFECTIVE merged compat (provider-level + model-level),
|
|
4617
4743
|
// mirroring Pi's mergeCompat behavior (model wins on conflicts). The fix may
|
|
@@ -4654,15 +4780,23 @@ function selfCheckFix(
|
|
|
4654
4780
|
for (const key of Object.keys(origObj)) {
|
|
4655
4781
|
if (!(key in modObj)) return false;
|
|
4656
4782
|
if (key === 'compat') {
|
|
4657
|
-
// 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.
|
|
4658
4786
|
if (typeof origObj[key] !== 'object' || typeof modObj[key] !== 'object') {
|
|
4659
4787
|
if (origObj[key] !== modObj[key]) return false;
|
|
4660
4788
|
} else {
|
|
4661
|
-
// Check all original compat keys are present and equal
|
|
4662
4789
|
const origCompat = origObj[key] as Record<string, unknown>;
|
|
4663
4790
|
const modCompat = modObj[key] as Record<string, unknown>;
|
|
4791
|
+
const mayRepairThisCompat = origObj === origProvider || origObj === origTargetModelRecord;
|
|
4664
4792
|
for (const ck of Object.keys(origCompat)) {
|
|
4665
|
-
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;
|
|
4666
4800
|
}
|
|
4667
4801
|
}
|
|
4668
4802
|
} else if (!isSubset(origObj[key], modObj[key], `${path}.${key}`)) {
|
|
@@ -4681,11 +4815,17 @@ function selfCheckFix(
|
|
|
4681
4815
|
return "Modified file: content is shorter than original (possible truncation)";
|
|
4682
4816
|
}
|
|
4683
4817
|
|
|
4684
|
-
// Step 9: Validate
|
|
4685
|
-
|
|
4686
|
-
|
|
4687
|
-
|
|
4688
|
-
|
|
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";
|
|
4689
4829
|
}
|
|
4690
4830
|
|
|
4691
4831
|
return null;
|
|
@@ -4701,8 +4841,7 @@ function selfCheckFix(
|
|
|
4701
4841
|
function formatCompatKeysForInsertion(compatKeys: Record<string, unknown>): string {
|
|
4702
4842
|
return Object.entries(compatKeys)
|
|
4703
4843
|
.map(([k, v]) => {
|
|
4704
|
-
|
|
4705
|
-
return ` ${JSON.stringify(k)}: ${val}`;
|
|
4844
|
+
return ` ${JSON.stringify(k)}: ${JSON.stringify(v)}`;
|
|
4706
4845
|
})
|
|
4707
4846
|
.join(',\n');
|
|
4708
4847
|
}
|
|
@@ -4752,6 +4891,7 @@ export const __internals_for_tests = {
|
|
|
4752
4891
|
isOpenAIFamilyToken,
|
|
4753
4892
|
describeMissingOpenAIFamilyProxyCompat,
|
|
4754
4893
|
describeMissingOpenAICompatibleProxyCompat,
|
|
4894
|
+
describeOptionalOpenAICompatibleProxyCompat,
|
|
4755
4895
|
describeMissingDeepSeekCompat,
|
|
4756
4896
|
isDeepSeekCompatCheckApplicable,
|
|
4757
4897
|
describeMissingCacheCompatForModel,
|
|
@@ -4919,8 +5059,12 @@ export const __internals_for_tests = {
|
|
|
4919
5059
|
makeSessionModelKey,
|
|
4920
5060
|
modelKeyFromSessionKey,
|
|
4921
5061
|
filterRestorableStatsForSession,
|
|
5062
|
+
parsePersistedRoutedModelRef,
|
|
5063
|
+
routedModelRefToPiModel,
|
|
5064
|
+
buildExactRouterStatusEntry,
|
|
4922
5065
|
// Persistence helpers (for reload/reset tests)
|
|
4923
5066
|
mergeCacheSessions,
|
|
5067
|
+
mergeLastRoutedModels,
|
|
4924
5068
|
writePersistedCacheStats,
|
|
4925
5069
|
readPersistedCacheStats,
|
|
4926
5070
|
STATE_FILE_PATH,
|
|
@@ -4929,10 +5073,14 @@ export const __internals_for_tests = {
|
|
|
4929
5073
|
// JSONC surgical edit helpers
|
|
4930
5074
|
MODELS_JSON_PATH,
|
|
4931
5075
|
stripJsoncComments,
|
|
5076
|
+
stripJsoncTrailingCommas,
|
|
5077
|
+
parseJsonc,
|
|
4932
5078
|
locateModelInJsonc,
|
|
4933
5079
|
composeFixInsertion,
|
|
4934
5080
|
selfCheckFix,
|
|
4935
5081
|
decideFixPlacement,
|
|
5082
|
+
chooseFixPlacement,
|
|
5083
|
+
findExistingCompatKeysInJsonc,
|
|
4936
5084
|
deepEqualIgnoringKeys,
|
|
4937
5085
|
formatCompatKeysForInsertion,
|
|
4938
5086
|
backupTimestamp,
|
|
@@ -4960,6 +5108,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
4960
5108
|
let currentSessionId = "";
|
|
4961
5109
|
let currentSessionHash = "";
|
|
4962
5110
|
let currentSessionHashSet = false;
|
|
5111
|
+
let lastActualRoutedModel: PersistedRoutedModelRef | undefined;
|
|
4963
5112
|
const PERSIST_DEBOUNCE_MS = 2000;
|
|
4964
5113
|
/** In-memory recent usage samples per model key (not persisted, cleared on reload). */
|
|
4965
5114
|
const recentSamplesByModelKey = new Map<string, CacheUsageSample[]>();
|
|
@@ -4970,6 +5119,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
4970
5119
|
currentSessionId = sid;
|
|
4971
5120
|
currentSessionHash = hashSessionId(sid);
|
|
4972
5121
|
currentSessionHashSet = true;
|
|
5122
|
+
lastActualRoutedModel = undefined;
|
|
4973
5123
|
}
|
|
4974
5124
|
}
|
|
4975
5125
|
|
|
@@ -5019,7 +5169,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
5019
5169
|
}
|
|
5020
5170
|
|
|
5021
5171
|
function getCacheStatsState(): CacheStatsState {
|
|
5022
|
-
return {
|
|
5172
|
+
return {
|
|
5173
|
+
statsByModel: cacheStatsByModel,
|
|
5174
|
+
legacyFamily: cacheStatsLegacyFamily,
|
|
5175
|
+
...(currentSessionHashSet && lastActualRoutedModel
|
|
5176
|
+
? { lastRoutedModelBySession: { [currentSessionHash]: lastActualRoutedModel } }
|
|
5177
|
+
: {}),
|
|
5178
|
+
};
|
|
5023
5179
|
}
|
|
5024
5180
|
|
|
5025
5181
|
/** Look up active stats for a model, falling back to legacy family. */
|
|
@@ -5146,6 +5302,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
5146
5302
|
currentSessionHashSet ? currentSessionHash : undefined,
|
|
5147
5303
|
);
|
|
5148
5304
|
cacheStatsLegacyFamily = persisted?.legacyFamily ?? emptyAllCacheStats();
|
|
5305
|
+
lastActualRoutedModel = currentSessionHashSet
|
|
5306
|
+
? persisted?.lastRoutedModelBySession?.[currentSessionHash]
|
|
5307
|
+
: undefined;
|
|
5149
5308
|
|
|
5150
5309
|
await rollOverStatsIfNeeded(ctx);
|
|
5151
5310
|
return;
|
|
@@ -5160,10 +5319,63 @@ export default function (pi: ExtensionAPI) {
|
|
|
5160
5319
|
currentSessionHashSet ? currentSessionHash : undefined,
|
|
5161
5320
|
);
|
|
5162
5321
|
cacheStatsLegacyFamily = persisted?.legacyFamily ?? emptyAllCacheStats();
|
|
5322
|
+
lastActualRoutedModel = currentSessionHashSet
|
|
5323
|
+
? persisted?.lastRoutedModelBySession?.[currentSessionHash]
|
|
5324
|
+
: undefined;
|
|
5163
5325
|
lastStatusText = undefined;
|
|
5164
5326
|
await rollOverStatsIfNeeded(ctx);
|
|
5165
5327
|
}
|
|
5166
5328
|
|
|
5329
|
+
/**
|
|
5330
|
+
* Fallback for older persisted files that do not yet carry exact
|
|
5331
|
+
* last-routed-model metadata. When the current model is a router channel
|
|
5332
|
+
* (e.g. router/auto), restorable stats are stored under the real upstream
|
|
5333
|
+
* model's provider/id key, not under router/auto. Find the best valid entry
|
|
5334
|
+
* (highest totalRequests among adapter-detectable model keys) so we can show
|
|
5335
|
+
* meaningful footer content on session_start after reload.
|
|
5336
|
+
*/
|
|
5337
|
+
function findBestRouterModelStats(): { adapter: CacheProviderAdapter; stats: CacheStats } | undefined {
|
|
5338
|
+
if (!currentSessionHash) return undefined;
|
|
5339
|
+
const prefix = `${currentSessionHash}:`;
|
|
5340
|
+
let best: { adapter: CacheProviderAdapter; stats: CacheStats; total: number } | undefined;
|
|
5341
|
+
|
|
5342
|
+
for (const [key, stats] of Object.entries(cacheStatsByModel)) {
|
|
5343
|
+
if (!key.startsWith(prefix)) continue;
|
|
5344
|
+
|
|
5345
|
+
// Extract provider/id from key like "abc123:run-claude/claude-opus-4-8"
|
|
5346
|
+
const modelKeyPart = key.slice(prefix.length);
|
|
5347
|
+
const slashIdx = modelKeyPart.indexOf("/");
|
|
5348
|
+
if (slashIdx < 0 || slashIdx >= modelKeyPart.length - 1) continue;
|
|
5349
|
+
const modelId = modelKeyPart.slice(slashIdx + 1);
|
|
5350
|
+
const providerName = modelKeyPart.slice(0, slashIdx);
|
|
5351
|
+
|
|
5352
|
+
// Construct a minimal model for adapter detection.
|
|
5353
|
+
// Every is*LikeModel function only accesses model.id and model.name
|
|
5354
|
+
// via getModelIdNameTokenValues, so { id, name } is sufficient.
|
|
5355
|
+
const mockModel = {
|
|
5356
|
+
id: modelId,
|
|
5357
|
+
name: modelId,
|
|
5358
|
+
provider: providerName,
|
|
5359
|
+
api: "",
|
|
5360
|
+
baseUrl: "",
|
|
5361
|
+
reasoning: false,
|
|
5362
|
+
input: ["text"],
|
|
5363
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
5364
|
+
contextWindow: 0,
|
|
5365
|
+
maxTokens: 0,
|
|
5366
|
+
} as PiModel;
|
|
5367
|
+
|
|
5368
|
+
const adapter = selectAdapterForModel(mockModel);
|
|
5369
|
+
if (!adapter) continue;
|
|
5370
|
+
|
|
5371
|
+
if (!best || stats.totalRequests > best.total) {
|
|
5372
|
+
best = { adapter, stats, total: stats.totalRequests };
|
|
5373
|
+
}
|
|
5374
|
+
}
|
|
5375
|
+
|
|
5376
|
+
return best ? { adapter: best.adapter, stats: best.stats } : undefined;
|
|
5377
|
+
}
|
|
5378
|
+
|
|
5167
5379
|
async function publishStatus(ctx: ExtensionContext, model: PiModel | undefined = ctx.model): Promise<void> {
|
|
5168
5380
|
syncSessionHash(ctx);
|
|
5169
5381
|
await rollOverStatsIfNeeded(ctx);
|
|
@@ -5171,11 +5383,23 @@ export default function (pi: ExtensionAPI) {
|
|
|
5171
5383
|
const adapter = selectAdapterForModel(model);
|
|
5172
5384
|
let statusText: string | undefined;
|
|
5173
5385
|
if (!adapter && isRouterModel(model)) {
|
|
5174
|
-
//
|
|
5175
|
-
//
|
|
5176
|
-
//
|
|
5177
|
-
//
|
|
5178
|
-
|
|
5386
|
+
// On model_select (existing footer), keep the existing cache footer
|
|
5387
|
+
// visible instead of clearing it. On session_start (no footer yet
|
|
5388
|
+
// after reload/fresh start), restore the exact last actual routed model
|
|
5389
|
+
// for this session when available; fall back to older best-effort
|
|
5390
|
+
// heuristics only when no exact metadata exists.
|
|
5391
|
+
if (lastStatusText !== undefined) return;
|
|
5392
|
+
const realEntry = buildExactRouterStatusEntry(
|
|
5393
|
+
currentSessionHashSet ? currentSessionHash : undefined,
|
|
5394
|
+
cacheStatsByModel,
|
|
5395
|
+
lastActualRoutedModel,
|
|
5396
|
+
) ?? findBestRouterModelStats();
|
|
5397
|
+
if (realEntry) {
|
|
5398
|
+
const statsText = formatCacheStats(realEntry.adapter, realEntry.stats);
|
|
5399
|
+
statusText = runtimeOptimizerEnabled
|
|
5400
|
+
? statsText
|
|
5401
|
+
: `Cache Optimizer disabled · ${statsText}`;
|
|
5402
|
+
}
|
|
5179
5403
|
}
|
|
5180
5404
|
|
|
5181
5405
|
if (adapter) {
|
|
@@ -5220,8 +5444,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
5220
5444
|
// changes and day rollovers. Redundant setStatus calls are blocked by the
|
|
5221
5445
|
// `lastStatusText` early return above.
|
|
5222
5446
|
if (runtimeOptimizerEnabled && statusText !== undefined && model) {
|
|
5223
|
-
|
|
5224
|
-
|
|
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) {
|
|
5225
5452
|
statusText = statusText + " ⚠️ compat";
|
|
5226
5453
|
}
|
|
5227
5454
|
}
|
|
@@ -5360,6 +5587,23 @@ export default function (pi: ExtensionAPI) {
|
|
|
5360
5587
|
const usage = adapter.normalizeUsage(event.message);
|
|
5361
5588
|
|
|
5362
5589
|
const statsModel = isRouterModel(ctx.model) ? modelFromAssistantMessage(event.message, ctx.model) : ctx.model;
|
|
5590
|
+
let routedModelChanged = false;
|
|
5591
|
+
if (isRouterModel(ctx.model) && statsModel && !isRouterModel(statsModel)) {
|
|
5592
|
+
const nextRoutedModel: PersistedRoutedModelRef = {
|
|
5593
|
+
provider: statsModel.provider,
|
|
5594
|
+
id: statsModel.id,
|
|
5595
|
+
name: statsModel.name || statsModel.id,
|
|
5596
|
+
};
|
|
5597
|
+
if (
|
|
5598
|
+
!lastActualRoutedModel ||
|
|
5599
|
+
lastActualRoutedModel.provider !== nextRoutedModel.provider ||
|
|
5600
|
+
lastActualRoutedModel.id !== nextRoutedModel.id ||
|
|
5601
|
+
(lastActualRoutedModel.name || lastActualRoutedModel.id) !== (nextRoutedModel.name || nextRoutedModel.id)
|
|
5602
|
+
) {
|
|
5603
|
+
lastActualRoutedModel = nextRoutedModel;
|
|
5604
|
+
routedModelChanged = true;
|
|
5605
|
+
}
|
|
5606
|
+
}
|
|
5363
5607
|
|
|
5364
5608
|
// Record recent sample (even when usage is missing, for trend diagnosis)
|
|
5365
5609
|
if (statsModel) {
|
|
@@ -5370,7 +5614,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
5370
5614
|
recordRecentSample(sk, usage ?? { cacheRead: 0, cacheWrite: 0, totalInput: 0 }, missingFields);
|
|
5371
5615
|
}
|
|
5372
5616
|
|
|
5373
|
-
if (!usage)
|
|
5617
|
+
if (!usage) {
|
|
5618
|
+
if (routedModelChanged) schedulePersistCacheStats(ctx);
|
|
5619
|
+
return;
|
|
5620
|
+
}
|
|
5374
5621
|
|
|
5375
5622
|
await rollOverStatsIfNeeded(ctx);
|
|
5376
5623
|
|
|
@@ -5555,7 +5802,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
5555
5802
|
|
|
5556
5803
|
// Compose the modified text — auto-detect the best placement level:
|
|
5557
5804
|
// provider level (channel-wide) when safe for all sibling models, else model level.
|
|
5558
|
-
const decision =
|
|
5805
|
+
const decision = chooseFixPlacement(originalText, location, suggestion.compatKeys, suggestion.providerLabel);
|
|
5559
5806
|
const modifiedText = composeFixInsertion(originalText, location, suggestion.compatKeys, decision.placement);
|
|
5560
5807
|
|
|
5561
5808
|
// Self-check
|
|
@@ -5569,10 +5816,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
5569
5816
|
return;
|
|
5570
5817
|
}
|
|
5571
5818
|
|
|
5572
|
-
// Build preview snippet
|
|
5573
|
-
|
|
5574
|
-
|
|
5575
|
-
.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);
|
|
5576
5822
|
const targetHasCompat = decision.placement === "provider" ? location.providerCompatBrace >= 0 : location.compatObjectBrace >= 0;
|
|
5577
5823
|
const placementDesc = targetHasCompat ? `existing "compat" object` : `new "compat" object`;
|
|
5578
5824
|
const locationDesc = decision.placement === "provider"
|
|
@@ -5591,7 +5837,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
5591
5837
|
``,
|
|
5592
5838
|
`Location: ${locationDesc}`,
|
|
5593
5839
|
`Placement: ${decision.placement} level — ${decision.reason}`,
|
|
5594
|
-
`
|
|
5840
|
+
`Compat JSON to write:`,
|
|
5595
5841
|
keysPreview,
|
|
5596
5842
|
``,
|
|
5597
5843
|
`⚠️ Risk notice:`,
|
|
@@ -5748,7 +5994,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
5748
5994
|
return;
|
|
5749
5995
|
}
|
|
5750
5996
|
|
|
5751
|
-
const menuDecision =
|
|
5997
|
+
const menuDecision = chooseFixPlacement(originalText, location, suggestion.compatKeys, suggestion.providerLabel);
|
|
5752
5998
|
const modifiedText = composeFixInsertion(originalText, location, suggestion.compatKeys, menuDecision.placement);
|
|
5753
5999
|
const checkError = selfCheckFix(originalText, modifiedText, suggestion.providerLabel, suggestion.modelId, suggestion.compatKeys);
|
|
5754
6000
|
if (checkError !== null) {
|
|
@@ -5756,9 +6002,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
5756
6002
|
return;
|
|
5757
6003
|
}
|
|
5758
6004
|
|
|
5759
|
-
const keysPreview =
|
|
5760
|
-
.map(([k, v]) => ` ${k}: ${JSON.stringify(v)}`)
|
|
5761
|
-
.join("\n");
|
|
6005
|
+
const keysPreview = JSON.stringify(suggestion.compatKeys, null, 2);
|
|
5762
6006
|
const ts = backupTimestamp();
|
|
5763
6007
|
const backupPath = `${MODELS_JSON_PATH}.backup-cache-optimizer-${ts}`;
|
|
5764
6008
|
|
|
@@ -5773,7 +6017,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
5773
6017
|
`📝 Preview of changes to ${getModelsJsonDisplayPath()}:`,
|
|
5774
6018
|
`Location: ${menuLocationDesc}`,
|
|
5775
6019
|
`Placement: ${menuDecision.placement} level — ${menuDecision.reason}`,
|
|
5776
|
-
`
|
|
6020
|
+
`Compat JSON to write:`,
|
|
5777
6021
|
keysPreview,
|
|
5778
6022
|
``,
|
|
5779
6023
|
`⚠️ Risk notice:`,
|