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