pi-cache-optimizer 2.8.16 → 2.8.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -17
- package/README.zh-CN.md +21 -17
- package/index.ts +260 -11412
- 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
package/src/fix-types.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { isNonEmptyString } from "./common.ts";
|
|
2
|
+
|
|
3
|
+
export type FixReceiptPlacement = "provider" | "model" | "modelOverride";
|
|
4
|
+
|
|
5
|
+
export type ReceiptScalar = string | number | boolean | null;
|
|
6
|
+
|
|
7
|
+
export type ReceiptScalarState =
|
|
8
|
+
| { present: false }
|
|
9
|
+
| { present: true; value: ReceiptScalar };
|
|
10
|
+
|
|
11
|
+
export type FixReceiptCompatChange = {
|
|
12
|
+
before: ReceiptScalarState;
|
|
13
|
+
after: ReceiptScalarState;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type ModelsJsonFixReceiptV1 = {
|
|
17
|
+
version: 1;
|
|
18
|
+
kind: "pi-cache-optimizer-fix-receipt";
|
|
19
|
+
transactionId: string;
|
|
20
|
+
provider: string;
|
|
21
|
+
modelId: string;
|
|
22
|
+
placement: FixReceiptPlacement;
|
|
23
|
+
targetExistedBefore: boolean;
|
|
24
|
+
changedKeys: Record<string, FixReceiptCompatChange>;
|
|
25
|
+
beforeHash: string;
|
|
26
|
+
afterHash: string;
|
|
27
|
+
backupFile: string;
|
|
28
|
+
createdAt: number;
|
|
29
|
+
appliedAt: number;
|
|
30
|
+
status?: "rolled_back";
|
|
31
|
+
rolledBackAt?: number;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// Receipts are trusted input to a later rollback command, so their key set is
|
|
35
|
+
// deliberately narrower than the full models.json compat vocabulary. This
|
|
36
|
+
// prevents a corrupted or hand-edited receipt from turning rollback into a
|
|
37
|
+
// way to rewrite credentials, headers, routing, or arbitrary provider fields.
|
|
38
|
+
export const RECEIPT_COMPAT_KEYS = new Set([
|
|
39
|
+
"sendSessionAffinityHeaders",
|
|
40
|
+
"supportsLongCacheRetention",
|
|
41
|
+
"requiresReasoningContentOnAssistantMessages",
|
|
42
|
+
"forceAdaptiveThinking",
|
|
43
|
+
"allowEmptySignature",
|
|
44
|
+
"thinkingFormat",
|
|
45
|
+
"supportsReasoningEffort",
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
export interface FixSuggestion {
|
|
49
|
+
providerLabel: string;
|
|
50
|
+
modelId: string;
|
|
51
|
+
compatKeys: Record<string, unknown>;
|
|
52
|
+
/** Runtime-observed failures must never broaden a model-specific fix. */
|
|
53
|
+
forceModelLevel?: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function isSha256(value: unknown): value is string {
|
|
57
|
+
return typeof value === "string" && /^[0-9a-f]{64}$/i.test(value);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function isSafeReceiptText(value: unknown): value is string {
|
|
61
|
+
return isNonEmptyString(value) && !/[\u0000-\u001f\u007f]/.test(value);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function isReceiptTimestamp(value: unknown): value is number {
|
|
65
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
66
|
+
}
|
package/src/jsonc.ts
ADDED
|
@@ -0,0 +1,602 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
|
|
3
|
+
// ── String-aware JSONC scanning primitives ─────────────────────────
|
|
4
|
+
//
|
|
5
|
+
// These operate on comment-stripped text produced by stripJsoncComments()
|
|
6
|
+
// (which preserves byte offsets), so every offset they return is also valid
|
|
7
|
+
// in the original text. All scanning skips string literals, so braces or
|
|
8
|
+
// brackets inside string values (e.g. apiKeyCommand shell snippets) cannot
|
|
9
|
+
// corrupt depth tracking.
|
|
10
|
+
|
|
11
|
+
export function isJsonWhitespace(ch: string): boolean {
|
|
12
|
+
return ch === " " || ch === "\n" || ch === "\r" || ch === "\t";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function skipJsonWhitespace(text: string, pos: number): number {
|
|
16
|
+
while (pos < text.length && isJsonWhitespace(text[pos])) pos++;
|
|
17
|
+
return pos;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Read a JSON string literal starting at `pos` (which must be `"`).
|
|
22
|
+
* Returns the decoded value and the offset just past the closing quote,
|
|
23
|
+
* or undefined when the literal is unterminated/malformed.
|
|
24
|
+
*/
|
|
25
|
+
export function readJsonStringLiteral(text: string, pos: number): { value: string; end: number } | undefined {
|
|
26
|
+
if (text[pos] !== '"') return undefined;
|
|
27
|
+
let i = pos + 1;
|
|
28
|
+
let value = "";
|
|
29
|
+
while (i < text.length) {
|
|
30
|
+
const ch = text[i];
|
|
31
|
+
if (ch === "\\") {
|
|
32
|
+
const next = text[i + 1];
|
|
33
|
+
if (next === undefined) return undefined;
|
|
34
|
+
if (next === "u") {
|
|
35
|
+
const hex = text.slice(i + 2, i + 6);
|
|
36
|
+
if (!/^[0-9a-fA-F]{4}$/.test(hex)) return undefined;
|
|
37
|
+
value += String.fromCharCode(parseInt(hex, 16));
|
|
38
|
+
i += 6;
|
|
39
|
+
} else {
|
|
40
|
+
if (next === "n") value += "\n";
|
|
41
|
+
else if (next === "t") value += "\t";
|
|
42
|
+
else if (next === "r") value += "\r";
|
|
43
|
+
else if (next === "b") value += "\b";
|
|
44
|
+
else if (next === "f") value += "\f";
|
|
45
|
+
else value += next; // ", \\, / and lenient passthrough
|
|
46
|
+
i += 2;
|
|
47
|
+
}
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (ch === '"') return { value, end: i + 1 };
|
|
51
|
+
value += ch;
|
|
52
|
+
i++;
|
|
53
|
+
}
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Find the offset of the `}` / `]` matching the opener at `openPos`,
|
|
59
|
+
* skipping string literals. Returns undefined on imbalance.
|
|
60
|
+
*/
|
|
61
|
+
export function findMatchingBracket(text: string, openPos: number): number | undefined {
|
|
62
|
+
const open = text[openPos];
|
|
63
|
+
if (open !== "{" && open !== "[") return undefined;
|
|
64
|
+
let depth = 0;
|
|
65
|
+
let i = openPos;
|
|
66
|
+
while (i < text.length) {
|
|
67
|
+
const ch = text[i];
|
|
68
|
+
if (ch === '"') {
|
|
69
|
+
const str = readJsonStringLiteral(text, i);
|
|
70
|
+
if (!str) return undefined;
|
|
71
|
+
i = str.end;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (ch === "{" || ch === "[") depth++;
|
|
75
|
+
else if (ch === "}" || ch === "]") {
|
|
76
|
+
depth--;
|
|
77
|
+
if (depth === 0) return i;
|
|
78
|
+
}
|
|
79
|
+
i++;
|
|
80
|
+
}
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Skip one JSON value starting at/after `pos`; returns the offset just past it. */
|
|
85
|
+
export function skipJsonValue(text: string, pos: number): number | undefined {
|
|
86
|
+
pos = skipJsonWhitespace(text, pos);
|
|
87
|
+
const ch = text[pos];
|
|
88
|
+
if (ch === '"') {
|
|
89
|
+
const str = readJsonStringLiteral(text, pos);
|
|
90
|
+
return str?.end;
|
|
91
|
+
}
|
|
92
|
+
if (ch === "{" || ch === "[") {
|
|
93
|
+
const end = findMatchingBracket(text, pos);
|
|
94
|
+
return end === undefined ? undefined : end + 1;
|
|
95
|
+
}
|
|
96
|
+
let i = pos;
|
|
97
|
+
while (i < text.length && !",}]".includes(text[i]) && !isJsonWhitespace(text[i])) i++;
|
|
98
|
+
return i > pos ? i : undefined;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Find a top-level key in the object whose `{` is at `openBracePos`.
|
|
103
|
+
* Only direct children are considered (nested values are skipped whole).
|
|
104
|
+
* When duplicate keys exist, return the last one so scanning follows
|
|
105
|
+
* JSON.parse/Pi's effective last-definition-wins behavior. `count` lets
|
|
106
|
+
* callers refuse an ambiguous surgical edit rather than silently selecting a
|
|
107
|
+
* user-added duplicate. Return undefined when the key is absent or the object
|
|
108
|
+
* is malformed.
|
|
109
|
+
*/
|
|
110
|
+
export function findJsonObjectKey(
|
|
111
|
+
text: string,
|
|
112
|
+
openBracePos: number,
|
|
113
|
+
targetKey: string,
|
|
114
|
+
): { keyStart: number; valueStart: number; count: number } | undefined {
|
|
115
|
+
if (text[openBracePos] !== "{") return undefined;
|
|
116
|
+
let i = openBracePos + 1;
|
|
117
|
+
let found: { keyStart: number; valueStart: number; count: number } | undefined;
|
|
118
|
+
let count = 0;
|
|
119
|
+
while (i < text.length) {
|
|
120
|
+
i = skipJsonWhitespace(text, i);
|
|
121
|
+
if (i >= text.length) return undefined;
|
|
122
|
+
if (text[i] === "}") return found;
|
|
123
|
+
if (text[i] === ",") {
|
|
124
|
+
i++;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (text[i] !== '"') return undefined; // unexpected token — refuse to guess
|
|
128
|
+
const keyStart = i;
|
|
129
|
+
const key = readJsonStringLiteral(text, i);
|
|
130
|
+
if (!key) return undefined;
|
|
131
|
+
i = skipJsonWhitespace(text, key.end);
|
|
132
|
+
if (text[i] !== ":") return undefined;
|
|
133
|
+
i = skipJsonWhitespace(text, i + 1);
|
|
134
|
+
if (key.value === targetKey) {
|
|
135
|
+
count++;
|
|
136
|
+
found = { keyStart, valueStart: i, count };
|
|
137
|
+
}
|
|
138
|
+
const after = skipJsonValue(text, i);
|
|
139
|
+
if (after === undefined) return undefined;
|
|
140
|
+
i = after;
|
|
141
|
+
}
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Leading whitespace of the line containing offset `pos` (up to `pos`). */
|
|
146
|
+
export function lineIndentOf(text: string, pos: number): string {
|
|
147
|
+
let lineStart = text.lastIndexOf("\n", pos - 1);
|
|
148
|
+
lineStart = lineStart < 0 ? 0 : lineStart + 1;
|
|
149
|
+
const m = text.slice(lineStart, pos).match(/^[ \t]*/);
|
|
150
|
+
return m ? m[0] : "";
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Indentation used by the first line inside the object spanning
|
|
155
|
+
* `openBrace`..`closeBrace` in the ORIGINAL text. Falls back to the
|
|
156
|
+
* opener's line indent plus two spaces for single-line objects.
|
|
157
|
+
*/
|
|
158
|
+
export function deriveInnerIndent(text: string, openBrace: number, closeBrace: number): string {
|
|
159
|
+
const nl = text.indexOf("\n", openBrace + 1);
|
|
160
|
+
if (nl >= 0 && nl < closeBrace) {
|
|
161
|
+
let i = nl + 1;
|
|
162
|
+
let ws = "";
|
|
163
|
+
while (i < text.length && (text[i] === " " || text[i] === "\t")) {
|
|
164
|
+
ws += text[i];
|
|
165
|
+
i++;
|
|
166
|
+
}
|
|
167
|
+
if (ws.length > 0) return ws;
|
|
168
|
+
}
|
|
169
|
+
return lineIndentOf(text, openBrace) + " ";
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Strip JSONC comments from text, replacing them with spaces.
|
|
174
|
+
* Handles string literals, escaped quotes, // line comments, /* block comments *\/.
|
|
175
|
+
* Returns the cleaned text with same line/column positions.
|
|
176
|
+
*/
|
|
177
|
+
export function stripJsoncComments(text: string): string {
|
|
178
|
+
const out: string[] = [];
|
|
179
|
+
let i = 0;
|
|
180
|
+
while (i < text.length) {
|
|
181
|
+
const ch = text[i];
|
|
182
|
+
|
|
183
|
+
if (ch === '"') {
|
|
184
|
+
// String literal — copy byte-for-byte until the closing quote.
|
|
185
|
+
// Escaped quotes/slashes must not be mistaken for comment delimiters.
|
|
186
|
+
out.push(ch);
|
|
187
|
+
i++;
|
|
188
|
+
while (i < text.length) {
|
|
189
|
+
const sc = text[i];
|
|
190
|
+
out.push(sc);
|
|
191
|
+
i++;
|
|
192
|
+
if (sc === '\\' && i < text.length) {
|
|
193
|
+
out.push(text[i]);
|
|
194
|
+
i++;
|
|
195
|
+
} else if (sc === '"') {
|
|
196
|
+
break;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (ch === '/' && i + 1 < text.length && text[i + 1] === '/') {
|
|
203
|
+
// Line comment — replace BOTH slashes and every comment byte with
|
|
204
|
+
// spaces, but leave the newline to be copied by the normal path.
|
|
205
|
+
out.push(' ', ' ');
|
|
206
|
+
i += 2;
|
|
207
|
+
while (i < text.length && text[i] !== '\n') {
|
|
208
|
+
out.push(' ');
|
|
209
|
+
i++;
|
|
210
|
+
}
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (ch === '/' && i + 1 < text.length && text[i + 1] === '*') {
|
|
215
|
+
// Block comment — replace every byte with a space except newlines.
|
|
216
|
+
// This deliberately preserves text.length and all structural offsets.
|
|
217
|
+
out.push(' ', ' ');
|
|
218
|
+
i += 2;
|
|
219
|
+
while (i < text.length) {
|
|
220
|
+
if (text[i] === '*' && i + 1 < text.length && text[i + 1] === '/') {
|
|
221
|
+
out.push(' ', ' ');
|
|
222
|
+
i += 2;
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
225
|
+
out.push(text[i] === '\n' ? '\n' : ' ');
|
|
226
|
+
i++;
|
|
227
|
+
}
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
out.push(ch);
|
|
232
|
+
i++;
|
|
233
|
+
}
|
|
234
|
+
return out.join('');
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Remove JSONC trailing commas from already comment-stripped text.
|
|
239
|
+
* The returned text stays length-preserving (commas become spaces), which
|
|
240
|
+
* gives JSON.parse a tolerant JSONC surface without affecting diagnostics.
|
|
241
|
+
*/
|
|
242
|
+
export function stripJsoncTrailingCommas(text: string): string {
|
|
243
|
+
const chars = text.split("");
|
|
244
|
+
let i = 0;
|
|
245
|
+
while (i < chars.length) {
|
|
246
|
+
if (chars[i] === '"') {
|
|
247
|
+
const str = readJsonStringLiteral(text, i);
|
|
248
|
+
if (!str) break;
|
|
249
|
+
i = str.end;
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (chars[i] === ',') {
|
|
254
|
+
let j = i + 1;
|
|
255
|
+
while (j < chars.length && isJsonWhitespace(chars[j])) j++;
|
|
256
|
+
if (chars[j] === '}' || chars[j] === ']') chars[i] = ' ';
|
|
257
|
+
}
|
|
258
|
+
i++;
|
|
259
|
+
}
|
|
260
|
+
return chars.join('');
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export function parseJsonc(text: string): unknown {
|
|
264
|
+
return JSON.parse(stripJsoncTrailingCommas(stripJsoncComments(text)));
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* JSONC scanner: locate the provider block and model entry in models.json text.
|
|
269
|
+
* Returns the byte offsets for surgical insertion, or undefined if ambiguous.
|
|
270
|
+
*/
|
|
271
|
+
export interface ModelNodeLocation {
|
|
272
|
+
/** Number of exact provider keys found in the root providers object. */
|
|
273
|
+
providerKeyCount: number;
|
|
274
|
+
/** Offset of the model object's opening `{` */
|
|
275
|
+
modelObjectBrace: number;
|
|
276
|
+
/** Offset of the model object's closing `}` */
|
|
277
|
+
modelObjectEnd: number;
|
|
278
|
+
/** Offset of the "compat" key start (the `"`), or -1 if compat doesn't exist */
|
|
279
|
+
compatKeyStart: number;
|
|
280
|
+
/** Offset of the compat object's opening `{`, or -1 if compat doesn't exist */
|
|
281
|
+
compatObjectBrace: number;
|
|
282
|
+
/** Offset of the compat object's closing `}`, or -1 */
|
|
283
|
+
compatObjectEnd: number;
|
|
284
|
+
/** Indentation string to use for inserted lines (derived from surrounding context) */
|
|
285
|
+
indent: string;
|
|
286
|
+
/** Offset of the provider object's opening `{` */
|
|
287
|
+
providerObjectBrace: number;
|
|
288
|
+
/** Offset of the provider object's closing `}` */
|
|
289
|
+
providerObjectEnd: number;
|
|
290
|
+
/** Offset of the provider-level compat object's opening `{`, or -1 if absent */
|
|
291
|
+
providerCompatBrace: number;
|
|
292
|
+
/** Offset of the provider-level compat object's closing `}`, or -1 if absent */
|
|
293
|
+
providerCompatEnd: number;
|
|
294
|
+
/** Offset of the target modelOverrides entry's opening `{`, or -1 if absent */
|
|
295
|
+
modelOverrideObjectBrace: number;
|
|
296
|
+
/** Offset of the target modelOverrides entry's closing `}`, or -1 if absent */
|
|
297
|
+
modelOverrideObjectEnd: number;
|
|
298
|
+
/** Offset of the target override compat object's opening `{`, or -1 if absent */
|
|
299
|
+
modelOverrideCompatBrace: number;
|
|
300
|
+
/** Offset of the target override compat object's closing `}`, or -1 if absent */
|
|
301
|
+
modelOverrideCompatEnd: number;
|
|
302
|
+
/** Number of exact modelOverrides keys for this model id. */
|
|
303
|
+
modelOverrideKeyCount: number;
|
|
304
|
+
/** Number of exact compat keys in the selected model object. */
|
|
305
|
+
modelCompatKeyCount: number;
|
|
306
|
+
/** Number of exact provider compat keys. */
|
|
307
|
+
providerCompatKeyCount: number;
|
|
308
|
+
/** All model ids found in this provider's models array (for placement safety analysis) */
|
|
309
|
+
allModelIds: string[];
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export interface ModelOverrideNodeLocation {
|
|
313
|
+
providerKeyCount: number;
|
|
314
|
+
modelOverridesKeyCount: number;
|
|
315
|
+
modelOverrideKeyCount: number;
|
|
316
|
+
modelOverrideCompatKeyCount: number;
|
|
317
|
+
providerObjectBrace: number;
|
|
318
|
+
providerObjectEnd: number;
|
|
319
|
+
modelOverridesObjectBrace: number;
|
|
320
|
+
modelOverridesObjectEnd: number;
|
|
321
|
+
modelOverrideObjectBrace: number;
|
|
322
|
+
modelOverrideObjectEnd: number;
|
|
323
|
+
modelOverrideCompatBrace: number;
|
|
324
|
+
modelOverrideCompatEnd: number;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export interface ProviderCompatNodeLocation {
|
|
328
|
+
providerObjectBrace: number;
|
|
329
|
+
providerObjectEnd: number;
|
|
330
|
+
providerCompatBrace: number;
|
|
331
|
+
providerCompatEnd: number;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Locate a unique provider and its direct compat object without requiring models[]. */
|
|
335
|
+
export function locateProviderCompatInJsonc(
|
|
336
|
+
text: string,
|
|
337
|
+
providerLabel: string,
|
|
338
|
+
): ProviderCompatNodeLocation | undefined {
|
|
339
|
+
const clean = stripJsoncComments(text);
|
|
340
|
+
const rootBrace = skipJsonWhitespace(clean, 0);
|
|
341
|
+
if (clean[rootBrace] !== "{") return undefined;
|
|
342
|
+
const providersKey = findJsonObjectKey(clean, rootBrace, "providers");
|
|
343
|
+
if (!providersKey || providersKey.count !== 1) return undefined;
|
|
344
|
+
const providersBrace = skipJsonWhitespace(clean, providersKey.valueStart);
|
|
345
|
+
if (clean[providersBrace] !== "{") return undefined;
|
|
346
|
+
const providersEnd = findMatchingBracket(clean, providersBrace);
|
|
347
|
+
if (providersEnd === undefined) return undefined;
|
|
348
|
+
const providerKey = findJsonObjectKey(clean, providersBrace, providerLabel);
|
|
349
|
+
if (!providerKey || providerKey.count !== 1 || providerKey.keyStart > providersEnd) return undefined;
|
|
350
|
+
const providerObjectBrace = skipJsonWhitespace(clean, providerKey.valueStart);
|
|
351
|
+
if (clean[providerObjectBrace] !== "{") return undefined;
|
|
352
|
+
const providerObjectEnd = findMatchingBracket(clean, providerObjectBrace);
|
|
353
|
+
if (providerObjectEnd === undefined || providerObjectEnd > providersEnd) return undefined;
|
|
354
|
+
|
|
355
|
+
for (const key of ["models", "modelOverrides"]) {
|
|
356
|
+
const child = findJsonObjectKey(clean, providerObjectBrace, key);
|
|
357
|
+
if (child?.count && child.count > 1) return undefined;
|
|
358
|
+
}
|
|
359
|
+
const compatKey = findJsonObjectKey(clean, providerObjectBrace, "compat");
|
|
360
|
+
if (compatKey?.count && compatKey.count > 1) return undefined;
|
|
361
|
+
if (!compatKey) {
|
|
362
|
+
return { providerObjectBrace, providerObjectEnd, providerCompatBrace: -1, providerCompatEnd: -1 };
|
|
363
|
+
}
|
|
364
|
+
const providerCompatBrace = skipJsonWhitespace(clean, compatKey.valueStart);
|
|
365
|
+
if (clean[providerCompatBrace] !== "{") return undefined;
|
|
366
|
+
const providerCompatEnd = findMatchingBracket(clean, providerCompatBrace);
|
|
367
|
+
if (providerCompatEnd === undefined || providerCompatEnd > providerObjectEnd) return undefined;
|
|
368
|
+
return { providerObjectBrace, providerObjectEnd, providerCompatBrace, providerCompatEnd };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** Locate a provider and optional modelOverrides entry without requiring models[]. */
|
|
372
|
+
export function locateModelOverrideInJsonc(
|
|
373
|
+
text: string,
|
|
374
|
+
providerLabel: string,
|
|
375
|
+
modelId: string,
|
|
376
|
+
): ModelOverrideNodeLocation | undefined {
|
|
377
|
+
const clean = stripJsoncComments(text);
|
|
378
|
+
const rootBrace = skipJsonWhitespace(clean, 0);
|
|
379
|
+
if (clean[rootBrace] !== "{") return undefined;
|
|
380
|
+
const providersKey = findJsonObjectKey(clean, rootBrace, "providers");
|
|
381
|
+
if (!providersKey || providersKey.count !== 1) return undefined;
|
|
382
|
+
const providersBrace = skipJsonWhitespace(clean, providersKey.valueStart);
|
|
383
|
+
if (clean[providersBrace] !== "{") return undefined;
|
|
384
|
+
const providersEnd = findMatchingBracket(clean, providersBrace);
|
|
385
|
+
if (providersEnd === undefined) return undefined;
|
|
386
|
+
const providerKey = findJsonObjectKey(clean, providersBrace, providerLabel);
|
|
387
|
+
if (!providerKey || providerKey.count !== 1 || providerKey.keyStart > providersEnd) return undefined;
|
|
388
|
+
const providerObjectBrace = skipJsonWhitespace(clean, providerKey.valueStart);
|
|
389
|
+
if (clean[providerObjectBrace] !== "{") return undefined;
|
|
390
|
+
const providerObjectEnd = findMatchingBracket(clean, providerObjectBrace);
|
|
391
|
+
if (providerObjectEnd === undefined || providerObjectEnd > providersEnd) return undefined;
|
|
392
|
+
const providerCompatKey = findJsonObjectKey(clean, providerObjectBrace, "compat");
|
|
393
|
+
if (providerCompatKey?.count && providerCompatKey.count > 1) return undefined;
|
|
394
|
+
const modelsKey = findJsonObjectKey(clean, providerObjectBrace, "models");
|
|
395
|
+
if (modelsKey?.count && modelsKey.count > 1) return undefined;
|
|
396
|
+
|
|
397
|
+
let modelOverridesObjectBrace = -1;
|
|
398
|
+
let modelOverridesObjectEnd = -1;
|
|
399
|
+
let modelOverrideObjectBrace = -1;
|
|
400
|
+
let modelOverrideObjectEnd = -1;
|
|
401
|
+
let modelOverrideCompatBrace = -1;
|
|
402
|
+
let modelOverrideCompatEnd = -1;
|
|
403
|
+
let modelOverrideKeyCount = 0;
|
|
404
|
+
let modelOverrideCompatKeyCount = 0;
|
|
405
|
+
const overridesKey = findJsonObjectKey(clean, providerObjectBrace, "modelOverrides");
|
|
406
|
+
if (overridesKey?.count && overridesKey.count > 1) return undefined;
|
|
407
|
+
if (overridesKey && overridesKey.keyStart < providerObjectEnd) {
|
|
408
|
+
const brace = skipJsonWhitespace(clean, overridesKey.valueStart);
|
|
409
|
+
if (clean[brace] !== "{") return undefined;
|
|
410
|
+
const end = findMatchingBracket(clean, brace);
|
|
411
|
+
if (end === undefined || end > providerObjectEnd) return undefined;
|
|
412
|
+
modelOverridesObjectBrace = brace;
|
|
413
|
+
modelOverridesObjectEnd = end;
|
|
414
|
+
|
|
415
|
+
const overrideKey = findJsonObjectKey(clean, brace, modelId);
|
|
416
|
+
modelOverrideKeyCount = overrideKey?.count ?? 0;
|
|
417
|
+
if (overrideKey?.count && overrideKey.count > 1) return undefined;
|
|
418
|
+
if (overrideKey && overrideKey.keyStart < end) {
|
|
419
|
+
const entryBrace = skipJsonWhitespace(clean, overrideKey.valueStart);
|
|
420
|
+
if (clean[entryBrace] !== "{") return undefined;
|
|
421
|
+
const entryEnd = findMatchingBracket(clean, entryBrace);
|
|
422
|
+
if (entryEnd === undefined || entryEnd > end) return undefined;
|
|
423
|
+
modelOverrideObjectBrace = entryBrace;
|
|
424
|
+
modelOverrideObjectEnd = entryEnd;
|
|
425
|
+
|
|
426
|
+
const compatKey = findJsonObjectKey(clean, entryBrace, "compat");
|
|
427
|
+
modelOverrideCompatKeyCount = compatKey?.count ?? 0;
|
|
428
|
+
if (compatKey?.count && compatKey.count > 1) return undefined;
|
|
429
|
+
if (compatKey && compatKey.keyStart < entryEnd) {
|
|
430
|
+
const compatBrace = skipJsonWhitespace(clean, compatKey.valueStart);
|
|
431
|
+
if (clean[compatBrace] !== "{") return undefined;
|
|
432
|
+
const compatEnd = findMatchingBracket(clean, compatBrace);
|
|
433
|
+
if (compatEnd === undefined || compatEnd > entryEnd) return undefined;
|
|
434
|
+
modelOverrideCompatBrace = compatBrace;
|
|
435
|
+
modelOverrideCompatEnd = compatEnd;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
return {
|
|
441
|
+
providerKeyCount: providerKey.count,
|
|
442
|
+
modelOverridesKeyCount: overridesKey?.count ?? 0,
|
|
443
|
+
modelOverrideKeyCount,
|
|
444
|
+
modelOverrideCompatKeyCount,
|
|
445
|
+
providerObjectBrace,
|
|
446
|
+
providerObjectEnd,
|
|
447
|
+
modelOverridesObjectBrace,
|
|
448
|
+
modelOverridesObjectEnd,
|
|
449
|
+
modelOverrideObjectBrace,
|
|
450
|
+
modelOverrideObjectEnd,
|
|
451
|
+
modelOverrideCompatBrace,
|
|
452
|
+
modelOverrideCompatEnd,
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Deep-equal comparison of two values, used for post-write self-check.
|
|
458
|
+
* Compares all keys recursively, allowing `extraKeys` to be present in `a` but not in `b`.
|
|
459
|
+
*/
|
|
460
|
+
export function deepEqualIgnoringKeys(a: unknown, b: unknown, extraKeys: string[]): boolean {
|
|
461
|
+
if (a === b) return true;
|
|
462
|
+
if (typeof a !== typeof b) return false;
|
|
463
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
464
|
+
if (a.length !== b.length) return false;
|
|
465
|
+
for (let i = 0; i < a.length; i++) {
|
|
466
|
+
if (!deepEqualIgnoringKeys(a[i], b[i], extraKeys)) return false;
|
|
467
|
+
}
|
|
468
|
+
return true;
|
|
469
|
+
}
|
|
470
|
+
if (typeof a === 'object' && a !== null && typeof b === 'object' && b !== null) {
|
|
471
|
+
const aKeys = Object.keys(a as Record<string, unknown>).filter(k => !extraKeys.includes(k));
|
|
472
|
+
const bKeys = Object.keys(b as Record<string, unknown>);
|
|
473
|
+
if (aKeys.length !== bKeys.length) return false;
|
|
474
|
+
for (const k of aKeys) {
|
|
475
|
+
if (!(k in (b as Record<string, unknown>))) return false;
|
|
476
|
+
if (!deepEqualIgnoringKeys(
|
|
477
|
+
(a as Record<string, unknown>)[k],
|
|
478
|
+
(b as Record<string, unknown>)[k],
|
|
479
|
+
extraKeys,
|
|
480
|
+
)) return false;
|
|
481
|
+
}
|
|
482
|
+
return true;
|
|
483
|
+
}
|
|
484
|
+
return false;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
export function findExistingCompatKeysInJsonc(
|
|
488
|
+
original: string,
|
|
489
|
+
compatBrace: number,
|
|
490
|
+
compatEnd: number,
|
|
491
|
+
keys: string[],
|
|
492
|
+
): string[] {
|
|
493
|
+
if (compatBrace < 0 || compatEnd <= compatBrace) return [];
|
|
494
|
+
const clean = stripJsoncComments(original);
|
|
495
|
+
return keys.filter((key) => {
|
|
496
|
+
const found = findJsonObjectKey(clean, compatBrace, key);
|
|
497
|
+
return !!found && found.keyStart < compatEnd;
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
export function maskJsonSyntaxPreservingComments(text: string, start: number, end: number): string {
|
|
502
|
+
// Replace only JSON syntax/value bytes. Comments are copied verbatim so a
|
|
503
|
+
// user explanation attached to a receipt-owned key survives a surgical
|
|
504
|
+
// rollback. Newlines are also retained to keep line structure unchanged.
|
|
505
|
+
const output = text.slice(start, end).split("");
|
|
506
|
+
let i = start;
|
|
507
|
+
let inLineComment = false;
|
|
508
|
+
let inBlockComment = false;
|
|
509
|
+
let inString = false;
|
|
510
|
+
let escaped = false;
|
|
511
|
+
while (i < end) {
|
|
512
|
+
const ch = text[i];
|
|
513
|
+
const next = text[i + 1];
|
|
514
|
+
const offset = i - start;
|
|
515
|
+
if (inLineComment) {
|
|
516
|
+
if (ch === "\n" || ch === "\r") inLineComment = false;
|
|
517
|
+
i++;
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
520
|
+
if (inBlockComment) {
|
|
521
|
+
if (ch === "*" && next === "/") {
|
|
522
|
+
i += 2;
|
|
523
|
+
inBlockComment = false;
|
|
524
|
+
} else {
|
|
525
|
+
i++;
|
|
526
|
+
}
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
529
|
+
if (inString) {
|
|
530
|
+
if (ch === "\n" || ch === "\r") {
|
|
531
|
+
i++;
|
|
532
|
+
continue;
|
|
533
|
+
}
|
|
534
|
+
output[offset] = " ";
|
|
535
|
+
if (escaped) escaped = false;
|
|
536
|
+
else if (ch === "\\") escaped = true;
|
|
537
|
+
else if (ch === '"') inString = false;
|
|
538
|
+
i++;
|
|
539
|
+
continue;
|
|
540
|
+
}
|
|
541
|
+
if (ch === '"') {
|
|
542
|
+
inString = true;
|
|
543
|
+
output[offset] = " ";
|
|
544
|
+
} else if (ch === "/" && next === "/") {
|
|
545
|
+
inLineComment = true;
|
|
546
|
+
i += 2;
|
|
547
|
+
continue;
|
|
548
|
+
} else if (ch === "/" && next === "*") {
|
|
549
|
+
inBlockComment = true;
|
|
550
|
+
i += 2;
|
|
551
|
+
continue;
|
|
552
|
+
} else if (ch !== "\n" && ch !== "\r") {
|
|
553
|
+
output[offset] = " ";
|
|
554
|
+
}
|
|
555
|
+
i++;
|
|
556
|
+
}
|
|
557
|
+
return output.join("");
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
export type JsonPropertyEdit = { start: number; end: number; text: string };
|
|
561
|
+
|
|
562
|
+
export function locateJsonPropertyValueSpan(
|
|
563
|
+
text: string,
|
|
564
|
+
objectBrace: number,
|
|
565
|
+
objectEnd: number,
|
|
566
|
+
key: string,
|
|
567
|
+
): { keyStart: number; valueStart: number; valueEnd: number; removal: JsonPropertyEdit } | undefined {
|
|
568
|
+
const clean = stripJsoncComments(text);
|
|
569
|
+
const property = findJsonObjectKey(clean, objectBrace, key);
|
|
570
|
+
if (!property || property.keyStart >= objectEnd || property.count !== 1) return undefined;
|
|
571
|
+
const valueStart = skipJsonWhitespace(clean, property.valueStart);
|
|
572
|
+
const valueEnd = skipJsonValue(clean, valueStart);
|
|
573
|
+
if (valueEnd === undefined || valueEnd > objectEnd) return undefined;
|
|
574
|
+
|
|
575
|
+
const next = skipJsonWhitespace(clean, valueEnd);
|
|
576
|
+
if (clean[next] === ",") {
|
|
577
|
+
return {
|
|
578
|
+
keyStart: property.keyStart,
|
|
579
|
+
valueStart,
|
|
580
|
+
valueEnd,
|
|
581
|
+
removal: {
|
|
582
|
+
start: property.keyStart,
|
|
583
|
+
end: next + 1,
|
|
584
|
+
text: maskJsonSyntaxPreservingComments(text, property.keyStart, next + 1),
|
|
585
|
+
},
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
let previous = property.keyStart - 1;
|
|
590
|
+
while (previous >= objectBrace && isJsonWhitespace(clean[previous])) previous--;
|
|
591
|
+
const removalStart = clean[previous] === "," ? previous : property.keyStart;
|
|
592
|
+
return {
|
|
593
|
+
keyStart: property.keyStart,
|
|
594
|
+
valueStart,
|
|
595
|
+
valueEnd,
|
|
596
|
+
removal: {
|
|
597
|
+
start: removalStart,
|
|
598
|
+
end: valueEnd,
|
|
599
|
+
text: maskJsonSyntaxPreservingComments(text, removalStart, valueEnd),
|
|
600
|
+
},
|
|
601
|
+
};
|
|
602
|
+
}
|