pi-cache-optimizer 2.8.6 → 2.8.8

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.
Files changed (4) hide show
  1. package/README.md +65 -8
  2. package/README.zh-CN.md +63 -7
  3. package/index.ts +2843 -231
  4. package/package.json +3 -2
package/index.ts CHANGED
@@ -2,7 +2,7 @@ import { createHash, randomUUID } from "node:crypto";
2
2
  import { chmod, copyFile, lstat, mkdir, readFile, readdir, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
3
3
  import { constants as fsConstants, readFileSync, statSync, watch } from "node:fs";
4
4
  import { homedir } from "node:os";
5
- import { dirname, join } from "node:path";
5
+ import { basename, dirname, join } from "node:path";
6
6
  import {
7
7
  getAgentDir,
8
8
  type BuildSystemPromptOptions,
@@ -80,6 +80,8 @@ const STARTUP_CACHE_RETENTION_ENV = getOrCaptureCacheRetentionBaseline();
80
80
  * 2. Sets PI_CACHE_RETENTION=long at extension load time.
81
81
  * 3. Warns once for provider/model cache compat gaps where the signal is conservative.
82
82
  * 4. Shows lightweight persisted provider-specific cache stats in Pi's footer.
83
+ * 5. Offers disabled-by-default deterministic built-in tool ordering when
84
+ * explicitly opted in.
83
85
  *
84
86
  * Provider prompt/KV caches are provider-side and best-effort. This extension improves
85
87
  * the odds of cache hits; it cannot guarantee hits, especially through proxies.
@@ -112,6 +114,13 @@ const SHARD_GLOBAL_EPOCH_PATH = join(SHARD_EPOCH_DIR, "global.json");
112
114
  const SHARD_CLEANUP_LOCK_PATH = join(SHARD_MAINTENANCE_DIR, "cleanup.lock");
113
115
  const SHARD_CLEANUP_MARKER_PATH = join(SHARD_MAINTENANCE_DIR, "last-cleanup");
114
116
  const CONFIG_FILE_PATH = join(STATE_DIR, "pi-cache-optimizer-config.json");
117
+ const CONFIG_RECEIPT_FILE_NAME = "pi-cache-optimizer-config-receipt.json";
118
+ const CONFIG_RECEIPT_PATH = join(STATE_DIR, CONFIG_RECEIPT_FILE_NAME);
119
+ const FIX_RECEIPT_FILE_NAME = "pi-cache-optimizer-fix-receipt.json";
120
+ const FIX_RECEIPT_PATH = join(STATE_DIR, FIX_RECEIPT_FILE_NAME);
121
+ const MODELS_TRANSACTION_LOCK_PATH = join(STATE_DIR, "pi-cache-optimizer-models-transaction.lock");
122
+ const MODELS_TRANSACTION_LOCK_STALE_MS = 60_000;
123
+ const MODELS_TRANSACTION_LOCK_WAIT_MS = 5_000;
115
124
  const SHARD_RETENTION_MS = 48 * 60 * 60 * 1000;
116
125
  const SHARD_TEMP_RETENTION_MS = 24 * 60 * 60 * 1000;
117
126
  const SHARD_CLEANUP_INTERVAL_MS = 6 * 60 * 60 * 1000;
@@ -122,17 +131,84 @@ const NO_OPENAI_CACHE_KEY_ENV = "PI_CACHE_OPTIMIZER_NO_OPENAI_CACHE_KEY";
122
131
  const OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH = 64;
123
132
  const NO_SKILL_COMPRESSION_ENV = "PI_CACHE_OPTIMIZER_NO_SKILL_COMPRESSION";
124
133
  const NO_PROMPT_REWRITE_ENV = "PI_CACHE_OPTIMIZER_NO_PROMPT_REWRITE";
134
+ const TOOL_ORDER_ENV = "PI_CACHE_OPTIMIZER_TOOL_ORDER";
125
135
  const FOOTER_MODE_ENV = "PI_CACHE_OPTIMIZER_FOOTER_MODE";
126
136
  type FooterStatsMode = "session" | "total" | "process";
137
+ type FixReceiptPlacement = "provider" | "model" | "modelOverride";
138
+ type ReceiptScalar = string | number | boolean | null;
139
+ type ReceiptScalarState =
140
+ | { present: false }
141
+ | { present: true; value: ReceiptScalar };
142
+ type FixReceiptCompatChange = {
143
+ before: ReceiptScalarState;
144
+ after: ReceiptScalarState;
145
+ };
146
+ type ModelsJsonFixReceiptV1 = {
147
+ version: 1;
148
+ kind: "pi-cache-optimizer-fix-receipt";
149
+ transactionId: string;
150
+ provider: string;
151
+ modelId: string;
152
+ placement: FixReceiptPlacement;
153
+ targetExistedBefore: boolean;
154
+ changedKeys: Record<string, FixReceiptCompatChange>;
155
+ beforeHash: string;
156
+ afterHash: string;
157
+ backupFile: string;
158
+ createdAt: number;
159
+ appliedAt: number;
160
+ status?: "rolled_back";
161
+ rolledBackAt?: number;
162
+ };
163
+
164
+ // Receipts are trusted input to a later rollback command, so their key set is
165
+ // deliberately narrower than the full models.json compat vocabulary. This
166
+ // prevents a corrupted or hand-edited receipt from turning rollback into a
167
+ // way to rewrite credentials, headers, routing, or arbitrary provider fields.
168
+ const RECEIPT_COMPAT_KEYS = new Set([
169
+ "sendSessionAffinityHeaders",
170
+ "supportsLongCacheRetention",
171
+ "requiresReasoningContentOnAssistantMessages",
172
+ "forceAdaptiveThinking",
173
+ "allowEmptySignature",
174
+ "thinkingFormat",
175
+ "supportsReasoningEffort",
176
+ ]);
127
177
  type FooterStatsModeSource = "config" | "env" | "default";
128
178
  type PersistedCacheOptimizerConfigV1 = {
129
179
  version: 1;
130
180
  footerMode?: FooterStatsMode;
131
181
  };
182
+ type PersistedCacheOptimizerConfigV2 = {
183
+ version: 2;
184
+ footerMode?: FooterStatsMode;
185
+ promptCacheKey?: {
186
+ omit?: string[];
187
+ };
188
+ };
189
+ type PersistedCacheOptimizerConfig = PersistedCacheOptimizerConfigV1 | PersistedCacheOptimizerConfigV2;
190
+ type PromptCacheKeyConfigReceipt = {
191
+ version: 2;
192
+ kind: "pi-cache-optimizer-config-receipt";
193
+ transactionId: string;
194
+ provider: string;
195
+ modelId: string;
196
+ beforeHash: string;
197
+ afterHash: string;
198
+ backupFile: string;
199
+ targetExistedBefore: boolean;
200
+ targetHadModelKey: boolean;
201
+ addedModelKey: string;
202
+ createdAt: number;
203
+ appliedAt: number;
204
+ status?: "rolled_back";
205
+ rolledBackAt?: number;
206
+ };
132
207
  const PI_ROUTING_REGISTRY_SYMBOL = Symbol.for("pi.routing.registry.v1");
133
208
  const PI_CACHE_HINTS_SYMBOL = Symbol.for("pi.cache.hints.v1");
134
209
  const PI_CACHE_HINTS_OWNER_SYMBOL = Symbol.for("pi.cache.optimizer.hints-owner.v1");
135
210
  const ANTHROPIC_TTL_FALLBACK_SYMBOL = Symbol.for("pi.cache.optimizer.anthropic-ttl-fallback.v1");
211
+ const REASONING_PROTOCOL_FALLBACK_SYMBOL = Symbol.for("pi.cache.optimizer.reasoning-protocol-fallback.v1");
136
212
 
137
213
  type AnthropicTtlFallbackStateV1 = {
138
214
  version: 1;
@@ -140,6 +216,31 @@ type AnthropicTtlFallbackStateV1 = {
140
216
  warnedModelKeys: Set<string>;
141
217
  };
142
218
 
219
+ type ReasoningProtocolFallbackStateV1 = {
220
+ version: 1;
221
+ modelKeys: Set<string>;
222
+ warnedModelKeys: Set<string>;
223
+ };
224
+
225
+ function getReasoningProtocolFallbackState(): ReasoningProtocolFallbackStateV1 {
226
+ const globals = globalThis as Record<symbol, unknown>;
227
+ const existing = globals[REASONING_PROTOCOL_FALLBACK_SYMBOL] as Partial<ReasoningProtocolFallbackStateV1> | undefined;
228
+ if (
229
+ existing?.version === 1 &&
230
+ existing.modelKeys instanceof Set &&
231
+ existing.warnedModelKeys instanceof Set
232
+ ) {
233
+ return existing as ReasoningProtocolFallbackStateV1;
234
+ }
235
+ const state: ReasoningProtocolFallbackStateV1 = {
236
+ version: 1,
237
+ modelKeys: new Set<string>(),
238
+ warnedModelKeys: new Set<string>(),
239
+ };
240
+ globals[REASONING_PROTOCOL_FALLBACK_SYMBOL] = state;
241
+ return state;
242
+ }
243
+
143
244
  function getAnthropicTtlFallbackState(): AnthropicTtlFallbackStateV1 {
144
245
  const globals = globalThis as Record<symbol, unknown>;
145
246
  const existing = globals[ANTHROPIC_TTL_FALLBACK_SYMBOL] as Partial<AnthropicTtlFallbackStateV1> | undefined;
@@ -391,6 +492,15 @@ type OptimizedSystemPrompt = {
391
492
  changed: boolean;
392
493
  };
393
494
 
495
+ type ToolOrderApi =
496
+ | "openai-completions"
497
+ | "openai-responses"
498
+ | "anthropic-messages"
499
+ | "google-generative-ai"
500
+ | "google-vertex"
501
+ | "bedrock-converse-stream";
502
+
503
+
394
504
  /**
395
505
  * Per-request sample stored for trend analysis and usage-field-missing detection.
396
506
  * Contains only numeric counters and booleans — never message content, prompts,
@@ -481,6 +591,205 @@ function isStableContextFilePath(filePath: string): boolean {
481
591
  );
482
592
  }
483
593
 
594
+ function isKnownToolOrderApi(api: unknown): api is ToolOrderApi {
595
+ return api === "openai-completions" || api === "openai-responses" ||
596
+ api === "anthropic-messages" || api === "google-generative-ai" ||
597
+ api === "google-vertex" || api === "bedrock-converse-stream";
598
+ }
599
+
600
+ function isToolOrderingEligibleModel(model: PiModel | undefined): boolean {
601
+ // Responses/Codex prompt bypasses remain untouched. The pure helper still
602
+ // supports the verified Responses shape for fixture use, but the request
603
+ // hook must preserve Pi's server-managed/safety-sensitive bypass.
604
+ return !!model && isKnownToolOrderApi(model.api) && !isResponsesPromptRewriteBypassApi(model.api);
605
+ }
606
+
607
+ function getToolNameForPayload(tool: unknown): string | undefined {
608
+ const record = asRecord(tool);
609
+ if (!record) return undefined;
610
+ if (isNonEmptyString(record.name)) return record.name;
611
+ const fn = asRecord(record.function);
612
+ if (isNonEmptyString(fn?.name)) return fn.name;
613
+ const custom = asRecord(record.custom);
614
+ if (isNonEmptyString(custom?.name)) return custom.name;
615
+ const spec = asRecord(record.toolSpec);
616
+ if (isNonEmptyString(spec?.name)) return spec.name;
617
+ return undefined;
618
+ }
619
+
620
+ function compareToolOrderEntries(
621
+ left: { name: string; index: number },
622
+ right: { name: string; index: number },
623
+ ): number {
624
+ // Do not use localeCompare here: its result can vary with the host locale.
625
+ // Exact UTF-16 code-unit ordering plus the original index is reproducible.
626
+ return left.name < right.name ? -1 : left.name > right.name ? 1 : left.index - right.index;
627
+ }
628
+
629
+ function isJsonObject(value: unknown): value is UnknownRecord {
630
+ return asRecord(value) !== undefined;
631
+ }
632
+
633
+ function isVerifiedToolForApi(tool: unknown, api: ToolOrderApi): tool is UnknownRecord {
634
+ const record = asRecord(tool);
635
+ if (!record) return false;
636
+
637
+ if (api === "openai-completions") {
638
+ if (record.type === "function") {
639
+ const fn = asRecord(record.function);
640
+ return isNonEmptyString(fn?.name) && isJsonObject(fn.parameters);
641
+ }
642
+ if (record.type === "custom") {
643
+ const custom = asRecord(record.custom);
644
+ return isNonEmptyString(custom?.name) && isJsonObject(custom.format);
645
+ }
646
+ return false;
647
+ }
648
+
649
+ if (api === "openai-responses") {
650
+ if (record.type === "function") {
651
+ return isNonEmptyString(record.name) && isJsonObject(record.parameters);
652
+ }
653
+ if (record.type === "custom") {
654
+ return isNonEmptyString(record.name) && isJsonObject(record.format);
655
+ }
656
+ return false;
657
+ }
658
+
659
+ if (api === "anthropic-messages") {
660
+ return isNonEmptyString(record.name) && isJsonObject(record.input_schema);
661
+ }
662
+
663
+ if (api === "google-generative-ai" || api === "google-vertex") {
664
+ return isNonEmptyString(record.name) &&
665
+ (isJsonObject(record.parametersJsonSchema) || isJsonObject(record.parameters));
666
+ }
667
+
668
+ const spec = asRecord(record.toolSpec);
669
+ const inputSchema = asRecord(spec?.inputSchema);
670
+ return isNonEmptyString(spec?.name) && isJsonObject(inputSchema?.json);
671
+ }
672
+
673
+ type ToolArrayInspection = {
674
+ sortedIndices: number[];
675
+ changed: boolean;
676
+ };
677
+
678
+ function hasTopLevelCacheControl(tools: unknown): boolean {
679
+ return Array.isArray(tools) && tools.some((tool) => {
680
+ const record = asRecord(tool);
681
+ return !!record && Object.prototype.hasOwnProperty.call(record, "cache_control");
682
+ });
683
+ }
684
+
685
+ function inspectToolArray(tools: unknown, api: ToolOrderApi): ToolArrayInspection | undefined {
686
+ if (!Array.isArray(tools)) return undefined;
687
+ // Native Anthropic and OpenAI-compatible transports can attach a cache
688
+ // breakpoint to a specific tool (normally the final one). Never move it,
689
+ // regardless of API id. Anthropic defer_loading also encodes immediate vs
690
+ // deferred tool groups in array order, so mixed/grouped payloads are no-ops.
691
+ if (hasTopLevelCacheControl(tools)) return undefined;
692
+ if (api === "anthropic-messages" && tools.some((tool) => {
693
+ const record = asRecord(tool);
694
+ return !!record && Object.prototype.hasOwnProperty.call(record, "defer_loading");
695
+ })) return undefined;
696
+ const entries = tools.map((tool, index) => {
697
+ if (!isVerifiedToolForApi(tool, api)) return undefined;
698
+ return { name: getToolNameForPayload(tool) ?? "", index };
699
+ });
700
+ if (entries.some((entry) => entry === undefined)) return undefined;
701
+
702
+ const verified = entries as Array<{ name: string; index: number }>;
703
+ const sorted = [...verified].sort(compareToolOrderEntries);
704
+ return {
705
+ sortedIndices: sorted.map((entry) => entry.index),
706
+ changed: sorted.some((entry, index) => entry.index !== index),
707
+ };
708
+ }
709
+
710
+ type ToolArrayPath = {
711
+ kind: "root" | "bedrock" | "google";
712
+ groupIndex?: number;
713
+ sortedIndices: number[];
714
+ };
715
+
716
+ /**
717
+ * Pure deterministic tool-order normalizer for the exact payload shapes
718
+ * emitted by Pi's built-in transports. It returns the original object for a
719
+ * no-op/unsupported/malformed payload and shallow-clones only the verified
720
+ * path to a tool array when sorting is needed. Tool objects and unrelated
721
+ * request fields (including SDK objects and AbortSignal) retain their identity.
722
+ */
723
+ function normalizeToolsInPayload(
724
+ payload: unknown,
725
+ api: unknown,
726
+ ): { payload: unknown; changed: boolean } {
727
+ if (!isKnownToolOrderApi(api)) return { payload, changed: false };
728
+ const root = asRecord(payload);
729
+ if (!root) return { payload, changed: false };
730
+
731
+ if (api === "google-generative-ai" || api === "google-vertex") {
732
+ const config = asRecord(root.config);
733
+ const groups = config?.tools;
734
+ if (!Array.isArray(groups) || groups.length === 0) return { payload, changed: false };
735
+
736
+ const paths: ToolArrayPath[] = [];
737
+ for (let groupIndex = 0; groupIndex < groups.length; groupIndex++) {
738
+ const group = asRecord(groups[groupIndex]);
739
+ if (!group || !Array.isArray(group.functionDeclarations)) return { payload, changed: false };
740
+ const inspected = inspectToolArray(group.functionDeclarations, api);
741
+ if (!inspected) return { payload, changed: false };
742
+ if (inspected.changed) paths.push({ kind: "google", groupIndex, sortedIndices: inspected.sortedIndices });
743
+ }
744
+ if (paths.length === 0) return { payload, changed: false };
745
+
746
+ const sortedGroups = [...groups];
747
+ for (const path of paths) {
748
+ const groupIndex = path.groupIndex!;
749
+ const group = asRecord(groups[groupIndex])!;
750
+ const tools = group.functionDeclarations as unknown[];
751
+ sortedGroups[groupIndex] = {
752
+ ...group,
753
+ functionDeclarations: path.sortedIndices.map((index) => tools[index]),
754
+ };
755
+ }
756
+ return {
757
+ payload: { ...root, config: { ...config, tools: sortedGroups } },
758
+ changed: true,
759
+ };
760
+ }
761
+
762
+ if (api === "bedrock-converse-stream") {
763
+ const toolConfig = asRecord(root.toolConfig);
764
+ const tools = toolConfig?.tools;
765
+ const inspected = inspectToolArray(tools, api);
766
+ if (!inspected || !inspected.changed || !Array.isArray(tools)) return { payload, changed: false };
767
+ return {
768
+ payload: {
769
+ ...root,
770
+ toolConfig: {
771
+ ...toolConfig,
772
+ tools: inspected.sortedIndices.map((index) => tools[index]),
773
+ },
774
+ },
775
+ changed: true,
776
+ };
777
+ }
778
+
779
+ const tools = root.tools;
780
+ const inspected = inspectToolArray(tools, api);
781
+ if (!inspected || !inspected.changed || !Array.isArray(tools)) return { payload, changed: false };
782
+ return {
783
+ payload: { ...root, tools: inspected.sortedIndices.map((index) => tools[index]) },
784
+ changed: true,
785
+ };
786
+ }
787
+
788
+ /** Public test-facing alias: a pure payload transformation. */
789
+ function sortToolsInPayload(payload: unknown, api: unknown): unknown {
790
+ return normalizeToolsInPayload(payload, api).payload;
791
+ }
792
+
484
793
  function formatSkillsForPrompt(skills: NonNullable<BuildSystemPromptOptions["skills"]>): string {
485
794
  const visibleSkills = skills.filter((skill) => !skill.disableModelInvocation);
486
795
  if (visibleSkills.length === 0) return "";
@@ -1039,15 +1348,21 @@ function routeSnapshotToPiModel(snapshot: PiRouteSnapshot, fallback?: PiModel):
1039
1348
  }
1040
1349
 
1041
1350
  function findModelInRegistry(registry: ModelRegistryLike | undefined, provider: string, id: string): PiModel | undefined {
1042
- const found = registry?.find?.(provider, id);
1043
- if (found) return found;
1351
+ try {
1352
+ const found = registry?.find?.(provider, id);
1353
+ if (found) return found;
1044
1354
 
1045
- const available = registry?.getAvailable?.() ?? [];
1046
- const availableMatch = available.find((candidate) => candidate.provider === provider && candidate.id === id);
1047
- if (availableMatch) return availableMatch;
1355
+ const available = registry?.getAvailable?.() ?? [];
1356
+ const availableMatch = available.find((candidate) => candidate.provider === provider && candidate.id === id);
1357
+ if (availableMatch) return availableMatch;
1048
1358
 
1049
- const all = registry?.getAll?.() ?? [];
1050
- return all.find((candidate) => candidate.provider === provider && candidate.id === id);
1359
+ const all = registry?.getAll?.() ?? [];
1360
+ return all.find((candidate) => candidate.provider === provider && candidate.id === id);
1361
+ } catch {
1362
+ // Registry extensions are optional input; a malformed/throwing registry
1363
+ // must not turn a provider error hook into a Pi session failure.
1364
+ return undefined;
1365
+ }
1051
1366
  }
1052
1367
 
1053
1368
  function isRoutedFallbackModel(model: PiModel | undefined): boolean {
@@ -1549,6 +1864,10 @@ function isEnabledEnv(value: string | undefined): boolean {
1549
1864
  return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
1550
1865
  }
1551
1866
 
1867
+ function isToolOrderEnabled(env: MutableEnv = process.env): boolean {
1868
+ return runtimeOptimizerEnabled && isEnabledEnv(env[TOOL_ORDER_ENV]);
1869
+ }
1870
+
1552
1871
  function parseFooterStatsMode(value: unknown): FooterStatsMode | undefined {
1553
1872
  if (typeof value !== "string") return undefined;
1554
1873
  const normalized = value.trim().toLowerCase();
@@ -1570,10 +1889,12 @@ const CACHE_OPTIMIZER_COMMANDS = [
1570
1889
  "compat",
1571
1890
  "reset",
1572
1891
  "fix",
1892
+ "rollback",
1573
1893
  ] as const;
1574
1894
  const CACHE_OPTIMIZER_CONFIG_ARGUMENTS = ["footer-mode"] as const;
1575
1895
  const CACHE_OPTIMIZER_FOOTER_MODES = ["total", "session", "process"] as const;
1576
1896
  const CACHE_OPTIMIZER_STATS_ARGUMENTS = ["all", "contributors"] as const;
1897
+ const CACHE_OPTIMIZER_FIX_ARGUMENTS = ["prompt-cache-key"] as const;
1577
1898
 
1578
1899
  function filterCommandCompletionItems(
1579
1900
  values: readonly string[],
@@ -1624,6 +1945,12 @@ function getCacheOptimizerArgumentCompletions(argumentPrefix: string): CommandCo
1624
1945
  : null;
1625
1946
  }
1626
1947
 
1948
+ if (parts[0].toLowerCase() === "fix") {
1949
+ return parts.length === 2
1950
+ ? filterCommandCompletionItems(CACHE_OPTIMIZER_FIX_ARGUMENTS, parts[1], "fix")
1951
+ : null;
1952
+ }
1953
+
1627
1954
  if (parts[0].toLowerCase() !== "config") return null;
1628
1955
 
1629
1956
  if (parts.length === 2) {
@@ -1641,36 +1968,368 @@ function getCacheOptimizerArgumentCompletions(argumentPrefix: string): CommandCo
1641
1968
  return null;
1642
1969
  }
1643
1970
 
1644
- function parsePersistedCacheOptimizerConfig(value: unknown): PersistedCacheOptimizerConfigV1 | undefined {
1971
+ function parsePersistedCacheOptimizerConfig(value: unknown): PersistedCacheOptimizerConfig | undefined {
1645
1972
  const record = asRecord(value);
1646
- if (!record || record.version !== 1) return undefined;
1973
+ if (!record || (record.version !== 1 && record.version !== 2)) return undefined;
1974
+ const allowedTopLevel = new Set(record.version === 1 ? ["version", "footerMode"] : ["version", "footerMode", "promptCacheKey"]);
1975
+ if (Object.keys(record).some((key) => !allowedTopLevel.has(key))) return undefined;
1647
1976
  const footerMode = parseFooterStatsMode(record.footerMode);
1648
1977
  if (record.footerMode !== undefined && !footerMode) return undefined;
1649
- return { version: 1, ...(footerMode ? { footerMode } : {}) };
1978
+ if (record.version === 1) return { version: 1, ...(footerMode ? { footerMode } : {}) };
1979
+
1980
+ const rawPromptCacheKey = record.promptCacheKey;
1981
+ if (rawPromptCacheKey !== undefined && !asRecord(rawPromptCacheKey)) return undefined;
1982
+ const promptCacheKey = asRecord(rawPromptCacheKey);
1983
+ if (promptCacheKey && Object.keys(promptCacheKey).some((key) => key !== "omit")) return undefined;
1984
+ const omit = promptCacheKey?.omit;
1985
+ if (omit !== undefined && (!Array.isArray(omit) || omit.some((value): value is string => !isNonEmptyString(value)))) return undefined;
1986
+ const stringOmit = omit as string[] | undefined;
1987
+ const uniqueOmit = stringOmit ? [...new Set(stringOmit.map((value) => value.trim()))].sort() : undefined;
1988
+ return {
1989
+ version: 2,
1990
+ ...(footerMode ? { footerMode } : {}),
1991
+ ...(uniqueOmit && uniqueOmit.length > 0 ? { promptCacheKey: { omit: uniqueOmit } } : {}),
1992
+ };
1993
+ }
1994
+
1995
+ function normalizePersistedCacheOptimizerConfig(value: PersistedCacheOptimizerConfig | undefined): PersistedCacheOptimizerConfigV2 {
1996
+ if (!value) return { version: 2 };
1997
+ return value.version === 2
1998
+ ? value
1999
+ : { version: 2, ...(value.footerMode ? { footerMode: value.footerMode } : {}) };
2000
+ }
2001
+
2002
+ function readPersistedCacheOptimizerConfig(configPath: string = CONFIG_FILE_PATH): PersistedCacheOptimizerConfigV2 {
2003
+ try {
2004
+ return normalizePersistedCacheOptimizerConfig(parsePersistedCacheOptimizerConfig(JSON.parse(readFileSync(configPath, "utf8"))));
2005
+ } catch (error) {
2006
+ if (getErrorCode(error) !== "ENOENT") console.warn(`${LOG_PREFIX}: failed to read optimizer config; using defaults`, error);
2007
+ return { version: 2 };
2008
+ }
1650
2009
  }
1651
2010
 
1652
2011
  function readPersistedFooterMode(configPath: string = CONFIG_FILE_PATH): FooterStatsMode | undefined {
2012
+ return readPersistedCacheOptimizerConfig(configPath).footerMode;
2013
+ }
2014
+
2015
+ async function writePersistedCacheOptimizerConfigUnlocked(
2016
+ config: PersistedCacheOptimizerConfigV2,
2017
+ configPath: string,
2018
+ ): Promise<void> {
2019
+ await mkdir(dirname(configPath), { recursive: true });
2020
+ const payloadText = JSON.stringify(normalizePersistedCacheOptimizerConfig(config), null, 2) + "\n";
2021
+ let targetInfo: Awaited<ReturnType<typeof lstat>> | undefined;
2022
+ let targetMode = 0o600;
2023
+ let targetHash: string | undefined;
1653
2024
  try {
1654
- const parsed = parsePersistedCacheOptimizerConfig(JSON.parse(readFileSync(configPath, "utf8")));
1655
- if (!parsed) throw new Error("invalid footer config schema");
1656
- return parsed.footerMode;
2025
+ targetInfo = await lstat(configPath);
2026
+ if (targetInfo.isSymbolicLink() || !targetInfo.isFile()) throw new Error("optimizer config is not a regular file; no changes were made");
2027
+ const targetText = await readFile(configPath, "utf8");
2028
+ targetMode = targetInfo.mode & 0o7777;
2029
+ targetHash = hashText(targetText);
1657
2030
  } catch (error) {
1658
- if (getErrorCode(error) !== "ENOENT") {
1659
- console.warn(`${LOG_PREFIX}: failed to read footer config; using environment/default mode`, error);
2031
+ if (getErrorCode(error) !== "ENOENT") throw error;
2032
+ }
2033
+ if (targetInfo) {
2034
+ await atomicReplaceTextFilePreservingMode(configPath, payloadText, targetMode, "config", {
2035
+ identity: targetInfo,
2036
+ hash: targetHash,
2037
+ mode: targetMode,
2038
+ });
2039
+ return;
2040
+ }
2041
+
2042
+ const tempPath = uniqueTempPath(configPath, "config");
2043
+ try {
2044
+ await writeFile(tempPath, payloadText, { encoding: "utf8", mode: targetMode, flag: "wx" });
2045
+ await chmod(tempPath, targetMode);
2046
+ try {
2047
+ await lstat(configPath);
2048
+ throw new Error("optimizer config appeared during write; no changes were made");
2049
+ } catch (error) {
2050
+ if (getErrorCode(error) !== "ENOENT") throw error;
1660
2051
  }
1661
- return undefined;
2052
+ await rename(tempPath, configPath);
2053
+ } catch (error) {
2054
+ await unlink(tempPath).catch((cleanupError) => { if (getErrorCode(cleanupError) !== "ENOENT") console.warn(`${LOG_PREFIX}: failed to remove temporary config`, cleanupError); });
2055
+ throw error;
2056
+ }
2057
+ }
2058
+
2059
+ async function writePersistedCacheOptimizerConfig(
2060
+ config: PersistedCacheOptimizerConfigV2,
2061
+ configPath: string = CONFIG_FILE_PATH,
2062
+ ): Promise<void> {
2063
+ await withModelsJsonTransactionLock(() => writePersistedCacheOptimizerConfigUnlocked(config, configPath));
2064
+ }
2065
+
2066
+ async function writePersistedFooterModeUnlocked(
2067
+ mode: FooterStatsMode,
2068
+ configPath: string,
2069
+ ): Promise<void> {
2070
+ let version: 1 | 2 = 1;
2071
+ let raw: PersistedCacheOptimizerConfig | undefined;
2072
+ let targetExists = false;
2073
+ try {
2074
+ raw = parsePersistedCacheOptimizerConfig(JSON.parse(readFileSync(configPath, "utf8")));
2075
+ targetExists = true;
2076
+ if (!raw) throw new Error("invalid footer config schema");
2077
+ version = raw.version === 2 ? 2 : 1;
2078
+ } catch (error) {
2079
+ if (getErrorCode(error) !== "ENOENT") throw new Error("invalid footer config schema");
2080
+ }
2081
+ const current = normalizePersistedCacheOptimizerConfig(raw);
2082
+ if (version === 1 && !current.promptCacheKey) {
2083
+ await mkdir(dirname(configPath), { recursive: true });
2084
+ const targetInfo = targetExists ? await lstat(configPath) : undefined;
2085
+ if (targetInfo && (targetInfo.isSymbolicLink() || !targetInfo.isFile())) throw new Error("optimizer config is not a regular file; no changes were made");
2086
+ const targetText = targetInfo ? await readFile(configPath, "utf8") : undefined;
2087
+ const targetMode = targetInfo ? targetInfo.mode & 0o7777 : 0o600;
2088
+ const tempPath = uniqueTempPath(configPath, "config-footer");
2089
+ try {
2090
+ await writeFile(tempPath, JSON.stringify({ version: 1, footerMode: mode }, null, 2) + "\n", { encoding: "utf8", mode: targetMode, flag: "wx" });
2091
+ await chmod(tempPath, targetMode);
2092
+ if (targetInfo && targetText !== undefined) {
2093
+ await validateAtomicTarget(configPath, { identity: targetInfo, hash: hashText(targetText), mode: targetMode });
2094
+ } else {
2095
+ try {
2096
+ await lstat(configPath);
2097
+ throw new Error("optimizer config appeared during footer write; no changes were made");
2098
+ } catch (error) {
2099
+ if (getErrorCode(error) !== "ENOENT") throw error;
2100
+ }
2101
+ }
2102
+ await rename(tempPath, configPath);
2103
+ } catch (error) {
2104
+ await unlink(tempPath).catch(() => {});
2105
+ throw error;
2106
+ }
2107
+ return;
1662
2108
  }
2109
+ await writePersistedCacheOptimizerConfigUnlocked({ ...current, version: 2, footerMode: mode }, configPath);
1663
2110
  }
1664
2111
 
1665
2112
  async function writePersistedFooterMode(
1666
2113
  mode: FooterStatsMode,
1667
2114
  configPath: string = CONFIG_FILE_PATH,
1668
2115
  ): Promise<void> {
2116
+ await withModelsJsonTransactionLock(() => writePersistedFooterModeUnlocked(mode, configPath));
2117
+ }
2118
+
2119
+ function configReceiptBackupPath(receipt: PromptCacheKeyConfigReceipt, receiptPath: string = CONFIG_RECEIPT_PATH): string {
2120
+ return join(dirname(receiptPath), receipt.backupFile);
2121
+ }
2122
+
2123
+ function parsePromptCacheKeyConfigReceipt(value: unknown): PromptCacheKeyConfigReceipt | undefined {
2124
+ const record = asRecord(value);
2125
+ if (!record || (record.version !== 1 && record.version !== 2) || record.kind !== "pi-cache-optimizer-config-receipt") return undefined;
2126
+ const allowed = new Set(record.version === 1
2127
+ ? ["version", "kind", "transactionId", "provider", "modelId", "beforeHash", "afterHash", "backupFile", "targetExistedBefore", "createdAt", "appliedAt", "status", "rolledBackAt"]
2128
+ : ["version", "kind", "transactionId", "provider", "modelId", "beforeHash", "afterHash", "backupFile", "targetExistedBefore", "targetHadModelKey", "addedModelKey", "createdAt", "appliedAt", "status", "rolledBackAt"]);
2129
+ if (Object.keys(record).some((key) => !allowed.has(key))) return undefined;
2130
+ if (![record.transactionId, record.provider, record.modelId].every(isSafeReceiptText)) return undefined;
2131
+ if (!isSha256(record.beforeHash) || !isSha256(record.afterHash) || record.beforeHash === record.afterHash) return undefined;
2132
+ if (!isSafeReceiptText(record.backupFile) || basename(record.backupFile) !== record.backupFile || !record.backupFile.startsWith("pi-cache-optimizer-config.backup-")) return undefined;
2133
+ if (typeof record.targetExistedBefore !== "boolean" || !isReceiptTimestamp(record.createdAt) || !isReceiptTimestamp(record.appliedAt)) return undefined;
2134
+ const addedModelKey = `${record.provider}/${record.modelId}`;
2135
+ const targetHadModelKey = record.version === 1 ? false : record.targetHadModelKey;
2136
+ if (typeof targetHadModelKey !== "boolean") return undefined;
2137
+ if (record.version === 2 && (!isSafeReceiptText(record.addedModelKey) || record.addedModelKey !== addedModelKey)) return undefined;
2138
+ if (record.status !== undefined && record.status !== "rolled_back") return undefined;
2139
+ if (record.status === "rolled_back" && (!isReceiptTimestamp(record.rolledBackAt) || record.rolledBackAt < record.appliedAt)) return undefined;
2140
+ if (record.status === undefined && record.rolledBackAt !== undefined) return undefined;
2141
+ return {
2142
+ version: 2,
2143
+ kind: "pi-cache-optimizer-config-receipt",
2144
+ transactionId: String(record.transactionId),
2145
+ provider: String(record.provider),
2146
+ modelId: String(record.modelId),
2147
+ beforeHash: String(record.beforeHash).toLowerCase(),
2148
+ afterHash: String(record.afterHash).toLowerCase(),
2149
+ backupFile: String(record.backupFile),
2150
+ targetExistedBefore: record.targetExistedBefore,
2151
+ targetHadModelKey,
2152
+ addedModelKey,
2153
+ createdAt: Number(record.createdAt),
2154
+ appliedAt: Number(record.appliedAt),
2155
+ ...(record.status === "rolled_back" ? { status: "rolled_back", rolledBackAt: Number(record.rolledBackAt) } : {}),
2156
+ };
2157
+ }
2158
+
2159
+ function isActionablePromptCacheKeyConfigReceipt(receipt: PromptCacheKeyConfigReceipt | undefined): receipt is PromptCacheKeyConfigReceipt {
2160
+ return receipt !== undefined && receipt.status === undefined;
2161
+ }
2162
+
2163
+ async function readPromptCacheKeyConfigReceipt(receiptPath: string = CONFIG_RECEIPT_PATH): Promise<PromptCacheKeyConfigReceipt | undefined> {
2164
+ try {
2165
+ const info = await lstat(receiptPath);
2166
+ if (info.isSymbolicLink() || !info.isFile()) return undefined;
2167
+ const receipt = parsePromptCacheKeyConfigReceipt(JSON.parse(await readFile(receiptPath, "utf8")));
2168
+ return receipt;
2169
+ } catch {
2170
+ return undefined;
2171
+ }
2172
+ }
2173
+
2174
+ async function writePromptCacheKeyConfigReceipt(receipt: PromptCacheKeyConfigReceipt, receiptPath: string = CONFIG_RECEIPT_PATH): Promise<void> {
2175
+ if (!parsePromptCacheKeyConfigReceipt(receipt)) throw new Error("invalid prompt cache key config receipt");
2176
+ await mkdir(dirname(receiptPath), { recursive: true });
2177
+ const tempPath = uniqueTempPath(receiptPath, "config-receipt");
2178
+ try {
2179
+ await writeFile(tempPath, JSON.stringify(receipt, null, 2) + "\n", { encoding: "utf8", mode: 0o600, flag: "wx" });
2180
+ await chmod(tempPath, 0o600);
2181
+ await rename(tempPath, receiptPath);
2182
+ } catch (error) {
2183
+ await unlink(tempPath).catch((cleanupError) => { if (getErrorCode(cleanupError) !== "ENOENT") console.warn(`${LOG_PREFIX}: failed to remove temporary config receipt`, cleanupError); });
2184
+ throw error;
2185
+ }
2186
+ }
2187
+
2188
+ async function applyPromptCacheKeyConfigFixUnderLock(
2189
+ model: PiModel,
2190
+ configPath: string = CONFIG_FILE_PATH,
2191
+ receiptPath: string = CONFIG_RECEIPT_PATH,
2192
+ ): Promise<{ receipt: PromptCacheKeyConfigReceipt; backupPath: string }> {
1669
2193
  await mkdir(dirname(configPath), { recursive: true });
1670
- const payload: PersistedCacheOptimizerConfigV1 = { version: 1, footerMode: mode };
1671
- const tempPath = `${configPath}.${process.pid}.${Date.now()}.tmp`;
1672
- await writeFile(tempPath, JSON.stringify(payload, null, 2) + "\n", "utf8");
1673
- await rename(tempPath, configPath);
2194
+ let originalText = "";
2195
+ let targetExistedBefore = false;
2196
+ let mode = 0o600;
2197
+ try {
2198
+ const info = await lstat(configPath);
2199
+ if (info.isSymbolicLink() || !info.isFile()) throw new Error("optimizer config is not a regular file; no changes were made");
2200
+ originalText = await readFile(configPath, "utf8");
2201
+ targetExistedBefore = true;
2202
+ mode = info.mode & 0o7777;
2203
+ } catch (error) {
2204
+ if (getErrorCode(error) !== "ENOENT") throw error;
2205
+ }
2206
+ const parsedCurrent = targetExistedBefore ? parsePersistedCacheOptimizerConfig(JSON.parse(originalText)) : undefined;
2207
+ if (targetExistedBefore && !parsedCurrent) throw new Error("optimizer config is invalid; no changes were made");
2208
+ const current = normalizePersistedCacheOptimizerConfig(parsedCurrent);
2209
+ const key = modelKey(model);
2210
+ const existingOmit = current.promptCacheKey?.omit ?? [];
2211
+ const targetHadModelKey = existingOmit.includes(key);
2212
+ const omit = [...new Set([...existingOmit, key])].sort();
2213
+ const next: PersistedCacheOptimizerConfigV2 = { ...current, version: 2, promptCacheKey: { omit } };
2214
+ const modifiedText = JSON.stringify(next, null, 2) + "\n";
2215
+ const beforeHash = hashText(originalText);
2216
+ const afterHash = hashText(modifiedText);
2217
+ const originalInfo = targetExistedBefore ? await lstat(configPath) : undefined;
2218
+ const backupFile = `pi-cache-optimizer-config.backup-${backupTimestamp()}`;
2219
+ const backupPath = join(dirname(configPath), backupFile);
2220
+ if (targetExistedBefore) {
2221
+ await copyFile(configPath, backupPath, fsConstants.COPYFILE_EXCL);
2222
+ await chmod(backupPath, mode);
2223
+ }
2224
+ const tempPath = uniqueTempPath(configPath, "config-fix");
2225
+ let committedInfo: Awaited<ReturnType<typeof lstat>> | undefined;
2226
+ try {
2227
+ await writeFile(tempPath, modifiedText, { encoding: "utf8", mode, flag: "wx" });
2228
+ await chmod(tempPath, mode);
2229
+ if (originalInfo) {
2230
+ await validateAtomicTarget(configPath, { identity: originalInfo, hash: beforeHash, mode });
2231
+ } else {
2232
+ try {
2233
+ await lstat(configPath);
2234
+ throw new Error("optimizer config appeared during the fix; no changes were made");
2235
+ } catch (error) {
2236
+ if (getErrorCode(error) !== "ENOENT") throw error;
2237
+ }
2238
+ }
2239
+ await rename(tempPath, configPath);
2240
+ committedInfo = await lstat(configPath);
2241
+ const receipt: PromptCacheKeyConfigReceipt = {
2242
+ version: 2,
2243
+ kind: "pi-cache-optimizer-config-receipt",
2244
+ transactionId: randomUUID(),
2245
+ provider: model.provider,
2246
+ modelId: model.id,
2247
+ beforeHash,
2248
+ afterHash,
2249
+ backupFile,
2250
+ targetExistedBefore,
2251
+ targetHadModelKey,
2252
+ addedModelKey: key,
2253
+ createdAt: Date.now(),
2254
+ appliedAt: Date.now(),
2255
+ };
2256
+ await writePromptCacheKeyConfigReceipt(receipt, receiptPath);
2257
+ return { receipt, backupPath };
2258
+ } catch (error) {
2259
+ await unlink(tempPath).catch(() => {});
2260
+ if (targetExistedBefore && committedInfo) {
2261
+ await atomicRestoreFileFromBackup(backupPath, configPath, mode, { identity: committedInfo, hash: afterHash, mode }).catch(() => {});
2262
+ } else if (!targetExistedBefore && committedInfo) {
2263
+ await validateAtomicTarget(configPath, { identity: committedInfo, hash: afterHash, mode }).then(() => unlink(configPath)).catch(() => {});
2264
+ }
2265
+ throw error;
2266
+ }
2267
+ }
2268
+
2269
+ async function applyPromptCacheKeyConfigFix(
2270
+ model: PiModel,
2271
+ configPath: string = CONFIG_FILE_PATH,
2272
+ receiptPath: string = CONFIG_RECEIPT_PATH,
2273
+ ): Promise<{ receipt: PromptCacheKeyConfigReceipt; backupPath: string }> {
2274
+ return withModelsJsonTransactionLock(() => applyPromptCacheKeyConfigFixUnderLock(model, configPath, receiptPath));
2275
+ }
2276
+
2277
+ async function rollbackPromptCacheKeyConfigUnderLock(
2278
+ receipt: PromptCacheKeyConfigReceipt,
2279
+ configPath: string = CONFIG_FILE_PATH,
2280
+ receiptPath: string = CONFIG_RECEIPT_PATH,
2281
+ ): Promise<void> {
2282
+ const currentInfo = await lstat(configPath);
2283
+ if (currentInfo.isSymbolicLink() || !currentInfo.isFile()) throw new Error("optimizer config is not a regular file; refusing to overwrite user changes");
2284
+ const currentText = await readFile(configPath, "utf8");
2285
+ if (hashText(currentText) !== receipt.afterHash) throw new Error("optimizer config changed after the fix; refusing to overwrite user changes");
2286
+ const current = parsePersistedCacheOptimizerConfig(JSON.parse(currentText));
2287
+ if (!current) throw new Error("optimizer config is invalid; refusing to overwrite user changes");
2288
+ const key = `${receipt.provider}/${receipt.modelId}`;
2289
+ if (receipt.addedModelKey !== key) throw new Error("prompt-cache-key receipt identity does not match; refusing to change user config");
2290
+ if (receipt.targetHadModelKey) throw new Error("prompt-cache-key was already configured before this fix; refusing to remove user configuration");
2291
+ const omit = current.version === 2 ? current.promptCacheKey?.omit ?? [] : [];
2292
+ if (!omit.includes(receipt.addedModelKey)) throw new Error("prompt-cache-key opt-out is no longer present; refusing to change user config");
2293
+ const backupPath = configReceiptBackupPath(receipt, receiptPath);
2294
+ if (receipt.targetExistedBefore) {
2295
+ const backupInfo = await lstat(backupPath);
2296
+ if (backupInfo.isSymbolicLink() || !backupInfo.isFile()) throw new Error("config backup is not a regular file");
2297
+ const backupText = await readFile(backupPath, "utf8");
2298
+ if (hashText(backupText) !== receipt.beforeHash) throw new Error("config backup hash does not match the fix receipt");
2299
+ await atomicRestoreFileFromBackup(
2300
+ backupPath,
2301
+ configPath,
2302
+ backupInfo.mode & 0o7777,
2303
+ { backupHash: receipt.beforeHash, identity: currentInfo, hash: hashText(currentText), mode: currentInfo.mode & 0o7777 },
2304
+ );
2305
+ } else {
2306
+ if (current.footerMode || omit.length > 1) {
2307
+ const restored: PersistedCacheOptimizerConfigV2 = {
2308
+ version: 2,
2309
+ ...(current.footerMode ? { footerMode: current.footerMode } : {}),
2310
+ ...(omit.length > 1 ? { promptCacheKey: { omit: omit.filter((item) => item !== receipt.addedModelKey) } } : {}),
2311
+ };
2312
+ await atomicReplaceTextFilePreservingMode(
2313
+ configPath,
2314
+ JSON.stringify(restored, null, 2) + "\n",
2315
+ currentInfo.mode & 0o7777,
2316
+ "config-rollback",
2317
+ { identity: currentInfo, hash: hashText(currentText), mode: currentInfo.mode & 0o7777 },
2318
+ );
2319
+ } else {
2320
+ await validateAtomicTarget(configPath, { identity: currentInfo, hash: hashText(currentText), mode: currentInfo.mode & 0o7777 });
2321
+ await unlink(configPath);
2322
+ }
2323
+ }
2324
+ await writePromptCacheKeyConfigReceipt({ ...receipt, status: "rolled_back", rolledBackAt: Date.now() }, receiptPath);
2325
+ }
2326
+
2327
+ async function rollbackPromptCacheKeyConfig(
2328
+ receipt: PromptCacheKeyConfigReceipt,
2329
+ configPath: string = CONFIG_FILE_PATH,
2330
+ receiptPath: string = CONFIG_RECEIPT_PATH,
2331
+ ): Promise<void> {
2332
+ return withModelsJsonTransactionLock(() => rollbackPromptCacheKeyConfigUnderLock(receipt, configPath, receiptPath));
1674
2333
  }
1675
2334
 
1676
2335
  function resolveFooterStatsMode(
@@ -1689,7 +2348,8 @@ function footerStatsMode(
1689
2348
  return resolveFooterStatsMode(configuredMode, env).mode;
1690
2349
  }
1691
2350
 
1692
- let persistedFooterStatsMode = readPersistedFooterMode();
2351
+ let persistedCacheOptimizerConfig = readPersistedCacheOptimizerConfig();
2352
+ let persistedFooterStatsMode = persistedCacheOptimizerConfig.footerMode;
1693
2353
 
1694
2354
  function isDisabledEnv(value: string | undefined): boolean {
1695
2355
  if (!value) return false;
@@ -1722,6 +2382,7 @@ function getOptimizerRuntimeModeLines(): string[] {
1722
2382
  const lines: string[] = [];
1723
2383
  lines.push(`Runtime state: ${state}`);
1724
2384
  lines.push(`• Prompt rewrite: ${runtimeOptimizerEnabled && !isEnabledEnv(process.env[NO_PROMPT_REWRITE_ENV]) ? "on" : "off"}`);
2385
+ lines.push(`• Deterministic tool ordering: ${isToolOrderEnabled() ? "on (verified built-in payloads, opt-in)" : "off"}`);
1725
2386
  lines.push(`• OpenAI prompt_cache_key fallback: ${shouldInjectOpenAIPromptCacheKey() ? "on" : "off"}`);
1726
2387
  lines.push(`• Footer cache stats: on${runtimeOptimizerEnabled ? "" : " (comparison mode)"}`);
1727
2388
  lines.push(`• Compat warnings: ${runtimeOptimizerEnabled ? "on" : "off"}`);
@@ -1810,6 +2471,25 @@ function shouldInjectOpenAIPromptCacheKeyForModel(model: PiModel | undefined): b
1810
2471
  return isOpenAICompatibleApi(model?.api);
1811
2472
  }
1812
2473
 
2474
+ function isPromptCacheKeyOmittedForModel(model: PiModel | undefined, config: PersistedCacheOptimizerConfigV2 = persistedCacheOptimizerConfig): boolean {
2475
+ if (!model || !isOpenAICompatibleApi(model.api)) return false;
2476
+ return config.promptCacheKey?.omit?.includes(modelKey(model)) === true;
2477
+ }
2478
+
2479
+ function setPersistedCacheOptimizerConfig(config: PersistedCacheOptimizerConfigV2): void {
2480
+ persistedCacheOptimizerConfig = normalizePersistedCacheOptimizerConfig(config);
2481
+ persistedFooterStatsMode = persistedCacheOptimizerConfig.footerMode;
2482
+ }
2483
+
2484
+ function omitOpenAIPromptCacheKeys(payload: unknown): unknown | undefined {
2485
+ const record = asRecord(payload);
2486
+ if (!record || (!Object.prototype.hasOwnProperty.call(record, "prompt_cache_key") && !Object.prototype.hasOwnProperty.call(record, "promptCacheKey"))) return undefined;
2487
+ const copy = { ...record };
2488
+ delete copy.prompt_cache_key;
2489
+ delete copy.promptCacheKey;
2490
+ return copy;
2491
+ }
2492
+
1813
2493
  function collectAnthropicCacheControlsInWireOrder(payload: unknown): UnknownRecord[] {
1814
2494
  const record = asRecord(payload);
1815
2495
  if (!record) return [];
@@ -2558,6 +3238,76 @@ function modelFromAssistantMessage(message: unknown, fallback: PiModel | undefin
2558
3238
  } as PiModel;
2559
3239
  }
2560
3240
 
3241
+ const REQUEST_SNAPSHOT_COMPAT_KEYS: Array<keyof CacheCompat> = [
3242
+ "supportsStore",
3243
+ "supportsDeveloperRole",
3244
+ "supportsReasoningEffort",
3245
+ "supportsUsageInStreaming",
3246
+ "supportsStrictMode",
3247
+ "maxTokensField",
3248
+ "sendSessionAffinityHeaders",
3249
+ "sessionAffinityFormat",
3250
+ "supportsLongCacheRetention",
3251
+ "thinkingFormat",
3252
+ "requiresReasoningContentOnAssistantMessages",
3253
+ "cacheControlFormat",
3254
+ "forceAdaptiveThinking",
3255
+ "allowEmptySignature",
3256
+ ];
3257
+
3258
+ function snapshotCompatForDiagnostics(model: PiModel): CacheCompat {
3259
+ const source = getCompat(model);
3260
+ const snapshot: CacheCompat = {};
3261
+ const mutableSnapshot = snapshot as Record<string, unknown>;
3262
+ for (const key of REQUEST_SNAPSHOT_COMPAT_KEYS) {
3263
+ const value = source[key];
3264
+ if (value !== undefined) mutableSnapshot[key] = value;
3265
+ }
3266
+ return snapshot;
3267
+ }
3268
+
3269
+ function snapshotBaseUrlForDiagnostics(value: unknown): string {
3270
+ if (!isNonEmptyString(value)) return "";
3271
+ try {
3272
+ const url = new URL(value);
3273
+ // Request correlation only needs endpoint identity. Strip userinfo and
3274
+ // query/fragment material so a provider URL cannot carry credentials into
3275
+ // the process-local snapshot.
3276
+ url.username = "";
3277
+ url.password = "";
3278
+ url.search = "";
3279
+ url.hash = "";
3280
+ return url.toString();
3281
+ } catch {
3282
+ // Invalid endpoint strings are not useful for applicability checks. Keep
3283
+ // only a conservative path-free origin-like prefix without userinfo.
3284
+ return value.replace(new RegExp("//[^/?#\\s@]+@"), "//").split(/[?#]/, 1)[0];
3285
+ }
3286
+ }
3287
+
3288
+ function snapshotProviderRequestModel(model: PiModel | undefined): PiModel | undefined {
3289
+ if (!model) return undefined;
3290
+
3291
+ // The provider response hooks do not receive the model that initiated the
3292
+ // request (Pi's runner only forwards status/headers). Keep a process-local,
3293
+ // credential-blind metadata snapshot so a model switch or route change
3294
+ // between request and response cannot retarget a protocol observation. Do
3295
+ // not retain the caller's complete model object or any request data.
3296
+ return {
3297
+ provider: model.provider,
3298
+ id: model.id,
3299
+ name: model.name,
3300
+ api: model.api,
3301
+ baseUrl: snapshotBaseUrlForDiagnostics(model.baseUrl),
3302
+ compat: snapshotCompatForDiagnostics(model),
3303
+ reasoning: model.reasoning ?? false,
3304
+ input: model.input ?? ["text"],
3305
+ cost: model.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
3306
+ contextWindow: model.contextWindow ?? 0,
3307
+ maxTokens: model.maxTokens ?? 0,
3308
+ } as PiModel;
3309
+ }
3310
+
2561
3311
  function keyForModelExt(model: { provider: string; id: string }): string {
2562
3312
  return `${model.provider}/${model.id}`;
2563
3313
  }
@@ -2923,18 +3673,306 @@ function hasPromptCacheRetentionUnsupportedErrorMessage(message: unknown): boole
2923
3673
  hasPromptCacheRetentionUnsupportedText(record.errorMessage);
2924
3674
  }
2925
3675
 
2926
- type CompatAdvicePlacement = {
2927
- providerLabel?: string;
2928
- modelId?: string;
2929
- };
3676
+ function hasPromptCacheKeyUnsupportedText(value: unknown): boolean {
3677
+ const normalized = lower(value).replace(/["'`]/g, "").replace(/[_-]+/g, "_").replace(/\s+/g, " ").trim();
3678
+ const key = "(?:prompt ?cache ?key|prompt_cache_key)";
3679
+ if (!new RegExp(key).test(normalized)) return false;
2930
3680
 
2931
- function buildProviderCompatOverride(providerLabel: string, compat: Record<string, unknown>): Record<string, unknown> {
2932
- return {
2933
- providers: {
2934
- [providerLabel]: {
2935
- compat,
2936
- },
2937
- },
3681
+ // Only classify a field-level capability rejection. Value validation,
3682
+ // conditional restrictions, and generic invalid-parameter messages are not
3683
+ // proof that the endpoint lacks support for the field.
3684
+ return [
3685
+ new RegExp(`unsupported (?:parameter|field|input|argument)[: ]+.*${key}`),
3686
+ new RegExp(`unknown (?:parameter|field|input|argument)[: ]+.*${key}`),
3687
+ new RegExp(`unrecognized (?:parameter|field|input|argument)[: ]+.*${key}`),
3688
+ new RegExp(`(?:parameter|field|input|argument)[: ]+${key} (?:is )?(?:unsupported|unknown|unrecognized|not supported|not allowed|not permitted)`),
3689
+ new RegExp(`${key} (?:is )?(?:unsupported|unknown|unrecognized|not supported|not allowed|not permitted)`),
3690
+ new RegExp(`extra inputs?(?: are)? (?:not permitted|not allowed)[: ]+.*${key}`),
3691
+ new RegExp(`(?:not supported|not permitted|not allowed)[: ]+.*${key}`),
3692
+ ].some((pattern) => pattern.test(normalized));
3693
+ }
3694
+
3695
+ function hasPromptCacheKeyUnsupportedSignal(headers: Record<string, string> | undefined): boolean {
3696
+ if (!headers) return false;
3697
+ return Object.entries(headers).some(([key, value]) => hasPromptCacheKeyUnsupportedText(`${key}: ${value}`));
3698
+ }
3699
+
3700
+ function hasPromptCacheKeyUnsupportedErrorMessage(message: unknown): boolean {
3701
+ const record = getAssistantRecord(message);
3702
+ return record?.stopReason === "error" &&
3703
+ getOptionalAssistantHttpStatus(record) === 400 &&
3704
+ hasPromptCacheKeyUnsupportedText(record.errorMessage);
3705
+ }
3706
+
3707
+ function isPromptCacheKeyUnsupportedApplicable(model: PiModel): boolean {
3708
+ return isOpenAICompatibleApi(model.api);
3709
+ }
3710
+
3711
+ function hasReasoningProtocolRejectionText(value: unknown): boolean {
3712
+ const normalized = lower(value).replace(/\s+/g, " ").trim();
3713
+ if (!normalized) return false;
3714
+
3715
+ // Match only a rejection that is attached to the `thinking` parameter. Do
3716
+ // not merely look for both parameter names: `reasoning_effort is not
3717
+ // supported; use thinking` is the opposite direction and must not activate
3718
+ // this fallback.
3719
+ const thinkingParameterRejectionPatterns = [
3720
+ /(?:^|[^a-z0-9_])["'`]?thinking["'`]?(?:\s+(?:parameter|field|argument))?\s*(?:is\s+)?(?:not supported|unsupported|unknown|unrecognized|not allowed|not permitted|rejected|invalid|not valid|not accepted|disallowed|not a valid(?:\s+(?:parameter|field|argument))?)(?![a-z0-9_])/,
3721
+ /(?:^|[^a-z0-9_])(?:unsupported|unknown|unrecognized|invalid|disallowed|rejected|not\s+(?:a\s+)?valid|not\s+accepted|not\s+allowed|not\s+permitted)(?:[_ ](?:parameter|field|argument))?\s*[:=]?\s*["'`]?thinking["'`]?(?![a-z0-9_])/,
3722
+ /(?:^|[^a-z0-9_])(?:extra\s+inputs?|additional\s+(?:inputs?|parameters?))\s+(?:are\s+)?(?:not permitted|not allowed|unsupported)\s*[:=]?\s*["'`]?thinking["'`]?(?![a-z0-9_])/,
3723
+ ];
3724
+ let rejectionEnd = -1;
3725
+ for (const pattern of thinkingParameterRejectionPatterns) {
3726
+ const match = pattern.exec(normalized);
3727
+ if (match && match.index + match[0].length > rejectionEnd) {
3728
+ rejectionEnd = match.index + match[0].length;
3729
+ }
3730
+ }
3731
+ if (rejectionEnd < 0) return false;
3732
+
3733
+ // The provider must direct the caller to reasoning_effort after the
3734
+ // rejection. Limiting this to the short suffix after the matched rejection
3735
+ // keeps generic documentation or an unrelated earlier sentence from being
3736
+ // treated as runtime protocol evidence.
3737
+ const recommendation = normalized.slice(rejectionEnd, rejectionEnd + 260);
3738
+ if (!/\breasoning[_\.]effort\b/.test(recommendation)) return false;
3739
+
3740
+ const recommendationClauses = recommendation.split(/[.;!?\n]/);
3741
+ return recommendationClauses.some((clause) => {
3742
+ if (!/\breasoning[_\.]effort\b/.test(clause)) return false;
3743
+ // A target mentioned inside a negated/disabled clause is not positive
3744
+ // protocol guidance, even if words such as `must` or `supported` occur.
3745
+ if (
3746
+ /\b(?:do\s+not|don't|never|avoid)\s+(?:use|set|send|pass|provide)?\s*["'`]?reasoning[_\.]effort\b/.test(clause) ||
3747
+ /\breasoning[_\.]effort\b[^.;]{0,80}\b(?:must|should|may|do)\s+(?:not|never)\b/.test(clause) ||
3748
+ /\breasoning[_\.]effort\b[^.;]{0,80}\b(?:unsupported|disabled|unavailable|not\s+(?:supported|accepted|allowed|available|enabled|required|recommended|expected))\b/.test(clause)
3749
+ ) return false;
3750
+
3751
+ return [
3752
+ /(?:use|try|set|send|pass|provide)\s+(?:the\s+)?(?:top[- ]level\s+)?["'`]?reasoning[_\.]effort["'`]?(?![a-z0-9_])/,
3753
+ /(?:reasoning[_\.]effort)\b[^.;]{0,120}(?:instead|required|must\s+be|should\s+be|is\s+(?:supported|accepted|preferred|recommended|expected))\b/,
3754
+ /(?:parameter|field|option)\s+(?:is|should be)\s+["'`]?reasoning[_\.]effort["'`]?(?![a-z0-9_])/,
3755
+ /instead[^.;]{0,120}(?:use|try|set|send|pass|provide)\s+(?:the\s+)?["'`]?reasoning[_\.]effort["'`]?(?![a-z0-9_])/,
3756
+ /(?:replace|change|switch)\s+(?:the\s+)?["'`]?thinking["'`]?\s+(?:with|to)\s+["'`]?reasoning[_\.]effort["'`]?(?![a-z0-9_])/,
3757
+ ].some((pattern) => pattern.test(clause));
3758
+ });
3759
+ }
3760
+
3761
+ function hasReasoningProtocolRejectionSignal(headers: Record<string, string> | undefined): boolean {
3762
+ if (!headers) return false;
3763
+ // Each response header is one diagnostic unit. Joining all values can pair a
3764
+ // rejection from one header with unrelated documentation in another.
3765
+ return Object.entries(headers).some(([key, headerValue]) =>
3766
+ hasReasoningProtocolRejectionText(`${key}: ${headerValue}`)
3767
+ );
3768
+ }
3769
+
3770
+ function getOptionalAssistantHttpStatus(record: UnknownRecord): number | undefined {
3771
+ const readStatus = (value: unknown): number | undefined => {
3772
+ if (typeof value === "number" && Number.isInteger(value) && value >= 100 && value <= 599) return value;
3773
+ if (typeof value === "string" && /^\d{3}$/.test(value.trim())) {
3774
+ const parsed = Number(value.trim());
3775
+ return parsed >= 100 && parsed <= 599 ? parsed : undefined;
3776
+ }
3777
+ return undefined;
3778
+ };
3779
+
3780
+ for (const key of ["status", "statusCode", "httpStatus", "httpStatusCode"]) {
3781
+ const status = readStatus(record[key]);
3782
+ if (status !== undefined) return status;
3783
+ }
3784
+
3785
+ // Foreign/provider adapters sometimes put the HTTP status under a
3786
+ // diagnostic `details`, `error`, or `cause` object rather than exposing it
3787
+ // on the finalized assistant message itself. Traverse only these known
3788
+ // diagnostic wrappers, with a small depth bound; never inspect arbitrary
3789
+ // payload/message fields for a number that happens to look like a status.
3790
+ const scanDiagnosticStatus = (value: unknown, depth: number): number | undefined => {
3791
+ if (depth > 2) return undefined;
3792
+ if (Array.isArray(value)) {
3793
+ for (const item of value) {
3794
+ const status = scanDiagnosticStatus(item, depth + 1);
3795
+ if (status !== undefined) return status;
3796
+ }
3797
+ return undefined;
3798
+ }
3799
+ const source = asRecord(value);
3800
+ if (!source) return undefined;
3801
+ for (const key of ["status", "statusCode", "httpStatus", "httpStatusCode", "code"]) {
3802
+ const status = readStatus(source[key]);
3803
+ if (status !== undefined) return status;
3804
+ }
3805
+ for (const key of ["details", "error", "cause", "diagnostics"]) {
3806
+ const status = scanDiagnosticStatus(source[key], depth + 1);
3807
+ if (status !== undefined) return status;
3808
+ }
3809
+ return undefined;
3810
+ };
3811
+ for (const key of ["diagnostics", "diagnostic", "details", "error", "cause"]) {
3812
+ const status = scanDiagnosticStatus(record[key], 0);
3813
+ if (status !== undefined) return status;
3814
+ }
3815
+
3816
+ // Pi's built-in OpenAI-compatible adapters normally prefix a provider error
3817
+ // body with the HTTP status (for example `400: {...}` or
3818
+ // `OpenAI API error (400): ...`). Only parse status-shaped prefixes; never
3819
+ // search arbitrary body text where a model id or payload number could be
3820
+ // mistaken for a response status.
3821
+ if (typeof record.errorMessage === "string") {
3822
+ const errorText = record.errorMessage;
3823
+ const matches = [
3824
+ /^\s*([1-5]\d{2})\b/,
3825
+ /^\s*(?:http(?:\s+status)?|status(?:\s+code)?)\s*[:(]?\s*([1-5]\d{2})\b/i,
3826
+ /^\s*[^():\n]{1,100}\(\s*([1-5]\d{2})\b/,
3827
+ /^\s*(?:error|api\s+error|provider\s+error|request\s+failed|http\s+error)\s*[:(]\s*([1-5]\d{2})\b/i,
3828
+ ];
3829
+ for (const pattern of matches) {
3830
+ const match = pattern.exec(errorText);
3831
+ if (match) return Number(match[1]);
3832
+ }
3833
+ }
3834
+ return undefined;
3835
+ }
3836
+
3837
+ function hasReasoningProtocolRejectionErrorMessage(message: unknown): boolean {
3838
+ const record = getAssistantRecord(message);
3839
+ if (!record || record.stopReason !== "error") return false;
3840
+ // Finalized assistant messages do not expose a separate status field in the
3841
+ // normal Pi path, so recover it only from structured diagnostics or the
3842
+ // adapter's status-shaped error prefix. A text-only 400-looking parameter
3843
+ // message is not enough evidence for a protocol repair.
3844
+ return getOptionalAssistantHttpStatus(record) === 400 &&
3845
+ hasReasoningProtocolRejectionText(record.errorMessage);
3846
+ }
3847
+
3848
+ function isReasoningProtocolRejectionSignalApplicable(model: PiModel | undefined): boolean {
3849
+ // A provider error can teach us which protocol it expects, so this gate is
3850
+ // intentionally broader than the explicit DeepSeek-format diagnostic gate.
3851
+ // The model family identifies the affected cache/compat bucket; the error
3852
+ // text, not the model name, supplies the wire-protocol evidence.
3853
+ return !!model && isOpenAICompatibleProxyApi(model.api) && isDeepSeekLikeModel(model);
3854
+ }
3855
+
3856
+ function isReasoningProtocolRejectionForModel(message: unknown, model: PiModel | undefined): boolean {
3857
+ if (!model || !isReasoningProtocolRejectionSignalApplicable(model)) return false;
3858
+ if (!hasReasoningProtocolRejectionErrorMessage(message)) return false;
3859
+ const record = getAssistantRecord(message);
3860
+ if (!record) return false;
3861
+ const messageProvider = firstNonEmptyString(record.provider);
3862
+ const messageModel = firstNonEmptyString(record.responseModel, record.model);
3863
+ // An explicit identity in the finalized assistant error is authoritative.
3864
+ // Do not broaden a model-scoped observation to another DeepSeek-named model.
3865
+ return (!messageProvider || messageProvider === model.provider) &&
3866
+ (!messageModel || messageModel === model.id);
3867
+ }
3868
+
3869
+ async function notifyReasoningProtocolObservation(
3870
+ model: PiModel,
3871
+ ctx: Pick<ExtensionContext, "ui">,
3872
+ rejectedModelKeys: Set<string>,
3873
+ warnedModelKeys: Set<string>,
3874
+ ): Promise<void> {
3875
+ const key = modelKey(model);
3876
+ rejectedModelKeys.add(key);
3877
+ if (warnedModelKeys.has(key)) return;
3878
+ warnedModelKeys.add(key);
3879
+
3880
+ const receipt = await readModelsJsonFixReceipt();
3881
+ const matchingReceipt = isActionableModelsJsonFixReceipt(receipt) &&
3882
+ receipt.provider === model.provider &&
3883
+ receipt.modelId === model.id;
3884
+ const recovery = matchingReceipt
3885
+ ? "A matching confirmed fix receipt exists; run /cache-optimizer rollback to undo it safely."
3886
+ : "Run /cache-optimizer fix to review a model-scoped repair.";
3887
+ ctx.ui.notify(
3888
+ `⚠️ ${LOG_PREFIX}: ${key} rejected the configured reasoning format. ${recovery} ` +
3889
+ "No configuration was changed automatically.",
3890
+ "warning",
3891
+ );
3892
+ }
3893
+
3894
+ function fixSuggestionIdentity(model: PiModel): { providerLabel: string; modelId: string } {
3895
+ const key = modelKey(model);
3896
+ const slashIdx = key.indexOf("/");
3897
+ return {
3898
+ providerLabel: slashIdx > 0 ? key.slice(0, slashIdx) : key,
3899
+ modelId: model.id,
3900
+ };
3901
+ }
3902
+
3903
+ function mergeFixSuggestions(...suggestions: Array<FixSuggestion | undefined>): FixSuggestion | undefined {
3904
+ const present = suggestions.filter((suggestion): suggestion is FixSuggestion => suggestion !== undefined);
3905
+ if (present.length === 0) return undefined;
3906
+ const first = present[0];
3907
+ return {
3908
+ providerLabel: first.providerLabel,
3909
+ modelId: first.modelId,
3910
+ compatKeys: Object.assign({}, ...present.map((suggestion) => suggestion.compatKeys)),
3911
+ forceModelLevel: present.some((suggestion) => suggestion.forceModelLevel === true),
3912
+ };
3913
+ }
3914
+
3915
+ function buildReasoningProtocolFixSuggestion(
3916
+ model: PiModel,
3917
+ protocolRejectionObserved = false,
3918
+ ): FixSuggestion | undefined {
3919
+ if (!protocolRejectionObserved) return undefined;
3920
+ if (!isDeepSeekLikeModel(model) || !isOpenAICompatibleProxyApi(model.api)) return undefined;
3921
+
3922
+ const compat = getCompat(model);
3923
+ // Explicit provider-specific formats are user/provider evidence in their own
3924
+ // right. Do not replace qwen/openrouter/together/etc. merely because a
3925
+ // generic error happened to mention both reasoning parameter names. The
3926
+ // evidence-driven repair is limited to the old DeepSeek `thinking` format,
3927
+ // an explicit standard OpenAI format, or an absent format that needs to be
3928
+ // made unambiguous after the provider's rejection.
3929
+ if (
3930
+ compat.thinkingFormat !== undefined &&
3931
+ compat.thinkingFormat !== "deepseek" &&
3932
+ compat.thinkingFormat !== "openai"
3933
+ ) {
3934
+ return undefined;
3935
+ }
3936
+
3937
+ const identity = fixSuggestionIdentity(model);
3938
+ const compatKeys: Record<string, unknown> = {};
3939
+ if (compat.thinkingFormat !== "openai") {
3940
+ compatKeys.thinkingFormat = "openai";
3941
+ }
3942
+ if (compat.supportsReasoningEffort !== true) {
3943
+ compatKeys.supportsReasoningEffort = true;
3944
+ }
3945
+
3946
+ // Replay behavior is changed only when the current effective configuration
3947
+ // actually enabled it. Never invent a false value merely because the model
3948
+ // family is named DeepSeek or because a provider supports reasoning effort.
3949
+ if (compat.requiresReasoningContentOnAssistantMessages === true) {
3950
+ compatKeys.requiresReasoningContentOnAssistantMessages = false;
3951
+ }
3952
+ if (Object.keys(compatKeys).length === 0) return undefined;
3953
+
3954
+ return {
3955
+ ...identity,
3956
+ // Protocol observations are always model-scoped. A provider may expose
3957
+ // DeepSeek-named models with different wire formats, so sibling models
3958
+ // must never inherit this evidence-driven change.
3959
+ compatKeys,
3960
+ forceModelLevel: true,
3961
+ };
3962
+ }
3963
+
3964
+ type CompatAdvicePlacement = {
3965
+ providerLabel?: string;
3966
+ modelId?: string;
3967
+ };
3968
+
3969
+ function buildProviderCompatOverride(providerLabel: string, compat: Record<string, unknown>): Record<string, unknown> {
3970
+ return {
3971
+ providers: {
3972
+ [providerLabel]: {
3973
+ compat,
3974
+ },
3975
+ },
2938
3976
  };
2939
3977
  }
2940
3978
 
@@ -3021,7 +4059,7 @@ function buildOpenAIProxyCompatWarningText(key: string, missing: string[]): stri
3021
4059
 
3022
4060
  const modelsJsonPath = getModelsJsonDisplayPath();
3023
4061
  const lines: string[] = [
3024
- `💡 pi-cache-optimizer: ${key} is a third-party GPT/OpenAI-compatible proxy but merged compat lacks ${missing.join(" and ")}.`,
4062
+ `💡 pi-cache-optimizer: ${key} is a third-party OpenAI-compatible proxy but merged compat lacks ${missing.join(" and ")}.`,
3025
4063
  `Edit ${modelsJsonPath} -> providers["${providerLabel}"] -> compat (at the same level as baseUrl/api/apiKey/models).`,
3026
4064
  ``,
3027
4065
  ];
@@ -3031,80 +4069,82 @@ function buildOpenAIProxyCompatWarningText(key: string, missing: string[]): stri
3031
4069
  return lines.join("\n");
3032
4070
  }
3033
4071
 
4072
+ /**
4073
+ * DeepSeek's model family and its reasoning wire protocol are separate facts.
4074
+ * The family name selects the cache adapter, but only an explicit effective
4075
+ * `thinkingFormat: "deepseek"` opts a third-party OpenAI Completions model into
4076
+ * the reasoning/replay compat checks below. Model names, provider ids, URLs,
4077
+ * and supportsReasoningEffort are not protocol evidence.
4078
+ */
4079
+ function isDeepSeekWireCompatApplicable(model: PiModel): boolean {
4080
+ // `thinkingFormat` is the only wire-protocol signal. The model family is
4081
+ // still name/id based for adapter selection, while provider, URL, and
4082
+ // supportsReasoningEffort remain deliberately irrelevant here.
4083
+ return isDeepSeekLikeModel(model)
4084
+ && isOpenAICompatibleProxyApi(model.api)
4085
+ && getCompat(model).thinkingFormat === "deepseek";
4086
+ }
4087
+
3034
4088
  function describeMissingDeepSeekCompat(model: PiModel): string[] {
4089
+ if (!isDeepSeekWireCompatApplicable(model)) return [];
4090
+
3035
4091
  const compat = getCompat(model);
3036
4092
  const missing: string[] = [];
3037
-
3038
- if (compat.supportsLongCacheRetention !== true) {
3039
- missing.push("supportsLongCacheRetention");
3040
- }
3041
- if (model.api !== "openai-responses" && compat.sendSessionAffinityHeaders !== true) {
3042
- missing.push("sendSessionAffinityHeaders");
3043
- }
3044
4093
  if (compat.requiresReasoningContentOnAssistantMessages !== true) {
3045
4094
  missing.push("requiresReasoningContentOnAssistantMessages");
3046
4095
  }
3047
- if (compat.thinkingFormat !== "deepseek") {
3048
- missing.push("thinkingFormat");
3049
- }
3050
-
3051
4096
  return missing;
3052
4097
  }
3053
4098
 
4099
+ /**
4100
+ * Compatibility diagnostics for a DeepSeek-like model are generic unless the
4101
+ * user has explicitly selected Pi's DeepSeek reasoning wire format. In
4102
+ * particular, never manufacture `thinkingFormat: "deepseek"`: doing so can
4103
+ * turn a valid reasoning_effort request into a provider-rejected `thinking`
4104
+ * request (Issue #10).
4105
+ */
3054
4106
  function isDeepSeekCompatCheckApplicable(model: PiModel): boolean {
3055
- return isDeepSeekLikeModel(model)
3056
- && isOpenAICompatibleApi(model.api)
3057
- && isKnownThirdPartyOpenAIEndpoint(model)
3058
- && !isPiBuiltInLlamaCppModel(model);
4107
+ return isDeepSeekWireCompatApplicable(model);
4108
+ }
4109
+
4110
+ function hasExplicitDeepSeekReasoningProtocol(model: PiModel): boolean {
4111
+ return isDeepSeekWireCompatApplicable(model);
3059
4112
  }
3060
4113
 
3061
4114
  function describeMissingCacheCompatForModel(model: PiModel): string[] {
3062
4115
  if (isAdaptiveThinkingCompatApplicable(model)) {
3063
4116
  return describeMissingAdaptiveThinkingCompat(model);
3064
4117
  }
3065
- if (isDeepSeekCompatCheckApplicable(model)) {
3066
- return describeMissingDeepSeekCompat(model);
4118
+
4119
+ const missing = describeMissingOpenAICompatibleProxyCompat(model);
4120
+ if (isDeepSeekWireCompatApplicable(model)) {
4121
+ missing.push(...describeMissingDeepSeekCompat(model));
3067
4122
  }
3068
- return describeMissingOpenAICompatibleProxyCompat(model);
4123
+ return missing;
3069
4124
  }
3070
4125
 
3071
4126
  function buildDeepSeekCompatSuggestion(missing: string[]): Record<string, unknown> {
3072
- const suggestion: Record<string, unknown> = {};
3073
-
3074
- if (missing.includes("supportsLongCacheRetention")) {
3075
- suggestion.supportsLongCacheRetention = true;
3076
- }
3077
- if (missing.includes("sendSessionAffinityHeaders")) {
3078
- suggestion.sendSessionAffinityHeaders = true;
3079
- }
4127
+ const suggestion: Record<string, unknown> = {
4128
+ ...buildSafeOpenAIProxyCompatSuggestion(missing),
4129
+ };
3080
4130
  if (missing.includes("requiresReasoningContentOnAssistantMessages")) {
3081
4131
  suggestion.requiresReasoningContentOnAssistantMessages = true;
3082
4132
  }
3083
- if (missing.includes("thinkingFormat")) {
3084
- suggestion.thinkingFormat = "deepseek";
3085
- }
3086
-
3087
4133
  return suggestion;
3088
4134
  }
3089
4135
 
3090
4136
  function appendDeepSeekCompatAdviceLines(lines: string[], missing: string[], placement: CompatAdvicePlacement = {}): void {
3091
4137
  const suggestion = buildDeepSeekCompatSuggestion(missing);
3092
4138
  if (Object.keys(suggestion).length > 0) {
3093
- lines.push("Recommended DeepSeek compat snippet:");
4139
+ lines.push("Recommended DeepSeek reasoning/replay compat snippet:");
3094
4140
  lines.push(JSON.stringify(suggestion, null, 2));
3095
4141
  }
3096
4142
 
3097
4143
  if (missing.includes("requiresReasoningContentOnAssistantMessages")) {
3098
- lines.push('- requiresReasoningContentOnAssistantMessages: true keeps replayed assistant turns compatible with DeepSeek reasoning_content requirements.');
3099
- }
3100
- if (missing.includes("thinkingFormat")) {
3101
- lines.push('- thinkingFormat: "deepseek" tells Pi to use DeepSeek reasoning/thinking parameter format.');
4144
+ lines.push('- requiresReasoningContentOnAssistantMessages: true keeps replayed assistant turns compatible with an explicitly selected DeepSeek reasoning wire format.');
3102
4145
  }
3103
4146
  if (missing.includes("sendSessionAffinityHeaders")) {
3104
- lines.push("- sendSessionAffinityHeaders: recommended for OpenAI-compatible DeepSeek proxies when supported; it helps keep one Pi session on the same upstream/backend.");
3105
- }
3106
- if (missing.includes("supportsLongCacheRetention")) {
3107
- lines.push("- supportsLongCacheRetention: enable for DeepSeek-compatible endpoints that support long cache retention.");
4147
+ lines.push("- sendSessionAffinityHeaders: recommended for third-party OpenAI-compatible proxies when supported; it helps keep one Pi session on the same upstream/backend.");
3108
4148
  }
3109
4149
 
3110
4150
  appendCredentialSafeProviderGuidance(lines, placement, suggestion);
@@ -3139,13 +4179,13 @@ const CACHE_PROVIDER_ADAPTERS: CacheProviderAdapter[] = [
3139
4179
  return normalizeWithFallback(message, getDeepSeekRawUsage, { allowInputOnlyPiUsage: true });
3140
4180
  },
3141
4181
  warningText(model) {
3142
- if (!isDeepSeekCompatCheckApplicable(model)) return undefined;
3143
-
3144
- const missing = describeMissingDeepSeekCompat(model);
4182
+ const missing = describeMissingCacheCompatForModel(model);
3145
4183
  if (missing.length === 0) return undefined;
3146
4184
 
3147
4185
  const key = modelKey(model);
3148
- return buildDeepSeekCompatWarningText(key, missing);
4186
+ return isDeepSeekWireCompatApplicable(model)
4187
+ ? buildDeepSeekCompatWarningText(key, missing)
4188
+ : buildOpenAIProxyCompatWarningText(key, missing);
3149
4189
  },
3150
4190
  },
3151
4191
  {
@@ -4109,6 +5149,10 @@ function notifyCacheCompatIfNeeded(
4109
5149
  ): void {
4110
5150
  if (!model) return;
4111
5151
 
5152
+ // A protocol rejection is retained separately from ordinary compat warnings;
5153
+ // it is surfaced once by the response hook and can be used by a later fix.
5154
+ // Do not turn it into an automatic write here.
5155
+
4112
5156
  // Native anthropic-messages adaptive thinking compat check.
4113
5157
  // Adapter warningText only fires for OpenAI-compatible APIs, so native
4114
5158
  // Anthropic and Kimi Coding adaptive models need a separate check.
@@ -5484,7 +6528,7 @@ function getCompatCheckNotApplicableLines(model: PiModel): string[] {
5484
6528
  return ["ℹ️ Compat check not applicable for this model."];
5485
6529
  }
5486
6530
 
5487
- function buildDoctorDiagnosis(model: PiModel, options: { promptCacheRetention400?: boolean; anthropicTtlOrderError?: boolean; sessionAffinity403?: boolean; openAISdkHeader403?: boolean } = {}): string {
6531
+ function buildDoctorDiagnosis(model: PiModel, options: { promptCacheRetention400?: boolean; promptCacheKey400?: boolean; anthropicTtlOrderError?: boolean; sessionAffinity403?: boolean; openAISdkHeader403?: boolean } = {}): string {
5488
6532
  const lines: string[] = [];
5489
6533
  lines.push(`Provider: ${model.provider}`);
5490
6534
  lines.push(`Model: ${model.id}`);
@@ -5498,7 +6542,7 @@ function buildDoctorDiagnosis(model: PiModel, options: { promptCacheRetention400
5498
6542
  const adaptiveThinkingApplicable = isAdaptiveThinkingCompatApplicable(model);
5499
6543
  const deepSeekCompatApplicable = isDeepSeekCompatCheckApplicable(model);
5500
6544
  const missing = describeMissingCacheCompatForModel(model);
5501
- const optionalOpenAIProxyCompat = (!adaptiveThinkingApplicable && !deepSeekCompatApplicable)
6545
+ const optionalOpenAIProxyCompat = !adaptiveThinkingApplicable
5502
6546
  ? describeOptionalOpenAICompatibleProxyCompat(model)
5503
6547
  : [];
5504
6548
  const fixSug = buildFixSuggestion(model);
@@ -5522,6 +6566,7 @@ function buildDoctorDiagnosis(model: PiModel, options: { promptCacheRetention400
5522
6566
  appendAdaptiveThinkingCompatAdviceLines(lines, missing, { providerLabel, modelId: model.id });
5523
6567
  } else if (deepSeekCompatApplicable) {
5524
6568
  appendDeepSeekCompatAdviceLines(lines, missing, { providerLabel, modelId: model.id });
6569
+ appendOptionalOpenAIProxyCompatAdviceLines(lines, optionalOpenAIProxyCompat);
5525
6570
  } else {
5526
6571
  appendOpenAIProxyCompatAdviceLines(lines, missing, { providerLabel, modelId: model.id });
5527
6572
  appendOptionalOpenAIProxyCompatAdviceLines(lines, optionalOpenAIProxyCompat);
@@ -5533,6 +6578,13 @@ function buildDoctorDiagnosis(model: PiModel, options: { promptCacheRetention400
5533
6578
  lines.push(...getCompatCheckNotApplicableLines(model));
5534
6579
  }
5535
6580
 
6581
+ if (options.promptCacheKey400 && isPromptCacheKeyUnsupportedApplicable(model)) {
6582
+ lines.push("");
6583
+ lines.push("⚠️ This model previously rejected prompt_cache_key with an explicit HTTP 400 signal.");
6584
+ lines.push(" /cache-optimizer fix will offer a precise model-scoped opt-out.");
6585
+ lines.push(" The opt-out removes both prompt_cache_key and promptCacheKey from the final request body.");
6586
+ }
6587
+
5536
6588
  if (isPromptCacheRetention400Applicable(model)) {
5537
6589
  lines.push("");
5538
6590
  if (options.promptCacheRetention400) {
@@ -5732,7 +6784,7 @@ function buildCompatDiagnosis(model: PiModel): string | undefined {
5732
6784
  const advisoryMissingC = missing.filter(m => !safeFixableMissingC.includes(m));
5733
6785
  const adaptiveThinkingApplicable = isAdaptiveThinkingCompatApplicable(model);
5734
6786
  const deepSeekCompatApplicable = isDeepSeekCompatCheckApplicable(model);
5735
- const optionalOpenAIProxyCompat = (!adaptiveThinkingApplicable && !deepSeekCompatApplicable)
6787
+ const optionalOpenAIProxyCompat = !adaptiveThinkingApplicable
5736
6788
  ? describeOptionalOpenAICompatibleProxyCompat(model)
5737
6789
  : [];
5738
6790
  const routerNotes = describeRouterChannelDiagnostics(model);
@@ -5760,6 +6812,7 @@ function buildCompatDiagnosis(model: PiModel): string | undefined {
5760
6812
  appendAdaptiveThinkingCompatAdviceLines(lines, missing, { providerLabel, modelId: model.id });
5761
6813
  } else if (deepSeekCompatApplicable) {
5762
6814
  appendDeepSeekCompatAdviceLines(lines, missing, { providerLabel, modelId: model.id });
6815
+ appendOptionalOpenAIProxyCompatAdviceLines(lines, optionalOpenAIProxyCompat);
5763
6816
  } else {
5764
6817
  appendOpenAIProxyCompatAdviceLines(lines, missing, { providerLabel, modelId: model.id });
5765
6818
  appendOptionalOpenAIProxyCompatAdviceLines(lines, optionalOpenAIProxyCompat);
@@ -5912,19 +6965,25 @@ function skipJsonValue(text: string, pos: number): number | undefined {
5912
6965
  /**
5913
6966
  * Find a top-level key in the object whose `{` is at `openBracePos`.
5914
6967
  * Only direct children are considered (nested values are skipped whole).
5915
- * Returns the key's opening-quote offset and its value's start offset,
5916
- * or undefined when the key is absent or the object is malformed.
6968
+ * When duplicate keys exist, return the last one so scanning follows
6969
+ * JSON.parse/Pi's effective last-definition-wins behavior. `count` lets
6970
+ * callers refuse an ambiguous surgical edit rather than silently selecting a
6971
+ * user-added duplicate. Return undefined when the key is absent or the object
6972
+ * is malformed.
5917
6973
  */
5918
6974
  function findJsonObjectKey(
5919
6975
  text: string,
5920
6976
  openBracePos: number,
5921
6977
  targetKey: string,
5922
- ): { keyStart: number; valueStart: number } | undefined {
6978
+ ): { keyStart: number; valueStart: number; count: number } | undefined {
5923
6979
  if (text[openBracePos] !== "{") return undefined;
5924
6980
  let i = openBracePos + 1;
6981
+ let found: { keyStart: number; valueStart: number; count: number } | undefined;
6982
+ let count = 0;
5925
6983
  while (i < text.length) {
5926
6984
  i = skipJsonWhitespace(text, i);
5927
- if (i >= text.length || text[i] === "}") return undefined;
6985
+ if (i >= text.length) return undefined;
6986
+ if (text[i] === "}") return found;
5928
6987
  if (text[i] === ",") {
5929
6988
  i++;
5930
6989
  continue;
@@ -5936,7 +6995,10 @@ function findJsonObjectKey(
5936
6995
  i = skipJsonWhitespace(text, key.end);
5937
6996
  if (text[i] !== ":") return undefined;
5938
6997
  i = skipJsonWhitespace(text, i + 1);
5939
- if (key.value === targetKey) return { keyStart, valueStart: i };
6998
+ if (key.value === targetKey) {
6999
+ count++;
7000
+ found = { keyStart, valueStart: i, count };
7001
+ }
5940
7002
  const after = skipJsonValue(text, i);
5941
7003
  if (after === undefined) return undefined;
5942
7004
  i = after;
@@ -6168,6 +7230,8 @@ function hasExplicitLongRetentionOptInFromConfig(
6168
7230
  * Returns the byte offsets for surgical insertion, or undefined if ambiguous.
6169
7231
  */
6170
7232
  interface ModelNodeLocation {
7233
+ /** Number of exact provider keys found in the root providers object. */
7234
+ providerKeyCount: number;
6171
7235
  /** Offset of the model object's opening `{` */
6172
7236
  modelObjectBrace: number;
6173
7237
  /** Offset of the model object's closing `}` */
@@ -6196,11 +7260,21 @@ interface ModelNodeLocation {
6196
7260
  modelOverrideCompatBrace: number;
6197
7261
  /** Offset of the target override compat object's closing `}`, or -1 if absent */
6198
7262
  modelOverrideCompatEnd: number;
7263
+ /** Number of exact modelOverrides keys for this model id. */
7264
+ modelOverrideKeyCount: number;
7265
+ /** Number of exact compat keys in the selected model object. */
7266
+ modelCompatKeyCount: number;
7267
+ /** Number of exact provider compat keys. */
7268
+ providerCompatKeyCount: number;
6199
7269
  /** All model ids found in this provider's models array (for placement safety analysis) */
6200
7270
  allModelIds: string[];
6201
7271
  }
6202
7272
 
6203
7273
  interface ModelOverrideNodeLocation {
7274
+ providerKeyCount: number;
7275
+ modelOverridesKeyCount: number;
7276
+ modelOverrideKeyCount: number;
7277
+ modelOverrideCompatKeyCount: number;
6204
7278
  providerObjectBrace: number;
6205
7279
  providerObjectEnd: number;
6206
7280
  modelOverridesObjectBrace: number;
@@ -6221,17 +7295,21 @@ function locateModelOverrideInJsonc(
6221
7295
  const rootBrace = skipJsonWhitespace(clean, 0);
6222
7296
  if (clean[rootBrace] !== "{") return undefined;
6223
7297
  const providersKey = findJsonObjectKey(clean, rootBrace, "providers");
6224
- if (!providersKey) return undefined;
7298
+ if (!providersKey || providersKey.count !== 1) return undefined;
6225
7299
  const providersBrace = skipJsonWhitespace(clean, providersKey.valueStart);
6226
7300
  if (clean[providersBrace] !== "{") return undefined;
6227
7301
  const providersEnd = findMatchingBracket(clean, providersBrace);
6228
7302
  if (providersEnd === undefined) return undefined;
6229
7303
  const providerKey = findJsonObjectKey(clean, providersBrace, providerLabel);
6230
- if (!providerKey || providerKey.keyStart > providersEnd) return undefined;
7304
+ if (!providerKey || providerKey.count !== 1 || providerKey.keyStart > providersEnd) return undefined;
6231
7305
  const providerObjectBrace = skipJsonWhitespace(clean, providerKey.valueStart);
6232
7306
  if (clean[providerObjectBrace] !== "{") return undefined;
6233
7307
  const providerObjectEnd = findMatchingBracket(clean, providerObjectBrace);
6234
7308
  if (providerObjectEnd === undefined || providerObjectEnd > providersEnd) return undefined;
7309
+ const providerCompatKey = findJsonObjectKey(clean, providerObjectBrace, "compat");
7310
+ if (providerCompatKey?.count && providerCompatKey.count > 1) return undefined;
7311
+ const modelsKey = findJsonObjectKey(clean, providerObjectBrace, "models");
7312
+ if (modelsKey?.count && modelsKey.count > 1) return undefined;
6235
7313
 
6236
7314
  let modelOverridesObjectBrace = -1;
6237
7315
  let modelOverridesObjectEnd = -1;
@@ -6239,7 +7317,10 @@ function locateModelOverrideInJsonc(
6239
7317
  let modelOverrideObjectEnd = -1;
6240
7318
  let modelOverrideCompatBrace = -1;
6241
7319
  let modelOverrideCompatEnd = -1;
7320
+ let modelOverrideKeyCount = 0;
7321
+ let modelOverrideCompatKeyCount = 0;
6242
7322
  const overridesKey = findJsonObjectKey(clean, providerObjectBrace, "modelOverrides");
7323
+ if (overridesKey?.count && overridesKey.count > 1) return undefined;
6243
7324
  if (overridesKey && overridesKey.keyStart < providerObjectEnd) {
6244
7325
  const brace = skipJsonWhitespace(clean, overridesKey.valueStart);
6245
7326
  if (clean[brace] !== "{") return undefined;
@@ -6249,6 +7330,8 @@ function locateModelOverrideInJsonc(
6249
7330
  modelOverridesObjectEnd = end;
6250
7331
 
6251
7332
  const overrideKey = findJsonObjectKey(clean, brace, modelId);
7333
+ modelOverrideKeyCount = overrideKey?.count ?? 0;
7334
+ if (overrideKey?.count && overrideKey.count > 1) return undefined;
6252
7335
  if (overrideKey && overrideKey.keyStart < end) {
6253
7336
  const entryBrace = skipJsonWhitespace(clean, overrideKey.valueStart);
6254
7337
  if (clean[entryBrace] !== "{") return undefined;
@@ -6258,6 +7341,8 @@ function locateModelOverrideInJsonc(
6258
7341
  modelOverrideObjectEnd = entryEnd;
6259
7342
 
6260
7343
  const compatKey = findJsonObjectKey(clean, entryBrace, "compat");
7344
+ modelOverrideCompatKeyCount = compatKey?.count ?? 0;
7345
+ if (compatKey?.count && compatKey.count > 1) return undefined;
6261
7346
  if (compatKey && compatKey.keyStart < entryEnd) {
6262
7347
  const compatBrace = skipJsonWhitespace(clean, compatKey.valueStart);
6263
7348
  if (clean[compatBrace] !== "{") return undefined;
@@ -6270,6 +7355,10 @@ function locateModelOverrideInJsonc(
6270
7355
  }
6271
7356
 
6272
7357
  return {
7358
+ providerKeyCount: providerKey.count,
7359
+ modelOverridesKeyCount: overridesKey?.count ?? 0,
7360
+ modelOverrideKeyCount,
7361
+ modelOverrideCompatKeyCount,
6273
7362
  providerObjectBrace,
6274
7363
  providerObjectEnd,
6275
7364
  modelOverridesObjectBrace,
@@ -6304,14 +7393,14 @@ function locateModelInJsonc(
6304
7393
  if (clean[rootBrace] !== "{") return undefined;
6305
7394
 
6306
7395
  const providersKey = findJsonObjectKey(clean, rootBrace, "providers");
6307
- if (!providersKey) return undefined;
7396
+ if (!providersKey || providersKey.count !== 1) return undefined;
6308
7397
  const providersBrace = skipJsonWhitespace(clean, providersKey.valueStart);
6309
7398
  if (clean[providersBrace] !== "{") return undefined;
6310
7399
  const providersEnd = findMatchingBracket(clean, providersBrace);
6311
7400
  if (providersEnd === undefined) return undefined;
6312
7401
 
6313
7402
  const providerKey = findJsonObjectKey(clean, providersBrace, providerLabel);
6314
- if (!providerKey || providerKey.keyStart > providersEnd) return undefined;
7403
+ if (!providerKey || providerKey.count !== 1 || providerKey.keyStart > providersEnd) return undefined;
6315
7404
  const providerBrace = skipJsonWhitespace(clean, providerKey.valueStart);
6316
7405
  if (clean[providerBrace] !== "{") return undefined;
6317
7406
  const providerEndBrace = findMatchingBracket(clean, providerBrace);
@@ -6322,14 +7411,33 @@ function locateModelInJsonc(
6322
7411
  let providerCompatBrace = -1;
6323
7412
  let providerCompatEnd = -1;
6324
7413
  const providerCompatKey = findJsonObjectKey(clean, providerBrace, "compat");
7414
+ const providerCompatKeyCount = providerCompatKey?.count ?? 0;
7415
+ if (providerCompatKey?.count && providerCompatKey.count > 1) return undefined;
6325
7416
  if (providerCompatKey && providerCompatKey.keyStart < providerEndBrace) {
6326
7417
  const brace = skipJsonWhitespace(clean, providerCompatKey.valueStart);
6327
- if (clean[brace] === "{") {
6328
- const end = findMatchingBracket(clean, brace);
6329
- if (end !== undefined && end <= providerEndBrace) {
6330
- providerCompatBrace = brace;
6331
- providerCompatEnd = end;
6332
- }
7418
+ if (clean[brace] !== "{") return undefined;
7419
+ const end = findMatchingBracket(clean, brace);
7420
+ if (end === undefined || end > providerEndBrace) return undefined;
7421
+ providerCompatBrace = brace;
7422
+ providerCompatEnd = end;
7423
+ }
7424
+
7425
+ const modelOverridesKey = findJsonObjectKey(clean, providerBrace, "modelOverrides");
7426
+ if (modelOverridesKey?.count && modelOverridesKey.count > 1) return undefined;
7427
+ if (modelOverridesKey) {
7428
+ const modelOverridesBrace = skipJsonWhitespace(clean, modelOverridesKey.valueStart);
7429
+ if (clean[modelOverridesBrace] !== "{") return undefined;
7430
+ const modelOverridesEnd = findMatchingBracket(clean, modelOverridesBrace);
7431
+ if (modelOverridesEnd === undefined || modelOverridesEnd > providerEndBrace) return undefined;
7432
+ const overrideKey = findJsonObjectKey(clean, modelOverridesBrace, modelId);
7433
+ if (overrideKey?.count && overrideKey.count > 1) return undefined;
7434
+ if (overrideKey) {
7435
+ const overrideBrace = skipJsonWhitespace(clean, overrideKey.valueStart);
7436
+ if (clean[overrideBrace] !== "{") return undefined;
7437
+ const overrideEnd = findMatchingBracket(clean, overrideBrace);
7438
+ if (overrideEnd === undefined || overrideEnd > modelOverridesEnd) return undefined;
7439
+ const overrideCompatKey = findJsonObjectKey(clean, overrideBrace, "compat");
7440
+ if (overrideCompatKey?.count && overrideCompatKey.count > 1) return undefined;
6333
7441
  }
6334
7442
  }
6335
7443
 
@@ -6340,7 +7448,7 @@ function locateModelInJsonc(
6340
7448
  const modelOverrideCompatEnd = overrideLocation?.modelOverrideCompatEnd ?? -1;
6341
7449
 
6342
7450
  const modelsKey = findJsonObjectKey(clean, providerBrace, "models");
6343
- if (!modelsKey || modelsKey.keyStart > providerEndBrace) return undefined;
7451
+ if (!modelsKey || modelsKey.count !== 1 || modelsKey.keyStart > providerEndBrace) return undefined;
6344
7452
 
6345
7453
  let modelsScan = skipJsonWhitespace(clean, modelsKey.valueStart);
6346
7454
  if (clean[modelsScan] !== "[") return undefined;
@@ -6355,6 +7463,7 @@ function locateModelInJsonc(
6355
7463
  let compatKeyStartClean = -1;
6356
7464
  let compatBrace = -1;
6357
7465
  let compatEndBrace = -1;
7466
+ let modelCompatKeyCount = 0;
6358
7467
 
6359
7468
  while (modelsScan < modelsEnd) {
6360
7469
  modelsScan = skipJsonWhitespace(clean, modelsScan);
@@ -6370,6 +7479,7 @@ function locateModelInJsonc(
6370
7479
  if (elementEnd === undefined || elementEnd > modelsEnd) return undefined;
6371
7480
 
6372
7481
  const idKey = findJsonObjectKey(clean, elementBrace, "id");
7482
+ if (idKey?.count && idKey.count > 1) return undefined;
6373
7483
  let elementId: string | undefined;
6374
7484
  if (idKey && idKey.keyStart < elementEnd) {
6375
7485
  const idValueStart = skipJsonWhitespace(clean, idKey.valueStart);
@@ -6393,16 +7503,16 @@ function locateModelInJsonc(
6393
7503
  compatEndBrace = -1;
6394
7504
 
6395
7505
  const compatKey = findJsonObjectKey(clean, modelBrace, "compat");
7506
+ modelCompatKeyCount = compatKey?.count ?? 0;
7507
+ if (compatKey?.count && compatKey.count > 1) return undefined;
6396
7508
  if (compatKey && compatKey.keyStart < modelEndBrace) {
6397
7509
  compatKeyStartClean = compatKey.keyStart;
6398
7510
  const brace = skipJsonWhitespace(clean, compatKey.valueStart);
6399
- if (clean[brace] === "{") {
6400
- const end = findMatchingBracket(clean, brace);
6401
- if (end !== undefined && end <= modelEndBrace) {
6402
- compatBrace = brace;
6403
- compatEndBrace = end;
6404
- }
6405
- }
7511
+ if (clean[brace] !== "{") return undefined;
7512
+ const end = findMatchingBracket(clean, brace);
7513
+ if (end === undefined || end > modelEndBrace) return undefined;
7514
+ compatBrace = brace;
7515
+ compatEndBrace = end;
6406
7516
  }
6407
7517
  }
6408
7518
 
@@ -6435,6 +7545,10 @@ function locateModelInJsonc(
6435
7545
  modelOverrideObjectEnd,
6436
7546
  modelOverrideCompatBrace,
6437
7547
  modelOverrideCompatEnd,
7548
+ providerKeyCount: providerKey.count,
7549
+ modelOverrideKeyCount: overrideLocation?.modelOverrideKeyCount ?? 0,
7550
+ modelCompatKeyCount,
7551
+ providerCompatKeyCount,
6438
7552
  allModelIds,
6439
7553
  };
6440
7554
  }
@@ -6464,8 +7578,9 @@ function analyzeModelsJsonForMissingEntry(
6464
7578
  if (clean[rootBrace] !== "{") return undefined;
6465
7579
 
6466
7580
  const providersKey = findJsonObjectKey(clean, rootBrace, "providers");
6467
- if (!providersKey) {
6468
- // Root has no "providers" key at all — we don't auto-create one.
7581
+ if (!providersKey || providersKey.count !== 1) {
7582
+ // Root has no unique "providers" key — we don't auto-create or guess
7583
+ // among duplicate definitions.
6469
7584
  return undefined;
6470
7585
  }
6471
7586
  const providersBrace = skipJsonWhitespace(clean, providersKey.valueStart);
@@ -6474,9 +7589,10 @@ function analyzeModelsJsonForMissingEntry(
6474
7589
  if (providersEnd === undefined) return undefined;
6475
7590
 
6476
7591
  const providerKey = findJsonObjectKey(clean, providersBrace, providerLabel);
6477
- if (!providerKey || providerKey.keyStart > providersEnd) {
7592
+ if (!providerKey) {
6478
7593
  return { scenario: "provider_missing", providersBrace, providersEnd };
6479
7594
  }
7595
+ if (providerKey.count !== 1 || providerKey.keyStart > providersEnd) return undefined;
6480
7596
 
6481
7597
  // Provider exists. Check for a models array so we know where to append.
6482
7598
  const providerBrace = skipJsonWhitespace(clean, providerKey.valueStart);
@@ -6485,14 +7601,42 @@ function analyzeModelsJsonForMissingEntry(
6485
7601
  if (providerEndBrace === undefined || providerEndBrace > providersEnd) return undefined;
6486
7602
 
6487
7603
  const modelsKey = findJsonObjectKey(clean, providerBrace, "models");
7604
+ if (modelsKey?.count && modelsKey.count > 1) return undefined;
6488
7605
  if (modelsKey && modelsKey.keyStart < providerEndBrace) {
6489
- let mScan = skipJsonWhitespace(clean, modelsKey.valueStart);
6490
- if (clean[mScan] === "[") {
6491
- const modelsEnd = findMatchingBracket(clean, mScan);
6492
- if (modelsEnd !== undefined && modelsEnd <= providerEndBrace) {
6493
- return { scenario: "model_missing", modelsEnd, providerBrace, providerEndBrace };
7606
+ const mScan = skipJsonWhitespace(clean, modelsKey.valueStart);
7607
+ if (clean[mScan] !== "[") return undefined;
7608
+ const modelsEnd = findMatchingBracket(clean, mScan);
7609
+ if (modelsEnd === undefined || modelsEnd > providerEndBrace) return undefined;
7610
+
7611
+ // Confirm that the target really is absent before offering an insertion.
7612
+ // This second pass also refuses malformed/ambiguous existing entries, so
7613
+ // a scanner failure can never turn into a duplicate model definition.
7614
+ let modelScan = mScan + 1;
7615
+ let targetFound = false;
7616
+ while (modelScan < modelsEnd) {
7617
+ modelScan = skipJsonWhitespace(clean, modelScan);
7618
+ if (clean[modelScan] === ",") {
7619
+ modelScan++;
7620
+ continue;
6494
7621
  }
7622
+ if (modelScan >= modelsEnd || clean[modelScan] === "]") break;
7623
+ if (clean[modelScan] !== "{") return undefined;
7624
+ const elementEnd = findMatchingBracket(clean, modelScan);
7625
+ if (elementEnd === undefined || elementEnd > modelsEnd) return undefined;
7626
+ const idKey = findJsonObjectKey(clean, modelScan, "id");
7627
+ if (!idKey || idKey.count !== 1) return undefined;
7628
+ const idStart = skipJsonWhitespace(clean, idKey.valueStart);
7629
+ const idLiteral = readJsonStringLiteral(clean, idStart);
7630
+ if (!idLiteral || idLiteral.end > elementEnd) return undefined;
7631
+ if (idLiteral.value === modelId) {
7632
+ targetFound = true;
7633
+ const compatKey = findJsonObjectKey(clean, modelScan, "compat");
7634
+ if (compatKey?.count && compatKey.count > 1) return undefined;
7635
+ }
7636
+ modelScan = elementEnd + 1;
6495
7637
  }
7638
+ if (targetFound) return undefined;
7639
+ return { scenario: "model_missing", modelsEnd, providerBrace, providerEndBrace };
6496
7640
  }
6497
7641
 
6498
7642
  // Provider exists, but there's no discoverable models array — treat as
@@ -6514,7 +7658,7 @@ function formatMissingEntryManualSnippet(
6514
7658
  const lines: string[] = [];
6515
7659
  const sorted = Object.entries(compatKeys).sort(([a], [b]) => a.localeCompare(b));
6516
7660
  const compatItems = sorted.map(([k, v]) => ` ${JSON.stringify(k)}: ${JSON.stringify(v)}`);
6517
- lines.push(`"${providerLabel}": {`);
7661
+ lines.push(`${JSON.stringify(providerLabel)}: {`);
6518
7662
  lines.push(` "modelOverrides": {`);
6519
7663
  lines.push(` ${JSON.stringify(modelId)}: {`);
6520
7664
  lines.push(` "compat": {`);
@@ -6541,6 +7685,7 @@ function composeModelOverrideInsertion(
6541
7685
 
6542
7686
  if (location?.modelOverrideObjectBrace !== undefined && location.modelOverrideObjectBrace >= 0) {
6543
7687
  const modelLocation: ModelNodeLocation = {
7688
+ providerKeyCount: location.providerKeyCount,
6544
7689
  modelObjectBrace: -1,
6545
7690
  modelObjectEnd: -1,
6546
7691
  compatKeyStart: -1,
@@ -6555,6 +7700,9 @@ function composeModelOverrideInsertion(
6555
7700
  modelOverrideObjectEnd: location.modelOverrideObjectEnd,
6556
7701
  modelOverrideCompatBrace: location.modelOverrideCompatBrace,
6557
7702
  modelOverrideCompatEnd: location.modelOverrideCompatEnd,
7703
+ modelOverrideKeyCount: location.modelOverrideKeyCount,
7704
+ modelCompatKeyCount: 0,
7705
+ providerCompatKeyCount: 0,
6558
7706
  allModelIds: [],
6559
7707
  };
6560
7708
  return {
@@ -6794,7 +7942,8 @@ function composeMissingEntryInsertion(
6794
7942
  * Lightweight self-check for a newly inserted model or modelOverrides entry.
6795
7943
  * Parses the modified text as JSONC and confirms:
6796
7944
  * 1. The target exists under models[] or modelOverrides.
6797
- * 2. Every compat key has the expected effective three-layer value.
7945
+ * 2. Every compat key has the expected effective provider/model/runtime/
7946
+ * modelOverride value.
6798
7947
  * Returns null on success, an error string on failure.
6799
7948
  */
6800
7949
  function selfCheckMissingEntryInsertion(
@@ -6803,6 +7952,7 @@ function selfCheckMissingEntryInsertion(
6803
7952
  providerLabel: string,
6804
7953
  modelId: string,
6805
7954
  compatKeys: Record<string, unknown>,
7955
+ runtimeModel?: PiModel,
6806
7956
  ): string | null {
6807
7957
  try {
6808
7958
  const origParsed = parseJsonc(originalText);
@@ -6820,7 +7970,19 @@ function selfCheckMissingEntryInsertion(
6820
7970
  return `Modified file: model or modelOverrides entry "${modelId}" not found in provider after insertion`;
6821
7971
  }
6822
7972
 
7973
+ const effectiveCompat = runtimeModel
7974
+ ? resolveEffectiveCompatFromConfig(runtimeModel, modParsed)
7975
+ : undefined;
6823
7976
  for (const [k, v] of Object.entries(compatKeys)) {
7977
+ if (effectiveCompat) {
7978
+ if (!Object.prototype.hasOwnProperty.call(effectiveCompat, k)) {
7979
+ return `Modified file: effective compat.${k} not found`;
7980
+ }
7981
+ if ((effectiveCompat as Record<string, unknown>)[k] !== v) {
7982
+ return `Modified file: effective compat.${k} wrong value: expected ${JSON.stringify(v)}, got ${JSON.stringify((effectiveCompat as Record<string, unknown>)[k])}`;
7983
+ }
7984
+ continue;
7985
+ }
6824
7986
  const effective = resolveExplicitCompatValue(modParsed, providerLabel, modelId, k);
6825
7987
  if (!effective) return `Modified file: effective compat.${k} not found`;
6826
7988
  if (effective.value !== v) {
@@ -7002,8 +8164,10 @@ function decideFixPlacement(
7002
8164
  continue;
7003
8165
  }
7004
8166
  if (key === "thinkingFormat" || key === "requiresReasoningContentOnAssistantMessages") {
7005
- const allDeepSeek = siblings.every((id) => isDeepSeekLikeModel(syntheticModelForId(providerLabel, id)));
7006
- if (!allDeepSeek) unsafeKeys.push(key);
8167
+ // Reasoning wire/replay behavior is model-specific. Sibling ids do not
8168
+ // prove that they use the same protocol, so never broaden this repair to
8169
+ // provider scope based on a shared DeepSeek name alone.
8170
+ unsafeKeys.push(key);
7007
8171
  continue;
7008
8172
  }
7009
8173
  // Unknown model-behavior key — be conservative, keep it model-scoped.
@@ -7052,8 +8216,8 @@ function chooseFixPlacement(
7052
8216
 
7053
8217
  if (forceModelLevel) {
7054
8218
  return {
7055
- placement: "model",
7056
- reason: "runtime-observed provider/model failure — scoping the repair to the affected model",
8219
+ placement: "modelOverride",
8220
+ reason: "runtime-observed provider/model failure — using Pi's highest-precedence model override",
7057
8221
  };
7058
8222
  }
7059
8223
 
@@ -7203,6 +8367,7 @@ function selfCheckFix(
7203
8367
  modelId: string,
7204
8368
  compatKeys: Record<string, unknown>,
7205
8369
  placement: "provider" | "model" | "modelOverride" = "model",
8370
+ runtimeModel?: PiModel,
7206
8371
  ): string | null {
7207
8372
  try {
7208
8373
  // Step 1: Parse both versions as JSONC (comments + trailing commas allowed).
@@ -7247,8 +8412,9 @@ function selfCheckFix(
7247
8412
  }
7248
8413
 
7249
8414
  // Step 5: Compute the EFFECTIVE merged compat using Pi's precedence:
7250
- // provider, custom model, then modelOverrides. The fix may have written any
7251
- // level, so validation must check what Pi will actually use.
8415
+ // provider, custom model, runtime model, then modelOverrides. The fix may
8416
+ // have written any persistent level, so validation must check what Pi will
8417
+ // actually use.
7252
8418
  const provCompatRaw = (provider as Record<string, unknown>).compat;
7253
8419
  const provCompat = (provCompatRaw && typeof provCompatRaw === 'object' && !Array.isArray(provCompatRaw))
7254
8420
  ? provCompatRaw as Record<string, unknown>
@@ -7264,7 +8430,9 @@ function selfCheckFix(
7264
8430
  return `Modified file: modelOverrides["${modelId}"].compat is not an object`;
7265
8431
  }
7266
8432
  const overrideCompat = asRecord(overrideCompatRaw) ?? {};
7267
- const mergedCompat: Record<string, unknown> = { ...provCompat, ...mdlCompat, ...overrideCompat };
8433
+ const mergedCompat: Record<string, unknown> = runtimeModel
8434
+ ? resolveEffectiveCompatFromConfig(runtimeModel, modParsed) as Record<string, unknown>
8435
+ : { ...provCompat, ...mdlCompat, ...overrideCompat };
7268
8436
 
7269
8437
  // Step 6: Validate all inserted keys are effective in the merged compat
7270
8438
  for (const [k, v] of Object.entries(compatKeys)) {
@@ -7276,62 +8444,97 @@ function selfCheckFix(
7276
8444
  }
7277
8445
  }
7278
8446
 
7279
- // Step 7: Validate original structure is preserved (no accidental deletions/changes)
7280
-
7281
- function isSubset(origVal: unknown, modVal: unknown, path = ''): boolean {
7282
- if (origVal === modVal) return true;
7283
- if (typeof origVal !== typeof modVal) return false;
7284
- if (typeof origVal !== 'object' || origVal === null || modVal === null) return false;
7285
- if (Array.isArray(origVal) !== Array.isArray(modVal)) return false;
7286
- if (Array.isArray(origVal) && Array.isArray(modVal)) {
7287
- if (origVal.length !== modVal.length) return false;
7288
- return origVal.every((_, i) => isSubset(origVal[i], modVal[i], `${path}[${i}]`));
8447
+ // Step 7: Validate that the parsed document is unchanged except for the
8448
+ // explicitly requested scalar keys in the compat object we edited. A
8449
+ // one-way subset check is not sufficient here: it would accept accidental
8450
+ // additions such as credentials or unrelated provider fields.
8451
+ const editedCompatContainer = (value: Record<string, unknown>): boolean =>
8452
+ (placement === "provider" && value === origProvider) ||
8453
+ (placement === "model" && value === origTargetModelRecord) ||
8454
+ (placement === "modelOverride" && value === asRecord(asRecord(origProvider.modelOverrides)?.[modelId]));
8455
+
8456
+ function sameCompatObject(
8457
+ originalCompatValue: unknown,
8458
+ modifiedCompatValue: unknown,
8459
+ ): boolean {
8460
+ const originalCompat = originalCompatValue === undefined
8461
+ ? {}
8462
+ : asRecord(originalCompatValue);
8463
+ const modifiedCompat = asRecord(modifiedCompatValue);
8464
+ if (!originalCompat || !modifiedCompat) return false;
8465
+
8466
+ const allowedKeys = new Set([...Object.keys(originalCompat), ...Object.keys(compatKeys)]);
8467
+ const modifiedKeys = Object.keys(modifiedCompat);
8468
+ if (modifiedKeys.length !== allowedKeys.size || modifiedKeys.some((key) => !allowedKeys.has(key))) {
8469
+ return false;
7289
8470
  }
7290
- // Both objects: check that every key in orig is in mod with same value
7291
- const origObj = origVal as Record<string, unknown>;
7292
- const modObj = modVal as Record<string, unknown>;
7293
- for (const key of Object.keys(origObj)) {
7294
- if (!(key in modObj)) return false;
7295
- if (key === 'compat') {
7296
- // For compat, allow extra keys in modified (the inserted ones).
7297
- // Use recursive isSubset so nested objects (e.g. { deep: true })
7298
- // are compared by content, not reference.
7299
- if (typeof origObj[key] !== 'object' || typeof modObj[key] !== 'object') {
7300
- if (origObj[key] !== modObj[key]) return false;
7301
- } else {
7302
- const origCompat = origObj[key] as Record<string, unknown>;
7303
- const modCompat = modObj[key] as Record<string, unknown>;
7304
- // Only the compat object at the level ACTUALLY edited may have
7305
- // its values repaired by this fix. The un-edited level must
7306
- // remain byte/structure-equivalent, so its same-name keys stay
7307
- // under full validation. Using a disjunction OR (provider ||
7308
- // target) here would silently skip validation at the un-edited
7309
- // level, masking corruption (e.g. a buggy editor accidentally
7310
- // breaking provider.compat.sendSessionAffinityHeaders while
7311
- // the fix was a model-level repair). Track placement — only
7312
- // the placement-resolved object's own compat may be exempt.
7313
- const mayRepairThisCompat =
7314
- (placement === "provider" && origObj === origProvider) ||
7315
- (placement === "model" && origObj === origTargetModelRecord) ||
7316
- (placement === "modelOverride" && origObj === asRecord(asRecord(origProvider.modelOverrides)?.[modelId]));
7317
- for (const ck of Object.keys(origCompat)) {
7318
- if (!(ck in modCompat)) return false;
7319
- // The fix may repair an existing wrong compat value (for example
7320
- // thinkingFormat: "legacy" -> "deepseek"), but only on the
7321
- // target provider/model compat objects. Sibling compat blocks must
7322
- // remain structure-equivalent.
7323
- if (mayRepairThisCompat && Object.prototype.hasOwnProperty.call(compatKeys, ck)) continue;
7324
- if (!isSubset(origCompat[ck], modCompat[ck], `${path}.${ck}`)) return false;
7325
- }
8471
+
8472
+ for (const key of allowedKeys) {
8473
+ if (Object.prototype.hasOwnProperty.call(compatKeys, key)) {
8474
+ if (!Object.prototype.hasOwnProperty.call(modifiedCompat, key) || modifiedCompat[key] !== compatKeys[key]) {
8475
+ return false;
7326
8476
  }
7327
- } else if (!isSubset(origObj[key], modObj[key], `${path}.${key}`)) {
8477
+ continue;
8478
+ }
8479
+ if (!Object.prototype.hasOwnProperty.call(originalCompat, key) ||
8480
+ !sameDocumentValue(originalCompat[key], modifiedCompat[key])) {
7328
8481
  return false;
7329
8482
  }
7330
8483
  }
7331
8484
  return true;
7332
8485
  }
7333
8486
 
7334
- if (!isSubset(origParsed, modParsed)) {
8487
+ function sameDocumentValue(originalValue: unknown, modifiedValue: unknown): boolean {
8488
+ if (originalValue === modifiedValue) return true;
8489
+ if (typeof originalValue !== typeof modifiedValue || originalValue === null || modifiedValue === null) return false;
8490
+ if (Array.isArray(originalValue) || Array.isArray(modifiedValue)) {
8491
+ if (!Array.isArray(originalValue) || !Array.isArray(modifiedValue) || originalValue.length !== modifiedValue.length) return false;
8492
+ return originalValue.every((value, index) => sameDocumentValue(value, modifiedValue[index]));
8493
+ }
8494
+ if (typeof originalValue !== "object") return false;
8495
+
8496
+ const originalObject = originalValue as Record<string, unknown>;
8497
+ const modifiedObject = modifiedValue as Record<string, unknown>;
8498
+ const originalKeys = Object.keys(originalObject);
8499
+ const modifiedKeys = Object.keys(modifiedObject);
8500
+ if (originalKeys.length !== modifiedKeys.length || modifiedKeys.some((key) => !Object.prototype.hasOwnProperty.call(originalObject, key))) {
8501
+ return false;
8502
+ }
8503
+ return originalKeys.every((key) => sameDocumentValue(originalObject[key], modifiedObject[key]));
8504
+ }
8505
+
8506
+ function sameDocumentExceptEditedCompat(originalValue: unknown, modifiedValue: unknown): boolean {
8507
+ if (originalValue === modifiedValue) return true;
8508
+ if (typeof originalValue !== typeof modifiedValue || originalValue === null || modifiedValue === null) return false;
8509
+ if (Array.isArray(originalValue) || Array.isArray(modifiedValue)) {
8510
+ if (!Array.isArray(originalValue) || !Array.isArray(modifiedValue) || originalValue.length !== modifiedValue.length) return false;
8511
+ return originalValue.every((value, index) => sameDocumentExceptEditedCompat(value, modifiedValue[index]));
8512
+ }
8513
+ if (typeof originalValue !== "object") return false;
8514
+
8515
+ const originalObject = originalValue as Record<string, unknown>;
8516
+ const modifiedObject = modifiedValue as Record<string, unknown>;
8517
+ if (editedCompatContainer(originalObject)) {
8518
+ const originalKeys = Object.keys(originalObject).filter((key) => key !== "compat");
8519
+ const modifiedKeys = Object.keys(modifiedObject).filter((key) => key !== "compat");
8520
+ if (originalKeys.length !== modifiedKeys.length || modifiedKeys.some((key) => !Object.prototype.hasOwnProperty.call(originalObject, key))) {
8521
+ return false;
8522
+ }
8523
+ for (const key of originalKeys) {
8524
+ if (!sameDocumentExceptEditedCompat(originalObject[key], modifiedObject[key])) return false;
8525
+ }
8526
+ return sameCompatObject(originalObject.compat, modifiedObject.compat);
8527
+ }
8528
+
8529
+ const originalKeys = Object.keys(originalObject);
8530
+ const modifiedKeys = Object.keys(modifiedObject);
8531
+ if (originalKeys.length !== modifiedKeys.length || modifiedKeys.some((key) => !Object.prototype.hasOwnProperty.call(originalObject, key))) {
8532
+ return false;
8533
+ }
8534
+ return originalKeys.every((key) => sameDocumentExceptEditedCompat(originalObject[key], modifiedObject[key]));
8535
+ }
8536
+
8537
+ if (!sameDocumentExceptEditedCompat(origParsed, modParsed)) {
7335
8538
  return "Modified file: original structure was altered (data loss detected)";
7336
8539
  }
7337
8540
 
@@ -7395,16 +8598,78 @@ function uniqueTempPath(targetPath: string, purpose: string): string {
7395
8598
  return `${targetPath}.${process.pid}.${Date.now()}.${backupSequence++}.${purpose}.tmp`;
7396
8599
  }
7397
8600
 
8601
+ type FileIdentity = { dev: number | bigint; ino: number | bigint };
8602
+
8603
+ function sameFileIdentity(left: FileIdentity, right: FileIdentity): boolean {
8604
+ // Some platforms do not expose a meaningful inode. On those platforms the
8605
+ // hash checks around the transaction remain authoritative.
8606
+ const leftDev = Number(left.dev);
8607
+ const rightDev = Number(right.dev);
8608
+ const leftIno = Number(left.ino);
8609
+ const rightIno = Number(right.ino);
8610
+ return leftIno === 0 || rightIno === 0 || (leftDev === rightDev && leftIno === rightIno);
8611
+ }
8612
+
8613
+ type AtomicTargetGuard = {
8614
+ identity?: FileIdentity;
8615
+ hash?: string;
8616
+ mode?: number;
8617
+ };
8618
+
8619
+ async function validateAtomicTarget(
8620
+ targetPath: string,
8621
+ guard?: AtomicTargetGuard,
8622
+ ): Promise<Awaited<ReturnType<typeof lstat>>> {
8623
+ const info = await lstat(targetPath);
8624
+ if (info.isSymbolicLink() || !info.isFile()) {
8625
+ throw new Error("target is not a regular file; refusing atomic replacement");
8626
+ }
8627
+ if (guard?.identity && !sameFileIdentity(guard.identity, info)) {
8628
+ throw new Error("target changed during atomic replacement");
8629
+ }
8630
+ if (guard?.mode !== undefined && (info.mode & 0o7777) !== guard.mode) {
8631
+ throw new Error("target access mode changed during atomic replacement");
8632
+ }
8633
+ if (guard?.hash !== undefined) {
8634
+ const text = await readFile(targetPath, "utf8");
8635
+ if (hashText(text) !== guard.hash) {
8636
+ throw new Error("target content changed during atomic replacement");
8637
+ }
8638
+ }
8639
+ return info;
8640
+ }
8641
+
7398
8642
  async function atomicReplaceTextFilePreservingMode(
7399
8643
  targetPath: string,
7400
8644
  content: string,
7401
8645
  mode: number,
7402
8646
  purpose: string,
8647
+ guard?: AtomicTargetGuard,
8648
+ /** Test-only race injector; production callers leave this undefined. */
8649
+ beforeRename?: () => Promise<void>,
7403
8650
  ): Promise<void> {
8651
+ const initialTargetInfo = await validateAtomicTarget(targetPath, guard);
7404
8652
  const tempPath = uniqueTempPath(targetPath, purpose);
7405
8653
  try {
7406
- await writeFile(tempPath, content, { encoding: "utf8", mode });
8654
+ // `wx` makes the unique temporary path non-overwriting even if a hostile
8655
+ // or stale file appears between name generation and the write.
8656
+ await writeFile(tempPath, content, { encoding: "utf8", mode, flag: "wx" });
8657
+ const tempInfo = await lstat(tempPath);
8658
+ if (tempInfo.isSymbolicLink() || !tempInfo.isFile()) {
8659
+ throw new Error("temporary replacement is not a regular file");
8660
+ }
7407
8661
  await chmod(tempPath, mode);
8662
+ await validateAtomicTarget(targetPath, {
8663
+ ...guard,
8664
+ identity: initialTargetInfo,
8665
+ mode,
8666
+ });
8667
+ if (beforeRename) await beforeRename();
8668
+ await validateAtomicTarget(targetPath, {
8669
+ ...guard,
8670
+ identity: initialTargetInfo,
8671
+ mode,
8672
+ });
7408
8673
  await rename(tempPath, targetPath);
7409
8674
  } catch (error) {
7410
8675
  try {
@@ -7422,11 +8687,42 @@ async function atomicRestoreFileFromBackup(
7422
8687
  backupPath: string,
7423
8688
  targetPath: string,
7424
8689
  mode: number,
8690
+ guard?: AtomicTargetGuard & { backupHash?: string },
8691
+ /** Test-only race injector; production callers leave this undefined. */
8692
+ beforeRename?: () => Promise<void>,
7425
8693
  ): Promise<void> {
8694
+ const backupInfo = await lstat(backupPath);
8695
+ if (backupInfo.isSymbolicLink() || !backupInfo.isFile()) {
8696
+ throw new Error("backup is not a regular file; refusing atomic restore");
8697
+ }
8698
+ if (guard?.backupHash !== undefined) {
8699
+ const backupText = await readFile(backupPath, "utf8");
8700
+ if (hashText(backupText) !== guard.backupHash) {
8701
+ throw new Error("backup content changed during atomic restore");
8702
+ }
8703
+ }
8704
+ const targetInfo = await validateAtomicTarget(targetPath, guard);
7426
8705
  const tempPath = uniqueTempPath(targetPath, "restore");
7427
8706
  try {
7428
8707
  await copyFile(backupPath, tempPath, fsConstants.COPYFILE_EXCL);
8708
+ const tempInfo = await lstat(tempPath);
8709
+ if (tempInfo.isSymbolicLink() || !tempInfo.isFile()) {
8710
+ throw new Error("temporary restore is not a regular file");
8711
+ }
7429
8712
  await chmod(tempPath, mode);
8713
+ const tempText = await readFile(tempPath, "utf8");
8714
+ if (guard?.backupHash !== undefined && hashText(tempText) !== guard.backupHash) {
8715
+ throw new Error("backup content changed during atomic restore");
8716
+ }
8717
+ await validateAtomicTarget(targetPath, {
8718
+ ...guard,
8719
+ identity: targetInfo,
8720
+ });
8721
+ if (beforeRename) await beforeRename();
8722
+ await validateAtomicTarget(targetPath, {
8723
+ ...guard,
8724
+ identity: targetInfo,
8725
+ });
7430
8726
  await rename(tempPath, targetPath);
7431
8727
  } catch (error) {
7432
8728
  try {
@@ -7444,43 +8740,285 @@ type ModelsJsonFixTransactionResult =
7444
8740
  | { ok: true }
7445
8741
  | { ok: false; postCheckError: string };
7446
8742
 
7447
- async function applyModelsJsonFixTransaction(
8743
+ type ModelsJsonFixReceiptSnapshot = {
8744
+ receipt: ModelsJsonFixReceiptV1;
8745
+ receiptPath: string;
8746
+ hash: string;
8747
+ identity: FileIdentity;
8748
+ };
8749
+
8750
+ type ModelsJsonFixTransactionOptions = {
8751
+ onCommitted?: (writtenText: string) => Promise<void>;
8752
+ expectedCurrentHash?: string;
8753
+ /** Access mode captured by a preview, used to reject mode races. */
8754
+ expectedCurrentMode?: number;
8755
+ /** Hash of the transaction backup, used to revalidate a guarded restore. */
8756
+ expectedBackupHash?: string;
8757
+ /** Receipt identity captured before a rollback preview. */
8758
+ receiptGuard?: ModelsJsonFixReceiptSnapshot;
8759
+ purpose?: string;
8760
+ };
8761
+
8762
+ function hashText(text: string): string {
8763
+ return createHash("sha256").update(text, "utf8").digest("hex");
8764
+ }
8765
+
8766
+ async function withModelsJsonTransactionLock<T>(operation: () => Promise<T>): Promise<T> {
8767
+ await mkdir(STATE_DIR, { recursive: true });
8768
+ const deadline = Date.now() + MODELS_TRANSACTION_LOCK_WAIT_MS;
8769
+ const ownerToken = randomUUID();
8770
+ const ownerText = JSON.stringify({ pid: process.pid, token: ownerToken });
8771
+ let ownedLockIdentity: FileIdentity | undefined;
8772
+ while (true) {
8773
+ try {
8774
+ // A single O_EXCL file is the lease: unlike mkdir-then-owner-file, there
8775
+ // is no ownerless crash window between creating the lock and identifying
8776
+ // its owner.
8777
+ await writeFile(MODELS_TRANSACTION_LOCK_PATH, ownerText, {
8778
+ encoding: "utf8",
8779
+ mode: 0o600,
8780
+ flag: "wx",
8781
+ });
8782
+ const lockInfo = await lstat(MODELS_TRANSACTION_LOCK_PATH);
8783
+ if (lockInfo.isSymbolicLink() || !lockInfo.isFile()) {
8784
+ throw new Error("models.json transaction lock path is unsafe");
8785
+ }
8786
+ ownedLockIdentity = lockInfo;
8787
+ break;
8788
+ } catch (error) {
8789
+ if (getErrorCode(error) !== "EEXIST") throw error;
8790
+ const lockInfo = await lstat(MODELS_TRANSACTION_LOCK_PATH).catch(() => undefined);
8791
+ if (!lockInfo) continue;
8792
+ if (lockInfo.isSymbolicLink() || !lockInfo.isFile()) {
8793
+ throw new Error("models.json transaction lock path is unsafe");
8794
+ }
8795
+ let staleOwnerPid: number | undefined;
8796
+ try {
8797
+ const owner = asRecord(JSON.parse(await readFile(MODELS_TRANSACTION_LOCK_PATH, "utf8")));
8798
+ if (typeof owner?.pid === "number") staleOwnerPid = owner.pid;
8799
+ } catch {}
8800
+ const stale = Date.now() - lockInfo.mtimeMs > MODELS_TRANSACTION_LOCK_STALE_MS;
8801
+ if (stale && (staleOwnerPid === undefined || !isProcessAlive(staleOwnerPid))) {
8802
+ // Recheck identity immediately before unlinking so a recovered owner
8803
+ // cannot delete a replacement lease created at the same path.
8804
+ const currentLock = await lstat(MODELS_TRANSACTION_LOCK_PATH).catch(() => undefined);
8805
+ if (currentLock && sameFileIdentity(lockInfo, currentLock)) {
8806
+ await unlink(MODELS_TRANSACTION_LOCK_PATH).catch((unlinkError) => {
8807
+ if (getErrorCode(unlinkError) !== "ENOENT") throw unlinkError;
8808
+ });
8809
+ }
8810
+ continue;
8811
+ }
8812
+ if (Date.now() >= deadline) {
8813
+ throw new Error("another cache-optimizer models.json transaction is still running");
8814
+ }
8815
+ await new Promise((resolve) => setTimeout(resolve, 25));
8816
+ }
8817
+ }
8818
+
8819
+ try {
8820
+ return await operation();
8821
+ } finally {
8822
+ // Only the matching inode and owner token may remove the lease. A
8823
+ // recovered/replaced lock belongs to another transaction.
8824
+ try {
8825
+ const currentLock = await lstat(MODELS_TRANSACTION_LOCK_PATH);
8826
+ if (
8827
+ ownedLockIdentity &&
8828
+ !currentLock.isSymbolicLink() &&
8829
+ currentLock.isFile() &&
8830
+ sameFileIdentity(ownedLockIdentity, currentLock) &&
8831
+ await readFile(MODELS_TRANSACTION_LOCK_PATH, "utf8") === ownerText
8832
+ ) {
8833
+ await unlink(MODELS_TRANSACTION_LOCK_PATH);
8834
+ }
8835
+ } catch (error) {
8836
+ if (getErrorCode(error) !== "ENOENT") {
8837
+ console.warn(`${LOG_PREFIX}: failed to remove models.json transaction lock`, error);
8838
+ }
8839
+ }
8840
+ }
8841
+ }
8842
+
8843
+ async function assertModelsJsonFixReceiptSnapshotUnchanged(
8844
+ snapshot: ModelsJsonFixReceiptSnapshot,
8845
+ ): Promise<void> {
8846
+ const info = await lstat(snapshot.receiptPath);
8847
+ if (
8848
+ info.isSymbolicLink() ||
8849
+ !info.isFile() ||
8850
+ !sameFileIdentity(snapshot.identity, info)
8851
+ ) {
8852
+ throw new Error("fix receipt changed since the rollback preview");
8853
+ }
8854
+ const text = await readFile(snapshot.receiptPath, "utf8");
8855
+ const afterRead = await lstat(snapshot.receiptPath);
8856
+ if (
8857
+ afterRead.isSymbolicLink() ||
8858
+ !afterRead.isFile() ||
8859
+ !sameFileIdentity(info, afterRead) ||
8860
+ hashText(text) !== snapshot.hash
8861
+ ) {
8862
+ throw new Error("fix receipt changed since the rollback preview");
8863
+ }
8864
+ }
8865
+
8866
+ async function applyModelsJsonFixTransactionUnderLock(
7448
8867
  modifiedText: string,
7449
8868
  backupPath: string,
7450
8869
  validateWrittenText: (writtenText: string) => string | null,
8870
+ options: ModelsJsonFixTransactionOptions = {},
7451
8871
  ): Promise<ModelsJsonFixTransactionResult> {
7452
- const originalMode = (await stat(MODELS_JSON_PATH)).mode & 0o7777;
8872
+ if (options.receiptGuard) {
8873
+ await assertModelsJsonFixReceiptSnapshotUnchanged(options.receiptGuard);
8874
+ }
8875
+ const targetInfo = await lstat(MODELS_JSON_PATH);
8876
+ if (!targetInfo.isFile() || targetInfo.isSymbolicLink()) {
8877
+ throw new Error("models.json is not a regular file; no changes were made");
8878
+ }
8879
+ const originalMode = targetInfo.mode & 0o7777;
8880
+ const transactionName = options.purpose === "rollback" ? "rollback" : "fix";
8881
+ if (options.expectedCurrentMode !== undefined && options.expectedCurrentMode !== originalMode) {
8882
+ throw new Error(`models.json access mode changed since the ${transactionName} preview; no changes were made`);
8883
+ }
8884
+ const initialTargetText = await readFile(MODELS_JSON_PATH, "utf8");
8885
+ const transactionInitialHash = hashText(initialTargetText);
8886
+ const expectedCurrentHash = options.expectedCurrentHash ?? transactionInitialHash;
8887
+ const expectedBackupHash = options.expectedBackupHash ?? expectedCurrentHash;
8888
+ const assertCurrentTargetUnchanged = async (): Promise<void> => {
8889
+ const currentInfo = await lstat(MODELS_JSON_PATH);
8890
+ if (
8891
+ currentInfo.isSymbolicLink() ||
8892
+ !currentInfo.isFile() ||
8893
+ !sameFileIdentity(targetInfo, currentInfo) ||
8894
+ (currentInfo.mode & 0o7777) !== originalMode
8895
+ ) {
8896
+ throw new Error(`models.json changed during ${transactionName}; no changes were made`);
8897
+ }
8898
+ const currentText = await readFile(MODELS_JSON_PATH, "utf8");
8899
+ if (hashText(currentText) !== expectedCurrentHash) {
8900
+ throw new Error(`models.json changed since the ${transactionName} preview; no changes were made`);
8901
+ }
8902
+ };
8903
+ const assertBackupUnchanged = async (): Promise<void> => {
8904
+ const backupInfo = await lstat(backupPath);
8905
+ if (backupInfo.isSymbolicLink() || !backupInfo.isFile()) {
8906
+ throw new Error(`models.json ${transactionName} backup is not a regular file; no changes were made`);
8907
+ }
8908
+ if (expectedBackupHash !== undefined) {
8909
+ const backupText = await readFile(backupPath, "utf8");
8910
+ if (hashText(backupText) !== expectedBackupHash) {
8911
+ throw new Error(`models.json ${transactionName} backup changed during preparation; no changes were made`);
8912
+ }
8913
+ }
8914
+ };
8915
+
8916
+ await assertCurrentTargetUnchanged();
7453
8917
  await copyFile(MODELS_JSON_PATH, backupPath, fsConstants.COPYFILE_EXCL);
8918
+ const createdBackupInfo = await lstat(backupPath);
8919
+ if (createdBackupInfo.isSymbolicLink() || !createdBackupInfo.isFile()) {
8920
+ throw new Error(`models.json ${transactionName} backup is not a regular file; no changes were made`);
8921
+ }
7454
8922
  await chmod(backupPath, originalMode);
8923
+ const chmodBackupInfo = await lstat(backupPath);
8924
+ if (
8925
+ chmodBackupInfo.isSymbolicLink() ||
8926
+ !chmodBackupInfo.isFile() ||
8927
+ !sameFileIdentity(createdBackupInfo, chmodBackupInfo)
8928
+ ) {
8929
+ throw new Error(`models.json ${transactionName} backup changed during preparation; no changes were made`);
8930
+ }
8931
+ await assertBackupUnchanged();
7455
8932
 
8933
+ const expectedModifiedHash = hashText(modifiedText);
7456
8934
  let targetReplaced = false;
8935
+ const restoreTargetIfUnchanged = async (): Promise<void> => {
8936
+ const currentInfo = await lstat(MODELS_JSON_PATH);
8937
+ if (
8938
+ currentInfo.isSymbolicLink() ||
8939
+ !currentInfo.isFile() ||
8940
+ (currentInfo.mode & 0o7777) !== originalMode
8941
+ ) {
8942
+ throw new Error(`models.json changed after ${transactionName} replacement; refusing to overwrite user changes`);
8943
+ }
8944
+ const currentText = await readFile(MODELS_JSON_PATH, "utf8");
8945
+ if (hashText(currentText) !== expectedModifiedHash) {
8946
+ throw new Error(`models.json changed after ${transactionName} replacement; refusing to overwrite user changes`);
8947
+ }
8948
+ await assertBackupUnchanged();
8949
+ await atomicRestoreFileFromBackup(backupPath, MODELS_JSON_PATH, originalMode, {
8950
+ identity: currentInfo,
8951
+ hash: expectedModifiedHash,
8952
+ mode: originalMode,
8953
+ backupHash: expectedBackupHash,
8954
+ });
8955
+ const restoredInfo = await lstat(MODELS_JSON_PATH);
8956
+ if (restoredInfo.isSymbolicLink() || !restoredInfo.isFile()) {
8957
+ throw new Error(`models.json restore produced a non-regular file`);
8958
+ }
8959
+ const restoredText = await readFile(MODELS_JSON_PATH, "utf8");
8960
+ if (expectedBackupHash !== undefined && hashText(restoredText) !== expectedBackupHash) {
8961
+ throw new Error(`models.json restore did not match the transaction backup`);
8962
+ }
8963
+ };
8964
+
7457
8965
  try {
8966
+ await assertCurrentTargetUnchanged();
7458
8967
  await atomicReplaceTextFilePreservingMode(
7459
8968
  MODELS_JSON_PATH,
7460
8969
  modifiedText,
7461
8970
  originalMode,
7462
- "fix",
8971
+ options.purpose ?? "fix",
8972
+ {
8973
+ identity: targetInfo,
8974
+ hash: expectedCurrentHash,
8975
+ mode: originalMode,
8976
+ },
7463
8977
  );
7464
8978
  targetReplaced = true;
7465
8979
 
8980
+ const writtenInfo = await lstat(MODELS_JSON_PATH);
8981
+ if (writtenInfo.isSymbolicLink() || !writtenInfo.isFile()) {
8982
+ throw new Error(`models.json became a non-regular file during ${transactionName} replacement`);
8983
+ }
7466
8984
  const writtenText = await readFile(MODELS_JSON_PATH, "utf8");
7467
8985
  const postCheckError = validateWrittenText(writtenText);
7468
- const writtenMode = (await stat(MODELS_JSON_PATH)).mode & 0o7777;
7469
- const effectiveError = postCheckError ?? (
7470
- writtenMode === originalMode
7471
- ? null
7472
- : `models.json access mode changed from ${originalMode.toString(8)} to ${writtenMode.toString(8)}`
7473
- );
8986
+ const writtenMode = writtenInfo.mode & 0o7777;
8987
+ const writeHashError = hashText(writtenText) === expectedModifiedHash
8988
+ ? null
8989
+ : `models.json changed during ${transactionName} replacement`;
8990
+ const modeError = writtenMode === originalMode
8991
+ ? null
8992
+ : `models.json access mode changed from ${originalMode.toString(8)} to ${writtenMode.toString(8)}`;
8993
+ const effectiveError = postCheckError ?? writeHashError ?? modeError;
7474
8994
  if (effectiveError !== null) {
7475
- await atomicRestoreFileFromBackup(backupPath, MODELS_JSON_PATH, originalMode);
8995
+ await restoreTargetIfUnchanged();
8996
+ targetReplaced = false;
7476
8997
  return { ok: false, postCheckError: effectiveError };
7477
8998
  }
7478
8999
 
9000
+ // Re-check immediately before committing receipt metadata. If a caller
9001
+ // changed the replacement after validation, do not mark the transaction
9002
+ // successful and do not restore over that caller's change.
9003
+ await validateAtomicTarget(MODELS_JSON_PATH, {
9004
+ identity: writtenInfo,
9005
+ hash: expectedModifiedHash,
9006
+ mode: originalMode,
9007
+ });
9008
+
9009
+ // A receipt is part of a successful fix transaction. If its atomic write
9010
+ // fails, the catch path restores the models file from the transaction
9011
+ // backup instead of leaving an un-recoverable configuration change.
9012
+ if (options.receiptGuard) {
9013
+ await assertModelsJsonFixReceiptSnapshotUnchanged(options.receiptGuard);
9014
+ }
9015
+ await options.onCommitted?.(writtenText);
7479
9016
  return { ok: true };
7480
9017
  } catch (error) {
7481
9018
  if (targetReplaced) {
7482
9019
  try {
7483
- await atomicRestoreFileFromBackup(backupPath, MODELS_JSON_PATH, originalMode);
9020
+ await restoreTargetIfUnchanged();
9021
+ targetReplaced = false;
7484
9022
  } catch (restoreError) {
7485
9023
  throw new AggregateError(
7486
9024
  [error, restoreError],
@@ -7492,7 +9030,631 @@ async function applyModelsJsonFixTransaction(
7492
9030
  }
7493
9031
  }
7494
9032
 
7495
- // Internal helpers exported only so the task verification script
9033
+ async function applyModelsJsonFixTransaction(
9034
+ modifiedText: string,
9035
+ backupPath: string,
9036
+ validateWrittenText: (writtenText: string) => string | null,
9037
+ options: ModelsJsonFixTransactionOptions = {},
9038
+ ): Promise<ModelsJsonFixTransactionResult> {
9039
+ return withModelsJsonTransactionLock(() =>
9040
+ applyModelsJsonFixTransactionUnderLock(modifiedText, backupPath, validateWrittenText, options)
9041
+ );
9042
+ }
9043
+
9044
+ function isReceiptScalar(value: unknown): value is ReceiptScalar {
9045
+ return value === null || typeof value === "string" || typeof value === "boolean" ||
9046
+ (typeof value === "number" && Number.isFinite(value));
9047
+ }
9048
+
9049
+ function isReceiptScalarState(value: unknown): value is ReceiptScalarState {
9050
+ const record = asRecord(value);
9051
+ if (!record || record.present === undefined) return false;
9052
+ if (record.present === false) return Object.keys(record).every((key) => key === "present");
9053
+ return record.present === true && isReceiptScalar(record.value) && Object.keys(record).every((key) => key === "present" || key === "value");
9054
+ }
9055
+
9056
+ function sameReceiptScalarState(left: ReceiptScalarState, right: ReceiptScalarState): boolean {
9057
+ if (left.present !== right.present) return false;
9058
+ return !left.present || left.value === (right as { present: true; value: ReceiptScalar }).value;
9059
+ }
9060
+
9061
+ function isFixReceiptPlacement(value: unknown): value is FixReceiptPlacement {
9062
+ return value === "provider" || value === "model" || value === "modelOverride";
9063
+ }
9064
+
9065
+ function isSha256(value: unknown): value is string {
9066
+ return typeof value === "string" && /^[0-9a-f]{64}$/i.test(value);
9067
+ }
9068
+
9069
+ function hasReceiptReasoningProtocolChange(receipt: ModelsJsonFixReceiptV1): boolean {
9070
+ return [
9071
+ "thinkingFormat",
9072
+ "supportsReasoningEffort",
9073
+ "requiresReasoningContentOnAssistantMessages",
9074
+ ].some((key) => Object.prototype.hasOwnProperty.call(receipt.changedKeys, key));
9075
+ }
9076
+
9077
+ function isActionableModelsJsonFixReceipt(receipt: ModelsJsonFixReceiptV1 | undefined): receipt is ModelsJsonFixReceiptV1 {
9078
+ return receipt !== undefined && receipt.status === undefined;
9079
+ }
9080
+
9081
+ function createRollbackBackupPath(modelsPath: string = MODELS_JSON_PATH): string {
9082
+ return `${modelsPath}.backup-cache-optimizer-rollback-${backupTimestamp()}`;
9083
+ }
9084
+
9085
+ function isSafeReceiptText(value: unknown): value is string {
9086
+ return isNonEmptyString(value) && !/[\u0000-\u001f\u007f]/.test(value);
9087
+ }
9088
+
9089
+ function isReceiptTimestamp(value: unknown): value is number {
9090
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
9091
+ }
9092
+
9093
+ function parseModelsJsonFixReceipt(value: unknown): ModelsJsonFixReceiptV1 | undefined {
9094
+ const record = asRecord(value);
9095
+ if (!record || record.version !== 1 || record.kind !== "pi-cache-optimizer-fix-receipt") return undefined;
9096
+ const allowedKeys = new Set([
9097
+ "version", "kind", "transactionId", "provider", "modelId", "placement",
9098
+ "targetExistedBefore", "changedKeys", "beforeHash", "afterHash", "backupFile",
9099
+ "createdAt", "appliedAt", "status", "rolledBackAt",
9100
+ ]);
9101
+ if (Object.keys(record).some((key) => !allowedKeys.has(key))) return undefined;
9102
+ if (!isSafeReceiptText(record.transactionId) || !isSafeReceiptText(record.provider) || !isSafeReceiptText(record.modelId)) return undefined;
9103
+ if (!isFixReceiptPlacement(record.placement) || typeof record.targetExistedBefore !== "boolean") return undefined;
9104
+ if (!isSha256(record.beforeHash) || !isSha256(record.afterHash) || record.beforeHash === record.afterHash) return undefined;
9105
+ if (!isSafeReceiptText(record.backupFile) || basename(record.backupFile) !== record.backupFile || record.backupFile.includes("..")) return undefined;
9106
+ if (!/^models\.json\.backup-cache-optimizer-/.test(record.backupFile)) return undefined;
9107
+ const beforeHash = typeof record.beforeHash === "string" ? record.beforeHash.toLowerCase() : "";
9108
+ const afterHash = typeof record.afterHash === "string" ? record.afterHash.toLowerCase() : "";
9109
+ if (beforeHash === afterHash) return undefined;
9110
+ if (!isReceiptTimestamp(record.createdAt) || !isReceiptTimestamp(record.appliedAt) || record.appliedAt < record.createdAt) return undefined;
9111
+ const changedKeys = asRecord(record.changedKeys);
9112
+ if (!changedKeys || Object.keys(changedKeys).length === 0) return undefined;
9113
+
9114
+ const parsedChanges: Record<string, FixReceiptCompatChange> = {};
9115
+ for (const [key, rawChange] of Object.entries(changedKeys)) {
9116
+ if (!RECEIPT_COMPAT_KEYS.has(key)) return undefined;
9117
+ const change = asRecord(rawChange);
9118
+ if (!change || !isReceiptScalarState(change.before) || !isReceiptScalarState(change.after)) return undefined;
9119
+ if (sameReceiptScalarState(change.before, change.after)) return undefined;
9120
+ parsedChanges[key] = {
9121
+ before: change.before,
9122
+ after: change.after,
9123
+ };
9124
+ }
9125
+
9126
+ if (record.status !== undefined && record.status !== "rolled_back") return undefined;
9127
+ if (record.status === "rolled_back" && (!isReceiptTimestamp(record.rolledBackAt) || record.rolledBackAt < record.appliedAt)) return undefined;
9128
+ if (record.status === undefined && record.rolledBackAt !== undefined) return undefined;
9129
+
9130
+ return {
9131
+ version: 1,
9132
+ kind: "pi-cache-optimizer-fix-receipt",
9133
+ transactionId: record.transactionId.trim(),
9134
+ provider: record.provider.trim(),
9135
+ modelId: record.modelId.trim(),
9136
+ placement: record.placement,
9137
+ targetExistedBefore: record.targetExistedBefore,
9138
+ changedKeys: parsedChanges,
9139
+ beforeHash,
9140
+ afterHash,
9141
+ backupFile: record.backupFile,
9142
+ createdAt: Number(record.createdAt),
9143
+ appliedAt: Number(record.appliedAt),
9144
+ ...(record.status === "rolled_back" ? { status: "rolled_back", rolledBackAt: Number(record.rolledBackAt) } : {}),
9145
+ };
9146
+ }
9147
+
9148
+ function receiptBackupPath(receipt: ModelsJsonFixReceiptV1, receiptPath: string = FIX_RECEIPT_PATH): string {
9149
+ return join(dirname(receiptPath), receipt.backupFile);
9150
+ }
9151
+
9152
+ async function writeModelsJsonFixReceipt(
9153
+ receipt: ModelsJsonFixReceiptV1,
9154
+ receiptPath: string = FIX_RECEIPT_PATH,
9155
+ expectedSnapshot?: ModelsJsonFixReceiptSnapshot,
9156
+ /** Test-only race injector; production callers leave this undefined. */
9157
+ beforeRename?: () => Promise<void>,
9158
+ ): Promise<void> {
9159
+ if (!parseModelsJsonFixReceipt(receipt)) throw new Error("invalid fix receipt");
9160
+ if (expectedSnapshot) {
9161
+ if (expectedSnapshot.receiptPath !== receiptPath) throw new Error("fix receipt path changed since the rollback preview");
9162
+ await assertModelsJsonFixReceiptSnapshotUnchanged(expectedSnapshot);
9163
+ }
9164
+ await mkdir(dirname(receiptPath), { recursive: true });
9165
+ let existingReceiptInfo: { dev: number; ino: number } | undefined;
9166
+ try {
9167
+ const info = await lstat(receiptPath);
9168
+ if (info.isSymbolicLink() || !info.isFile()) throw new Error("invalid fix receipt path");
9169
+ existingReceiptInfo = info;
9170
+ } catch (error) {
9171
+ if (getErrorCode(error) !== "ENOENT") throw error;
9172
+ }
9173
+ const mode = 0o600;
9174
+
9175
+ const tempPath = uniqueTempPath(receiptPath, "receipt");
9176
+ try {
9177
+ await writeFile(tempPath, JSON.stringify(receipt, null, 2) + "\n", { encoding: "utf8", mode, flag: "wx" });
9178
+ const tempInfo = await lstat(tempPath);
9179
+ if (tempInfo.isSymbolicLink() || !tempInfo.isFile()) throw new Error("invalid temporary fix receipt");
9180
+ await chmod(tempPath, mode);
9181
+ if (expectedSnapshot) {
9182
+ await assertModelsJsonFixReceiptSnapshotUnchanged(expectedSnapshot);
9183
+ }
9184
+ const assertReceiptDestinationUnchanged = async (): Promise<void> => {
9185
+ try {
9186
+ const currentReceiptInfo = await lstat(receiptPath);
9187
+ if (!existingReceiptInfo || !sameFileIdentity(existingReceiptInfo, currentReceiptInfo)) {
9188
+ throw new Error("fix receipt changed during atomic write");
9189
+ }
9190
+ } catch (error) {
9191
+ if (getErrorCode(error) === "ENOENT" && !existingReceiptInfo) {
9192
+ // The destination was absent both before and immediately before the
9193
+ // rename. The atomic rename will create it safely.
9194
+ } else {
9195
+ throw error;
9196
+ }
9197
+ }
9198
+ if (expectedSnapshot) await assertModelsJsonFixReceiptSnapshotUnchanged(expectedSnapshot);
9199
+ };
9200
+ await assertReceiptDestinationUnchanged();
9201
+ if (beforeRename) await beforeRename();
9202
+ await assertReceiptDestinationUnchanged();
9203
+ await rename(tempPath, receiptPath);
9204
+ } catch (error) {
9205
+ try {
9206
+ await unlink(tempPath);
9207
+ } catch (cleanupError) {
9208
+ if (getErrorCode(cleanupError) !== "ENOENT") console.warn(`${LOG_PREFIX}: failed to remove temporary fix receipt`, cleanupError);
9209
+ }
9210
+ throw error;
9211
+ }
9212
+ }
9213
+
9214
+ async function readModelsJsonFixReceiptSnapshot(
9215
+ receiptPath: string = FIX_RECEIPT_PATH,
9216
+ ): Promise<ModelsJsonFixReceiptSnapshot | undefined> {
9217
+ try {
9218
+ const info = await lstat(receiptPath);
9219
+ if (info.isSymbolicLink() || !info.isFile()) return undefined;
9220
+ const text = await readFile(receiptPath, "utf8");
9221
+ const afterRead = await lstat(receiptPath);
9222
+ if (
9223
+ afterRead.isSymbolicLink() ||
9224
+ !afterRead.isFile() ||
9225
+ !sameFileIdentity(info, afterRead)
9226
+ ) return undefined;
9227
+ const receipt = parseModelsJsonFixReceipt(JSON.parse(text));
9228
+ if (!receipt) return undefined;
9229
+ return {
9230
+ receipt,
9231
+ receiptPath,
9232
+ hash: hashText(text),
9233
+ identity: afterRead,
9234
+ };
9235
+ } catch {
9236
+ return undefined;
9237
+ }
9238
+ }
9239
+
9240
+ async function readModelsJsonFixReceipt(
9241
+ receiptPath: string = FIX_RECEIPT_PATH,
9242
+ ): Promise<ModelsJsonFixReceiptV1 | undefined> {
9243
+ return (await readModelsJsonFixReceiptSnapshot(receiptPath))?.receipt;
9244
+ }
9245
+
9246
+ async function markModelsJsonFixReceiptRolledBack(
9247
+ snapshot: ModelsJsonFixReceiptSnapshot,
9248
+ ): Promise<void> {
9249
+ const receipt = snapshot.receipt;
9250
+ await writeModelsJsonFixReceipt({
9251
+ ...receipt,
9252
+ status: "rolled_back",
9253
+ rolledBackAt: Date.now(),
9254
+ }, snapshot.receiptPath, snapshot);
9255
+ }
9256
+
9257
+ type ReceiptCompatTarget = {
9258
+ targetExists: boolean;
9259
+ compatBrace: number;
9260
+ compatEnd: number;
9261
+ };
9262
+
9263
+ function locateReceiptCompatTarget(
9264
+ text: string,
9265
+ provider: string,
9266
+ modelId: string,
9267
+ placement: FixReceiptPlacement,
9268
+ ): ReceiptCompatTarget | undefined {
9269
+ if (placement === "modelOverride") {
9270
+ const location = locateModelOverrideInJsonc(text, provider, modelId);
9271
+ if (
9272
+ !location ||
9273
+ location.providerKeyCount !== 1 ||
9274
+ location.modelOverridesKeyCount > 1 ||
9275
+ location.modelOverrideKeyCount > 1 ||
9276
+ location.modelOverrideCompatKeyCount > 1
9277
+ ) return undefined;
9278
+ return {
9279
+ targetExists: location.modelOverrideObjectBrace >= 0,
9280
+ compatBrace: location.modelOverrideCompatBrace,
9281
+ compatEnd: location.modelOverrideCompatEnd,
9282
+ };
9283
+ }
9284
+
9285
+ const location = locateModelInJsonc(text, provider, modelId);
9286
+ if (!location || location.providerKeyCount !== 1) return undefined;
9287
+ // A changed-file surgical rollback cannot tell which duplicate model object
9288
+ // was the receipt's target. Refuse that ambiguity instead of touching a
9289
+ // later user-added definition. Provider-level changes do not need to inspect
9290
+ // model compat, but duplicate target ids still make the receipt identity
9291
+ // ambiguous and are rejected for both placements.
9292
+ if (location.allModelIds.filter((id) => id === modelId).length !== 1) return undefined;
9293
+ if (placement === "provider" && location.providerCompatKeyCount > 1) return undefined;
9294
+ if (placement === "model" && location.modelCompatKeyCount > 1) return undefined;
9295
+ return {
9296
+ targetExists: location.modelObjectBrace >= 0,
9297
+ compatBrace: placement === "provider" ? location.providerCompatBrace : location.compatObjectBrace,
9298
+ compatEnd: placement === "provider" ? location.providerCompatEnd : location.compatObjectEnd,
9299
+ };
9300
+ }
9301
+
9302
+ function readReceiptCompatScalarState(
9303
+ text: string,
9304
+ target: ReceiptCompatTarget | undefined,
9305
+ key: string,
9306
+ ): ReceiptScalarState | undefined {
9307
+ if (!target || target.compatBrace < 0 || target.compatEnd <= target.compatBrace) {
9308
+ return { present: false };
9309
+ }
9310
+ const clean = stripJsoncComments(text);
9311
+ const property = findJsonObjectKey(clean, target.compatBrace, key);
9312
+ if (!property || property.keyStart >= target.compatEnd) return { present: false };
9313
+ if (property.count !== 1) return undefined;
9314
+ const valueStart = skipJsonWhitespace(clean, property.valueStart);
9315
+ const valueEnd = skipJsonValue(clean, valueStart);
9316
+ if (valueEnd === undefined || valueEnd > target.compatEnd) return undefined;
9317
+ try {
9318
+ const value = parseJsonc(text.slice(valueStart, valueEnd));
9319
+ return isReceiptScalar(value) ? { present: true, value } : undefined;
9320
+ } catch {
9321
+ return undefined;
9322
+ }
9323
+ }
9324
+
9325
+ function createModelsJsonFixReceipt(
9326
+ originalText: string,
9327
+ modifiedText: string,
9328
+ provider: string,
9329
+ modelId: string,
9330
+ placement: FixReceiptPlacement,
9331
+ compatKeys: Record<string, unknown>,
9332
+ targetExistedBefore: boolean,
9333
+ backupPath: string,
9334
+ now: number = Date.now(),
9335
+ ): ModelsJsonFixReceiptV1 | undefined {
9336
+ const backupFile = basename(backupPath);
9337
+ if (!/^models\.json\.backup-cache-optimizer-/.test(backupFile)) return undefined;
9338
+ const beforeTarget = locateReceiptCompatTarget(originalText, provider, modelId, placement);
9339
+ const afterTarget = locateReceiptCompatTarget(modifiedText, provider, modelId, placement);
9340
+ if (targetExistedBefore && !beforeTarget?.targetExists) return undefined;
9341
+ if (!afterTarget?.targetExists) return undefined;
9342
+
9343
+ const changedKeys: Record<string, FixReceiptCompatChange> = {};
9344
+ for (const [key, afterValue] of Object.entries(compatKeys)) {
9345
+ if (!isReceiptScalar(afterValue)) return undefined;
9346
+ const before = readReceiptCompatScalarState(originalText, beforeTarget, key);
9347
+ const after = readReceiptCompatScalarState(modifiedText, afterTarget, key);
9348
+ if (!before || !after || !sameReceiptScalarState(after, { present: true, value: afterValue })) return undefined;
9349
+ if (!sameReceiptScalarState(before, after)) changedKeys[key] = { before, after };
9350
+ }
9351
+ if (Object.keys(changedKeys).length === 0) return undefined;
9352
+
9353
+ const receipt: ModelsJsonFixReceiptV1 = {
9354
+ version: 1,
9355
+ kind: "pi-cache-optimizer-fix-receipt",
9356
+ transactionId: randomUUID(),
9357
+ provider,
9358
+ modelId,
9359
+ placement,
9360
+ targetExistedBefore,
9361
+ changedKeys,
9362
+ beforeHash: hashText(originalText),
9363
+ afterHash: hashText(modifiedText),
9364
+ backupFile,
9365
+ createdAt: now,
9366
+ appliedAt: now,
9367
+ };
9368
+ return parseModelsJsonFixReceipt(receipt);
9369
+ }
9370
+
9371
+ function maskJsonSyntaxPreservingComments(text: string, start: number, end: number): string {
9372
+ // Replace only JSON syntax/value bytes. Comments are copied verbatim so a
9373
+ // user explanation attached to a receipt-owned key survives a surgical
9374
+ // rollback. Newlines are also retained to keep line structure unchanged.
9375
+ const output = text.slice(start, end).split("");
9376
+ let i = start;
9377
+ let inLineComment = false;
9378
+ let inBlockComment = false;
9379
+ let inString = false;
9380
+ let escaped = false;
9381
+ while (i < end) {
9382
+ const ch = text[i];
9383
+ const next = text[i + 1];
9384
+ const offset = i - start;
9385
+ if (inLineComment) {
9386
+ if (ch === "\n" || ch === "\r") inLineComment = false;
9387
+ i++;
9388
+ continue;
9389
+ }
9390
+ if (inBlockComment) {
9391
+ if (ch === "*" && next === "/") {
9392
+ i += 2;
9393
+ inBlockComment = false;
9394
+ } else {
9395
+ i++;
9396
+ }
9397
+ continue;
9398
+ }
9399
+ if (inString) {
9400
+ if (ch === "\n" || ch === "\r") {
9401
+ i++;
9402
+ continue;
9403
+ }
9404
+ output[offset] = " ";
9405
+ if (escaped) escaped = false;
9406
+ else if (ch === "\\") escaped = true;
9407
+ else if (ch === '"') inString = false;
9408
+ i++;
9409
+ continue;
9410
+ }
9411
+ if (ch === '"') {
9412
+ inString = true;
9413
+ output[offset] = " ";
9414
+ } else if (ch === "/" && next === "/") {
9415
+ inLineComment = true;
9416
+ i += 2;
9417
+ continue;
9418
+ } else if (ch === "/" && next === "*") {
9419
+ inBlockComment = true;
9420
+ i += 2;
9421
+ continue;
9422
+ } else if (ch !== "\n" && ch !== "\r") {
9423
+ output[offset] = " ";
9424
+ }
9425
+ i++;
9426
+ }
9427
+ return output.join("");
9428
+ }
9429
+
9430
+ type JsonPropertyEdit = { start: number; end: number; text: string };
9431
+
9432
+ function locateJsonPropertyValueSpan(
9433
+ text: string,
9434
+ objectBrace: number,
9435
+ objectEnd: number,
9436
+ key: string,
9437
+ ): { keyStart: number; valueStart: number; valueEnd: number; removal: JsonPropertyEdit } | undefined {
9438
+ const clean = stripJsoncComments(text);
9439
+ const property = findJsonObjectKey(clean, objectBrace, key);
9440
+ if (!property || property.keyStart >= objectEnd || property.count !== 1) return undefined;
9441
+ const valueStart = skipJsonWhitespace(clean, property.valueStart);
9442
+ const valueEnd = skipJsonValue(clean, valueStart);
9443
+ if (valueEnd === undefined || valueEnd > objectEnd) return undefined;
9444
+
9445
+ const next = skipJsonWhitespace(clean, valueEnd);
9446
+ if (clean[next] === ",") {
9447
+ return {
9448
+ keyStart: property.keyStart,
9449
+ valueStart,
9450
+ valueEnd,
9451
+ removal: {
9452
+ start: property.keyStart,
9453
+ end: next + 1,
9454
+ text: maskJsonSyntaxPreservingComments(text, property.keyStart, next + 1),
9455
+ },
9456
+ };
9457
+ }
9458
+
9459
+ let previous = property.keyStart - 1;
9460
+ while (previous >= objectBrace && isJsonWhitespace(clean[previous])) previous--;
9461
+ const removalStart = clean[previous] === "," ? previous : property.keyStart;
9462
+ return {
9463
+ keyStart: property.keyStart,
9464
+ valueStart,
9465
+ valueEnd,
9466
+ removal: {
9467
+ start: removalStart,
9468
+ end: valueEnd,
9469
+ text: maskJsonSyntaxPreservingComments(text, removalStart, valueEnd),
9470
+ },
9471
+ };
9472
+ }
9473
+
9474
+ function composeModelsJsonReceiptRollback(
9475
+ currentText: string,
9476
+ receipt: ModelsJsonFixReceiptV1,
9477
+ ): { modifiedText: string; changed: boolean } | { error: string } {
9478
+ if (!receipt.targetExistedBefore) {
9479
+ return { error: "the fix created a new target entry and the file changed; refusing to remove user changes automatically" };
9480
+ }
9481
+
9482
+ const target = locateReceiptCompatTarget(currentText, receipt.provider, receipt.modelId, receipt.placement);
9483
+ if (!target?.targetExists) {
9484
+ return { error: "the original target entry is missing or no longer safely locatable" };
9485
+ }
9486
+ if (target.compatBrace < 0 || target.compatEnd <= target.compatBrace) {
9487
+ return { error: "the target compat object is missing or no longer safely locatable" };
9488
+ }
9489
+
9490
+ const edits: JsonPropertyEdit[] = [];
9491
+ for (const [key, change] of Object.entries(receipt.changedKeys)) {
9492
+ const current = readReceiptCompatScalarState(currentText, target, key);
9493
+ if (!current || !sameReceiptScalarState(current, change.after)) {
9494
+ return { error: `receipt-owned compat.${key} was changed after the fix; refusing to overwrite it` };
9495
+ }
9496
+ const property = locateJsonPropertyValueSpan(currentText, target.compatBrace, target.compatEnd, key);
9497
+ if (!property) return { error: `receipt-owned compat.${key} is no longer safely locatable` };
9498
+ if (change.before.present) {
9499
+ edits.push({
9500
+ start: property.valueStart,
9501
+ end: property.valueEnd,
9502
+ text: JSON.stringify(change.before.value),
9503
+ });
9504
+ } else {
9505
+ edits.push(property.removal);
9506
+ }
9507
+ }
9508
+
9509
+ const modifiedText = edits
9510
+ .sort((left, right) => right.start - left.start)
9511
+ .reduce((text, edit) => text.slice(0, edit.start) + edit.text + text.slice(edit.end), currentText);
9512
+ return { modifiedText, changed: modifiedText !== currentText };
9513
+ }
9514
+
9515
+ function validateModelsJsonRollback(
9516
+ writtenText: string,
9517
+ receipt: ModelsJsonFixReceiptV1,
9518
+ expectedHash?: string,
9519
+ ): string | null {
9520
+ if (expectedHash !== undefined && hashText(writtenText) !== expectedHash) {
9521
+ return "rollback result hash did not match the expected pre-fix file";
9522
+ }
9523
+ try {
9524
+ parseJsonc(writtenText);
9525
+ } catch {
9526
+ return "rollback result is not valid JSONC";
9527
+ }
9528
+ // An exact hash check proves the complete pre-fix document, including a
9529
+ // newly created target entry (which intentionally did not exist before).
9530
+ if (expectedHash !== undefined) return null;
9531
+
9532
+ const target = locateReceiptCompatTarget(writtenText, receipt.provider, receipt.modelId, receipt.placement);
9533
+ if (!target?.targetExists) return "rollback result lost the original target entry";
9534
+ for (const [key, change] of Object.entries(receipt.changedKeys)) {
9535
+ const state = readReceiptCompatScalarState(writtenText, target, key);
9536
+ if (!state || !sameReceiptScalarState(state, change.before)) return `rollback result did not restore compat.${key}`;
9537
+ }
9538
+ return null;
9539
+ }
9540
+
9541
+ type ModelsJsonRollbackPlan = {
9542
+ receiptSnapshot: ModelsJsonFixReceiptSnapshot;
9543
+ receipt: ModelsJsonFixReceiptV1;
9544
+ currentHash: string;
9545
+ modifiedText: string;
9546
+ expectedResultHash?: string;
9547
+ mode: "exact" | "surgical";
9548
+ fileMode: number;
9549
+ originalBackupPath: string;
9550
+ rollbackBackupPath: string;
9551
+ };
9552
+
9553
+ async function readRegularTextFile(path: string): Promise<{ text: string; mode: number }> {
9554
+ const info = await lstat(path);
9555
+ if (info.isSymbolicLink() || !info.isFile()) {
9556
+ throw new Error("file is not a regular file");
9557
+ }
9558
+ const text = await readFile(path, "utf8");
9559
+ const afterRead = await lstat(path);
9560
+ if (
9561
+ afterRead.isSymbolicLink() ||
9562
+ !afterRead.isFile() ||
9563
+ !sameFileIdentity(info, afterRead)
9564
+ ) {
9565
+ throw new Error("file changed while it was being read");
9566
+ }
9567
+ return {
9568
+ text,
9569
+ mode: afterRead.mode & 0o7777,
9570
+ };
9571
+ }
9572
+
9573
+ async function prepareModelsJsonRollback(
9574
+ receiptSnapshot: ModelsJsonFixReceiptSnapshot | undefined,
9575
+ modelsPath: string = MODELS_JSON_PATH,
9576
+ ): Promise<ModelsJsonRollbackPlan | { error: string }> {
9577
+ const receipt = receiptSnapshot?.receipt;
9578
+ if (!receiptSnapshot || !isActionableModelsJsonFixReceipt(receipt)) {
9579
+ return { error: "No unapplied /cache-optimizer fix receipt was found." };
9580
+ }
9581
+
9582
+ let current: { text: string; mode: number };
9583
+ try {
9584
+ current = await readRegularTextFile(modelsPath);
9585
+ } catch {
9586
+ return { error: "models.json is missing or is not a regular file; no changes were made." };
9587
+ }
9588
+ const currentHash = hashText(current.text);
9589
+ const originalBackupPath = join(dirname(modelsPath), receipt.backupFile);
9590
+ const rollbackBackupPath = createRollbackBackupPath(modelsPath);
9591
+
9592
+ if (currentHash === receipt.afterHash) {
9593
+ let backup: { text: string; mode: number };
9594
+ try {
9595
+ backup = await readRegularTextFile(originalBackupPath);
9596
+ } catch {
9597
+ return { error: "The receipt backup is missing or is not a regular file; refusing to overwrite models.json." };
9598
+ }
9599
+ if (hashText(backup.text) !== receipt.beforeHash) {
9600
+ return { error: "The receipt backup hash does not match the recorded pre-fix file; refusing to overwrite models.json." };
9601
+ }
9602
+ try {
9603
+ parseJsonc(backup.text);
9604
+ } catch {
9605
+ return { error: "The receipt backup is not valid JSONC; refusing to overwrite models.json." };
9606
+ }
9607
+ return {
9608
+ receiptSnapshot,
9609
+ receipt,
9610
+ currentHash,
9611
+ modifiedText: backup.text,
9612
+ expectedResultHash: receipt.beforeHash,
9613
+ mode: "exact",
9614
+ fileMode: current.mode,
9615
+ originalBackupPath,
9616
+ rollbackBackupPath,
9617
+ };
9618
+ }
9619
+
9620
+ if (!receipt.targetExistedBefore) {
9621
+ return {
9622
+ error: "models.json changed after the fix, and the fix created the target entry. Refusing to remove or overwrite unrelated user changes; use the recorded backup for manual guidance.",
9623
+ };
9624
+ }
9625
+
9626
+ // A changed file is eligible only for a scalar, receipt-owned surgical
9627
+ // rollback. Validate the current document before showing a confirmation;
9628
+ // malformed JSONC must never be replaced by an edit derived from offsets.
9629
+ try {
9630
+ parseJsonc(current.text);
9631
+ } catch {
9632
+ return { error: "models.json changed after the fix and is not valid JSONC; refusing to overwrite user changes. Use the recorded backup for manual guidance." };
9633
+ }
9634
+
9635
+ const surgical = composeModelsJsonReceiptRollback(current.text, receipt);
9636
+ if ("error" in surgical) {
9637
+ return {
9638
+ error: `models.json changed after the fix. ${surgical.error}. Use the recorded backup for manual guidance.`,
9639
+ };
9640
+ }
9641
+ if (!surgical.changed) {
9642
+ return { error: "No receipt-owned change remains to roll back." };
9643
+ }
9644
+
9645
+ return {
9646
+ receiptSnapshot,
9647
+ receipt,
9648
+ currentHash,
9649
+ modifiedText: surgical.modifiedText,
9650
+ mode: "surgical",
9651
+ fileMode: current.mode,
9652
+ originalBackupPath,
9653
+ rollbackBackupPath,
9654
+ };
9655
+ }
9656
+
9657
+ // Internal helpers exported only so the task verification script
7496
9658
  // (.trellis/tasks/.../verify.ts) can exercise them. They are not part of the
7497
9659
  // extension's public API; pi only invokes the default export below.
7498
9660
  export const __internals_for_tests = {
@@ -7511,6 +9673,8 @@ export const __internals_for_tests = {
7511
9673
  addOpenAIPromptCacheKey,
7512
9674
  clampPromptCacheKey,
7513
9675
  hasEffectivePromptCacheKey,
9676
+ omitOpenAIPromptCacheKeys,
9677
+ isPromptCacheKeyOmittedForModel,
7514
9678
  isNonEmptyString,
7515
9679
  shouldInjectOpenAIPromptCacheKey,
7516
9680
  shouldInjectOpenAIPromptCacheKeyForModel,
@@ -7531,7 +9695,9 @@ export const __internals_for_tests = {
7531
9695
  describeMissingOpenAICompatibleProxyCompat,
7532
9696
  describeOptionalOpenAICompatibleProxyCompat,
7533
9697
  describeMissingDeepSeekCompat,
9698
+ isDeepSeekWireCompatApplicable,
7534
9699
  isDeepSeekCompatCheckApplicable,
9700
+ hasExplicitDeepSeekReasoningProtocol,
7535
9701
  describeMissingCacheCompatForModel,
7536
9702
  buildDeepSeekCompatSuggestion,
7537
9703
  buildDeepSeekCompatWarningText,
@@ -7545,6 +9711,15 @@ export const __internals_for_tests = {
7545
9711
  isOpenAISdkHeader403Applicable,
7546
9712
  hasPromptCacheRetentionUnsupportedSignal,
7547
9713
  hasPromptCacheRetentionUnsupportedErrorMessage,
9714
+ hasPromptCacheKeyUnsupportedSignal,
9715
+ hasPromptCacheKeyUnsupportedErrorMessage,
9716
+ isPromptCacheKeyUnsupportedApplicable,
9717
+ hasReasoningProtocolRejectionText,
9718
+ hasReasoningProtocolRejectionSignal,
9719
+ hasReasoningProtocolRejectionErrorMessage,
9720
+ isReasoningProtocolRejectionSignalApplicable,
9721
+ isReasoningProtocolRejectionForModel,
9722
+ buildReasoningProtocolFixSuggestion,
7548
9723
  // Non-GPT OpenAI-compatible model detection
7549
9724
  isKimiLikeModel,
7550
9725
  isKimiLikeAssistantMessage,
@@ -7666,6 +9841,12 @@ export const __internals_for_tests = {
7666
9841
  isValidModelsConfigForEffectiveCompat,
7667
9842
  addEffectiveSessionAffinityHeaders,
7668
9843
  getCompat,
9844
+ isKnownToolOrderApi,
9845
+ getToolNameForPayload,
9846
+ compareToolOrderEntries,
9847
+ isVerifiedToolForApi,
9848
+ normalizeToolsInPayload,
9849
+ sortToolsInPayload,
7669
9850
  modelKey,
7670
9851
  modelFromAssistantMessage,
7671
9852
  consolidateDirectProviderStatsModel,
@@ -7683,6 +9864,8 @@ export const __internals_for_tests = {
7683
9864
  formatOptimizerRuntimeMode,
7684
9865
  PI_CACHE_RETENTION_ENV,
7685
9866
  LONG_CACHE_RETENTION_VALUE,
9867
+ TOOL_ORDER_ENV,
9868
+ isToolOrderEnabled,
7686
9869
  // Integrity diagnostics
7687
9870
  getLastPromptIntegrityWarningAt,
7688
9871
  // Diagnostic command helpers
@@ -7725,15 +9908,27 @@ export const __internals_for_tests = {
7725
9908
  selectFooterStatsForModel,
7726
9909
  parseFooterStatsMode,
7727
9910
  parsePersistedCacheOptimizerConfig,
9911
+ readPersistedCacheOptimizerConfig,
9912
+ writePersistedCacheOptimizerConfig,
7728
9913
  readPersistedFooterMode,
7729
9914
  writePersistedFooterMode,
9915
+ setPersistedCacheOptimizerConfig,
7730
9916
  resolveFooterStatsMode,
7731
9917
  footerStatsMode,
7732
9918
  CONFIG_FILE_PATH,
9919
+ CONFIG_RECEIPT_PATH,
9920
+ parsePromptCacheKeyConfigReceipt,
9921
+ isActionablePromptCacheKeyConfigReceipt,
9922
+ readPromptCacheKeyConfigReceipt,
9923
+ writePromptCacheKeyConfigReceipt,
9924
+ applyPromptCacheKeyConfigFix,
9925
+ rollbackPromptCacheKeyConfig,
9926
+ configReceiptBackupPath,
7733
9927
  FOOTER_MODE_ENV,
7734
9928
  // Routing-provider protocol helpers
7735
9929
  PI_ROUTING_REGISTRY_SYMBOL,
7736
9930
  PI_CACHE_HINTS_SYMBOL,
9931
+ REASONING_PROTOCOL_FALLBACK_SYMBOL,
7737
9932
  ensureRoutingRegistry,
7738
9933
  getRoutingRegistry,
7739
9934
  parseRouteSnapshot,
@@ -7771,6 +9966,8 @@ export const __internals_for_tests = {
7771
9966
  STATE_FILE_PATH,
7772
9967
  LEGACY_STATE_FILE_PATH,
7773
9968
  SHARD_STATE_DIR,
9969
+ FIX_RECEIPT_FILE_NAME,
9970
+ FIX_RECEIPT_PATH,
7774
9971
  SHARD_FILES_DIR,
7775
9972
  SHARD_GLOBAL_EPOCH_PATH,
7776
9973
  STATE_DIR,
@@ -7798,6 +9995,20 @@ export const __internals_for_tests = {
7798
9995
  atomicReplaceTextFilePreservingMode,
7799
9996
  atomicRestoreFileFromBackup,
7800
9997
  applyModelsJsonFixTransaction,
9998
+ hashText,
9999
+ parseModelsJsonFixReceipt,
10000
+ isActionableModelsJsonFixReceipt,
10001
+ hasReceiptReasoningProtocolChange,
10002
+ createRollbackBackupPath,
10003
+ createModelsJsonFixReceipt,
10004
+ writeModelsJsonFixReceipt,
10005
+ readModelsJsonFixReceipt,
10006
+ readModelsJsonFixReceiptSnapshot,
10007
+ markModelsJsonFixReceiptRolledBack,
10008
+ receiptBackupPath,
10009
+ composeModelsJsonReceiptRollback,
10010
+ validateModelsJsonRollback,
10011
+ prepareModelsJsonRollback,
7801
10012
  // Fix suggestion builder
7802
10013
  buildFixSuggestion,
7803
10014
  // Adaptive thinking compat helpers
@@ -7822,6 +10033,11 @@ export default function (pi: ExtensionAPI) {
7822
10033
  const warnedSendSessionAffinityHeaders403Models = new Set<string>();
7823
10034
  const openAISdkHeader403Models = new Set<string>();
7824
10035
  const warnedOpenAISdkHeader403Models = new Set<string>();
10036
+ const reasoningProtocolFallbackState = getReasoningProtocolFallbackState();
10037
+ const reasoningProtocolRejectedModels = reasoningProtocolFallbackState.modelKeys;
10038
+ const warnedReasoningProtocolRejectedModels = reasoningProtocolFallbackState.warnedModelKeys;
10039
+ const promptCacheKeyRejectedModels = new Set<string>();
10040
+ const warnedPromptCacheKeyRejectedModels = new Set<string>();
7825
10041
  let cacheStatsByModel: Record<string, CacheStats> = {};
7826
10042
  let cacheStatsProcessByModel: Record<string, CacheStats> = {};
7827
10043
  let cacheStatsTotalsByModel: Record<string, CacheStats> = {};
@@ -7845,6 +10061,13 @@ export default function (pi: ExtensionAPI) {
7845
10061
  let currentSessionHashSet = false;
7846
10062
  let lastActualRoutedModel: PersistedRoutedModelRef | undefined;
7847
10063
  let latestCacheHint: PiCacheHintSnapshot | undefined;
10064
+ // Pi's response hooks do not expose a request id. Track one FIFO lifecycle
10065
+ // record per request and mark records when their response hook arrives. A
10066
+ // finalized assistant message consumes the oldest completed record, falling
10067
+ // back to the oldest request only when a transport emitted no response hook.
10068
+ // This preserves response A when request B/C starts before message_end(A).
10069
+ type ProviderRequestState = { model: PiModel; responseReceived: boolean };
10070
+ const providerRequestStates: ProviderRequestState[] = [];
7848
10071
  let shardCreatedAt = Date.now();
7849
10072
  const PERSIST_DEBOUNCE_MS = 2000;
7850
10073
  const SHARD_REFRESH_DEBOUNCE_MS = 250;
@@ -7873,7 +10096,14 @@ export default function (pi: ExtensionAPI) {
7873
10096
 
7874
10097
  function buildCommandFixSuggestion(model: PiModel): FixSuggestion | undefined {
7875
10098
  const regular = buildFixSuggestion(model);
7876
- const observed = buildObservedRuntimeFixSuggestion(model);
10099
+ const observedReasoning = buildReasoningProtocolFixSuggestion(
10100
+ model,
10101
+ reasoningProtocolRejectedModels.has(modelKey(model)),
10102
+ );
10103
+ const observed = mergeFixSuggestions(
10104
+ buildObservedRuntimeFixSuggestion(model),
10105
+ observedReasoning,
10106
+ );
7877
10107
  if (!regular) return observed;
7878
10108
  if (!observed) return regular;
7879
10109
  return {
@@ -7883,6 +10113,21 @@ export default function (pi: ExtensionAPI) {
7883
10113
  forceModelLevel: regular.forceModelLevel || observed.forceModelLevel,
7884
10114
  };
7885
10115
  }
10116
+
10117
+ function promptCacheKeyFixApplies(model: PiModel): boolean {
10118
+ return isPromptCacheKeyUnsupportedApplicable(model) && promptCacheKeyRejectedModels.has(modelKey(model));
10119
+ }
10120
+
10121
+ function buildPromptCacheKeyConfigPreview(model: PiModel): string[] {
10122
+ const key = modelKey(model);
10123
+ return [
10124
+ `Target: ${key}`,
10125
+ `Action: persist prompt_cache_key mode = omit in ${CONFIG_FILE_PATH}.`,
10126
+ "The final request body will omit both prompt_cache_key and promptCacheKey.",
10127
+ "This can reduce provider-side prompt-cache reuse for this model; it does not change prompts, credentials, headers, or other models.",
10128
+ "The setting applies after Pi core has built the payload, including when Pi already supplied a key.",
10129
+ ];
10130
+ }
7886
10131
  /** In-memory recent usage samples per model key (not persisted, cleared on reload). */
7887
10132
  const recentSamplesByModelKey = new Map<string, CacheUsageSample[]>();
7888
10133
 
@@ -8380,6 +10625,7 @@ export default function (pi: ExtensionAPI) {
8380
10625
  shardWatcher?.close();
8381
10626
  shardWatcher = undefined;
8382
10627
  latestCacheHint = undefined;
10628
+ providerRequestStates.length = 0;
8383
10629
  delete getProtocolGlobal().__piCacheOptimizerCacheKey__;
8384
10630
  uninstallCacheHintsService();
8385
10631
  restoreCacheRetentionEnv(STARTUP_CACHE_RETENTION_ENV);
@@ -8440,9 +10686,7 @@ export default function (pi: ExtensionAPI) {
8440
10686
  // prompt mutations below (session-overview churn strip, skill compression,
8441
10687
  // and stable-prefix reordering). Footer stats and the OpenAI
8442
10688
  // prompt_cache_key fallback remain active.
8443
- if (isEnabledEnv(process.env[NO_PROMPT_REWRITE_ENV])) {
8444
- return {};
8445
- }
10689
+ if (isEnabledEnv(process.env[NO_PROMPT_REWRITE_ENV])) return {};
8446
10690
 
8447
10691
  // Step 1: strip per-turn churn from <session-overview>.
8448
10692
  // Removing RECENT COMMITS, Working directory status, and
@@ -8523,6 +10767,18 @@ export default function (pi: ExtensionAPI) {
8523
10767
 
8524
10768
  pi.on("before_provider_request", (event, ctx) => {
8525
10769
  const requestModel = resolveRouteModel(ctx.model, ctx) ?? ctx.model;
10770
+ if (runtimeOptimizerEnabled) {
10771
+ const snapshot = snapshotProviderRequestModel(requestModel);
10772
+ if (snapshot) providerRequestStates.push({ model: snapshot, responseReceived: false });
10773
+ }
10774
+ let requestPayload: unknown = event.payload;
10775
+ let toolOrderChanged = false;
10776
+
10777
+ if (isToolOrderEnabled() && isToolOrderingEligibleModel(requestModel)) {
10778
+ const normalized = normalizeToolsInPayload(requestPayload, requestModel.api);
10779
+ requestPayload = normalized.payload;
10780
+ toolOrderChanged = normalized.changed;
10781
+ }
8526
10782
 
8527
10783
  // Anthropic rejects mixed cache breakpoints when a 1h block appears after
8528
10784
  // a 5m/default block in wire order (tools → system → messages). Repair any
@@ -8530,9 +10786,9 @@ export default function (pi: ExtensionAPI) {
8530
10786
  // hidden short breakpoints after this hook; only models that have actually
8531
10787
  // returned the explicit TTL-order error receive the process-local 5m fallback.
8532
10788
  if (requestModel && isAnthropicMessagesApi(requestModel.api)) {
8533
- const visibleConflictFixed = normalizeAnthropicCacheControlTtlOrder(event.payload);
10789
+ const visibleConflictFixed = normalizeAnthropicCacheControlTtlOrder(requestPayload);
8534
10790
  if (!visibleConflictFixed && anthropicTtlOrderErrorModels.has(modelKey(requestModel))) {
8535
- downgradeAnthropicLongCacheControls(event.payload);
10791
+ downgradeAnthropicLongCacheControls(requestPayload);
8536
10792
  }
8537
10793
  }
8538
10794
 
@@ -8550,50 +10806,89 @@ export default function (pi: ExtensionAPI) {
8550
10806
  // Gate 2 before Gate 3 is critical: if a user explicitly opted in but
8551
10807
  // the API returned 400, we must strip — otherwise the 400 repeats forever.
8552
10808
  if (runtimeOptimizerEnabled) {
8553
- const payload = event.payload as UnknownRecord;
8554
- if (payload && typeof payload.prompt_cache_retention === 'string') {
10809
+ const payloadRecord = asRecord(requestPayload);
10810
+ if (payloadRecord && typeof payloadRecord.prompt_cache_retention === "string") {
8555
10811
  if (requestModel) {
8556
10812
  if (isOfficialOpenAIBaseUrl(requestModel)) {
8557
10813
  // Gate 1: Official OpenAI → keep
8558
10814
  } else if (promptCacheRetention400Models.has(modelKey(requestModel))) {
8559
10815
  // Gate 2: 400 history → strip (overrides user opt-in)
8560
- delete payload.prompt_cache_retention;
10816
+ delete payloadRecord.prompt_cache_retention;
8561
10817
  } else if (hasExplicitLongRetentionOptIn(requestModel)) {
8562
10818
  // Gate 3: Explicit user opt-in → keep
8563
10819
  } else {
8564
10820
  // Gate 4: Safe default → strip
8565
- delete payload.prompt_cache_retention;
10821
+ delete payloadRecord.prompt_cache_retention;
8566
10822
  }
8567
10823
  }
8568
10824
  }
8569
10825
  }
8570
10826
 
8571
- if (!shouldInjectOpenAIPromptCacheKey()) return undefined;
8572
- if (!shouldInjectOpenAIPromptCacheKeyForModel(requestModel)) return undefined;
10827
+ if (isPromptCacheKeyOmittedForModel(requestModel)) {
10828
+ const omitted = omitOpenAIPromptCacheKeys(requestPayload);
10829
+ return omitted ?? (toolOrderChanged ? requestPayload : undefined);
10830
+ }
10831
+
10832
+ if (!shouldInjectOpenAIPromptCacheKey() || !shouldInjectOpenAIPromptCacheKeyForModel(requestModel)) {
10833
+ return toolOrderChanged ? requestPayload : undefined;
10834
+ }
8573
10835
 
8574
- return addOpenAIPromptCacheKey(event.payload, getSessionPromptCacheKey(ctx));
10836
+ const withCacheKey = addOpenAIPromptCacheKey(requestPayload, getSessionPromptCacheKey(ctx));
10837
+ return withCacheKey ?? (toolOrderChanged ? requestPayload : undefined);
8575
10838
  });
8576
10839
 
8577
- pi.on("after_provider_response", (event, ctx) => {
8578
- const model = resolveRouteModel(ctx.model, ctx) ?? ctx.model;
10840
+ pi.on("after_provider_response", async (event, ctx) => {
10841
+ const responseState = providerRequestStates.find((state) => !state.responseReceived);
10842
+ if (responseState) responseState.responseReceived = true;
10843
+ const model = responseState?.model ?? (resolveRouteModel(ctx.model, ctx) ?? ctx.model);
8579
10844
  if (!runtimeOptimizerEnabled || !model) return;
8580
10845
 
8581
- // ── 400: prompt_cache_retention unsupported ──
8582
- if (event.status === 400) {
8583
- if (!isPromptCacheRetention400Applicable(model)) return;
8584
- if (!hasPromptCacheRetentionUnsupportedSignal(event.headers)) return;
10846
+ // Keep only the category, never the provider's complete error text. This
10847
+ // is evidence for a later, model-scoped `/fix`; it never edits config from
10848
+ // a response hook.
10849
+ if (
10850
+ isReasoningProtocolRejectionSignalApplicable(model) &&
10851
+ event.status === 400 &&
10852
+ hasReasoningProtocolRejectionSignal(event.headers)
10853
+ ) {
10854
+ await notifyReasoningProtocolObservation(
10855
+ model,
10856
+ ctx,
10857
+ reasoningProtocolRejectedModels,
10858
+ warnedReasoningProtocolRejectedModels,
10859
+ );
10860
+ }
10861
+
10862
+ // ── 400: prompt_cache_key unsupported ──
10863
+ if (event.status === 400 && isPromptCacheKeyUnsupportedApplicable(model) && hasPromptCacheKeyUnsupportedSignal(event.headers)) {
10864
+ const key = modelKey(model);
10865
+ promptCacheKeyRejectedModels.add(key);
10866
+ if (!warnedPromptCacheKeyRejectedModels.has(key)) {
10867
+ warnedPromptCacheKeyRejectedModels.add(key);
10868
+ ctx.ui.notify(
10869
+ `⚠️ ${LOG_PREFIX}: ${key} rejected prompt_cache_key. Run /cache-optimizer fix to review a precise model-scoped repair. No configuration was changed automatically.`,
10870
+ "warning",
10871
+ );
10872
+ }
10873
+ }
8585
10874
 
10875
+ // ── 400: prompt_cache_retention unsupported ──
10876
+ if (
10877
+ event.status === 400 &&
10878
+ isPromptCacheRetention400Applicable(model) &&
10879
+ hasPromptCacheRetentionUnsupportedSignal(event.headers)
10880
+ ) {
8586
10881
  const key = modelKey(model);
8587
10882
  promptCacheRetention400Models.add(key);
8588
- if (warnedPromptCacheRetention400Models.has(key)) return;
8589
- warnedPromptCacheRetention400Models.add(key);
8590
- ctx.ui.notify(
8591
- `⚠️ ${LOG_PREFIX}: ${key} returned HTTP 400 while supportsLongCacheRetention is enabled. ` +
8592
- getPromptCacheRetentionUnsupportedHint() +
8593
- ` Run /cache-optimizer doctor for the exact edit location.`,
8594
- "warning",
8595
- );
8596
- return;
10883
+ if (!warnedPromptCacheRetention400Models.has(key)) {
10884
+ warnedPromptCacheRetention400Models.add(key);
10885
+ ctx.ui.notify(
10886
+ `⚠️ ${LOG_PREFIX}: ${key} returned HTTP 400 while supportsLongCacheRetention is enabled. ` +
10887
+ getPromptCacheRetentionUnsupportedHint() +
10888
+ ` Run /cache-optimizer doctor for the exact edit location.`,
10889
+ "warning",
10890
+ );
10891
+ }
8597
10892
  }
8598
10893
 
8599
10894
  // ── 403: proxy/CDN/WAF blocking ──
@@ -8638,13 +10933,55 @@ export default function (pi: ExtensionAPI) {
8638
10933
  pi.on("message_end", async (event, ctx) => {
8639
10934
  syncSessionHash(ctx);
8640
10935
  const msgRecord = asRecord(event.message);
10936
+ const requestModelForMessage = msgRecord?.role === "assistant"
10937
+ ? (() => {
10938
+ const completedIndex = providerRequestStates.findIndex((state) => state.responseReceived);
10939
+ const index = completedIndex >= 0 ? completedIndex : 0;
10940
+ return providerRequestStates.splice(index, 1)[0]?.model;
10941
+ })()
10942
+ : undefined;
8641
10943
 
8642
10944
  // Some providers expose an HTTP 400 error body only through the finalized
8643
10945
  // assistant error message; after_provider_response may contain the status
8644
- // and no diagnostic response headers. Record the same model-scoped
8645
- // prompt_cache_retention fallback from that authoritative message identity.
10946
+ // and no diagnostic response headers. Record only the model-scoped
10947
+ // reasoning-protocol category from that authoritative message identity.
10948
+ if (runtimeOptimizerEnabled && hasReasoningProtocolRejectionErrorMessage(event.message)) {
10949
+ const fallbackModel = requestModelForMessage
10950
+ ?? (resolveRouteModel(ctx.model, ctx) ?? ctx.model);
10951
+ const messageModel = modelFromAssistantMessage(event.message, fallbackModel) ?? fallbackModel;
10952
+ const errorModel = messageModel
10953
+ ? findModelInRegistry(ctx.modelRegistry, messageModel.provider, messageModel.id) ?? messageModel
10954
+ : undefined;
10955
+ if (isReasoningProtocolRejectionForModel(event.message, errorModel)) {
10956
+ await notifyReasoningProtocolObservation(
10957
+ errorModel,
10958
+ ctx,
10959
+ reasoningProtocolRejectedModels,
10960
+ warnedReasoningProtocolRejectedModels,
10961
+ );
10962
+ }
10963
+ }
10964
+ if (runtimeOptimizerEnabled && hasPromptCacheKeyUnsupportedErrorMessage(event.message)) {
10965
+ const fallbackModel = requestModelForMessage ?? (resolveRouteModel(ctx.model, ctx) ?? ctx.model);
10966
+ const messageModel = modelFromAssistantMessage(event.message, fallbackModel) ?? fallbackModel;
10967
+ const errorModel = messageModel
10968
+ ? findModelInRegistry(ctx.modelRegistry, messageModel.provider, messageModel.id) ?? messageModel
10969
+ : undefined;
10970
+ if (errorModel && isPromptCacheKeyUnsupportedApplicable(errorModel)) {
10971
+ const key = modelKey(errorModel);
10972
+ promptCacheKeyRejectedModels.add(key);
10973
+ if (!warnedPromptCacheKeyRejectedModels.has(key)) {
10974
+ warnedPromptCacheKeyRejectedModels.add(key);
10975
+ ctx.ui.notify(
10976
+ `⚠️ ${LOG_PREFIX}: ${key} rejected prompt_cache_key. Run /cache-optimizer fix to review a precise model-scoped repair. No configuration was changed automatically.`,
10977
+ "warning",
10978
+ );
10979
+ }
10980
+ }
10981
+ }
8646
10982
  if (runtimeOptimizerEnabled && hasPromptCacheRetentionUnsupportedErrorMessage(event.message)) {
8647
- const fallbackModel = resolveRouteModel(ctx.model, ctx) ?? ctx.model;
10983
+ const fallbackModel = requestModelForMessage
10984
+ ?? (resolveRouteModel(ctx.model, ctx) ?? ctx.model);
8648
10985
  const messageModel = modelFromAssistantMessage(event.message, fallbackModel) ?? fallbackModel;
8649
10986
  const errorModel = messageModel
8650
10987
  ? findModelInRegistry(ctx.modelRegistry, messageModel.provider, messageModel.id) ?? messageModel
@@ -8668,7 +11005,8 @@ export default function (pi: ExtensionAPI) {
8668
11005
  // non-retryable 400 in Pi 0.82.1, so the fallback applies to the next
8669
11006
  // subsequent request (and to a retry only if another layer initiates one).
8670
11007
  if (hasAnthropicCacheTtlOrderError(event.message)) {
8671
- const fallbackModel = resolveRouteModel(ctx.model, ctx) ?? ctx.model;
11008
+ const fallbackModel = requestModelForMessage
11009
+ ?? (resolveRouteModel(ctx.model, ctx) ?? ctx.model);
8672
11010
  const errorModel = modelFromAssistantMessage(event.message, fallbackModel) ?? fallbackModel;
8673
11011
  if (errorModel && isAnthropicMessagesApi(errorModel.api)) {
8674
11012
  const key = modelKey(errorModel);
@@ -8789,7 +11127,8 @@ export default function (pi: ExtensionAPI) {
8789
11127
  // stats — show detailed current-session models; `stats all` shows all local shards
8790
11128
  // compat — show compat suggestion with file path
8791
11129
  // config footer-mode total|session|process — persist footer mode override
8792
- // fix — auto-fix compat issues (writes models.json, requires UI)
11130
+ // fix — auto-fix compat issues (writes models.json or extension config, requires UI)
11131
+ // rollback — undo the latest confirmed fix for the active model
8793
11132
  // reset — reset current provider/model footer stats bucket (local only)
8794
11133
  // (no args) — interactive menu (with UI) or help summary
8795
11134
  // ────────────────────────────────────────────────────────────────
@@ -8811,6 +11150,7 @@ export default function (pi: ExtensionAPI) {
8811
11150
  cmdCtx.ui.notify(`✅ Pi Cache Optimizer enabled for this Pi process. Local footer stats were reset for before/after comparison.\n${formatOptimizerRuntimeMode()}`, "info");
8812
11151
  } else if (subcommand === "disable") {
8813
11152
  setRuntimeOptimizerEnabled(false);
11153
+ providerRequestStates.length = 0;
8814
11154
  await resetCurrentSessionStats();
8815
11155
  await flushPersistCacheStats(cmdCtx);
8816
11156
  await publishStatus(cmdCtx, model);
@@ -8821,7 +11161,7 @@ export default function (pi: ExtensionAPI) {
8821
11161
  cmdCtx.ui.notify("No active model selected. Select a model first with /model or pi --model.", "warning");
8822
11162
  return;
8823
11163
  }
8824
- const diagnosis = buildDoctorDiagnosis(model, { promptCacheRetention400: promptCacheRetention400Models.has(modelKey(model)), anthropicTtlOrderError: anthropicTtlOrderErrorModels.has(modelKey(model)), sessionAffinity403: sendSessionAffinityHeaders403Models.has(modelKey(model)), openAISdkHeader403: openAISdkHeader403Models.has(modelKey(model)) });
11164
+ const diagnosis = buildDoctorDiagnosis(model, { promptCacheRetention400: promptCacheRetention400Models.has(modelKey(model)), promptCacheKey400: promptCacheKeyRejectedModels.has(modelKey(model)), anthropicTtlOrderError: anthropicTtlOrderErrorModels.has(modelKey(model)), sessionAffinity403: sendSessionAffinityHeaders403Models.has(modelKey(model)), openAISdkHeader403: openAISdkHeader403Models.has(modelKey(model)) });
8825
11165
  const adapter = selectAdapterForModel(model);
8826
11166
  const sk = model ? sessionModelKey(model) : undefined;
8827
11167
  const statsState = model ? cacheStatsTotalsByModel[modelKey(model)] : undefined;
@@ -8860,7 +11200,7 @@ export default function (pi: ExtensionAPI) {
8860
11200
  const nextMode = requestedMode as FooterStatsMode;
8861
11201
  try {
8862
11202
  await writePersistedFooterMode(nextMode);
8863
- persistedFooterStatsMode = nextMode;
11203
+ setPersistedCacheOptimizerConfig(readPersistedCacheOptimizerConfig());
8864
11204
  lastStatusText = undefined;
8865
11205
  await publishStatus(cmdCtx, model);
8866
11206
  const resolved = resolveFooterStatsMode(persistedFooterStatsMode);
@@ -8890,6 +11230,133 @@ export default function (pi: ExtensionAPI) {
8890
11230
  "info",
8891
11231
  );
8892
11232
  }
11233
+ } else if (subcommand === "rollback") {
11234
+ if (!model) {
11235
+ cmdCtx.ui.notify("No active model selected. Select a model first with /model or pi --model.", "warning");
11236
+ return;
11237
+ }
11238
+ const configReceipt = await readPromptCacheKeyConfigReceipt();
11239
+ const modelsReceipt = await readModelsJsonFixReceipt();
11240
+ const useConfigReceipt = isActionablePromptCacheKeyConfigReceipt(configReceipt) &&
11241
+ configReceipt.provider === model.provider &&
11242
+ configReceipt.modelId === model.id &&
11243
+ (!isActionableModelsJsonFixReceipt(modelsReceipt) || modelsReceipt.provider !== model.provider || modelsReceipt.modelId !== model.id || configReceipt.appliedAt >= modelsReceipt.appliedAt);
11244
+ if (useConfigReceipt) {
11245
+ if (!cmdCtx.hasUI) {
11246
+ cmdCtx.ui.notify("❌ Rollback requires interactive confirmation. No changes were made.\nRun /cache-optimizer rollback in Pi's interactive UI to restore the prompt-cache-key setting.", "warning");
11247
+ return;
11248
+ }
11249
+ const confirmed = await cmdCtx.ui.confirm(
11250
+ "Cache Optimizer — Rollback prompt_cache_key opt-out",
11251
+ `Model: ${modelKey(model)}\nAction: restore the extension config before the confirmed opt-out.\nFooter mode and unrelated configuration will be preserved.\nAfterward, run /reload or restart Pi.\n\nProceed with rollback?`,
11252
+ );
11253
+ if (!confirmed) {
11254
+ cmdCtx.ui.notify("No changes were made. Rollback canceled by user.", "info");
11255
+ return;
11256
+ }
11257
+ try {
11258
+ await rollbackPromptCacheKeyConfig(configReceipt);
11259
+ setPersistedCacheOptimizerConfig(readPersistedCacheOptimizerConfig());
11260
+ cmdCtx.ui.notify(`✅ Restored prompt_cache_key behavior for ${modelKey(model)}. Run /reload or restart Pi for the change to take effect.`, "info");
11261
+ } catch (error) {
11262
+ cmdCtx.ui.notify(`❌ Prompt cache key rollback refused: ${error instanceof Error ? error.message : String(error)}. No changes were made.`, "error");
11263
+ }
11264
+ return;
11265
+ }
11266
+ if (!cmdCtx.hasUI) {
11267
+ const receipt = await readModelsJsonFixReceipt();
11268
+ const backupHint = receipt && isActionableModelsJsonFixReceipt(receipt)
11269
+ ? ` The recorded backup is ${receipt.backupFile} next to ${getModelsJsonDisplayPath()}.`
11270
+ : " Check the recorded models.json backup manually if a fix receipt exists.";
11271
+ cmdCtx.ui.notify(
11272
+ "❌ Rollback requires interactive confirmation. No changes were made.\n" +
11273
+ `Run /cache-optimizer rollback in Pi's interactive UI.${backupHint} Then run /reload.`,
11274
+ "warning",
11275
+ );
11276
+ return;
11277
+ }
11278
+
11279
+ const receiptSnapshot = await readModelsJsonFixReceiptSnapshot();
11280
+ const receipt = receiptSnapshot?.receipt;
11281
+ if (!receiptSnapshot || !isActionableModelsJsonFixReceipt(receipt)) {
11282
+ cmdCtx.ui.notify("ℹ️ No unapplied /cache-optimizer fix receipt was found.", "info");
11283
+ return;
11284
+ }
11285
+ if (receipt.provider !== model.provider || receipt.modelId !== model.id) {
11286
+ cmdCtx.ui.notify(
11287
+ `ℹ️ The latest fix receipt is for ${receipt.provider}/${receipt.modelId}, not the active model ${model.provider}/${model.id}. ` +
11288
+ "Switch to the matching model before running rollback. No changes were made.",
11289
+ "warning",
11290
+ );
11291
+ return;
11292
+ }
11293
+
11294
+ const rollback = await prepareModelsJsonRollback(receiptSnapshot);
11295
+ if ("error" in rollback) {
11296
+ cmdCtx.ui.notify(`ℹ️ ${rollback.error}`, "info");
11297
+ return;
11298
+ }
11299
+
11300
+ const rollbackScope = rollback.mode === "exact"
11301
+ ? "restore the exact pre-fix models.json because the file is unchanged since the fix"
11302
+ : "restore only the receipt-owned compat scalar keys and preserve subsequent user changes";
11303
+ const rollbackPreview = [
11304
+ `Rollback transaction ${rollback.receipt.transactionId}:`,
11305
+ `Model: ${rollback.receipt.provider}/${rollback.receipt.modelId}`,
11306
+ `Action: ${rollbackScope}.`,
11307
+ `A new rollback backup will be written to: ${rollback.rollbackBackupPath}`,
11308
+ "Comments, credentials, unrelated fields, and the existing access mode will be preserved.",
11309
+ "Afterward, run /reload or restart Pi for the configuration change to take effect.",
11310
+ "",
11311
+ "Proceed with rollback?",
11312
+ ].join("\n");
11313
+ const confirmed = await cmdCtx.ui.confirm("Cache Optimizer — Rollback", rollbackPreview);
11314
+ if (!confirmed) {
11315
+ cmdCtx.ui.notify("No changes were made. Rollback canceled by user.", "info");
11316
+ return;
11317
+ }
11318
+
11319
+ try {
11320
+ const result = await applyModelsJsonFixTransaction(
11321
+ rollback.modifiedText,
11322
+ rollback.rollbackBackupPath,
11323
+ (writtenText) => validateModelsJsonRollback(
11324
+ writtenText,
11325
+ rollback.receipt,
11326
+ rollback.expectedResultHash,
11327
+ ),
11328
+ {
11329
+ expectedCurrentHash: rollback.currentHash,
11330
+ expectedCurrentMode: rollback.fileMode,
11331
+ receiptGuard: rollback.receiptSnapshot,
11332
+ purpose: "rollback",
11333
+ onCommitted: async () => {
11334
+ await markModelsJsonFixReceiptRolledBack(rollback.receiptSnapshot);
11335
+ },
11336
+ },
11337
+ );
11338
+ if ("postCheckError" in result) {
11339
+ cmdCtx.ui.notify(
11340
+ `❌ Rollback self-check failed: ${result.postCheckError}\n` +
11341
+ `The rollback backup at ${rollback.rollbackBackupPath} was restored. No changes applied.`,
11342
+ "error",
11343
+ );
11344
+ return;
11345
+ }
11346
+ invalidateModelsConfigCache();
11347
+ cmdCtx.ui.notify(
11348
+ `✅ Rollback completed for ${rollback.receipt.provider}/${rollback.receipt.modelId}.\n` +
11349
+ `Rollback backup saved to: ${rollback.rollbackBackupPath}\n` +
11350
+ "The receipt was marked as rolled back. Run /reload or restart Pi for the change to take effect.",
11351
+ "info",
11352
+ );
11353
+ } catch (rollbackError) {
11354
+ cmdCtx.ui.notify(
11355
+ `❌ Rollback failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}\n` +
11356
+ "No automatic overwrite was performed; use the recorded backup for manual guidance.",
11357
+ "error",
11358
+ );
11359
+ }
8893
11360
  } else if (subcommand === "reset") {
8894
11361
  if (!model) {
8895
11362
  cmdCtx.ui.notify("No active model selected. Select a model first with /model or pi --model.", "warning");
@@ -8926,7 +11393,58 @@ export default function (pi: ExtensionAPI) {
8926
11393
  return;
8927
11394
  }
8928
11395
 
8929
- const suggestion = buildCommandFixSuggestion(model);
11396
+ const promptCacheKeyRequested = commandParts[1] === "prompt-cache-key";
11397
+ const promptCacheKeyFixRequested = promptCacheKeyRequested || promptCacheKeyFixApplies(model);
11398
+ if (promptCacheKeyRequested && !isPromptCacheKeyUnsupportedApplicable(model)) {
11399
+ cmdCtx.ui.notify("ℹ️ Prompt cache key opt-out applies only to a known OpenAI-compatible provider/model endpoint. No changes were made.", "info");
11400
+ return;
11401
+ }
11402
+ const suggestion = promptCacheKeyFixRequested ? undefined : buildCommandFixSuggestion(model);
11403
+
11404
+ if (promptCacheKeyFixRequested) {
11405
+ if (isPromptCacheKeyOmittedForModel(model)) {
11406
+ cmdCtx.ui.notify(`✅ prompt_cache_key is already omitted for "${modelKey(model)}".`, "info");
11407
+ return;
11408
+ }
11409
+ if (!cmdCtx.hasUI) {
11410
+ cmdCtx.ui.notify(
11411
+ "❌ Non-interactive terminal detected. Prompt cache key opt-out requires UI confirmation. No changes were made.\n" +
11412
+ `Run /cache-optimizer fix prompt-cache-key in Pi's interactive UI for ${modelKey(model)}.`,
11413
+ "warning",
11414
+ );
11415
+ return;
11416
+ }
11417
+ const preview = [
11418
+ "📝 Preview of extension configuration change:",
11419
+ ...buildPromptCacheKeyConfigPreview(model),
11420
+ "",
11421
+ "⚠️ Risk notice:",
11422
+ " 1. This affects all sessions using this exact provider/model.",
11423
+ " 2. Provider prompt-cache reuse may decrease because both key spellings are removed.",
11424
+ " 3. This does not modify Pi's models.json, credentials, prompts, headers, or other models.",
11425
+ " 4. The extension config will be backed up and Pi must be reloaded/restarted.",
11426
+ "",
11427
+ "Apply this persistent opt-out?",
11428
+ ].join("\n");
11429
+ const confirmed = await cmdCtx.ui.confirm("Cache Optimizer — Omit prompt_cache_key", preview);
11430
+ if (!confirmed) {
11431
+ cmdCtx.ui.notify("No changes were made. Canceled by user.", "info");
11432
+ return;
11433
+ }
11434
+ try {
11435
+ const result = await applyPromptCacheKeyConfigFix(model);
11436
+ setPersistedCacheOptimizerConfig(readPersistedCacheOptimizerConfig());
11437
+ cmdCtx.ui.notify(
11438
+ `✅ Prompt cache key opt-out saved for ${modelKey(model)}.\n` +
11439
+ `Config backup saved to: ${result.backupPath}\n` +
11440
+ "Run /reload or restart Pi for the change to take effect. Use /cache-optimizer rollback to restore it.",
11441
+ "info",
11442
+ );
11443
+ } catch (error) {
11444
+ cmdCtx.ui.notify(`❌ Could not save prompt cache key opt-out: ${error instanceof Error ? error.message : String(error)}. No changes were made.`, "error");
11445
+ }
11446
+ return;
11447
+ }
8930
11448
 
8931
11449
  if (!suggestion) {
8932
11450
  const key = modelKey(model);
@@ -9021,6 +11539,7 @@ export default function (pi: ExtensionAPI) {
9021
11539
  const checkError = selfCheckMissingEntryInsertion(
9022
11540
  originalText, plan.modifiedText,
9023
11541
  suggestion.providerLabel, suggestion.modelId, suggestion.compatKeys,
11542
+ model,
9024
11543
  );
9025
11544
  if (checkError !== null) {
9026
11545
  // Fall through to manual guidance.
@@ -9065,6 +11584,20 @@ export default function (pi: ExtensionAPI) {
9065
11584
  );
9066
11585
  if (confirmed) {
9067
11586
  try {
11587
+ const receipt = createModelsJsonFixReceipt(
11588
+ originalText,
11589
+ plan.modifiedText,
11590
+ suggestion.providerLabel,
11591
+ suggestion.modelId,
11592
+ "modelOverride",
11593
+ suggestion.compatKeys,
11594
+ repairsExistingOverride,
11595
+ backupPath,
11596
+ );
11597
+ if (!receipt) {
11598
+ cmdCtx.ui.notify("❌ Could not create a privacy-safe fix receipt. No changes were made.", "error");
11599
+ return;
11600
+ }
9068
11601
  const result = await applyModelsJsonFixTransaction(
9069
11602
  plan.modifiedText,
9070
11603
  backupPath,
@@ -9074,7 +11607,13 @@ export default function (pi: ExtensionAPI) {
9074
11607
  suggestion.providerLabel,
9075
11608
  suggestion.modelId,
9076
11609
  suggestion.compatKeys,
11610
+ model,
9077
11611
  ),
11612
+ {
11613
+ expectedCurrentHash: hashText(originalText),
11614
+ purpose: "fix",
11615
+ onCommitted: async () => writeModelsJsonFixReceipt(receipt),
11616
+ },
9078
11617
  );
9079
11618
  if ("postCheckError" in result) {
9080
11619
  cmdCtx.ui.notify(
@@ -9084,6 +11623,7 @@ export default function (pi: ExtensionAPI) {
9084
11623
  );
9085
11624
  return;
9086
11625
  }
11626
+ invalidateModelsConfigCache();
9087
11627
  cmdCtx.ui.notify(
9088
11628
  `✅ Fix applied to ${getModelsJsonDisplayPath()}.\n` +
9089
11629
  `Backup saved to: ${backupPath}\n` +
@@ -9161,10 +11701,45 @@ export default function (pi: ExtensionAPI) {
9161
11701
  suggestion.providerLabel,
9162
11702
  suggestion.forceModelLevel,
9163
11703
  );
9164
- const modifiedText = composeFixInsertion(originalText, location, suggestion.compatKeys, decision.placement);
11704
+ const createsModelOverride = decision.placement === "modelOverride" && location.modelOverrideObjectBrace < 0;
11705
+ const modelOverridePlan = createsModelOverride
11706
+ ? composeModelOverrideInsertion(
11707
+ originalText,
11708
+ suggestion.providerLabel,
11709
+ suggestion.modelId,
11710
+ suggestion.compatKeys,
11711
+ )
11712
+ : undefined;
11713
+ if (createsModelOverride && !modelOverridePlan) {
11714
+ cmdCtx.ui.notify(
11715
+ "❌ Could not safely create the highest-precedence model override. No changes were made.",
11716
+ "error",
11717
+ );
11718
+ return;
11719
+ }
11720
+ const modifiedText = modelOverridePlan?.modifiedText
11721
+ ?? composeFixInsertion(originalText, location, suggestion.compatKeys, decision.placement);
9165
11722
 
9166
- // Self-check
9167
- const checkError = selfCheckFix(originalText, modifiedText, suggestion.providerLabel, suggestion.modelId, suggestion.compatKeys, decision.placement);
11723
+ // Self-check against the same provider → model → runtime →
11724
+ // modelOverride precedence used by request hooks.
11725
+ const checkError = createsModelOverride
11726
+ ? selfCheckMissingEntryInsertion(
11727
+ originalText,
11728
+ modifiedText,
11729
+ suggestion.providerLabel,
11730
+ suggestion.modelId,
11731
+ suggestion.compatKeys,
11732
+ model,
11733
+ )
11734
+ : selfCheckFix(
11735
+ originalText,
11736
+ modifiedText,
11737
+ suggestion.providerLabel,
11738
+ suggestion.modelId,
11739
+ suggestion.compatKeys,
11740
+ decision.placement,
11741
+ model,
11742
+ );
9168
11743
  if (checkError !== null) {
9169
11744
  cmdCtx.ui.notify(
9170
11745
  `❌ Self-check failed before write: ${checkError}\n` +
@@ -9229,17 +11804,46 @@ export default function (pi: ExtensionAPI) {
9229
11804
 
9230
11805
  // Write: backup → temp + rename → self-check again
9231
11806
  try {
11807
+ const receipt = createModelsJsonFixReceipt(
11808
+ originalText,
11809
+ modifiedText,
11810
+ suggestion.providerLabel,
11811
+ suggestion.modelId,
11812
+ decision.placement,
11813
+ suggestion.compatKeys,
11814
+ !createsModelOverride,
11815
+ backupPath,
11816
+ );
11817
+ if (!receipt) {
11818
+ cmdCtx.ui.notify("❌ Could not create a privacy-safe fix receipt. No changes were made.", "error");
11819
+ return;
11820
+ }
9232
11821
  const result = await applyModelsJsonFixTransaction(
9233
11822
  modifiedText,
9234
11823
  backupPath,
9235
- (writtenText) => selfCheckFix(
9236
- originalText,
9237
- writtenText,
9238
- suggestion.providerLabel,
9239
- suggestion.modelId,
9240
- suggestion.compatKeys,
9241
- decision.placement,
9242
- ),
11824
+ (writtenText) => createsModelOverride
11825
+ ? selfCheckMissingEntryInsertion(
11826
+ originalText,
11827
+ writtenText,
11828
+ suggestion.providerLabel,
11829
+ suggestion.modelId,
11830
+ suggestion.compatKeys,
11831
+ model,
11832
+ )
11833
+ : selfCheckFix(
11834
+ originalText,
11835
+ writtenText,
11836
+ suggestion.providerLabel,
11837
+ suggestion.modelId,
11838
+ suggestion.compatKeys,
11839
+ decision.placement,
11840
+ model,
11841
+ ),
11842
+ {
11843
+ expectedCurrentHash: hashText(originalText),
11844
+ purpose: "fix",
11845
+ onCommitted: async () => writeModelsJsonFixReceipt(receipt),
11846
+ },
9243
11847
  );
9244
11848
  if ("postCheckError" in result) {
9245
11849
  cmdCtx.ui.notify(
@@ -9273,7 +11877,9 @@ export default function (pi: ExtensionAPI) {
9273
11877
  "Doctor — Show cache configuration",
9274
11878
  "Stats — Show current-session model statistics",
9275
11879
  "Compat — Show compat suggestion",
9276
- "Fix — Auto-fix compat issues (writes models.json)",
11880
+ "Fix — Auto-fix compat issues (writes models.json or extension config)",
11881
+ "Disable prompt_cache_key — Omit it for the active model",
11882
+ "Rollback — Undo the latest confirmed fix",
9277
11883
  "Footer mode — Choose total, session, or process stats",
9278
11884
  "Reset — Reset local provider/model stats",
9279
11885
  "Cancel",
@@ -9292,6 +11898,10 @@ export default function (pi: ExtensionAPI) {
9292
11898
  } else if (choice === menuOptions[5]) {
9293
11899
  await handleCacheOptimizerCommand("fix", cmdCtx);
9294
11900
  } else if (choice === menuOptions[6]) {
11901
+ await handleCacheOptimizerCommand("fix prompt-cache-key", cmdCtx);
11902
+ } else if (choice === menuOptions[7]) {
11903
+ await handleCacheOptimizerCommand("rollback", cmdCtx);
11904
+ } else if (choice === menuOptions[8]) {
9295
11905
  const modeOptions = ["session — Current Pi conversation session (default)", "total — All local sessions today", "process — Current extension instance only", "Cancel"];
9296
11906
  const modeChoice = await cmdCtx.ui.select("Footer cache stats mode", modeOptions);
9297
11907
  const nextMode = modeChoice === modeOptions[0]
@@ -9302,7 +11912,7 @@ export default function (pi: ExtensionAPI) {
9302
11912
  ? "process"
9303
11913
  : undefined;
9304
11914
  if (nextMode) await handleCacheOptimizerCommand(`config footer-mode ${nextMode}`, cmdCtx);
9305
- } else if (choice === menuOptions[7]) {
11915
+ } else if (choice === menuOptions[9]) {
9306
11916
  await handleCacheOptimizerCommand("reset", cmdCtx);
9307
11917
  }
9308
11918
  // choice === "cancel" or undefined → no action
@@ -9320,7 +11930,9 @@ export default function (pi: ExtensionAPI) {
9320
11930
  diagnosis.push(" stats contributors — Show per-session contributors for the active model");
9321
11931
  diagnosis.push(" compat — Show compat suggestion with edit location");
9322
11932
  diagnosis.push(" config footer-mode total|session|process — Persist the footer stats mode");
9323
- diagnosis.push(" fix — Auto-fix compat issues (writes models.json, requires UI)");
11933
+ diagnosis.push(" fix — Auto-fix compat issues (writes models.json or extension config, requires UI)");
11934
+ diagnosis.push(" fix prompt-cache-key — Explicitly omit prompt_cache_key for the active model");
11935
+ diagnosis.push(" rollback — Undo the latest confirmed fix (requires UI confirmation)");
9324
11936
  diagnosis.push(" reset — Reset local provider/model stats for current model (does not affect upstream)");
9325
11937
  diagnosis.push("");
9326
11938
  diagnosis.push(formatOptimizerRuntimeMode());