pi-cache-optimizer 2.6.23 → 2.6.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.ts CHANGED
@@ -104,11 +104,37 @@ const NO_PROMPT_REWRITE_ENV = "PI_CACHE_OPTIMIZER_NO_PROMPT_REWRITE";
104
104
  const PI_ROUTING_REGISTRY_SYMBOL = Symbol.for("pi.routing.registry.v1");
105
105
  const PI_CACHE_HINTS_SYMBOL = Symbol.for("pi.cache.hints.v1");
106
106
  const PI_CACHE_HINTS_OWNER_SYMBOL = Symbol.for("pi.cache.optimizer.hints-owner.v1");
107
+ const ANTHROPIC_TTL_FALLBACK_SYMBOL = Symbol.for("pi.cache.optimizer.anthropic-ttl-fallback.v1");
108
+
109
+ type AnthropicTtlFallbackStateV1 = {
110
+ version: 1;
111
+ modelKeys: Set<string>;
112
+ warnedModelKeys: Set<string>;
113
+ };
114
+
115
+ function getAnthropicTtlFallbackState(): AnthropicTtlFallbackStateV1 {
116
+ const globals = globalThis as Record<symbol, unknown>;
117
+ const existing = globals[ANTHROPIC_TTL_FALLBACK_SYMBOL] as Partial<AnthropicTtlFallbackStateV1> | undefined;
118
+ if (
119
+ existing?.version === 1 &&
120
+ existing.modelKeys instanceof Set &&
121
+ existing.warnedModelKeys instanceof Set
122
+ ) {
123
+ return existing as AnthropicTtlFallbackStateV1;
124
+ }
125
+ const state: AnthropicTtlFallbackStateV1 = {
126
+ version: 1,
127
+ modelKeys: new Set<string>(),
128
+ warnedModelKeys: new Set<string>(),
129
+ };
130
+ globals[ANTHROPIC_TTL_FALLBACK_SYMBOL] = state;
131
+ return state;
132
+ }
107
133
 
108
134
  let runtimeOptimizerEnabled = true;
109
135
 
110
- // WORM-flag: if optimizeSystemPrompt ever detects that its blind-replace
111
- // logic has accidentally truncated a structural marker (any XML tag or
136
+ // WORM-flag: if optimizeSystemPrompt ever detects that candidate extraction
137
+ // has accidentally truncated a structural marker (any XML tag or
112
138
  // HTML comment boundary marker present in the original prompt), we flip
113
139
  // this. publishStatus reads it once, appends a footer warning, then
114
140
  // resets it. The flag surface is kept separate from the regular
@@ -135,12 +161,10 @@ const SKILL_COMPRESSION_MIN_COUNT = 4;
135
161
 
136
162
  // Minimum trimmed length for a candidate to qualify as a stable-prefix "part".
137
163
  //
138
- // `optimizeSystemPrompt` removes each accepted candidate from the dynamic
139
- // remainder via `rest.replace(part, "")`. Short or character-class candidates
140
- // (think: `S`, `- u`, `- (`, `- }`) match the FIRST occurrence of those bytes
141
- // anywhere in `rest`, ripping unrelated text out of the prompt and yielding a
142
- // non-deterministic dynamic remainder per request. Both behaviors poison the
143
- // provider's prompt-prefix cache.
164
+ // `optimizeSystemPrompt` removes each uniquely occurring accepted candidate
165
+ // from the dynamic remainder. Short or character-class candidates (think: `S`,
166
+ // `- u`, `- (`, `- }`) are too likely to match unrelated prompt text, so they
167
+ // are never eligible for lifting.
144
168
  //
145
169
  // The threshold also caps the upstream string-vs-array regression we saw with
146
170
  // trellis 0.5.16 / 0.6.0-beta.17 (subagent tool registration passing
@@ -618,7 +642,7 @@ function stripSessionOverviewChurn(prompt: string): string {
618
642
  * Extract structural markers from a prompt for the integrity guard.
619
643
  *
620
644
  * The guard runs in `optimizeSystemPrompt` to catch cases where the
621
- * blind `rest.replace(part, "")` reorder accidentally eats text inside
645
+ * candidate extraction accidentally eats text inside
622
646
  * an extension-injected structural block (e.g., trellis
623
647
  * `<workflow-state>`, a hypothetical `<task-tracker>`, or AGENTS.md
624
648
  * `<!-- TRELLIS:START -->` markers). When the original prompt contains
@@ -678,16 +702,43 @@ function optimizeSystemPrompt(
678
702
  const seen = new Set<string>();
679
703
  let rest = original;
680
704
 
681
- // Stable layer: content likely to be identical across sessions/turns.
682
- // Short / single-char candidates are dropped: see MIN_STABLE_CANDIDATE_LENGTH.
705
+ // Classify candidate ambiguity against one immutable snapshot. Candidates
706
+ // can be nested (a full context-file block also contains its bare content),
707
+ // so recomputing occurrence counts after each removal is unsafe: deleting
708
+ // the full block can make the dynamic copy of its bare content appear unique.
709
+ const candidates: string[] = [];
683
710
  for (const candidate of buildStableCandidates(opts)) {
684
711
  const part = candidate.trim();
685
- if (!part || part.length < MIN_STABLE_CANDIDATE_LENGTH) continue;
686
- if (seen.has(part) || !rest.includes(part)) continue;
712
+ if (!part || part.length < MIN_STABLE_CANDIDATE_LENGTH || seen.has(part)) continue;
713
+ seen.add(part);
714
+ candidates.push(part);
715
+ }
716
+
717
+ const initialRemainder = rest;
718
+ const occurrenceCount = new Map<string, number>();
719
+ for (const part of candidates) {
720
+ let count = 0;
721
+ let searchFrom = 0;
722
+ while (searchFrom < initialRemainder.length) {
723
+ const occurrence = initialRemainder.indexOf(part, searchFrom);
724
+ if (occurrence < 0) break;
725
+ count++;
726
+ if (count > 1) break;
727
+ searchFrom = occurrence + 1;
728
+ }
729
+ occurrenceCount.set(part, count);
730
+ }
731
+
732
+ // Stable layer: content likely to be identical across sessions/turns.
733
+ // Short / single-char candidates are dropped: see MIN_STABLE_CANDIDATE_LENGTH.
734
+ for (const part of candidates) {
735
+ if (occurrenceCount.get(part) !== 1) continue;
736
+
737
+ const firstOccurrence = rest.indexOf(part);
738
+ if (firstOccurrence < 0) continue;
687
739
 
688
740
  stableParts.push(part);
689
- seen.add(part);
690
- rest = rest.replace(part, "");
741
+ rest = rest.slice(0, firstOccurrence) + rest.slice(firstOccurrence + part.length);
691
742
  }
692
743
 
693
744
  const stablePrefix = stableParts.join("\n\n");
@@ -705,8 +756,8 @@ function optimizeSystemPrompt(
705
756
 
706
757
  // Sanity check: scan ALL structural markers (XML tags + HTML comment
707
758
  // boundary markers) in the original and verify each one survives the
708
- // reorder. If any marker drops, the blind `rest.replace(part, "")`
709
- // logic ate something it shouldn't have — fall back to the original
759
+ // reorder. If any marker drops, candidate extraction ate something it
760
+ // shouldn't have — fall back to the original
710
761
  // prompt and flag the footer warning. This is provider-agnostic and
711
762
  // extension-agnostic: trellis `<workflow-state>`, a hypothetical
712
763
  // `<task-tracker>`, AGENTS.md `<!-- TRELLIS:START -->`, etc., are all
@@ -1234,6 +1285,28 @@ function collectAnthropicCacheControlsInWireOrder(payload: unknown): UnknownReco
1234
1285
  * If the final serialized payload contains a short → long transition, downgrade
1235
1286
  * all 1h breakpoints to the default 5m so the request remains valid.
1236
1287
  */
1288
+ function downgradeAnthropicLongCacheControls(payload: unknown): boolean {
1289
+ let changed = false;
1290
+ for (const control of collectAnthropicCacheControlsInWireOrder(payload)) {
1291
+ if (control.ttl === "1h") {
1292
+ delete control.ttl;
1293
+ changed = true;
1294
+ }
1295
+ }
1296
+ return changed;
1297
+ }
1298
+
1299
+ function hasAnthropicCacheTtlOrderError(message: unknown): boolean {
1300
+ const record = getAssistantRecord(message);
1301
+ if (record?.stopReason !== "error" || typeof record.errorMessage !== "string") return false;
1302
+
1303
+ const error = lower(record.errorMessage);
1304
+ return error.includes("cache_control") &&
1305
+ error.includes("ttl='1h'") &&
1306
+ error.includes("ttl='5m'") &&
1307
+ error.includes("must not come after");
1308
+ }
1309
+
1237
1310
  function normalizeAnthropicCacheControlTtlOrder(payload: unknown): boolean {
1238
1311
  const controls = collectAnthropicCacheControlsInWireOrder(payload);
1239
1312
  let seenShort = false;
@@ -1250,10 +1323,7 @@ function normalizeAnthropicCacheControlTtlOrder(payload: unknown): boolean {
1250
1323
  }
1251
1324
  if (!hasInvalidLongAfterShort) return false;
1252
1325
 
1253
- for (const control of controls) {
1254
- if (control.ttl === "1h") delete control.ttl;
1255
- }
1256
- return true;
1326
+ return downgradeAnthropicLongCacheControls(payload);
1257
1327
  }
1258
1328
 
1259
1329
  function isResponsesPromptRewriteBypassApi(api: unknown): boolean {
@@ -4374,7 +4444,7 @@ function getCompatCheckNotApplicableLines(model: PiModel): string[] {
4374
4444
  return ["ℹ️ Compat check not applicable for this model."];
4375
4445
  }
4376
4446
 
4377
- function buildDoctorDiagnosis(model: PiModel, options: { promptCacheRetention400?: boolean; sessionAffinity403?: boolean; openAISdkHeader403?: boolean } = {}): string {
4447
+ function buildDoctorDiagnosis(model: PiModel, options: { promptCacheRetention400?: boolean; anthropicTtlOrderError?: boolean; sessionAffinity403?: boolean; openAISdkHeader403?: boolean } = {}): string {
4378
4448
  const lines: string[] = [];
4379
4449
  lines.push(`Provider: ${model.provider}`);
4380
4450
  lines.push(`Model: ${model.id}`);
@@ -4433,6 +4503,13 @@ function buildDoctorDiagnosis(model: PiModel, options: { promptCacheRetention400
4433
4503
  }
4434
4504
  }
4435
4505
 
4506
+ if (options.anthropicTtlOrderError) {
4507
+ lines.push("");
4508
+ lines.push("⚠️ An Anthropic cache-control TTL ordering error was observed for this model.");
4509
+ lines.push(" Runtime requests now fall back from 1h to the default 5-minute cache TTL.");
4510
+ lines.push(` Run /cache-optimizer fix to set model-level supportsLongCacheRetention: false in ${getModelsJsonDisplayPath()}.`);
4511
+ }
4512
+
4436
4513
  // ── Session affinity 403 diagnostics ──
4437
4514
  // Advises the user when sendSessionAffinityHeaders is enabled, since some
4438
4515
  // third-party proxies / CDNs / WAFs block Pi's custom session-affinity HTTP
@@ -4858,6 +4935,8 @@ interface FixSuggestion {
4858
4935
  providerLabel: string;
4859
4936
  modelId: string;
4860
4937
  compatKeys: Record<string, unknown>;
4938
+ /** Runtime-observed failures must never broaden a model-specific fix. */
4939
+ forceModelLevel?: boolean;
4861
4940
  }
4862
4941
 
4863
4942
  /**
@@ -4986,6 +5065,63 @@ function parseJsonc(text: string): unknown {
4986
5065
  return JSON.parse(stripJsoncTrailingCommas(stripJsoncComments(text)));
4987
5066
  }
4988
5067
 
5068
+ type ExplicitCompatSource = "modelOverride" | "model" | "provider";
5069
+
5070
+ interface ExplicitCompatValue {
5071
+ source: ExplicitCompatSource;
5072
+ value: unknown;
5073
+ }
5074
+
5075
+ /**
5076
+ * Resolve one explicitly configured compat value using Pi's models.json
5077
+ * precedence. Built-in model overrides win over custom model definitions,
5078
+ * which in turn win over provider defaults.
5079
+ */
5080
+ function resolveExplicitCompatValue(
5081
+ config: unknown,
5082
+ providerLabel: string,
5083
+ modelId: string,
5084
+ compatKey: string,
5085
+ ): ExplicitCompatValue | undefined {
5086
+ const providers = asRecord(asRecord(config)?.providers);
5087
+ const provider = asRecord(providers?.[providerLabel]);
5088
+ if (!provider) return undefined;
5089
+
5090
+ const override = asRecord(asRecord(provider.modelOverrides)?.[modelId]);
5091
+ const overrideCompat = asRecord(override?.compat);
5092
+ if (overrideCompat && Object.prototype.hasOwnProperty.call(overrideCompat, compatKey)) {
5093
+ return { source: "modelOverride", value: overrideCompat[compatKey] };
5094
+ }
5095
+
5096
+ if (Array.isArray(provider.models)) {
5097
+ const model = provider.models.find((entry: unknown) => asRecord(entry)?.id === modelId);
5098
+ const modelCompat = asRecord(asRecord(model)?.compat);
5099
+ if (modelCompat && Object.prototype.hasOwnProperty.call(modelCompat, compatKey)) {
5100
+ return { source: "model", value: modelCompat[compatKey] };
5101
+ }
5102
+ }
5103
+
5104
+ const providerCompat = asRecord(provider.compat);
5105
+ if (providerCompat && Object.prototype.hasOwnProperty.call(providerCompat, compatKey)) {
5106
+ return { source: "provider", value: providerCompat[compatKey] };
5107
+ }
5108
+
5109
+ return undefined;
5110
+ }
5111
+
5112
+ function hasExplicitLongRetentionOptInFromConfig(
5113
+ config: unknown,
5114
+ providerLabel: string,
5115
+ modelId: string,
5116
+ ): boolean {
5117
+ return resolveExplicitCompatValue(
5118
+ config,
5119
+ providerLabel,
5120
+ modelId,
5121
+ "supportsLongCacheRetention",
5122
+ )?.value === true;
5123
+ }
5124
+
4989
5125
  /**
4990
5126
  * JSONC scanner: locate the provider block and model entry in models.json text.
4991
5127
  * Returns the byte offsets for surgical insertion, or undefined if ambiguous.
@@ -5011,10 +5147,99 @@ interface ModelNodeLocation {
5011
5147
  providerCompatBrace: number;
5012
5148
  /** Offset of the provider-level compat object's closing `}`, or -1 if absent */
5013
5149
  providerCompatEnd: number;
5150
+ /** Offset of the target modelOverrides entry's opening `{`, or -1 if absent */
5151
+ modelOverrideObjectBrace: number;
5152
+ /** Offset of the target modelOverrides entry's closing `}`, or -1 if absent */
5153
+ modelOverrideObjectEnd: number;
5154
+ /** Offset of the target override compat object's opening `{`, or -1 if absent */
5155
+ modelOverrideCompatBrace: number;
5156
+ /** Offset of the target override compat object's closing `}`, or -1 if absent */
5157
+ modelOverrideCompatEnd: number;
5014
5158
  /** All model ids found in this provider's models array (for placement safety analysis) */
5015
5159
  allModelIds: string[];
5016
5160
  }
5017
5161
 
5162
+ interface ModelOverrideNodeLocation {
5163
+ providerObjectBrace: number;
5164
+ providerObjectEnd: number;
5165
+ modelOverridesObjectBrace: number;
5166
+ modelOverridesObjectEnd: number;
5167
+ modelOverrideObjectBrace: number;
5168
+ modelOverrideObjectEnd: number;
5169
+ modelOverrideCompatBrace: number;
5170
+ modelOverrideCompatEnd: number;
5171
+ }
5172
+
5173
+ /** Locate a provider and optional modelOverrides entry without requiring models[]. */
5174
+ function locateModelOverrideInJsonc(
5175
+ text: string,
5176
+ providerLabel: string,
5177
+ modelId: string,
5178
+ ): ModelOverrideNodeLocation | undefined {
5179
+ const clean = stripJsoncComments(text);
5180
+ const rootBrace = skipJsonWhitespace(clean, 0);
5181
+ if (clean[rootBrace] !== "{") return undefined;
5182
+ const providersKey = findJsonObjectKey(clean, rootBrace, "providers");
5183
+ if (!providersKey) return undefined;
5184
+ const providersBrace = skipJsonWhitespace(clean, providersKey.valueStart);
5185
+ if (clean[providersBrace] !== "{") return undefined;
5186
+ const providersEnd = findMatchingBracket(clean, providersBrace);
5187
+ if (providersEnd === undefined) return undefined;
5188
+ const providerKey = findJsonObjectKey(clean, providersBrace, providerLabel);
5189
+ if (!providerKey || providerKey.keyStart > providersEnd) return undefined;
5190
+ const providerObjectBrace = skipJsonWhitespace(clean, providerKey.valueStart);
5191
+ if (clean[providerObjectBrace] !== "{") return undefined;
5192
+ const providerObjectEnd = findMatchingBracket(clean, providerObjectBrace);
5193
+ if (providerObjectEnd === undefined || providerObjectEnd > providersEnd) return undefined;
5194
+
5195
+ let modelOverridesObjectBrace = -1;
5196
+ let modelOverridesObjectEnd = -1;
5197
+ let modelOverrideObjectBrace = -1;
5198
+ let modelOverrideObjectEnd = -1;
5199
+ let modelOverrideCompatBrace = -1;
5200
+ let modelOverrideCompatEnd = -1;
5201
+ const overridesKey = findJsonObjectKey(clean, providerObjectBrace, "modelOverrides");
5202
+ if (overridesKey && overridesKey.keyStart < providerObjectEnd) {
5203
+ const brace = skipJsonWhitespace(clean, overridesKey.valueStart);
5204
+ if (clean[brace] !== "{") return undefined;
5205
+ const end = findMatchingBracket(clean, brace);
5206
+ if (end === undefined || end > providerObjectEnd) return undefined;
5207
+ modelOverridesObjectBrace = brace;
5208
+ modelOverridesObjectEnd = end;
5209
+
5210
+ const overrideKey = findJsonObjectKey(clean, brace, modelId);
5211
+ if (overrideKey && overrideKey.keyStart < end) {
5212
+ const entryBrace = skipJsonWhitespace(clean, overrideKey.valueStart);
5213
+ if (clean[entryBrace] !== "{") return undefined;
5214
+ const entryEnd = findMatchingBracket(clean, entryBrace);
5215
+ if (entryEnd === undefined || entryEnd > end) return undefined;
5216
+ modelOverrideObjectBrace = entryBrace;
5217
+ modelOverrideObjectEnd = entryEnd;
5218
+
5219
+ const compatKey = findJsonObjectKey(clean, entryBrace, "compat");
5220
+ if (compatKey && compatKey.keyStart < entryEnd) {
5221
+ const compatBrace = skipJsonWhitespace(clean, compatKey.valueStart);
5222
+ if (clean[compatBrace] !== "{") return undefined;
5223
+ const compatEnd = findMatchingBracket(clean, compatBrace);
5224
+ if (compatEnd === undefined || compatEnd > entryEnd) return undefined;
5225
+ modelOverrideCompatBrace = compatBrace;
5226
+ modelOverrideCompatEnd = compatEnd;
5227
+ }
5228
+ }
5229
+ }
5230
+
5231
+ return {
5232
+ providerObjectBrace,
5233
+ providerObjectEnd,
5234
+ modelOverridesObjectBrace,
5235
+ modelOverridesObjectEnd,
5236
+ modelOverrideObjectBrace,
5237
+ modelOverrideObjectEnd,
5238
+ modelOverrideCompatBrace,
5239
+ modelOverrideCompatEnd,
5240
+ };
5241
+ }
5242
+
5018
5243
  /**
5019
5244
  * Locate the provider + model entry in raw JSONC text.
5020
5245
  * Returns the positions needed for surgical insertion, or undefined on failure.
@@ -5067,6 +5292,12 @@ function locateModelInJsonc(
5067
5292
  }
5068
5293
  }
5069
5294
 
5295
+ const overrideLocation = locateModelOverrideInJsonc(text, providerLabel, modelId);
5296
+ const modelOverrideObjectBrace = overrideLocation?.modelOverrideObjectBrace ?? -1;
5297
+ const modelOverrideObjectEnd = overrideLocation?.modelOverrideObjectEnd ?? -1;
5298
+ const modelOverrideCompatBrace = overrideLocation?.modelOverrideCompatBrace ?? -1;
5299
+ const modelOverrideCompatEnd = overrideLocation?.modelOverrideCompatEnd ?? -1;
5300
+
5070
5301
  const modelsKey = findJsonObjectKey(clean, providerBrace, "models");
5071
5302
  if (!modelsKey || modelsKey.keyStart > providerEndBrace) return undefined;
5072
5303
 
@@ -5154,6 +5385,10 @@ function locateModelInJsonc(
5154
5385
  providerObjectEnd: providerEndBrace,
5155
5386
  providerCompatBrace,
5156
5387
  providerCompatEnd,
5388
+ modelOverrideObjectBrace,
5389
+ modelOverrideObjectEnd,
5390
+ modelOverrideCompatBrace,
5391
+ modelOverrideCompatEnd,
5157
5392
  allModelIds,
5158
5393
  };
5159
5394
  }
@@ -5163,7 +5398,7 @@ function locateModelInJsonc(
5163
5398
  * `locateModelInJsonc` cannot find the target provider/model.
5164
5399
  */
5165
5400
  type MissingEntryDiagnosis =
5166
- | { scenario: "provider_missing"; providersEnd: number }
5401
+ | { scenario: "provider_missing"; providersBrace: number; providersEnd: number }
5167
5402
  | { scenario: "model_missing"; modelsEnd: number; providerBrace: number; providerEndBrace: number }
5168
5403
  | { scenario: "provider_without_models"; providerBrace: number; providerEndBrace: number };
5169
5404
 
@@ -5194,7 +5429,7 @@ function analyzeModelsJsonForMissingEntry(
5194
5429
 
5195
5430
  const providerKey = findJsonObjectKey(clean, providersBrace, providerLabel);
5196
5431
  if (!providerKey || providerKey.keyStart > providersEnd) {
5197
- return { scenario: "provider_missing", providersEnd };
5432
+ return { scenario: "provider_missing", providersBrace, providersEnd };
5198
5433
  }
5199
5434
 
5200
5435
  // Provider exists. Check for a models array so we know where to append.
@@ -5232,20 +5467,126 @@ function formatMissingEntryManualSnippet(
5232
5467
  ): string {
5233
5468
  const lines: string[] = [];
5234
5469
  const sorted = Object.entries(compatKeys).sort(([a], [b]) => a.localeCompare(b));
5235
- const compatItems = sorted.map(([k, v]) => ` ${JSON.stringify(k)}: ${JSON.stringify(v)}`);
5470
+ const compatItems = sorted.map(([k, v]) => ` ${JSON.stringify(k)}: ${JSON.stringify(v)}`);
5236
5471
  lines.push(`"${providerLabel}": {`);
5237
- lines.push(` "models": [`);
5238
- lines.push(` {`);
5239
- lines.push(` "id": ${JSON.stringify(modelId)},`);
5472
+ lines.push(` "modelOverrides": {`);
5473
+ lines.push(` ${JSON.stringify(modelId)}: {`);
5240
5474
  lines.push(` "compat": {`);
5241
5475
  lines.push(compatItems.join(",\n"));
5242
5476
  lines.push(` }`);
5243
5477
  lines.push(` }`);
5244
- lines.push(` ]`);
5478
+ lines.push(` }`);
5245
5479
  lines.push(` }`);
5246
5480
  return lines.join("\n");
5247
5481
  }
5248
5482
 
5483
+ /**
5484
+ * Insert or repair a modelOverrides entry without creating a custom models[]
5485
+ * definition. This is the safest representation for built-in/API-login models
5486
+ * and has the same highest precedence Pi applies at runtime.
5487
+ */
5488
+ function composeModelOverrideInsertion(
5489
+ originalText: string,
5490
+ providerLabel: string,
5491
+ modelId: string,
5492
+ compatKeys: Record<string, unknown>,
5493
+ ): { modifiedText: string; placementLabel: string } | undefined {
5494
+ const location = locateModelOverrideInJsonc(originalText, providerLabel, modelId);
5495
+
5496
+ if (location?.modelOverrideObjectBrace !== undefined && location.modelOverrideObjectBrace >= 0) {
5497
+ const modelLocation: ModelNodeLocation = {
5498
+ modelObjectBrace: -1,
5499
+ modelObjectEnd: -1,
5500
+ compatKeyStart: -1,
5501
+ compatObjectBrace: -1,
5502
+ compatObjectEnd: -1,
5503
+ indent: "",
5504
+ providerObjectBrace: location.providerObjectBrace,
5505
+ providerObjectEnd: location.providerObjectEnd,
5506
+ providerCompatBrace: -1,
5507
+ providerCompatEnd: -1,
5508
+ modelOverrideObjectBrace: location.modelOverrideObjectBrace,
5509
+ modelOverrideObjectEnd: location.modelOverrideObjectEnd,
5510
+ modelOverrideCompatBrace: location.modelOverrideCompatBrace,
5511
+ modelOverrideCompatEnd: location.modelOverrideCompatEnd,
5512
+ allModelIds: [],
5513
+ };
5514
+ return {
5515
+ modifiedText: composeFixInsertion(originalText, modelLocation, compatKeys, "modelOverride"),
5516
+ placementLabel: `providers["${providerLabel}"] -> modelOverrides["${modelId}"] -> compat`,
5517
+ };
5518
+ }
5519
+
5520
+ const sortedEntries = Object.entries(compatKeys).sort(([a], [b]) => a.localeCompare(b));
5521
+ const formatOverrideEntry = (keyIndent: string, unit: string): string => {
5522
+ const propertyIndent = keyIndent + unit;
5523
+ const compatIndent = propertyIndent + unit;
5524
+ const compatLines = sortedEntries
5525
+ .map(([key, value]) => `${compatIndent}${JSON.stringify(key)}: ${JSON.stringify(value)}`)
5526
+ .join(",\n");
5527
+ return `${keyIndent}${JSON.stringify(modelId)}: {\n` +
5528
+ `${propertyIndent}"compat": {\n${compatLines}\n${propertyIndent}}\n${keyIndent}}`;
5529
+ };
5530
+ const previousTokenNeedsComma = (clean: string, closeBrace: number): boolean => {
5531
+ let pos = closeBrace - 1;
5532
+ while (pos >= 0 && isJsonWhitespace(clean[pos])) pos--;
5533
+ return clean[pos] !== "{" && clean[pos] !== ",";
5534
+ };
5535
+
5536
+ if (location) {
5537
+ const clean = stripJsoncComments(originalText);
5538
+ if (location.modelOverridesObjectBrace >= 0) {
5539
+ const containerIndent = lineIndentOf(originalText, location.modelOverridesObjectBrace);
5540
+ const keyIndent = deriveInnerIndent(
5541
+ originalText,
5542
+ location.modelOverridesObjectBrace,
5543
+ location.modelOverridesObjectEnd,
5544
+ );
5545
+ const unit = keyIndent.length > containerIndent.length
5546
+ ? keyIndent.slice(containerIndent.length)
5547
+ : " ";
5548
+ const comma = previousTokenNeedsComma(clean, location.modelOverridesObjectEnd) ? "," : "";
5549
+ const insertion = `${comma}\n${formatOverrideEntry(keyIndent, unit)}\n${containerIndent}`;
5550
+ return {
5551
+ modifiedText: originalText.slice(0, location.modelOverridesObjectEnd) + insertion + originalText.slice(location.modelOverridesObjectEnd),
5552
+ placementLabel: `providers["${providerLabel}"] -> modelOverrides -> (new entry "${modelId}")`,
5553
+ };
5554
+ }
5555
+
5556
+ const providerIndent = lineIndentOf(originalText, location.providerObjectBrace);
5557
+ const propertyIndent = deriveInnerIndent(originalText, location.providerObjectBrace, location.providerObjectEnd);
5558
+ const unit = propertyIndent.length > providerIndent.length
5559
+ ? propertyIndent.slice(providerIndent.length)
5560
+ : " ";
5561
+ const entryIndent = propertyIndent + unit;
5562
+ const block = `\n${propertyIndent}"modelOverrides": {\n` +
5563
+ `${formatOverrideEntry(entryIndent, unit)}\n${propertyIndent}},`;
5564
+ return {
5565
+ modifiedText: originalText.slice(0, location.providerObjectBrace + 1) + block + originalText.slice(location.providerObjectBrace + 1),
5566
+ placementLabel: `providers["${providerLabel}"] -> (new modelOverrides entry "${modelId}")`,
5567
+ };
5568
+ }
5569
+
5570
+ const diagnosis = analyzeModelsJsonForMissingEntry(originalText, providerLabel, modelId);
5571
+ if (!diagnosis || diagnosis.scenario !== "provider_missing") return undefined;
5572
+ const clean = stripJsoncComments(originalText);
5573
+ const providersIndent = lineIndentOf(originalText, diagnosis.providersEnd);
5574
+ const providerIndent = deriveInnerIndent(originalText, diagnosis.providersBrace, diagnosis.providersEnd);
5575
+ const unit = providerIndent.length > providersIndent.length
5576
+ ? providerIndent.slice(providersIndent.length)
5577
+ : " ";
5578
+ const overridesIndent = providerIndent + unit;
5579
+ const entryIndent = overridesIndent + unit;
5580
+ const comma = previousTokenNeedsComma(clean, diagnosis.providersEnd) ? "," : "";
5581
+ const block = `${comma}\n${providerIndent}${JSON.stringify(providerLabel)}: {\n` +
5582
+ `${overridesIndent}"modelOverrides": {\n${formatOverrideEntry(entryIndent, unit)}\n` +
5583
+ `${overridesIndent}}\n${providerIndent}}\n${providersIndent}`;
5584
+ return {
5585
+ modifiedText: originalText.slice(0, diagnosis.providersEnd) + block + originalText.slice(diagnosis.providersEnd),
5586
+ placementLabel: `providers -> (new modelOverrides-only entry "${providerLabel}/${modelId}")`,
5587
+ };
5588
+ }
5589
+
5249
5590
  /**
5250
5591
  * Surgically insert the missing provider/model entry into the original
5251
5592
  * JSONC text. Returns the modified text and placement descriptor.
@@ -5404,10 +5745,10 @@ function composeMissingEntryInsertion(
5404
5745
  }
5405
5746
 
5406
5747
  /**
5407
- * Lightweight self-check for a newly inserted entry.
5748
+ * Lightweight self-check for a newly inserted model or modelOverrides entry.
5408
5749
  * Parses the modified text as JSONC and confirms:
5409
- * 1. The target model exists under the provider.
5410
- * 2. Every compat key has the expected value (merged provider+model).
5750
+ * 1. The target exists under models[] or modelOverrides.
5751
+ * 2. Every compat key has the expected effective three-layer value.
5411
5752
  * Returns null on success, an error string on failure.
5412
5753
  */
5413
5754
  function selfCheckMissingEntryInsertion(
@@ -5418,34 +5759,88 @@ function selfCheckMissingEntryInsertion(
5418
5759
  compatKeys: Record<string, unknown>,
5419
5760
  ): string | null {
5420
5761
  try {
5762
+ const origParsed = parseJsonc(originalText);
5421
5763
  const modParsed = parseJsonc(modifiedText);
5422
5764
  const providers = asRecord(asRecord(modParsed)?.providers);
5423
5765
  if (!providers) return "Modified file: providers object missing or invalid";
5424
5766
  const provider = asRecord(providers[providerLabel]);
5425
5767
  if (!provider) return `Modified file: provider "${providerLabel}" not found`;
5426
5768
  const models = provider.models;
5427
- if (!Array.isArray(models)) return `Modified file: provider "${providerLabel}".models is not an array`;
5428
- const targetModel = models.find((m: unknown) => asRecord(m)?.id === modelId);
5429
- if (!targetModel || typeof targetModel !== "object")
5430
- return `Modified file: model "${modelId}" not found in provider after insertion`;
5769
+ const targetModel = Array.isArray(models)
5770
+ ? models.find((m: unknown) => asRecord(m)?.id === modelId)
5771
+ : undefined;
5772
+ const targetOverride = asRecord(asRecord(provider.modelOverrides)?.[modelId]);
5773
+ if (!targetModel && !targetOverride) {
5774
+ return `Modified file: model or modelOverrides entry "${modelId}" not found in provider after insertion`;
5775
+ }
5431
5776
 
5432
- // Validate effective merged compat
5433
- const provCompatRaw = (provider as Record<string, unknown>).compat;
5434
- const provCompat = (provCompatRaw && typeof provCompatRaw === "object" && !Array.isArray(provCompatRaw))
5435
- ? provCompatRaw as Record<string, unknown>
5436
- : {};
5437
- const mdlCompatRaw = (targetModel as Record<string, unknown>).compat;
5438
- const mdlCompat = (mdlCompatRaw && typeof mdlCompatRaw === "object" && !Array.isArray(mdlCompatRaw))
5439
- ? mdlCompatRaw as Record<string, unknown>
5440
- : {};
5441
- const merged = { ...provCompat, ...mdlCompat };
5442
5777
  for (const [k, v] of Object.entries(compatKeys)) {
5443
- if (!(k in merged)) return `Modified file: effective compat.${k} not found`;
5444
- if (merged[k] !== v) return `Modified file: effective compat.${k} wrong value`;
5778
+ const effective = resolveExplicitCompatValue(modParsed, providerLabel, modelId, k);
5779
+ if (!effective) return `Modified file: effective compat.${k} not found`;
5780
+ if (effective.value !== v) {
5781
+ return `Modified file: effective compat.${k} wrong value: expected ${JSON.stringify(v)}, got ${JSON.stringify(effective.value)} from ${effective.source}`;
5782
+ }
5783
+ }
5784
+
5785
+ // Normalize only the intended override edit back to its original shape,
5786
+ // then require the complete parsed structure to match. This catches data
5787
+ // loss while allowing legitimate shorter repairs such as false -> true.
5788
+ const normalized = JSON.parse(JSON.stringify(modParsed)) as Record<string, unknown>;
5789
+ const origProviders = asRecord(asRecord(origParsed)?.providers);
5790
+ const origProvider = asRecord(origProviders?.[providerLabel]);
5791
+ const normalizedProviders = asRecord(normalized.providers);
5792
+ if (!normalizedProviders) return "Modified file: normalized providers object missing";
5793
+
5794
+ if (!origProvider) {
5795
+ delete normalizedProviders[providerLabel];
5796
+ } else if (targetOverride) {
5797
+ const normalizedProvider = asRecord(normalizedProviders[providerLabel]);
5798
+ const normalizedOverrides = asRecord(normalizedProvider?.modelOverrides);
5799
+ const origOverrides = asRecord(origProvider.modelOverrides);
5800
+ const origOverride = asRecord(origOverrides?.[modelId]);
5801
+
5802
+ if (!normalizedProvider || !normalizedOverrides) {
5803
+ return `Modified file: modelOverrides["${modelId}"] missing during structure validation`;
5804
+ }
5805
+ if (!origOverride) {
5806
+ delete normalizedOverrides[modelId];
5807
+ if (!origOverrides) delete normalizedProvider.modelOverrides;
5808
+ } else {
5809
+ const normalizedOverride = asRecord(normalizedOverrides[modelId]);
5810
+ if (!normalizedOverride) return `Modified file: modelOverrides["${modelId}"] invalid`;
5811
+ const origCompat = asRecord(origOverride.compat);
5812
+ const normalizedCompat = asRecord(normalizedOverride.compat);
5813
+ if (!origCompat) {
5814
+ delete normalizedOverride.compat;
5815
+ } else {
5816
+ if (!normalizedCompat) return `Modified file: modelOverrides["${modelId}"].compat invalid`;
5817
+ for (const key of Object.keys(compatKeys)) {
5818
+ if (Object.prototype.hasOwnProperty.call(origCompat, key)) {
5819
+ normalizedCompat[key] = origCompat[key];
5820
+ } else {
5821
+ delete normalizedCompat[key];
5822
+ }
5823
+ }
5824
+ }
5825
+ }
5826
+ } else {
5827
+ // Backward-compatible validation for the legacy models[] insertion
5828
+ // helper retained in __internals_for_tests.
5829
+ const normalizedProvider = asRecord(normalizedProviders[providerLabel]);
5830
+ const normalizedModels = normalizedProvider?.models;
5831
+ const origModels = origProvider.models;
5832
+ if (!normalizedProvider || !Array.isArray(normalizedModels)) {
5833
+ return `Modified file: provider "${providerLabel}".models missing during structure validation`;
5834
+ }
5835
+ normalizedProvider.models = normalizedModels.filter(
5836
+ (entry: unknown) => asRecord(entry)?.id !== modelId,
5837
+ );
5838
+ if (!Array.isArray(origModels)) delete normalizedProvider.models;
5445
5839
  }
5446
5840
 
5447
- if (modifiedText.length < originalText.length)
5448
- return "Modified file: content is shorter than original (possible truncation)";
5841
+ if (!deepEqualIgnoringKeys(normalized, origParsed, [])) {
5842
+ return "Modified file: original structure was altered (data loss detected)";
5843
+ }
5449
5844
 
5450
5845
  const modClean = stripJsoncComments(modifiedText);
5451
5846
  const rootStart = skipJsonWhitespace(modClean, 0);
@@ -5519,8 +5914,9 @@ function syntheticModelForId(providerLabel: string, id: string): PiModel {
5519
5914
  * Decide whether the fix should write provider-level or model-level compat.
5520
5915
  *
5521
5916
  * Strategy (auto-detect, prefer provider level when safe):
5522
- * - Channel-capability keys (session affinity / long retention) are always
5523
- * provider-safe.
5917
+ * - Channel-capability keys (session affinity / long retention) are normally
5918
+ * provider-safe; runtime-observed model failures explicitly override this
5919
+ * default through chooseFixPlacement(..., forceModelLevel=true).
5524
5920
  * - Model-behavior keys (forceAdaptiveThinking, allowEmptySignature,
5525
5921
  * thinkingFormat, ...) are provider-safe ONLY when every sibling model in
5526
5922
  * the provider also matches the same detection (all adaptive-generation /
@@ -5599,7 +5995,22 @@ function chooseFixPlacement(
5599
5995
  location: ModelNodeLocation,
5600
5996
  compatKeys: Record<string, unknown>,
5601
5997
  providerLabel: string,
5602
- ): { placement: "provider" | "model"; reason: string } {
5998
+ forceModelLevel = false,
5999
+ ): { placement: "provider" | "model" | "modelOverride"; reason: string } {
6000
+ if (location.modelOverrideObjectBrace >= 0) {
6001
+ return {
6002
+ placement: "modelOverride",
6003
+ reason: "an existing modelOverrides entry has Pi's highest precedence — repairing it directly",
6004
+ };
6005
+ }
6006
+
6007
+ if (forceModelLevel) {
6008
+ return {
6009
+ placement: "model",
6010
+ reason: "runtime-observed provider/model failure — scoping the repair to the affected model",
6011
+ };
6012
+ }
6013
+
5603
6014
  const decision = decideFixPlacement(compatKeys, providerLabel, location.allModelIds);
5604
6015
  const existingModelKeys = findExistingCompatKeysInJsonc(
5605
6016
  original,
@@ -5626,12 +6037,24 @@ function composeFixInsertion(
5626
6037
  original: string,
5627
6038
  location: ModelNodeLocation,
5628
6039
  compatKeys: Record<string, unknown>,
5629
- placement: "provider" | "model" = "model",
6040
+ placement: "provider" | "model" | "modelOverride" = "model",
5630
6041
  ): string {
5631
6042
  // Resolve the target compat object and its container based on placement.
5632
- const targetCompatBrace = placement === "provider" ? location.providerCompatBrace : location.compatObjectBrace;
5633
- const targetCompatEnd = placement === "provider" ? location.providerCompatEnd : location.compatObjectEnd;
5634
- const containerBrace = placement === "provider" ? location.providerObjectBrace : location.modelObjectBrace;
6043
+ const targetCompatBrace = placement === "provider"
6044
+ ? location.providerCompatBrace
6045
+ : placement === "modelOverride"
6046
+ ? location.modelOverrideCompatBrace
6047
+ : location.compatObjectBrace;
6048
+ const targetCompatEnd = placement === "provider"
6049
+ ? location.providerCompatEnd
6050
+ : placement === "modelOverride"
6051
+ ? location.modelOverrideCompatEnd
6052
+ : location.compatObjectEnd;
6053
+ const containerBrace = placement === "provider"
6054
+ ? location.providerObjectBrace
6055
+ : placement === "modelOverride"
6056
+ ? location.modelOverrideObjectBrace
6057
+ : location.modelObjectBrace;
5635
6058
 
5636
6059
  // Helper: format key/value pairs as lines with the given indent,
5637
6060
  // alphabetically sorted for stable previews and deterministic edits.
@@ -5733,7 +6156,7 @@ function selfCheckFix(
5733
6156
  providerLabel: string,
5734
6157
  modelId: string,
5735
6158
  compatKeys: Record<string, unknown>,
5736
- placement: "provider" | "model" = "model",
6159
+ placement: "provider" | "model" | "modelOverride" = "model",
5737
6160
  ): string | null {
5738
6161
  try {
5739
6162
  // Step 1: Parse both versions as JSONC (comments + trailing commas allowed).
@@ -5777,9 +6200,9 @@ function selfCheckFix(
5777
6200
  return `Original file: provider/model "${providerLabel}/${modelId}" not found`;
5778
6201
  }
5779
6202
 
5780
- // Step 5: Compute the EFFECTIVE merged compat (provider-level + model-level),
5781
- // mirroring Pi's mergeCompat behavior (model wins on conflicts). The fix may
5782
- // have written either level, so validation must check the merged result.
6203
+ // Step 5: Compute the EFFECTIVE merged compat using Pi's precedence:
6204
+ // provider, custom model, then modelOverrides. The fix may have written any
6205
+ // level, so validation must check what Pi will actually use.
5783
6206
  const provCompatRaw = (provider as Record<string, unknown>).compat;
5784
6207
  const provCompat = (provCompatRaw && typeof provCompatRaw === 'object' && !Array.isArray(provCompatRaw))
5785
6208
  ? provCompatRaw as Record<string, unknown>
@@ -5789,7 +6212,13 @@ function selfCheckFix(
5789
6212
  return `Modified file: model "${modelId}" compat is not an object`;
5790
6213
  }
5791
6214
  const mdlCompat = (modelCompatRaw ?? {}) as Record<string, unknown>;
5792
- const mergedCompat: Record<string, unknown> = { ...provCompat, ...mdlCompat };
6215
+ const override = asRecord(asRecord(provider.modelOverrides)?.[modelId]);
6216
+ const overrideCompatRaw = override?.compat;
6217
+ if (overrideCompatRaw !== undefined && !asRecord(overrideCompatRaw)) {
6218
+ return `Modified file: modelOverrides["${modelId}"].compat is not an object`;
6219
+ }
6220
+ const overrideCompat = asRecord(overrideCompatRaw) ?? {};
6221
+ const mergedCompat: Record<string, unknown> = { ...provCompat, ...mdlCompat, ...overrideCompat };
5793
6222
 
5794
6223
  // Step 6: Validate all inserted keys are effective in the merged compat
5795
6224
  for (const [k, v] of Object.entries(compatKeys)) {
@@ -5837,7 +6266,8 @@ function selfCheckFix(
5837
6266
  // the placement-resolved object's own compat may be exempt.
5838
6267
  const mayRepairThisCompat =
5839
6268
  (placement === "provider" && origObj === origProvider) ||
5840
- (placement === "model" && origObj === origTargetModelRecord);
6269
+ (placement === "model" && origObj === origTargetModelRecord) ||
6270
+ (placement === "modelOverride" && origObj === asRecord(asRecord(origProvider.modelOverrides)?.[modelId]));
5841
6271
  for (const ck of Object.keys(origCompat)) {
5842
6272
  if (!(ck in modCompat)) return false;
5843
6273
  // The fix may repair an existing wrong compat value (for example
@@ -5939,6 +6369,8 @@ export const __internals_for_tests = {
5939
6369
  isAnthropicMessagesApi,
5940
6370
  isOpenAICompatibleProxyApi,
5941
6371
  collectAnthropicCacheControlsInWireOrder,
6372
+ downgradeAnthropicLongCacheControls,
6373
+ hasAnthropicCacheTtlOrderError,
5942
6374
  normalizeAnthropicCacheControlTtlOrder,
5943
6375
  isPiBuiltInLlamaCppModel,
5944
6376
  isResponsesPromptRewriteBypassApi,
@@ -6157,11 +6589,15 @@ export const __internals_for_tests = {
6157
6589
  stripJsoncComments,
6158
6590
  stripJsoncTrailingCommas,
6159
6591
  parseJsonc,
6592
+ resolveExplicitCompatValue,
6593
+ hasExplicitLongRetentionOptInFromConfig,
6160
6594
  locateModelInJsonc,
6595
+ locateModelOverrideInJsonc,
6161
6596
  composeFixInsertion,
6162
6597
  selfCheckFix,
6163
6598
  analyzeModelsJsonForMissingEntry,
6164
6599
  composeMissingEntryInsertion,
6600
+ composeModelOverrideInsertion,
6165
6601
  selfCheckMissingEntryInsertion,
6166
6602
  decideFixPlacement,
6167
6603
  chooseFixPlacement,
@@ -6186,6 +6622,9 @@ export default function (pi: ExtensionAPI) {
6186
6622
  const warnedModels = new Set<string>();
6187
6623
  const promptCacheRetention400Models = new Set<string>();
6188
6624
  const warnedPromptCacheRetention400Models = new Set<string>();
6625
+ const anthropicTtlFallbackState = getAnthropicTtlFallbackState();
6626
+ const anthropicTtlOrderErrorModels = anthropicTtlFallbackState.modelKeys;
6627
+ const warnedAnthropicTtlOrderErrorModels = anthropicTtlFallbackState.warnedModelKeys;
6189
6628
  const sendSessionAffinityHeaders403Models = new Set<string>();
6190
6629
  const warnedSendSessionAffinityHeaders403Models = new Set<string>();
6191
6630
  const openAISdkHeader403Models = new Set<string>();
@@ -6207,6 +6646,41 @@ export default function (pi: ExtensionAPI) {
6207
6646
  let lastActualRoutedModel: PersistedRoutedModelRef | undefined;
6208
6647
  let latestCacheHint: PiCacheHintSnapshot | undefined;
6209
6648
  const PERSIST_DEBOUNCE_MS = 2000;
6649
+
6650
+ function buildObservedRuntimeFixSuggestion(model: PiModel): FixSuggestion | undefined {
6651
+ const key = modelKey(model);
6652
+ const slashIdx = key.indexOf("/");
6653
+ const providerLabel = slashIdx > 0 ? key.slice(0, slashIdx) : key;
6654
+
6655
+ if (
6656
+ anthropicTtlOrderErrorModels.has(key) ||
6657
+ (isPromptCacheRetention400Applicable(model) && promptCacheRetention400Models.has(key))
6658
+ ) {
6659
+ return {
6660
+ providerLabel,
6661
+ modelId: model.id,
6662
+ compatKeys: { supportsLongCacheRetention: false },
6663
+ forceModelLevel: true,
6664
+ };
6665
+ }
6666
+ if (isSessionAffinity403Applicable(model) && sendSessionAffinityHeaders403Models.has(key)) {
6667
+ return { providerLabel, modelId: model.id, compatKeys: { sendSessionAffinityHeaders: false } };
6668
+ }
6669
+ return undefined;
6670
+ }
6671
+
6672
+ function buildCommandFixSuggestion(model: PiModel): FixSuggestion | undefined {
6673
+ const regular = buildFixSuggestion(model);
6674
+ const observed = buildObservedRuntimeFixSuggestion(model);
6675
+ if (!regular) return observed;
6676
+ if (!observed) return regular;
6677
+ return {
6678
+ providerLabel: regular.providerLabel,
6679
+ modelId: regular.modelId,
6680
+ compatKeys: { ...regular.compatKeys, ...observed.compatKeys },
6681
+ forceModelLevel: regular.forceModelLevel || observed.forceModelLevel,
6682
+ };
6683
+ }
6210
6684
  /** In-memory recent usage samples per model key (not persisted, cleared on reload). */
6211
6685
  const recentSamplesByModelKey = new Map<string, CacheUsageSample[]>();
6212
6686
 
@@ -6657,9 +7131,8 @@ export default function (pi: ExtensionAPI) {
6657
7131
 
6658
7132
  /**
6659
7133
  * Check whether a model has an EXPLICIT supportsLongCacheRetention: true
6660
- * opt-in in models.json (either at provider-level or model-level).
6661
- * Model-level compat takes precedence over provider-level (mirrors Pi's
6662
- * mergeCompat behaviour: model wins on conflicts).
7134
+ * opt-in in models.json. Precedence mirrors Pi's effective model config:
7135
+ * modelOverrides[model.id].compat, then models[].compat, then provider.compat.
6663
7136
  *
6664
7137
  * Returns true ONLY when the user explicitly opted in. Returns false for:
6665
7138
  * - Explicit false (opt-out)
@@ -6673,31 +7146,7 @@ export default function (pi: ExtensionAPI) {
6673
7146
  try {
6674
7147
  const text = readFileSync(MODELS_JSON_PATH, "utf8");
6675
7148
  const parsed = parseJsonc(text);
6676
- const providers = asRecord(asRecord(parsed)?.providers);
6677
- if (!providers) return false;
6678
-
6679
- const prov = asRecord(providers[model.provider]);
6680
- if (!prov) return false;
6681
-
6682
- // Check model-level first (higher priority in Pi's merge logic)
6683
- const models = prov.models;
6684
- if (Array.isArray(models)) {
6685
- const modelEntry = models.find(m => asRecord(m)?.id === model.id);
6686
- if (modelEntry) {
6687
- const modelCompat = asRecord(asRecord(modelEntry)?.compat);
6688
- if (modelCompat?.supportsLongCacheRetention !== undefined) {
6689
- return modelCompat.supportsLongCacheRetention === true;
6690
- }
6691
- }
6692
- }
6693
-
6694
- // Check provider-level
6695
- const provCompat = asRecord(prov.compat);
6696
- if (provCompat?.supportsLongCacheRetention !== undefined) {
6697
- return provCompat.supportsLongCacheRetention === true;
6698
- }
6699
-
6700
- return false;
7149
+ return hasExplicitLongRetentionOptInFromConfig(parsed, model.provider, model.id);
6701
7150
  } catch {
6702
7151
  return false;
6703
7152
  }
@@ -6849,11 +7298,15 @@ export default function (pi: ExtensionAPI) {
6849
7298
  const requestModel = resolveRouteModel(ctx.model, ctx) ?? ctx.model;
6850
7299
 
6851
7300
  // Anthropic rejects mixed cache breakpoints when a 1h block appears after
6852
- // a 5m/default block in wire order (tools → system → messages). Normalize
6853
- // only provably-invalid final payloads; legal long-only and long→short
6854
- // payloads keep their original retention.
6855
- if (isAnthropicMessagesApi(requestModel?.api)) {
6856
- normalizeAnthropicCacheControlTtlOrder(event.payload);
7301
+ // a 5m/default block in wire order (tools → system → messages). Repair any
7302
+ // conflict visible in Pi's final payload immediately. Some proxies inject
7303
+ // hidden short breakpoints after this hook; only models that have actually
7304
+ // returned the explicit TTL-order error receive the process-local 5m fallback.
7305
+ if (requestModel && isAnthropicMessagesApi(requestModel.api)) {
7306
+ const visibleConflictFixed = normalizeAnthropicCacheControlTtlOrder(event.payload);
7307
+ if (!visibleConflictFixed && anthropicTtlOrderErrorModels.has(modelKey(requestModel))) {
7308
+ downgradeAnthropicLongCacheControls(event.payload);
7309
+ }
6857
7310
  }
6858
7311
 
6859
7312
  // ── Safety: strip prompt_cache_retention from payload for models that
@@ -6957,6 +7410,29 @@ export default function (pi: ExtensionAPI) {
6957
7410
 
6958
7411
  pi.on("message_end", async (event, ctx) => {
6959
7412
  syncSessionHash(ctx);
7413
+ const msgRecord = asRecord(event.message);
7414
+
7415
+ // Record only Anthropic's explicit mixed-TTL ordering error. This is a
7416
+ // non-retryable 400 in Pi 0.82.1, so the fallback applies to the next
7417
+ // subsequent request (and to a retry only if another layer initiates one).
7418
+ if (hasAnthropicCacheTtlOrderError(event.message)) {
7419
+ const fallbackModel = resolveRouteModel(ctx.model, ctx) ?? ctx.model;
7420
+ const errorModel = modelFromAssistantMessage(event.message, fallbackModel) ?? fallbackModel;
7421
+ if (errorModel && isAnthropicMessagesApi(errorModel.api)) {
7422
+ const key = modelKey(errorModel);
7423
+ anthropicTtlOrderErrorModels.add(key);
7424
+ if (!warnedAnthropicTtlOrderErrorModels.has(key)) {
7425
+ warnedAnthropicTtlOrderErrorModels.add(key);
7426
+ ctx.ui.notify(
7427
+ `⚠️ ${LOG_PREFIX}: ${key} returned an Anthropic cache-control TTL ordering error. ` +
7428
+ `The next request will fall back to the default 5-minute cache TTL. ` +
7429
+ `Run /cache-optimizer fix to set model-level supportsLongCacheRetention: false persistently.`,
7430
+ "warning",
7431
+ );
7432
+ }
7433
+ }
7434
+ }
7435
+
6960
7436
  const adapter = selectAdapterForAssistantMessage(event.message, ctx.model);
6961
7437
  if (!adapter) return;
6962
7438
 
@@ -6967,7 +7443,6 @@ export default function (pi: ExtensionAPI) {
6967
7443
  // returns as a valid (non-undefined) snapshot, which would inflate
6968
7444
  // totalRequests and skew cache hit-rate accuracy. The final successful
6969
7445
  // response is emitted as a separate message_end with real usage data.
6970
- const msgRecord = asRecord(event.message);
6971
7446
  if (msgRecord?.stopReason === "error" || msgRecord?.stopReason === "aborted") {
6972
7447
  return;
6973
7448
  }
@@ -7074,7 +7549,7 @@ export default function (pi: ExtensionAPI) {
7074
7549
  cmdCtx.ui.notify("No active model selected. Select a model first with /model or pi --model.", "warning");
7075
7550
  return;
7076
7551
  }
7077
- const diagnosis = buildDoctorDiagnosis(model, { promptCacheRetention400: promptCacheRetention400Models.has(modelKey(model)), sessionAffinity403: sendSessionAffinityHeaders403Models.has(modelKey(model)), openAISdkHeader403: openAISdkHeader403Models.has(modelKey(model)) });
7552
+ const diagnosis = buildDoctorDiagnosis(model, { promptCacheRetention400: promptCacheRetention400Models.has(modelKey(model)), anthropicTtlOrderError: anthropicTtlOrderErrorModels.has(modelKey(model)), sessionAffinity403: sendSessionAffinityHeaders403Models.has(modelKey(model)), openAISdkHeader403: openAISdkHeader403Models.has(modelKey(model)) });
7078
7553
  const adapter = selectAdapterForModel(model);
7079
7554
  const sk = model ? sessionModelKey(model) : undefined;
7080
7555
  const statsState = model ? cacheStatsTotalsByModel[modelKey(model)] : undefined;
@@ -7147,37 +7622,7 @@ export default function (pi: ExtensionAPI) {
7147
7622
  return;
7148
7623
  }
7149
7624
 
7150
- let suggestion = buildFixSuggestion(model);
7151
-
7152
- // If no regular missing compat flags but the model has a recorded
7153
- // prompt_cache_retention 400 (Pi sent `prompt_cache_retention` and
7154
- // the provider rejected it), offer to override
7155
- // `supportsLongCacheRetention` to false in models.json.
7156
- if (!suggestion && isPromptCacheRetention400Applicable(model) && promptCacheRetention400Models.has(modelKey(model))) {
7157
- const key = modelKey(model);
7158
- const slashIdx = key.indexOf("/");
7159
- const providerLabel = slashIdx > 0 ? key.slice(0, slashIdx) : key;
7160
- suggestion = {
7161
- providerLabel,
7162
- modelId: model.id,
7163
- compatKeys: { supportsLongCacheRetention: false },
7164
- };
7165
- }
7166
-
7167
- // If no regular missing compat flags but the model has a recorded
7168
- // 403 while sendSessionAffinityHeaders was enabled (Pi sent custom
7169
- // session-affinity HTTP headers and the proxy/CDN blocked them), offer
7170
- // to override `sendSessionAffinityHeaders` to false in models.json.
7171
- if (!suggestion && isSessionAffinity403Applicable(model) && sendSessionAffinityHeaders403Models.has(modelKey(model))) {
7172
- const key = modelKey(model);
7173
- const slashIdx = key.indexOf("/");
7174
- const providerLabel = slashIdx > 0 ? key.slice(0, slashIdx) : key;
7175
- suggestion = {
7176
- providerLabel,
7177
- modelId: model.id,
7178
- compatKeys: { sendSessionAffinityHeaders: false },
7179
- };
7180
- }
7625
+ const suggestion = buildCommandFixSuggestion(model);
7181
7626
 
7182
7627
  if (!suggestion) {
7183
7628
  const key = modelKey(model);
@@ -7203,6 +7648,13 @@ export default function (pi: ExtensionAPI) {
7203
7648
  "Create or edit the entry below to override supportsLongCacheRetention to false.",
7204
7649
  );
7205
7650
  }
7651
+ if (anthropicTtlOrderErrorModels.has(modelKey(model))) {
7652
+ manualLines.push(
7653
+ "",
7654
+ "💡 This model returned an Anthropic cache-control TTL ordering error.",
7655
+ "Create or edit the entry below to override supportsLongCacheRetention to false.",
7656
+ );
7657
+ }
7206
7658
  if (sendSessionAffinityHeaders403Models.has(modelKey(model))) {
7207
7659
  manualLines.push(
7208
7660
  "",
@@ -7212,8 +7664,8 @@ export default function (pi: ExtensionAPI) {
7212
7664
  }
7213
7665
  manualLines.push(
7214
7666
  "",
7215
- "If the provider/model already exists in models.json, add these compat keys under",
7216
- `providers["${suggestion.providerLabel}"] -> models -> entry with id "${suggestion.modelId}" -> compat:`,
7667
+ "Add these compat keys at Pi's highest-precedence model override path:",
7668
+ `providers["${suggestion.providerLabel}"] -> modelOverrides -> "${suggestion.modelId}" -> compat:`,
7217
7669
  formatCompatKeysForInsertion(suggestion.compatKeys),
7218
7670
  );
7219
7671
  if (snippet.length > 0) {
@@ -7247,11 +7699,21 @@ export default function (pi: ExtensionAPI) {
7247
7699
  if (!location) {
7248
7700
  const diagnosis = analyzeModelsJsonForMissingEntry(originalText, suggestion.providerLabel, suggestion.modelId);
7249
7701
  if (diagnosis && cmdCtx.hasUI) {
7250
- // Offer to create the missing entry.
7251
- const plan = composeMissingEntryInsertion(
7252
- originalText, diagnosis,
7253
- suggestion.providerLabel, suggestion.modelId, suggestion.compatKeys,
7702
+ const overrideLocation = locateModelOverrideInJsonc(
7703
+ originalText, suggestion.providerLabel, suggestion.modelId,
7704
+ );
7705
+ const repairsExistingOverride = (overrideLocation?.modelOverrideObjectBrace ?? -1) >= 0;
7706
+ // Prefer a modelOverrides edit when models[] has no target entry.
7707
+ const plan = composeModelOverrideInsertion(
7708
+ originalText, suggestion.providerLabel, suggestion.modelId, suggestion.compatKeys,
7254
7709
  );
7710
+ if (!plan) {
7711
+ cmdCtx.ui.notify(
7712
+ `❌ Could not safely locate a modelOverrides insertion point.\n` +
7713
+ `Falling back to manual guidance. No changes were made.`,
7714
+ "error",
7715
+ );
7716
+ } else {
7255
7717
  const checkError = selfCheckMissingEntryInsertion(
7256
7718
  originalText, plan.modifiedText,
7257
7719
  suggestion.providerLabel, suggestion.modelId, suggestion.compatKeys,
@@ -7276,7 +7738,9 @@ export default function (pi: ExtensionAPI) {
7276
7738
  keysPreview,
7277
7739
  ``,
7278
7740
  `⚠️ Risk notice:`,
7279
- ` 1. This creates a new entry in models.json. Existing auth (e.g. login API tokens) is not affected.`,
7741
+ repairsExistingOverride
7742
+ ? ` 1. This updates the existing modelOverrides entry for "${suggestion.modelId}". Existing auth is not affected.`
7743
+ : ` 1. This creates a modelOverrides entry in models.json. Existing auth (e.g. login API tokens) is not affected.`,
7280
7744
  ` 2. A timestamped backup will be written to: ${backupPath}`,
7281
7745
  ` 3. You must run /reload or restart Pi for the change to take effect.`,
7282
7746
  ` 4. If the file contains comments or unusual formatting, please verify the result after write.`,
@@ -7291,7 +7755,10 @@ export default function (pi: ExtensionAPI) {
7291
7755
  );
7292
7756
  }
7293
7757
  previewLines.push("", `Apply these changes?`);
7294
- const confirmed = await cmdCtx.ui.confirm("Cache Optimizer — Fix (new entry)", previewLines.join("\n"));
7758
+ const confirmed = await cmdCtx.ui.confirm(
7759
+ repairsExistingOverride ? "Cache Optimizer — Fix (model override)" : "Cache Optimizer — Fix (new override)",
7760
+ previewLines.join("\n"),
7761
+ );
7295
7762
  if (confirmed) {
7296
7763
  try {
7297
7764
  await copyFile(MODELS_JSON_PATH, backupPath);
@@ -7330,6 +7797,7 @@ export default function (pi: ExtensionAPI) {
7330
7797
  cmdCtx.ui.notify("No changes were made. Canceled by user.", "info");
7331
7798
  return;
7332
7799
  }
7800
+ }
7333
7801
  }
7334
7802
 
7335
7803
  // Non-interactive or no diagnosis: show manual guidance.
@@ -7342,7 +7810,7 @@ export default function (pi: ExtensionAPI) {
7342
7810
  `❌ Could not locate model "${suggestion.modelId}" or provider "${suggestion.providerLabel}" in ${getModelsJsonDisplayPath()}.`,
7343
7811
  "",
7344
7812
  "Providers that were added via Pi /login API (e.g. opencode go) do not have",
7345
- "entries in models.json. You can create a minimal compat-only entry by hand:",
7813
+ "entries in models.json. You can create a minimal modelOverrides entry by hand:",
7346
7814
  );
7347
7815
  } else if (diagnosis.scenario === "provider_missing") {
7348
7816
  adviceLines.push(
@@ -7357,7 +7825,7 @@ export default function (pi: ExtensionAPI) {
7357
7825
  `ℹ️ Model "${suggestion.modelId}" was not found in ${getModelsJsonDisplayPath()}`,
7358
7826
  `under providers["${suggestion.providerLabel}"].`,
7359
7827
  "",
7360
- "Add the following entry to the models array (keep existing auth):",
7828
+ "Add the following modelOverrides entry (keep existing auth):",
7361
7829
  );
7362
7830
  }
7363
7831
  adviceLines.push("", snippet, "", "Then save and run /reload.");
@@ -7365,9 +7833,15 @@ export default function (pi: ExtensionAPI) {
7365
7833
  return;
7366
7834
  }
7367
7835
 
7368
- // Compose the modified text — auto-detect the best placement level:
7369
- // provider level (channel-wide) when safe for all sibling models, else model level.
7370
- const decision = chooseFixPlacement(originalText, location, suggestion.compatKeys, suggestion.providerLabel);
7836
+ // Compose the modified text — observed runtime failures are always
7837
+ // model-scoped; ordinary compat fixes use the safety-based placement.
7838
+ const decision = chooseFixPlacement(
7839
+ originalText,
7840
+ location,
7841
+ suggestion.compatKeys,
7842
+ suggestion.providerLabel,
7843
+ suggestion.forceModelLevel,
7844
+ );
7371
7845
  const modifiedText = composeFixInsertion(originalText, location, suggestion.compatKeys, decision.placement);
7372
7846
 
7373
7847
  // Self-check
@@ -7384,11 +7858,17 @@ export default function (pi: ExtensionAPI) {
7384
7858
  // Build preview snippet as copyable JSON (the surgical editor will
7385
7859
  // insert or repair these exact compat key/value pairs).
7386
7860
  const keysPreview = JSON.stringify(suggestion.compatKeys, null, 2);
7387
- const targetHasCompat = decision.placement === "provider" ? location.providerCompatBrace >= 0 : location.compatObjectBrace >= 0;
7861
+ const targetHasCompat = decision.placement === "provider"
7862
+ ? location.providerCompatBrace >= 0
7863
+ : decision.placement === "modelOverride"
7864
+ ? location.modelOverrideCompatBrace >= 0
7865
+ : location.compatObjectBrace >= 0;
7388
7866
  const placementDesc = targetHasCompat ? `existing "compat" object` : `new "compat" object`;
7389
7867
  const locationDesc = decision.placement === "provider"
7390
7868
  ? `providers["${suggestion.providerLabel}"] -> compat (provider level, ${placementDesc})`
7391
- : `providers["${suggestion.providerLabel}"] -> models -> "${suggestion.modelId}" -> compat (model level, ${placementDesc})`;
7869
+ : decision.placement === "modelOverride"
7870
+ ? `providers["${suggestion.providerLabel}"] -> modelOverrides -> "${suggestion.modelId}" -> compat (${placementDesc})`
7871
+ : `providers["${suggestion.providerLabel}"] -> models -> "${suggestion.modelId}" -> compat (model level, ${placementDesc})`;
7392
7872
 
7393
7873
  const ts = backupTimestamp();
7394
7874
  const backupPath = `${MODELS_JSON_PATH}.backup-cache-optimizer-${ts}`;
@@ -7495,7 +7975,7 @@ export default function (pi: ExtensionAPI) {
7495
7975
  if (!model) {
7496
7976
  cmdCtx.ui.notify("No active model selected. Select a model first with /model or pi --model.", "warning");
7497
7977
  } else {
7498
- const diagnosis = buildDoctorDiagnosis(model, { promptCacheRetention400: promptCacheRetention400Models.has(modelKey(model)), sessionAffinity403: sendSessionAffinityHeaders403Models.has(modelKey(model)), openAISdkHeader403: openAISdkHeader403Models.has(modelKey(model)) });
7978
+ const diagnosis = buildDoctorDiagnosis(model, { promptCacheRetention400: promptCacheRetention400Models.has(modelKey(model)), anthropicTtlOrderError: anthropicTtlOrderErrorModels.has(modelKey(model)), sessionAffinity403: sendSessionAffinityHeaders403Models.has(modelKey(model)), openAISdkHeader403: openAISdkHeader403Models.has(modelKey(model)) });
7499
7979
  const adapter = selectAdapterForModel(model);
7500
7980
  const sk = model ? sessionModelKey(model) : undefined;
7501
7981
  const statsState = model ? cacheStatsTotalsByModel[modelKey(model)] : undefined;
@@ -7539,7 +8019,7 @@ export default function (pi: ExtensionAPI) {
7539
8019
  cmdCtx.ui.notify("No active model selected. Select a model first with /model or pi --model.", "warning");
7540
8020
  return;
7541
8021
  }
7542
- const suggestion = buildFixSuggestion(model);
8022
+ const suggestion = buildCommandFixSuggestion(model);
7543
8023
  if (!suggestion) {
7544
8024
  const key = modelKey(model);
7545
8025
  cmdCtx.ui.notify(`✅ Nothing to fix for "${key}". Compat already configured.`, "info");
@@ -7556,20 +8036,38 @@ export default function (pi: ExtensionAPI) {
7556
8036
  }
7557
8037
 
7558
8038
  const location = locateModelInJsonc(originalText, suggestion.providerLabel, suggestion.modelId);
7559
- if (!location) {
8039
+ const menuDecision = location
8040
+ ? chooseFixPlacement(
8041
+ originalText,
8042
+ location,
8043
+ suggestion.compatKeys,
8044
+ suggestion.providerLabel,
8045
+ suggestion.forceModelLevel,
8046
+ )
8047
+ : undefined;
8048
+ const overridePlan = location
8049
+ ? undefined
8050
+ : composeModelOverrideInsertion(
8051
+ originalText,
8052
+ suggestion.providerLabel,
8053
+ suggestion.modelId,
8054
+ suggestion.compatKeys,
8055
+ );
8056
+ if (!location && !overridePlan) {
7560
8057
  cmdCtx.ui.notify(
7561
- `❌ Could not locate model "${suggestion.modelId}" in ${getModelsJsonDisplayPath()}.\n` +
7562
- `Manual edit required: open the file and add:\n` +
7563
- `${formatCompatKeysForInsertion(suggestion.compatKeys)}\n` +
8058
+ `❌ Could not safely locate a modelOverrides insertion point in ${getModelsJsonDisplayPath()}.\n` +
8059
+ `Manual edit required:\n${formatMissingEntryManualSnippet(suggestion.providerLabel, suggestion.modelId, suggestion.compatKeys)}\n` +
7564
8060
  `Then run /reload.`,
7565
8061
  "warning",
7566
8062
  );
7567
8063
  return;
7568
8064
  }
7569
-
7570
- const menuDecision = chooseFixPlacement(originalText, location, suggestion.compatKeys, suggestion.providerLabel);
7571
- const modifiedText = composeFixInsertion(originalText, location, suggestion.compatKeys, menuDecision.placement);
7572
- const checkError = selfCheckFix(originalText, modifiedText, suggestion.providerLabel, suggestion.modelId, suggestion.compatKeys, menuDecision.placement);
8065
+ const modifiedText = location && menuDecision
8066
+ ? composeFixInsertion(originalText, location, suggestion.compatKeys, menuDecision.placement)
8067
+ : overridePlan!.modifiedText;
8068
+ const checkError = location && menuDecision
8069
+ ? selfCheckFix(originalText, modifiedText, suggestion.providerLabel, suggestion.modelId, suggestion.compatKeys, menuDecision.placement)
8070
+ : selfCheckMissingEntryInsertion(originalText, modifiedText, suggestion.providerLabel, suggestion.modelId, suggestion.compatKeys);
7573
8071
  if (checkError !== null) {
7574
8072
  cmdCtx.ui.notify(`❌ Self-check failed: ${checkError}\nNo changes made.`, "error");
7575
8073
  return;
@@ -7579,17 +8077,19 @@ export default function (pi: ExtensionAPI) {
7579
8077
  const ts = backupTimestamp();
7580
8078
  const backupPath = `${MODELS_JSON_PATH}.backup-cache-optimizer-${ts}`;
7581
8079
 
7582
- const menuLocationDesc = menuDecision.placement === "provider"
8080
+ const menuLocationDesc = overridePlan?.placementLabel ?? (menuDecision?.placement === "provider"
7583
8081
  ? `providers["${suggestion.providerLabel}"] -> compat (provider level)`
7584
- : `providers["${suggestion.providerLabel}"] -> models -> "${suggestion.modelId}" -> compat (model level)`;
7585
- const menuScopeRiskLine = menuDecision.placement === "provider"
7586
- ? ` 1. This change applies to ALL ${location.allModelIds.length || 1} model(s) in the "${suggestion.providerLabel}" provider, across all sessions.`
8082
+ : menuDecision?.placement === "modelOverride"
8083
+ ? `providers["${suggestion.providerLabel}"] -> modelOverrides -> "${suggestion.modelId}" -> compat`
8084
+ : `providers["${suggestion.providerLabel}"] -> models -> "${suggestion.modelId}" -> compat (model level)`);
8085
+ const menuScopeRiskLine = menuDecision?.placement === "provider"
8086
+ ? ` 1. This change applies to ALL ${location?.allModelIds.length || 1} model(s) in the "${suggestion.providerLabel}" provider, across all sessions.`
7587
8087
  : ` 1. This change affects ALL sessions using the "${suggestion.providerLabel}" provider/channel (scoped to model "${suggestion.modelId}").`;
7588
8088
 
7589
8089
  const previewLines = [
7590
8090
  `📝 Preview of changes to ${getModelsJsonDisplayPath()}:`,
7591
8091
  `Location: ${menuLocationDesc}`,
7592
- `Placement: ${menuDecision.placement} level — ${menuDecision.reason}`,
8092
+ `Placement: ${menuDecision ? `${menuDecision.placement} level — ${menuDecision.reason}` : "modelOverrides — built-in/API-login-safe model override"}`,
7593
8093
  `Compat JSON to write:`,
7594
8094
  keysPreview,
7595
8095
  ``,
@@ -7615,7 +8115,9 @@ export default function (pi: ExtensionAPI) {
7615
8115
  await rename(tempPath, MODELS_JSON_PATH);
7616
8116
 
7617
8117
  const writtenText = await readFile(MODELS_JSON_PATH, "utf8");
7618
- const postCheck = selfCheckFix(originalText, writtenText, suggestion.providerLabel, suggestion.modelId, suggestion.compatKeys, menuDecision.placement);
8118
+ const postCheck = location && menuDecision
8119
+ ? selfCheckFix(originalText, writtenText, suggestion.providerLabel, suggestion.modelId, suggestion.compatKeys, menuDecision.placement)
8120
+ : selfCheckMissingEntryInsertion(originalText, writtenText, suggestion.providerLabel, suggestion.modelId, suggestion.compatKeys);
7619
8121
  if (postCheck !== null) {
7620
8122
  await copyFile(backupPath, MODELS_JSON_PATH);
7621
8123
  cmdCtx.ui.notify(`❌ Post-write check failed: ${postCheck}\nBackup restored.`, "error");