pi-cache-optimizer 2.8.15 → 2.8.17
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 +19 -17
- package/README.zh-CN.md +19 -17
- package/index.ts +248 -11380
- package/package.json +8 -6
- package/src/adapters.ts +250 -0
- package/src/atomic-fs.ts +300 -0
- package/src/command-completion.ts +114 -0
- package/src/command.ts +1012 -0
- package/src/common.ts +49 -0
- package/src/compat-advice.ts +320 -0
- package/src/compat-config.ts +99 -0
- package/src/config.ts +374 -0
- package/src/diagnostics.ts +757 -0
- package/src/fix-types.ts +66 -0
- package/src/jsonc.ts +602 -0
- package/src/model-detect.ts +66 -0
- package/src/model-identity.ts +368 -0
- package/src/models-json-fix.ts +1982 -0
- package/src/paths.ts +18 -0
- package/src/prompt-cache-key-config.ts +406 -0
- package/src/prompt-rewrite.ts +294 -0
- package/src/provider-errors.ts +328 -0
- package/src/request-payload.ts +197 -0
- package/src/request-state.ts +102 -0
- package/src/retention.ts +82 -0
- package/src/routing.ts +551 -0
- package/src/stats-report.ts +771 -0
- package/src/stats-store.ts +500 -0
- package/src/tool-ordering.ts +209 -0
- package/src/usage.ts +172 -0
|
@@ -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
|
+
}
|