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