pi-cache-optimizer 2.8.6 → 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 +2218 -186
  4. package/package.json +2 -2
package/index.ts CHANGED
@@ -2,7 +2,7 @@ import { createHash, randomUUID } from "node:crypto";
2
2
  import { chmod, copyFile, lstat, mkdir, readFile, readdir, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
3
3
  import { constants as fsConstants, readFileSync, statSync, watch } from "node:fs";
4
4
  import { homedir } from "node:os";
5
- import { dirname, join } from "node:path";
5
+ import { basename, dirname, join } from "node:path";
6
6
  import {
7
7
  getAgentDir,
8
8
  type BuildSystemPromptOptions,
@@ -80,6 +80,8 @@ const STARTUP_CACHE_RETENTION_ENV = getOrCaptureCacheRetentionBaseline();
80
80
  * 2. Sets PI_CACHE_RETENTION=long at extension load time.
81
81
  * 3. Warns once for provider/model cache compat gaps where the signal is conservative.
82
82
  * 4. Shows lightweight persisted provider-specific cache stats in Pi's footer.
83
+ * 5. Offers disabled-by-default deterministic built-in tool ordering when
84
+ * explicitly opted in.
83
85
  *
84
86
  * Provider prompt/KV caches are provider-side and best-effort. This extension improves
85
87
  * the odds of cache hits; it cannot guarantee hits, especially through proxies.
@@ -112,6 +114,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 {
@@ -1549,6 +1837,10 @@ function isEnabledEnv(value: string | undefined): boolean {
1549
1837
  return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
1550
1838
  }
1551
1839
 
1840
+ function isToolOrderEnabled(env: MutableEnv = process.env): boolean {
1841
+ return runtimeOptimizerEnabled && isEnabledEnv(env[TOOL_ORDER_ENV]);
1842
+ }
1843
+
1552
1844
  function parseFooterStatsMode(value: unknown): FooterStatsMode | undefined {
1553
1845
  if (typeof value !== "string") return undefined;
1554
1846
  const normalized = value.trim().toLowerCase();
@@ -1570,6 +1862,7 @@ const CACHE_OPTIMIZER_COMMANDS = [
1570
1862
  "compat",
1571
1863
  "reset",
1572
1864
  "fix",
1865
+ "rollback",
1573
1866
  ] as const;
1574
1867
  const CACHE_OPTIMIZER_CONFIG_ARGUMENTS = ["footer-mode"] as const;
1575
1868
  const CACHE_OPTIMIZER_FOOTER_MODES = ["total", "session", "process"] as const;
@@ -1722,6 +2015,7 @@ function getOptimizerRuntimeModeLines(): string[] {
1722
2015
  const lines: string[] = [];
1723
2016
  lines.push(`Runtime state: ${state}`);
1724
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"}`);
1725
2019
  lines.push(`• OpenAI prompt_cache_key fallback: ${shouldInjectOpenAIPromptCacheKey() ? "on" : "off"}`);
1726
2020
  lines.push(`• Footer cache stats: on${runtimeOptimizerEnabled ? "" : " (comparison mode)"}`);
1727
2021
  lines.push(`• Compat warnings: ${runtimeOptimizerEnabled ? "on" : "off"}`);
@@ -2558,6 +2852,76 @@ function modelFromAssistantMessage(message: unknown, fallback: PiModel | undefin
2558
2852
  } as PiModel;
2559
2853
  }
2560
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
+
2561
2925
  function keyForModelExt(model: { provider: string; id: string }): string {
2562
2926
  return `${model.provider}/${model.id}`;
2563
2927
  }
@@ -2923,6 +3287,259 @@ function hasPromptCacheRetentionUnsupportedErrorMessage(message: unknown): boole
2923
3287
  hasPromptCacheRetentionUnsupportedText(record.errorMessage);
2924
3288
  }
2925
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
+
2926
3543
  type CompatAdvicePlacement = {
2927
3544
  providerLabel?: string;
2928
3545
  modelId?: string;
@@ -3021,7 +3638,7 @@ function buildOpenAIProxyCompatWarningText(key: string, missing: string[]): stri
3021
3638
 
3022
3639
  const modelsJsonPath = getModelsJsonDisplayPath();
3023
3640
  const lines: string[] = [
3024
- `💡 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 ")}.`,
3025
3642
  `Edit ${modelsJsonPath} -> providers["${providerLabel}"] -> compat (at the same level as baseUrl/api/apiKey/models).`,
3026
3643
  ``,
3027
3644
  ];
@@ -3031,80 +3648,82 @@ function buildOpenAIProxyCompatWarningText(key: string, missing: string[]): stri
3031
3648
  return lines.join("\n");
3032
3649
  }
3033
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
+
3034
3667
  function describeMissingDeepSeekCompat(model: PiModel): string[] {
3668
+ if (!isDeepSeekWireCompatApplicable(model)) return [];
3669
+
3035
3670
  const compat = getCompat(model);
3036
3671
  const missing: string[] = [];
3037
-
3038
- if (compat.supportsLongCacheRetention !== true) {
3039
- missing.push("supportsLongCacheRetention");
3040
- }
3041
- if (model.api !== "openai-responses" && compat.sendSessionAffinityHeaders !== true) {
3042
- missing.push("sendSessionAffinityHeaders");
3043
- }
3044
3672
  if (compat.requiresReasoningContentOnAssistantMessages !== true) {
3045
3673
  missing.push("requiresReasoningContentOnAssistantMessages");
3046
3674
  }
3047
- if (compat.thinkingFormat !== "deepseek") {
3048
- missing.push("thinkingFormat");
3049
- }
3050
-
3051
3675
  return missing;
3052
3676
  }
3053
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
+ */
3054
3685
  function isDeepSeekCompatCheckApplicable(model: PiModel): boolean {
3055
- return isDeepSeekLikeModel(model)
3056
- && isOpenAICompatibleApi(model.api)
3057
- && isKnownThirdPartyOpenAIEndpoint(model)
3058
- && !isPiBuiltInLlamaCppModel(model);
3686
+ return isDeepSeekWireCompatApplicable(model);
3687
+ }
3688
+
3689
+ function hasExplicitDeepSeekReasoningProtocol(model: PiModel): boolean {
3690
+ return isDeepSeekWireCompatApplicable(model);
3059
3691
  }
3060
3692
 
3061
3693
  function describeMissingCacheCompatForModel(model: PiModel): string[] {
3062
3694
  if (isAdaptiveThinkingCompatApplicable(model)) {
3063
3695
  return describeMissingAdaptiveThinkingCompat(model);
3064
3696
  }
3065
- if (isDeepSeekCompatCheckApplicable(model)) {
3066
- return describeMissingDeepSeekCompat(model);
3697
+
3698
+ const missing = describeMissingOpenAICompatibleProxyCompat(model);
3699
+ if (isDeepSeekWireCompatApplicable(model)) {
3700
+ missing.push(...describeMissingDeepSeekCompat(model));
3067
3701
  }
3068
- return describeMissingOpenAICompatibleProxyCompat(model);
3702
+ return missing;
3069
3703
  }
3070
3704
 
3071
3705
  function buildDeepSeekCompatSuggestion(missing: string[]): Record<string, unknown> {
3072
- const suggestion: Record<string, unknown> = {};
3073
-
3074
- if (missing.includes("supportsLongCacheRetention")) {
3075
- suggestion.supportsLongCacheRetention = true;
3076
- }
3077
- if (missing.includes("sendSessionAffinityHeaders")) {
3078
- suggestion.sendSessionAffinityHeaders = true;
3079
- }
3706
+ const suggestion: Record<string, unknown> = {
3707
+ ...buildSafeOpenAIProxyCompatSuggestion(missing),
3708
+ };
3080
3709
  if (missing.includes("requiresReasoningContentOnAssistantMessages")) {
3081
3710
  suggestion.requiresReasoningContentOnAssistantMessages = true;
3082
3711
  }
3083
- if (missing.includes("thinkingFormat")) {
3084
- suggestion.thinkingFormat = "deepseek";
3085
- }
3086
-
3087
3712
  return suggestion;
3088
3713
  }
3089
3714
 
3090
3715
  function appendDeepSeekCompatAdviceLines(lines: string[], missing: string[], placement: CompatAdvicePlacement = {}): void {
3091
3716
  const suggestion = buildDeepSeekCompatSuggestion(missing);
3092
3717
  if (Object.keys(suggestion).length > 0) {
3093
- lines.push("Recommended DeepSeek compat snippet:");
3718
+ lines.push("Recommended DeepSeek reasoning/replay compat snippet:");
3094
3719
  lines.push(JSON.stringify(suggestion, null, 2));
3095
3720
  }
3096
3721
 
3097
3722
  if (missing.includes("requiresReasoningContentOnAssistantMessages")) {
3098
- lines.push('- requiresReasoningContentOnAssistantMessages: true keeps replayed assistant turns compatible with DeepSeek reasoning_content requirements.');
3099
- }
3100
- if (missing.includes("thinkingFormat")) {
3101
- lines.push('- thinkingFormat: "deepseek" tells Pi to use DeepSeek reasoning/thinking parameter format.');
3723
+ lines.push('- requiresReasoningContentOnAssistantMessages: true keeps replayed assistant turns compatible with an explicitly selected DeepSeek reasoning wire format.');
3102
3724
  }
3103
3725
  if (missing.includes("sendSessionAffinityHeaders")) {
3104
- lines.push("- sendSessionAffinityHeaders: recommended for OpenAI-compatible DeepSeek proxies when supported; it helps keep one Pi session on the same upstream/backend.");
3105
- }
3106
- if (missing.includes("supportsLongCacheRetention")) {
3107
- lines.push("- supportsLongCacheRetention: enable for DeepSeek-compatible endpoints that support long cache retention.");
3726
+ lines.push("- sendSessionAffinityHeaders: recommended for third-party OpenAI-compatible proxies when supported; it helps keep one Pi session on the same upstream/backend.");
3108
3727
  }
3109
3728
 
3110
3729
  appendCredentialSafeProviderGuidance(lines, placement, suggestion);
@@ -3139,13 +3758,13 @@ const CACHE_PROVIDER_ADAPTERS: CacheProviderAdapter[] = [
3139
3758
  return normalizeWithFallback(message, getDeepSeekRawUsage, { allowInputOnlyPiUsage: true });
3140
3759
  },
3141
3760
  warningText(model) {
3142
- if (!isDeepSeekCompatCheckApplicable(model)) return undefined;
3143
-
3144
- const missing = describeMissingDeepSeekCompat(model);
3761
+ const missing = describeMissingCacheCompatForModel(model);
3145
3762
  if (missing.length === 0) return undefined;
3146
3763
 
3147
3764
  const key = modelKey(model);
3148
- return buildDeepSeekCompatWarningText(key, missing);
3765
+ return isDeepSeekWireCompatApplicable(model)
3766
+ ? buildDeepSeekCompatWarningText(key, missing)
3767
+ : buildOpenAIProxyCompatWarningText(key, missing);
3149
3768
  },
3150
3769
  },
3151
3770
  {
@@ -4109,6 +4728,10 @@ function notifyCacheCompatIfNeeded(
4109
4728
  ): void {
4110
4729
  if (!model) return;
4111
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
+
4112
4735
  // Native anthropic-messages adaptive thinking compat check.
4113
4736
  // Adapter warningText only fires for OpenAI-compatible APIs, so native
4114
4737
  // Anthropic and Kimi Coding adaptive models need a separate check.
@@ -5498,7 +6121,7 @@ function buildDoctorDiagnosis(model: PiModel, options: { promptCacheRetention400
5498
6121
  const adaptiveThinkingApplicable = isAdaptiveThinkingCompatApplicable(model);
5499
6122
  const deepSeekCompatApplicable = isDeepSeekCompatCheckApplicable(model);
5500
6123
  const missing = describeMissingCacheCompatForModel(model);
5501
- const optionalOpenAIProxyCompat = (!adaptiveThinkingApplicable && !deepSeekCompatApplicable)
6124
+ const optionalOpenAIProxyCompat = !adaptiveThinkingApplicable
5502
6125
  ? describeOptionalOpenAICompatibleProxyCompat(model)
5503
6126
  : [];
5504
6127
  const fixSug = buildFixSuggestion(model);
@@ -5522,6 +6145,7 @@ function buildDoctorDiagnosis(model: PiModel, options: { promptCacheRetention400
5522
6145
  appendAdaptiveThinkingCompatAdviceLines(lines, missing, { providerLabel, modelId: model.id });
5523
6146
  } else if (deepSeekCompatApplicable) {
5524
6147
  appendDeepSeekCompatAdviceLines(lines, missing, { providerLabel, modelId: model.id });
6148
+ appendOptionalOpenAIProxyCompatAdviceLines(lines, optionalOpenAIProxyCompat);
5525
6149
  } else {
5526
6150
  appendOpenAIProxyCompatAdviceLines(lines, missing, { providerLabel, modelId: model.id });
5527
6151
  appendOptionalOpenAIProxyCompatAdviceLines(lines, optionalOpenAIProxyCompat);
@@ -5732,7 +6356,7 @@ function buildCompatDiagnosis(model: PiModel): string | undefined {
5732
6356
  const advisoryMissingC = missing.filter(m => !safeFixableMissingC.includes(m));
5733
6357
  const adaptiveThinkingApplicable = isAdaptiveThinkingCompatApplicable(model);
5734
6358
  const deepSeekCompatApplicable = isDeepSeekCompatCheckApplicable(model);
5735
- const optionalOpenAIProxyCompat = (!adaptiveThinkingApplicable && !deepSeekCompatApplicable)
6359
+ const optionalOpenAIProxyCompat = !adaptiveThinkingApplicable
5736
6360
  ? describeOptionalOpenAICompatibleProxyCompat(model)
5737
6361
  : [];
5738
6362
  const routerNotes = describeRouterChannelDiagnostics(model);
@@ -5760,6 +6384,7 @@ function buildCompatDiagnosis(model: PiModel): string | undefined {
5760
6384
  appendAdaptiveThinkingCompatAdviceLines(lines, missing, { providerLabel, modelId: model.id });
5761
6385
  } else if (deepSeekCompatApplicable) {
5762
6386
  appendDeepSeekCompatAdviceLines(lines, missing, { providerLabel, modelId: model.id });
6387
+ appendOptionalOpenAIProxyCompatAdviceLines(lines, optionalOpenAIProxyCompat);
5763
6388
  } else {
5764
6389
  appendOpenAIProxyCompatAdviceLines(lines, missing, { providerLabel, modelId: model.id });
5765
6390
  appendOptionalOpenAIProxyCompatAdviceLines(lines, optionalOpenAIProxyCompat);
@@ -5912,19 +6537,25 @@ function skipJsonValue(text: string, pos: number): number | undefined {
5912
6537
  /**
5913
6538
  * Find a top-level key in the object whose `{` is at `openBracePos`.
5914
6539
  * Only direct children are considered (nested values are skipped whole).
5915
- * Returns the key's opening-quote offset and its value's start offset,
5916
- * or undefined when the key is absent or the object is malformed.
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.
5917
6545
  */
5918
6546
  function findJsonObjectKey(
5919
6547
  text: string,
5920
6548
  openBracePos: number,
5921
6549
  targetKey: string,
5922
- ): { keyStart: number; valueStart: number } | undefined {
6550
+ ): { keyStart: number; valueStart: number; count: number } | undefined {
5923
6551
  if (text[openBracePos] !== "{") return undefined;
5924
6552
  let i = openBracePos + 1;
6553
+ let found: { keyStart: number; valueStart: number; count: number } | undefined;
6554
+ let count = 0;
5925
6555
  while (i < text.length) {
5926
6556
  i = skipJsonWhitespace(text, i);
5927
- if (i >= text.length || text[i] === "}") return undefined;
6557
+ if (i >= text.length) return undefined;
6558
+ if (text[i] === "}") return found;
5928
6559
  if (text[i] === ",") {
5929
6560
  i++;
5930
6561
  continue;
@@ -5936,7 +6567,10 @@ function findJsonObjectKey(
5936
6567
  i = skipJsonWhitespace(text, key.end);
5937
6568
  if (text[i] !== ":") return undefined;
5938
6569
  i = skipJsonWhitespace(text, i + 1);
5939
- if (key.value === targetKey) return { keyStart, valueStart: i };
6570
+ if (key.value === targetKey) {
6571
+ count++;
6572
+ found = { keyStart, valueStart: i, count };
6573
+ }
5940
6574
  const after = skipJsonValue(text, i);
5941
6575
  if (after === undefined) return undefined;
5942
6576
  i = after;
@@ -6168,6 +6802,8 @@ function hasExplicitLongRetentionOptInFromConfig(
6168
6802
  * Returns the byte offsets for surgical insertion, or undefined if ambiguous.
6169
6803
  */
6170
6804
  interface ModelNodeLocation {
6805
+ /** Number of exact provider keys found in the root providers object. */
6806
+ providerKeyCount: number;
6171
6807
  /** Offset of the model object's opening `{` */
6172
6808
  modelObjectBrace: number;
6173
6809
  /** Offset of the model object's closing `}` */
@@ -6196,11 +6832,21 @@ interface ModelNodeLocation {
6196
6832
  modelOverrideCompatBrace: number;
6197
6833
  /** Offset of the target override compat object's closing `}`, or -1 if absent */
6198
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;
6199
6841
  /** All model ids found in this provider's models array (for placement safety analysis) */
6200
6842
  allModelIds: string[];
6201
6843
  }
6202
6844
 
6203
6845
  interface ModelOverrideNodeLocation {
6846
+ providerKeyCount: number;
6847
+ modelOverridesKeyCount: number;
6848
+ modelOverrideKeyCount: number;
6849
+ modelOverrideCompatKeyCount: number;
6204
6850
  providerObjectBrace: number;
6205
6851
  providerObjectEnd: number;
6206
6852
  modelOverridesObjectBrace: number;
@@ -6221,17 +6867,21 @@ function locateModelOverrideInJsonc(
6221
6867
  const rootBrace = skipJsonWhitespace(clean, 0);
6222
6868
  if (clean[rootBrace] !== "{") return undefined;
6223
6869
  const providersKey = findJsonObjectKey(clean, rootBrace, "providers");
6224
- if (!providersKey) return undefined;
6870
+ if (!providersKey || providersKey.count !== 1) return undefined;
6225
6871
  const providersBrace = skipJsonWhitespace(clean, providersKey.valueStart);
6226
6872
  if (clean[providersBrace] !== "{") return undefined;
6227
6873
  const providersEnd = findMatchingBracket(clean, providersBrace);
6228
6874
  if (providersEnd === undefined) return undefined;
6229
6875
  const providerKey = findJsonObjectKey(clean, providersBrace, providerLabel);
6230
- if (!providerKey || providerKey.keyStart > providersEnd) return undefined;
6876
+ if (!providerKey || providerKey.count !== 1 || providerKey.keyStart > providersEnd) return undefined;
6231
6877
  const providerObjectBrace = skipJsonWhitespace(clean, providerKey.valueStart);
6232
6878
  if (clean[providerObjectBrace] !== "{") return undefined;
6233
6879
  const providerObjectEnd = findMatchingBracket(clean, providerObjectBrace);
6234
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;
6235
6885
 
6236
6886
  let modelOverridesObjectBrace = -1;
6237
6887
  let modelOverridesObjectEnd = -1;
@@ -6239,7 +6889,10 @@ function locateModelOverrideInJsonc(
6239
6889
  let modelOverrideObjectEnd = -1;
6240
6890
  let modelOverrideCompatBrace = -1;
6241
6891
  let modelOverrideCompatEnd = -1;
6892
+ let modelOverrideKeyCount = 0;
6893
+ let modelOverrideCompatKeyCount = 0;
6242
6894
  const overridesKey = findJsonObjectKey(clean, providerObjectBrace, "modelOverrides");
6895
+ if (overridesKey?.count && overridesKey.count > 1) return undefined;
6243
6896
  if (overridesKey && overridesKey.keyStart < providerObjectEnd) {
6244
6897
  const brace = skipJsonWhitespace(clean, overridesKey.valueStart);
6245
6898
  if (clean[brace] !== "{") return undefined;
@@ -6249,6 +6902,8 @@ function locateModelOverrideInJsonc(
6249
6902
  modelOverridesObjectEnd = end;
6250
6903
 
6251
6904
  const overrideKey = findJsonObjectKey(clean, brace, modelId);
6905
+ modelOverrideKeyCount = overrideKey?.count ?? 0;
6906
+ if (overrideKey?.count && overrideKey.count > 1) return undefined;
6252
6907
  if (overrideKey && overrideKey.keyStart < end) {
6253
6908
  const entryBrace = skipJsonWhitespace(clean, overrideKey.valueStart);
6254
6909
  if (clean[entryBrace] !== "{") return undefined;
@@ -6258,6 +6913,8 @@ function locateModelOverrideInJsonc(
6258
6913
  modelOverrideObjectEnd = entryEnd;
6259
6914
 
6260
6915
  const compatKey = findJsonObjectKey(clean, entryBrace, "compat");
6916
+ modelOverrideCompatKeyCount = compatKey?.count ?? 0;
6917
+ if (compatKey?.count && compatKey.count > 1) return undefined;
6261
6918
  if (compatKey && compatKey.keyStart < entryEnd) {
6262
6919
  const compatBrace = skipJsonWhitespace(clean, compatKey.valueStart);
6263
6920
  if (clean[compatBrace] !== "{") return undefined;
@@ -6270,6 +6927,10 @@ function locateModelOverrideInJsonc(
6270
6927
  }
6271
6928
 
6272
6929
  return {
6930
+ providerKeyCount: providerKey.count,
6931
+ modelOverridesKeyCount: overridesKey?.count ?? 0,
6932
+ modelOverrideKeyCount,
6933
+ modelOverrideCompatKeyCount,
6273
6934
  providerObjectBrace,
6274
6935
  providerObjectEnd,
6275
6936
  modelOverridesObjectBrace,
@@ -6304,14 +6965,14 @@ function locateModelInJsonc(
6304
6965
  if (clean[rootBrace] !== "{") return undefined;
6305
6966
 
6306
6967
  const providersKey = findJsonObjectKey(clean, rootBrace, "providers");
6307
- if (!providersKey) return undefined;
6968
+ if (!providersKey || providersKey.count !== 1) return undefined;
6308
6969
  const providersBrace = skipJsonWhitespace(clean, providersKey.valueStart);
6309
6970
  if (clean[providersBrace] !== "{") return undefined;
6310
6971
  const providersEnd = findMatchingBracket(clean, providersBrace);
6311
6972
  if (providersEnd === undefined) return undefined;
6312
6973
 
6313
6974
  const providerKey = findJsonObjectKey(clean, providersBrace, providerLabel);
6314
- if (!providerKey || providerKey.keyStart > providersEnd) return undefined;
6975
+ if (!providerKey || providerKey.count !== 1 || providerKey.keyStart > providersEnd) return undefined;
6315
6976
  const providerBrace = skipJsonWhitespace(clean, providerKey.valueStart);
6316
6977
  if (clean[providerBrace] !== "{") return undefined;
6317
6978
  const providerEndBrace = findMatchingBracket(clean, providerBrace);
@@ -6322,14 +6983,33 @@ function locateModelInJsonc(
6322
6983
  let providerCompatBrace = -1;
6323
6984
  let providerCompatEnd = -1;
6324
6985
  const providerCompatKey = findJsonObjectKey(clean, providerBrace, "compat");
6986
+ const providerCompatKeyCount = providerCompatKey?.count ?? 0;
6987
+ if (providerCompatKey?.count && providerCompatKey.count > 1) return undefined;
6325
6988
  if (providerCompatKey && providerCompatKey.keyStart < providerEndBrace) {
6326
6989
  const brace = skipJsonWhitespace(clean, providerCompatKey.valueStart);
6327
- if (clean[brace] === "{") {
6328
- const end = findMatchingBracket(clean, brace);
6329
- if (end !== undefined && end <= providerEndBrace) {
6330
- providerCompatBrace = brace;
6331
- providerCompatEnd = end;
6332
- }
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;
6333
7013
  }
6334
7014
  }
6335
7015
 
@@ -6340,7 +7020,7 @@ function locateModelInJsonc(
6340
7020
  const modelOverrideCompatEnd = overrideLocation?.modelOverrideCompatEnd ?? -1;
6341
7021
 
6342
7022
  const modelsKey = findJsonObjectKey(clean, providerBrace, "models");
6343
- if (!modelsKey || modelsKey.keyStart > providerEndBrace) return undefined;
7023
+ if (!modelsKey || modelsKey.count !== 1 || modelsKey.keyStart > providerEndBrace) return undefined;
6344
7024
 
6345
7025
  let modelsScan = skipJsonWhitespace(clean, modelsKey.valueStart);
6346
7026
  if (clean[modelsScan] !== "[") return undefined;
@@ -6355,6 +7035,7 @@ function locateModelInJsonc(
6355
7035
  let compatKeyStartClean = -1;
6356
7036
  let compatBrace = -1;
6357
7037
  let compatEndBrace = -1;
7038
+ let modelCompatKeyCount = 0;
6358
7039
 
6359
7040
  while (modelsScan < modelsEnd) {
6360
7041
  modelsScan = skipJsonWhitespace(clean, modelsScan);
@@ -6370,6 +7051,7 @@ function locateModelInJsonc(
6370
7051
  if (elementEnd === undefined || elementEnd > modelsEnd) return undefined;
6371
7052
 
6372
7053
  const idKey = findJsonObjectKey(clean, elementBrace, "id");
7054
+ if (idKey?.count && idKey.count > 1) return undefined;
6373
7055
  let elementId: string | undefined;
6374
7056
  if (idKey && idKey.keyStart < elementEnd) {
6375
7057
  const idValueStart = skipJsonWhitespace(clean, idKey.valueStart);
@@ -6393,16 +7075,16 @@ function locateModelInJsonc(
6393
7075
  compatEndBrace = -1;
6394
7076
 
6395
7077
  const compatKey = findJsonObjectKey(clean, modelBrace, "compat");
7078
+ modelCompatKeyCount = compatKey?.count ?? 0;
7079
+ if (compatKey?.count && compatKey.count > 1) return undefined;
6396
7080
  if (compatKey && compatKey.keyStart < modelEndBrace) {
6397
7081
  compatKeyStartClean = compatKey.keyStart;
6398
7082
  const brace = skipJsonWhitespace(clean, compatKey.valueStart);
6399
- if (clean[brace] === "{") {
6400
- const end = findMatchingBracket(clean, brace);
6401
- if (end !== undefined && end <= modelEndBrace) {
6402
- compatBrace = brace;
6403
- compatEndBrace = end;
6404
- }
6405
- }
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;
6406
7088
  }
6407
7089
  }
6408
7090
 
@@ -6435,6 +7117,10 @@ function locateModelInJsonc(
6435
7117
  modelOverrideObjectEnd,
6436
7118
  modelOverrideCompatBrace,
6437
7119
  modelOverrideCompatEnd,
7120
+ providerKeyCount: providerKey.count,
7121
+ modelOverrideKeyCount: overrideLocation?.modelOverrideKeyCount ?? 0,
7122
+ modelCompatKeyCount,
7123
+ providerCompatKeyCount,
6438
7124
  allModelIds,
6439
7125
  };
6440
7126
  }
@@ -6464,8 +7150,9 @@ function analyzeModelsJsonForMissingEntry(
6464
7150
  if (clean[rootBrace] !== "{") return undefined;
6465
7151
 
6466
7152
  const providersKey = findJsonObjectKey(clean, rootBrace, "providers");
6467
- if (!providersKey) {
6468
- // 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.
6469
7156
  return undefined;
6470
7157
  }
6471
7158
  const providersBrace = skipJsonWhitespace(clean, providersKey.valueStart);
@@ -6474,9 +7161,10 @@ function analyzeModelsJsonForMissingEntry(
6474
7161
  if (providersEnd === undefined) return undefined;
6475
7162
 
6476
7163
  const providerKey = findJsonObjectKey(clean, providersBrace, providerLabel);
6477
- if (!providerKey || providerKey.keyStart > providersEnd) {
7164
+ if (!providerKey) {
6478
7165
  return { scenario: "provider_missing", providersBrace, providersEnd };
6479
7166
  }
7167
+ if (providerKey.count !== 1 || providerKey.keyStart > providersEnd) return undefined;
6480
7168
 
6481
7169
  // Provider exists. Check for a models array so we know where to append.
6482
7170
  const providerBrace = skipJsonWhitespace(clean, providerKey.valueStart);
@@ -6485,14 +7173,42 @@ function analyzeModelsJsonForMissingEntry(
6485
7173
  if (providerEndBrace === undefined || providerEndBrace > providersEnd) return undefined;
6486
7174
 
6487
7175
  const modelsKey = findJsonObjectKey(clean, providerBrace, "models");
7176
+ if (modelsKey?.count && modelsKey.count > 1) return undefined;
6488
7177
  if (modelsKey && modelsKey.keyStart < providerEndBrace) {
6489
- let mScan = skipJsonWhitespace(clean, modelsKey.valueStart);
6490
- if (clean[mScan] === "[") {
6491
- const modelsEnd = findMatchingBracket(clean, mScan);
6492
- if (modelsEnd !== undefined && modelsEnd <= providerEndBrace) {
6493
- return { scenario: "model_missing", modelsEnd, providerBrace, providerEndBrace };
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;
6494
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;
6495
7209
  }
7210
+ if (targetFound) return undefined;
7211
+ return { scenario: "model_missing", modelsEnd, providerBrace, providerEndBrace };
6496
7212
  }
6497
7213
 
6498
7214
  // Provider exists, but there's no discoverable models array — treat as
@@ -6514,7 +7230,7 @@ function formatMissingEntryManualSnippet(
6514
7230
  const lines: string[] = [];
6515
7231
  const sorted = Object.entries(compatKeys).sort(([a], [b]) => a.localeCompare(b));
6516
7232
  const compatItems = sorted.map(([k, v]) => ` ${JSON.stringify(k)}: ${JSON.stringify(v)}`);
6517
- lines.push(`"${providerLabel}": {`);
7233
+ lines.push(`${JSON.stringify(providerLabel)}: {`);
6518
7234
  lines.push(` "modelOverrides": {`);
6519
7235
  lines.push(` ${JSON.stringify(modelId)}: {`);
6520
7236
  lines.push(` "compat": {`);
@@ -6541,6 +7257,7 @@ function composeModelOverrideInsertion(
6541
7257
 
6542
7258
  if (location?.modelOverrideObjectBrace !== undefined && location.modelOverrideObjectBrace >= 0) {
6543
7259
  const modelLocation: ModelNodeLocation = {
7260
+ providerKeyCount: location.providerKeyCount,
6544
7261
  modelObjectBrace: -1,
6545
7262
  modelObjectEnd: -1,
6546
7263
  compatKeyStart: -1,
@@ -6555,6 +7272,9 @@ function composeModelOverrideInsertion(
6555
7272
  modelOverrideObjectEnd: location.modelOverrideObjectEnd,
6556
7273
  modelOverrideCompatBrace: location.modelOverrideCompatBrace,
6557
7274
  modelOverrideCompatEnd: location.modelOverrideCompatEnd,
7275
+ modelOverrideKeyCount: location.modelOverrideKeyCount,
7276
+ modelCompatKeyCount: 0,
7277
+ providerCompatKeyCount: 0,
6558
7278
  allModelIds: [],
6559
7279
  };
6560
7280
  return {
@@ -6794,7 +7514,8 @@ function composeMissingEntryInsertion(
6794
7514
  * Lightweight self-check for a newly inserted model or modelOverrides entry.
6795
7515
  * Parses the modified text as JSONC and confirms:
6796
7516
  * 1. The target exists under models[] or modelOverrides.
6797
- * 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.
6798
7519
  * Returns null on success, an error string on failure.
6799
7520
  */
6800
7521
  function selfCheckMissingEntryInsertion(
@@ -6803,6 +7524,7 @@ function selfCheckMissingEntryInsertion(
6803
7524
  providerLabel: string,
6804
7525
  modelId: string,
6805
7526
  compatKeys: Record<string, unknown>,
7527
+ runtimeModel?: PiModel,
6806
7528
  ): string | null {
6807
7529
  try {
6808
7530
  const origParsed = parseJsonc(originalText);
@@ -6820,7 +7542,19 @@ function selfCheckMissingEntryInsertion(
6820
7542
  return `Modified file: model or modelOverrides entry "${modelId}" not found in provider after insertion`;
6821
7543
  }
6822
7544
 
7545
+ const effectiveCompat = runtimeModel
7546
+ ? resolveEffectiveCompatFromConfig(runtimeModel, modParsed)
7547
+ : undefined;
6823
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
+ }
6824
7558
  const effective = resolveExplicitCompatValue(modParsed, providerLabel, modelId, k);
6825
7559
  if (!effective) return `Modified file: effective compat.${k} not found`;
6826
7560
  if (effective.value !== v) {
@@ -7002,8 +7736,10 @@ function decideFixPlacement(
7002
7736
  continue;
7003
7737
  }
7004
7738
  if (key === "thinkingFormat" || key === "requiresReasoningContentOnAssistantMessages") {
7005
- const allDeepSeek = siblings.every((id) => isDeepSeekLikeModel(syntheticModelForId(providerLabel, id)));
7006
- 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);
7007
7743
  continue;
7008
7744
  }
7009
7745
  // Unknown model-behavior key — be conservative, keep it model-scoped.
@@ -7052,8 +7788,8 @@ function chooseFixPlacement(
7052
7788
 
7053
7789
  if (forceModelLevel) {
7054
7790
  return {
7055
- placement: "model",
7056
- 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",
7057
7793
  };
7058
7794
  }
7059
7795
 
@@ -7203,6 +7939,7 @@ function selfCheckFix(
7203
7939
  modelId: string,
7204
7940
  compatKeys: Record<string, unknown>,
7205
7941
  placement: "provider" | "model" | "modelOverride" = "model",
7942
+ runtimeModel?: PiModel,
7206
7943
  ): string | null {
7207
7944
  try {
7208
7945
  // Step 1: Parse both versions as JSONC (comments + trailing commas allowed).
@@ -7247,8 +7984,9 @@ function selfCheckFix(
7247
7984
  }
7248
7985
 
7249
7986
  // Step 5: Compute the EFFECTIVE merged compat using Pi's precedence:
7250
- // provider, custom model, then modelOverrides. The fix may have written any
7251
- // level, so validation must check what Pi will actually use.
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.
7252
7990
  const provCompatRaw = (provider as Record<string, unknown>).compat;
7253
7991
  const provCompat = (provCompatRaw && typeof provCompatRaw === 'object' && !Array.isArray(provCompatRaw))
7254
7992
  ? provCompatRaw as Record<string, unknown>
@@ -7264,7 +8002,9 @@ function selfCheckFix(
7264
8002
  return `Modified file: modelOverrides["${modelId}"].compat is not an object`;
7265
8003
  }
7266
8004
  const overrideCompat = asRecord(overrideCompatRaw) ?? {};
7267
- 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 };
7268
8008
 
7269
8009
  // Step 6: Validate all inserted keys are effective in the merged compat
7270
8010
  for (const [k, v] of Object.entries(compatKeys)) {
@@ -7276,62 +8016,97 @@ function selfCheckFix(
7276
8016
  }
7277
8017
  }
7278
8018
 
7279
- // Step 7: Validate original structure is preserved (no accidental deletions/changes)
7280
-
7281
- function isSubset(origVal: unknown, modVal: unknown, path = ''): boolean {
7282
- if (origVal === modVal) return true;
7283
- if (typeof origVal !== typeof modVal) return false;
7284
- if (typeof origVal !== 'object' || origVal === null || modVal === null) return false;
7285
- if (Array.isArray(origVal) !== Array.isArray(modVal)) return false;
7286
- if (Array.isArray(origVal) && Array.isArray(modVal)) {
7287
- if (origVal.length !== modVal.length) return false;
7288
- return origVal.every((_, i) => isSubset(origVal[i], modVal[i], `${path}[${i}]`));
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;
7289
8042
  }
7290
- // Both objects: check that every key in orig is in mod with same value
7291
- const origObj = origVal as Record<string, unknown>;
7292
- const modObj = modVal as Record<string, unknown>;
7293
- for (const key of Object.keys(origObj)) {
7294
- if (!(key in modObj)) return false;
7295
- if (key === 'compat') {
7296
- // For compat, allow extra keys in modified (the inserted ones).
7297
- // Use recursive isSubset so nested objects (e.g. { deep: true })
7298
- // are compared by content, not reference.
7299
- if (typeof origObj[key] !== 'object' || typeof modObj[key] !== 'object') {
7300
- if (origObj[key] !== modObj[key]) return false;
7301
- } else {
7302
- const origCompat = origObj[key] as Record<string, unknown>;
7303
- const modCompat = modObj[key] as Record<string, unknown>;
7304
- // Only the compat object at the level ACTUALLY edited may have
7305
- // its values repaired by this fix. The un-edited level must
7306
- // remain byte/structure-equivalent, so its same-name keys stay
7307
- // under full validation. Using a disjunction OR (provider ||
7308
- // target) here would silently skip validation at the un-edited
7309
- // level, masking corruption (e.g. a buggy editor accidentally
7310
- // breaking provider.compat.sendSessionAffinityHeaders while
7311
- // the fix was a model-level repair). Track placement — only
7312
- // the placement-resolved object's own compat may be exempt.
7313
- const mayRepairThisCompat =
7314
- (placement === "provider" && origObj === origProvider) ||
7315
- (placement === "model" && origObj === origTargetModelRecord) ||
7316
- (placement === "modelOverride" && origObj === asRecord(asRecord(origProvider.modelOverrides)?.[modelId]));
7317
- for (const ck of Object.keys(origCompat)) {
7318
- if (!(ck in modCompat)) return false;
7319
- // The fix may repair an existing wrong compat value (for example
7320
- // thinkingFormat: "legacy" -> "deepseek"), but only on the
7321
- // target provider/model compat objects. Sibling compat blocks must
7322
- // remain structure-equivalent.
7323
- if (mayRepairThisCompat && Object.prototype.hasOwnProperty.call(compatKeys, ck)) continue;
7324
- if (!isSubset(origCompat[ck], modCompat[ck], `${path}.${ck}`)) return false;
7325
- }
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;
7326
8048
  }
7327
- } 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])) {
7328
8053
  return false;
7329
8054
  }
7330
8055
  }
7331
8056
  return true;
7332
8057
  }
7333
8058
 
7334
- 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)) {
7335
8110
  return "Modified file: original structure was altered (data loss detected)";
7336
8111
  }
7337
8112
 
@@ -7395,16 +8170,78 @@ function uniqueTempPath(targetPath: string, purpose: string): string {
7395
8170
  return `${targetPath}.${process.pid}.${Date.now()}.${backupSequence++}.${purpose}.tmp`;
7396
8171
  }
7397
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
+
7398
8214
  async function atomicReplaceTextFilePreservingMode(
7399
8215
  targetPath: string,
7400
8216
  content: string,
7401
8217
  mode: number,
7402
8218
  purpose: string,
8219
+ guard?: AtomicTargetGuard,
8220
+ /** Test-only race injector; production callers leave this undefined. */
8221
+ beforeRename?: () => Promise<void>,
7403
8222
  ): Promise<void> {
8223
+ const initialTargetInfo = await validateAtomicTarget(targetPath, guard);
7404
8224
  const tempPath = uniqueTempPath(targetPath, purpose);
7405
8225
  try {
7406
- 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
+ }
7407
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
+ });
7408
8245
  await rename(tempPath, targetPath);
7409
8246
  } catch (error) {
7410
8247
  try {
@@ -7422,11 +8259,42 @@ async function atomicRestoreFileFromBackup(
7422
8259
  backupPath: string,
7423
8260
  targetPath: string,
7424
8261
  mode: number,
8262
+ guard?: AtomicTargetGuard & { backupHash?: string },
8263
+ /** Test-only race injector; production callers leave this undefined. */
8264
+ beforeRename?: () => Promise<void>,
7425
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);
7426
8277
  const tempPath = uniqueTempPath(targetPath, "restore");
7427
8278
  try {
7428
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
+ }
7429
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
+ });
7430
8298
  await rename(tempPath, targetPath);
7431
8299
  } catch (error) {
7432
8300
  try {
@@ -7444,43 +8312,285 @@ type ModelsJsonFixTransactionResult =
7444
8312
  | { ok: true }
7445
8313
  | { ok: false; postCheckError: string };
7446
8314
 
7447
- 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(
7448
8439
  modifiedText: string,
7449
8440
  backupPath: string,
7450
8441
  validateWrittenText: (writtenText: string) => string | null,
8442
+ options: ModelsJsonFixTransactionOptions = {},
7451
8443
  ): Promise<ModelsJsonFixTransactionResult> {
7452
- 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();
7453
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
+ }
7454
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();
7455
8504
 
8505
+ const expectedModifiedHash = hashText(modifiedText);
7456
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
+
7457
8537
  try {
8538
+ await assertCurrentTargetUnchanged();
7458
8539
  await atomicReplaceTextFilePreservingMode(
7459
8540
  MODELS_JSON_PATH,
7460
8541
  modifiedText,
7461
8542
  originalMode,
7462
- "fix",
8543
+ options.purpose ?? "fix",
8544
+ {
8545
+ identity: targetInfo,
8546
+ hash: expectedCurrentHash,
8547
+ mode: originalMode,
8548
+ },
7463
8549
  );
7464
8550
  targetReplaced = true;
7465
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
+ }
7466
8556
  const writtenText = await readFile(MODELS_JSON_PATH, "utf8");
7467
8557
  const postCheckError = validateWrittenText(writtenText);
7468
- const writtenMode = (await stat(MODELS_JSON_PATH)).mode & 0o7777;
7469
- const effectiveError = postCheckError ?? (
7470
- writtenMode === originalMode
7471
- ? null
7472
- : `models.json access mode changed from ${originalMode.toString(8)} to ${writtenMode.toString(8)}`
7473
- );
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;
7474
8566
  if (effectiveError !== null) {
7475
- await atomicRestoreFileFromBackup(backupPath, MODELS_JSON_PATH, originalMode);
8567
+ await restoreTargetIfUnchanged();
8568
+ targetReplaced = false;
7476
8569
  return { ok: false, postCheckError: effectiveError };
7477
8570
  }
7478
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);
7479
8588
  return { ok: true };
7480
8589
  } catch (error) {
7481
8590
  if (targetReplaced) {
7482
8591
  try {
7483
- await atomicRestoreFileFromBackup(backupPath, MODELS_JSON_PATH, originalMode);
8592
+ await restoreTargetIfUnchanged();
8593
+ targetReplaced = false;
7484
8594
  } catch (restoreError) {
7485
8595
  throw new AggregateError(
7486
8596
  [error, restoreError],
@@ -7492,6 +8602,630 @@ async function applyModelsJsonFixTransaction(
7492
8602
  }
7493
8603
  }
7494
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
+
7495
9229
  // Internal helpers exported only so the task verification script
7496
9230
  // (.trellis/tasks/.../verify.ts) can exercise them. They are not part of the
7497
9231
  // extension's public API; pi only invokes the default export below.
@@ -7531,7 +9265,9 @@ export const __internals_for_tests = {
7531
9265
  describeMissingOpenAICompatibleProxyCompat,
7532
9266
  describeOptionalOpenAICompatibleProxyCompat,
7533
9267
  describeMissingDeepSeekCompat,
9268
+ isDeepSeekWireCompatApplicable,
7534
9269
  isDeepSeekCompatCheckApplicable,
9270
+ hasExplicitDeepSeekReasoningProtocol,
7535
9271
  describeMissingCacheCompatForModel,
7536
9272
  buildDeepSeekCompatSuggestion,
7537
9273
  buildDeepSeekCompatWarningText,
@@ -7545,6 +9281,12 @@ export const __internals_for_tests = {
7545
9281
  isOpenAISdkHeader403Applicable,
7546
9282
  hasPromptCacheRetentionUnsupportedSignal,
7547
9283
  hasPromptCacheRetentionUnsupportedErrorMessage,
9284
+ hasReasoningProtocolRejectionText,
9285
+ hasReasoningProtocolRejectionSignal,
9286
+ hasReasoningProtocolRejectionErrorMessage,
9287
+ isReasoningProtocolRejectionSignalApplicable,
9288
+ isReasoningProtocolRejectionForModel,
9289
+ buildReasoningProtocolFixSuggestion,
7548
9290
  // Non-GPT OpenAI-compatible model detection
7549
9291
  isKimiLikeModel,
7550
9292
  isKimiLikeAssistantMessage,
@@ -7666,6 +9408,12 @@ export const __internals_for_tests = {
7666
9408
  isValidModelsConfigForEffectiveCompat,
7667
9409
  addEffectiveSessionAffinityHeaders,
7668
9410
  getCompat,
9411
+ isKnownToolOrderApi,
9412
+ getToolNameForPayload,
9413
+ compareToolOrderEntries,
9414
+ isVerifiedToolForApi,
9415
+ normalizeToolsInPayload,
9416
+ sortToolsInPayload,
7669
9417
  modelKey,
7670
9418
  modelFromAssistantMessage,
7671
9419
  consolidateDirectProviderStatsModel,
@@ -7683,6 +9431,8 @@ export const __internals_for_tests = {
7683
9431
  formatOptimizerRuntimeMode,
7684
9432
  PI_CACHE_RETENTION_ENV,
7685
9433
  LONG_CACHE_RETENTION_VALUE,
9434
+ TOOL_ORDER_ENV,
9435
+ isToolOrderEnabled,
7686
9436
  // Integrity diagnostics
7687
9437
  getLastPromptIntegrityWarningAt,
7688
9438
  // Diagnostic command helpers
@@ -7734,6 +9484,7 @@ export const __internals_for_tests = {
7734
9484
  // Routing-provider protocol helpers
7735
9485
  PI_ROUTING_REGISTRY_SYMBOL,
7736
9486
  PI_CACHE_HINTS_SYMBOL,
9487
+ REASONING_PROTOCOL_FALLBACK_SYMBOL,
7737
9488
  ensureRoutingRegistry,
7738
9489
  getRoutingRegistry,
7739
9490
  parseRouteSnapshot,
@@ -7771,6 +9522,8 @@ export const __internals_for_tests = {
7771
9522
  STATE_FILE_PATH,
7772
9523
  LEGACY_STATE_FILE_PATH,
7773
9524
  SHARD_STATE_DIR,
9525
+ FIX_RECEIPT_FILE_NAME,
9526
+ FIX_RECEIPT_PATH,
7774
9527
  SHARD_FILES_DIR,
7775
9528
  SHARD_GLOBAL_EPOCH_PATH,
7776
9529
  STATE_DIR,
@@ -7798,6 +9551,20 @@ export const __internals_for_tests = {
7798
9551
  atomicReplaceTextFilePreservingMode,
7799
9552
  atomicRestoreFileFromBackup,
7800
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,
7801
9568
  // Fix suggestion builder
7802
9569
  buildFixSuggestion,
7803
9570
  // Adaptive thinking compat helpers
@@ -7822,6 +9589,9 @@ export default function (pi: ExtensionAPI) {
7822
9589
  const warnedSendSessionAffinityHeaders403Models = new Set<string>();
7823
9590
  const openAISdkHeader403Models = new Set<string>();
7824
9591
  const warnedOpenAISdkHeader403Models = new Set<string>();
9592
+ const reasoningProtocolFallbackState = getReasoningProtocolFallbackState();
9593
+ const reasoningProtocolRejectedModels = reasoningProtocolFallbackState.modelKeys;
9594
+ const warnedReasoningProtocolRejectedModels = reasoningProtocolFallbackState.warnedModelKeys;
7825
9595
  let cacheStatsByModel: Record<string, CacheStats> = {};
7826
9596
  let cacheStatsProcessByModel: Record<string, CacheStats> = {};
7827
9597
  let cacheStatsTotalsByModel: Record<string, CacheStats> = {};
@@ -7845,6 +9615,7 @@ export default function (pi: ExtensionAPI) {
7845
9615
  let currentSessionHashSet = false;
7846
9616
  let lastActualRoutedModel: PersistedRoutedModelRef | undefined;
7847
9617
  let latestCacheHint: PiCacheHintSnapshot | undefined;
9618
+ let pendingProviderResponseModel: PiModel | undefined;
7848
9619
  let shardCreatedAt = Date.now();
7849
9620
  const PERSIST_DEBOUNCE_MS = 2000;
7850
9621
  const SHARD_REFRESH_DEBOUNCE_MS = 250;
@@ -7873,7 +9644,14 @@ export default function (pi: ExtensionAPI) {
7873
9644
 
7874
9645
  function buildCommandFixSuggestion(model: PiModel): FixSuggestion | undefined {
7875
9646
  const regular = buildFixSuggestion(model);
7876
- 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
+ );
7877
9655
  if (!regular) return observed;
7878
9656
  if (!observed) return regular;
7879
9657
  return {
@@ -8380,6 +10158,7 @@ export default function (pi: ExtensionAPI) {
8380
10158
  shardWatcher?.close();
8381
10159
  shardWatcher = undefined;
8382
10160
  latestCacheHint = undefined;
10161
+ pendingProviderResponseModel = undefined;
8383
10162
  delete getProtocolGlobal().__piCacheOptimizerCacheKey__;
8384
10163
  uninstallCacheHintsService();
8385
10164
  restoreCacheRetentionEnv(STARTUP_CACHE_RETENTION_ENV);
@@ -8440,9 +10219,7 @@ export default function (pi: ExtensionAPI) {
8440
10219
  // prompt mutations below (session-overview churn strip, skill compression,
8441
10220
  // and stable-prefix reordering). Footer stats and the OpenAI
8442
10221
  // prompt_cache_key fallback remain active.
8443
- if (isEnabledEnv(process.env[NO_PROMPT_REWRITE_ENV])) {
8444
- return {};
8445
- }
10222
+ if (isEnabledEnv(process.env[NO_PROMPT_REWRITE_ENV])) return {};
8446
10223
 
8447
10224
  // Step 1: strip per-turn churn from <session-overview>.
8448
10225
  // Removing RECENT COMMITS, Working directory status, and
@@ -8523,6 +10300,17 @@ export default function (pi: ExtensionAPI) {
8523
10300
 
8524
10301
  pi.on("before_provider_request", (event, ctx) => {
8525
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
+ }
8526
10314
 
8527
10315
  // Anthropic rejects mixed cache breakpoints when a 1h block appears after
8528
10316
  // a 5m/default block in wire order (tools → system → messages). Repair any
@@ -8530,9 +10318,9 @@ export default function (pi: ExtensionAPI) {
8530
10318
  // hidden short breakpoints after this hook; only models that have actually
8531
10319
  // returned the explicit TTL-order error receive the process-local 5m fallback.
8532
10320
  if (requestModel && isAnthropicMessagesApi(requestModel.api)) {
8533
- const visibleConflictFixed = normalizeAnthropicCacheControlTtlOrder(event.payload);
10321
+ const visibleConflictFixed = normalizeAnthropicCacheControlTtlOrder(requestPayload);
8534
10322
  if (!visibleConflictFixed && anthropicTtlOrderErrorModels.has(modelKey(requestModel))) {
8535
- downgradeAnthropicLongCacheControls(event.payload);
10323
+ downgradeAnthropicLongCacheControls(requestPayload);
8536
10324
  }
8537
10325
  }
8538
10326
 
@@ -8550,33 +10338,59 @@ export default function (pi: ExtensionAPI) {
8550
10338
  // Gate 2 before Gate 3 is critical: if a user explicitly opted in but
8551
10339
  // the API returned 400, we must strip — otherwise the 400 repeats forever.
8552
10340
  if (runtimeOptimizerEnabled) {
8553
- const payload = event.payload as UnknownRecord;
8554
- if (payload && typeof payload.prompt_cache_retention === 'string') {
10341
+ const payloadRecord = asRecord(requestPayload);
10342
+ if (payloadRecord && typeof payloadRecord.prompt_cache_retention === "string") {
8555
10343
  if (requestModel) {
8556
10344
  if (isOfficialOpenAIBaseUrl(requestModel)) {
8557
10345
  // Gate 1: Official OpenAI → keep
8558
10346
  } else if (promptCacheRetention400Models.has(modelKey(requestModel))) {
8559
10347
  // Gate 2: 400 history → strip (overrides user opt-in)
8560
- delete payload.prompt_cache_retention;
10348
+ delete payloadRecord.prompt_cache_retention;
8561
10349
  } else if (hasExplicitLongRetentionOptIn(requestModel)) {
8562
10350
  // Gate 3: Explicit user opt-in → keep
8563
10351
  } else {
8564
10352
  // Gate 4: Safe default → strip
8565
- delete payload.prompt_cache_retention;
10353
+ delete payloadRecord.prompt_cache_retention;
8566
10354
  }
8567
10355
  }
8568
10356
  }
8569
10357
  }
8570
10358
 
8571
- if (!shouldInjectOpenAIPromptCacheKey()) return undefined;
8572
- if (!shouldInjectOpenAIPromptCacheKeyForModel(requestModel)) return undefined;
10359
+ if (!shouldInjectOpenAIPromptCacheKey() || !shouldInjectOpenAIPromptCacheKeyForModel(requestModel)) {
10360
+ return toolOrderChanged ? requestPayload : undefined;
10361
+ }
8573
10362
 
8574
- return addOpenAIPromptCacheKey(event.payload, getSessionPromptCacheKey(ctx));
10363
+ const withCacheKey = addOpenAIPromptCacheKey(requestPayload, getSessionPromptCacheKey(ctx));
10364
+ return withCacheKey ?? (toolOrderChanged ? requestPayload : undefined);
8575
10365
  });
8576
10366
 
8577
- pi.on("after_provider_response", (event, ctx) => {
8578
- const model = resolveRouteModel(ctx.model, ctx) ?? ctx.model;
8579
- 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
+ }
8580
10394
 
8581
10395
  // ── 400: prompt_cache_retention unsupported ──
8582
10396
  if (event.status === 400) {
@@ -8638,13 +10452,39 @@ export default function (pi: ExtensionAPI) {
8638
10452
  pi.on("message_end", async (event, ctx) => {
8639
10453
  syncSessionHash(ctx);
8640
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
+ }
8641
10464
 
8642
10465
  // Some providers expose an HTTP 400 error body only through the finalized
8643
10466
  // assistant error message; after_provider_response may contain the status
8644
- // and no diagnostic response headers. Record the same model-scoped
8645
- // prompt_cache_retention fallback from that authoritative message identity.
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
+ }
8646
10485
  if (runtimeOptimizerEnabled && hasPromptCacheRetentionUnsupportedErrorMessage(event.message)) {
8647
- const fallbackModel = resolveRouteModel(ctx.model, ctx) ?? ctx.model;
10486
+ const fallbackModel = requestModelForMessage
10487
+ ?? (resolveRouteModel(ctx.model, ctx) ?? ctx.model);
8648
10488
  const messageModel = modelFromAssistantMessage(event.message, fallbackModel) ?? fallbackModel;
8649
10489
  const errorModel = messageModel
8650
10490
  ? findModelInRegistry(ctx.modelRegistry, messageModel.provider, messageModel.id) ?? messageModel
@@ -8668,7 +10508,8 @@ export default function (pi: ExtensionAPI) {
8668
10508
  // non-retryable 400 in Pi 0.82.1, so the fallback applies to the next
8669
10509
  // subsequent request (and to a retry only if another layer initiates one).
8670
10510
  if (hasAnthropicCacheTtlOrderError(event.message)) {
8671
- const fallbackModel = resolveRouteModel(ctx.model, ctx) ?? ctx.model;
10511
+ const fallbackModel = requestModelForMessage
10512
+ ?? (resolveRouteModel(ctx.model, ctx) ?? ctx.model);
8672
10513
  const errorModel = modelFromAssistantMessage(event.message, fallbackModel) ?? fallbackModel;
8673
10514
  if (errorModel && isAnthropicMessagesApi(errorModel.api)) {
8674
10515
  const key = modelKey(errorModel);
@@ -8790,6 +10631,7 @@ export default function (pi: ExtensionAPI) {
8790
10631
  // compat — show compat suggestion with file path
8791
10632
  // config footer-mode total|session|process — persist footer mode override
8792
10633
  // fix — auto-fix compat issues (writes models.json, requires UI)
10634
+ // rollback — undo the latest confirmed fix for the active model
8793
10635
  // reset — reset current provider/model footer stats bucket (local only)
8794
10636
  // (no args) — interactive menu (with UI) or help summary
8795
10637
  // ────────────────────────────────────────────────────────────────
@@ -8811,6 +10653,7 @@ export default function (pi: ExtensionAPI) {
8811
10653
  cmdCtx.ui.notify(`✅ Pi Cache Optimizer enabled for this Pi process. Local footer stats were reset for before/after comparison.\n${formatOptimizerRuntimeMode()}`, "info");
8812
10654
  } else if (subcommand === "disable") {
8813
10655
  setRuntimeOptimizerEnabled(false);
10656
+ pendingProviderResponseModel = undefined;
8814
10657
  await resetCurrentSessionStats();
8815
10658
  await flushPersistCacheStats(cmdCtx);
8816
10659
  await publishStatus(cmdCtx, model);
@@ -8890,6 +10733,105 @@ export default function (pi: ExtensionAPI) {
8890
10733
  "info",
8891
10734
  );
8892
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
+ }
8893
10835
  } else if (subcommand === "reset") {
8894
10836
  if (!model) {
8895
10837
  cmdCtx.ui.notify("No active model selected. Select a model first with /model or pi --model.", "warning");
@@ -9021,6 +10963,7 @@ export default function (pi: ExtensionAPI) {
9021
10963
  const checkError = selfCheckMissingEntryInsertion(
9022
10964
  originalText, plan.modifiedText,
9023
10965
  suggestion.providerLabel, suggestion.modelId, suggestion.compatKeys,
10966
+ model,
9024
10967
  );
9025
10968
  if (checkError !== null) {
9026
10969
  // Fall through to manual guidance.
@@ -9065,6 +11008,20 @@ export default function (pi: ExtensionAPI) {
9065
11008
  );
9066
11009
  if (confirmed) {
9067
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
+ }
9068
11025
  const result = await applyModelsJsonFixTransaction(
9069
11026
  plan.modifiedText,
9070
11027
  backupPath,
@@ -9074,7 +11031,13 @@ export default function (pi: ExtensionAPI) {
9074
11031
  suggestion.providerLabel,
9075
11032
  suggestion.modelId,
9076
11033
  suggestion.compatKeys,
11034
+ model,
9077
11035
  ),
11036
+ {
11037
+ expectedCurrentHash: hashText(originalText),
11038
+ purpose: "fix",
11039
+ onCommitted: async () => writeModelsJsonFixReceipt(receipt),
11040
+ },
9078
11041
  );
9079
11042
  if ("postCheckError" in result) {
9080
11043
  cmdCtx.ui.notify(
@@ -9084,6 +11047,7 @@ export default function (pi: ExtensionAPI) {
9084
11047
  );
9085
11048
  return;
9086
11049
  }
11050
+ invalidateModelsConfigCache();
9087
11051
  cmdCtx.ui.notify(
9088
11052
  `✅ Fix applied to ${getModelsJsonDisplayPath()}.\n` +
9089
11053
  `Backup saved to: ${backupPath}\n` +
@@ -9161,10 +11125,45 @@ export default function (pi: ExtensionAPI) {
9161
11125
  suggestion.providerLabel,
9162
11126
  suggestion.forceModelLevel,
9163
11127
  );
9164
- 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);
9165
11146
 
9166
- // Self-check
9167
- 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
+ );
9168
11167
  if (checkError !== null) {
9169
11168
  cmdCtx.ui.notify(
9170
11169
  `❌ Self-check failed before write: ${checkError}\n` +
@@ -9229,17 +11228,46 @@ export default function (pi: ExtensionAPI) {
9229
11228
 
9230
11229
  // Write: backup → temp + rename → self-check again
9231
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
+ }
9232
11245
  const result = await applyModelsJsonFixTransaction(
9233
11246
  modifiedText,
9234
11247
  backupPath,
9235
- (writtenText) => selfCheckFix(
9236
- originalText,
9237
- writtenText,
9238
- suggestion.providerLabel,
9239
- suggestion.modelId,
9240
- suggestion.compatKeys,
9241
- decision.placement,
9242
- ),
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
+ },
9243
11271
  );
9244
11272
  if ("postCheckError" in result) {
9245
11273
  cmdCtx.ui.notify(
@@ -9274,6 +11302,7 @@ export default function (pi: ExtensionAPI) {
9274
11302
  "Stats — Show current-session model statistics",
9275
11303
  "Compat — Show compat suggestion",
9276
11304
  "Fix — Auto-fix compat issues (writes models.json)",
11305
+ "Rollback — Undo the latest confirmed fix",
9277
11306
  "Footer mode — Choose total, session, or process stats",
9278
11307
  "Reset — Reset local provider/model stats",
9279
11308
  "Cancel",
@@ -9292,6 +11321,8 @@ export default function (pi: ExtensionAPI) {
9292
11321
  } else if (choice === menuOptions[5]) {
9293
11322
  await handleCacheOptimizerCommand("fix", cmdCtx);
9294
11323
  } else if (choice === menuOptions[6]) {
11324
+ await handleCacheOptimizerCommand("rollback", cmdCtx);
11325
+ } else if (choice === menuOptions[7]) {
9295
11326
  const modeOptions = ["session — Current Pi conversation session (default)", "total — All local sessions today", "process — Current extension instance only", "Cancel"];
9296
11327
  const modeChoice = await cmdCtx.ui.select("Footer cache stats mode", modeOptions);
9297
11328
  const nextMode = modeChoice === modeOptions[0]
@@ -9302,7 +11333,7 @@ export default function (pi: ExtensionAPI) {
9302
11333
  ? "process"
9303
11334
  : undefined;
9304
11335
  if (nextMode) await handleCacheOptimizerCommand(`config footer-mode ${nextMode}`, cmdCtx);
9305
- } else if (choice === menuOptions[7]) {
11336
+ } else if (choice === menuOptions[8]) {
9306
11337
  await handleCacheOptimizerCommand("reset", cmdCtx);
9307
11338
  }
9308
11339
  // choice === "cancel" or undefined → no action
@@ -9321,6 +11352,7 @@ export default function (pi: ExtensionAPI) {
9321
11352
  diagnosis.push(" compat — Show compat suggestion with edit location");
9322
11353
  diagnosis.push(" config footer-mode total|session|process — Persist the footer stats mode");
9323
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)");
9324
11356
  diagnosis.push(" reset — Reset local provider/model stats for current model (does not affect upstream)");
9325
11357
  diagnosis.push("");
9326
11358
  diagnosis.push(formatOptimizerRuntimeMode());