pi-cache-optimizer 2.8.5 → 2.8.7

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 +50 -7
  2. package/README.zh-CN.md +48 -6
  3. package/index.ts +2308 -204
  4. package/package.json +2 -2
package/index.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  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
- import { constants as fsConstants, readFileSync, watch } from "node:fs";
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,11 @@ 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 FIX_RECEIPT_FILE_NAME = "pi-cache-optimizer-fix-receipt.json";
118
+ const FIX_RECEIPT_PATH = join(STATE_DIR, FIX_RECEIPT_FILE_NAME);
119
+ const MODELS_TRANSACTION_LOCK_PATH = join(STATE_DIR, "pi-cache-optimizer-models-transaction.lock");
120
+ const MODELS_TRANSACTION_LOCK_STALE_MS = 60_000;
121
+ const MODELS_TRANSACTION_LOCK_WAIT_MS = 5_000;
115
122
  const SHARD_RETENTION_MS = 48 * 60 * 60 * 1000;
116
123
  const SHARD_TEMP_RETENTION_MS = 24 * 60 * 60 * 1000;
117
124
  const SHARD_CLEANUP_INTERVAL_MS = 6 * 60 * 60 * 1000;
@@ -122,8 +129,49 @@ const NO_OPENAI_CACHE_KEY_ENV = "PI_CACHE_OPTIMIZER_NO_OPENAI_CACHE_KEY";
122
129
  const OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH = 64;
123
130
  const NO_SKILL_COMPRESSION_ENV = "PI_CACHE_OPTIMIZER_NO_SKILL_COMPRESSION";
124
131
  const NO_PROMPT_REWRITE_ENV = "PI_CACHE_OPTIMIZER_NO_PROMPT_REWRITE";
132
+ const TOOL_ORDER_ENV = "PI_CACHE_OPTIMIZER_TOOL_ORDER";
125
133
  const FOOTER_MODE_ENV = "PI_CACHE_OPTIMIZER_FOOTER_MODE";
126
134
  type FooterStatsMode = "session" | "total" | "process";
135
+ type FixReceiptPlacement = "provider" | "model" | "modelOverride";
136
+ type ReceiptScalar = string | number | boolean | null;
137
+ type ReceiptScalarState =
138
+ | { present: false }
139
+ | { present: true; value: ReceiptScalar };
140
+ type FixReceiptCompatChange = {
141
+ before: ReceiptScalarState;
142
+ after: ReceiptScalarState;
143
+ };
144
+ type ModelsJsonFixReceiptV1 = {
145
+ version: 1;
146
+ kind: "pi-cache-optimizer-fix-receipt";
147
+ transactionId: string;
148
+ provider: string;
149
+ modelId: string;
150
+ placement: FixReceiptPlacement;
151
+ targetExistedBefore: boolean;
152
+ changedKeys: Record<string, FixReceiptCompatChange>;
153
+ beforeHash: string;
154
+ afterHash: string;
155
+ backupFile: string;
156
+ createdAt: number;
157
+ appliedAt: number;
158
+ status?: "rolled_back";
159
+ rolledBackAt?: number;
160
+ };
161
+
162
+ // Receipts are trusted input to a later rollback command, so their key set is
163
+ // deliberately narrower than the full models.json compat vocabulary. This
164
+ // prevents a corrupted or hand-edited receipt from turning rollback into a
165
+ // way to rewrite credentials, headers, routing, or arbitrary provider fields.
166
+ const RECEIPT_COMPAT_KEYS = new Set([
167
+ "sendSessionAffinityHeaders",
168
+ "supportsLongCacheRetention",
169
+ "requiresReasoningContentOnAssistantMessages",
170
+ "forceAdaptiveThinking",
171
+ "allowEmptySignature",
172
+ "thinkingFormat",
173
+ "supportsReasoningEffort",
174
+ ]);
127
175
  type FooterStatsModeSource = "config" | "env" | "default";
128
176
  type PersistedCacheOptimizerConfigV1 = {
129
177
  version: 1;
@@ -133,6 +181,7 @@ const PI_ROUTING_REGISTRY_SYMBOL = Symbol.for("pi.routing.registry.v1");
133
181
  const PI_CACHE_HINTS_SYMBOL = Symbol.for("pi.cache.hints.v1");
134
182
  const PI_CACHE_HINTS_OWNER_SYMBOL = Symbol.for("pi.cache.optimizer.hints-owner.v1");
135
183
  const ANTHROPIC_TTL_FALLBACK_SYMBOL = Symbol.for("pi.cache.optimizer.anthropic-ttl-fallback.v1");
184
+ const REASONING_PROTOCOL_FALLBACK_SYMBOL = Symbol.for("pi.cache.optimizer.reasoning-protocol-fallback.v1");
136
185
 
137
186
  type AnthropicTtlFallbackStateV1 = {
138
187
  version: 1;
@@ -140,6 +189,31 @@ type AnthropicTtlFallbackStateV1 = {
140
189
  warnedModelKeys: Set<string>;
141
190
  };
142
191
 
192
+ type ReasoningProtocolFallbackStateV1 = {
193
+ version: 1;
194
+ modelKeys: Set<string>;
195
+ warnedModelKeys: Set<string>;
196
+ };
197
+
198
+ function getReasoningProtocolFallbackState(): ReasoningProtocolFallbackStateV1 {
199
+ const globals = globalThis as Record<symbol, unknown>;
200
+ const existing = globals[REASONING_PROTOCOL_FALLBACK_SYMBOL] as Partial<ReasoningProtocolFallbackStateV1> | undefined;
201
+ if (
202
+ existing?.version === 1 &&
203
+ existing.modelKeys instanceof Set &&
204
+ existing.warnedModelKeys instanceof Set
205
+ ) {
206
+ return existing as ReasoningProtocolFallbackStateV1;
207
+ }
208
+ const state: ReasoningProtocolFallbackStateV1 = {
209
+ version: 1,
210
+ modelKeys: new Set<string>(),
211
+ warnedModelKeys: new Set<string>(),
212
+ };
213
+ globals[REASONING_PROTOCOL_FALLBACK_SYMBOL] = state;
214
+ return state;
215
+ }
216
+
143
217
  function getAnthropicTtlFallbackState(): AnthropicTtlFallbackStateV1 {
144
218
  const globals = globalThis as Record<symbol, unknown>;
145
219
  const existing = globals[ANTHROPIC_TTL_FALLBACK_SYMBOL] as Partial<AnthropicTtlFallbackStateV1> | undefined;
@@ -391,6 +465,15 @@ type OptimizedSystemPrompt = {
391
465
  changed: boolean;
392
466
  };
393
467
 
468
+ type ToolOrderApi =
469
+ | "openai-completions"
470
+ | "openai-responses"
471
+ | "anthropic-messages"
472
+ | "google-generative-ai"
473
+ | "google-vertex"
474
+ | "bedrock-converse-stream";
475
+
476
+
394
477
  /**
395
478
  * Per-request sample stored for trend analysis and usage-field-missing detection.
396
479
  * Contains only numeric counters and booleans — never message content, prompts,
@@ -481,6 +564,205 @@ function isStableContextFilePath(filePath: string): boolean {
481
564
  );
482
565
  }
483
566
 
567
+ function isKnownToolOrderApi(api: unknown): api is ToolOrderApi {
568
+ return api === "openai-completions" || api === "openai-responses" ||
569
+ api === "anthropic-messages" || api === "google-generative-ai" ||
570
+ api === "google-vertex" || api === "bedrock-converse-stream";
571
+ }
572
+
573
+ function isToolOrderingEligibleModel(model: PiModel | undefined): boolean {
574
+ // Responses/Codex prompt bypasses remain untouched. The pure helper still
575
+ // supports the verified Responses shape for fixture use, but the request
576
+ // hook must preserve Pi's server-managed/safety-sensitive bypass.
577
+ return !!model && isKnownToolOrderApi(model.api) && !isResponsesPromptRewriteBypassApi(model.api);
578
+ }
579
+
580
+ function getToolNameForPayload(tool: unknown): string | undefined {
581
+ const record = asRecord(tool);
582
+ if (!record) return undefined;
583
+ if (isNonEmptyString(record.name)) return record.name;
584
+ const fn = asRecord(record.function);
585
+ if (isNonEmptyString(fn?.name)) return fn.name;
586
+ const custom = asRecord(record.custom);
587
+ if (isNonEmptyString(custom?.name)) return custom.name;
588
+ const spec = asRecord(record.toolSpec);
589
+ if (isNonEmptyString(spec?.name)) return spec.name;
590
+ return undefined;
591
+ }
592
+
593
+ function compareToolOrderEntries(
594
+ left: { name: string; index: number },
595
+ right: { name: string; index: number },
596
+ ): number {
597
+ // Do not use localeCompare here: its result can vary with the host locale.
598
+ // Exact UTF-16 code-unit ordering plus the original index is reproducible.
599
+ return left.name < right.name ? -1 : left.name > right.name ? 1 : left.index - right.index;
600
+ }
601
+
602
+ function isJsonObject(value: unknown): value is UnknownRecord {
603
+ return asRecord(value) !== undefined;
604
+ }
605
+
606
+ function isVerifiedToolForApi(tool: unknown, api: ToolOrderApi): tool is UnknownRecord {
607
+ const record = asRecord(tool);
608
+ if (!record) return false;
609
+
610
+ if (api === "openai-completions") {
611
+ if (record.type === "function") {
612
+ const fn = asRecord(record.function);
613
+ return isNonEmptyString(fn?.name) && isJsonObject(fn.parameters);
614
+ }
615
+ if (record.type === "custom") {
616
+ const custom = asRecord(record.custom);
617
+ return isNonEmptyString(custom?.name) && isJsonObject(custom.format);
618
+ }
619
+ return false;
620
+ }
621
+
622
+ if (api === "openai-responses") {
623
+ if (record.type === "function") {
624
+ return isNonEmptyString(record.name) && isJsonObject(record.parameters);
625
+ }
626
+ if (record.type === "custom") {
627
+ return isNonEmptyString(record.name) && isJsonObject(record.format);
628
+ }
629
+ return false;
630
+ }
631
+
632
+ if (api === "anthropic-messages") {
633
+ return isNonEmptyString(record.name) && isJsonObject(record.input_schema);
634
+ }
635
+
636
+ if (api === "google-generative-ai" || api === "google-vertex") {
637
+ return isNonEmptyString(record.name) &&
638
+ (isJsonObject(record.parametersJsonSchema) || isJsonObject(record.parameters));
639
+ }
640
+
641
+ const spec = asRecord(record.toolSpec);
642
+ const inputSchema = asRecord(spec?.inputSchema);
643
+ return isNonEmptyString(spec?.name) && isJsonObject(inputSchema?.json);
644
+ }
645
+
646
+ type ToolArrayInspection = {
647
+ sortedIndices: number[];
648
+ changed: boolean;
649
+ };
650
+
651
+ function hasTopLevelCacheControl(tools: unknown): boolean {
652
+ return Array.isArray(tools) && tools.some((tool) => {
653
+ const record = asRecord(tool);
654
+ return !!record && Object.prototype.hasOwnProperty.call(record, "cache_control");
655
+ });
656
+ }
657
+
658
+ function inspectToolArray(tools: unknown, api: ToolOrderApi): ToolArrayInspection | undefined {
659
+ if (!Array.isArray(tools)) return undefined;
660
+ // Native Anthropic and OpenAI-compatible transports can attach a cache
661
+ // breakpoint to a specific tool (normally the final one). Never move it,
662
+ // regardless of API id. Anthropic defer_loading also encodes immediate vs
663
+ // deferred tool groups in array order, so mixed/grouped payloads are no-ops.
664
+ if (hasTopLevelCacheControl(tools)) return undefined;
665
+ if (api === "anthropic-messages" && tools.some((tool) => {
666
+ const record = asRecord(tool);
667
+ return !!record && Object.prototype.hasOwnProperty.call(record, "defer_loading");
668
+ })) return undefined;
669
+ const entries = tools.map((tool, index) => {
670
+ if (!isVerifiedToolForApi(tool, api)) return undefined;
671
+ return { name: getToolNameForPayload(tool) ?? "", index };
672
+ });
673
+ if (entries.some((entry) => entry === undefined)) return undefined;
674
+
675
+ const verified = entries as Array<{ name: string; index: number }>;
676
+ const sorted = [...verified].sort(compareToolOrderEntries);
677
+ return {
678
+ sortedIndices: sorted.map((entry) => entry.index),
679
+ changed: sorted.some((entry, index) => entry.index !== index),
680
+ };
681
+ }
682
+
683
+ type ToolArrayPath = {
684
+ kind: "root" | "bedrock" | "google";
685
+ groupIndex?: number;
686
+ sortedIndices: number[];
687
+ };
688
+
689
+ /**
690
+ * Pure deterministic tool-order normalizer for the exact payload shapes
691
+ * emitted by Pi's built-in transports. It returns the original object for a
692
+ * no-op/unsupported/malformed payload and shallow-clones only the verified
693
+ * path to a tool array when sorting is needed. Tool objects and unrelated
694
+ * request fields (including SDK objects and AbortSignal) retain their identity.
695
+ */
696
+ function normalizeToolsInPayload(
697
+ payload: unknown,
698
+ api: unknown,
699
+ ): { payload: unknown; changed: boolean } {
700
+ if (!isKnownToolOrderApi(api)) return { payload, changed: false };
701
+ const root = asRecord(payload);
702
+ if (!root) return { payload, changed: false };
703
+
704
+ if (api === "google-generative-ai" || api === "google-vertex") {
705
+ const config = asRecord(root.config);
706
+ const groups = config?.tools;
707
+ if (!Array.isArray(groups) || groups.length === 0) return { payload, changed: false };
708
+
709
+ const paths: ToolArrayPath[] = [];
710
+ for (let groupIndex = 0; groupIndex < groups.length; groupIndex++) {
711
+ const group = asRecord(groups[groupIndex]);
712
+ if (!group || !Array.isArray(group.functionDeclarations)) return { payload, changed: false };
713
+ const inspected = inspectToolArray(group.functionDeclarations, api);
714
+ if (!inspected) return { payload, changed: false };
715
+ if (inspected.changed) paths.push({ kind: "google", groupIndex, sortedIndices: inspected.sortedIndices });
716
+ }
717
+ if (paths.length === 0) return { payload, changed: false };
718
+
719
+ const sortedGroups = [...groups];
720
+ for (const path of paths) {
721
+ const groupIndex = path.groupIndex!;
722
+ const group = asRecord(groups[groupIndex])!;
723
+ const tools = group.functionDeclarations as unknown[];
724
+ sortedGroups[groupIndex] = {
725
+ ...group,
726
+ functionDeclarations: path.sortedIndices.map((index) => tools[index]),
727
+ };
728
+ }
729
+ return {
730
+ payload: { ...root, config: { ...config, tools: sortedGroups } },
731
+ changed: true,
732
+ };
733
+ }
734
+
735
+ if (api === "bedrock-converse-stream") {
736
+ const toolConfig = asRecord(root.toolConfig);
737
+ const tools = toolConfig?.tools;
738
+ const inspected = inspectToolArray(tools, api);
739
+ if (!inspected || !inspected.changed || !Array.isArray(tools)) return { payload, changed: false };
740
+ return {
741
+ payload: {
742
+ ...root,
743
+ toolConfig: {
744
+ ...toolConfig,
745
+ tools: inspected.sortedIndices.map((index) => tools[index]),
746
+ },
747
+ },
748
+ changed: true,
749
+ };
750
+ }
751
+
752
+ const tools = root.tools;
753
+ const inspected = inspectToolArray(tools, api);
754
+ if (!inspected || !inspected.changed || !Array.isArray(tools)) return { payload, changed: false };
755
+ return {
756
+ payload: { ...root, tools: inspected.sortedIndices.map((index) => tools[index]) },
757
+ changed: true,
758
+ };
759
+ }
760
+
761
+ /** Public test-facing alias: a pure payload transformation. */
762
+ function sortToolsInPayload(payload: unknown, api: unknown): unknown {
763
+ return normalizeToolsInPayload(payload, api).payload;
764
+ }
765
+
484
766
  function formatSkillsForPrompt(skills: NonNullable<BuildSystemPromptOptions["skills"]>): string {
485
767
  const visibleSkills = skills.filter((skill) => !skill.disableModelInvocation);
486
768
  if (visibleSkills.length === 0) return "";
@@ -1039,15 +1321,21 @@ function routeSnapshotToPiModel(snapshot: PiRouteSnapshot, fallback?: PiModel):
1039
1321
  }
1040
1322
 
1041
1323
  function findModelInRegistry(registry: ModelRegistryLike | undefined, provider: string, id: string): PiModel | undefined {
1042
- const found = registry?.find?.(provider, id);
1043
- if (found) return found;
1324
+ try {
1325
+ const found = registry?.find?.(provider, id);
1326
+ if (found) return found;
1044
1327
 
1045
- const available = registry?.getAvailable?.() ?? [];
1046
- const availableMatch = available.find((candidate) => candidate.provider === provider && candidate.id === id);
1047
- if (availableMatch) return availableMatch;
1328
+ const available = registry?.getAvailable?.() ?? [];
1329
+ const availableMatch = available.find((candidate) => candidate.provider === provider && candidate.id === id);
1330
+ if (availableMatch) return availableMatch;
1048
1331
 
1049
- const all = registry?.getAll?.() ?? [];
1050
- return all.find((candidate) => candidate.provider === provider && candidate.id === id);
1332
+ const all = registry?.getAll?.() ?? [];
1333
+ return all.find((candidate) => candidate.provider === provider && candidate.id === id);
1334
+ } catch {
1335
+ // Registry extensions are optional input; a malformed/throwing registry
1336
+ // must not turn a provider error hook into a Pi session failure.
1337
+ return undefined;
1338
+ }
1051
1339
  }
1052
1340
 
1053
1341
  function isRoutedFallbackModel(model: PiModel | undefined): boolean {
@@ -1057,9 +1345,10 @@ function isRoutedFallbackModel(model: PiModel | undefined): boolean {
1057
1345
  function applyConfiguredTransportToModel(model: PiModel, config: unknown): PiModel {
1058
1346
  const providers = asRecord(asRecord(config)?.providers);
1059
1347
  const provider = asRecord(providers?.[model.provider]);
1060
- const customModel = Array.isArray(provider?.models)
1061
- ? asRecord(provider.models.find((entry: unknown) => asRecord(entry)?.id === model.id))
1062
- : undefined;
1348
+ const customModel = findLastExactModelDefinition(
1349
+ Array.isArray(provider?.models) ? provider.models : undefined,
1350
+ model.id,
1351
+ );
1063
1352
  const configuredApi = customModel?.api ?? provider?.api;
1064
1353
  const configuredBaseUrl = customModel?.baseUrl ?? provider?.baseUrl;
1065
1354
  const api = isNonEmptyString(model.api)
@@ -1172,6 +1461,21 @@ const NESTED_COMPAT_KEYS = [
1172
1461
  "chatTemplateArgs",
1173
1462
  ] as const;
1174
1463
 
1464
+ function findLastExactModelDefinition(
1465
+ models: unknown[] | undefined,
1466
+ modelId: string,
1467
+ ): UnknownRecord | undefined {
1468
+ if (!models) return undefined;
1469
+ // Pi's provider composer processes definitions in array order. If a malformed
1470
+ // or hand-edited config repeats an id, the last definition replaces earlier
1471
+ // definitions, so effective resolution must use the same deterministic rule.
1472
+ for (let index = models.length - 1; index >= 0; index--) {
1473
+ const candidate = asRecord(models[index]);
1474
+ if (candidate?.id === modelId) return candidate;
1475
+ }
1476
+ return undefined;
1477
+ }
1478
+
1175
1479
  function mergeCacheCompat(...sources: Array<UnknownRecord | undefined>): CacheCompat {
1176
1480
  const merged: CacheCompat = {};
1177
1481
  for (const source of sources) {
@@ -1196,9 +1500,10 @@ function getEffectiveCompatSources(model: PiModel, config: unknown): Array<{ sou
1196
1500
  const providers = asRecord(root?.providers);
1197
1501
  const provider = asRecord(providers?.[model.provider]);
1198
1502
  const providerCompat = asRecord(provider?.compat);
1199
- const customModel = Array.isArray(provider?.models)
1200
- ? provider.models.find((entry: unknown) => asRecord(entry)?.id === model.id)
1201
- : undefined;
1503
+ const customModel = findLastExactModelDefinition(
1504
+ Array.isArray(provider?.models) ? provider.models : undefined,
1505
+ model.id,
1506
+ );
1202
1507
  const customModelCompat = asRecord(asRecord(customModel)?.compat);
1203
1508
  const runtimeCompat = asRecord(model.compat);
1204
1509
  const modelOverride = asRecord(asRecord(provider?.modelOverrides)?.[model.id]);
@@ -1357,6 +1662,10 @@ function isValidCompatRecord(value: unknown): boolean {
1357
1662
  );
1358
1663
  }
1359
1664
 
1665
+ // Credential-blind fail-closed subset of Pi's models.json validation. This
1666
+ // intentionally covers only fields consumed by compat diagnostics, transport
1667
+ // recovery, and exact route fallback; it is not a replacement for Pi's full
1668
+ // schema and must stay conservative when the file is malformed.
1360
1669
  function isValidModelsConfigForEffectiveCompat(value: unknown): boolean {
1361
1670
  const root = asRecord(value);
1362
1671
  const providers = asRecord(root?.providers);
@@ -1376,13 +1685,42 @@ function isValidModelsConfigForEffectiveCompat(value: unknown): boolean {
1376
1685
  return true;
1377
1686
  }
1378
1687
 
1379
- function readEffectiveCompatConfig(): unknown | undefined {
1688
+ type ModelsConfigCache = {
1689
+ signature: string;
1690
+ value: unknown | undefined;
1691
+ };
1692
+
1693
+ let modelsConfigCache: ModelsConfigCache | undefined;
1694
+
1695
+ function invalidateModelsConfigCache(): void {
1696
+ modelsConfigCache = undefined;
1697
+ }
1698
+
1699
+ function getModelsConfigSignature(): string {
1380
1700
  try {
1381
- const parsed = parseJsonc(readFileSync(MODELS_JSON_PATH, "utf8"));
1382
- return isValidModelsConfigForEffectiveCompat(parsed) ? parsed : undefined;
1701
+ const info = statSync(MODELS_JSON_PATH);
1702
+ return `${info.dev}:${info.ino}:${info.size}:${info.mtimeMs}:${info.ctimeMs}`;
1383
1703
  } catch {
1384
- return undefined;
1704
+ return "missing";
1705
+ }
1706
+ }
1707
+
1708
+ function readEffectiveCompatConfig(): unknown | undefined {
1709
+ const signature = getModelsConfigSignature();
1710
+ if (modelsConfigCache?.signature === signature) return modelsConfigCache.value;
1711
+
1712
+ let value: unknown | undefined;
1713
+ if (signature !== "missing") {
1714
+ try {
1715
+ const parsed = parseJsonc(readFileSync(MODELS_JSON_PATH, "utf8"));
1716
+ value = isValidModelsConfigForEffectiveCompat(parsed) ? parsed : undefined;
1717
+ } catch {
1718
+ value = undefined;
1719
+ }
1385
1720
  }
1721
+
1722
+ modelsConfigCache = { signature, value };
1723
+ return value;
1386
1724
  }
1387
1725
 
1388
1726
  function getCompat(model: PiModel | undefined): CacheCompat {
@@ -1499,6 +1837,10 @@ function isEnabledEnv(value: string | undefined): boolean {
1499
1837
  return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
1500
1838
  }
1501
1839
 
1840
+ function isToolOrderEnabled(env: MutableEnv = process.env): boolean {
1841
+ return runtimeOptimizerEnabled && isEnabledEnv(env[TOOL_ORDER_ENV]);
1842
+ }
1843
+
1502
1844
  function parseFooterStatsMode(value: unknown): FooterStatsMode | undefined {
1503
1845
  if (typeof value !== "string") return undefined;
1504
1846
  const normalized = value.trim().toLowerCase();
@@ -1520,6 +1862,7 @@ const CACHE_OPTIMIZER_COMMANDS = [
1520
1862
  "compat",
1521
1863
  "reset",
1522
1864
  "fix",
1865
+ "rollback",
1523
1866
  ] as const;
1524
1867
  const CACHE_OPTIMIZER_CONFIG_ARGUMENTS = ["footer-mode"] as const;
1525
1868
  const CACHE_OPTIMIZER_FOOTER_MODES = ["total", "session", "process"] as const;
@@ -1672,6 +2015,7 @@ function getOptimizerRuntimeModeLines(): string[] {
1672
2015
  const lines: string[] = [];
1673
2016
  lines.push(`Runtime state: ${state}`);
1674
2017
  lines.push(`• Prompt rewrite: ${runtimeOptimizerEnabled && !isEnabledEnv(process.env[NO_PROMPT_REWRITE_ENV]) ? "on" : "off"}`);
2018
+ lines.push(`• Deterministic tool ordering: ${isToolOrderEnabled() ? "on (verified built-in payloads, opt-in)" : "off"}`);
1675
2019
  lines.push(`• OpenAI prompt_cache_key fallback: ${shouldInjectOpenAIPromptCacheKey() ? "on" : "off"}`);
1676
2020
  lines.push(`• Footer cache stats: on${runtimeOptimizerEnabled ? "" : " (comparison mode)"}`);
1677
2021
  lines.push(`• Compat warnings: ${runtimeOptimizerEnabled ? "on" : "off"}`);
@@ -2508,6 +2852,76 @@ function modelFromAssistantMessage(message: unknown, fallback: PiModel | undefin
2508
2852
  } as PiModel;
2509
2853
  }
2510
2854
 
2855
+ const REQUEST_SNAPSHOT_COMPAT_KEYS: Array<keyof CacheCompat> = [
2856
+ "supportsStore",
2857
+ "supportsDeveloperRole",
2858
+ "supportsReasoningEffort",
2859
+ "supportsUsageInStreaming",
2860
+ "supportsStrictMode",
2861
+ "maxTokensField",
2862
+ "sendSessionAffinityHeaders",
2863
+ "sessionAffinityFormat",
2864
+ "supportsLongCacheRetention",
2865
+ "thinkingFormat",
2866
+ "requiresReasoningContentOnAssistantMessages",
2867
+ "cacheControlFormat",
2868
+ "forceAdaptiveThinking",
2869
+ "allowEmptySignature",
2870
+ ];
2871
+
2872
+ function snapshotCompatForDiagnostics(model: PiModel): CacheCompat {
2873
+ const source = getCompat(model);
2874
+ const snapshot: CacheCompat = {};
2875
+ const mutableSnapshot = snapshot as Record<string, unknown>;
2876
+ for (const key of REQUEST_SNAPSHOT_COMPAT_KEYS) {
2877
+ const value = source[key];
2878
+ if (value !== undefined) mutableSnapshot[key] = value;
2879
+ }
2880
+ return snapshot;
2881
+ }
2882
+
2883
+ function snapshotBaseUrlForDiagnostics(value: unknown): string {
2884
+ if (!isNonEmptyString(value)) return "";
2885
+ try {
2886
+ const url = new URL(value);
2887
+ // Request correlation only needs endpoint identity. Strip userinfo and
2888
+ // query/fragment material so a provider URL cannot carry credentials into
2889
+ // the process-local snapshot.
2890
+ url.username = "";
2891
+ url.password = "";
2892
+ url.search = "";
2893
+ url.hash = "";
2894
+ return url.toString();
2895
+ } catch {
2896
+ // Invalid endpoint strings are not useful for applicability checks. Keep
2897
+ // only a conservative path-free origin-like prefix without userinfo.
2898
+ return value.replace(new RegExp("//[^/?#\\s@]+@"), "//").split(/[?#]/, 1)[0];
2899
+ }
2900
+ }
2901
+
2902
+ function snapshotProviderRequestModel(model: PiModel | undefined): PiModel | undefined {
2903
+ if (!model) return undefined;
2904
+
2905
+ // The provider response hooks do not receive the model that initiated the
2906
+ // request (Pi's runner only forwards status/headers). Keep a process-local,
2907
+ // credential-blind metadata snapshot so a model switch or route change
2908
+ // between request and response cannot retarget a protocol observation. Do
2909
+ // not retain the caller's complete model object or any request data.
2910
+ return {
2911
+ provider: model.provider,
2912
+ id: model.id,
2913
+ name: model.name,
2914
+ api: model.api,
2915
+ baseUrl: snapshotBaseUrlForDiagnostics(model.baseUrl),
2916
+ compat: snapshotCompatForDiagnostics(model),
2917
+ reasoning: model.reasoning ?? false,
2918
+ input: model.input ?? ["text"],
2919
+ cost: model.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
2920
+ contextWindow: model.contextWindow ?? 0,
2921
+ maxTokens: model.maxTokens ?? 0,
2922
+ } as PiModel;
2923
+ }
2924
+
2511
2925
  function keyForModelExt(model: { provider: string; id: string }): string {
2512
2926
  return `${model.provider}/${model.id}`;
2513
2927
  }
@@ -2873,6 +3287,259 @@ function hasPromptCacheRetentionUnsupportedErrorMessage(message: unknown): boole
2873
3287
  hasPromptCacheRetentionUnsupportedText(record.errorMessage);
2874
3288
  }
2875
3289
 
3290
+ function hasReasoningProtocolRejectionText(value: unknown): boolean {
3291
+ const normalized = lower(value).replace(/\s+/g, " ").trim();
3292
+ if (!normalized) return false;
3293
+
3294
+ // Match only a rejection that is attached to the `thinking` parameter. Do
3295
+ // not merely look for both parameter names: `reasoning_effort is not
3296
+ // supported; use thinking` is the opposite direction and must not activate
3297
+ // this fallback.
3298
+ const thinkingParameterRejectionPatterns = [
3299
+ /(?:^|[^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_])/,
3300
+ /(?:^|[^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_])/,
3301
+ /(?:^|[^a-z0-9_])(?:extra\s+inputs?|additional\s+(?:inputs?|parameters?))\s+(?:are\s+)?(?:not permitted|not allowed|unsupported)\s*[:=]?\s*["'`]?thinking["'`]?(?![a-z0-9_])/,
3302
+ ];
3303
+ let rejectionEnd = -1;
3304
+ for (const pattern of thinkingParameterRejectionPatterns) {
3305
+ const match = pattern.exec(normalized);
3306
+ if (match && match.index + match[0].length > rejectionEnd) {
3307
+ rejectionEnd = match.index + match[0].length;
3308
+ }
3309
+ }
3310
+ if (rejectionEnd < 0) return false;
3311
+
3312
+ // The provider must direct the caller to reasoning_effort after the
3313
+ // rejection. Limiting this to the short suffix after the matched rejection
3314
+ // keeps generic documentation or an unrelated earlier sentence from being
3315
+ // treated as runtime protocol evidence.
3316
+ const recommendation = normalized.slice(rejectionEnd, rejectionEnd + 260);
3317
+ if (!/\breasoning[_\.]effort\b/.test(recommendation)) return false;
3318
+
3319
+ const recommendationClauses = recommendation.split(/[.;!?\n]/);
3320
+ return recommendationClauses.some((clause) => {
3321
+ if (!/\breasoning[_\.]effort\b/.test(clause)) return false;
3322
+ // A target mentioned inside a negated/disabled clause is not positive
3323
+ // protocol guidance, even if words such as `must` or `supported` occur.
3324
+ if (
3325
+ /\b(?:do\s+not|don't|never|avoid)\s+(?:use|set|send|pass|provide)?\s*["'`]?reasoning[_\.]effort\b/.test(clause) ||
3326
+ /\breasoning[_\.]effort\b[^.;]{0,80}\b(?:must|should|may|do)\s+(?:not|never)\b/.test(clause) ||
3327
+ /\breasoning[_\.]effort\b[^.;]{0,80}\b(?:unsupported|disabled|unavailable|not\s+(?:supported|accepted|allowed|available|enabled|required|recommended|expected))\b/.test(clause)
3328
+ ) return false;
3329
+
3330
+ return [
3331
+ /(?:use|try|set|send|pass|provide)\s+(?:the\s+)?(?:top[- ]level\s+)?["'`]?reasoning[_\.]effort["'`]?(?![a-z0-9_])/,
3332
+ /(?:reasoning[_\.]effort)\b[^.;]{0,120}(?:instead|required|must\s+be|should\s+be|is\s+(?:supported|accepted|preferred|recommended|expected))\b/,
3333
+ /(?:parameter|field|option)\s+(?:is|should be)\s+["'`]?reasoning[_\.]effort["'`]?(?![a-z0-9_])/,
3334
+ /instead[^.;]{0,120}(?:use|try|set|send|pass|provide)\s+(?:the\s+)?["'`]?reasoning[_\.]effort["'`]?(?![a-z0-9_])/,
3335
+ /(?:replace|change|switch)\s+(?:the\s+)?["'`]?thinking["'`]?\s+(?:with|to)\s+["'`]?reasoning[_\.]effort["'`]?(?![a-z0-9_])/,
3336
+ ].some((pattern) => pattern.test(clause));
3337
+ });
3338
+ }
3339
+
3340
+ function hasReasoningProtocolRejectionSignal(headers: Record<string, string> | undefined): boolean {
3341
+ if (!headers) return false;
3342
+ // Each response header is one diagnostic unit. Joining all values can pair a
3343
+ // rejection from one header with unrelated documentation in another.
3344
+ return Object.entries(headers).some(([key, headerValue]) =>
3345
+ hasReasoningProtocolRejectionText(`${key}: ${headerValue}`)
3346
+ );
3347
+ }
3348
+
3349
+ function getOptionalAssistantHttpStatus(record: UnknownRecord): number | undefined {
3350
+ const readStatus = (value: unknown): number | undefined => {
3351
+ if (typeof value === "number" && Number.isInteger(value) && value >= 100 && value <= 599) return value;
3352
+ if (typeof value === "string" && /^\d{3}$/.test(value.trim())) {
3353
+ const parsed = Number(value.trim());
3354
+ return parsed >= 100 && parsed <= 599 ? parsed : undefined;
3355
+ }
3356
+ return undefined;
3357
+ };
3358
+
3359
+ for (const key of ["status", "statusCode", "httpStatus", "httpStatusCode"]) {
3360
+ const status = readStatus(record[key]);
3361
+ if (status !== undefined) return status;
3362
+ }
3363
+
3364
+ // Foreign/provider adapters sometimes put the HTTP status under a
3365
+ // diagnostic `details`, `error`, or `cause` object rather than exposing it
3366
+ // on the finalized assistant message itself. Traverse only these known
3367
+ // diagnostic wrappers, with a small depth bound; never inspect arbitrary
3368
+ // payload/message fields for a number that happens to look like a status.
3369
+ const scanDiagnosticStatus = (value: unknown, depth: number): number | undefined => {
3370
+ if (depth > 2) return undefined;
3371
+ if (Array.isArray(value)) {
3372
+ for (const item of value) {
3373
+ const status = scanDiagnosticStatus(item, depth + 1);
3374
+ if (status !== undefined) return status;
3375
+ }
3376
+ return undefined;
3377
+ }
3378
+ const source = asRecord(value);
3379
+ if (!source) return undefined;
3380
+ for (const key of ["status", "statusCode", "httpStatus", "httpStatusCode", "code"]) {
3381
+ const status = readStatus(source[key]);
3382
+ if (status !== undefined) return status;
3383
+ }
3384
+ for (const key of ["details", "error", "cause", "diagnostics"]) {
3385
+ const status = scanDiagnosticStatus(source[key], depth + 1);
3386
+ if (status !== undefined) return status;
3387
+ }
3388
+ return undefined;
3389
+ };
3390
+ for (const key of ["diagnostics", "diagnostic", "details", "error", "cause"]) {
3391
+ const status = scanDiagnosticStatus(record[key], 0);
3392
+ if (status !== undefined) return status;
3393
+ }
3394
+
3395
+ // Pi's built-in OpenAI-compatible adapters normally prefix a provider error
3396
+ // body with the HTTP status (for example `400: {...}` or
3397
+ // `OpenAI API error (400): ...`). Only parse status-shaped prefixes; never
3398
+ // search arbitrary body text where a model id or payload number could be
3399
+ // mistaken for a response status.
3400
+ if (typeof record.errorMessage === "string") {
3401
+ const errorText = record.errorMessage;
3402
+ const matches = [
3403
+ /^\s*([1-5]\d{2})\b/,
3404
+ /^\s*(?:http(?:\s+status)?|status(?:\s+code)?)\s*[:(]?\s*([1-5]\d{2})\b/i,
3405
+ /^\s*[^():\n]{1,100}\(\s*([1-5]\d{2})\b/,
3406
+ /^\s*(?:error|api\s+error|provider\s+error|request\s+failed|http\s+error)\s*[:(]\s*([1-5]\d{2})\b/i,
3407
+ ];
3408
+ for (const pattern of matches) {
3409
+ const match = pattern.exec(errorText);
3410
+ if (match) return Number(match[1]);
3411
+ }
3412
+ }
3413
+ return undefined;
3414
+ }
3415
+
3416
+ function hasReasoningProtocolRejectionErrorMessage(message: unknown): boolean {
3417
+ const record = getAssistantRecord(message);
3418
+ if (!record || record.stopReason !== "error") return false;
3419
+ // Finalized assistant messages do not expose a separate status field in the
3420
+ // normal Pi path, so recover it only from structured diagnostics or the
3421
+ // adapter's status-shaped error prefix. A text-only 400-looking parameter
3422
+ // message is not enough evidence for a protocol repair.
3423
+ return getOptionalAssistantHttpStatus(record) === 400 &&
3424
+ hasReasoningProtocolRejectionText(record.errorMessage);
3425
+ }
3426
+
3427
+ function isReasoningProtocolRejectionSignalApplicable(model: PiModel | undefined): boolean {
3428
+ // A provider error can teach us which protocol it expects, so this gate is
3429
+ // intentionally broader than the explicit DeepSeek-format diagnostic gate.
3430
+ // The model family identifies the affected cache/compat bucket; the error
3431
+ // text, not the model name, supplies the wire-protocol evidence.
3432
+ return !!model && isOpenAICompatibleProxyApi(model.api) && isDeepSeekLikeModel(model);
3433
+ }
3434
+
3435
+ function isReasoningProtocolRejectionForModel(message: unknown, model: PiModel | undefined): boolean {
3436
+ if (!model || !isReasoningProtocolRejectionSignalApplicable(model)) return false;
3437
+ if (!hasReasoningProtocolRejectionErrorMessage(message)) return false;
3438
+ const record = getAssistantRecord(message);
3439
+ if (!record) return false;
3440
+ const messageProvider = firstNonEmptyString(record.provider);
3441
+ const messageModel = firstNonEmptyString(record.responseModel, record.model);
3442
+ // An explicit identity in the finalized assistant error is authoritative.
3443
+ // Do not broaden a model-scoped observation to another DeepSeek-named model.
3444
+ return (!messageProvider || messageProvider === model.provider) &&
3445
+ (!messageModel || messageModel === model.id);
3446
+ }
3447
+
3448
+ async function notifyReasoningProtocolObservation(
3449
+ model: PiModel,
3450
+ ctx: Pick<ExtensionContext, "ui">,
3451
+ rejectedModelKeys: Set<string>,
3452
+ warnedModelKeys: Set<string>,
3453
+ ): Promise<void> {
3454
+ const key = modelKey(model);
3455
+ rejectedModelKeys.add(key);
3456
+ if (warnedModelKeys.has(key)) return;
3457
+ warnedModelKeys.add(key);
3458
+
3459
+ const receipt = await readModelsJsonFixReceipt();
3460
+ const matchingReceipt = isActionableModelsJsonFixReceipt(receipt) &&
3461
+ receipt.provider === model.provider &&
3462
+ receipt.modelId === model.id;
3463
+ const recovery = matchingReceipt
3464
+ ? "A matching confirmed fix receipt exists; run /cache-optimizer rollback to undo it safely."
3465
+ : "Run /cache-optimizer fix to review a model-scoped repair.";
3466
+ ctx.ui.notify(
3467
+ `⚠️ ${LOG_PREFIX}: ${key} rejected the configured reasoning format. ${recovery} ` +
3468
+ "No configuration was changed automatically.",
3469
+ "warning",
3470
+ );
3471
+ }
3472
+
3473
+ function fixSuggestionIdentity(model: PiModel): { providerLabel: string; modelId: string } {
3474
+ const key = modelKey(model);
3475
+ const slashIdx = key.indexOf("/");
3476
+ return {
3477
+ providerLabel: slashIdx > 0 ? key.slice(0, slashIdx) : key,
3478
+ modelId: model.id,
3479
+ };
3480
+ }
3481
+
3482
+ function mergeFixSuggestions(...suggestions: Array<FixSuggestion | undefined>): FixSuggestion | undefined {
3483
+ const present = suggestions.filter((suggestion): suggestion is FixSuggestion => suggestion !== undefined);
3484
+ if (present.length === 0) return undefined;
3485
+ const first = present[0];
3486
+ return {
3487
+ providerLabel: first.providerLabel,
3488
+ modelId: first.modelId,
3489
+ compatKeys: Object.assign({}, ...present.map((suggestion) => suggestion.compatKeys)),
3490
+ forceModelLevel: present.some((suggestion) => suggestion.forceModelLevel === true),
3491
+ };
3492
+ }
3493
+
3494
+ function buildReasoningProtocolFixSuggestion(
3495
+ model: PiModel,
3496
+ protocolRejectionObserved = false,
3497
+ ): FixSuggestion | undefined {
3498
+ if (!protocolRejectionObserved) return undefined;
3499
+ if (!isDeepSeekLikeModel(model) || !isOpenAICompatibleProxyApi(model.api)) return undefined;
3500
+
3501
+ const compat = getCompat(model);
3502
+ // Explicit provider-specific formats are user/provider evidence in their own
3503
+ // right. Do not replace qwen/openrouter/together/etc. merely because a
3504
+ // generic error happened to mention both reasoning parameter names. The
3505
+ // evidence-driven repair is limited to the old DeepSeek `thinking` format,
3506
+ // an explicit standard OpenAI format, or an absent format that needs to be
3507
+ // made unambiguous after the provider's rejection.
3508
+ if (
3509
+ compat.thinkingFormat !== undefined &&
3510
+ compat.thinkingFormat !== "deepseek" &&
3511
+ compat.thinkingFormat !== "openai"
3512
+ ) {
3513
+ return undefined;
3514
+ }
3515
+
3516
+ const identity = fixSuggestionIdentity(model);
3517
+ const compatKeys: Record<string, unknown> = {};
3518
+ if (compat.thinkingFormat !== "openai") {
3519
+ compatKeys.thinkingFormat = "openai";
3520
+ }
3521
+ if (compat.supportsReasoningEffort !== true) {
3522
+ compatKeys.supportsReasoningEffort = true;
3523
+ }
3524
+
3525
+ // Replay behavior is changed only when the current effective configuration
3526
+ // actually enabled it. Never invent a false value merely because the model
3527
+ // family is named DeepSeek or because a provider supports reasoning effort.
3528
+ if (compat.requiresReasoningContentOnAssistantMessages === true) {
3529
+ compatKeys.requiresReasoningContentOnAssistantMessages = false;
3530
+ }
3531
+ if (Object.keys(compatKeys).length === 0) return undefined;
3532
+
3533
+ return {
3534
+ ...identity,
3535
+ // Protocol observations are always model-scoped. A provider may expose
3536
+ // DeepSeek-named models with different wire formats, so sibling models
3537
+ // must never inherit this evidence-driven change.
3538
+ compatKeys,
3539
+ forceModelLevel: true,
3540
+ };
3541
+ }
3542
+
2876
3543
  type CompatAdvicePlacement = {
2877
3544
  providerLabel?: string;
2878
3545
  modelId?: string;
@@ -2971,7 +3638,7 @@ function buildOpenAIProxyCompatWarningText(key: string, missing: string[]): stri
2971
3638
 
2972
3639
  const modelsJsonPath = getModelsJsonDisplayPath();
2973
3640
  const lines: string[] = [
2974
- `💡 pi-cache-optimizer: ${key} is a third-party GPT/OpenAI-compatible proxy but merged compat lacks ${missing.join(" and ")}.`,
3641
+ `💡 pi-cache-optimizer: ${key} is a third-party OpenAI-compatible proxy but merged compat lacks ${missing.join(" and ")}.`,
2975
3642
  `Edit ${modelsJsonPath} -> providers["${providerLabel}"] -> compat (at the same level as baseUrl/api/apiKey/models).`,
2976
3643
  ``,
2977
3644
  ];
@@ -2981,80 +3648,82 @@ function buildOpenAIProxyCompatWarningText(key: string, missing: string[]): stri
2981
3648
  return lines.join("\n");
2982
3649
  }
2983
3650
 
3651
+ /**
3652
+ * DeepSeek's model family and its reasoning wire protocol are separate facts.
3653
+ * The family name selects the cache adapter, but only an explicit effective
3654
+ * `thinkingFormat: "deepseek"` opts a third-party OpenAI Completions model into
3655
+ * the reasoning/replay compat checks below. Model names, provider ids, URLs,
3656
+ * and supportsReasoningEffort are not protocol evidence.
3657
+ */
3658
+ function isDeepSeekWireCompatApplicable(model: PiModel): boolean {
3659
+ // `thinkingFormat` is the only wire-protocol signal. The model family is
3660
+ // still name/id based for adapter selection, while provider, URL, and
3661
+ // supportsReasoningEffort remain deliberately irrelevant here.
3662
+ return isDeepSeekLikeModel(model)
3663
+ && isOpenAICompatibleProxyApi(model.api)
3664
+ && getCompat(model).thinkingFormat === "deepseek";
3665
+ }
3666
+
2984
3667
  function describeMissingDeepSeekCompat(model: PiModel): string[] {
3668
+ if (!isDeepSeekWireCompatApplicable(model)) return [];
3669
+
2985
3670
  const compat = getCompat(model);
2986
3671
  const missing: string[] = [];
2987
-
2988
- if (compat.supportsLongCacheRetention !== true) {
2989
- missing.push("supportsLongCacheRetention");
2990
- }
2991
- if (model.api !== "openai-responses" && compat.sendSessionAffinityHeaders !== true) {
2992
- missing.push("sendSessionAffinityHeaders");
2993
- }
2994
3672
  if (compat.requiresReasoningContentOnAssistantMessages !== true) {
2995
3673
  missing.push("requiresReasoningContentOnAssistantMessages");
2996
3674
  }
2997
- if (compat.thinkingFormat !== "deepseek") {
2998
- missing.push("thinkingFormat");
2999
- }
3000
-
3001
3675
  return missing;
3002
3676
  }
3003
3677
 
3678
+ /**
3679
+ * Compatibility diagnostics for a DeepSeek-like model are generic unless the
3680
+ * user has explicitly selected Pi's DeepSeek reasoning wire format. In
3681
+ * particular, never manufacture `thinkingFormat: "deepseek"`: doing so can
3682
+ * turn a valid reasoning_effort request into a provider-rejected `thinking`
3683
+ * request (Issue #10).
3684
+ */
3004
3685
  function isDeepSeekCompatCheckApplicable(model: PiModel): boolean {
3005
- return isDeepSeekLikeModel(model)
3006
- && isOpenAICompatibleApi(model.api)
3007
- && isKnownThirdPartyOpenAIEndpoint(model)
3008
- && !isPiBuiltInLlamaCppModel(model);
3686
+ return isDeepSeekWireCompatApplicable(model);
3687
+ }
3688
+
3689
+ function hasExplicitDeepSeekReasoningProtocol(model: PiModel): boolean {
3690
+ return isDeepSeekWireCompatApplicable(model);
3009
3691
  }
3010
3692
 
3011
3693
  function describeMissingCacheCompatForModel(model: PiModel): string[] {
3012
3694
  if (isAdaptiveThinkingCompatApplicable(model)) {
3013
3695
  return describeMissingAdaptiveThinkingCompat(model);
3014
3696
  }
3015
- if (isDeepSeekCompatCheckApplicable(model)) {
3016
- return describeMissingDeepSeekCompat(model);
3697
+
3698
+ const missing = describeMissingOpenAICompatibleProxyCompat(model);
3699
+ if (isDeepSeekWireCompatApplicable(model)) {
3700
+ missing.push(...describeMissingDeepSeekCompat(model));
3017
3701
  }
3018
- return describeMissingOpenAICompatibleProxyCompat(model);
3702
+ return missing;
3019
3703
  }
3020
3704
 
3021
3705
  function buildDeepSeekCompatSuggestion(missing: string[]): Record<string, unknown> {
3022
- const suggestion: Record<string, unknown> = {};
3023
-
3024
- if (missing.includes("supportsLongCacheRetention")) {
3025
- suggestion.supportsLongCacheRetention = true;
3026
- }
3027
- if (missing.includes("sendSessionAffinityHeaders")) {
3028
- suggestion.sendSessionAffinityHeaders = true;
3029
- }
3706
+ const suggestion: Record<string, unknown> = {
3707
+ ...buildSafeOpenAIProxyCompatSuggestion(missing),
3708
+ };
3030
3709
  if (missing.includes("requiresReasoningContentOnAssistantMessages")) {
3031
3710
  suggestion.requiresReasoningContentOnAssistantMessages = true;
3032
3711
  }
3033
- if (missing.includes("thinkingFormat")) {
3034
- suggestion.thinkingFormat = "deepseek";
3035
- }
3036
-
3037
3712
  return suggestion;
3038
3713
  }
3039
3714
 
3040
3715
  function appendDeepSeekCompatAdviceLines(lines: string[], missing: string[], placement: CompatAdvicePlacement = {}): void {
3041
3716
  const suggestion = buildDeepSeekCompatSuggestion(missing);
3042
3717
  if (Object.keys(suggestion).length > 0) {
3043
- lines.push("Recommended DeepSeek compat snippet:");
3718
+ lines.push("Recommended DeepSeek reasoning/replay compat snippet:");
3044
3719
  lines.push(JSON.stringify(suggestion, null, 2));
3045
3720
  }
3046
3721
 
3047
3722
  if (missing.includes("requiresReasoningContentOnAssistantMessages")) {
3048
- lines.push('- requiresReasoningContentOnAssistantMessages: true keeps replayed assistant turns compatible with DeepSeek reasoning_content requirements.');
3049
- }
3050
- if (missing.includes("thinkingFormat")) {
3051
- lines.push('- thinkingFormat: "deepseek" tells Pi to use DeepSeek reasoning/thinking parameter format.');
3723
+ lines.push('- requiresReasoningContentOnAssistantMessages: true keeps replayed assistant turns compatible with an explicitly selected DeepSeek reasoning wire format.');
3052
3724
  }
3053
3725
  if (missing.includes("sendSessionAffinityHeaders")) {
3054
- lines.push("- sendSessionAffinityHeaders: recommended for OpenAI-compatible DeepSeek proxies when supported; it helps keep one Pi session on the same upstream/backend.");
3055
- }
3056
- if (missing.includes("supportsLongCacheRetention")) {
3057
- lines.push("- supportsLongCacheRetention: enable for DeepSeek-compatible endpoints that support long cache retention.");
3726
+ lines.push("- sendSessionAffinityHeaders: recommended for third-party OpenAI-compatible proxies when supported; it helps keep one Pi session on the same upstream/backend.");
3058
3727
  }
3059
3728
 
3060
3729
  appendCredentialSafeProviderGuidance(lines, placement, suggestion);
@@ -3089,13 +3758,13 @@ const CACHE_PROVIDER_ADAPTERS: CacheProviderAdapter[] = [
3089
3758
  return normalizeWithFallback(message, getDeepSeekRawUsage, { allowInputOnlyPiUsage: true });
3090
3759
  },
3091
3760
  warningText(model) {
3092
- if (!isDeepSeekCompatCheckApplicable(model)) return undefined;
3093
-
3094
- const missing = describeMissingDeepSeekCompat(model);
3761
+ const missing = describeMissingCacheCompatForModel(model);
3095
3762
  if (missing.length === 0) return undefined;
3096
3763
 
3097
3764
  const key = modelKey(model);
3098
- return buildDeepSeekCompatWarningText(key, missing);
3765
+ return isDeepSeekWireCompatApplicable(model)
3766
+ ? buildDeepSeekCompatWarningText(key, missing)
3767
+ : buildOpenAIProxyCompatWarningText(key, missing);
3099
3768
  },
3100
3769
  },
3101
3770
  {
@@ -4059,6 +4728,10 @@ function notifyCacheCompatIfNeeded(
4059
4728
  ): void {
4060
4729
  if (!model) return;
4061
4730
 
4731
+ // A protocol rejection is retained separately from ordinary compat warnings;
4732
+ // it is surfaced once by the response hook and can be used by a later fix.
4733
+ // Do not turn it into an automatic write here.
4734
+
4062
4735
  // Native anthropic-messages adaptive thinking compat check.
4063
4736
  // Adapter warningText only fires for OpenAI-compatible APIs, so native
4064
4737
  // Anthropic and Kimi Coding adaptive models need a separate check.
@@ -5448,7 +6121,7 @@ function buildDoctorDiagnosis(model: PiModel, options: { promptCacheRetention400
5448
6121
  const adaptiveThinkingApplicable = isAdaptiveThinkingCompatApplicable(model);
5449
6122
  const deepSeekCompatApplicable = isDeepSeekCompatCheckApplicable(model);
5450
6123
  const missing = describeMissingCacheCompatForModel(model);
5451
- const optionalOpenAIProxyCompat = (!adaptiveThinkingApplicable && !deepSeekCompatApplicable)
6124
+ const optionalOpenAIProxyCompat = !adaptiveThinkingApplicable
5452
6125
  ? describeOptionalOpenAICompatibleProxyCompat(model)
5453
6126
  : [];
5454
6127
  const fixSug = buildFixSuggestion(model);
@@ -5472,6 +6145,7 @@ function buildDoctorDiagnosis(model: PiModel, options: { promptCacheRetention400
5472
6145
  appendAdaptiveThinkingCompatAdviceLines(lines, missing, { providerLabel, modelId: model.id });
5473
6146
  } else if (deepSeekCompatApplicable) {
5474
6147
  appendDeepSeekCompatAdviceLines(lines, missing, { providerLabel, modelId: model.id });
6148
+ appendOptionalOpenAIProxyCompatAdviceLines(lines, optionalOpenAIProxyCompat);
5475
6149
  } else {
5476
6150
  appendOpenAIProxyCompatAdviceLines(lines, missing, { providerLabel, modelId: model.id });
5477
6151
  appendOptionalOpenAIProxyCompatAdviceLines(lines, optionalOpenAIProxyCompat);
@@ -5682,7 +6356,7 @@ function buildCompatDiagnosis(model: PiModel): string | undefined {
5682
6356
  const advisoryMissingC = missing.filter(m => !safeFixableMissingC.includes(m));
5683
6357
  const adaptiveThinkingApplicable = isAdaptiveThinkingCompatApplicable(model);
5684
6358
  const deepSeekCompatApplicable = isDeepSeekCompatCheckApplicable(model);
5685
- const optionalOpenAIProxyCompat = (!adaptiveThinkingApplicable && !deepSeekCompatApplicable)
6359
+ const optionalOpenAIProxyCompat = !adaptiveThinkingApplicable
5686
6360
  ? describeOptionalOpenAICompatibleProxyCompat(model)
5687
6361
  : [];
5688
6362
  const routerNotes = describeRouterChannelDiagnostics(model);
@@ -5710,6 +6384,7 @@ function buildCompatDiagnosis(model: PiModel): string | undefined {
5710
6384
  appendAdaptiveThinkingCompatAdviceLines(lines, missing, { providerLabel, modelId: model.id });
5711
6385
  } else if (deepSeekCompatApplicable) {
5712
6386
  appendDeepSeekCompatAdviceLines(lines, missing, { providerLabel, modelId: model.id });
6387
+ appendOptionalOpenAIProxyCompatAdviceLines(lines, optionalOpenAIProxyCompat);
5713
6388
  } else {
5714
6389
  appendOpenAIProxyCompatAdviceLines(lines, missing, { providerLabel, modelId: model.id });
5715
6390
  appendOptionalOpenAIProxyCompatAdviceLines(lines, optionalOpenAIProxyCompat);
@@ -5862,19 +6537,25 @@ function skipJsonValue(text: string, pos: number): number | undefined {
5862
6537
  /**
5863
6538
  * Find a top-level key in the object whose `{` is at `openBracePos`.
5864
6539
  * Only direct children are considered (nested values are skipped whole).
5865
- * Returns the key's opening-quote offset and its value's start offset,
5866
- * or undefined when the key is absent or the object is malformed.
6540
+ * When duplicate keys exist, return the last one so scanning follows
6541
+ * JSON.parse/Pi's effective last-definition-wins behavior. `count` lets
6542
+ * callers refuse an ambiguous surgical edit rather than silently selecting a
6543
+ * user-added duplicate. Return undefined when the key is absent or the object
6544
+ * is malformed.
5867
6545
  */
5868
6546
  function findJsonObjectKey(
5869
6547
  text: string,
5870
6548
  openBracePos: number,
5871
6549
  targetKey: string,
5872
- ): { keyStart: number; valueStart: number } | undefined {
6550
+ ): { keyStart: number; valueStart: number; count: number } | undefined {
5873
6551
  if (text[openBracePos] !== "{") return undefined;
5874
6552
  let i = openBracePos + 1;
6553
+ let found: { keyStart: number; valueStart: number; count: number } | undefined;
6554
+ let count = 0;
5875
6555
  while (i < text.length) {
5876
6556
  i = skipJsonWhitespace(text, i);
5877
- if (i >= text.length || text[i] === "}") return undefined;
6557
+ if (i >= text.length) return undefined;
6558
+ if (text[i] === "}") return found;
5878
6559
  if (text[i] === ",") {
5879
6560
  i++;
5880
6561
  continue;
@@ -5886,7 +6567,10 @@ function findJsonObjectKey(
5886
6567
  i = skipJsonWhitespace(text, key.end);
5887
6568
  if (text[i] !== ":") return undefined;
5888
6569
  i = skipJsonWhitespace(text, i + 1);
5889
- if (key.value === targetKey) return { keyStart, valueStart: i };
6570
+ if (key.value === targetKey) {
6571
+ count++;
6572
+ found = { keyStart, valueStart: i, count };
6573
+ }
5890
6574
  const after = skipJsonValue(text, i);
5891
6575
  if (after === undefined) return undefined;
5892
6576
  i = after;
@@ -6083,12 +6767,13 @@ function resolveExplicitCompatValue(
6083
6767
  return { source: "modelOverride", value: overrideCompat[compatKey] };
6084
6768
  }
6085
6769
 
6086
- if (Array.isArray(provider.models)) {
6087
- const model = provider.models.find((entry: unknown) => asRecord(entry)?.id === modelId);
6088
- const modelCompat = asRecord(asRecord(model)?.compat);
6089
- if (modelCompat && Object.prototype.hasOwnProperty.call(modelCompat, compatKey)) {
6090
- return { source: "model", value: modelCompat[compatKey] };
6091
- }
6770
+ const model = findLastExactModelDefinition(
6771
+ Array.isArray(provider.models) ? provider.models : undefined,
6772
+ modelId,
6773
+ );
6774
+ const modelCompat = asRecord(model?.compat);
6775
+ if (modelCompat && Object.prototype.hasOwnProperty.call(modelCompat, compatKey)) {
6776
+ return { source: "model", value: modelCompat[compatKey] };
6092
6777
  }
6093
6778
 
6094
6779
  const providerCompat = asRecord(provider.compat);
@@ -6117,6 +6802,8 @@ function hasExplicitLongRetentionOptInFromConfig(
6117
6802
  * Returns the byte offsets for surgical insertion, or undefined if ambiguous.
6118
6803
  */
6119
6804
  interface ModelNodeLocation {
6805
+ /** Number of exact provider keys found in the root providers object. */
6806
+ providerKeyCount: number;
6120
6807
  /** Offset of the model object's opening `{` */
6121
6808
  modelObjectBrace: number;
6122
6809
  /** Offset of the model object's closing `}` */
@@ -6145,11 +6832,21 @@ interface ModelNodeLocation {
6145
6832
  modelOverrideCompatBrace: number;
6146
6833
  /** Offset of the target override compat object's closing `}`, or -1 if absent */
6147
6834
  modelOverrideCompatEnd: number;
6835
+ /** Number of exact modelOverrides keys for this model id. */
6836
+ modelOverrideKeyCount: number;
6837
+ /** Number of exact compat keys in the selected model object. */
6838
+ modelCompatKeyCount: number;
6839
+ /** Number of exact provider compat keys. */
6840
+ providerCompatKeyCount: number;
6148
6841
  /** All model ids found in this provider's models array (for placement safety analysis) */
6149
6842
  allModelIds: string[];
6150
6843
  }
6151
6844
 
6152
6845
  interface ModelOverrideNodeLocation {
6846
+ providerKeyCount: number;
6847
+ modelOverridesKeyCount: number;
6848
+ modelOverrideKeyCount: number;
6849
+ modelOverrideCompatKeyCount: number;
6153
6850
  providerObjectBrace: number;
6154
6851
  providerObjectEnd: number;
6155
6852
  modelOverridesObjectBrace: number;
@@ -6170,17 +6867,21 @@ function locateModelOverrideInJsonc(
6170
6867
  const rootBrace = skipJsonWhitespace(clean, 0);
6171
6868
  if (clean[rootBrace] !== "{") return undefined;
6172
6869
  const providersKey = findJsonObjectKey(clean, rootBrace, "providers");
6173
- if (!providersKey) return undefined;
6870
+ if (!providersKey || providersKey.count !== 1) return undefined;
6174
6871
  const providersBrace = skipJsonWhitespace(clean, providersKey.valueStart);
6175
6872
  if (clean[providersBrace] !== "{") return undefined;
6176
6873
  const providersEnd = findMatchingBracket(clean, providersBrace);
6177
6874
  if (providersEnd === undefined) return undefined;
6178
6875
  const providerKey = findJsonObjectKey(clean, providersBrace, providerLabel);
6179
- if (!providerKey || providerKey.keyStart > providersEnd) return undefined;
6876
+ if (!providerKey || providerKey.count !== 1 || providerKey.keyStart > providersEnd) return undefined;
6180
6877
  const providerObjectBrace = skipJsonWhitespace(clean, providerKey.valueStart);
6181
6878
  if (clean[providerObjectBrace] !== "{") return undefined;
6182
6879
  const providerObjectEnd = findMatchingBracket(clean, providerObjectBrace);
6183
6880
  if (providerObjectEnd === undefined || providerObjectEnd > providersEnd) return undefined;
6881
+ const providerCompatKey = findJsonObjectKey(clean, providerObjectBrace, "compat");
6882
+ if (providerCompatKey?.count && providerCompatKey.count > 1) return undefined;
6883
+ const modelsKey = findJsonObjectKey(clean, providerObjectBrace, "models");
6884
+ if (modelsKey?.count && modelsKey.count > 1) return undefined;
6184
6885
 
6185
6886
  let modelOverridesObjectBrace = -1;
6186
6887
  let modelOverridesObjectEnd = -1;
@@ -6188,7 +6889,10 @@ function locateModelOverrideInJsonc(
6188
6889
  let modelOverrideObjectEnd = -1;
6189
6890
  let modelOverrideCompatBrace = -1;
6190
6891
  let modelOverrideCompatEnd = -1;
6892
+ let modelOverrideKeyCount = 0;
6893
+ let modelOverrideCompatKeyCount = 0;
6191
6894
  const overridesKey = findJsonObjectKey(clean, providerObjectBrace, "modelOverrides");
6895
+ if (overridesKey?.count && overridesKey.count > 1) return undefined;
6192
6896
  if (overridesKey && overridesKey.keyStart < providerObjectEnd) {
6193
6897
  const brace = skipJsonWhitespace(clean, overridesKey.valueStart);
6194
6898
  if (clean[brace] !== "{") return undefined;
@@ -6198,6 +6902,8 @@ function locateModelOverrideInJsonc(
6198
6902
  modelOverridesObjectEnd = end;
6199
6903
 
6200
6904
  const overrideKey = findJsonObjectKey(clean, brace, modelId);
6905
+ modelOverrideKeyCount = overrideKey?.count ?? 0;
6906
+ if (overrideKey?.count && overrideKey.count > 1) return undefined;
6201
6907
  if (overrideKey && overrideKey.keyStart < end) {
6202
6908
  const entryBrace = skipJsonWhitespace(clean, overrideKey.valueStart);
6203
6909
  if (clean[entryBrace] !== "{") return undefined;
@@ -6207,6 +6913,8 @@ function locateModelOverrideInJsonc(
6207
6913
  modelOverrideObjectEnd = entryEnd;
6208
6914
 
6209
6915
  const compatKey = findJsonObjectKey(clean, entryBrace, "compat");
6916
+ modelOverrideCompatKeyCount = compatKey?.count ?? 0;
6917
+ if (compatKey?.count && compatKey.count > 1) return undefined;
6210
6918
  if (compatKey && compatKey.keyStart < entryEnd) {
6211
6919
  const compatBrace = skipJsonWhitespace(clean, compatKey.valueStart);
6212
6920
  if (clean[compatBrace] !== "{") return undefined;
@@ -6219,6 +6927,10 @@ function locateModelOverrideInJsonc(
6219
6927
  }
6220
6928
 
6221
6929
  return {
6930
+ providerKeyCount: providerKey.count,
6931
+ modelOverridesKeyCount: overridesKey?.count ?? 0,
6932
+ modelOverrideKeyCount,
6933
+ modelOverrideCompatKeyCount,
6222
6934
  providerObjectBrace,
6223
6935
  providerObjectEnd,
6224
6936
  modelOverridesObjectBrace,
@@ -6253,14 +6965,14 @@ function locateModelInJsonc(
6253
6965
  if (clean[rootBrace] !== "{") return undefined;
6254
6966
 
6255
6967
  const providersKey = findJsonObjectKey(clean, rootBrace, "providers");
6256
- if (!providersKey) return undefined;
6968
+ if (!providersKey || providersKey.count !== 1) return undefined;
6257
6969
  const providersBrace = skipJsonWhitespace(clean, providersKey.valueStart);
6258
6970
  if (clean[providersBrace] !== "{") return undefined;
6259
6971
  const providersEnd = findMatchingBracket(clean, providersBrace);
6260
6972
  if (providersEnd === undefined) return undefined;
6261
6973
 
6262
6974
  const providerKey = findJsonObjectKey(clean, providersBrace, providerLabel);
6263
- if (!providerKey || providerKey.keyStart > providersEnd) return undefined;
6975
+ if (!providerKey || providerKey.count !== 1 || providerKey.keyStart > providersEnd) return undefined;
6264
6976
  const providerBrace = skipJsonWhitespace(clean, providerKey.valueStart);
6265
6977
  if (clean[providerBrace] !== "{") return undefined;
6266
6978
  const providerEndBrace = findMatchingBracket(clean, providerBrace);
@@ -6271,14 +6983,33 @@ function locateModelInJsonc(
6271
6983
  let providerCompatBrace = -1;
6272
6984
  let providerCompatEnd = -1;
6273
6985
  const providerCompatKey = findJsonObjectKey(clean, providerBrace, "compat");
6986
+ const providerCompatKeyCount = providerCompatKey?.count ?? 0;
6987
+ if (providerCompatKey?.count && providerCompatKey.count > 1) return undefined;
6274
6988
  if (providerCompatKey && providerCompatKey.keyStart < providerEndBrace) {
6275
6989
  const brace = skipJsonWhitespace(clean, providerCompatKey.valueStart);
6276
- if (clean[brace] === "{") {
6277
- const end = findMatchingBracket(clean, brace);
6278
- if (end !== undefined && end <= providerEndBrace) {
6279
- providerCompatBrace = brace;
6280
- providerCompatEnd = end;
6281
- }
6990
+ if (clean[brace] !== "{") return undefined;
6991
+ const end = findMatchingBracket(clean, brace);
6992
+ if (end === undefined || end > providerEndBrace) return undefined;
6993
+ providerCompatBrace = brace;
6994
+ providerCompatEnd = end;
6995
+ }
6996
+
6997
+ const modelOverridesKey = findJsonObjectKey(clean, providerBrace, "modelOverrides");
6998
+ if (modelOverridesKey?.count && modelOverridesKey.count > 1) return undefined;
6999
+ if (modelOverridesKey) {
7000
+ const modelOverridesBrace = skipJsonWhitespace(clean, modelOverridesKey.valueStart);
7001
+ if (clean[modelOverridesBrace] !== "{") return undefined;
7002
+ const modelOverridesEnd = findMatchingBracket(clean, modelOverridesBrace);
7003
+ if (modelOverridesEnd === undefined || modelOverridesEnd > providerEndBrace) return undefined;
7004
+ const overrideKey = findJsonObjectKey(clean, modelOverridesBrace, modelId);
7005
+ if (overrideKey?.count && overrideKey.count > 1) return undefined;
7006
+ if (overrideKey) {
7007
+ const overrideBrace = skipJsonWhitespace(clean, overrideKey.valueStart);
7008
+ if (clean[overrideBrace] !== "{") return undefined;
7009
+ const overrideEnd = findMatchingBracket(clean, overrideBrace);
7010
+ if (overrideEnd === undefined || overrideEnd > modelOverridesEnd) return undefined;
7011
+ const overrideCompatKey = findJsonObjectKey(clean, overrideBrace, "compat");
7012
+ if (overrideCompatKey?.count && overrideCompatKey.count > 1) return undefined;
6282
7013
  }
6283
7014
  }
6284
7015
 
@@ -6289,7 +7020,7 @@ function locateModelInJsonc(
6289
7020
  const modelOverrideCompatEnd = overrideLocation?.modelOverrideCompatEnd ?? -1;
6290
7021
 
6291
7022
  const modelsKey = findJsonObjectKey(clean, providerBrace, "models");
6292
- if (!modelsKey || modelsKey.keyStart > providerEndBrace) return undefined;
7023
+ if (!modelsKey || modelsKey.count !== 1 || modelsKey.keyStart > providerEndBrace) return undefined;
6293
7024
 
6294
7025
  let modelsScan = skipJsonWhitespace(clean, modelsKey.valueStart);
6295
7026
  if (clean[modelsScan] !== "[") return undefined;
@@ -6304,6 +7035,7 @@ function locateModelInJsonc(
6304
7035
  let compatKeyStartClean = -1;
6305
7036
  let compatBrace = -1;
6306
7037
  let compatEndBrace = -1;
7038
+ let modelCompatKeyCount = 0;
6307
7039
 
6308
7040
  while (modelsScan < modelsEnd) {
6309
7041
  modelsScan = skipJsonWhitespace(clean, modelsScan);
@@ -6319,6 +7051,7 @@ function locateModelInJsonc(
6319
7051
  if (elementEnd === undefined || elementEnd > modelsEnd) return undefined;
6320
7052
 
6321
7053
  const idKey = findJsonObjectKey(clean, elementBrace, "id");
7054
+ if (idKey?.count && idKey.count > 1) return undefined;
6322
7055
  let elementId: string | undefined;
6323
7056
  if (idKey && idKey.keyStart < elementEnd) {
6324
7057
  const idValueStart = skipJsonWhitespace(clean, idKey.valueStart);
@@ -6332,21 +7065,26 @@ function locateModelInJsonc(
6332
7065
  allModelIds.push(elementId);
6333
7066
  }
6334
7067
 
6335
- if (elementId === modelId && modelBrace < 0) {
7068
+ if (elementId === modelId) {
7069
+ // Match Pi's provider composer: later duplicate definitions replace
7070
+ // earlier ones. The effective/fix target is therefore the last exact id.
6336
7071
  modelBrace = elementBrace;
6337
7072
  modelEndBrace = elementEnd;
7073
+ compatKeyStartClean = -1;
7074
+ compatBrace = -1;
7075
+ compatEndBrace = -1;
6338
7076
 
6339
7077
  const compatKey = findJsonObjectKey(clean, modelBrace, "compat");
7078
+ modelCompatKeyCount = compatKey?.count ?? 0;
7079
+ if (compatKey?.count && compatKey.count > 1) return undefined;
6340
7080
  if (compatKey && compatKey.keyStart < modelEndBrace) {
6341
7081
  compatKeyStartClean = compatKey.keyStart;
6342
7082
  const brace = skipJsonWhitespace(clean, compatKey.valueStart);
6343
- if (clean[brace] === "{") {
6344
- const end = findMatchingBracket(clean, brace);
6345
- if (end !== undefined && end <= modelEndBrace) {
6346
- compatBrace = brace;
6347
- compatEndBrace = end;
6348
- }
6349
- }
7083
+ if (clean[brace] !== "{") return undefined;
7084
+ const end = findMatchingBracket(clean, brace);
7085
+ if (end === undefined || end > modelEndBrace) return undefined;
7086
+ compatBrace = brace;
7087
+ compatEndBrace = end;
6350
7088
  }
6351
7089
  }
6352
7090
 
@@ -6379,6 +7117,10 @@ function locateModelInJsonc(
6379
7117
  modelOverrideObjectEnd,
6380
7118
  modelOverrideCompatBrace,
6381
7119
  modelOverrideCompatEnd,
7120
+ providerKeyCount: providerKey.count,
7121
+ modelOverrideKeyCount: overrideLocation?.modelOverrideKeyCount ?? 0,
7122
+ modelCompatKeyCount,
7123
+ providerCompatKeyCount,
6382
7124
  allModelIds,
6383
7125
  };
6384
7126
  }
@@ -6408,8 +7150,9 @@ function analyzeModelsJsonForMissingEntry(
6408
7150
  if (clean[rootBrace] !== "{") return undefined;
6409
7151
 
6410
7152
  const providersKey = findJsonObjectKey(clean, rootBrace, "providers");
6411
- if (!providersKey) {
6412
- // Root has no "providers" key at all — we don't auto-create one.
7153
+ if (!providersKey || providersKey.count !== 1) {
7154
+ // Root has no unique "providers" key — we don't auto-create or guess
7155
+ // among duplicate definitions.
6413
7156
  return undefined;
6414
7157
  }
6415
7158
  const providersBrace = skipJsonWhitespace(clean, providersKey.valueStart);
@@ -6418,9 +7161,10 @@ function analyzeModelsJsonForMissingEntry(
6418
7161
  if (providersEnd === undefined) return undefined;
6419
7162
 
6420
7163
  const providerKey = findJsonObjectKey(clean, providersBrace, providerLabel);
6421
- if (!providerKey || providerKey.keyStart > providersEnd) {
7164
+ if (!providerKey) {
6422
7165
  return { scenario: "provider_missing", providersBrace, providersEnd };
6423
7166
  }
7167
+ if (providerKey.count !== 1 || providerKey.keyStart > providersEnd) return undefined;
6424
7168
 
6425
7169
  // Provider exists. Check for a models array so we know where to append.
6426
7170
  const providerBrace = skipJsonWhitespace(clean, providerKey.valueStart);
@@ -6429,14 +7173,42 @@ function analyzeModelsJsonForMissingEntry(
6429
7173
  if (providerEndBrace === undefined || providerEndBrace > providersEnd) return undefined;
6430
7174
 
6431
7175
  const modelsKey = findJsonObjectKey(clean, providerBrace, "models");
7176
+ if (modelsKey?.count && modelsKey.count > 1) return undefined;
6432
7177
  if (modelsKey && modelsKey.keyStart < providerEndBrace) {
6433
- let mScan = skipJsonWhitespace(clean, modelsKey.valueStart);
6434
- if (clean[mScan] === "[") {
6435
- const modelsEnd = findMatchingBracket(clean, mScan);
6436
- if (modelsEnd !== undefined && modelsEnd <= providerEndBrace) {
6437
- return { scenario: "model_missing", modelsEnd, providerBrace, providerEndBrace };
7178
+ const mScan = skipJsonWhitespace(clean, modelsKey.valueStart);
7179
+ if (clean[mScan] !== "[") return undefined;
7180
+ const modelsEnd = findMatchingBracket(clean, mScan);
7181
+ if (modelsEnd === undefined || modelsEnd > providerEndBrace) return undefined;
7182
+
7183
+ // Confirm that the target really is absent before offering an insertion.
7184
+ // This second pass also refuses malformed/ambiguous existing entries, so
7185
+ // a scanner failure can never turn into a duplicate model definition.
7186
+ let modelScan = mScan + 1;
7187
+ let targetFound = false;
7188
+ while (modelScan < modelsEnd) {
7189
+ modelScan = skipJsonWhitespace(clean, modelScan);
7190
+ if (clean[modelScan] === ",") {
7191
+ modelScan++;
7192
+ continue;
6438
7193
  }
7194
+ if (modelScan >= modelsEnd || clean[modelScan] === "]") break;
7195
+ if (clean[modelScan] !== "{") return undefined;
7196
+ const elementEnd = findMatchingBracket(clean, modelScan);
7197
+ if (elementEnd === undefined || elementEnd > modelsEnd) return undefined;
7198
+ const idKey = findJsonObjectKey(clean, modelScan, "id");
7199
+ if (!idKey || idKey.count !== 1) return undefined;
7200
+ const idStart = skipJsonWhitespace(clean, idKey.valueStart);
7201
+ const idLiteral = readJsonStringLiteral(clean, idStart);
7202
+ if (!idLiteral || idLiteral.end > elementEnd) return undefined;
7203
+ if (idLiteral.value === modelId) {
7204
+ targetFound = true;
7205
+ const compatKey = findJsonObjectKey(clean, modelScan, "compat");
7206
+ if (compatKey?.count && compatKey.count > 1) return undefined;
7207
+ }
7208
+ modelScan = elementEnd + 1;
6439
7209
  }
7210
+ if (targetFound) return undefined;
7211
+ return { scenario: "model_missing", modelsEnd, providerBrace, providerEndBrace };
6440
7212
  }
6441
7213
 
6442
7214
  // Provider exists, but there's no discoverable models array — treat as
@@ -6458,7 +7230,7 @@ function formatMissingEntryManualSnippet(
6458
7230
  const lines: string[] = [];
6459
7231
  const sorted = Object.entries(compatKeys).sort(([a], [b]) => a.localeCompare(b));
6460
7232
  const compatItems = sorted.map(([k, v]) => ` ${JSON.stringify(k)}: ${JSON.stringify(v)}`);
6461
- lines.push(`"${providerLabel}": {`);
7233
+ lines.push(`${JSON.stringify(providerLabel)}: {`);
6462
7234
  lines.push(` "modelOverrides": {`);
6463
7235
  lines.push(` ${JSON.stringify(modelId)}: {`);
6464
7236
  lines.push(` "compat": {`);
@@ -6485,6 +7257,7 @@ function composeModelOverrideInsertion(
6485
7257
 
6486
7258
  if (location?.modelOverrideObjectBrace !== undefined && location.modelOverrideObjectBrace >= 0) {
6487
7259
  const modelLocation: ModelNodeLocation = {
7260
+ providerKeyCount: location.providerKeyCount,
6488
7261
  modelObjectBrace: -1,
6489
7262
  modelObjectEnd: -1,
6490
7263
  compatKeyStart: -1,
@@ -6499,6 +7272,9 @@ function composeModelOverrideInsertion(
6499
7272
  modelOverrideObjectEnd: location.modelOverrideObjectEnd,
6500
7273
  modelOverrideCompatBrace: location.modelOverrideCompatBrace,
6501
7274
  modelOverrideCompatEnd: location.modelOverrideCompatEnd,
7275
+ modelOverrideKeyCount: location.modelOverrideKeyCount,
7276
+ modelCompatKeyCount: 0,
7277
+ providerCompatKeyCount: 0,
6502
7278
  allModelIds: [],
6503
7279
  };
6504
7280
  return {
@@ -6738,7 +7514,8 @@ function composeMissingEntryInsertion(
6738
7514
  * Lightweight self-check for a newly inserted model or modelOverrides entry.
6739
7515
  * Parses the modified text as JSONC and confirms:
6740
7516
  * 1. The target exists under models[] or modelOverrides.
6741
- * 2. Every compat key has the expected effective three-layer value.
7517
+ * 2. Every compat key has the expected effective provider/model/runtime/
7518
+ * modelOverride value.
6742
7519
  * Returns null on success, an error string on failure.
6743
7520
  */
6744
7521
  function selfCheckMissingEntryInsertion(
@@ -6747,6 +7524,7 @@ function selfCheckMissingEntryInsertion(
6747
7524
  providerLabel: string,
6748
7525
  modelId: string,
6749
7526
  compatKeys: Record<string, unknown>,
7527
+ runtimeModel?: PiModel,
6750
7528
  ): string | null {
6751
7529
  try {
6752
7530
  const origParsed = parseJsonc(originalText);
@@ -6764,7 +7542,19 @@ function selfCheckMissingEntryInsertion(
6764
7542
  return `Modified file: model or modelOverrides entry "${modelId}" not found in provider after insertion`;
6765
7543
  }
6766
7544
 
7545
+ const effectiveCompat = runtimeModel
7546
+ ? resolveEffectiveCompatFromConfig(runtimeModel, modParsed)
7547
+ : undefined;
6767
7548
  for (const [k, v] of Object.entries(compatKeys)) {
7549
+ if (effectiveCompat) {
7550
+ if (!Object.prototype.hasOwnProperty.call(effectiveCompat, k)) {
7551
+ return `Modified file: effective compat.${k} not found`;
7552
+ }
7553
+ if ((effectiveCompat as Record<string, unknown>)[k] !== v) {
7554
+ return `Modified file: effective compat.${k} wrong value: expected ${JSON.stringify(v)}, got ${JSON.stringify((effectiveCompat as Record<string, unknown>)[k])}`;
7555
+ }
7556
+ continue;
7557
+ }
6768
7558
  const effective = resolveExplicitCompatValue(modParsed, providerLabel, modelId, k);
6769
7559
  if (!effective) return `Modified file: effective compat.${k} not found`;
6770
7560
  if (effective.value !== v) {
@@ -6946,8 +7736,10 @@ function decideFixPlacement(
6946
7736
  continue;
6947
7737
  }
6948
7738
  if (key === "thinkingFormat" || key === "requiresReasoningContentOnAssistantMessages") {
6949
- const allDeepSeek = siblings.every((id) => isDeepSeekLikeModel(syntheticModelForId(providerLabel, id)));
6950
- if (!allDeepSeek) unsafeKeys.push(key);
7739
+ // Reasoning wire/replay behavior is model-specific. Sibling ids do not
7740
+ // prove that they use the same protocol, so never broaden this repair to
7741
+ // provider scope based on a shared DeepSeek name alone.
7742
+ unsafeKeys.push(key);
6951
7743
  continue;
6952
7744
  }
6953
7745
  // Unknown model-behavior key — be conservative, keep it model-scoped.
@@ -6996,8 +7788,8 @@ function chooseFixPlacement(
6996
7788
 
6997
7789
  if (forceModelLevel) {
6998
7790
  return {
6999
- placement: "model",
7000
- reason: "runtime-observed provider/model failure — scoping the repair to the affected model",
7791
+ placement: "modelOverride",
7792
+ reason: "runtime-observed provider/model failure — using Pi's highest-precedence model override",
7001
7793
  };
7002
7794
  }
7003
7795
 
@@ -7147,6 +7939,7 @@ function selfCheckFix(
7147
7939
  modelId: string,
7148
7940
  compatKeys: Record<string, unknown>,
7149
7941
  placement: "provider" | "model" | "modelOverride" = "model",
7942
+ runtimeModel?: PiModel,
7150
7943
  ): string | null {
7151
7944
  try {
7152
7945
  // Step 1: Parse both versions as JSONC (comments + trailing commas allowed).
@@ -7191,8 +7984,9 @@ function selfCheckFix(
7191
7984
  }
7192
7985
 
7193
7986
  // Step 5: Compute the EFFECTIVE merged compat using Pi's precedence:
7194
- // provider, custom model, then modelOverrides. The fix may have written any
7195
- // level, so validation must check what Pi will actually use.
7987
+ // provider, custom model, runtime model, then modelOverrides. The fix may
7988
+ // have written any persistent level, so validation must check what Pi will
7989
+ // actually use.
7196
7990
  const provCompatRaw = (provider as Record<string, unknown>).compat;
7197
7991
  const provCompat = (provCompatRaw && typeof provCompatRaw === 'object' && !Array.isArray(provCompatRaw))
7198
7992
  ? provCompatRaw as Record<string, unknown>
@@ -7208,7 +8002,9 @@ function selfCheckFix(
7208
8002
  return `Modified file: modelOverrides["${modelId}"].compat is not an object`;
7209
8003
  }
7210
8004
  const overrideCompat = asRecord(overrideCompatRaw) ?? {};
7211
- const mergedCompat: Record<string, unknown> = { ...provCompat, ...mdlCompat, ...overrideCompat };
8005
+ const mergedCompat: Record<string, unknown> = runtimeModel
8006
+ ? resolveEffectiveCompatFromConfig(runtimeModel, modParsed) as Record<string, unknown>
8007
+ : { ...provCompat, ...mdlCompat, ...overrideCompat };
7212
8008
 
7213
8009
  // Step 6: Validate all inserted keys are effective in the merged compat
7214
8010
  for (const [k, v] of Object.entries(compatKeys)) {
@@ -7220,62 +8016,97 @@ function selfCheckFix(
7220
8016
  }
7221
8017
  }
7222
8018
 
7223
- // Step 7: Validate original structure is preserved (no accidental deletions/changes)
7224
-
7225
- function isSubset(origVal: unknown, modVal: unknown, path = ''): boolean {
7226
- if (origVal === modVal) return true;
7227
- if (typeof origVal !== typeof modVal) return false;
7228
- if (typeof origVal !== 'object' || origVal === null || modVal === null) return false;
7229
- if (Array.isArray(origVal) !== Array.isArray(modVal)) return false;
7230
- if (Array.isArray(origVal) && Array.isArray(modVal)) {
7231
- if (origVal.length !== modVal.length) return false;
7232
- return origVal.every((_, i) => isSubset(origVal[i], modVal[i], `${path}[${i}]`));
8019
+ // Step 7: Validate that the parsed document is unchanged except for the
8020
+ // explicitly requested scalar keys in the compat object we edited. A
8021
+ // one-way subset check is not sufficient here: it would accept accidental
8022
+ // additions such as credentials or unrelated provider fields.
8023
+ const editedCompatContainer = (value: Record<string, unknown>): boolean =>
8024
+ (placement === "provider" && value === origProvider) ||
8025
+ (placement === "model" && value === origTargetModelRecord) ||
8026
+ (placement === "modelOverride" && value === asRecord(asRecord(origProvider.modelOverrides)?.[modelId]));
8027
+
8028
+ function sameCompatObject(
8029
+ originalCompatValue: unknown,
8030
+ modifiedCompatValue: unknown,
8031
+ ): boolean {
8032
+ const originalCompat = originalCompatValue === undefined
8033
+ ? {}
8034
+ : asRecord(originalCompatValue);
8035
+ const modifiedCompat = asRecord(modifiedCompatValue);
8036
+ if (!originalCompat || !modifiedCompat) return false;
8037
+
8038
+ const allowedKeys = new Set([...Object.keys(originalCompat), ...Object.keys(compatKeys)]);
8039
+ const modifiedKeys = Object.keys(modifiedCompat);
8040
+ if (modifiedKeys.length !== allowedKeys.size || modifiedKeys.some((key) => !allowedKeys.has(key))) {
8041
+ return false;
7233
8042
  }
7234
- // Both objects: check that every key in orig is in mod with same value
7235
- const origObj = origVal as Record<string, unknown>;
7236
- const modObj = modVal as Record<string, unknown>;
7237
- for (const key of Object.keys(origObj)) {
7238
- if (!(key in modObj)) return false;
7239
- if (key === 'compat') {
7240
- // For compat, allow extra keys in modified (the inserted ones).
7241
- // Use recursive isSubset so nested objects (e.g. { deep: true })
7242
- // are compared by content, not reference.
7243
- if (typeof origObj[key] !== 'object' || typeof modObj[key] !== 'object') {
7244
- if (origObj[key] !== modObj[key]) return false;
7245
- } else {
7246
- const origCompat = origObj[key] as Record<string, unknown>;
7247
- const modCompat = modObj[key] as Record<string, unknown>;
7248
- // Only the compat object at the level ACTUALLY edited may have
7249
- // its values repaired by this fix. The un-edited level must
7250
- // remain byte/structure-equivalent, so its same-name keys stay
7251
- // under full validation. Using a disjunction OR (provider ||
7252
- // target) here would silently skip validation at the un-edited
7253
- // level, masking corruption (e.g. a buggy editor accidentally
7254
- // breaking provider.compat.sendSessionAffinityHeaders while
7255
- // the fix was a model-level repair). Track placement — only
7256
- // the placement-resolved object's own compat may be exempt.
7257
- const mayRepairThisCompat =
7258
- (placement === "provider" && origObj === origProvider) ||
7259
- (placement === "model" && origObj === origTargetModelRecord) ||
7260
- (placement === "modelOverride" && origObj === asRecord(asRecord(origProvider.modelOverrides)?.[modelId]));
7261
- for (const ck of Object.keys(origCompat)) {
7262
- if (!(ck in modCompat)) return false;
7263
- // The fix may repair an existing wrong compat value (for example
7264
- // thinkingFormat: "legacy" -> "deepseek"), but only on the
7265
- // target provider/model compat objects. Sibling compat blocks must
7266
- // remain structure-equivalent.
7267
- if (mayRepairThisCompat && Object.prototype.hasOwnProperty.call(compatKeys, ck)) continue;
7268
- if (!isSubset(origCompat[ck], modCompat[ck], `${path}.${ck}`)) return false;
7269
- }
8043
+
8044
+ for (const key of allowedKeys) {
8045
+ if (Object.prototype.hasOwnProperty.call(compatKeys, key)) {
8046
+ if (!Object.prototype.hasOwnProperty.call(modifiedCompat, key) || modifiedCompat[key] !== compatKeys[key]) {
8047
+ return false;
7270
8048
  }
7271
- } else if (!isSubset(origObj[key], modObj[key], `${path}.${key}`)) {
8049
+ continue;
8050
+ }
8051
+ if (!Object.prototype.hasOwnProperty.call(originalCompat, key) ||
8052
+ !sameDocumentValue(originalCompat[key], modifiedCompat[key])) {
7272
8053
  return false;
7273
8054
  }
7274
8055
  }
7275
8056
  return true;
7276
8057
  }
7277
8058
 
7278
- if (!isSubset(origParsed, modParsed)) {
8059
+ function sameDocumentValue(originalValue: unknown, modifiedValue: unknown): boolean {
8060
+ if (originalValue === modifiedValue) return true;
8061
+ if (typeof originalValue !== typeof modifiedValue || originalValue === null || modifiedValue === null) return false;
8062
+ if (Array.isArray(originalValue) || Array.isArray(modifiedValue)) {
8063
+ if (!Array.isArray(originalValue) || !Array.isArray(modifiedValue) || originalValue.length !== modifiedValue.length) return false;
8064
+ return originalValue.every((value, index) => sameDocumentValue(value, modifiedValue[index]));
8065
+ }
8066
+ if (typeof originalValue !== "object") return false;
8067
+
8068
+ const originalObject = originalValue as Record<string, unknown>;
8069
+ const modifiedObject = modifiedValue as Record<string, unknown>;
8070
+ const originalKeys = Object.keys(originalObject);
8071
+ const modifiedKeys = Object.keys(modifiedObject);
8072
+ if (originalKeys.length !== modifiedKeys.length || modifiedKeys.some((key) => !Object.prototype.hasOwnProperty.call(originalObject, key))) {
8073
+ return false;
8074
+ }
8075
+ return originalKeys.every((key) => sameDocumentValue(originalObject[key], modifiedObject[key]));
8076
+ }
8077
+
8078
+ function sameDocumentExceptEditedCompat(originalValue: unknown, modifiedValue: unknown): boolean {
8079
+ if (originalValue === modifiedValue) return true;
8080
+ if (typeof originalValue !== typeof modifiedValue || originalValue === null || modifiedValue === null) return false;
8081
+ if (Array.isArray(originalValue) || Array.isArray(modifiedValue)) {
8082
+ if (!Array.isArray(originalValue) || !Array.isArray(modifiedValue) || originalValue.length !== modifiedValue.length) return false;
8083
+ return originalValue.every((value, index) => sameDocumentExceptEditedCompat(value, modifiedValue[index]));
8084
+ }
8085
+ if (typeof originalValue !== "object") return false;
8086
+
8087
+ const originalObject = originalValue as Record<string, unknown>;
8088
+ const modifiedObject = modifiedValue as Record<string, unknown>;
8089
+ if (editedCompatContainer(originalObject)) {
8090
+ const originalKeys = Object.keys(originalObject).filter((key) => key !== "compat");
8091
+ const modifiedKeys = Object.keys(modifiedObject).filter((key) => key !== "compat");
8092
+ if (originalKeys.length !== modifiedKeys.length || modifiedKeys.some((key) => !Object.prototype.hasOwnProperty.call(originalObject, key))) {
8093
+ return false;
8094
+ }
8095
+ for (const key of originalKeys) {
8096
+ if (!sameDocumentExceptEditedCompat(originalObject[key], modifiedObject[key])) return false;
8097
+ }
8098
+ return sameCompatObject(originalObject.compat, modifiedObject.compat);
8099
+ }
8100
+
8101
+ const originalKeys = Object.keys(originalObject);
8102
+ const modifiedKeys = Object.keys(modifiedObject);
8103
+ if (originalKeys.length !== modifiedKeys.length || modifiedKeys.some((key) => !Object.prototype.hasOwnProperty.call(originalObject, key))) {
8104
+ return false;
8105
+ }
8106
+ return originalKeys.every((key) => sameDocumentExceptEditedCompat(originalObject[key], modifiedObject[key]));
8107
+ }
8108
+
8109
+ if (!sameDocumentExceptEditedCompat(origParsed, modParsed)) {
7279
8110
  return "Modified file: original structure was altered (data loss detected)";
7280
8111
  }
7281
8112
 
@@ -7339,16 +8170,78 @@ function uniqueTempPath(targetPath: string, purpose: string): string {
7339
8170
  return `${targetPath}.${process.pid}.${Date.now()}.${backupSequence++}.${purpose}.tmp`;
7340
8171
  }
7341
8172
 
8173
+ type FileIdentity = { dev: number | bigint; ino: number | bigint };
8174
+
8175
+ function sameFileIdentity(left: FileIdentity, right: FileIdentity): boolean {
8176
+ // Some platforms do not expose a meaningful inode. On those platforms the
8177
+ // hash checks around the transaction remain authoritative.
8178
+ const leftDev = Number(left.dev);
8179
+ const rightDev = Number(right.dev);
8180
+ const leftIno = Number(left.ino);
8181
+ const rightIno = Number(right.ino);
8182
+ return leftIno === 0 || rightIno === 0 || (leftDev === rightDev && leftIno === rightIno);
8183
+ }
8184
+
8185
+ type AtomicTargetGuard = {
8186
+ identity?: FileIdentity;
8187
+ hash?: string;
8188
+ mode?: number;
8189
+ };
8190
+
8191
+ async function validateAtomicTarget(
8192
+ targetPath: string,
8193
+ guard?: AtomicTargetGuard,
8194
+ ): Promise<Awaited<ReturnType<typeof lstat>>> {
8195
+ const info = await lstat(targetPath);
8196
+ if (info.isSymbolicLink() || !info.isFile()) {
8197
+ throw new Error("target is not a regular file; refusing atomic replacement");
8198
+ }
8199
+ if (guard?.identity && !sameFileIdentity(guard.identity, info)) {
8200
+ throw new Error("target changed during atomic replacement");
8201
+ }
8202
+ if (guard?.mode !== undefined && (info.mode & 0o7777) !== guard.mode) {
8203
+ throw new Error("target access mode changed during atomic replacement");
8204
+ }
8205
+ if (guard?.hash !== undefined) {
8206
+ const text = await readFile(targetPath, "utf8");
8207
+ if (hashText(text) !== guard.hash) {
8208
+ throw new Error("target content changed during atomic replacement");
8209
+ }
8210
+ }
8211
+ return info;
8212
+ }
8213
+
7342
8214
  async function atomicReplaceTextFilePreservingMode(
7343
8215
  targetPath: string,
7344
8216
  content: string,
7345
8217
  mode: number,
7346
8218
  purpose: string,
8219
+ guard?: AtomicTargetGuard,
8220
+ /** Test-only race injector; production callers leave this undefined. */
8221
+ beforeRename?: () => Promise<void>,
7347
8222
  ): Promise<void> {
8223
+ const initialTargetInfo = await validateAtomicTarget(targetPath, guard);
7348
8224
  const tempPath = uniqueTempPath(targetPath, purpose);
7349
8225
  try {
7350
- await writeFile(tempPath, content, { encoding: "utf8", mode });
8226
+ // `wx` makes the unique temporary path non-overwriting even if a hostile
8227
+ // or stale file appears between name generation and the write.
8228
+ await writeFile(tempPath, content, { encoding: "utf8", mode, flag: "wx" });
8229
+ const tempInfo = await lstat(tempPath);
8230
+ if (tempInfo.isSymbolicLink() || !tempInfo.isFile()) {
8231
+ throw new Error("temporary replacement is not a regular file");
8232
+ }
7351
8233
  await chmod(tempPath, mode);
8234
+ await validateAtomicTarget(targetPath, {
8235
+ ...guard,
8236
+ identity: initialTargetInfo,
8237
+ mode,
8238
+ });
8239
+ if (beforeRename) await beforeRename();
8240
+ await validateAtomicTarget(targetPath, {
8241
+ ...guard,
8242
+ identity: initialTargetInfo,
8243
+ mode,
8244
+ });
7352
8245
  await rename(tempPath, targetPath);
7353
8246
  } catch (error) {
7354
8247
  try {
@@ -7366,11 +8259,42 @@ async function atomicRestoreFileFromBackup(
7366
8259
  backupPath: string,
7367
8260
  targetPath: string,
7368
8261
  mode: number,
8262
+ guard?: AtomicTargetGuard & { backupHash?: string },
8263
+ /** Test-only race injector; production callers leave this undefined. */
8264
+ beforeRename?: () => Promise<void>,
7369
8265
  ): Promise<void> {
8266
+ const backupInfo = await lstat(backupPath);
8267
+ if (backupInfo.isSymbolicLink() || !backupInfo.isFile()) {
8268
+ throw new Error("backup is not a regular file; refusing atomic restore");
8269
+ }
8270
+ if (guard?.backupHash !== undefined) {
8271
+ const backupText = await readFile(backupPath, "utf8");
8272
+ if (hashText(backupText) !== guard.backupHash) {
8273
+ throw new Error("backup content changed during atomic restore");
8274
+ }
8275
+ }
8276
+ const targetInfo = await validateAtomicTarget(targetPath, guard);
7370
8277
  const tempPath = uniqueTempPath(targetPath, "restore");
7371
8278
  try {
7372
8279
  await copyFile(backupPath, tempPath, fsConstants.COPYFILE_EXCL);
8280
+ const tempInfo = await lstat(tempPath);
8281
+ if (tempInfo.isSymbolicLink() || !tempInfo.isFile()) {
8282
+ throw new Error("temporary restore is not a regular file");
8283
+ }
7373
8284
  await chmod(tempPath, mode);
8285
+ const tempText = await readFile(tempPath, "utf8");
8286
+ if (guard?.backupHash !== undefined && hashText(tempText) !== guard.backupHash) {
8287
+ throw new Error("backup content changed during atomic restore");
8288
+ }
8289
+ await validateAtomicTarget(targetPath, {
8290
+ ...guard,
8291
+ identity: targetInfo,
8292
+ });
8293
+ if (beforeRename) await beforeRename();
8294
+ await validateAtomicTarget(targetPath, {
8295
+ ...guard,
8296
+ identity: targetInfo,
8297
+ });
7374
8298
  await rename(tempPath, targetPath);
7375
8299
  } catch (error) {
7376
8300
  try {
@@ -7388,43 +8312,285 @@ type ModelsJsonFixTransactionResult =
7388
8312
  | { ok: true }
7389
8313
  | { ok: false; postCheckError: string };
7390
8314
 
7391
- async function applyModelsJsonFixTransaction(
8315
+ type ModelsJsonFixReceiptSnapshot = {
8316
+ receipt: ModelsJsonFixReceiptV1;
8317
+ receiptPath: string;
8318
+ hash: string;
8319
+ identity: FileIdentity;
8320
+ };
8321
+
8322
+ type ModelsJsonFixTransactionOptions = {
8323
+ onCommitted?: (writtenText: string) => Promise<void>;
8324
+ expectedCurrentHash?: string;
8325
+ /** Access mode captured by a preview, used to reject mode races. */
8326
+ expectedCurrentMode?: number;
8327
+ /** Hash of the transaction backup, used to revalidate a guarded restore. */
8328
+ expectedBackupHash?: string;
8329
+ /** Receipt identity captured before a rollback preview. */
8330
+ receiptGuard?: ModelsJsonFixReceiptSnapshot;
8331
+ purpose?: string;
8332
+ };
8333
+
8334
+ function hashText(text: string): string {
8335
+ return createHash("sha256").update(text, "utf8").digest("hex");
8336
+ }
8337
+
8338
+ async function withModelsJsonTransactionLock<T>(operation: () => Promise<T>): Promise<T> {
8339
+ await mkdir(STATE_DIR, { recursive: true });
8340
+ const deadline = Date.now() + MODELS_TRANSACTION_LOCK_WAIT_MS;
8341
+ const ownerToken = randomUUID();
8342
+ const ownerText = JSON.stringify({ pid: process.pid, token: ownerToken });
8343
+ let ownedLockIdentity: FileIdentity | undefined;
8344
+ while (true) {
8345
+ try {
8346
+ // A single O_EXCL file is the lease: unlike mkdir-then-owner-file, there
8347
+ // is no ownerless crash window between creating the lock and identifying
8348
+ // its owner.
8349
+ await writeFile(MODELS_TRANSACTION_LOCK_PATH, ownerText, {
8350
+ encoding: "utf8",
8351
+ mode: 0o600,
8352
+ flag: "wx",
8353
+ });
8354
+ const lockInfo = await lstat(MODELS_TRANSACTION_LOCK_PATH);
8355
+ if (lockInfo.isSymbolicLink() || !lockInfo.isFile()) {
8356
+ throw new Error("models.json transaction lock path is unsafe");
8357
+ }
8358
+ ownedLockIdentity = lockInfo;
8359
+ break;
8360
+ } catch (error) {
8361
+ if (getErrorCode(error) !== "EEXIST") throw error;
8362
+ const lockInfo = await lstat(MODELS_TRANSACTION_LOCK_PATH).catch(() => undefined);
8363
+ if (!lockInfo) continue;
8364
+ if (lockInfo.isSymbolicLink() || !lockInfo.isFile()) {
8365
+ throw new Error("models.json transaction lock path is unsafe");
8366
+ }
8367
+ let staleOwnerPid: number | undefined;
8368
+ try {
8369
+ const owner = asRecord(JSON.parse(await readFile(MODELS_TRANSACTION_LOCK_PATH, "utf8")));
8370
+ if (typeof owner?.pid === "number") staleOwnerPid = owner.pid;
8371
+ } catch {}
8372
+ const stale = Date.now() - lockInfo.mtimeMs > MODELS_TRANSACTION_LOCK_STALE_MS;
8373
+ if (stale && (staleOwnerPid === undefined || !isProcessAlive(staleOwnerPid))) {
8374
+ // Recheck identity immediately before unlinking so a recovered owner
8375
+ // cannot delete a replacement lease created at the same path.
8376
+ const currentLock = await lstat(MODELS_TRANSACTION_LOCK_PATH).catch(() => undefined);
8377
+ if (currentLock && sameFileIdentity(lockInfo, currentLock)) {
8378
+ await unlink(MODELS_TRANSACTION_LOCK_PATH).catch((unlinkError) => {
8379
+ if (getErrorCode(unlinkError) !== "ENOENT") throw unlinkError;
8380
+ });
8381
+ }
8382
+ continue;
8383
+ }
8384
+ if (Date.now() >= deadline) {
8385
+ throw new Error("another cache-optimizer models.json transaction is still running");
8386
+ }
8387
+ await new Promise((resolve) => setTimeout(resolve, 25));
8388
+ }
8389
+ }
8390
+
8391
+ try {
8392
+ return await operation();
8393
+ } finally {
8394
+ // Only the matching inode and owner token may remove the lease. A
8395
+ // recovered/replaced lock belongs to another transaction.
8396
+ try {
8397
+ const currentLock = await lstat(MODELS_TRANSACTION_LOCK_PATH);
8398
+ if (
8399
+ ownedLockIdentity &&
8400
+ !currentLock.isSymbolicLink() &&
8401
+ currentLock.isFile() &&
8402
+ sameFileIdentity(ownedLockIdentity, currentLock) &&
8403
+ await readFile(MODELS_TRANSACTION_LOCK_PATH, "utf8") === ownerText
8404
+ ) {
8405
+ await unlink(MODELS_TRANSACTION_LOCK_PATH);
8406
+ }
8407
+ } catch (error) {
8408
+ if (getErrorCode(error) !== "ENOENT") {
8409
+ console.warn(`${LOG_PREFIX}: failed to remove models.json transaction lock`, error);
8410
+ }
8411
+ }
8412
+ }
8413
+ }
8414
+
8415
+ async function assertModelsJsonFixReceiptSnapshotUnchanged(
8416
+ snapshot: ModelsJsonFixReceiptSnapshot,
8417
+ ): Promise<void> {
8418
+ const info = await lstat(snapshot.receiptPath);
8419
+ if (
8420
+ info.isSymbolicLink() ||
8421
+ !info.isFile() ||
8422
+ !sameFileIdentity(snapshot.identity, info)
8423
+ ) {
8424
+ throw new Error("fix receipt changed since the rollback preview");
8425
+ }
8426
+ const text = await readFile(snapshot.receiptPath, "utf8");
8427
+ const afterRead = await lstat(snapshot.receiptPath);
8428
+ if (
8429
+ afterRead.isSymbolicLink() ||
8430
+ !afterRead.isFile() ||
8431
+ !sameFileIdentity(info, afterRead) ||
8432
+ hashText(text) !== snapshot.hash
8433
+ ) {
8434
+ throw new Error("fix receipt changed since the rollback preview");
8435
+ }
8436
+ }
8437
+
8438
+ async function applyModelsJsonFixTransactionUnderLock(
7392
8439
  modifiedText: string,
7393
8440
  backupPath: string,
7394
8441
  validateWrittenText: (writtenText: string) => string | null,
8442
+ options: ModelsJsonFixTransactionOptions = {},
7395
8443
  ): Promise<ModelsJsonFixTransactionResult> {
7396
- const originalMode = (await stat(MODELS_JSON_PATH)).mode & 0o7777;
8444
+ if (options.receiptGuard) {
8445
+ await assertModelsJsonFixReceiptSnapshotUnchanged(options.receiptGuard);
8446
+ }
8447
+ const targetInfo = await lstat(MODELS_JSON_PATH);
8448
+ if (!targetInfo.isFile() || targetInfo.isSymbolicLink()) {
8449
+ throw new Error("models.json is not a regular file; no changes were made");
8450
+ }
8451
+ const originalMode = targetInfo.mode & 0o7777;
8452
+ const transactionName = options.purpose === "rollback" ? "rollback" : "fix";
8453
+ if (options.expectedCurrentMode !== undefined && options.expectedCurrentMode !== originalMode) {
8454
+ throw new Error(`models.json access mode changed since the ${transactionName} preview; no changes were made`);
8455
+ }
8456
+ const initialTargetText = await readFile(MODELS_JSON_PATH, "utf8");
8457
+ const transactionInitialHash = hashText(initialTargetText);
8458
+ const expectedCurrentHash = options.expectedCurrentHash ?? transactionInitialHash;
8459
+ const expectedBackupHash = options.expectedBackupHash ?? expectedCurrentHash;
8460
+ const assertCurrentTargetUnchanged = async (): Promise<void> => {
8461
+ const currentInfo = await lstat(MODELS_JSON_PATH);
8462
+ if (
8463
+ currentInfo.isSymbolicLink() ||
8464
+ !currentInfo.isFile() ||
8465
+ !sameFileIdentity(targetInfo, currentInfo) ||
8466
+ (currentInfo.mode & 0o7777) !== originalMode
8467
+ ) {
8468
+ throw new Error(`models.json changed during ${transactionName}; no changes were made`);
8469
+ }
8470
+ const currentText = await readFile(MODELS_JSON_PATH, "utf8");
8471
+ if (hashText(currentText) !== expectedCurrentHash) {
8472
+ throw new Error(`models.json changed since the ${transactionName} preview; no changes were made`);
8473
+ }
8474
+ };
8475
+ const assertBackupUnchanged = async (): Promise<void> => {
8476
+ const backupInfo = await lstat(backupPath);
8477
+ if (backupInfo.isSymbolicLink() || !backupInfo.isFile()) {
8478
+ throw new Error(`models.json ${transactionName} backup is not a regular file; no changes were made`);
8479
+ }
8480
+ if (expectedBackupHash !== undefined) {
8481
+ const backupText = await readFile(backupPath, "utf8");
8482
+ if (hashText(backupText) !== expectedBackupHash) {
8483
+ throw new Error(`models.json ${transactionName} backup changed during preparation; no changes were made`);
8484
+ }
8485
+ }
8486
+ };
8487
+
8488
+ await assertCurrentTargetUnchanged();
7397
8489
  await copyFile(MODELS_JSON_PATH, backupPath, fsConstants.COPYFILE_EXCL);
8490
+ const createdBackupInfo = await lstat(backupPath);
8491
+ if (createdBackupInfo.isSymbolicLink() || !createdBackupInfo.isFile()) {
8492
+ throw new Error(`models.json ${transactionName} backup is not a regular file; no changes were made`);
8493
+ }
7398
8494
  await chmod(backupPath, originalMode);
8495
+ const chmodBackupInfo = await lstat(backupPath);
8496
+ if (
8497
+ chmodBackupInfo.isSymbolicLink() ||
8498
+ !chmodBackupInfo.isFile() ||
8499
+ !sameFileIdentity(createdBackupInfo, chmodBackupInfo)
8500
+ ) {
8501
+ throw new Error(`models.json ${transactionName} backup changed during preparation; no changes were made`);
8502
+ }
8503
+ await assertBackupUnchanged();
7399
8504
 
8505
+ const expectedModifiedHash = hashText(modifiedText);
7400
8506
  let targetReplaced = false;
8507
+ const restoreTargetIfUnchanged = async (): Promise<void> => {
8508
+ const currentInfo = await lstat(MODELS_JSON_PATH);
8509
+ if (
8510
+ currentInfo.isSymbolicLink() ||
8511
+ !currentInfo.isFile() ||
8512
+ (currentInfo.mode & 0o7777) !== originalMode
8513
+ ) {
8514
+ throw new Error(`models.json changed after ${transactionName} replacement; refusing to overwrite user changes`);
8515
+ }
8516
+ const currentText = await readFile(MODELS_JSON_PATH, "utf8");
8517
+ if (hashText(currentText) !== expectedModifiedHash) {
8518
+ throw new Error(`models.json changed after ${transactionName} replacement; refusing to overwrite user changes`);
8519
+ }
8520
+ await assertBackupUnchanged();
8521
+ await atomicRestoreFileFromBackup(backupPath, MODELS_JSON_PATH, originalMode, {
8522
+ identity: currentInfo,
8523
+ hash: expectedModifiedHash,
8524
+ mode: originalMode,
8525
+ backupHash: expectedBackupHash,
8526
+ });
8527
+ const restoredInfo = await lstat(MODELS_JSON_PATH);
8528
+ if (restoredInfo.isSymbolicLink() || !restoredInfo.isFile()) {
8529
+ throw new Error(`models.json restore produced a non-regular file`);
8530
+ }
8531
+ const restoredText = await readFile(MODELS_JSON_PATH, "utf8");
8532
+ if (expectedBackupHash !== undefined && hashText(restoredText) !== expectedBackupHash) {
8533
+ throw new Error(`models.json restore did not match the transaction backup`);
8534
+ }
8535
+ };
8536
+
7401
8537
  try {
8538
+ await assertCurrentTargetUnchanged();
7402
8539
  await atomicReplaceTextFilePreservingMode(
7403
8540
  MODELS_JSON_PATH,
7404
8541
  modifiedText,
7405
8542
  originalMode,
7406
- "fix",
8543
+ options.purpose ?? "fix",
8544
+ {
8545
+ identity: targetInfo,
8546
+ hash: expectedCurrentHash,
8547
+ mode: originalMode,
8548
+ },
7407
8549
  );
7408
8550
  targetReplaced = true;
7409
8551
 
8552
+ const writtenInfo = await lstat(MODELS_JSON_PATH);
8553
+ if (writtenInfo.isSymbolicLink() || !writtenInfo.isFile()) {
8554
+ throw new Error(`models.json became a non-regular file during ${transactionName} replacement`);
8555
+ }
7410
8556
  const writtenText = await readFile(MODELS_JSON_PATH, "utf8");
7411
8557
  const postCheckError = validateWrittenText(writtenText);
7412
- const writtenMode = (await stat(MODELS_JSON_PATH)).mode & 0o7777;
7413
- const effectiveError = postCheckError ?? (
7414
- writtenMode === originalMode
7415
- ? null
7416
- : `models.json access mode changed from ${originalMode.toString(8)} to ${writtenMode.toString(8)}`
7417
- );
8558
+ const writtenMode = writtenInfo.mode & 0o7777;
8559
+ const writeHashError = hashText(writtenText) === expectedModifiedHash
8560
+ ? null
8561
+ : `models.json changed during ${transactionName} replacement`;
8562
+ const modeError = writtenMode === originalMode
8563
+ ? null
8564
+ : `models.json access mode changed from ${originalMode.toString(8)} to ${writtenMode.toString(8)}`;
8565
+ const effectiveError = postCheckError ?? writeHashError ?? modeError;
7418
8566
  if (effectiveError !== null) {
7419
- await atomicRestoreFileFromBackup(backupPath, MODELS_JSON_PATH, originalMode);
8567
+ await restoreTargetIfUnchanged();
8568
+ targetReplaced = false;
7420
8569
  return { ok: false, postCheckError: effectiveError };
7421
8570
  }
7422
8571
 
8572
+ // Re-check immediately before committing receipt metadata. If a caller
8573
+ // changed the replacement after validation, do not mark the transaction
8574
+ // successful and do not restore over that caller's change.
8575
+ await validateAtomicTarget(MODELS_JSON_PATH, {
8576
+ identity: writtenInfo,
8577
+ hash: expectedModifiedHash,
8578
+ mode: originalMode,
8579
+ });
8580
+
8581
+ // A receipt is part of a successful fix transaction. If its atomic write
8582
+ // fails, the catch path restores the models file from the transaction
8583
+ // backup instead of leaving an un-recoverable configuration change.
8584
+ if (options.receiptGuard) {
8585
+ await assertModelsJsonFixReceiptSnapshotUnchanged(options.receiptGuard);
8586
+ }
8587
+ await options.onCommitted?.(writtenText);
7423
8588
  return { ok: true };
7424
8589
  } catch (error) {
7425
8590
  if (targetReplaced) {
7426
8591
  try {
7427
- await atomicRestoreFileFromBackup(backupPath, MODELS_JSON_PATH, originalMode);
8592
+ await restoreTargetIfUnchanged();
8593
+ targetReplaced = false;
7428
8594
  } catch (restoreError) {
7429
8595
  throw new AggregateError(
7430
8596
  [error, restoreError],
@@ -7436,6 +8602,630 @@ async function applyModelsJsonFixTransaction(
7436
8602
  }
7437
8603
  }
7438
8604
 
8605
+ async function applyModelsJsonFixTransaction(
8606
+ modifiedText: string,
8607
+ backupPath: string,
8608
+ validateWrittenText: (writtenText: string) => string | null,
8609
+ options: ModelsJsonFixTransactionOptions = {},
8610
+ ): Promise<ModelsJsonFixTransactionResult> {
8611
+ return withModelsJsonTransactionLock(() =>
8612
+ applyModelsJsonFixTransactionUnderLock(modifiedText, backupPath, validateWrittenText, options)
8613
+ );
8614
+ }
8615
+
8616
+ function isReceiptScalar(value: unknown): value is ReceiptScalar {
8617
+ return value === null || typeof value === "string" || typeof value === "boolean" ||
8618
+ (typeof value === "number" && Number.isFinite(value));
8619
+ }
8620
+
8621
+ function isReceiptScalarState(value: unknown): value is ReceiptScalarState {
8622
+ const record = asRecord(value);
8623
+ if (!record || record.present === undefined) return false;
8624
+ if (record.present === false) return Object.keys(record).every((key) => key === "present");
8625
+ return record.present === true && isReceiptScalar(record.value) && Object.keys(record).every((key) => key === "present" || key === "value");
8626
+ }
8627
+
8628
+ function sameReceiptScalarState(left: ReceiptScalarState, right: ReceiptScalarState): boolean {
8629
+ if (left.present !== right.present) return false;
8630
+ return !left.present || left.value === (right as { present: true; value: ReceiptScalar }).value;
8631
+ }
8632
+
8633
+ function isFixReceiptPlacement(value: unknown): value is FixReceiptPlacement {
8634
+ return value === "provider" || value === "model" || value === "modelOverride";
8635
+ }
8636
+
8637
+ function isSha256(value: unknown): value is string {
8638
+ return typeof value === "string" && /^[0-9a-f]{64}$/i.test(value);
8639
+ }
8640
+
8641
+ function hasReceiptReasoningProtocolChange(receipt: ModelsJsonFixReceiptV1): boolean {
8642
+ return [
8643
+ "thinkingFormat",
8644
+ "supportsReasoningEffort",
8645
+ "requiresReasoningContentOnAssistantMessages",
8646
+ ].some((key) => Object.prototype.hasOwnProperty.call(receipt.changedKeys, key));
8647
+ }
8648
+
8649
+ function isActionableModelsJsonFixReceipt(receipt: ModelsJsonFixReceiptV1 | undefined): receipt is ModelsJsonFixReceiptV1 {
8650
+ return receipt !== undefined && receipt.status === undefined;
8651
+ }
8652
+
8653
+ function createRollbackBackupPath(modelsPath: string = MODELS_JSON_PATH): string {
8654
+ return `${modelsPath}.backup-cache-optimizer-rollback-${backupTimestamp()}`;
8655
+ }
8656
+
8657
+ function isSafeReceiptText(value: unknown): value is string {
8658
+ return isNonEmptyString(value) && !/[\u0000-\u001f\u007f]/.test(value);
8659
+ }
8660
+
8661
+ function isReceiptTimestamp(value: unknown): value is number {
8662
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
8663
+ }
8664
+
8665
+ function parseModelsJsonFixReceipt(value: unknown): ModelsJsonFixReceiptV1 | undefined {
8666
+ const record = asRecord(value);
8667
+ if (!record || record.version !== 1 || record.kind !== "pi-cache-optimizer-fix-receipt") return undefined;
8668
+ const allowedKeys = new Set([
8669
+ "version", "kind", "transactionId", "provider", "modelId", "placement",
8670
+ "targetExistedBefore", "changedKeys", "beforeHash", "afterHash", "backupFile",
8671
+ "createdAt", "appliedAt", "status", "rolledBackAt",
8672
+ ]);
8673
+ if (Object.keys(record).some((key) => !allowedKeys.has(key))) return undefined;
8674
+ if (!isSafeReceiptText(record.transactionId) || !isSafeReceiptText(record.provider) || !isSafeReceiptText(record.modelId)) return undefined;
8675
+ if (!isFixReceiptPlacement(record.placement) || typeof record.targetExistedBefore !== "boolean") return undefined;
8676
+ if (!isSha256(record.beforeHash) || !isSha256(record.afterHash) || record.beforeHash === record.afterHash) return undefined;
8677
+ if (!isSafeReceiptText(record.backupFile) || basename(record.backupFile) !== record.backupFile || record.backupFile.includes("..")) return undefined;
8678
+ if (!/^models\.json\.backup-cache-optimizer-/.test(record.backupFile)) return undefined;
8679
+ const beforeHash = typeof record.beforeHash === "string" ? record.beforeHash.toLowerCase() : "";
8680
+ const afterHash = typeof record.afterHash === "string" ? record.afterHash.toLowerCase() : "";
8681
+ if (beforeHash === afterHash) return undefined;
8682
+ if (!isReceiptTimestamp(record.createdAt) || !isReceiptTimestamp(record.appliedAt) || record.appliedAt < record.createdAt) return undefined;
8683
+ const changedKeys = asRecord(record.changedKeys);
8684
+ if (!changedKeys || Object.keys(changedKeys).length === 0) return undefined;
8685
+
8686
+ const parsedChanges: Record<string, FixReceiptCompatChange> = {};
8687
+ for (const [key, rawChange] of Object.entries(changedKeys)) {
8688
+ if (!RECEIPT_COMPAT_KEYS.has(key)) return undefined;
8689
+ const change = asRecord(rawChange);
8690
+ if (!change || !isReceiptScalarState(change.before) || !isReceiptScalarState(change.after)) return undefined;
8691
+ if (sameReceiptScalarState(change.before, change.after)) return undefined;
8692
+ parsedChanges[key] = {
8693
+ before: change.before,
8694
+ after: change.after,
8695
+ };
8696
+ }
8697
+
8698
+ if (record.status !== undefined && record.status !== "rolled_back") return undefined;
8699
+ if (record.status === "rolled_back" && (!isReceiptTimestamp(record.rolledBackAt) || record.rolledBackAt < record.appliedAt)) return undefined;
8700
+ if (record.status === undefined && record.rolledBackAt !== undefined) return undefined;
8701
+
8702
+ return {
8703
+ version: 1,
8704
+ kind: "pi-cache-optimizer-fix-receipt",
8705
+ transactionId: record.transactionId.trim(),
8706
+ provider: record.provider.trim(),
8707
+ modelId: record.modelId.trim(),
8708
+ placement: record.placement,
8709
+ targetExistedBefore: record.targetExistedBefore,
8710
+ changedKeys: parsedChanges,
8711
+ beforeHash,
8712
+ afterHash,
8713
+ backupFile: record.backupFile,
8714
+ createdAt: Number(record.createdAt),
8715
+ appliedAt: Number(record.appliedAt),
8716
+ ...(record.status === "rolled_back" ? { status: "rolled_back", rolledBackAt: Number(record.rolledBackAt) } : {}),
8717
+ };
8718
+ }
8719
+
8720
+ function receiptBackupPath(receipt: ModelsJsonFixReceiptV1, receiptPath: string = FIX_RECEIPT_PATH): string {
8721
+ return join(dirname(receiptPath), receipt.backupFile);
8722
+ }
8723
+
8724
+ async function writeModelsJsonFixReceipt(
8725
+ receipt: ModelsJsonFixReceiptV1,
8726
+ receiptPath: string = FIX_RECEIPT_PATH,
8727
+ expectedSnapshot?: ModelsJsonFixReceiptSnapshot,
8728
+ /** Test-only race injector; production callers leave this undefined. */
8729
+ beforeRename?: () => Promise<void>,
8730
+ ): Promise<void> {
8731
+ if (!parseModelsJsonFixReceipt(receipt)) throw new Error("invalid fix receipt");
8732
+ if (expectedSnapshot) {
8733
+ if (expectedSnapshot.receiptPath !== receiptPath) throw new Error("fix receipt path changed since the rollback preview");
8734
+ await assertModelsJsonFixReceiptSnapshotUnchanged(expectedSnapshot);
8735
+ }
8736
+ await mkdir(dirname(receiptPath), { recursive: true });
8737
+ let existingReceiptInfo: { dev: number; ino: number } | undefined;
8738
+ try {
8739
+ const info = await lstat(receiptPath);
8740
+ if (info.isSymbolicLink() || !info.isFile()) throw new Error("invalid fix receipt path");
8741
+ existingReceiptInfo = info;
8742
+ } catch (error) {
8743
+ if (getErrorCode(error) !== "ENOENT") throw error;
8744
+ }
8745
+ const mode = 0o600;
8746
+
8747
+ const tempPath = uniqueTempPath(receiptPath, "receipt");
8748
+ try {
8749
+ await writeFile(tempPath, JSON.stringify(receipt, null, 2) + "\n", { encoding: "utf8", mode, flag: "wx" });
8750
+ const tempInfo = await lstat(tempPath);
8751
+ if (tempInfo.isSymbolicLink() || !tempInfo.isFile()) throw new Error("invalid temporary fix receipt");
8752
+ await chmod(tempPath, mode);
8753
+ if (expectedSnapshot) {
8754
+ await assertModelsJsonFixReceiptSnapshotUnchanged(expectedSnapshot);
8755
+ }
8756
+ const assertReceiptDestinationUnchanged = async (): Promise<void> => {
8757
+ try {
8758
+ const currentReceiptInfo = await lstat(receiptPath);
8759
+ if (!existingReceiptInfo || !sameFileIdentity(existingReceiptInfo, currentReceiptInfo)) {
8760
+ throw new Error("fix receipt changed during atomic write");
8761
+ }
8762
+ } catch (error) {
8763
+ if (getErrorCode(error) === "ENOENT" && !existingReceiptInfo) {
8764
+ // The destination was absent both before and immediately before the
8765
+ // rename. The atomic rename will create it safely.
8766
+ } else {
8767
+ throw error;
8768
+ }
8769
+ }
8770
+ if (expectedSnapshot) await assertModelsJsonFixReceiptSnapshotUnchanged(expectedSnapshot);
8771
+ };
8772
+ await assertReceiptDestinationUnchanged();
8773
+ if (beforeRename) await beforeRename();
8774
+ await assertReceiptDestinationUnchanged();
8775
+ await rename(tempPath, receiptPath);
8776
+ } catch (error) {
8777
+ try {
8778
+ await unlink(tempPath);
8779
+ } catch (cleanupError) {
8780
+ if (getErrorCode(cleanupError) !== "ENOENT") console.warn(`${LOG_PREFIX}: failed to remove temporary fix receipt`, cleanupError);
8781
+ }
8782
+ throw error;
8783
+ }
8784
+ }
8785
+
8786
+ async function readModelsJsonFixReceiptSnapshot(
8787
+ receiptPath: string = FIX_RECEIPT_PATH,
8788
+ ): Promise<ModelsJsonFixReceiptSnapshot | undefined> {
8789
+ try {
8790
+ const info = await lstat(receiptPath);
8791
+ if (info.isSymbolicLink() || !info.isFile()) return undefined;
8792
+ const text = await readFile(receiptPath, "utf8");
8793
+ const afterRead = await lstat(receiptPath);
8794
+ if (
8795
+ afterRead.isSymbolicLink() ||
8796
+ !afterRead.isFile() ||
8797
+ !sameFileIdentity(info, afterRead)
8798
+ ) return undefined;
8799
+ const receipt = parseModelsJsonFixReceipt(JSON.parse(text));
8800
+ if (!receipt) return undefined;
8801
+ return {
8802
+ receipt,
8803
+ receiptPath,
8804
+ hash: hashText(text),
8805
+ identity: afterRead,
8806
+ };
8807
+ } catch {
8808
+ return undefined;
8809
+ }
8810
+ }
8811
+
8812
+ async function readModelsJsonFixReceipt(
8813
+ receiptPath: string = FIX_RECEIPT_PATH,
8814
+ ): Promise<ModelsJsonFixReceiptV1 | undefined> {
8815
+ return (await readModelsJsonFixReceiptSnapshot(receiptPath))?.receipt;
8816
+ }
8817
+
8818
+ async function markModelsJsonFixReceiptRolledBack(
8819
+ snapshot: ModelsJsonFixReceiptSnapshot,
8820
+ ): Promise<void> {
8821
+ const receipt = snapshot.receipt;
8822
+ await writeModelsJsonFixReceipt({
8823
+ ...receipt,
8824
+ status: "rolled_back",
8825
+ rolledBackAt: Date.now(),
8826
+ }, snapshot.receiptPath, snapshot);
8827
+ }
8828
+
8829
+ type ReceiptCompatTarget = {
8830
+ targetExists: boolean;
8831
+ compatBrace: number;
8832
+ compatEnd: number;
8833
+ };
8834
+
8835
+ function locateReceiptCompatTarget(
8836
+ text: string,
8837
+ provider: string,
8838
+ modelId: string,
8839
+ placement: FixReceiptPlacement,
8840
+ ): ReceiptCompatTarget | undefined {
8841
+ if (placement === "modelOverride") {
8842
+ const location = locateModelOverrideInJsonc(text, provider, modelId);
8843
+ if (
8844
+ !location ||
8845
+ location.providerKeyCount !== 1 ||
8846
+ location.modelOverridesKeyCount > 1 ||
8847
+ location.modelOverrideKeyCount > 1 ||
8848
+ location.modelOverrideCompatKeyCount > 1
8849
+ ) return undefined;
8850
+ return {
8851
+ targetExists: location.modelOverrideObjectBrace >= 0,
8852
+ compatBrace: location.modelOverrideCompatBrace,
8853
+ compatEnd: location.modelOverrideCompatEnd,
8854
+ };
8855
+ }
8856
+
8857
+ const location = locateModelInJsonc(text, provider, modelId);
8858
+ if (!location || location.providerKeyCount !== 1) return undefined;
8859
+ // A changed-file surgical rollback cannot tell which duplicate model object
8860
+ // was the receipt's target. Refuse that ambiguity instead of touching a
8861
+ // later user-added definition. Provider-level changes do not need to inspect
8862
+ // model compat, but duplicate target ids still make the receipt identity
8863
+ // ambiguous and are rejected for both placements.
8864
+ if (location.allModelIds.filter((id) => id === modelId).length !== 1) return undefined;
8865
+ if (placement === "provider" && location.providerCompatKeyCount > 1) return undefined;
8866
+ if (placement === "model" && location.modelCompatKeyCount > 1) return undefined;
8867
+ return {
8868
+ targetExists: location.modelObjectBrace >= 0,
8869
+ compatBrace: placement === "provider" ? location.providerCompatBrace : location.compatObjectBrace,
8870
+ compatEnd: placement === "provider" ? location.providerCompatEnd : location.compatObjectEnd,
8871
+ };
8872
+ }
8873
+
8874
+ function readReceiptCompatScalarState(
8875
+ text: string,
8876
+ target: ReceiptCompatTarget | undefined,
8877
+ key: string,
8878
+ ): ReceiptScalarState | undefined {
8879
+ if (!target || target.compatBrace < 0 || target.compatEnd <= target.compatBrace) {
8880
+ return { present: false };
8881
+ }
8882
+ const clean = stripJsoncComments(text);
8883
+ const property = findJsonObjectKey(clean, target.compatBrace, key);
8884
+ if (!property || property.keyStart >= target.compatEnd) return { present: false };
8885
+ if (property.count !== 1) return undefined;
8886
+ const valueStart = skipJsonWhitespace(clean, property.valueStart);
8887
+ const valueEnd = skipJsonValue(clean, valueStart);
8888
+ if (valueEnd === undefined || valueEnd > target.compatEnd) return undefined;
8889
+ try {
8890
+ const value = parseJsonc(text.slice(valueStart, valueEnd));
8891
+ return isReceiptScalar(value) ? { present: true, value } : undefined;
8892
+ } catch {
8893
+ return undefined;
8894
+ }
8895
+ }
8896
+
8897
+ function createModelsJsonFixReceipt(
8898
+ originalText: string,
8899
+ modifiedText: string,
8900
+ provider: string,
8901
+ modelId: string,
8902
+ placement: FixReceiptPlacement,
8903
+ compatKeys: Record<string, unknown>,
8904
+ targetExistedBefore: boolean,
8905
+ backupPath: string,
8906
+ now: number = Date.now(),
8907
+ ): ModelsJsonFixReceiptV1 | undefined {
8908
+ const backupFile = basename(backupPath);
8909
+ if (!/^models\.json\.backup-cache-optimizer-/.test(backupFile)) return undefined;
8910
+ const beforeTarget = locateReceiptCompatTarget(originalText, provider, modelId, placement);
8911
+ const afterTarget = locateReceiptCompatTarget(modifiedText, provider, modelId, placement);
8912
+ if (targetExistedBefore && !beforeTarget?.targetExists) return undefined;
8913
+ if (!afterTarget?.targetExists) return undefined;
8914
+
8915
+ const changedKeys: Record<string, FixReceiptCompatChange> = {};
8916
+ for (const [key, afterValue] of Object.entries(compatKeys)) {
8917
+ if (!isReceiptScalar(afterValue)) return undefined;
8918
+ const before = readReceiptCompatScalarState(originalText, beforeTarget, key);
8919
+ const after = readReceiptCompatScalarState(modifiedText, afterTarget, key);
8920
+ if (!before || !after || !sameReceiptScalarState(after, { present: true, value: afterValue })) return undefined;
8921
+ if (!sameReceiptScalarState(before, after)) changedKeys[key] = { before, after };
8922
+ }
8923
+ if (Object.keys(changedKeys).length === 0) return undefined;
8924
+
8925
+ const receipt: ModelsJsonFixReceiptV1 = {
8926
+ version: 1,
8927
+ kind: "pi-cache-optimizer-fix-receipt",
8928
+ transactionId: randomUUID(),
8929
+ provider,
8930
+ modelId,
8931
+ placement,
8932
+ targetExistedBefore,
8933
+ changedKeys,
8934
+ beforeHash: hashText(originalText),
8935
+ afterHash: hashText(modifiedText),
8936
+ backupFile,
8937
+ createdAt: now,
8938
+ appliedAt: now,
8939
+ };
8940
+ return parseModelsJsonFixReceipt(receipt);
8941
+ }
8942
+
8943
+ function maskJsonSyntaxPreservingComments(text: string, start: number, end: number): string {
8944
+ // Replace only JSON syntax/value bytes. Comments are copied verbatim so a
8945
+ // user explanation attached to a receipt-owned key survives a surgical
8946
+ // rollback. Newlines are also retained to keep line structure unchanged.
8947
+ const output = text.slice(start, end).split("");
8948
+ let i = start;
8949
+ let inLineComment = false;
8950
+ let inBlockComment = false;
8951
+ let inString = false;
8952
+ let escaped = false;
8953
+ while (i < end) {
8954
+ const ch = text[i];
8955
+ const next = text[i + 1];
8956
+ const offset = i - start;
8957
+ if (inLineComment) {
8958
+ if (ch === "\n" || ch === "\r") inLineComment = false;
8959
+ i++;
8960
+ continue;
8961
+ }
8962
+ if (inBlockComment) {
8963
+ if (ch === "*" && next === "/") {
8964
+ i += 2;
8965
+ inBlockComment = false;
8966
+ } else {
8967
+ i++;
8968
+ }
8969
+ continue;
8970
+ }
8971
+ if (inString) {
8972
+ if (ch === "\n" || ch === "\r") {
8973
+ i++;
8974
+ continue;
8975
+ }
8976
+ output[offset] = " ";
8977
+ if (escaped) escaped = false;
8978
+ else if (ch === "\\") escaped = true;
8979
+ else if (ch === '"') inString = false;
8980
+ i++;
8981
+ continue;
8982
+ }
8983
+ if (ch === '"') {
8984
+ inString = true;
8985
+ output[offset] = " ";
8986
+ } else if (ch === "/" && next === "/") {
8987
+ inLineComment = true;
8988
+ i += 2;
8989
+ continue;
8990
+ } else if (ch === "/" && next === "*") {
8991
+ inBlockComment = true;
8992
+ i += 2;
8993
+ continue;
8994
+ } else if (ch !== "\n" && ch !== "\r") {
8995
+ output[offset] = " ";
8996
+ }
8997
+ i++;
8998
+ }
8999
+ return output.join("");
9000
+ }
9001
+
9002
+ type JsonPropertyEdit = { start: number; end: number; text: string };
9003
+
9004
+ function locateJsonPropertyValueSpan(
9005
+ text: string,
9006
+ objectBrace: number,
9007
+ objectEnd: number,
9008
+ key: string,
9009
+ ): { keyStart: number; valueStart: number; valueEnd: number; removal: JsonPropertyEdit } | undefined {
9010
+ const clean = stripJsoncComments(text);
9011
+ const property = findJsonObjectKey(clean, objectBrace, key);
9012
+ if (!property || property.keyStart >= objectEnd || property.count !== 1) return undefined;
9013
+ const valueStart = skipJsonWhitespace(clean, property.valueStart);
9014
+ const valueEnd = skipJsonValue(clean, valueStart);
9015
+ if (valueEnd === undefined || valueEnd > objectEnd) return undefined;
9016
+
9017
+ const next = skipJsonWhitespace(clean, valueEnd);
9018
+ if (clean[next] === ",") {
9019
+ return {
9020
+ keyStart: property.keyStart,
9021
+ valueStart,
9022
+ valueEnd,
9023
+ removal: {
9024
+ start: property.keyStart,
9025
+ end: next + 1,
9026
+ text: maskJsonSyntaxPreservingComments(text, property.keyStart, next + 1),
9027
+ },
9028
+ };
9029
+ }
9030
+
9031
+ let previous = property.keyStart - 1;
9032
+ while (previous >= objectBrace && isJsonWhitespace(clean[previous])) previous--;
9033
+ const removalStart = clean[previous] === "," ? previous : property.keyStart;
9034
+ return {
9035
+ keyStart: property.keyStart,
9036
+ valueStart,
9037
+ valueEnd,
9038
+ removal: {
9039
+ start: removalStart,
9040
+ end: valueEnd,
9041
+ text: maskJsonSyntaxPreservingComments(text, removalStart, valueEnd),
9042
+ },
9043
+ };
9044
+ }
9045
+
9046
+ function composeModelsJsonReceiptRollback(
9047
+ currentText: string,
9048
+ receipt: ModelsJsonFixReceiptV1,
9049
+ ): { modifiedText: string; changed: boolean } | { error: string } {
9050
+ if (!receipt.targetExistedBefore) {
9051
+ return { error: "the fix created a new target entry and the file changed; refusing to remove user changes automatically" };
9052
+ }
9053
+
9054
+ const target = locateReceiptCompatTarget(currentText, receipt.provider, receipt.modelId, receipt.placement);
9055
+ if (!target?.targetExists) {
9056
+ return { error: "the original target entry is missing or no longer safely locatable" };
9057
+ }
9058
+ if (target.compatBrace < 0 || target.compatEnd <= target.compatBrace) {
9059
+ return { error: "the target compat object is missing or no longer safely locatable" };
9060
+ }
9061
+
9062
+ const edits: JsonPropertyEdit[] = [];
9063
+ for (const [key, change] of Object.entries(receipt.changedKeys)) {
9064
+ const current = readReceiptCompatScalarState(currentText, target, key);
9065
+ if (!current || !sameReceiptScalarState(current, change.after)) {
9066
+ return { error: `receipt-owned compat.${key} was changed after the fix; refusing to overwrite it` };
9067
+ }
9068
+ const property = locateJsonPropertyValueSpan(currentText, target.compatBrace, target.compatEnd, key);
9069
+ if (!property) return { error: `receipt-owned compat.${key} is no longer safely locatable` };
9070
+ if (change.before.present) {
9071
+ edits.push({
9072
+ start: property.valueStart,
9073
+ end: property.valueEnd,
9074
+ text: JSON.stringify(change.before.value),
9075
+ });
9076
+ } else {
9077
+ edits.push(property.removal);
9078
+ }
9079
+ }
9080
+
9081
+ const modifiedText = edits
9082
+ .sort((left, right) => right.start - left.start)
9083
+ .reduce((text, edit) => text.slice(0, edit.start) + edit.text + text.slice(edit.end), currentText);
9084
+ return { modifiedText, changed: modifiedText !== currentText };
9085
+ }
9086
+
9087
+ function validateModelsJsonRollback(
9088
+ writtenText: string,
9089
+ receipt: ModelsJsonFixReceiptV1,
9090
+ expectedHash?: string,
9091
+ ): string | null {
9092
+ if (expectedHash !== undefined && hashText(writtenText) !== expectedHash) {
9093
+ return "rollback result hash did not match the expected pre-fix file";
9094
+ }
9095
+ try {
9096
+ parseJsonc(writtenText);
9097
+ } catch {
9098
+ return "rollback result is not valid JSONC";
9099
+ }
9100
+ // An exact hash check proves the complete pre-fix document, including a
9101
+ // newly created target entry (which intentionally did not exist before).
9102
+ if (expectedHash !== undefined) return null;
9103
+
9104
+ const target = locateReceiptCompatTarget(writtenText, receipt.provider, receipt.modelId, receipt.placement);
9105
+ if (!target?.targetExists) return "rollback result lost the original target entry";
9106
+ for (const [key, change] of Object.entries(receipt.changedKeys)) {
9107
+ const state = readReceiptCompatScalarState(writtenText, target, key);
9108
+ if (!state || !sameReceiptScalarState(state, change.before)) return `rollback result did not restore compat.${key}`;
9109
+ }
9110
+ return null;
9111
+ }
9112
+
9113
+ type ModelsJsonRollbackPlan = {
9114
+ receiptSnapshot: ModelsJsonFixReceiptSnapshot;
9115
+ receipt: ModelsJsonFixReceiptV1;
9116
+ currentHash: string;
9117
+ modifiedText: string;
9118
+ expectedResultHash?: string;
9119
+ mode: "exact" | "surgical";
9120
+ fileMode: number;
9121
+ originalBackupPath: string;
9122
+ rollbackBackupPath: string;
9123
+ };
9124
+
9125
+ async function readRegularTextFile(path: string): Promise<{ text: string; mode: number }> {
9126
+ const info = await lstat(path);
9127
+ if (info.isSymbolicLink() || !info.isFile()) {
9128
+ throw new Error("file is not a regular file");
9129
+ }
9130
+ const text = await readFile(path, "utf8");
9131
+ const afterRead = await lstat(path);
9132
+ if (
9133
+ afterRead.isSymbolicLink() ||
9134
+ !afterRead.isFile() ||
9135
+ !sameFileIdentity(info, afterRead)
9136
+ ) {
9137
+ throw new Error("file changed while it was being read");
9138
+ }
9139
+ return {
9140
+ text,
9141
+ mode: afterRead.mode & 0o7777,
9142
+ };
9143
+ }
9144
+
9145
+ async function prepareModelsJsonRollback(
9146
+ receiptSnapshot: ModelsJsonFixReceiptSnapshot | undefined,
9147
+ modelsPath: string = MODELS_JSON_PATH,
9148
+ ): Promise<ModelsJsonRollbackPlan | { error: string }> {
9149
+ const receipt = receiptSnapshot?.receipt;
9150
+ if (!receiptSnapshot || !isActionableModelsJsonFixReceipt(receipt)) {
9151
+ return { error: "No unapplied /cache-optimizer fix receipt was found." };
9152
+ }
9153
+
9154
+ let current: { text: string; mode: number };
9155
+ try {
9156
+ current = await readRegularTextFile(modelsPath);
9157
+ } catch {
9158
+ return { error: "models.json is missing or is not a regular file; no changes were made." };
9159
+ }
9160
+ const currentHash = hashText(current.text);
9161
+ const originalBackupPath = join(dirname(modelsPath), receipt.backupFile);
9162
+ const rollbackBackupPath = createRollbackBackupPath(modelsPath);
9163
+
9164
+ if (currentHash === receipt.afterHash) {
9165
+ let backup: { text: string; mode: number };
9166
+ try {
9167
+ backup = await readRegularTextFile(originalBackupPath);
9168
+ } catch {
9169
+ return { error: "The receipt backup is missing or is not a regular file; refusing to overwrite models.json." };
9170
+ }
9171
+ if (hashText(backup.text) !== receipt.beforeHash) {
9172
+ return { error: "The receipt backup hash does not match the recorded pre-fix file; refusing to overwrite models.json." };
9173
+ }
9174
+ try {
9175
+ parseJsonc(backup.text);
9176
+ } catch {
9177
+ return { error: "The receipt backup is not valid JSONC; refusing to overwrite models.json." };
9178
+ }
9179
+ return {
9180
+ receiptSnapshot,
9181
+ receipt,
9182
+ currentHash,
9183
+ modifiedText: backup.text,
9184
+ expectedResultHash: receipt.beforeHash,
9185
+ mode: "exact",
9186
+ fileMode: current.mode,
9187
+ originalBackupPath,
9188
+ rollbackBackupPath,
9189
+ };
9190
+ }
9191
+
9192
+ if (!receipt.targetExistedBefore) {
9193
+ return {
9194
+ 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.",
9195
+ };
9196
+ }
9197
+
9198
+ // A changed file is eligible only for a scalar, receipt-owned surgical
9199
+ // rollback. Validate the current document before showing a confirmation;
9200
+ // malformed JSONC must never be replaced by an edit derived from offsets.
9201
+ try {
9202
+ parseJsonc(current.text);
9203
+ } catch {
9204
+ 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." };
9205
+ }
9206
+
9207
+ const surgical = composeModelsJsonReceiptRollback(current.text, receipt);
9208
+ if ("error" in surgical) {
9209
+ return {
9210
+ error: `models.json changed after the fix. ${surgical.error}. Use the recorded backup for manual guidance.`,
9211
+ };
9212
+ }
9213
+ if (!surgical.changed) {
9214
+ return { error: "No receipt-owned change remains to roll back." };
9215
+ }
9216
+
9217
+ return {
9218
+ receiptSnapshot,
9219
+ receipt,
9220
+ currentHash,
9221
+ modifiedText: surgical.modifiedText,
9222
+ mode: "surgical",
9223
+ fileMode: current.mode,
9224
+ originalBackupPath,
9225
+ rollbackBackupPath,
9226
+ };
9227
+ }
9228
+
7439
9229
  // Internal helpers exported only so the task verification script
7440
9230
  // (.trellis/tasks/.../verify.ts) can exercise them. They are not part of the
7441
9231
  // extension's public API; pi only invokes the default export below.
@@ -7475,7 +9265,9 @@ export const __internals_for_tests = {
7475
9265
  describeMissingOpenAICompatibleProxyCompat,
7476
9266
  describeOptionalOpenAICompatibleProxyCompat,
7477
9267
  describeMissingDeepSeekCompat,
9268
+ isDeepSeekWireCompatApplicable,
7478
9269
  isDeepSeekCompatCheckApplicable,
9270
+ hasExplicitDeepSeekReasoningProtocol,
7479
9271
  describeMissingCacheCompatForModel,
7480
9272
  buildDeepSeekCompatSuggestion,
7481
9273
  buildDeepSeekCompatWarningText,
@@ -7489,6 +9281,12 @@ export const __internals_for_tests = {
7489
9281
  isOpenAISdkHeader403Applicable,
7490
9282
  hasPromptCacheRetentionUnsupportedSignal,
7491
9283
  hasPromptCacheRetentionUnsupportedErrorMessage,
9284
+ hasReasoningProtocolRejectionText,
9285
+ hasReasoningProtocolRejectionSignal,
9286
+ hasReasoningProtocolRejectionErrorMessage,
9287
+ isReasoningProtocolRejectionSignalApplicable,
9288
+ isReasoningProtocolRejectionForModel,
9289
+ buildReasoningProtocolFixSuggestion,
7492
9290
  // Non-GPT OpenAI-compatible model detection
7493
9291
  isKimiLikeModel,
7494
9292
  isKimiLikeAssistantMessage,
@@ -7606,9 +9404,16 @@ export const __internals_for_tests = {
7606
9404
  mergeCacheCompat,
7607
9405
  resolveEffectiveCompatFromConfig,
7608
9406
  getEffectiveCompatValueSource,
9407
+ findLastExactModelDefinition,
7609
9408
  isValidModelsConfigForEffectiveCompat,
7610
9409
  addEffectiveSessionAffinityHeaders,
7611
9410
  getCompat,
9411
+ isKnownToolOrderApi,
9412
+ getToolNameForPayload,
9413
+ compareToolOrderEntries,
9414
+ isVerifiedToolForApi,
9415
+ normalizeToolsInPayload,
9416
+ sortToolsInPayload,
7612
9417
  modelKey,
7613
9418
  modelFromAssistantMessage,
7614
9419
  consolidateDirectProviderStatsModel,
@@ -7626,6 +9431,8 @@ export const __internals_for_tests = {
7626
9431
  formatOptimizerRuntimeMode,
7627
9432
  PI_CACHE_RETENTION_ENV,
7628
9433
  LONG_CACHE_RETENTION_VALUE,
9434
+ TOOL_ORDER_ENV,
9435
+ isToolOrderEnabled,
7629
9436
  // Integrity diagnostics
7630
9437
  getLastPromptIntegrityWarningAt,
7631
9438
  // Diagnostic command helpers
@@ -7677,6 +9484,7 @@ export const __internals_for_tests = {
7677
9484
  // Routing-provider protocol helpers
7678
9485
  PI_ROUTING_REGISTRY_SYMBOL,
7679
9486
  PI_CACHE_HINTS_SYMBOL,
9487
+ REASONING_PROTOCOL_FALLBACK_SYMBOL,
7680
9488
  ensureRoutingRegistry,
7681
9489
  getRoutingRegistry,
7682
9490
  parseRouteSnapshot,
@@ -7714,6 +9522,8 @@ export const __internals_for_tests = {
7714
9522
  STATE_FILE_PATH,
7715
9523
  LEGACY_STATE_FILE_PATH,
7716
9524
  SHARD_STATE_DIR,
9525
+ FIX_RECEIPT_FILE_NAME,
9526
+ FIX_RECEIPT_PATH,
7717
9527
  SHARD_FILES_DIR,
7718
9528
  SHARD_GLOBAL_EPOCH_PATH,
7719
9529
  STATE_DIR,
@@ -7741,6 +9551,20 @@ export const __internals_for_tests = {
7741
9551
  atomicReplaceTextFilePreservingMode,
7742
9552
  atomicRestoreFileFromBackup,
7743
9553
  applyModelsJsonFixTransaction,
9554
+ hashText,
9555
+ parseModelsJsonFixReceipt,
9556
+ isActionableModelsJsonFixReceipt,
9557
+ hasReceiptReasoningProtocolChange,
9558
+ createRollbackBackupPath,
9559
+ createModelsJsonFixReceipt,
9560
+ writeModelsJsonFixReceipt,
9561
+ readModelsJsonFixReceipt,
9562
+ readModelsJsonFixReceiptSnapshot,
9563
+ markModelsJsonFixReceiptRolledBack,
9564
+ receiptBackupPath,
9565
+ composeModelsJsonReceiptRollback,
9566
+ validateModelsJsonRollback,
9567
+ prepareModelsJsonRollback,
7744
9568
  // Fix suggestion builder
7745
9569
  buildFixSuggestion,
7746
9570
  // Adaptive thinking compat helpers
@@ -7765,6 +9589,9 @@ export default function (pi: ExtensionAPI) {
7765
9589
  const warnedSendSessionAffinityHeaders403Models = new Set<string>();
7766
9590
  const openAISdkHeader403Models = new Set<string>();
7767
9591
  const warnedOpenAISdkHeader403Models = new Set<string>();
9592
+ const reasoningProtocolFallbackState = getReasoningProtocolFallbackState();
9593
+ const reasoningProtocolRejectedModels = reasoningProtocolFallbackState.modelKeys;
9594
+ const warnedReasoningProtocolRejectedModels = reasoningProtocolFallbackState.warnedModelKeys;
7768
9595
  let cacheStatsByModel: Record<string, CacheStats> = {};
7769
9596
  let cacheStatsProcessByModel: Record<string, CacheStats> = {};
7770
9597
  let cacheStatsTotalsByModel: Record<string, CacheStats> = {};
@@ -7788,6 +9615,7 @@ export default function (pi: ExtensionAPI) {
7788
9615
  let currentSessionHashSet = false;
7789
9616
  let lastActualRoutedModel: PersistedRoutedModelRef | undefined;
7790
9617
  let latestCacheHint: PiCacheHintSnapshot | undefined;
9618
+ let pendingProviderResponseModel: PiModel | undefined;
7791
9619
  let shardCreatedAt = Date.now();
7792
9620
  const PERSIST_DEBOUNCE_MS = 2000;
7793
9621
  const SHARD_REFRESH_DEBOUNCE_MS = 250;
@@ -7816,7 +9644,14 @@ export default function (pi: ExtensionAPI) {
7816
9644
 
7817
9645
  function buildCommandFixSuggestion(model: PiModel): FixSuggestion | undefined {
7818
9646
  const regular = buildFixSuggestion(model);
7819
- const observed = buildObservedRuntimeFixSuggestion(model);
9647
+ const observedReasoning = buildReasoningProtocolFixSuggestion(
9648
+ model,
9649
+ reasoningProtocolRejectedModels.has(modelKey(model)),
9650
+ );
9651
+ const observed = mergeFixSuggestions(
9652
+ buildObservedRuntimeFixSuggestion(model),
9653
+ observedReasoning,
9654
+ );
7820
9655
  if (!regular) return observed;
7821
9656
  if (!observed) return regular;
7822
9657
  return {
@@ -8323,6 +10158,7 @@ export default function (pi: ExtensionAPI) {
8323
10158
  shardWatcher?.close();
8324
10159
  shardWatcher = undefined;
8325
10160
  latestCacheHint = undefined;
10161
+ pendingProviderResponseModel = undefined;
8326
10162
  delete getProtocolGlobal().__piCacheOptimizerCacheKey__;
8327
10163
  uninstallCacheHintsService();
8328
10164
  restoreCacheRetentionEnv(STARTUP_CACHE_RETENTION_ENV);
@@ -8383,9 +10219,7 @@ export default function (pi: ExtensionAPI) {
8383
10219
  // prompt mutations below (session-overview churn strip, skill compression,
8384
10220
  // and stable-prefix reordering). Footer stats and the OpenAI
8385
10221
  // prompt_cache_key fallback remain active.
8386
- if (isEnabledEnv(process.env[NO_PROMPT_REWRITE_ENV])) {
8387
- return {};
8388
- }
10222
+ if (isEnabledEnv(process.env[NO_PROMPT_REWRITE_ENV])) return {};
8389
10223
 
8390
10224
  // Step 1: strip per-turn churn from <session-overview>.
8391
10225
  // Removing RECENT COMMITS, Working directory status, and
@@ -8466,6 +10300,17 @@ export default function (pi: ExtensionAPI) {
8466
10300
 
8467
10301
  pi.on("before_provider_request", (event, ctx) => {
8468
10302
  const requestModel = resolveRouteModel(ctx.model, ctx) ?? ctx.model;
10303
+ pendingProviderResponseModel = runtimeOptimizerEnabled
10304
+ ? snapshotProviderRequestModel(requestModel)
10305
+ : undefined;
10306
+ let requestPayload: unknown = event.payload;
10307
+ let toolOrderChanged = false;
10308
+
10309
+ if (isToolOrderEnabled() && isToolOrderingEligibleModel(requestModel)) {
10310
+ const normalized = normalizeToolsInPayload(requestPayload, requestModel.api);
10311
+ requestPayload = normalized.payload;
10312
+ toolOrderChanged = normalized.changed;
10313
+ }
8469
10314
 
8470
10315
  // Anthropic rejects mixed cache breakpoints when a 1h block appears after
8471
10316
  // a 5m/default block in wire order (tools → system → messages). Repair any
@@ -8473,9 +10318,9 @@ export default function (pi: ExtensionAPI) {
8473
10318
  // hidden short breakpoints after this hook; only models that have actually
8474
10319
  // returned the explicit TTL-order error receive the process-local 5m fallback.
8475
10320
  if (requestModel && isAnthropicMessagesApi(requestModel.api)) {
8476
- const visibleConflictFixed = normalizeAnthropicCacheControlTtlOrder(event.payload);
10321
+ const visibleConflictFixed = normalizeAnthropicCacheControlTtlOrder(requestPayload);
8477
10322
  if (!visibleConflictFixed && anthropicTtlOrderErrorModels.has(modelKey(requestModel))) {
8478
- downgradeAnthropicLongCacheControls(event.payload);
10323
+ downgradeAnthropicLongCacheControls(requestPayload);
8479
10324
  }
8480
10325
  }
8481
10326
 
@@ -8493,33 +10338,59 @@ export default function (pi: ExtensionAPI) {
8493
10338
  // Gate 2 before Gate 3 is critical: if a user explicitly opted in but
8494
10339
  // the API returned 400, we must strip — otherwise the 400 repeats forever.
8495
10340
  if (runtimeOptimizerEnabled) {
8496
- const payload = event.payload as UnknownRecord;
8497
- if (payload && typeof payload.prompt_cache_retention === 'string') {
10341
+ const payloadRecord = asRecord(requestPayload);
10342
+ if (payloadRecord && typeof payloadRecord.prompt_cache_retention === "string") {
8498
10343
  if (requestModel) {
8499
10344
  if (isOfficialOpenAIBaseUrl(requestModel)) {
8500
10345
  // Gate 1: Official OpenAI → keep
8501
10346
  } else if (promptCacheRetention400Models.has(modelKey(requestModel))) {
8502
10347
  // Gate 2: 400 history → strip (overrides user opt-in)
8503
- delete payload.prompt_cache_retention;
10348
+ delete payloadRecord.prompt_cache_retention;
8504
10349
  } else if (hasExplicitLongRetentionOptIn(requestModel)) {
8505
10350
  // Gate 3: Explicit user opt-in → keep
8506
10351
  } else {
8507
10352
  // Gate 4: Safe default → strip
8508
- delete payload.prompt_cache_retention;
10353
+ delete payloadRecord.prompt_cache_retention;
8509
10354
  }
8510
10355
  }
8511
10356
  }
8512
10357
  }
8513
10358
 
8514
- if (!shouldInjectOpenAIPromptCacheKey()) return undefined;
8515
- if (!shouldInjectOpenAIPromptCacheKeyForModel(requestModel)) return undefined;
10359
+ if (!shouldInjectOpenAIPromptCacheKey() || !shouldInjectOpenAIPromptCacheKeyForModel(requestModel)) {
10360
+ return toolOrderChanged ? requestPayload : undefined;
10361
+ }
8516
10362
 
8517
- return addOpenAIPromptCacheKey(event.payload, getSessionPromptCacheKey(ctx));
10363
+ const withCacheKey = addOpenAIPromptCacheKey(requestPayload, getSessionPromptCacheKey(ctx));
10364
+ return withCacheKey ?? (toolOrderChanged ? requestPayload : undefined);
8518
10365
  });
8519
10366
 
8520
- pi.on("after_provider_response", (event, ctx) => {
8521
- const model = resolveRouteModel(ctx.model, ctx) ?? ctx.model;
8522
- if (!runtimeOptimizerEnabled || !model) return;
10367
+ pi.on("after_provider_response", async (event, ctx) => {
10368
+ // Keep the request snapshot until the corresponding assistant message ends.
10369
+ // Pi's response hook exposes only status/headers; the finalized error may
10370
+ // carry the useful protocol text later in message_end. A subsequent request
10371
+ // replaces this process-local snapshot, and shutdown clears it.
10372
+ const correlatedModel = pendingProviderResponseModel;
10373
+ const model = correlatedModel ?? (resolveRouteModel(ctx.model, ctx) ?? ctx.model);
10374
+ if (!runtimeOptimizerEnabled || !model) {
10375
+ pendingProviderResponseModel = undefined;
10376
+ return;
10377
+ }
10378
+
10379
+ // Keep only the category, never the provider's complete error text. This
10380
+ // is evidence for a later, model-scoped `/fix`; it never edits config from
10381
+ // a response hook.
10382
+ if (
10383
+ isReasoningProtocolRejectionSignalApplicable(model) &&
10384
+ event.status === 400 &&
10385
+ hasReasoningProtocolRejectionSignal(event.headers)
10386
+ ) {
10387
+ await notifyReasoningProtocolObservation(
10388
+ model,
10389
+ ctx,
10390
+ reasoningProtocolRejectedModels,
10391
+ warnedReasoningProtocolRejectedModels,
10392
+ );
10393
+ }
8523
10394
 
8524
10395
  // ── 400: prompt_cache_retention unsupported ──
8525
10396
  if (event.status === 400) {
@@ -8581,13 +10452,39 @@ export default function (pi: ExtensionAPI) {
8581
10452
  pi.on("message_end", async (event, ctx) => {
8582
10453
  syncSessionHash(ctx);
8583
10454
  const msgRecord = asRecord(event.message);
10455
+ const requestModelForMessage = msgRecord?.role === "assistant"
10456
+ ? pendingProviderResponseModel
10457
+ : undefined;
10458
+ if (msgRecord?.role === "assistant") {
10459
+ // If a transport failed before emitting after_provider_response, do not
10460
+ // let its request snapshot be consumed by a later response. Normal
10461
+ // responses already clear this in after_provider_response.
10462
+ pendingProviderResponseModel = undefined;
10463
+ }
8584
10464
 
8585
10465
  // Some providers expose an HTTP 400 error body only through the finalized
8586
10466
  // assistant error message; after_provider_response may contain the status
8587
- // and no diagnostic response headers. Record the same model-scoped
8588
- // prompt_cache_retention fallback from that authoritative message identity.
10467
+ // and no diagnostic response headers. Record only the model-scoped
10468
+ // reasoning-protocol category from that authoritative message identity.
10469
+ if (runtimeOptimizerEnabled && hasReasoningProtocolRejectionErrorMessage(event.message)) {
10470
+ const fallbackModel = requestModelForMessage
10471
+ ?? (resolveRouteModel(ctx.model, ctx) ?? ctx.model);
10472
+ const messageModel = modelFromAssistantMessage(event.message, fallbackModel) ?? fallbackModel;
10473
+ const errorModel = messageModel
10474
+ ? findModelInRegistry(ctx.modelRegistry, messageModel.provider, messageModel.id) ?? messageModel
10475
+ : undefined;
10476
+ if (isReasoningProtocolRejectionForModel(event.message, errorModel)) {
10477
+ await notifyReasoningProtocolObservation(
10478
+ errorModel,
10479
+ ctx,
10480
+ reasoningProtocolRejectedModels,
10481
+ warnedReasoningProtocolRejectedModels,
10482
+ );
10483
+ }
10484
+ }
8589
10485
  if (runtimeOptimizerEnabled && hasPromptCacheRetentionUnsupportedErrorMessage(event.message)) {
8590
- const fallbackModel = resolveRouteModel(ctx.model, ctx) ?? ctx.model;
10486
+ const fallbackModel = requestModelForMessage
10487
+ ?? (resolveRouteModel(ctx.model, ctx) ?? ctx.model);
8591
10488
  const messageModel = modelFromAssistantMessage(event.message, fallbackModel) ?? fallbackModel;
8592
10489
  const errorModel = messageModel
8593
10490
  ? findModelInRegistry(ctx.modelRegistry, messageModel.provider, messageModel.id) ?? messageModel
@@ -8611,7 +10508,8 @@ export default function (pi: ExtensionAPI) {
8611
10508
  // non-retryable 400 in Pi 0.82.1, so the fallback applies to the next
8612
10509
  // subsequent request (and to a retry only if another layer initiates one).
8613
10510
  if (hasAnthropicCacheTtlOrderError(event.message)) {
8614
- const fallbackModel = resolveRouteModel(ctx.model, ctx) ?? ctx.model;
10511
+ const fallbackModel = requestModelForMessage
10512
+ ?? (resolveRouteModel(ctx.model, ctx) ?? ctx.model);
8615
10513
  const errorModel = modelFromAssistantMessage(event.message, fallbackModel) ?? fallbackModel;
8616
10514
  if (errorModel && isAnthropicMessagesApi(errorModel.api)) {
8617
10515
  const key = modelKey(errorModel);
@@ -8733,6 +10631,7 @@ export default function (pi: ExtensionAPI) {
8733
10631
  // compat — show compat suggestion with file path
8734
10632
  // config footer-mode total|session|process — persist footer mode override
8735
10633
  // fix — auto-fix compat issues (writes models.json, requires UI)
10634
+ // rollback — undo the latest confirmed fix for the active model
8736
10635
  // reset — reset current provider/model footer stats bucket (local only)
8737
10636
  // (no args) — interactive menu (with UI) or help summary
8738
10637
  // ────────────────────────────────────────────────────────────────
@@ -8754,6 +10653,7 @@ export default function (pi: ExtensionAPI) {
8754
10653
  cmdCtx.ui.notify(`✅ Pi Cache Optimizer enabled for this Pi process. Local footer stats were reset for before/after comparison.\n${formatOptimizerRuntimeMode()}`, "info");
8755
10654
  } else if (subcommand === "disable") {
8756
10655
  setRuntimeOptimizerEnabled(false);
10656
+ pendingProviderResponseModel = undefined;
8757
10657
  await resetCurrentSessionStats();
8758
10658
  await flushPersistCacheStats(cmdCtx);
8759
10659
  await publishStatus(cmdCtx, model);
@@ -8833,6 +10733,105 @@ export default function (pi: ExtensionAPI) {
8833
10733
  "info",
8834
10734
  );
8835
10735
  }
10736
+ } else if (subcommand === "rollback") {
10737
+ if (!model) {
10738
+ cmdCtx.ui.notify("No active model selected. Select a model first with /model or pi --model.", "warning");
10739
+ return;
10740
+ }
10741
+ if (!cmdCtx.hasUI) {
10742
+ const receipt = await readModelsJsonFixReceipt();
10743
+ const backupHint = receipt && isActionableModelsJsonFixReceipt(receipt)
10744
+ ? ` The recorded backup is ${receipt.backupFile} next to ${getModelsJsonDisplayPath()}.`
10745
+ : " Check the recorded models.json backup manually if a fix receipt exists.";
10746
+ cmdCtx.ui.notify(
10747
+ "❌ Rollback requires interactive confirmation. No changes were made.\n" +
10748
+ `Run /cache-optimizer rollback in Pi's interactive UI.${backupHint} Then run /reload.`,
10749
+ "warning",
10750
+ );
10751
+ return;
10752
+ }
10753
+
10754
+ const receiptSnapshot = await readModelsJsonFixReceiptSnapshot();
10755
+ const receipt = receiptSnapshot?.receipt;
10756
+ if (!receiptSnapshot || !isActionableModelsJsonFixReceipt(receipt)) {
10757
+ cmdCtx.ui.notify("ℹ️ No unapplied /cache-optimizer fix receipt was found.", "info");
10758
+ return;
10759
+ }
10760
+ if (receipt.provider !== model.provider || receipt.modelId !== model.id) {
10761
+ cmdCtx.ui.notify(
10762
+ `ℹ️ The latest fix receipt is for ${receipt.provider}/${receipt.modelId}, not the active model ${model.provider}/${model.id}. ` +
10763
+ "Switch to the matching model before running rollback. No changes were made.",
10764
+ "warning",
10765
+ );
10766
+ return;
10767
+ }
10768
+
10769
+ const rollback = await prepareModelsJsonRollback(receiptSnapshot);
10770
+ if ("error" in rollback) {
10771
+ cmdCtx.ui.notify(`ℹ️ ${rollback.error}`, "info");
10772
+ return;
10773
+ }
10774
+
10775
+ const rollbackScope = rollback.mode === "exact"
10776
+ ? "restore the exact pre-fix models.json because the file is unchanged since the fix"
10777
+ : "restore only the receipt-owned compat scalar keys and preserve subsequent user changes";
10778
+ const rollbackPreview = [
10779
+ `Rollback transaction ${rollback.receipt.transactionId}:`,
10780
+ `Model: ${rollback.receipt.provider}/${rollback.receipt.modelId}`,
10781
+ `Action: ${rollbackScope}.`,
10782
+ `A new rollback backup will be written to: ${rollback.rollbackBackupPath}`,
10783
+ "Comments, credentials, unrelated fields, and the existing access mode will be preserved.",
10784
+ "Afterward, run /reload or restart Pi for the configuration change to take effect.",
10785
+ "",
10786
+ "Proceed with rollback?",
10787
+ ].join("\n");
10788
+ const confirmed = await cmdCtx.ui.confirm("Cache Optimizer — Rollback", rollbackPreview);
10789
+ if (!confirmed) {
10790
+ cmdCtx.ui.notify("No changes were made. Rollback canceled by user.", "info");
10791
+ return;
10792
+ }
10793
+
10794
+ try {
10795
+ const result = await applyModelsJsonFixTransaction(
10796
+ rollback.modifiedText,
10797
+ rollback.rollbackBackupPath,
10798
+ (writtenText) => validateModelsJsonRollback(
10799
+ writtenText,
10800
+ rollback.receipt,
10801
+ rollback.expectedResultHash,
10802
+ ),
10803
+ {
10804
+ expectedCurrentHash: rollback.currentHash,
10805
+ expectedCurrentMode: rollback.fileMode,
10806
+ receiptGuard: rollback.receiptSnapshot,
10807
+ purpose: "rollback",
10808
+ onCommitted: async () => {
10809
+ await markModelsJsonFixReceiptRolledBack(rollback.receiptSnapshot);
10810
+ },
10811
+ },
10812
+ );
10813
+ if ("postCheckError" in result) {
10814
+ cmdCtx.ui.notify(
10815
+ `❌ Rollback self-check failed: ${result.postCheckError}\n` +
10816
+ `The rollback backup at ${rollback.rollbackBackupPath} was restored. No changes applied.`,
10817
+ "error",
10818
+ );
10819
+ return;
10820
+ }
10821
+ invalidateModelsConfigCache();
10822
+ cmdCtx.ui.notify(
10823
+ `✅ Rollback completed for ${rollback.receipt.provider}/${rollback.receipt.modelId}.\n` +
10824
+ `Rollback backup saved to: ${rollback.rollbackBackupPath}\n` +
10825
+ "The receipt was marked as rolled back. Run /reload or restart Pi for the change to take effect.",
10826
+ "info",
10827
+ );
10828
+ } catch (rollbackError) {
10829
+ cmdCtx.ui.notify(
10830
+ `❌ Rollback failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}\n` +
10831
+ "No automatic overwrite was performed; use the recorded backup for manual guidance.",
10832
+ "error",
10833
+ );
10834
+ }
8836
10835
  } else if (subcommand === "reset") {
8837
10836
  if (!model) {
8838
10837
  cmdCtx.ui.notify("No active model selected. Select a model first with /model or pi --model.", "warning");
@@ -8964,6 +10963,7 @@ export default function (pi: ExtensionAPI) {
8964
10963
  const checkError = selfCheckMissingEntryInsertion(
8965
10964
  originalText, plan.modifiedText,
8966
10965
  suggestion.providerLabel, suggestion.modelId, suggestion.compatKeys,
10966
+ model,
8967
10967
  );
8968
10968
  if (checkError !== null) {
8969
10969
  // Fall through to manual guidance.
@@ -9008,6 +11008,20 @@ export default function (pi: ExtensionAPI) {
9008
11008
  );
9009
11009
  if (confirmed) {
9010
11010
  try {
11011
+ const receipt = createModelsJsonFixReceipt(
11012
+ originalText,
11013
+ plan.modifiedText,
11014
+ suggestion.providerLabel,
11015
+ suggestion.modelId,
11016
+ "modelOverride",
11017
+ suggestion.compatKeys,
11018
+ repairsExistingOverride,
11019
+ backupPath,
11020
+ );
11021
+ if (!receipt) {
11022
+ cmdCtx.ui.notify("❌ Could not create a privacy-safe fix receipt. No changes were made.", "error");
11023
+ return;
11024
+ }
9011
11025
  const result = await applyModelsJsonFixTransaction(
9012
11026
  plan.modifiedText,
9013
11027
  backupPath,
@@ -9017,7 +11031,13 @@ export default function (pi: ExtensionAPI) {
9017
11031
  suggestion.providerLabel,
9018
11032
  suggestion.modelId,
9019
11033
  suggestion.compatKeys,
11034
+ model,
9020
11035
  ),
11036
+ {
11037
+ expectedCurrentHash: hashText(originalText),
11038
+ purpose: "fix",
11039
+ onCommitted: async () => writeModelsJsonFixReceipt(receipt),
11040
+ },
9021
11041
  );
9022
11042
  if ("postCheckError" in result) {
9023
11043
  cmdCtx.ui.notify(
@@ -9027,6 +11047,7 @@ export default function (pi: ExtensionAPI) {
9027
11047
  );
9028
11048
  return;
9029
11049
  }
11050
+ invalidateModelsConfigCache();
9030
11051
  cmdCtx.ui.notify(
9031
11052
  `✅ Fix applied to ${getModelsJsonDisplayPath()}.\n` +
9032
11053
  `Backup saved to: ${backupPath}\n` +
@@ -9081,6 +11102,20 @@ export default function (pi: ExtensionAPI) {
9081
11102
  return;
9082
11103
  }
9083
11104
 
11105
+ const duplicateTargetDefinitions = location.allModelIds.filter(
11106
+ (configuredModelId) => configuredModelId === suggestion.modelId,
11107
+ ).length;
11108
+ if (duplicateTargetDefinitions > 1) {
11109
+ cmdCtx.ui.notify(
11110
+ `❌ ${getModelsJsonDisplayPath()} contains ${duplicateTargetDefinitions} custom model definitions ` +
11111
+ `with the exact id "${suggestion.modelId}" under providers["${suggestion.providerLabel}"].\n` +
11112
+ `Pi uses the last definition, but /cache-optimizer fix refuses an ambiguous duplicate-id edit. ` +
11113
+ `Remove or consolidate the duplicates, then run the command again.`,
11114
+ "error",
11115
+ );
11116
+ return;
11117
+ }
11118
+
9084
11119
  // Compose the modified text — observed runtime failures are always
9085
11120
  // model-scoped; ordinary compat fixes use the safety-based placement.
9086
11121
  const decision = chooseFixPlacement(
@@ -9090,10 +11125,45 @@ export default function (pi: ExtensionAPI) {
9090
11125
  suggestion.providerLabel,
9091
11126
  suggestion.forceModelLevel,
9092
11127
  );
9093
- const modifiedText = composeFixInsertion(originalText, location, suggestion.compatKeys, decision.placement);
11128
+ const createsModelOverride = decision.placement === "modelOverride" && location.modelOverrideObjectBrace < 0;
11129
+ const modelOverridePlan = createsModelOverride
11130
+ ? composeModelOverrideInsertion(
11131
+ originalText,
11132
+ suggestion.providerLabel,
11133
+ suggestion.modelId,
11134
+ suggestion.compatKeys,
11135
+ )
11136
+ : undefined;
11137
+ if (createsModelOverride && !modelOverridePlan) {
11138
+ cmdCtx.ui.notify(
11139
+ "❌ Could not safely create the highest-precedence model override. No changes were made.",
11140
+ "error",
11141
+ );
11142
+ return;
11143
+ }
11144
+ const modifiedText = modelOverridePlan?.modifiedText
11145
+ ?? composeFixInsertion(originalText, location, suggestion.compatKeys, decision.placement);
9094
11146
 
9095
- // Self-check
9096
- const checkError = selfCheckFix(originalText, modifiedText, suggestion.providerLabel, suggestion.modelId, suggestion.compatKeys, decision.placement);
11147
+ // Self-check against the same provider → model → runtime →
11148
+ // modelOverride precedence used by request hooks.
11149
+ const checkError = createsModelOverride
11150
+ ? selfCheckMissingEntryInsertion(
11151
+ originalText,
11152
+ modifiedText,
11153
+ suggestion.providerLabel,
11154
+ suggestion.modelId,
11155
+ suggestion.compatKeys,
11156
+ model,
11157
+ )
11158
+ : selfCheckFix(
11159
+ originalText,
11160
+ modifiedText,
11161
+ suggestion.providerLabel,
11162
+ suggestion.modelId,
11163
+ suggestion.compatKeys,
11164
+ decision.placement,
11165
+ model,
11166
+ );
9097
11167
  if (checkError !== null) {
9098
11168
  cmdCtx.ui.notify(
9099
11169
  `❌ Self-check failed before write: ${checkError}\n` +
@@ -9158,17 +11228,46 @@ export default function (pi: ExtensionAPI) {
9158
11228
 
9159
11229
  // Write: backup → temp + rename → self-check again
9160
11230
  try {
11231
+ const receipt = createModelsJsonFixReceipt(
11232
+ originalText,
11233
+ modifiedText,
11234
+ suggestion.providerLabel,
11235
+ suggestion.modelId,
11236
+ decision.placement,
11237
+ suggestion.compatKeys,
11238
+ !createsModelOverride,
11239
+ backupPath,
11240
+ );
11241
+ if (!receipt) {
11242
+ cmdCtx.ui.notify("❌ Could not create a privacy-safe fix receipt. No changes were made.", "error");
11243
+ return;
11244
+ }
9161
11245
  const result = await applyModelsJsonFixTransaction(
9162
11246
  modifiedText,
9163
11247
  backupPath,
9164
- (writtenText) => selfCheckFix(
9165
- originalText,
9166
- writtenText,
9167
- suggestion.providerLabel,
9168
- suggestion.modelId,
9169
- suggestion.compatKeys,
9170
- decision.placement,
9171
- ),
11248
+ (writtenText) => createsModelOverride
11249
+ ? selfCheckMissingEntryInsertion(
11250
+ originalText,
11251
+ writtenText,
11252
+ suggestion.providerLabel,
11253
+ suggestion.modelId,
11254
+ suggestion.compatKeys,
11255
+ model,
11256
+ )
11257
+ : selfCheckFix(
11258
+ originalText,
11259
+ writtenText,
11260
+ suggestion.providerLabel,
11261
+ suggestion.modelId,
11262
+ suggestion.compatKeys,
11263
+ decision.placement,
11264
+ model,
11265
+ ),
11266
+ {
11267
+ expectedCurrentHash: hashText(originalText),
11268
+ purpose: "fix",
11269
+ onCommitted: async () => writeModelsJsonFixReceipt(receipt),
11270
+ },
9172
11271
  );
9173
11272
  if ("postCheckError" in result) {
9174
11273
  cmdCtx.ui.notify(
@@ -9179,6 +11278,7 @@ export default function (pi: ExtensionAPI) {
9179
11278
  return;
9180
11279
  }
9181
11280
 
11281
+ invalidateModelsConfigCache();
9182
11282
  cmdCtx.ui.notify(
9183
11283
  `✅ Fix applied to ${getModelsJsonDisplayPath()}.\n` +
9184
11284
  `Backup saved to: ${backupPath}\n` +
@@ -9202,6 +11302,7 @@ export default function (pi: ExtensionAPI) {
9202
11302
  "Stats — Show current-session model statistics",
9203
11303
  "Compat — Show compat suggestion",
9204
11304
  "Fix — Auto-fix compat issues (writes models.json)",
11305
+ "Rollback — Undo the latest confirmed fix",
9205
11306
  "Footer mode — Choose total, session, or process stats",
9206
11307
  "Reset — Reset local provider/model stats",
9207
11308
  "Cancel",
@@ -9220,6 +11321,8 @@ export default function (pi: ExtensionAPI) {
9220
11321
  } else if (choice === menuOptions[5]) {
9221
11322
  await handleCacheOptimizerCommand("fix", cmdCtx);
9222
11323
  } else if (choice === menuOptions[6]) {
11324
+ await handleCacheOptimizerCommand("rollback", cmdCtx);
11325
+ } else if (choice === menuOptions[7]) {
9223
11326
  const modeOptions = ["session — Current Pi conversation session (default)", "total — All local sessions today", "process — Current extension instance only", "Cancel"];
9224
11327
  const modeChoice = await cmdCtx.ui.select("Footer cache stats mode", modeOptions);
9225
11328
  const nextMode = modeChoice === modeOptions[0]
@@ -9230,7 +11333,7 @@ export default function (pi: ExtensionAPI) {
9230
11333
  ? "process"
9231
11334
  : undefined;
9232
11335
  if (nextMode) await handleCacheOptimizerCommand(`config footer-mode ${nextMode}`, cmdCtx);
9233
- } else if (choice === menuOptions[7]) {
11336
+ } else if (choice === menuOptions[8]) {
9234
11337
  await handleCacheOptimizerCommand("reset", cmdCtx);
9235
11338
  }
9236
11339
  // choice === "cancel" or undefined → no action
@@ -9249,6 +11352,7 @@ export default function (pi: ExtensionAPI) {
9249
11352
  diagnosis.push(" compat — Show compat suggestion with edit location");
9250
11353
  diagnosis.push(" config footer-mode total|session|process — Persist the footer stats mode");
9251
11354
  diagnosis.push(" fix — Auto-fix compat issues (writes models.json, requires UI)");
11355
+ diagnosis.push(" rollback — Undo the latest confirmed fix (requires UI confirmation)");
9252
11356
  diagnosis.push(" reset — Reset local provider/model stats for current model (does not affect upstream)");
9253
11357
  diagnosis.push("");
9254
11358
  diagnosis.push(formatOptimizerRuntimeMode());