pi-cache-optimizer 2.8.16 → 2.8.18

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.
@@ -0,0 +1,1982 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { constants as fsConstants } from "node:fs";
3
+ import { chmod, copyFile, link, lstat, mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
4
+ import { basename, dirname, join } from "node:path";
5
+ import { type FileIdentity, atomicReplaceTextFilePreservingMode, atomicRestoreFileFromBackup, backupTimestamp, hashText, readRegularTextFile, sameFileIdentity, uniqueTempPath, validateAtomicTarget, withModelsJsonTransactionLock } from "./atomic-fs.ts";
6
+ import { LOG_PREFIX, type PiModel, asRecord, getErrorCode } from "./common.ts";
7
+ import { findLastExactModelDefinition, resolveEffectiveCompatFromConfig } from "./compat-config.ts";
8
+ import { type FixReceiptCompatChange, type FixReceiptPlacement, type ModelsJsonFixReceiptV1, RECEIPT_COMPAT_KEYS, type ReceiptScalar, type ReceiptScalarState, isReceiptTimestamp, isSafeReceiptText, isSha256 } from "./fix-types.ts";
9
+ import { type JsonPropertyEdit, type ModelNodeLocation, deepEqualIgnoringKeys, deriveInnerIndent, findExistingCompatKeysInJsonc, findJsonObjectKey, findMatchingBracket, isJsonWhitespace, lineIndentOf, locateJsonPropertyValueSpan, locateModelOverrideInJsonc, locateProviderCompatInJsonc, parseJsonc, readJsonStringLiteral, skipJsonValue, skipJsonWhitespace, stripJsoncComments } from "./jsonc.ts";
10
+ import { isAdaptiveGenerationModel, isKimiCodingAdaptiveModel } from "./model-detect.ts";
11
+ import { FIX_RECEIPT_PATH, MODELS_JSON_PATH } from "./paths.ts";
12
+
13
+ export type ExplicitCompatSource = "modelOverride" | "model" | "provider";
14
+
15
+ export interface ExplicitCompatValue {
16
+ source: ExplicitCompatSource;
17
+ value: unknown;
18
+ }
19
+
20
+ /**
21
+ * Resolve one explicitly configured compat value using Pi's models.json
22
+ * precedence. Built-in model overrides win over custom model definitions,
23
+ * which in turn win over provider defaults.
24
+ */
25
+ export function resolveExplicitCompatValue(
26
+ config: unknown,
27
+ providerLabel: string,
28
+ modelId: string,
29
+ compatKey: string,
30
+ ): ExplicitCompatValue | undefined {
31
+ const providers = asRecord(asRecord(config)?.providers);
32
+ const provider = asRecord(providers?.[providerLabel]);
33
+ if (!provider) return undefined;
34
+
35
+ const override = asRecord(asRecord(provider.modelOverrides)?.[modelId]);
36
+ const overrideCompat = asRecord(override?.compat);
37
+ if (overrideCompat && Object.prototype.hasOwnProperty.call(overrideCompat, compatKey)) {
38
+ return { source: "modelOverride", value: overrideCompat[compatKey] };
39
+ }
40
+
41
+ const model = findLastExactModelDefinition(
42
+ Array.isArray(provider.models) ? provider.models : undefined,
43
+ modelId,
44
+ );
45
+ const modelCompat = asRecord(model?.compat);
46
+ if (modelCompat && Object.prototype.hasOwnProperty.call(modelCompat, compatKey)) {
47
+ return { source: "model", value: modelCompat[compatKey] };
48
+ }
49
+
50
+ const providerCompat = asRecord(provider.compat);
51
+ if (providerCompat && Object.prototype.hasOwnProperty.call(providerCompat, compatKey)) {
52
+ return { source: "provider", value: providerCompat[compatKey] };
53
+ }
54
+
55
+ return undefined;
56
+ }
57
+
58
+ export function hasExplicitLongRetentionOptInFromConfig(
59
+ config: unknown,
60
+ providerLabel: string,
61
+ modelId: string,
62
+ ): boolean {
63
+ return resolveExplicitCompatValue(
64
+ config,
65
+ providerLabel,
66
+ modelId,
67
+ "supportsLongCacheRetention",
68
+ )?.value === true;
69
+ }
70
+
71
+ /**
72
+ * Locate the provider + model entry in raw JSONC text.
73
+ * Returns the positions needed for surgical insertion, or undefined on failure.
74
+ *
75
+ * This is a scan-only pass — no AST build, no regex reliance.
76
+ */
77
+ export function locateModelInJsonc(
78
+ text: string,
79
+ providerLabel: string,
80
+ modelId: string,
81
+ ): ModelNodeLocation | undefined {
82
+ // Clean text of comments first for reliable structural scanning
83
+ const clean = stripJsoncComments(text);
84
+
85
+ // Strategy: find `"providers"` as a direct root key, then find the
86
+ // provider key under it, then the provider's direct `"models"` key.
87
+ // All object/value traversal uses the string-aware primitives above so
88
+ // braces, brackets, comment markers, or escaped quotes inside strings do
89
+ // not corrupt offsets.
90
+ const rootBrace = skipJsonWhitespace(clean, 0);
91
+ if (clean[rootBrace] !== "{") return undefined;
92
+
93
+ const providersKey = findJsonObjectKey(clean, rootBrace, "providers");
94
+ if (!providersKey || providersKey.count !== 1) return undefined;
95
+ const providersBrace = skipJsonWhitespace(clean, providersKey.valueStart);
96
+ if (clean[providersBrace] !== "{") return undefined;
97
+ const providersEnd = findMatchingBracket(clean, providersBrace);
98
+ if (providersEnd === undefined) return undefined;
99
+
100
+ const providerKey = findJsonObjectKey(clean, providersBrace, providerLabel);
101
+ if (!providerKey || providerKey.count !== 1 || providerKey.keyStart > providersEnd) return undefined;
102
+ const providerBrace = skipJsonWhitespace(clean, providerKey.valueStart);
103
+ if (clean[providerBrace] !== "{") return undefined;
104
+ const providerEndBrace = findMatchingBracket(clean, providerBrace);
105
+ if (providerEndBrace === undefined || providerEndBrace > providersEnd) return undefined;
106
+
107
+ // Provider-level compat is a direct provider child only. Nested model
108
+ // compat objects are intentionally skipped whole by findJsonObjectKey.
109
+ let providerCompatBrace = -1;
110
+ let providerCompatEnd = -1;
111
+ const providerCompatKey = findJsonObjectKey(clean, providerBrace, "compat");
112
+ const providerCompatKeyCount = providerCompatKey?.count ?? 0;
113
+ if (providerCompatKey?.count && providerCompatKey.count > 1) return undefined;
114
+ if (providerCompatKey && providerCompatKey.keyStart < providerEndBrace) {
115
+ const brace = skipJsonWhitespace(clean, providerCompatKey.valueStart);
116
+ if (clean[brace] !== "{") return undefined;
117
+ const end = findMatchingBracket(clean, brace);
118
+ if (end === undefined || end > providerEndBrace) return undefined;
119
+ providerCompatBrace = brace;
120
+ providerCompatEnd = end;
121
+ }
122
+
123
+ const modelOverridesKey = findJsonObjectKey(clean, providerBrace, "modelOverrides");
124
+ if (modelOverridesKey?.count && modelOverridesKey.count > 1) return undefined;
125
+ if (modelOverridesKey) {
126
+ const modelOverridesBrace = skipJsonWhitespace(clean, modelOverridesKey.valueStart);
127
+ if (clean[modelOverridesBrace] !== "{") return undefined;
128
+ const modelOverridesEnd = findMatchingBracket(clean, modelOverridesBrace);
129
+ if (modelOverridesEnd === undefined || modelOverridesEnd > providerEndBrace) return undefined;
130
+ const overrideKey = findJsonObjectKey(clean, modelOverridesBrace, modelId);
131
+ if (overrideKey?.count && overrideKey.count > 1) return undefined;
132
+ if (overrideKey) {
133
+ const overrideBrace = skipJsonWhitespace(clean, overrideKey.valueStart);
134
+ if (clean[overrideBrace] !== "{") return undefined;
135
+ const overrideEnd = findMatchingBracket(clean, overrideBrace);
136
+ if (overrideEnd === undefined || overrideEnd > modelOverridesEnd) return undefined;
137
+ const overrideCompatKey = findJsonObjectKey(clean, overrideBrace, "compat");
138
+ if (overrideCompatKey?.count && overrideCompatKey.count > 1) return undefined;
139
+ }
140
+ }
141
+
142
+ const overrideLocation = locateModelOverrideInJsonc(text, providerLabel, modelId);
143
+ const modelOverrideObjectBrace = overrideLocation?.modelOverrideObjectBrace ?? -1;
144
+ const modelOverrideObjectEnd = overrideLocation?.modelOverrideObjectEnd ?? -1;
145
+ const modelOverrideCompatBrace = overrideLocation?.modelOverrideCompatBrace ?? -1;
146
+ const modelOverrideCompatEnd = overrideLocation?.modelOverrideCompatEnd ?? -1;
147
+
148
+ const modelsKey = findJsonObjectKey(clean, providerBrace, "models");
149
+ if (!modelsKey || modelsKey.count !== 1 || modelsKey.keyStart > providerEndBrace) return undefined;
150
+
151
+ let modelsScan = skipJsonWhitespace(clean, modelsKey.valueStart);
152
+ if (clean[modelsScan] !== "[") return undefined;
153
+ const modelsEnd = findMatchingBracket(clean, modelsScan);
154
+ if (modelsEnd === undefined || modelsEnd > providerEndBrace) return undefined;
155
+ modelsScan++; // Skip `[`
156
+
157
+ // Scan ALL array elements: collect every model id, and record the target's position
158
+ const allModelIds: string[] = [];
159
+ let modelBrace = -1;
160
+ let modelEndBrace = -1;
161
+ let compatKeyStartClean = -1;
162
+ let compatBrace = -1;
163
+ let compatEndBrace = -1;
164
+ let modelCompatKeyCount = 0;
165
+
166
+ while (modelsScan < modelsEnd) {
167
+ modelsScan = skipJsonWhitespace(clean, modelsScan);
168
+ if (clean[modelsScan] === ',') {
169
+ modelsScan++;
170
+ continue;
171
+ }
172
+ if (modelsScan >= modelsEnd || clean[modelsScan] === ']') break;
173
+ if (clean[modelsScan] !== '{') return undefined;
174
+
175
+ const elementBrace = modelsScan;
176
+ const elementEnd = findMatchingBracket(clean, elementBrace);
177
+ if (elementEnd === undefined || elementEnd > modelsEnd) return undefined;
178
+
179
+ const idKey = findJsonObjectKey(clean, elementBrace, "id");
180
+ if (idKey?.count && idKey.count > 1) return undefined;
181
+ let elementId: string | undefined;
182
+ if (idKey && idKey.keyStart < elementEnd) {
183
+ const idValueStart = skipJsonWhitespace(clean, idKey.valueStart);
184
+ const idLiteral = readJsonStringLiteral(clean, idValueStart);
185
+ if (idLiteral && idLiteral.end <= elementEnd) {
186
+ elementId = idLiteral.value;
187
+ }
188
+ }
189
+
190
+ if (elementId !== undefined) {
191
+ allModelIds.push(elementId);
192
+ }
193
+
194
+ if (elementId === modelId) {
195
+ // Match Pi's provider composer: later duplicate definitions replace
196
+ // earlier ones. The effective/fix target is therefore the last exact id.
197
+ modelBrace = elementBrace;
198
+ modelEndBrace = elementEnd;
199
+ compatKeyStartClean = -1;
200
+ compatBrace = -1;
201
+ compatEndBrace = -1;
202
+
203
+ const compatKey = findJsonObjectKey(clean, modelBrace, "compat");
204
+ modelCompatKeyCount = compatKey?.count ?? 0;
205
+ if (compatKey?.count && compatKey.count > 1) return undefined;
206
+ if (compatKey && compatKey.keyStart < modelEndBrace) {
207
+ compatKeyStartClean = compatKey.keyStart;
208
+ const brace = skipJsonWhitespace(clean, compatKey.valueStart);
209
+ if (clean[brace] !== "{") return undefined;
210
+ const end = findMatchingBracket(clean, brace);
211
+ if (end === undefined || end > modelEndBrace) return undefined;
212
+ compatBrace = brace;
213
+ compatEndBrace = end;
214
+ }
215
+ }
216
+
217
+ modelsScan = elementEnd + 1;
218
+ }
219
+
220
+ if (modelBrace < 0 || modelEndBrace < 0) return undefined;
221
+
222
+ // Derive indentation from the model object's opening `{` line in original text
223
+ // Look backwards to find the line start
224
+ let lineStart = text.lastIndexOf('\n', modelBrace);
225
+ if (lineStart < 0) lineStart = 0;
226
+ const lineBefore = text.slice(lineStart, modelBrace);
227
+ const indentMatch = lineBefore.match(/^(\s*)/);
228
+ const baseIndent = indentMatch ? indentMatch[1] : ' ';
229
+ const indent = baseIndent + ' '; // +2 for one level deeper
230
+
231
+ return {
232
+ modelObjectBrace: modelBrace,
233
+ modelObjectEnd: modelEndBrace,
234
+ compatKeyStart: compatKeyStartClean >= 0 ? compatKeyStartClean : -1,
235
+ compatObjectBrace: compatBrace,
236
+ compatObjectEnd: compatEndBrace,
237
+ indent,
238
+ providerObjectBrace: providerBrace,
239
+ providerObjectEnd: providerEndBrace,
240
+ providerCompatBrace,
241
+ providerCompatEnd,
242
+ modelOverrideObjectBrace,
243
+ modelOverrideObjectEnd,
244
+ modelOverrideCompatBrace,
245
+ modelOverrideCompatEnd,
246
+ providerKeyCount: providerKey.count,
247
+ modelOverrideKeyCount: overrideLocation?.modelOverrideKeyCount ?? 0,
248
+ modelCompatKeyCount,
249
+ providerCompatKeyCount,
250
+ allModelIds,
251
+ };
252
+ }
253
+
254
+ /**
255
+ * Scan produced by `analyzeModelsJsonForMissingEntry` when
256
+ * `locateModelInJsonc` cannot find the target provider/model.
257
+ */
258
+ export type MissingEntryDiagnosis =
259
+ | { scenario: "provider_missing"; providersBrace: number; providersEnd: number }
260
+ | { scenario: "model_missing"; modelsEnd: number; providerBrace: number; providerEndBrace: number }
261
+ | { scenario: "provider_without_models"; providerBrace: number; providerEndBrace: number };
262
+
263
+ /**
264
+ * Light second-pass scan that determines *why* `locateModelInJsonc` failed.
265
+ * Returns structured diagnostic so the fix handler can compose targeted
266
+ * guidance and an optional surgical insertion for API-logged-in models
267
+ * (e.g. opencode go) that never appear in `models.json`.
268
+ */
269
+ export function analyzeModelsJsonForMissingEntry(
270
+ text: string,
271
+ providerLabel: string,
272
+ modelId: string,
273
+ ): MissingEntryDiagnosis | undefined {
274
+ const clean = stripJsoncComments(text);
275
+ const rootBrace = skipJsonWhitespace(clean, 0);
276
+ if (clean[rootBrace] !== "{") return undefined;
277
+
278
+ const providersKey = findJsonObjectKey(clean, rootBrace, "providers");
279
+ if (!providersKey || providersKey.count !== 1) {
280
+ // Root has no unique "providers" key — we don't auto-create or guess
281
+ // among duplicate definitions.
282
+ return undefined;
283
+ }
284
+ const providersBrace = skipJsonWhitespace(clean, providersKey.valueStart);
285
+ if (clean[providersBrace] !== "{") return undefined;
286
+ const providersEnd = findMatchingBracket(clean, providersBrace);
287
+ if (providersEnd === undefined) return undefined;
288
+
289
+ const providerKey = findJsonObjectKey(clean, providersBrace, providerLabel);
290
+ if (!providerKey) {
291
+ return { scenario: "provider_missing", providersBrace, providersEnd };
292
+ }
293
+ if (providerKey.count !== 1 || providerKey.keyStart > providersEnd) return undefined;
294
+
295
+ // Provider exists. Check for a models array so we know where to append.
296
+ const providerBrace = skipJsonWhitespace(clean, providerKey.valueStart);
297
+ if (clean[providerBrace] !== "{") return undefined;
298
+ const providerEndBrace = findMatchingBracket(clean, providerBrace);
299
+ if (providerEndBrace === undefined || providerEndBrace > providersEnd) return undefined;
300
+
301
+ const modelsKey = findJsonObjectKey(clean, providerBrace, "models");
302
+ if (modelsKey?.count && modelsKey.count > 1) return undefined;
303
+ if (modelsKey && modelsKey.keyStart < providerEndBrace) {
304
+ const mScan = skipJsonWhitespace(clean, modelsKey.valueStart);
305
+ if (clean[mScan] !== "[") return undefined;
306
+ const modelsEnd = findMatchingBracket(clean, mScan);
307
+ if (modelsEnd === undefined || modelsEnd > providerEndBrace) return undefined;
308
+
309
+ // Confirm that the target really is absent before offering an insertion.
310
+ // This second pass also refuses malformed/ambiguous existing entries, so
311
+ // a scanner failure can never turn into a duplicate model definition.
312
+ let modelScan = mScan + 1;
313
+ let targetFound = false;
314
+ while (modelScan < modelsEnd) {
315
+ modelScan = skipJsonWhitespace(clean, modelScan);
316
+ if (clean[modelScan] === ",") {
317
+ modelScan++;
318
+ continue;
319
+ }
320
+ if (modelScan >= modelsEnd || clean[modelScan] === "]") break;
321
+ if (clean[modelScan] !== "{") return undefined;
322
+ const elementEnd = findMatchingBracket(clean, modelScan);
323
+ if (elementEnd === undefined || elementEnd > modelsEnd) return undefined;
324
+ const idKey = findJsonObjectKey(clean, modelScan, "id");
325
+ if (!idKey || idKey.count !== 1) return undefined;
326
+ const idStart = skipJsonWhitespace(clean, idKey.valueStart);
327
+ const idLiteral = readJsonStringLiteral(clean, idStart);
328
+ if (!idLiteral || idLiteral.end > elementEnd) return undefined;
329
+ if (idLiteral.value === modelId) {
330
+ targetFound = true;
331
+ const compatKey = findJsonObjectKey(clean, modelScan, "compat");
332
+ if (compatKey?.count && compatKey.count > 1) return undefined;
333
+ }
334
+ modelScan = elementEnd + 1;
335
+ }
336
+ if (targetFound) return undefined;
337
+ return { scenario: "model_missing", modelsEnd, providerBrace, providerEndBrace };
338
+ }
339
+
340
+ // Provider exists, but there's no discoverable models array — treat as
341
+ // a provider that needs one.
342
+ return { scenario: "provider_without_models", providerBrace, providerEndBrace };
343
+ }
344
+
345
+ /**
346
+ * Build a copyable manual-edit snippet for the missing entry. Used when the
347
+ * terminal is non-interactive or the user chooses to edit by hand.
348
+ * Returns a complete provider→model→compat JSON block that the user can
349
+ * paste into `models.json` under `providers`.
350
+ */
351
+ export function formatMissingEntryManualSnippet(
352
+ providerLabel: string,
353
+ modelId: string,
354
+ compatKeys: Record<string, unknown>,
355
+ ): string {
356
+ const lines: string[] = [];
357
+ const sorted = Object.entries(compatKeys).sort(([a], [b]) => a.localeCompare(b));
358
+ const compatItems = sorted.map(([k, v]) => ` ${JSON.stringify(k)}: ${JSON.stringify(v)}`);
359
+ lines.push(`${JSON.stringify(providerLabel)}: {`);
360
+ lines.push(` "modelOverrides": {`);
361
+ lines.push(` ${JSON.stringify(modelId)}: {`);
362
+ lines.push(` "compat": {`);
363
+ lines.push(compatItems.join(",\n"));
364
+ lines.push(` }`);
365
+ lines.push(` }`);
366
+ lines.push(` }`);
367
+ lines.push(` }`);
368
+ return lines.join("\n");
369
+ }
370
+
371
+ /**
372
+ * Insert or repair a modelOverrides entry without creating a custom models[]
373
+ * definition. This is the safest representation for built-in/API-login models
374
+ * and has the same highest precedence Pi applies at runtime.
375
+ */
376
+ export function composeModelOverrideInsertion(
377
+ originalText: string,
378
+ providerLabel: string,
379
+ modelId: string,
380
+ compatKeys: Record<string, unknown>,
381
+ ): { modifiedText: string; placementLabel: string } | undefined {
382
+ const location = locateModelOverrideInJsonc(originalText, providerLabel, modelId);
383
+
384
+ if (location?.modelOverrideObjectBrace !== undefined && location.modelOverrideObjectBrace >= 0) {
385
+ const modelLocation: ModelNodeLocation = {
386
+ providerKeyCount: location.providerKeyCount,
387
+ modelObjectBrace: -1,
388
+ modelObjectEnd: -1,
389
+ compatKeyStart: -1,
390
+ compatObjectBrace: -1,
391
+ compatObjectEnd: -1,
392
+ indent: "",
393
+ providerObjectBrace: location.providerObjectBrace,
394
+ providerObjectEnd: location.providerObjectEnd,
395
+ providerCompatBrace: -1,
396
+ providerCompatEnd: -1,
397
+ modelOverrideObjectBrace: location.modelOverrideObjectBrace,
398
+ modelOverrideObjectEnd: location.modelOverrideObjectEnd,
399
+ modelOverrideCompatBrace: location.modelOverrideCompatBrace,
400
+ modelOverrideCompatEnd: location.modelOverrideCompatEnd,
401
+ modelOverrideKeyCount: location.modelOverrideKeyCount,
402
+ modelCompatKeyCount: 0,
403
+ providerCompatKeyCount: 0,
404
+ allModelIds: [],
405
+ };
406
+ return {
407
+ modifiedText: composeFixInsertion(originalText, modelLocation, compatKeys, "modelOverride"),
408
+ placementLabel: `providers["${providerLabel}"] -> modelOverrides["${modelId}"] -> compat`,
409
+ };
410
+ }
411
+
412
+ const sortedEntries = Object.entries(compatKeys).sort(([a], [b]) => a.localeCompare(b));
413
+ const formatOverrideEntry = (keyIndent: string, unit: string): string => {
414
+ const propertyIndent = keyIndent + unit;
415
+ const compatIndent = propertyIndent + unit;
416
+ const compatLines = sortedEntries
417
+ .map(([key, value]) => `${compatIndent}${JSON.stringify(key)}: ${JSON.stringify(value)}`)
418
+ .join(",\n");
419
+ return `${keyIndent}${JSON.stringify(modelId)}: {\n` +
420
+ `${propertyIndent}"compat": {\n${compatLines}\n${propertyIndent}}\n${keyIndent}}`;
421
+ };
422
+ const previousTokenNeedsComma = (clean: string, closeBrace: number): boolean => {
423
+ let pos = closeBrace - 1;
424
+ while (pos >= 0 && isJsonWhitespace(clean[pos])) pos--;
425
+ return clean[pos] !== "{" && clean[pos] !== ",";
426
+ };
427
+
428
+ if (location) {
429
+ const clean = stripJsoncComments(originalText);
430
+ if (location.modelOverridesObjectBrace >= 0) {
431
+ const containerIndent = lineIndentOf(originalText, location.modelOverridesObjectBrace);
432
+ const keyIndent = deriveInnerIndent(
433
+ originalText,
434
+ location.modelOverridesObjectBrace,
435
+ location.modelOverridesObjectEnd,
436
+ );
437
+ const unit = keyIndent.length > containerIndent.length
438
+ ? keyIndent.slice(containerIndent.length)
439
+ : " ";
440
+ const comma = previousTokenNeedsComma(clean, location.modelOverridesObjectEnd) ? "," : "";
441
+ const insertion = `${comma}\n${formatOverrideEntry(keyIndent, unit)}\n${containerIndent}`;
442
+ return {
443
+ modifiedText: originalText.slice(0, location.modelOverridesObjectEnd) + insertion + originalText.slice(location.modelOverridesObjectEnd),
444
+ placementLabel: `providers["${providerLabel}"] -> modelOverrides -> (new entry "${modelId}")`,
445
+ };
446
+ }
447
+
448
+ const providerIndent = lineIndentOf(originalText, location.providerObjectBrace);
449
+ const propertyIndent = deriveInnerIndent(originalText, location.providerObjectBrace, location.providerObjectEnd);
450
+ const unit = propertyIndent.length > providerIndent.length
451
+ ? propertyIndent.slice(providerIndent.length)
452
+ : " ";
453
+ const entryIndent = propertyIndent + unit;
454
+ const block = `\n${propertyIndent}"modelOverrides": {\n` +
455
+ `${formatOverrideEntry(entryIndent, unit)}\n${propertyIndent}},`;
456
+ return {
457
+ modifiedText: originalText.slice(0, location.providerObjectBrace + 1) + block + originalText.slice(location.providerObjectBrace + 1),
458
+ placementLabel: `providers["${providerLabel}"] -> (new modelOverrides entry "${modelId}")`,
459
+ };
460
+ }
461
+
462
+ const diagnosis = analyzeModelsJsonForMissingEntry(originalText, providerLabel, modelId);
463
+ if (!diagnosis || diagnosis.scenario !== "provider_missing") return undefined;
464
+ const clean = stripJsoncComments(originalText);
465
+ const providersIndent = lineIndentOf(originalText, diagnosis.providersEnd);
466
+ const providerIndent = deriveInnerIndent(originalText, diagnosis.providersBrace, diagnosis.providersEnd);
467
+ const unit = providerIndent.length > providersIndent.length
468
+ ? providerIndent.slice(providersIndent.length)
469
+ : " ";
470
+ const overridesIndent = providerIndent + unit;
471
+ const entryIndent = overridesIndent + unit;
472
+ const comma = previousTokenNeedsComma(clean, diagnosis.providersEnd) ? "," : "";
473
+ const block = `${comma}\n${providerIndent}${JSON.stringify(providerLabel)}: {\n` +
474
+ `${overridesIndent}"modelOverrides": {\n${formatOverrideEntry(entryIndent, unit)}\n` +
475
+ `${overridesIndent}}\n${providerIndent}}\n${providersIndent}`;
476
+ return {
477
+ modifiedText: originalText.slice(0, diagnosis.providersEnd) + block + originalText.slice(diagnosis.providersEnd),
478
+ placementLabel: `providers -> (new modelOverrides-only entry "${providerLabel}/${modelId}")`,
479
+ };
480
+ }
481
+
482
+ /**
483
+ * Surgically insert the missing provider/model entry into the original
484
+ * JSONC text. Returns the modified text and placement descriptor.
485
+ *
486
+ * Handles three scenarios:
487
+ * - `model_missing`: append a new model object to the provider's `models` array.
488
+ * - `provider_missing`: append a new provider block to the root `providers` object.
489
+ * - `provider_without_models`: inject a `"models": [...]` key into the existing provider.
490
+ */
491
+ export function composeMissingEntryInsertion(
492
+ originalText: string,
493
+ diagnosis: MissingEntryDiagnosis,
494
+ providerLabel: string,
495
+ modelId: string,
496
+ compatKeys: Record<string, unknown>,
497
+ ): { modifiedText: string; placementLabel: string } {
498
+ // Comments preserve length when stripped (`stripJsoncComments` replaces
499
+ // comment bytes 1-for-1 with spaces), so offsets derived from the
500
+ // comment-stripped text map cleanly back to the original. However,
501
+ // `lastIndexOf("{", pos)` / `lastIndexOf("[", pos)` must NOT be run
502
+ // against the raw original: a comment like `// add [more] here with a {
503
+ // brace` would surface `[` / `{` bytes that have no structural meaning,
504
+ // contaminating the `hasExisting`/`hasExistingElements` decision below
505
+ // and producing a stray leading comma. Run the structural searches
506
+ // against the comment-stripped version; keep the indentation lookups
507
+ // (which only care about newlines + leading whitespace) on the
508
+ // original since comments never contain forward-scan-relevant bytes.
509
+ const cleanText = stripJsoncComments(originalText);
510
+
511
+ // Resolve a sensible indentation step from an arbitrary byte offset in
512
+ // the original file.
513
+ const indentUnitAt = (offset: number): string => {
514
+ const ls = originalText.lastIndexOf("\n", offset);
515
+ const line = originalText.slice(ls < 0 ? 0 : ls + 1, offset);
516
+ const m = line.match(/^(\s+)/);
517
+ return m ? m[1] : " ";
518
+ };
519
+
520
+ // Figure out the base indent from the insertion point's own line.
521
+ // Then derive inner indents (+1 and +2 levels).
522
+ const sorted = Object.entries(compatKeys).sort(([a], [b]) => a.localeCompare(b));
523
+ const formatCompactCompat = (indent: string): string => {
524
+ // Single-line compact when there's only one key, multi-line otherwise.
525
+ if (sorted.length === 1) {
526
+ const [k, v] = sorted[0];
527
+ return `{ ${JSON.stringify(k)}: ${JSON.stringify(v)} }`;
528
+ }
529
+ return (
530
+ "{\n" +
531
+ sorted.map(([k, v]) => `${indent}${JSON.stringify(k)}: ${JSON.stringify(v)}`).join(",\n") +
532
+ "\n" +
533
+ indent.slice(0, -2) +
534
+ "}"
535
+ );
536
+ };
537
+
538
+ if (diagnosis.scenario === "model_missing") {
539
+ // Append to the provider's models array, right before `]`.
540
+ const unit = indentUnitAt(diagnosis.modelsEnd);
541
+ const inner0 = unit + unit; // indent of model object's own keys
542
+ const inner1 = inner0 + unit; // indent of compat keys inside the model
543
+ const inner2 = inner1 + unit; // indent of compat values
544
+
545
+ // Determine whether the array is empty (need to skip the leading comma).
546
+ // Search for the models `[` on the comment-stripped text so a `[` inside
547
+ // a comment cannot be mistaken for the array opener.
548
+ const arrayInterior = cleanText.slice(
549
+ cleanText.lastIndexOf("[", diagnosis.modelsEnd) + 1,
550
+ diagnosis.modelsEnd,
551
+ ).trim();
552
+ const hasExistingElements = arrayInterior.length > 0;
553
+
554
+ const compatBlock = formatCompactCompat(inner2);
555
+ const modelBlock = [
556
+ hasExistingElements ? "," : "",
557
+ inner0 + "{",
558
+ inner1 + `"id": ${JSON.stringify(modelId)},`,
559
+ inner1 + `"compat": ` + compatBlock,
560
+ inner0 + "}",
561
+ unit,
562
+ ].filter(Boolean).join("\n");
563
+
564
+ const insertionPoint = diagnosis.modelsEnd;
565
+ const prefix = originalText.slice(0, insertionPoint);
566
+ const suffix = originalText.slice(insertionPoint); // starts with `]`
567
+ return {
568
+ modifiedText: prefix + modelBlock + suffix,
569
+ placementLabel: `providers["${providerLabel}"] -> models -> (new entry for "${modelId}")`,
570
+ };
571
+ }
572
+
573
+ if (diagnosis.scenario === "provider_missing") {
574
+ // Append a new provider entry to the root `providers` object, right
575
+ // before its closing `}`.
576
+ const unit = indentUnitAt(diagnosis.providersEnd);
577
+ const inner0 = unit + unit;
578
+ const inner1 = inner0 + unit;
579
+ const inner2 = inner1 + unit;
580
+ const inner3 = inner2 + unit;
581
+
582
+ const compatBlock = formatCompactCompat(inner3);
583
+ // Search for the providers `{` on the comment-stripped text so a `{`
584
+ // inside a comment cannot be mistaken for the providers object opener.
585
+ const providersInterior = cleanText.slice(
586
+ cleanText.lastIndexOf("{", diagnosis.providersEnd) + 1,
587
+ diagnosis.providersEnd,
588
+ ).trim();
589
+ const hasExisting = providersInterior.length > 0;
590
+
591
+ const providerBlock = [
592
+ hasExisting ? "," : "",
593
+ inner0 + `"${providerLabel}": {`,
594
+ inner1 + `"models": [`,
595
+ inner2 + "{",
596
+ inner3 + `"id": ${JSON.stringify(modelId)},`,
597
+ inner3 + `"compat": ` + compatBlock,
598
+ inner2 + "}",
599
+ inner1 + "]",
600
+ inner0 + "}",
601
+ unit,
602
+ ].filter(Boolean).join("\n");
603
+
604
+ const insertionPoint = diagnosis.providersEnd;
605
+ const prefix = originalText.slice(0, insertionPoint);
606
+ const suffix = originalText.slice(insertionPoint);
607
+ return {
608
+ modifiedText: prefix + providerBlock + suffix,
609
+ placementLabel: `providers -> (new entry "${providerLabel}")`,
610
+ };
611
+ }
612
+
613
+ // `provider_without_models`: inject a models array key into the
614
+ // existing provider block, right after the provider's opening `{`.
615
+ const unit = indentUnitAt(diagnosis.providerBrace);
616
+ const inner0 = unit + unit;
617
+ const inner1 = inner0 + unit;
618
+ const inner2 = inner1 + unit;
619
+
620
+ const compatBlock = formatCompactCompat(inner2);
621
+ const afterBrace = diagnosis.providerBrace + 1;
622
+ const modelsBlock = [
623
+ "",
624
+ inner0 + `"models": [`,
625
+ inner1 + "{",
626
+ inner2 + `"id": ${JSON.stringify(modelId)},`,
627
+ inner2 + `"compat": ` + compatBlock,
628
+ inner1 + "}",
629
+ inner0 + "],",
630
+ unit,
631
+ ].join("\n");
632
+
633
+ return {
634
+ modifiedText: originalText.slice(0, afterBrace) + modelsBlock + originalText.slice(afterBrace),
635
+ placementLabel: `providers["${providerLabel}"] -> (new "models" array with "${modelId}")`,
636
+ };
637
+ }
638
+
639
+ /**
640
+ * Lightweight self-check for a newly inserted model or modelOverrides entry.
641
+ * Parses the modified text as JSONC and confirms:
642
+ * 1. The target exists under models[] or modelOverrides.
643
+ * 2. Every compat key has the expected effective provider/model/runtime/
644
+ * modelOverride value.
645
+ * Returns null on success, an error string on failure.
646
+ */
647
+ export function selfCheckMissingEntryInsertion(
648
+ originalText: string,
649
+ modifiedText: string,
650
+ providerLabel: string,
651
+ modelId: string,
652
+ compatKeys: Record<string, unknown>,
653
+ runtimeModel?: PiModel,
654
+ ): string | null {
655
+ try {
656
+ const origParsed = parseJsonc(originalText);
657
+ const modParsed = parseJsonc(modifiedText);
658
+ const providers = asRecord(asRecord(modParsed)?.providers);
659
+ if (!providers) return "Modified file: providers object missing or invalid";
660
+ const provider = asRecord(providers[providerLabel]);
661
+ if (!provider) return `Modified file: provider "${providerLabel}" not found`;
662
+ const models = provider.models;
663
+ const targetModel = Array.isArray(models)
664
+ ? models.find((m: unknown) => asRecord(m)?.id === modelId)
665
+ : undefined;
666
+ const targetOverride = asRecord(asRecord(provider.modelOverrides)?.[modelId]);
667
+ if (!targetModel && !targetOverride) {
668
+ return `Modified file: model or modelOverrides entry "${modelId}" not found in provider after insertion`;
669
+ }
670
+
671
+ const effectiveCompat = runtimeModel
672
+ ? resolveEffectiveCompatFromConfig(runtimeModel, modParsed)
673
+ : undefined;
674
+ for (const [k, v] of Object.entries(compatKeys)) {
675
+ if (effectiveCompat) {
676
+ if (!Object.prototype.hasOwnProperty.call(effectiveCompat, k)) {
677
+ return `Modified file: effective compat.${k} not found`;
678
+ }
679
+ if ((effectiveCompat as Record<string, unknown>)[k] !== v) {
680
+ return `Modified file: effective compat.${k} wrong value: expected ${JSON.stringify(v)}, got ${JSON.stringify((effectiveCompat as Record<string, unknown>)[k])}`;
681
+ }
682
+ continue;
683
+ }
684
+ const effective = resolveExplicitCompatValue(modParsed, providerLabel, modelId, k);
685
+ if (!effective) return `Modified file: effective compat.${k} not found`;
686
+ if (effective.value !== v) {
687
+ return `Modified file: effective compat.${k} wrong value: expected ${JSON.stringify(v)}, got ${JSON.stringify(effective.value)} from ${effective.source}`;
688
+ }
689
+ }
690
+
691
+ // Normalize only the intended override edit back to its original shape,
692
+ // then require the complete parsed structure to match. This catches data
693
+ // loss while allowing legitimate shorter repairs such as false -> true.
694
+ const normalized = JSON.parse(JSON.stringify(modParsed)) as Record<string, unknown>;
695
+ const origProviders = asRecord(asRecord(origParsed)?.providers);
696
+ const origProvider = asRecord(origProviders?.[providerLabel]);
697
+ const normalizedProviders = asRecord(normalized.providers);
698
+ if (!normalizedProviders) return "Modified file: normalized providers object missing";
699
+
700
+ if (!origProvider) {
701
+ delete normalizedProviders[providerLabel];
702
+ } else if (targetOverride) {
703
+ const normalizedProvider = asRecord(normalizedProviders[providerLabel]);
704
+ const normalizedOverrides = asRecord(normalizedProvider?.modelOverrides);
705
+ const origOverrides = asRecord(origProvider.modelOverrides);
706
+ const origOverride = asRecord(origOverrides?.[modelId]);
707
+
708
+ if (!normalizedProvider || !normalizedOverrides) {
709
+ return `Modified file: modelOverrides["${modelId}"] missing during structure validation`;
710
+ }
711
+ if (!origOverride) {
712
+ delete normalizedOverrides[modelId];
713
+ if (!origOverrides) delete normalizedProvider.modelOverrides;
714
+ } else {
715
+ const normalizedOverride = asRecord(normalizedOverrides[modelId]);
716
+ if (!normalizedOverride) return `Modified file: modelOverrides["${modelId}"] invalid`;
717
+ const origCompat = asRecord(origOverride.compat);
718
+ const normalizedCompat = asRecord(normalizedOverride.compat);
719
+ if (!origCompat) {
720
+ delete normalizedOverride.compat;
721
+ } else {
722
+ if (!normalizedCompat) return `Modified file: modelOverrides["${modelId}"].compat invalid`;
723
+ for (const key of Object.keys(compatKeys)) {
724
+ if (Object.prototype.hasOwnProperty.call(origCompat, key)) {
725
+ normalizedCompat[key] = origCompat[key];
726
+ } else {
727
+ delete normalizedCompat[key];
728
+ }
729
+ }
730
+ }
731
+ }
732
+ } else {
733
+ // Backward-compatible validation for the legacy models[] insertion
734
+ // helper retained in __internals_for_tests.
735
+ const normalizedProvider = asRecord(normalizedProviders[providerLabel]);
736
+ const normalizedModels = normalizedProvider?.models;
737
+ const origModels = origProvider.models;
738
+ if (!normalizedProvider || !Array.isArray(normalizedModels)) {
739
+ return `Modified file: provider "${providerLabel}".models missing during structure validation`;
740
+ }
741
+ normalizedProvider.models = normalizedModels.filter(
742
+ (entry: unknown) => asRecord(entry)?.id !== modelId,
743
+ );
744
+ if (!Array.isArray(origModels)) delete normalizedProvider.models;
745
+ }
746
+
747
+ if (!deepEqualIgnoringKeys(normalized, origParsed, [])) {
748
+ return "Modified file: original structure was altered (data loss detected)";
749
+ }
750
+
751
+ const modClean = stripJsoncComments(modifiedText);
752
+ const rootStart = skipJsonWhitespace(modClean, 0);
753
+ const rootEnd = findMatchingBracket(modClean, rootStart);
754
+ if (rootEnd === undefined) return "Modified file: root bracket mismatch";
755
+ if (skipJsonWhitespace(modClean, rootEnd + 1) !== modClean.length)
756
+ return "Modified file: trailing content after root object";
757
+
758
+ return null;
759
+ } catch (e) {
760
+ return `Self-check error: ${e instanceof Error ? e.message : String(e)}`;
761
+ }
762
+ }
763
+
764
+ /**
765
+ * Compose the fix: produce the modified text with compat keys inserted.
766
+ *
767
+ * Strategy:
768
+ * - If compat object exists: replace its interior (between `{` and `}`)
769
+ * with new keys + existing content, preserving surrounding bytes.
770
+ * - If compat doesn't exist: insert `"compat": { keys }` after model `{`.
771
+ *
772
+ * Uses the raw original text; only the inserted/compat region changes.
773
+ */
774
+ /**
775
+ * Compat keys that describe CHANNEL capabilities (routing, endpoint features).
776
+ * These are always safe at the provider level because they do not change
777
+ * per-model request semantics.
778
+ */
779
+ export const PROVIDER_LEVEL_SAFE_COMPAT_KEYS = new Set<string>([
780
+ "sendSessionAffinityHeaders",
781
+ "supportsLongCacheRetention",
782
+ ]);
783
+
784
+ export function syntheticModelForId(providerLabel: string, id: string): PiModel {
785
+ return { provider: providerLabel, id, name: id } as PiModel;
786
+ }
787
+
788
+ /**
789
+ * Decide whether the fix should write provider-level or model-level compat.
790
+ *
791
+ * Strategy (auto-detect, prefer provider level when safe):
792
+ * - Channel-capability keys (session affinity / long retention) are normally
793
+ * provider-safe; runtime-observed model failures explicitly override this
794
+ * default through chooseFixPlacement(..., forceModelLevel=true).
795
+ * - Model-behavior keys (forceAdaptiveThinking, allowEmptySignature,
796
+ * thinkingFormat, ...) are provider-safe ONLY when every sibling model in
797
+ * the provider also matches the same detection (all adaptive-generation /
798
+ * Kimi Coding adaptive / DeepSeek-like).
799
+ * - Single-model providers: provider level is equivalent — prefer it.
800
+ * - Any unsafe key → fall back to model level (single write, smallest blast radius).
801
+ */
802
+ export function decideFixPlacement(
803
+ compatKeys: Record<string, unknown>,
804
+ providerLabel: string,
805
+ allModelIds: string[],
806
+ ): { placement: "provider" | "model"; reason: string } {
807
+ const siblings = allModelIds.filter(Boolean);
808
+
809
+ if (siblings.length <= 1) {
810
+ return {
811
+ placement: "provider",
812
+ reason: "this provider has only one model — provider-level compat is equivalent and easier to maintain",
813
+ };
814
+ }
815
+
816
+ const unsafeKeys: string[] = [];
817
+ for (const key of Object.keys(compatKeys)) {
818
+ if (PROVIDER_LEVEL_SAFE_COMPAT_KEYS.has(key)) continue;
819
+
820
+ if (key === "forceAdaptiveThinking") {
821
+ const allAdaptive = siblings.every((id) => {
822
+ const sibling = syntheticModelForId(providerLabel, id);
823
+ return isAdaptiveGenerationModel(sibling) || isKimiCodingAdaptiveModel(sibling);
824
+ });
825
+ if (!allAdaptive) unsafeKeys.push(key);
826
+ continue;
827
+ }
828
+ if (key === "allowEmptySignature") {
829
+ const allKimiCodingAdaptive = siblings.every((id) => isKimiCodingAdaptiveModel(syntheticModelForId(providerLabel, id)));
830
+ if (!allKimiCodingAdaptive) unsafeKeys.push(key);
831
+ continue;
832
+ }
833
+ if (key === "thinkingFormat" || key === "requiresReasoningContentOnAssistantMessages") {
834
+ // Reasoning wire/replay behavior is model-specific. Sibling ids do not
835
+ // prove that they use the same protocol, so never broaden this repair to
836
+ // provider scope based on a shared DeepSeek name alone.
837
+ unsafeKeys.push(key);
838
+ continue;
839
+ }
840
+ // Unknown model-behavior key — be conservative, keep it model-scoped.
841
+ unsafeKeys.push(key);
842
+ }
843
+
844
+ if (unsafeKeys.length === 0) {
845
+ return {
846
+ placement: "provider",
847
+ reason: `all ${siblings.length} models in this provider are compatible with these flags`,
848
+ };
849
+ }
850
+ return {
851
+ placement: "model",
852
+ reason: `${unsafeKeys.join(", ")} could break sibling models in this provider (${siblings.length} models total) — scoping to this model only`,
853
+ };
854
+ }
855
+
856
+ export function chooseFixPlacement(
857
+ original: string,
858
+ location: ModelNodeLocation,
859
+ compatKeys: Record<string, unknown>,
860
+ providerLabel: string,
861
+ forceModelLevel = false,
862
+ ): { placement: "provider" | "model" | "modelOverride"; reason: string } {
863
+ if (location.modelOverrideObjectBrace >= 0) {
864
+ return {
865
+ placement: "modelOverride",
866
+ reason: "an existing modelOverrides entry has Pi's highest precedence — repairing it directly",
867
+ };
868
+ }
869
+
870
+ if (forceModelLevel) {
871
+ return {
872
+ placement: "modelOverride",
873
+ reason: "runtime-observed provider/model failure — using Pi's highest-precedence model override",
874
+ };
875
+ }
876
+
877
+ const decision = decideFixPlacement(compatKeys, providerLabel, location.allModelIds);
878
+ const existingModelKeys = findExistingCompatKeysInJsonc(
879
+ original,
880
+ location.compatObjectBrace,
881
+ location.compatObjectEnd,
882
+ Object.keys(compatKeys),
883
+ );
884
+
885
+ // Provider-level writes cannot override a model-level compat key because Pi's
886
+ // merge order is provider.compat then model.compat. If the active model already
887
+ // has one of the keys we need to repair (e.g. thinkingFormat: "legacy"), write
888
+ // at model level even when the key would otherwise be provider-safe.
889
+ if (decision.placement === "provider" && existingModelKeys.length > 0) {
890
+ return {
891
+ placement: "model",
892
+ reason: `model-level compat already contains ${existingModelKeys.join(", ")} — repairing the active model override directly`,
893
+ };
894
+ }
895
+
896
+ return decision;
897
+ }
898
+
899
+ export type CompatInsertionLocation = Pick<ModelNodeLocation,
900
+ | "modelObjectBrace"
901
+ | "compatObjectBrace"
902
+ | "compatObjectEnd"
903
+ | "providerObjectBrace"
904
+ | "providerCompatBrace"
905
+ | "providerCompatEnd"
906
+ | "modelOverrideObjectBrace"
907
+ | "modelOverrideCompatBrace"
908
+ | "modelOverrideCompatEnd"
909
+ >;
910
+
911
+ export function composeProviderAffinityInsertion(
912
+ original: string,
913
+ providerLabel: string,
914
+ ): { modifiedText: string; placementLabel: string } | undefined {
915
+ const location = locateProviderCompatInJsonc(original, providerLabel);
916
+ if (!location) return undefined;
917
+ const node: CompatInsertionLocation = {
918
+ modelObjectBrace: -1,
919
+ compatObjectBrace: -1,
920
+ compatObjectEnd: -1,
921
+ providerObjectBrace: location.providerObjectBrace,
922
+ providerCompatBrace: location.providerCompatBrace,
923
+ providerCompatEnd: location.providerCompatEnd,
924
+ modelOverrideObjectBrace: -1,
925
+ modelOverrideCompatBrace: -1,
926
+ modelOverrideCompatEnd: -1,
927
+ };
928
+ return {
929
+ modifiedText: composeFixInsertion(original, node, { sendSessionAffinityHeaders: true }, "provider"),
930
+ placementLabel: `providers["${providerLabel}"] -> compat (provider level)`,
931
+ };
932
+ }
933
+
934
+ export function composeFixInsertion(
935
+ original: string,
936
+ location: CompatInsertionLocation,
937
+ compatKeys: Record<string, unknown>,
938
+ placement: "provider" | "model" | "modelOverride" = "model",
939
+ ): string {
940
+ // Resolve the target compat object and its container based on placement.
941
+ const targetCompatBrace = placement === "provider"
942
+ ? location.providerCompatBrace
943
+ : placement === "modelOverride"
944
+ ? location.modelOverrideCompatBrace
945
+ : location.compatObjectBrace;
946
+ const targetCompatEnd = placement === "provider"
947
+ ? location.providerCompatEnd
948
+ : placement === "modelOverride"
949
+ ? location.modelOverrideCompatEnd
950
+ : location.compatObjectEnd;
951
+ const containerBrace = placement === "provider"
952
+ ? location.providerObjectBrace
953
+ : placement === "modelOverride"
954
+ ? location.modelOverrideObjectBrace
955
+ : location.modelObjectBrace;
956
+
957
+ // Helper: format key/value pairs as lines with the given indent,
958
+ // alphabetically sorted for stable previews and deterministic edits.
959
+ const sortedEntries = Object.entries(compatKeys).sort(([a], [b]) => a.localeCompare(b));
960
+ const formatEntries = (indent: string, entries: Array<[string, unknown]>): string =>
961
+ entries
962
+ .map(([k, v]) => `${indent}${JSON.stringify(k)}: ${JSON.stringify(v)}`)
963
+ .join(',\n');
964
+
965
+ // Helper: line-start indentation of the line containing `offset` in `original`.
966
+ const lineIndentAt = (offset: number): string => {
967
+ let ls = original.lastIndexOf('\n', offset);
968
+ if (ls < 0) ls = -1;
969
+ const line = original.slice(ls + 1, offset);
970
+ const m = line.match(/^(\s*)/);
971
+ return m ? m[1] : '';
972
+ };
973
+
974
+ if (targetCompatBrace >= 0 && targetCompatEnd > targetCompatBrace) {
975
+ // ── Existing compat object: insert absent keys and surgically replace
976
+ // direct existing keys whose value is wrong (e.g. thinkingFormat: "legacy").
977
+ // Unrelated interior bytes/comments/key order are preserved.
978
+ const interiorStart = targetCompatBrace + 1;
979
+ const interior = original.slice(interiorStart, targetCompatEnd);
980
+ const hasContent = interior.trim().length > 0;
981
+ const clean = stripJsoncComments(original);
982
+
983
+ // Indent for inserted key lines: copy the first existing key line's indent,
984
+ // else derive one level deeper than the compat brace's own line.
985
+ const braceLineIndent = lineIndentAt(targetCompatBrace);
986
+ const innerMatch = interior.match(/\r?\n([ \t]+)\S/);
987
+ const innerIndent = innerMatch ? innerMatch[1] : braceLineIndent + ' ';
988
+
989
+ const edits: Array<{ start: number; end: number; text: string }> = [];
990
+ const missingEntries: Array<[string, unknown]> = [];
991
+
992
+ for (const [key, value] of sortedEntries) {
993
+ const existing = findJsonObjectKey(clean, targetCompatBrace, key);
994
+ if (existing && existing.keyStart < targetCompatEnd) {
995
+ const valueStart = skipJsonWhitespace(clean, existing.valueStart);
996
+ const valueEnd = skipJsonValue(clean, valueStart);
997
+ if (valueEnd !== undefined && valueEnd <= targetCompatEnd) {
998
+ const nextValue = JSON.stringify(value);
999
+ if (original.slice(valueStart, valueEnd) !== nextValue) {
1000
+ edits.push({ start: valueStart, end: valueEnd, text: nextValue });
1001
+ }
1002
+ continue;
1003
+ }
1004
+ }
1005
+ missingEntries.push([key, value]);
1006
+ }
1007
+
1008
+ if (missingEntries.length > 0) {
1009
+ const keysFormatted = formatEntries(innerIndent, missingEntries);
1010
+ if (hasContent) {
1011
+ edits.push({ start: interiorStart, end: interiorStart, text: `\n${keysFormatted},` });
1012
+ } else {
1013
+ edits.push({ start: interiorStart, end: targetCompatEnd, text: `\n${keysFormatted}\n${braceLineIndent}` });
1014
+ }
1015
+ }
1016
+
1017
+ // Apply later edits first so earlier offsets remain valid.
1018
+ return edits
1019
+ .sort((a, b) => b.start - a.start)
1020
+ .reduce((text, edit) => text.slice(0, edit.start) + edit.text + text.slice(edit.end), original);
1021
+ }
1022
+
1023
+ // ── No compat object yet: create one right after the container `{`. ──
1024
+ // Everything after the brace (including the next line's indentation) is
1025
+ // preserved byte-for-byte; we only prepend a complete `"compat": {...},` block.
1026
+ const afterBrace = containerBrace + 1;
1027
+ const suffix = original.slice(afterBrace);
1028
+
1029
+ // Key indent: copy the first sibling key line's indent from the suffix,
1030
+ // else one level deeper than the container brace's line.
1031
+ const containerLineIndent = lineIndentAt(containerBrace);
1032
+ const siblingMatch = suffix.match(/^\r?\n([ \t]+)\S/);
1033
+ const keyIndent = siblingMatch ? siblingMatch[1] : containerLineIndent + ' ';
1034
+
1035
+ // One more level for keys inside compat: reuse the file's own indent unit.
1036
+ const unit = keyIndent.startsWith(containerLineIndent) && keyIndent.length > containerLineIndent.length
1037
+ ? keyIndent.slice(containerLineIndent.length)
1038
+ : ' ';
1039
+ const innerIndent = keyIndent + unit;
1040
+
1041
+ const compatBlock = `\n${keyIndent}"compat": {\n${formatEntries(innerIndent, sortedEntries)}\n${keyIndent}},`;
1042
+ return original.slice(0, afterBrace) + compatBlock + suffix;
1043
+ }
1044
+
1045
+ /**
1046
+ * Self-check after compose: parse original and modified as JSONC,
1047
+ * assert target compat flags exist in the right path, and remaining structure
1048
+ * is deep-equal (ignoring the inserted keys).
1049
+ * Returns null on success, error message on failure.
1050
+ */
1051
+ export function selfCheckFix(
1052
+ original: string,
1053
+ modified: string,
1054
+ providerLabel: string,
1055
+ modelId: string,
1056
+ compatKeys: Record<string, unknown>,
1057
+ placement: "provider" | "model" | "modelOverride" = "model",
1058
+ runtimeModel?: PiModel,
1059
+ ): string | null {
1060
+ try {
1061
+ // Step 1: Parse both versions as JSONC (comments + trailing commas allowed).
1062
+ const origParsed = parseJsonc(original);
1063
+ const modParsed = parseJsonc(modified);
1064
+
1065
+ // Step 2: Validate modified file has correct structure
1066
+ const providers = asRecord(asRecord(modParsed)?.providers);
1067
+ if (!providers) {
1068
+ return "Modified file: providers object missing or invalid";
1069
+ }
1070
+ const provider = asRecord(providers[providerLabel]);
1071
+ if (!provider) {
1072
+ return `Modified file: provider "${providerLabel}" not found`;
1073
+ }
1074
+
1075
+ // Step 3: Validate models array structure
1076
+ const models = provider.models;
1077
+ if (!Array.isArray(models)) {
1078
+ return `Modified file: provider "${providerLabel}".models is not an array`;
1079
+ }
1080
+ if (models.length === 0) {
1081
+ return `Modified file: provider "${providerLabel}".models is empty`;
1082
+ }
1083
+
1084
+ // Step 4: Find and validate target model
1085
+ const targetModel = models.find((m: Record<string, unknown>) => m.id === modelId);
1086
+ if (!targetModel || typeof targetModel !== 'object') {
1087
+ return `Modified file: model "${modelId}" not found in provider`;
1088
+ }
1089
+
1090
+ // Locate the corresponding original provider/model objects. The structure
1091
+ // preservation check below may allow repaired compat values to differ, but
1092
+ // only on these exact target/provider compat objects — never on siblings.
1093
+ const origProviders = asRecord(asRecord(origParsed)?.providers);
1094
+ const origProvider = asRecord(origProviders?.[providerLabel]);
1095
+ const origModels = Array.isArray(origProvider?.models) ? origProvider.models : undefined;
1096
+ const origTargetModel = origModels?.find((m: unknown) => asRecord(m)?.id === modelId);
1097
+ const origTargetModelRecord = asRecord(origTargetModel);
1098
+ if (!origProvider || !origTargetModelRecord) {
1099
+ return `Original file: provider/model "${providerLabel}/${modelId}" not found`;
1100
+ }
1101
+
1102
+ // Step 5: Compute the EFFECTIVE merged compat using Pi's precedence:
1103
+ // provider, custom model, runtime model, then modelOverrides. The fix may
1104
+ // have written any persistent level, so validation must check what Pi will
1105
+ // actually use.
1106
+ const provCompatRaw = (provider as Record<string, unknown>).compat;
1107
+ const provCompat = (provCompatRaw && typeof provCompatRaw === 'object' && !Array.isArray(provCompatRaw))
1108
+ ? provCompatRaw as Record<string, unknown>
1109
+ : {};
1110
+ const modelCompatRaw = (targetModel as Record<string, unknown>).compat;
1111
+ if (modelCompatRaw !== undefined && (typeof modelCompatRaw !== 'object' || modelCompatRaw === null || Array.isArray(modelCompatRaw))) {
1112
+ return `Modified file: model "${modelId}" compat is not an object`;
1113
+ }
1114
+ const mdlCompat = (modelCompatRaw ?? {}) as Record<string, unknown>;
1115
+ const override = asRecord(asRecord(provider.modelOverrides)?.[modelId]);
1116
+ const overrideCompatRaw = override?.compat;
1117
+ if (overrideCompatRaw !== undefined && !asRecord(overrideCompatRaw)) {
1118
+ return `Modified file: modelOverrides["${modelId}"].compat is not an object`;
1119
+ }
1120
+ const overrideCompat = asRecord(overrideCompatRaw) ?? {};
1121
+ const mergedCompat: Record<string, unknown> = runtimeModel
1122
+ ? resolveEffectiveCompatFromConfig(runtimeModel, modParsed) as Record<string, unknown>
1123
+ : { ...provCompat, ...mdlCompat, ...overrideCompat };
1124
+
1125
+ // Step 6: Validate all inserted keys are effective in the merged compat
1126
+ for (const [k, v] of Object.entries(compatKeys)) {
1127
+ if (!(k in mergedCompat)) {
1128
+ return `Modified file: compat.${k} not found at provider or model level (insertion failed)`;
1129
+ }
1130
+ if (mergedCompat[k] !== v) {
1131
+ return `Modified file: effective compat.${k} has wrong value: expected ${JSON.stringify(v)}, got ${JSON.stringify(mergedCompat[k])}`;
1132
+ }
1133
+ }
1134
+
1135
+ // Step 7: Validate that the parsed document is unchanged except for the
1136
+ // explicitly requested scalar keys in the compat object we edited. A
1137
+ // one-way subset check is not sufficient here: it would accept accidental
1138
+ // additions such as credentials or unrelated provider fields.
1139
+ const editedCompatContainer = (value: Record<string, unknown>): boolean =>
1140
+ (placement === "provider" && value === origProvider) ||
1141
+ (placement === "model" && value === origTargetModelRecord) ||
1142
+ (placement === "modelOverride" && value === asRecord(asRecord(origProvider.modelOverrides)?.[modelId]));
1143
+
1144
+ function sameCompatObject(
1145
+ originalCompatValue: unknown,
1146
+ modifiedCompatValue: unknown,
1147
+ ): boolean {
1148
+ const originalCompat = originalCompatValue === undefined
1149
+ ? {}
1150
+ : asRecord(originalCompatValue);
1151
+ const modifiedCompat = asRecord(modifiedCompatValue);
1152
+ if (!originalCompat || !modifiedCompat) return false;
1153
+
1154
+ const allowedKeys = new Set([...Object.keys(originalCompat), ...Object.keys(compatKeys)]);
1155
+ const modifiedKeys = Object.keys(modifiedCompat);
1156
+ if (modifiedKeys.length !== allowedKeys.size || modifiedKeys.some((key) => !allowedKeys.has(key))) {
1157
+ return false;
1158
+ }
1159
+
1160
+ for (const key of allowedKeys) {
1161
+ if (Object.prototype.hasOwnProperty.call(compatKeys, key)) {
1162
+ if (!Object.prototype.hasOwnProperty.call(modifiedCompat, key) || modifiedCompat[key] !== compatKeys[key]) {
1163
+ return false;
1164
+ }
1165
+ continue;
1166
+ }
1167
+ if (!Object.prototype.hasOwnProperty.call(originalCompat, key) ||
1168
+ !sameDocumentValue(originalCompat[key], modifiedCompat[key])) {
1169
+ return false;
1170
+ }
1171
+ }
1172
+ return true;
1173
+ }
1174
+
1175
+ function sameDocumentValue(originalValue: unknown, modifiedValue: unknown): boolean {
1176
+ if (originalValue === modifiedValue) return true;
1177
+ if (typeof originalValue !== typeof modifiedValue || originalValue === null || modifiedValue === null) return false;
1178
+ if (Array.isArray(originalValue) || Array.isArray(modifiedValue)) {
1179
+ if (!Array.isArray(originalValue) || !Array.isArray(modifiedValue) || originalValue.length !== modifiedValue.length) return false;
1180
+ return originalValue.every((value, index) => sameDocumentValue(value, modifiedValue[index]));
1181
+ }
1182
+ if (typeof originalValue !== "object") return false;
1183
+
1184
+ const originalObject = originalValue as Record<string, unknown>;
1185
+ const modifiedObject = modifiedValue as Record<string, unknown>;
1186
+ const originalKeys = Object.keys(originalObject);
1187
+ const modifiedKeys = Object.keys(modifiedObject);
1188
+ if (originalKeys.length !== modifiedKeys.length || modifiedKeys.some((key) => !Object.prototype.hasOwnProperty.call(originalObject, key))) {
1189
+ return false;
1190
+ }
1191
+ return originalKeys.every((key) => sameDocumentValue(originalObject[key], modifiedObject[key]));
1192
+ }
1193
+
1194
+ function sameDocumentExceptEditedCompat(originalValue: unknown, modifiedValue: unknown): boolean {
1195
+ if (originalValue === modifiedValue) return true;
1196
+ if (typeof originalValue !== typeof modifiedValue || originalValue === null || modifiedValue === null) return false;
1197
+ if (Array.isArray(originalValue) || Array.isArray(modifiedValue)) {
1198
+ if (!Array.isArray(originalValue) || !Array.isArray(modifiedValue) || originalValue.length !== modifiedValue.length) return false;
1199
+ return originalValue.every((value, index) => sameDocumentExceptEditedCompat(value, modifiedValue[index]));
1200
+ }
1201
+ if (typeof originalValue !== "object") return false;
1202
+
1203
+ const originalObject = originalValue as Record<string, unknown>;
1204
+ const modifiedObject = modifiedValue as Record<string, unknown>;
1205
+ if (editedCompatContainer(originalObject)) {
1206
+ const originalKeys = Object.keys(originalObject).filter((key) => key !== "compat");
1207
+ const modifiedKeys = Object.keys(modifiedObject).filter((key) => key !== "compat");
1208
+ if (originalKeys.length !== modifiedKeys.length || modifiedKeys.some((key) => !Object.prototype.hasOwnProperty.call(originalObject, key))) {
1209
+ return false;
1210
+ }
1211
+ for (const key of originalKeys) {
1212
+ if (!sameDocumentExceptEditedCompat(originalObject[key], modifiedObject[key])) return false;
1213
+ }
1214
+ return sameCompatObject(originalObject.compat, modifiedObject.compat);
1215
+ }
1216
+
1217
+ const originalKeys = Object.keys(originalObject);
1218
+ const modifiedKeys = Object.keys(modifiedObject);
1219
+ if (originalKeys.length !== modifiedKeys.length || modifiedKeys.some((key) => !Object.prototype.hasOwnProperty.call(originalObject, key))) {
1220
+ return false;
1221
+ }
1222
+ return originalKeys.every((key) => sameDocumentExceptEditedCompat(originalObject[key], modifiedObject[key]));
1223
+ }
1224
+
1225
+ if (!sameDocumentExceptEditedCompat(origParsed, modParsed)) {
1226
+ return "Modified file: original structure was altered (data loss detected)";
1227
+ }
1228
+
1229
+ // Note: we intentionally do NOT enforce `modified.length >= original.length`.
1230
+ // The surgical editor may replace an existing compat value with a shorter one
1231
+ // (e.g. `false` -> `true`), which legitimately shrinks the file by a byte.
1232
+ // Real data loss / truncation is already caught by Step 7's isSubset
1233
+ // (every original key still present) and Step 8's root-bracket integrity
1234
+ // check below — a surviving length heuristic would false-positive on every
1235
+ // such value repair. (Tracked: the mofas glm-5.2 self-check failure path.)
1236
+
1237
+ // Step 8: Validate root bracket integrity with the same string/comment-aware
1238
+ // scanner used for edits. Do not count raw braces: comments or strings may
1239
+ // legitimately contain unmatched `{` / `}` bytes.
1240
+ const modifiedClean = stripJsoncComments(modified);
1241
+ const rootStart = skipJsonWhitespace(modifiedClean, 0);
1242
+ const rootEnd = findMatchingBracket(modifiedClean, rootStart);
1243
+ if (rootEnd === undefined) {
1244
+ return "Modified file: root bracket mismatch";
1245
+ }
1246
+ if (skipJsonWhitespace(modifiedClean, rootEnd + 1) !== modifiedClean.length) {
1247
+ return "Modified file: trailing non-whitespace content after root object";
1248
+ }
1249
+
1250
+ return null;
1251
+ } catch (e) {
1252
+ return `Self-check error: ${e instanceof Error ? e.message : String(e)}`;
1253
+ }
1254
+ }
1255
+
1256
+ /**
1257
+ * Serialize a compat suggestion to the JSON text that will be inserted.
1258
+ * Returns the exact key-value pairs as a formatted JSON string without outer braces.
1259
+ */
1260
+ export function formatCompatKeysForInsertion(compatKeys: Record<string, unknown>): string {
1261
+ return Object.entries(compatKeys)
1262
+ .map(([k, v]) => {
1263
+ return ` ${JSON.stringify(k)}: ${JSON.stringify(v)}`;
1264
+ })
1265
+ .join(',\n');
1266
+ }
1267
+
1268
+ export type ModelsJsonFixTransactionResult =
1269
+ | { ok: true }
1270
+ | { ok: false; postCheckError: string };
1271
+
1272
+ export type ModelsJsonFixReceiptSnapshot = {
1273
+ receipt: ModelsJsonFixReceiptV1;
1274
+ receiptPath: string;
1275
+ hash: string;
1276
+ identity: FileIdentity;
1277
+ };
1278
+
1279
+ export type ModelsJsonFixTransactionOptions = {
1280
+ onCommitted?: (writtenText: string) => Promise<void>;
1281
+ expectedCurrentHash?: string;
1282
+ /** Access mode captured by a preview, used to reject mode races. */
1283
+ expectedCurrentMode?: number;
1284
+ /** Hash of the transaction backup, used to revalidate a guarded restore. */
1285
+ expectedBackupHash?: string;
1286
+ /** Receipt identity captured before a rollback preview. */
1287
+ receiptGuard?: ModelsJsonFixReceiptSnapshot;
1288
+ purpose?: string;
1289
+ };
1290
+
1291
+ export async function assertModelsJsonFixReceiptSnapshotUnchanged(
1292
+ snapshot: ModelsJsonFixReceiptSnapshot,
1293
+ ): Promise<void> {
1294
+ const info = await lstat(snapshot.receiptPath);
1295
+ if (
1296
+ info.isSymbolicLink() ||
1297
+ !info.isFile() ||
1298
+ !sameFileIdentity(snapshot.identity, info)
1299
+ ) {
1300
+ throw new Error("fix receipt changed since the rollback preview");
1301
+ }
1302
+ const text = await readFile(snapshot.receiptPath, "utf8");
1303
+ const afterRead = await lstat(snapshot.receiptPath);
1304
+ if (
1305
+ afterRead.isSymbolicLink() ||
1306
+ !afterRead.isFile() ||
1307
+ !sameFileIdentity(info, afterRead) ||
1308
+ hashText(text) !== snapshot.hash
1309
+ ) {
1310
+ throw new Error("fix receipt changed since the rollback preview");
1311
+ }
1312
+ }
1313
+
1314
+ export async function applyModelsJsonFixTransactionUnderLock(
1315
+ modifiedText: string,
1316
+ backupPath: string,
1317
+ validateWrittenText: (writtenText: string) => string | null,
1318
+ options: ModelsJsonFixTransactionOptions = {},
1319
+ ): Promise<ModelsJsonFixTransactionResult> {
1320
+ if (options.receiptGuard) {
1321
+ await assertModelsJsonFixReceiptSnapshotUnchanged(options.receiptGuard);
1322
+ }
1323
+ const targetInfo = await lstat(MODELS_JSON_PATH);
1324
+ if (!targetInfo.isFile() || targetInfo.isSymbolicLink()) {
1325
+ throw new Error("models.json is not a regular file; no changes were made");
1326
+ }
1327
+ const originalMode = targetInfo.mode & 0o7777;
1328
+ const transactionName = options.purpose === "rollback" ? "rollback" : "fix";
1329
+ if (options.expectedCurrentMode !== undefined && options.expectedCurrentMode !== originalMode) {
1330
+ throw new Error(`models.json access mode changed since the ${transactionName} preview; no changes were made`);
1331
+ }
1332
+ const initialTargetText = await readFile(MODELS_JSON_PATH, "utf8");
1333
+ const transactionInitialHash = hashText(initialTargetText);
1334
+ const expectedCurrentHash = options.expectedCurrentHash ?? transactionInitialHash;
1335
+ const expectedBackupHash = options.expectedBackupHash ?? expectedCurrentHash;
1336
+ const assertCurrentTargetUnchanged = async (): Promise<void> => {
1337
+ const currentInfo = await lstat(MODELS_JSON_PATH);
1338
+ if (
1339
+ currentInfo.isSymbolicLink() ||
1340
+ !currentInfo.isFile() ||
1341
+ !sameFileIdentity(targetInfo, currentInfo) ||
1342
+ (currentInfo.mode & 0o7777) !== originalMode
1343
+ ) {
1344
+ throw new Error(`models.json changed during ${transactionName}; no changes were made`);
1345
+ }
1346
+ const currentText = await readFile(MODELS_JSON_PATH, "utf8");
1347
+ if (hashText(currentText) !== expectedCurrentHash) {
1348
+ throw new Error(`models.json changed since the ${transactionName} preview; no changes were made`);
1349
+ }
1350
+ };
1351
+ const assertBackupUnchanged = async (): Promise<void> => {
1352
+ const backupInfo = await lstat(backupPath);
1353
+ if (backupInfo.isSymbolicLink() || !backupInfo.isFile()) {
1354
+ throw new Error(`models.json ${transactionName} backup is not a regular file; no changes were made`);
1355
+ }
1356
+ if (expectedBackupHash !== undefined) {
1357
+ const backupText = await readFile(backupPath, "utf8");
1358
+ if (hashText(backupText) !== expectedBackupHash) {
1359
+ throw new Error(`models.json ${transactionName} backup changed during preparation; no changes were made`);
1360
+ }
1361
+ }
1362
+ };
1363
+
1364
+ await assertCurrentTargetUnchanged();
1365
+ await copyFile(MODELS_JSON_PATH, backupPath, fsConstants.COPYFILE_EXCL);
1366
+ const createdBackupInfo = await lstat(backupPath);
1367
+ if (createdBackupInfo.isSymbolicLink() || !createdBackupInfo.isFile()) {
1368
+ throw new Error(`models.json ${transactionName} backup is not a regular file; no changes were made`);
1369
+ }
1370
+ await chmod(backupPath, originalMode);
1371
+ const chmodBackupInfo = await lstat(backupPath);
1372
+ if (
1373
+ chmodBackupInfo.isSymbolicLink() ||
1374
+ !chmodBackupInfo.isFile() ||
1375
+ !sameFileIdentity(createdBackupInfo, chmodBackupInfo)
1376
+ ) {
1377
+ throw new Error(`models.json ${transactionName} backup changed during preparation; no changes were made`);
1378
+ }
1379
+ await assertBackupUnchanged();
1380
+
1381
+ const expectedModifiedHash = hashText(modifiedText);
1382
+ let targetReplaced = false;
1383
+ const restoreTargetIfUnchanged = async (): Promise<void> => {
1384
+ const currentInfo = await lstat(MODELS_JSON_PATH);
1385
+ if (
1386
+ currentInfo.isSymbolicLink() ||
1387
+ !currentInfo.isFile() ||
1388
+ (currentInfo.mode & 0o7777) !== originalMode
1389
+ ) {
1390
+ throw new Error(`models.json changed after ${transactionName} replacement; refusing to overwrite user changes`);
1391
+ }
1392
+ const currentText = await readFile(MODELS_JSON_PATH, "utf8");
1393
+ if (hashText(currentText) !== expectedModifiedHash) {
1394
+ throw new Error(`models.json changed after ${transactionName} replacement; refusing to overwrite user changes`);
1395
+ }
1396
+ await assertBackupUnchanged();
1397
+ await atomicRestoreFileFromBackup(backupPath, MODELS_JSON_PATH, originalMode, {
1398
+ identity: currentInfo,
1399
+ hash: expectedModifiedHash,
1400
+ mode: originalMode,
1401
+ backupHash: expectedBackupHash,
1402
+ });
1403
+ const restoredInfo = await lstat(MODELS_JSON_PATH);
1404
+ if (restoredInfo.isSymbolicLink() || !restoredInfo.isFile()) {
1405
+ throw new Error(`models.json restore produced a non-regular file`);
1406
+ }
1407
+ const restoredText = await readFile(MODELS_JSON_PATH, "utf8");
1408
+ if (expectedBackupHash !== undefined && hashText(restoredText) !== expectedBackupHash) {
1409
+ throw new Error(`models.json restore did not match the transaction backup`);
1410
+ }
1411
+ };
1412
+
1413
+ try {
1414
+ await assertCurrentTargetUnchanged();
1415
+ await atomicReplaceTextFilePreservingMode(
1416
+ MODELS_JSON_PATH,
1417
+ modifiedText,
1418
+ originalMode,
1419
+ options.purpose ?? "fix",
1420
+ {
1421
+ identity: targetInfo,
1422
+ hash: expectedCurrentHash,
1423
+ mode: originalMode,
1424
+ },
1425
+ );
1426
+ targetReplaced = true;
1427
+
1428
+ const writtenInfo = await lstat(MODELS_JSON_PATH);
1429
+ if (writtenInfo.isSymbolicLink() || !writtenInfo.isFile()) {
1430
+ throw new Error(`models.json became a non-regular file during ${transactionName} replacement`);
1431
+ }
1432
+ const writtenText = await readFile(MODELS_JSON_PATH, "utf8");
1433
+ const postCheckError = validateWrittenText(writtenText);
1434
+ const writtenMode = writtenInfo.mode & 0o7777;
1435
+ const writeHashError = hashText(writtenText) === expectedModifiedHash
1436
+ ? null
1437
+ : `models.json changed during ${transactionName} replacement`;
1438
+ const modeError = writtenMode === originalMode
1439
+ ? null
1440
+ : `models.json access mode changed from ${originalMode.toString(8)} to ${writtenMode.toString(8)}`;
1441
+ const effectiveError = postCheckError ?? writeHashError ?? modeError;
1442
+ if (effectiveError !== null) {
1443
+ await restoreTargetIfUnchanged();
1444
+ targetReplaced = false;
1445
+ return { ok: false, postCheckError: effectiveError };
1446
+ }
1447
+
1448
+ // Re-check immediately before committing receipt metadata. If a caller
1449
+ // changed the replacement after validation, do not mark the transaction
1450
+ // successful and do not restore over that caller's change.
1451
+ await validateAtomicTarget(MODELS_JSON_PATH, {
1452
+ identity: writtenInfo,
1453
+ hash: expectedModifiedHash,
1454
+ mode: originalMode,
1455
+ });
1456
+
1457
+ // A receipt is part of a successful fix transaction. If its atomic write
1458
+ // fails, the catch path restores the models file from the transaction
1459
+ // backup instead of leaving an un-recoverable configuration change.
1460
+ if (options.receiptGuard) {
1461
+ await assertModelsJsonFixReceiptSnapshotUnchanged(options.receiptGuard);
1462
+ }
1463
+ await options.onCommitted?.(writtenText);
1464
+ return { ok: true };
1465
+ } catch (error) {
1466
+ if (targetReplaced) {
1467
+ try {
1468
+ await restoreTargetIfUnchanged();
1469
+ targetReplaced = false;
1470
+ } catch (restoreError) {
1471
+ throw new AggregateError(
1472
+ [error, restoreError],
1473
+ "models.json fix failed and the atomic backup restore also failed",
1474
+ );
1475
+ }
1476
+ }
1477
+ throw error;
1478
+ }
1479
+ }
1480
+
1481
+ export async function applyModelsJsonFixTransaction(
1482
+ modifiedText: string,
1483
+ backupPath: string,
1484
+ validateWrittenText: (writtenText: string) => string | null,
1485
+ options: ModelsJsonFixTransactionOptions = {},
1486
+ ): Promise<ModelsJsonFixTransactionResult> {
1487
+ return withModelsJsonTransactionLock(() =>
1488
+ applyModelsJsonFixTransactionUnderLock(modifiedText, backupPath, validateWrittenText, options)
1489
+ );
1490
+ }
1491
+
1492
+ export function isReceiptScalar(value: unknown): value is ReceiptScalar {
1493
+ return value === null || typeof value === "string" || typeof value === "boolean" ||
1494
+ (typeof value === "number" && Number.isFinite(value));
1495
+ }
1496
+
1497
+ export function isReceiptScalarState(value: unknown): value is ReceiptScalarState {
1498
+ const record = asRecord(value);
1499
+ if (!record || record.present === undefined) return false;
1500
+ if (record.present === false) return Object.keys(record).every((key) => key === "present");
1501
+ return record.present === true && isReceiptScalar(record.value) && Object.keys(record).every((key) => key === "present" || key === "value");
1502
+ }
1503
+
1504
+ export function sameReceiptScalarState(left: ReceiptScalarState, right: ReceiptScalarState): boolean {
1505
+ if (left.present !== right.present) return false;
1506
+ return !left.present || left.value === (right as { present: true; value: ReceiptScalar }).value;
1507
+ }
1508
+
1509
+ export function isFixReceiptPlacement(value: unknown): value is FixReceiptPlacement {
1510
+ return value === "provider" || value === "model" || value === "modelOverride";
1511
+ }
1512
+
1513
+ export function hasReceiptReasoningProtocolChange(receipt: ModelsJsonFixReceiptV1): boolean {
1514
+ return [
1515
+ "thinkingFormat",
1516
+ "supportsReasoningEffort",
1517
+ "requiresReasoningContentOnAssistantMessages",
1518
+ ].some((key) => Object.prototype.hasOwnProperty.call(receipt.changedKeys, key));
1519
+ }
1520
+
1521
+ export function isActionableModelsJsonFixReceipt(receipt: ModelsJsonFixReceiptV1 | undefined): receipt is ModelsJsonFixReceiptV1 {
1522
+ return receipt !== undefined && receipt.status === undefined;
1523
+ }
1524
+
1525
+ export function createRollbackBackupPath(modelsPath: string = MODELS_JSON_PATH): string {
1526
+ return `${modelsPath}.backup-cache-optimizer-rollback-${backupTimestamp()}`;
1527
+ }
1528
+
1529
+ export function parseModelsJsonFixReceipt(value: unknown): ModelsJsonFixReceiptV1 | undefined {
1530
+ const record = asRecord(value);
1531
+ if (!record || record.version !== 1 || record.kind !== "pi-cache-optimizer-fix-receipt") return undefined;
1532
+ const allowedKeys = new Set([
1533
+ "version", "kind", "transactionId", "provider", "modelId", "placement",
1534
+ "targetExistedBefore", "changedKeys", "beforeHash", "afterHash", "backupFile",
1535
+ "createdAt", "appliedAt", "status", "rolledBackAt",
1536
+ ]);
1537
+ if (Object.keys(record).some((key) => !allowedKeys.has(key))) return undefined;
1538
+ if (!isSafeReceiptText(record.transactionId) || !isSafeReceiptText(record.provider) || !isSafeReceiptText(record.modelId)) return undefined;
1539
+ if (!isFixReceiptPlacement(record.placement) || typeof record.targetExistedBefore !== "boolean") return undefined;
1540
+ if (!isSha256(record.beforeHash) || !isSha256(record.afterHash) || record.beforeHash === record.afterHash) return undefined;
1541
+ if (!isSafeReceiptText(record.backupFile) || basename(record.backupFile) !== record.backupFile || record.backupFile.includes("..")) return undefined;
1542
+ if (!/^models\.json\.backup-cache-optimizer-/.test(record.backupFile)) return undefined;
1543
+ const beforeHash = typeof record.beforeHash === "string" ? record.beforeHash.toLowerCase() : "";
1544
+ const afterHash = typeof record.afterHash === "string" ? record.afterHash.toLowerCase() : "";
1545
+ if (beforeHash === afterHash) return undefined;
1546
+ if (!isReceiptTimestamp(record.createdAt) || !isReceiptTimestamp(record.appliedAt) || record.appliedAt < record.createdAt) return undefined;
1547
+ const changedKeys = asRecord(record.changedKeys);
1548
+ if (!changedKeys || Object.keys(changedKeys).length === 0) return undefined;
1549
+
1550
+ const parsedChanges: Record<string, FixReceiptCompatChange> = {};
1551
+ for (const [key, rawChange] of Object.entries(changedKeys)) {
1552
+ if (!RECEIPT_COMPAT_KEYS.has(key)) return undefined;
1553
+ const change = asRecord(rawChange);
1554
+ if (!change || !isReceiptScalarState(change.before) || !isReceiptScalarState(change.after)) return undefined;
1555
+ if (sameReceiptScalarState(change.before, change.after)) return undefined;
1556
+ parsedChanges[key] = {
1557
+ before: change.before,
1558
+ after: change.after,
1559
+ };
1560
+ }
1561
+
1562
+ if (record.status !== undefined && record.status !== "rolled_back") return undefined;
1563
+ if (record.status === "rolled_back" && (!isReceiptTimestamp(record.rolledBackAt) || record.rolledBackAt < record.appliedAt)) return undefined;
1564
+ if (record.status === undefined && record.rolledBackAt !== undefined) return undefined;
1565
+
1566
+ return {
1567
+ version: 1,
1568
+ kind: "pi-cache-optimizer-fix-receipt",
1569
+ transactionId: record.transactionId.trim(),
1570
+ provider: record.provider.trim(),
1571
+ modelId: record.modelId.trim(),
1572
+ placement: record.placement,
1573
+ targetExistedBefore: record.targetExistedBefore,
1574
+ changedKeys: parsedChanges,
1575
+ beforeHash,
1576
+ afterHash,
1577
+ backupFile: record.backupFile,
1578
+ createdAt: Number(record.createdAt),
1579
+ appliedAt: Number(record.appliedAt),
1580
+ ...(record.status === "rolled_back" ? { status: "rolled_back", rolledBackAt: Number(record.rolledBackAt) } : {}),
1581
+ };
1582
+ }
1583
+
1584
+ export function receiptBackupPath(receipt: ModelsJsonFixReceiptV1, receiptPath: string = FIX_RECEIPT_PATH): string {
1585
+ return join(dirname(receiptPath), receipt.backupFile);
1586
+ }
1587
+
1588
+ export async function writeModelsJsonFixReceipt(
1589
+ receipt: ModelsJsonFixReceiptV1,
1590
+ receiptPath: string = FIX_RECEIPT_PATH,
1591
+ expectedSnapshot?: ModelsJsonFixReceiptSnapshot,
1592
+ /** Test-only race injector; production callers leave this undefined. */
1593
+ beforeRename?: () => Promise<void>,
1594
+ ): Promise<void> {
1595
+ if (!parseModelsJsonFixReceipt(receipt)) throw new Error("invalid fix receipt");
1596
+ if (expectedSnapshot) {
1597
+ if (expectedSnapshot.receiptPath !== receiptPath) throw new Error("fix receipt path changed since the rollback preview");
1598
+ await assertModelsJsonFixReceiptSnapshotUnchanged(expectedSnapshot);
1599
+ }
1600
+ await mkdir(dirname(receiptPath), { recursive: true });
1601
+ let existingReceiptInfo: { dev: number; ino: number } | undefined;
1602
+ try {
1603
+ const info = await lstat(receiptPath);
1604
+ if (info.isSymbolicLink() || !info.isFile()) throw new Error("invalid fix receipt path");
1605
+ existingReceiptInfo = info;
1606
+ } catch (error) {
1607
+ if (getErrorCode(error) !== "ENOENT") throw error;
1608
+ }
1609
+ const mode = 0o600;
1610
+
1611
+ const tempPath = uniqueTempPath(receiptPath, "receipt");
1612
+ try {
1613
+ await writeFile(tempPath, JSON.stringify(receipt, null, 2) + "\n", { encoding: "utf8", mode, flag: "wx" });
1614
+ const tempInfo = await lstat(tempPath);
1615
+ if (tempInfo.isSymbolicLink() || !tempInfo.isFile()) throw new Error("invalid temporary fix receipt");
1616
+ await chmod(tempPath, mode);
1617
+ if (expectedSnapshot) {
1618
+ await assertModelsJsonFixReceiptSnapshotUnchanged(expectedSnapshot);
1619
+ }
1620
+ const assertReceiptDestinationUnchanged = async (): Promise<void> => {
1621
+ try {
1622
+ const currentReceiptInfo = await lstat(receiptPath);
1623
+ if (!existingReceiptInfo || !sameFileIdentity(existingReceiptInfo, currentReceiptInfo)) {
1624
+ throw new Error("fix receipt changed during atomic write");
1625
+ }
1626
+ } catch (error) {
1627
+ if (getErrorCode(error) === "ENOENT" && !existingReceiptInfo) {
1628
+ // The destination was absent both before and immediately before the
1629
+ // rename. The atomic rename will create it safely.
1630
+ } else {
1631
+ throw error;
1632
+ }
1633
+ }
1634
+ if (expectedSnapshot) await assertModelsJsonFixReceiptSnapshotUnchanged(expectedSnapshot);
1635
+ };
1636
+ await assertReceiptDestinationUnchanged();
1637
+ if (beforeRename) await beforeRename();
1638
+ await assertReceiptDestinationUnchanged();
1639
+ if (existingReceiptInfo) {
1640
+ await rename(tempPath, receiptPath);
1641
+ } else {
1642
+ await link(tempPath, receiptPath);
1643
+ await unlink(tempPath).catch((cleanupError) => {
1644
+ console.warn(`${LOG_PREFIX}: committed fix receipt but failed to remove its temporary hard link`, cleanupError);
1645
+ });
1646
+ }
1647
+ } catch (error) {
1648
+ try {
1649
+ await unlink(tempPath);
1650
+ } catch (cleanupError) {
1651
+ if (getErrorCode(cleanupError) !== "ENOENT") console.warn(`${LOG_PREFIX}: failed to remove temporary fix receipt`, cleanupError);
1652
+ }
1653
+ throw error;
1654
+ }
1655
+ }
1656
+
1657
+ export async function readModelsJsonFixReceiptSnapshot(
1658
+ receiptPath: string = FIX_RECEIPT_PATH,
1659
+ ): Promise<ModelsJsonFixReceiptSnapshot | undefined> {
1660
+ try {
1661
+ const info = await lstat(receiptPath);
1662
+ if (info.isSymbolicLink() || !info.isFile()) return undefined;
1663
+ const text = await readFile(receiptPath, "utf8");
1664
+ const afterRead = await lstat(receiptPath);
1665
+ if (
1666
+ afterRead.isSymbolicLink() ||
1667
+ !afterRead.isFile() ||
1668
+ !sameFileIdentity(info, afterRead)
1669
+ ) return undefined;
1670
+ const receipt = parseModelsJsonFixReceipt(JSON.parse(text));
1671
+ if (!receipt) return undefined;
1672
+ return {
1673
+ receipt,
1674
+ receiptPath,
1675
+ hash: hashText(text),
1676
+ identity: afterRead,
1677
+ };
1678
+ } catch {
1679
+ return undefined;
1680
+ }
1681
+ }
1682
+
1683
+ export async function readModelsJsonFixReceipt(
1684
+ receiptPath: string = FIX_RECEIPT_PATH,
1685
+ ): Promise<ModelsJsonFixReceiptV1 | undefined> {
1686
+ return (await readModelsJsonFixReceiptSnapshot(receiptPath))?.receipt;
1687
+ }
1688
+
1689
+ export async function markModelsJsonFixReceiptRolledBack(
1690
+ snapshot: ModelsJsonFixReceiptSnapshot,
1691
+ ): Promise<void> {
1692
+ const receipt = snapshot.receipt;
1693
+ await writeModelsJsonFixReceipt({
1694
+ ...receipt,
1695
+ status: "rolled_back",
1696
+ rolledBackAt: Date.now(),
1697
+ }, snapshot.receiptPath, snapshot);
1698
+ }
1699
+
1700
+ export type ReceiptCompatTarget = {
1701
+ targetExists: boolean;
1702
+ compatBrace: number;
1703
+ compatEnd: number;
1704
+ };
1705
+
1706
+ export function locateReceiptCompatTarget(
1707
+ text: string,
1708
+ provider: string,
1709
+ modelId: string,
1710
+ placement: FixReceiptPlacement,
1711
+ ): ReceiptCompatTarget | undefined {
1712
+ if (placement === "modelOverride") {
1713
+ const location = locateModelOverrideInJsonc(text, provider, modelId);
1714
+ if (
1715
+ !location ||
1716
+ location.providerKeyCount !== 1 ||
1717
+ location.modelOverridesKeyCount > 1 ||
1718
+ location.modelOverrideKeyCount > 1 ||
1719
+ location.modelOverrideCompatKeyCount > 1
1720
+ ) return undefined;
1721
+ return {
1722
+ targetExists: location.modelOverrideObjectBrace >= 0,
1723
+ compatBrace: location.modelOverrideCompatBrace,
1724
+ compatEnd: location.modelOverrideCompatEnd,
1725
+ };
1726
+ }
1727
+
1728
+ if (placement === "provider") {
1729
+ const location = locateProviderCompatInJsonc(text, provider);
1730
+ if (!location) return undefined;
1731
+ return {
1732
+ targetExists: true,
1733
+ compatBrace: location.providerCompatBrace,
1734
+ compatEnd: location.providerCompatEnd,
1735
+ };
1736
+ }
1737
+ const location = locateModelInJsonc(text, provider, modelId);
1738
+ if (!location || location.providerKeyCount !== 1) return undefined;
1739
+ // A changed-file model-scoped rollback cannot tell which duplicate model
1740
+ // object was the receipt's target. Refuse that ambiguity instead of touching
1741
+ // a later user-added definition. Provider-level receipts returned above do
1742
+ // not depend on model-array identity.
1743
+ if (location.allModelIds.filter((id) => id === modelId).length !== 1) return undefined;
1744
+ if (location.modelCompatKeyCount > 1) return undefined;
1745
+ return {
1746
+ targetExists: location.modelObjectBrace >= 0,
1747
+ compatBrace: location.compatObjectBrace,
1748
+ compatEnd: location.compatObjectEnd,
1749
+ };
1750
+ }
1751
+
1752
+ export function readReceiptCompatScalarState(
1753
+ text: string,
1754
+ target: ReceiptCompatTarget | undefined,
1755
+ key: string,
1756
+ ): ReceiptScalarState | undefined {
1757
+ if (!target || target.compatBrace < 0 || target.compatEnd <= target.compatBrace) {
1758
+ return { present: false };
1759
+ }
1760
+ const clean = stripJsoncComments(text);
1761
+ const property = findJsonObjectKey(clean, target.compatBrace, key);
1762
+ if (!property || property.keyStart >= target.compatEnd) return { present: false };
1763
+ if (property.count !== 1) return undefined;
1764
+ const valueStart = skipJsonWhitespace(clean, property.valueStart);
1765
+ const valueEnd = skipJsonValue(clean, valueStart);
1766
+ if (valueEnd === undefined || valueEnd > target.compatEnd) return undefined;
1767
+ try {
1768
+ const value = parseJsonc(text.slice(valueStart, valueEnd));
1769
+ return isReceiptScalar(value) ? { present: true, value } : undefined;
1770
+ } catch {
1771
+ return undefined;
1772
+ }
1773
+ }
1774
+
1775
+ export function createModelsJsonFixReceipt(
1776
+ originalText: string,
1777
+ modifiedText: string,
1778
+ provider: string,
1779
+ modelId: string,
1780
+ placement: FixReceiptPlacement,
1781
+ compatKeys: Record<string, unknown>,
1782
+ targetExistedBefore: boolean,
1783
+ backupPath: string,
1784
+ now: number = Date.now(),
1785
+ ): ModelsJsonFixReceiptV1 | undefined {
1786
+ const backupFile = basename(backupPath);
1787
+ if (!/^models\.json\.backup-cache-optimizer-/.test(backupFile)) return undefined;
1788
+ const beforeTarget = locateReceiptCompatTarget(originalText, provider, modelId, placement);
1789
+ const afterTarget = locateReceiptCompatTarget(modifiedText, provider, modelId, placement);
1790
+ if (targetExistedBefore && !beforeTarget?.targetExists) return undefined;
1791
+ if (!afterTarget?.targetExists) return undefined;
1792
+
1793
+ const changedKeys: Record<string, FixReceiptCompatChange> = {};
1794
+ for (const [key, afterValue] of Object.entries(compatKeys)) {
1795
+ if (!isReceiptScalar(afterValue)) return undefined;
1796
+ const before = readReceiptCompatScalarState(originalText, beforeTarget, key);
1797
+ const after = readReceiptCompatScalarState(modifiedText, afterTarget, key);
1798
+ if (!before || !after || !sameReceiptScalarState(after, { present: true, value: afterValue })) return undefined;
1799
+ if (!sameReceiptScalarState(before, after)) changedKeys[key] = { before, after };
1800
+ }
1801
+ if (Object.keys(changedKeys).length === 0) return undefined;
1802
+
1803
+ const receipt: ModelsJsonFixReceiptV1 = {
1804
+ version: 1,
1805
+ kind: "pi-cache-optimizer-fix-receipt",
1806
+ transactionId: randomUUID(),
1807
+ provider,
1808
+ modelId,
1809
+ placement,
1810
+ targetExistedBefore,
1811
+ changedKeys,
1812
+ beforeHash: hashText(originalText),
1813
+ afterHash: hashText(modifiedText),
1814
+ backupFile,
1815
+ createdAt: now,
1816
+ appliedAt: now,
1817
+ };
1818
+ return parseModelsJsonFixReceipt(receipt);
1819
+ }
1820
+
1821
+ export function composeModelsJsonReceiptRollback(
1822
+ currentText: string,
1823
+ receipt: ModelsJsonFixReceiptV1,
1824
+ ): { modifiedText: string; changed: boolean } | { error: string } {
1825
+ if (!receipt.targetExistedBefore) {
1826
+ return { error: "the fix created a new target entry and the file changed; refusing to remove user changes automatically" };
1827
+ }
1828
+
1829
+ const target = locateReceiptCompatTarget(currentText, receipt.provider, receipt.modelId, receipt.placement);
1830
+ if (!target?.targetExists) {
1831
+ return { error: "the original target entry is missing or no longer safely locatable" };
1832
+ }
1833
+ if (target.compatBrace < 0 || target.compatEnd <= target.compatBrace) {
1834
+ return { error: "the target compat object is missing or no longer safely locatable" };
1835
+ }
1836
+
1837
+ const edits: JsonPropertyEdit[] = [];
1838
+ for (const [key, change] of Object.entries(receipt.changedKeys)) {
1839
+ const current = readReceiptCompatScalarState(currentText, target, key);
1840
+ if (!current || !sameReceiptScalarState(current, change.after)) {
1841
+ return { error: `receipt-owned compat.${key} was changed after the fix; refusing to overwrite it` };
1842
+ }
1843
+ const property = locateJsonPropertyValueSpan(currentText, target.compatBrace, target.compatEnd, key);
1844
+ if (!property) return { error: `receipt-owned compat.${key} is no longer safely locatable` };
1845
+ if (change.before.present) {
1846
+ edits.push({
1847
+ start: property.valueStart,
1848
+ end: property.valueEnd,
1849
+ text: JSON.stringify(change.before.value),
1850
+ });
1851
+ } else {
1852
+ edits.push(property.removal);
1853
+ }
1854
+ }
1855
+
1856
+ const modifiedText = edits
1857
+ .sort((left, right) => right.start - left.start)
1858
+ .reduce((text, edit) => text.slice(0, edit.start) + edit.text + text.slice(edit.end), currentText);
1859
+ return { modifiedText, changed: modifiedText !== currentText };
1860
+ }
1861
+
1862
+ export function validateModelsJsonRollback(
1863
+ writtenText: string,
1864
+ receipt: ModelsJsonFixReceiptV1,
1865
+ expectedHash?: string,
1866
+ ): string | null {
1867
+ if (expectedHash !== undefined && hashText(writtenText) !== expectedHash) {
1868
+ return "rollback result hash did not match the expected pre-fix file";
1869
+ }
1870
+ try {
1871
+ parseJsonc(writtenText);
1872
+ } catch {
1873
+ return "rollback result is not valid JSONC";
1874
+ }
1875
+ // An exact hash check proves the complete pre-fix document, including a
1876
+ // newly created target entry (which intentionally did not exist before).
1877
+ if (expectedHash !== undefined) return null;
1878
+
1879
+ const target = locateReceiptCompatTarget(writtenText, receipt.provider, receipt.modelId, receipt.placement);
1880
+ if (!target?.targetExists) return "rollback result lost the original target entry";
1881
+ for (const [key, change] of Object.entries(receipt.changedKeys)) {
1882
+ const state = readReceiptCompatScalarState(writtenText, target, key);
1883
+ if (!state || !sameReceiptScalarState(state, change.before)) return `rollback result did not restore compat.${key}`;
1884
+ }
1885
+ return null;
1886
+ }
1887
+
1888
+ export type ModelsJsonRollbackPlan = {
1889
+ receiptSnapshot: ModelsJsonFixReceiptSnapshot;
1890
+ receipt: ModelsJsonFixReceiptV1;
1891
+ currentHash: string;
1892
+ modifiedText: string;
1893
+ expectedResultHash?: string;
1894
+ mode: "exact" | "surgical";
1895
+ fileMode: number;
1896
+ originalBackupPath: string;
1897
+ rollbackBackupPath: string;
1898
+ };
1899
+
1900
+ export async function prepareModelsJsonRollback(
1901
+ receiptSnapshot: ModelsJsonFixReceiptSnapshot | undefined,
1902
+ modelsPath: string = MODELS_JSON_PATH,
1903
+ ): Promise<ModelsJsonRollbackPlan | { error: string }> {
1904
+ const receipt = receiptSnapshot?.receipt;
1905
+ if (!receiptSnapshot || !isActionableModelsJsonFixReceipt(receipt)) {
1906
+ return { error: "No unapplied /cache-optimizer fix receipt was found." };
1907
+ }
1908
+
1909
+ let current: { text: string; mode: number };
1910
+ try {
1911
+ current = await readRegularTextFile(modelsPath);
1912
+ } catch {
1913
+ return { error: "models.json is missing or is not a regular file; no changes were made." };
1914
+ }
1915
+ const currentHash = hashText(current.text);
1916
+ const originalBackupPath = join(dirname(modelsPath), receipt.backupFile);
1917
+ const rollbackBackupPath = createRollbackBackupPath(modelsPath);
1918
+
1919
+ if (currentHash === receipt.afterHash) {
1920
+ let backup: { text: string; mode: number };
1921
+ try {
1922
+ backup = await readRegularTextFile(originalBackupPath);
1923
+ } catch {
1924
+ return { error: "The receipt backup is missing or is not a regular file; refusing to overwrite models.json." };
1925
+ }
1926
+ if (hashText(backup.text) !== receipt.beforeHash) {
1927
+ return { error: "The receipt backup hash does not match the recorded pre-fix file; refusing to overwrite models.json." };
1928
+ }
1929
+ try {
1930
+ parseJsonc(backup.text);
1931
+ } catch {
1932
+ return { error: "The receipt backup is not valid JSONC; refusing to overwrite models.json." };
1933
+ }
1934
+ return {
1935
+ receiptSnapshot,
1936
+ receipt,
1937
+ currentHash,
1938
+ modifiedText: backup.text,
1939
+ expectedResultHash: receipt.beforeHash,
1940
+ mode: "exact",
1941
+ fileMode: current.mode,
1942
+ originalBackupPath,
1943
+ rollbackBackupPath,
1944
+ };
1945
+ }
1946
+
1947
+ if (!receipt.targetExistedBefore) {
1948
+ return {
1949
+ 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.",
1950
+ };
1951
+ }
1952
+
1953
+ // A changed file is eligible only for a scalar, receipt-owned surgical
1954
+ // rollback. Validate the current document before showing a confirmation;
1955
+ // malformed JSONC must never be replaced by an edit derived from offsets.
1956
+ try {
1957
+ parseJsonc(current.text);
1958
+ } catch {
1959
+ 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." };
1960
+ }
1961
+
1962
+ const surgical = composeModelsJsonReceiptRollback(current.text, receipt);
1963
+ if ("error" in surgical) {
1964
+ return {
1965
+ error: `models.json changed after the fix. ${surgical.error}. Use the recorded backup for manual guidance.`,
1966
+ };
1967
+ }
1968
+ if (!surgical.changed) {
1969
+ return { error: "No receipt-owned change remains to roll back." };
1970
+ }
1971
+
1972
+ return {
1973
+ receiptSnapshot,
1974
+ receipt,
1975
+ currentHash,
1976
+ modifiedText: surgical.modifiedText,
1977
+ mode: "surgical",
1978
+ fileMode: current.mode,
1979
+ originalBackupPath,
1980
+ rollbackBackupPath,
1981
+ };
1982
+ }