pi-lilac-provider 1.5.0 → 1.6.0
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 +22 -1
- package/index.ts +139 -8
- package/package.json +4 -2
- package/scripts/test-model-overrides.ts +262 -0
package/README.md
CHANGED
|
@@ -190,7 +190,7 @@ Add to your pi configuration for automatic loading:
|
|
|
190
190
|
|
|
191
191
|
Lilac's API is OpenAI-compatible with these specifics:
|
|
192
192
|
|
|
193
|
-
- **`thinkingFormat: "chat-template"`** — All reasoning models. Lilac's vLLM backend toggles reasoning via `chat_template_kwargs`, but the honored key differs per model family. Per-model `chatTemplateKwargs` in `patch.json` send the right key(s): `thinking`+`enable_thinking` (bool) for Kimi K2.6, GLM 5.1, Gemma 4, and MiniMax M2.7; `enable_thinking` + `reasoning_effort` for GLM 5.2; `thinking_mode` (adaptive|enabled|disabled) for MiniMax M3. Kimi K2.6, GLM 5.1, and GLM 5.2 additionally send a preservation flag (`preserve_thinking: true` / `clear_thinking: false`) to retain full reasoning history across turns — see [Preserved thinking](#thinking-mode) above.
|
|
193
|
+
- **`thinkingFormat: "chat-template"`** — All reasoning models. Lilac's vLLM backend toggles reasoning via `chat_template_kwargs`, but the honored key differs per model family. Per-model `chatTemplateKwargs` in `patch.json` send the right key(s): `thinking`+`enable_thinking` (bool) for Kimi K2.6, GLM 5.1, Gemma 4, and MiniMax M2.7; `enable_thinking` + `reasoning_effort` for GLM 5.2; `thinking_mode` (adaptive|enabled|disabled) for MiniMax M3. Kimi K2.6, GLM 5.1, and GLM 5.2 additionally send a preservation flag (`preserve_thinking: true` / `clear_thinking: false`) to retain full reasoning history across turns — see [Preserved thinking](#thinking-mode) above. Override these per-model via [Model Overrides](#model-overrides).
|
|
194
194
|
- **`maxTokensField: "max_completion_tokens"`** — All models. Lilac supports `max_completion_tokens` (preferred for reasoning models as it includes reasoning tokens).
|
|
195
195
|
- **`supportsDeveloperRole: true`** — All models. Lilac's vLLM backend maps the developer role to system.
|
|
196
196
|
- **`supportsStore: false`** — All models. Lilac doesn't support the `store` parameter.
|
|
@@ -210,6 +210,27 @@ The `patch.json` file contains overrides that are applied on top of `models.json
|
|
|
210
210
|
- Adding compat settings that the API doesn't provide
|
|
211
211
|
- Overriding pricing when official rates change
|
|
212
212
|
|
|
213
|
+
### Model Overrides
|
|
214
|
+
|
|
215
|
+
`modelOverrides` lets you override compat flags and other model properties per model id, **on top of** `patch.json` + `custom-models.json`, without editing the extension. Keyed by model id; `compat` (including nested `chatTemplateKwargs`), `thinkingLevelMap`, and `cost` are deep-merged **recursively** (toggle one flag without redeclaring the rest), scalars and arrays are replaced. Applied at session start, so edits take effect on the next `pi` session.
|
|
216
|
+
|
|
217
|
+
Create `~/.pi/agent/extensions/lilac.json` (auto-populated with defaults on first run):
|
|
218
|
+
|
|
219
|
+
```jsonc
|
|
220
|
+
{
|
|
221
|
+
"modelOverrides": {
|
|
222
|
+
// Disable full-history reasoning for kimi-k2.6 (e.g. to save tokens):
|
|
223
|
+
"moonshotai/kimi-k2.6": { "compat": { "chatTemplateKwargs": { "preserve_thinking": false } } },
|
|
224
|
+
// Toggle the GLM 5.2 clear_thinking flag without redeclaring the rest of compat:
|
|
225
|
+
"zai-org/glm-5.2": { "compat": { "chatTemplateKwargs": { "clear_thinking": true } } },
|
|
226
|
+
// Override a single thinking level without redeclaring the whole map:
|
|
227
|
+
"zai-org/glm-5.1": { "thinkingLevelMap": { "high": "max" } }
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
The full set of overridable fields matches the model schema (`compat`, `thinkingLevelMap`, `cost`, `contextWindow`, `maxTokens`, `reasoning`, `input`). See [Compat Settings](#compat-settings) for the catalog of compat flags and what `chatTemplateKwargs` values mean per family. An invalid JSON file is left untouched (defaults are used) so a typo isn't silently wiped — fix the file and restart pi.
|
|
233
|
+
|
|
213
234
|
## Updating Models
|
|
214
235
|
|
|
215
236
|
Run the update script to fetch the latest models from Lilac's API:
|
package/index.ts
CHANGED
|
@@ -178,6 +178,120 @@ interface PatchEntry {
|
|
|
178
178
|
|
|
179
179
|
type PatchData = Record<string, PatchEntry>;
|
|
180
180
|
|
|
181
|
+
// User Configuration: ~/.pi/agent/extensions/lilac.json lets a user override model
|
|
182
|
+
// properties per id ON TOP of patch.json + custom-models.json (so they win).
|
|
183
|
+
// Recursively deep-merges `compat` (incl. nested `chatTemplateKwargs`),
|
|
184
|
+
// `thinkingLevelMap`, and `cost` (toggle one flag without redeclaring the rest),
|
|
185
|
+
// replaces scalars and arrays. Lets a user toggle chat_template_kwargs (e.g.
|
|
186
|
+
// preserve_thinking / clear_thinking) or a single thinking level without editing
|
|
187
|
+
// the extension. See README "Model Overrides".
|
|
188
|
+
interface ModelOverride {
|
|
189
|
+
thinkingLevelMap?: ThinkingLevelMap;
|
|
190
|
+
compat?: Record<string, unknown>;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
interface LilacConfig {
|
|
194
|
+
modelOverrides?: Record<string, ModelOverride>;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const CONFIG_PATH = path.join(os.homedir(), ".pi", "agent", "extensions", "lilac.json");
|
|
198
|
+
const DEFAULT_CONFIG: LilacConfig = { modelOverrides: {} };
|
|
199
|
+
|
|
200
|
+
// Validate user-supplied modelOverrides from the config file. Non-object ids and
|
|
201
|
+
// non-object overrides are dropped silently so a malformed file doesn't crash
|
|
202
|
+
// model registration.
|
|
203
|
+
function parseModelOverrides(raw: unknown): Record<string, ModelOverride> | undefined {
|
|
204
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
|
|
205
|
+
const result: Record<string, ModelOverride> = {};
|
|
206
|
+
for (const [id, override] of Object.entries(raw as Record<string, unknown>)) {
|
|
207
|
+
if (typeof id !== "string" || !override || typeof override !== "object" || Array.isArray(override)) continue;
|
|
208
|
+
const o = override as Record<string, unknown>;
|
|
209
|
+
const parsed: ModelOverride = {};
|
|
210
|
+
if (o.thinkingLevelMap && typeof o.thinkingLevelMap === "object") {
|
|
211
|
+
const m: Record<string, string | null> = {};
|
|
212
|
+
for (const [k, v] of Object.entries(o.thinkingLevelMap as Record<string, unknown>)) {
|
|
213
|
+
if (v === null || typeof v === "string") m[k] = v;
|
|
214
|
+
}
|
|
215
|
+
if (Object.keys(m).length > 0) parsed.thinkingLevelMap = m as ThinkingLevelMap;
|
|
216
|
+
}
|
|
217
|
+
if (o.compat && typeof o.compat === "object") parsed.compat = o.compat as Record<string, unknown>;
|
|
218
|
+
if (Object.keys(parsed).length > 0) result[id] = parsed;
|
|
219
|
+
}
|
|
220
|
+
return Object.keys(result).length > 0 ? result : undefined;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Reads ~/.pi/agent/extensions/lilac.json. Missing file → populate with defaults
|
|
224
|
+
// so the user can discover it, then return defaults. An existing-but-invalid file
|
|
225
|
+
// is left untouched (defaults returned) so a user's typo isn't silently wiped —
|
|
226
|
+
// they fix the file and restart pi. Loaded lazily on first use (not at import) so
|
|
227
|
+
// importing the module has no filesystem side effects and unit tests can import
|
|
228
|
+
// the pure helpers safely.
|
|
229
|
+
function loadConfig(): LilacConfig {
|
|
230
|
+
let rawText: string;
|
|
231
|
+
try {
|
|
232
|
+
rawText = fs.readFileSync(CONFIG_PATH, "utf8");
|
|
233
|
+
} catch {
|
|
234
|
+
try {
|
|
235
|
+
fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true });
|
|
236
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(DEFAULT_CONFIG, null, 2) + "\n");
|
|
237
|
+
} catch {
|
|
238
|
+
// Write failure is non-fatal — defaults still work in memory
|
|
239
|
+
}
|
|
240
|
+
return { ...DEFAULT_CONFIG };
|
|
241
|
+
}
|
|
242
|
+
try {
|
|
243
|
+
const raw = JSON.parse(rawText);
|
|
244
|
+
return { modelOverrides: parseModelOverrides(raw.modelOverrides) };
|
|
245
|
+
} catch {
|
|
246
|
+
// File exists but is invalid JSON — return defaults WITHOUT overwriting.
|
|
247
|
+
return { ...DEFAULT_CONFIG };
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Recursively deep-merge `override` into `base`. Plain objects are merged
|
|
252
|
+
// key-by-key (so a user can toggle a single chatTemplateKwargs flag without
|
|
253
|
+
// redeclaring the rest, and a single compat flag without redeclaring
|
|
254
|
+
// chatTemplateKwargs); arrays and non-plain-object values replace the base value
|
|
255
|
+
// (so an overridden { $var } schema object, or an `input` array, replaces wholesale).
|
|
256
|
+
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
|
257
|
+
return v !== null && typeof v === "object" && !Array.isArray(v);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function deepMerge<T>(base: T, override: unknown): T {
|
|
261
|
+
if (!isPlainObject(override) || !isPlainObject(base)) return override as T;
|
|
262
|
+
const result: Record<string, unknown> = { ...(base as Record<string, unknown>) };
|
|
263
|
+
for (const [k, v] of Object.entries(override)) {
|
|
264
|
+
result[k] = isPlainObject(v) && isPlainObject(result[k]) ? deepMerge(result[k], v) : v;
|
|
265
|
+
}
|
|
266
|
+
return result as T;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Apply a user-supplied modelOverride (from lilac.json) on top of a built model.
|
|
270
|
+
// Recursively deep-merges compat (incl. nested chatTemplateKwargs) /
|
|
271
|
+
// thinkingLevelMap / cost so a user can toggle a single flag (e.g.
|
|
272
|
+
// chatTemplateKwargs.preserve_thinking) without redeclaring the rest; replaces
|
|
273
|
+
// scalars and arrays. No reasoning-cleanup (unlike applyPatch) — the override is
|
|
274
|
+
// authoritative.
|
|
275
|
+
function applyModelOverride(model: JsonModel, override: ModelOverride): JsonModel {
|
|
276
|
+
const result = { ...model };
|
|
277
|
+
for (const [key, value] of Object.entries(override)) {
|
|
278
|
+
(result as any)[key] = isPlainObject(value) && isPlainObject((result as any)[key])
|
|
279
|
+
? deepMerge((result as any)[key], value)
|
|
280
|
+
: value;
|
|
281
|
+
}
|
|
282
|
+
return result;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
let config: LilacConfig | undefined;
|
|
286
|
+
function getConfig(): LilacConfig {
|
|
287
|
+
if (!config) config = loadConfig();
|
|
288
|
+
return config;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function activeOverrides(): Record<string, ModelOverride> {
|
|
292
|
+
return getConfig().modelOverrides ?? {};
|
|
293
|
+
}
|
|
294
|
+
|
|
181
295
|
// ─── Patch Application ────────────────────────────────────────────────────────
|
|
182
296
|
|
|
183
297
|
function applyPatch(model: JsonModel, patch: PatchEntry): JsonModel {
|
|
@@ -217,8 +331,13 @@ function applyPatch(model: JsonModel, patch: PatchEntry): JsonModel {
|
|
|
217
331
|
return result;
|
|
218
332
|
}
|
|
219
333
|
|
|
220
|
-
/** Full pipeline: base models → patch → custom → result */
|
|
221
|
-
function buildModels(
|
|
334
|
+
/** Full pipeline: base models → patch → custom → user modelOverrides → result */
|
|
335
|
+
function buildModels(
|
|
336
|
+
base: JsonModel[],
|
|
337
|
+
custom: JsonModel[],
|
|
338
|
+
patch: PatchData,
|
|
339
|
+
overrides: Record<string, ModelOverride> = {},
|
|
340
|
+
): JsonModel[] {
|
|
222
341
|
const modelMap = new Map<string, JsonModel>();
|
|
223
342
|
|
|
224
343
|
for (const model of base) {
|
|
@@ -246,6 +365,18 @@ function buildModels(base: JsonModel[], custom: JsonModel[], patch: PatchData):
|
|
|
246
365
|
}
|
|
247
366
|
}
|
|
248
367
|
|
|
368
|
+
// User-supplied modelOverrides (from ~/.pi/agent/extensions/lilac.json) applied
|
|
369
|
+
// LAST so they win over patch.json + custom-models.json. Recursively deep-merges
|
|
370
|
+
// compat (incl. chatTemplateKwargs) / thinkingLevelMap / cost so a user can
|
|
371
|
+
// toggle a single flag (e.g. chatTemplateKwargs.preserve_thinking) without
|
|
372
|
+
// redeclaring the rest.
|
|
373
|
+
for (const [id, override] of Object.entries(overrides)) {
|
|
374
|
+
const existing = modelMap.get(id);
|
|
375
|
+
if (existing) {
|
|
376
|
+
modelMap.set(id, applyModelOverride(existing, override));
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
249
380
|
return Array.from(modelMap.values());
|
|
250
381
|
}
|
|
251
382
|
|
|
@@ -617,14 +748,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
617
748
|
// the in-flight model's cost without compounding an already-applied discount.
|
|
618
749
|
function getListModels(): JsonModel[] {
|
|
619
750
|
if (!listModelsCache) {
|
|
620
|
-
listModelsCache = buildModels(loadStaleModels(embeddedModels), customModels, patches);
|
|
751
|
+
listModelsCache = buildModels(loadStaleModels(embeddedModels), customModels, patches, activeOverrides());
|
|
621
752
|
}
|
|
622
753
|
return listModelsCache;
|
|
623
754
|
}
|
|
624
755
|
|
|
625
756
|
const staleBase = loadStaleModels(embeddedModels);
|
|
626
757
|
latestDiscounts = loadCachedDiscounts();
|
|
627
|
-
const staleModels = applyDiscounts(buildModels(staleBase, customModels, patches), latestDiscounts);
|
|
758
|
+
const staleModels = applyDiscounts(buildModels(staleBase, customModels, patches, activeOverrides()), latestDiscounts);
|
|
628
759
|
|
|
629
760
|
pi.registerProvider("lilac", {
|
|
630
761
|
baseUrl: BASE_URL,
|
|
@@ -731,14 +862,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
731
862
|
baseUrl: BASE_URL,
|
|
732
863
|
apiKey: "$LILAC_API_KEY",
|
|
733
864
|
api: "openai-completions",
|
|
734
|
-
models: applyDiscounts(buildModels(merged, customModels, patches), latestDiscounts),
|
|
865
|
+
models: applyDiscounts(buildModels(merged, customModels, patches, activeOverrides()), latestDiscounts),
|
|
735
866
|
});
|
|
736
867
|
} else if (discounts) {
|
|
737
868
|
pi.registerProvider("lilac", {
|
|
738
869
|
baseUrl: BASE_URL,
|
|
739
870
|
apiKey: "$LILAC_API_KEY",
|
|
740
871
|
api: "openai-completions",
|
|
741
|
-
models: applyDiscounts(buildModels(staleBase, customModels, patches), latestDiscounts),
|
|
872
|
+
models: applyDiscounts(buildModels(staleBase, customModels, patches, activeOverrides()), latestDiscounts),
|
|
742
873
|
});
|
|
743
874
|
}
|
|
744
875
|
|
|
@@ -887,5 +1018,5 @@ export default function (pi: ExtensionAPI) {
|
|
|
887
1018
|
});
|
|
888
1019
|
}
|
|
889
1020
|
|
|
890
|
-
export { fetchStatusDiscounts, applyDiscounts, applyDiscountInPlace, loadCachedDiscounts, cacheDiscounts };
|
|
891
|
-
export type { JsonDiscount, JsonModel, PatchEntry, PatchData };
|
|
1021
|
+
export { fetchStatusDiscounts, applyDiscounts, applyDiscountInPlace, loadCachedDiscounts, cacheDiscounts, buildModels, applyModelOverride, parseModelOverrides, loadConfig, getConfig };
|
|
1022
|
+
export type { JsonDiscount, JsonModel, PatchEntry, PatchData, ModelOverride, LilacConfig };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-lilac-provider",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.0",
|
|
4
4
|
"description": "Lilac provider extension for pi - Access Kimi K2.6, GLM 5.1, and Gemma 4 models through Lilac's OpenAI-compatible API on idle GPUs",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.ts",
|
|
@@ -34,8 +34,10 @@
|
|
|
34
34
|
"clean": "echo 'nothing to clean'",
|
|
35
35
|
"build": "echo 'nothing to build'",
|
|
36
36
|
"check": "echo 'nothing to check'",
|
|
37
|
-
"test": "node scripts/test-discounts.ts",
|
|
37
|
+
"test": "node scripts/test-discounts.ts && node scripts/test-preserved-thinking.ts && node scripts/test-model-overrides.ts",
|
|
38
|
+
"test:discounts": "node scripts/test-discounts.ts",
|
|
38
39
|
"test:thinking": "node scripts/test-preserved-thinking.ts",
|
|
40
|
+
"test:overrides": "node scripts/test-model-overrides.ts",
|
|
39
41
|
"update-models": "node scripts/update-models.js"
|
|
40
42
|
}
|
|
41
43
|
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Test for user model overrides (~/.pi/agent/extensions/lilac.json).
|
|
4
|
+
*
|
|
5
|
+
* Verifies, against the REAL exported helpers from index.ts (not a re-implementation):
|
|
6
|
+
* - applyModelOverride: override wins over the base value; deep-merge compat /
|
|
7
|
+
* cost / thinkingLevelMap preserves non-overridden fields; scalars replaced;
|
|
8
|
+
* input model not mutated.
|
|
9
|
+
* - parseModelOverrides: drops invalid entries (non-object override, non-string
|
|
10
|
+
* thinkingLevelMap values), keeps valid ones.
|
|
11
|
+
* - loadConfig: auto-populates a scaffold on a missing file; parses an existing
|
|
12
|
+
* file's modelOverrides; returns defaults on invalid JSON WITHOUT overwriting
|
|
13
|
+
* the user's file; returns undefined overrides for a file lacking the key.
|
|
14
|
+
* - buildModels end-to-end: a user override on a real model id wins over
|
|
15
|
+
* patch.json (e.g. disable preserve_thinking on kimi-k2.6) while the rest of
|
|
16
|
+
* compat survives; with no overrides the built models are unchanged; an
|
|
17
|
+
* override for an unknown id adds no models.
|
|
18
|
+
*
|
|
19
|
+
* Config FS is isolated to a temp HOME so nothing touches the real ~/.pi.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import fs from "fs";
|
|
23
|
+
import os from "os";
|
|
24
|
+
import path from "path";
|
|
25
|
+
|
|
26
|
+
const root = path.resolve(import.meta.dirname, "..");
|
|
27
|
+
const modelsData = JSON.parse(fs.readFileSync(path.join(root, "models.json"), "utf8"));
|
|
28
|
+
const customModelsData = JSON.parse(fs.readFileSync(path.join(root, "custom-models.json"), "utf8"));
|
|
29
|
+
const patchData = JSON.parse(fs.readFileSync(path.join(root, "patch.json"), "utf8"));
|
|
30
|
+
|
|
31
|
+
// Isolate config + cache to a temp HOME so loadConfig never touches the real ~/.pi.
|
|
32
|
+
// Must be set before importing index.ts, which computes CONFIG_PATH at module scope.
|
|
33
|
+
const tmpHome = `/tmp/pi-lilac-override-test-${Date.now()}`;
|
|
34
|
+
fs.mkdirSync(tmpHome, { recursive: true });
|
|
35
|
+
process.env.HOME = tmpHome;
|
|
36
|
+
|
|
37
|
+
const {
|
|
38
|
+
buildModels,
|
|
39
|
+
applyModelOverride,
|
|
40
|
+
parseModelOverrides,
|
|
41
|
+
loadConfig,
|
|
42
|
+
} = await import("../index.ts");
|
|
43
|
+
|
|
44
|
+
let passed = 0;
|
|
45
|
+
let failed = 0;
|
|
46
|
+
function assert(condition: boolean, message: string) {
|
|
47
|
+
if (condition) {
|
|
48
|
+
console.log(` ✓ ${message}`);
|
|
49
|
+
passed++;
|
|
50
|
+
} else {
|
|
51
|
+
console.error(` ✗ ${message}`);
|
|
52
|
+
failed++;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
function eq<T>(actual: T, expected: T, message: string) {
|
|
56
|
+
const ok = JSON.stringify(actual) === JSON.stringify(expected);
|
|
57
|
+
if (ok) {
|
|
58
|
+
console.log(` ✓ ${message}`);
|
|
59
|
+
passed++;
|
|
60
|
+
} else {
|
|
61
|
+
console.error(` ✗ ${message}\n expected: ${JSON.stringify(expected)}\n actual: ${JSON.stringify(actual)}`);
|
|
62
|
+
failed++;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const KIMI = "moonshotai/kimi-k2.6";
|
|
67
|
+
const GLM52 = "zai-org/glm-5.2";
|
|
68
|
+
const GLM51 = "zai-org/glm-5.1";
|
|
69
|
+
|
|
70
|
+
// ─── applyModelOverride ────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
console.log("\n--- applyModelOverride ---");
|
|
73
|
+
|
|
74
|
+
{
|
|
75
|
+
const base = {
|
|
76
|
+
id: KIMI,
|
|
77
|
+
reasoning: true,
|
|
78
|
+
compat: {
|
|
79
|
+
thinkingFormat: "chat-template",
|
|
80
|
+
supportsDeveloperRole: false,
|
|
81
|
+
chatTemplateKwargs: { thinking: { $var: "thinking.enabled" }, preserve_thinking: true },
|
|
82
|
+
},
|
|
83
|
+
thinkingLevelMap: { low: "low", high: "high" },
|
|
84
|
+
} as any;
|
|
85
|
+
|
|
86
|
+
const out = applyModelOverride(base, { compat: { chatTemplateKwargs: { preserve_thinking: false } } } as any);
|
|
87
|
+
assert(out.compat.chatTemplateKwargs.preserve_thinking === false, "override wins over base for a compat flag it sets");
|
|
88
|
+
assert(out.compat.thinkingFormat === "chat-template", "deep-merge compat preserves non-overridden thinkingFormat");
|
|
89
|
+
assert(out.compat.supportsDeveloperRole === false, "deep-merge compat preserves non-overridden supportsDeveloperRole");
|
|
90
|
+
assert((out.compat.chatTemplateKwargs as any).thinking?.$var === "thinking.enabled", "deep-merge chatTemplateKwargs preserves non-overridden thinking key");
|
|
91
|
+
assert((base.compat.chatTemplateKwargs as any).preserve_thinking === true, "does not mutate the input model");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
{
|
|
95
|
+
const base = { id: GLM52, thinkingLevelMap: { low: "low", high: "high" } } as any;
|
|
96
|
+
const out = applyModelOverride(base, { thinkingLevelMap: { high: "max" } } as any);
|
|
97
|
+
eq(out.thinkingLevelMap, { low: "low", high: "max" }, "deep-merge thinkingLevelMap overrides a single level, keeps the rest");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
{
|
|
101
|
+
const base = { id: KIMI, reasoning: true, contextWindow: 131072 } as any;
|
|
102
|
+
const out = applyModelOverride(base, { reasoning: false, contextWindow: 65536 } as any);
|
|
103
|
+
assert(out.reasoning === false, "scalar override replaces reasoning");
|
|
104
|
+
assert(out.contextWindow === 65536, "scalar override replaces contextWindow");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
{
|
|
108
|
+
const base = { id: GLM52, cost: { input: 1, output: 2, cacheRead: 0.2, cacheWrite: 0 } } as any;
|
|
109
|
+
// cost is a plain object -> recursively deep-merged (not on the ModelOverride
|
|
110
|
+
// type, so exercise it via a loose cast).
|
|
111
|
+
const out = applyModelOverride(base, { cost: { input: 5 } } as any);
|
|
112
|
+
eq(out.cost, { input: 5, output: 2, cacheRead: 0.2, cacheWrite: 0 }, "deep-merge cost overrides one field, keeps the rest");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
{
|
|
116
|
+
// Overriding a { $var } schema object wholesale REPLACES it (no deep-merge into $var)
|
|
117
|
+
const base = { id: KIMI, compat: { chatTemplateKwargs: { thinking: { $var: "thinking.enabled" }, preserve_thinking: true } } } as any;
|
|
118
|
+
const out = applyModelOverride(base, { compat: { chatTemplateKwargs: { thinking: { $var: "thinking.effort" } } } } as any);
|
|
119
|
+
eq(out.compat.chatTemplateKwargs.thinking, { $var: "thinking.effort" }, "overriding a { $var } object replaces it (no merge into $var)");
|
|
120
|
+
assert(out.compat.chatTemplateKwargs.preserve_thinking === true, "sibling chatTemplateKwargs key survives a $var replacement");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
{
|
|
124
|
+
// Overriding an array (input) replaces it wholesale (no index-wise merge)
|
|
125
|
+
const base = { id: KIMI, input: ["text", "image"] } as any;
|
|
126
|
+
const out = applyModelOverride(base, { input: ["text"] } as any);
|
|
127
|
+
eq(out.input, ["text"], "array override replaces wholesale (no index-wise merge)");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ─── parseModelOverrides ───────────────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
console.log("\n--- parseModelOverrides ---");
|
|
133
|
+
|
|
134
|
+
eq(parseModelOverrides(undefined), undefined, "undefined input -> undefined");
|
|
135
|
+
eq(parseModelOverrides("nope"), undefined, "non-object input -> undefined");
|
|
136
|
+
eq(parseModelOverrides({}), undefined, "empty object -> undefined (no valid entries)");
|
|
137
|
+
eq(parseModelOverrides({ badId: "not-an-object" } as any), undefined, "non-object override value dropped -> undefined");
|
|
138
|
+
eq(parseModelOverrides({ id: 123 } as any), undefined, "non-object override (number) dropped -> undefined");
|
|
139
|
+
{
|
|
140
|
+
const r = parseModelOverrides({
|
|
141
|
+
[KIMI]: { compat: { chatTemplateKwargs: { preserve_thinking: false } } },
|
|
142
|
+
bad: 123,
|
|
143
|
+
alsobad: "string",
|
|
144
|
+
});
|
|
145
|
+
assert(r !== undefined && Object.keys(r!).length === 1 && r![KIMI] !== undefined, "keeps valid override, drops invalid ids");
|
|
146
|
+
assert((r![KIMI] as any).compat.chatTemplateKwargs.preserve_thinking === false, "valid override compat preserved");
|
|
147
|
+
}
|
|
148
|
+
{
|
|
149
|
+
// thinkingLevelMap: only string/null values kept; non-string values dropped
|
|
150
|
+
const r = parseModelOverrides({ [GLM52]: { thinkingLevelMap: { high: "max", bad: 42, off: null } } });
|
|
151
|
+
eq(r, { [GLM52]: { thinkingLevelMap: { high: "max", off: null } } }, "thinkingLevelMap keeps string/null values, drops others");
|
|
152
|
+
}
|
|
153
|
+
{
|
|
154
|
+
// an override whose fields all parse to nothing is dropped
|
|
155
|
+
const r = parseModelOverrides({ [KIMI]: { thinkingLevelMap: { bad: 42 } } });
|
|
156
|
+
eq(r, undefined, "override with no usable fields is dropped -> undefined");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ─── loadConfig ────────────────────────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
console.log("\n--- loadConfig ---");
|
|
162
|
+
|
|
163
|
+
const cfgPath = path.join(os.homedir(), ".pi", "agent", "extensions", "lilac.json");
|
|
164
|
+
|
|
165
|
+
{
|
|
166
|
+
// Fresh tmpHome: no config file -> loadConfig auto-populates the scaffold and returns defaults
|
|
167
|
+
assert(!fs.existsSync(cfgPath), "scaffold not present before first loadConfig");
|
|
168
|
+
const cfg = loadConfig();
|
|
169
|
+
eq(cfg, { modelOverrides: {} }, "missing file -> defaults (empty modelOverrides)");
|
|
170
|
+
assert(fs.existsSync(cfgPath), "loadConfig auto-populates the scaffold file on missing file");
|
|
171
|
+
eq(JSON.parse(fs.readFileSync(cfgPath, "utf8")), { modelOverrides: {} }, "scaffold file contains the default shape");
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
{
|
|
175
|
+
// Existing file with valid modelOverrides -> parsed
|
|
176
|
+
fs.writeFileSync(cfgPath, JSON.stringify({
|
|
177
|
+
modelOverrides: { [KIMI]: { compat: { chatTemplateKwargs: { preserve_thinking: false } } } },
|
|
178
|
+
}));
|
|
179
|
+
const cfg = loadConfig();
|
|
180
|
+
assert((cfg.modelOverrides as any)?.[KIMI]?.compat?.chatTemplateKwargs?.preserve_thinking === false, "existing file's modelOverrides parsed");
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
{
|
|
184
|
+
// Existing file WITHOUT a modelOverrides key -> undefined overrides (not an error)
|
|
185
|
+
fs.writeFileSync(cfgPath, JSON.stringify({ unrelatedKey: true }));
|
|
186
|
+
const cfg = loadConfig();
|
|
187
|
+
assert(cfg.modelOverrides === undefined, "file without modelOverrides key -> undefined overrides");
|
|
188
|
+
assert(JSON.parse(fs.readFileSync(cfgPath, "utf8")).unrelatedKey === true, "file without modelOverrides key is not rewritten");
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
{
|
|
192
|
+
// Existing file with invalid JSON -> defaults returned, file left UNTOUCHED (typo not wiped)
|
|
193
|
+
fs.writeFileSync(cfgPath, "not json {{{");
|
|
194
|
+
const cfg = loadConfig();
|
|
195
|
+
eq(cfg, { modelOverrides: {} }, "invalid JSON -> defaults");
|
|
196
|
+
assert(fs.readFileSync(cfgPath, "utf8") === "not json {{{", "invalid file is not overwritten (typo preserved)");
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ─── buildModels end-to-end (real models.json + patch.json) ────────────────────
|
|
200
|
+
|
|
201
|
+
console.log("\n--- buildModels end-to-end ---");
|
|
202
|
+
|
|
203
|
+
function find(models: any[], id: string): any {
|
|
204
|
+
const m = models.find((x) => x.id === id);
|
|
205
|
+
if (!m) throw new Error(`model ${id} not built`);
|
|
206
|
+
return m;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
{
|
|
210
|
+
// No overrides -> identical to today: kimi preserve_thinking: true survives patch
|
|
211
|
+
const models = buildModels(modelsData, customModelsData, patchData, {});
|
|
212
|
+
const kimi = find(models, KIMI);
|
|
213
|
+
assert(kimi.compat.chatTemplateKwargs.preserve_thinking === true, "no overrides -> kimi preserve_thinking stays true (patch wins)");
|
|
214
|
+
assert(kimi.compat.thinkingFormat === "chat-template", "no overrides -> kimi thinkingFormat intact");
|
|
215
|
+
const glm52 = find(models, GLM52);
|
|
216
|
+
assert(glm52.compat.chatTemplateKwargs.clear_thinking === false, "no overrides -> glm-5.2 clear_thinking stays false (patch wins)");
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
{
|
|
220
|
+
// User override wins over patch.json: disable preserve_thinking on kimi
|
|
221
|
+
const overrides = { [KIMI]: { compat: { chatTemplateKwargs: { preserve_thinking: false } } } } as any;
|
|
222
|
+
const models = buildModels(modelsData, customModelsData, patchData, overrides);
|
|
223
|
+
const kimi = find(models, KIMI);
|
|
224
|
+
assert(kimi.compat.chatTemplateKwargs.preserve_thinking === false, "override wins over patch: kimi preserve_thinking -> false");
|
|
225
|
+
// deep-merge: the $var thinking keys + thinkingFormat survive
|
|
226
|
+
assert((kimi.compat.chatTemplateKwargs as any).thinking?.$var === "thinking.enabled", "override deep-merges: thinking $var key survives");
|
|
227
|
+
assert((kimi.compat.chatTemplateKwargs as any).enable_thinking?.$var === "thinking.enabled", "override deep-merges: enable_thinking $var key survives");
|
|
228
|
+
assert(kimi.compat.thinkingFormat === "chat-template", "override deep-merges: thinkingFormat survives");
|
|
229
|
+
assert(kimi.compat.supportsDeveloperRole === false, "override deep-merges: supportsDeveloperRole survives");
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
{
|
|
233
|
+
// Override a single thinking level on glm-5.2 without redeclaring the map;
|
|
234
|
+
// the patch-applied clear_thinking flag survives a thinkingLevelMap-only override
|
|
235
|
+
const overrides = { [GLM52]: { thinkingLevelMap: { high: "max" } } } as any;
|
|
236
|
+
const models = buildModels(modelsData, customModelsData, patchData, overrides);
|
|
237
|
+
const glm = find(models, GLM52);
|
|
238
|
+
assert((glm.thinkingLevelMap as any)?.high === "max", "override thinkingLevelMap.high wins over patch");
|
|
239
|
+
assert(glm.compat.chatTemplateKwargs.clear_thinking === false, "non-overridden clear_thinking survives a thinkingLevelMap-only override");
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
{
|
|
243
|
+
// Override on glm-5.1 toggles clear_thinking (patch sets false); other compat survives
|
|
244
|
+
const overrides = { [GLM51]: { compat: { chatTemplateKwargs: { clear_thinking: true } } } } as any;
|
|
245
|
+
const models = buildModels(modelsData, customModelsData, patchData, overrides);
|
|
246
|
+
const glm = find(models, GLM51);
|
|
247
|
+
assert(glm.compat.chatTemplateKwargs.clear_thinking === true, "override wins over patch: glm-5.1 clear_thinking -> true");
|
|
248
|
+
assert((glm.compat.chatTemplateKwargs as any).thinking?.$var === "thinking.enabled", "override deep-merges: glm-5.1 thinking $var key survives");
|
|
249
|
+
assert(glm.compat.zaiToolStream === true, "override deep-merges: glm-5.1 zaiToolStream survives");
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
{
|
|
253
|
+
// Override for an unknown id is a no-op (adds no models)
|
|
254
|
+
const before = buildModels(modelsData, customModelsData, patchData, {});
|
|
255
|
+
const after = buildModels(modelsData, customModelsData, patchData, { "no/such-model": { reasoning: false } } as any);
|
|
256
|
+
eq(after.length, before.length, "override for an unknown id adds no models");
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ─── Summary ───────────────────────────────────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
console.log(`\n${failed === 0 ? "ALL PASS" : `${failed} FAILED`}`);
|
|
262
|
+
process.exit(failed === 0 ? 0 : 1);
|