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