pi-lilac-provider 1.6.2 → 1.7.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 +27 -0
- package/index.ts +224 -17
- package/models.json +1 -1
- package/package.json +3 -2
- package/scripts/test-flex.ts +370 -0
- package/scripts/test-model-overrides.ts +3 -3
package/README.md
CHANGED
|
@@ -23,6 +23,7 @@ Access Kimi K2.6, GLM 5.1, MiniMax M2.7, and Gemma 4 models through Lilac's Open
|
|
|
23
23
|
- **Reasoning Models** — Chain-of-thought via `chat_template_kwargs` (all models)
|
|
24
24
|
- **Vision Support** — Image input on Kimi K2.6 and Gemma 4
|
|
25
25
|
- **Context Caching** — Cache read pricing on Kimi K2.6 and GLM 5.1
|
|
26
|
+
- **Flex (Discount Gating)** — Only let the LLM respond when the active model's discount meets a threshold you set (`/lilac-flex`)
|
|
26
27
|
- **Idle GPU Scheduling** — Lilac leverages idle GPU capacity for cost-efficient inference
|
|
27
28
|
|
|
28
29
|
## Installation
|
|
@@ -218,6 +219,9 @@ Create `~/.pi/agent/extensions/lilac.json` (auto-populated with defaults on firs
|
|
|
218
219
|
|
|
219
220
|
```jsonc
|
|
220
221
|
{
|
|
222
|
+
// Only respond when the active model's discount is >= this percent. null = off.
|
|
223
|
+
// See "Flex (Discount Gating)" below. Set interactively with /lilac-flex.
|
|
224
|
+
"flexThreshold": null,
|
|
221
225
|
"modelOverrides": {
|
|
222
226
|
// Disable full-history reasoning for kimi-k2.6 (e.g. to save tokens):
|
|
223
227
|
"moonshotai/kimi-k2.6": { "compat": { "chatTemplateKwargs": { "preserve_thinking": false } } },
|
|
@@ -231,6 +235,29 @@ Create `~/.pi/agent/extensions/lilac.json` (auto-populated with defaults on firs
|
|
|
231
235
|
|
|
232
236
|
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
237
|
|
|
238
|
+
### Flex (Discount Gating)
|
|
239
|
+
|
|
240
|
+
Lilac's per-model discount fluctuates with idle-GPU supply. **Flex** lets you set a discount threshold so pi **only sends a prompt to the LLM when the active model's current discount is at or above it** — e.g. "only respond when the discount is ≥ 75%". Below the threshold, the prompt is blocked (dropped with a warning) until the next discount poll brings the discount back up. This is a spend-control feature: you only spend when supply is cheap.
|
|
241
|
+
|
|
242
|
+
Set it interactively with the `/lilac-flex` command:
|
|
243
|
+
|
|
244
|
+
```
|
|
245
|
+
/lilac-flex # picker: Off / ≥50% / ≥75% / Custom…
|
|
246
|
+
/lilac-flex 75 # set threshold directly (only respond at ≥75% discount)
|
|
247
|
+
/lilac-flex 50% # trailing % accepted on the command line
|
|
248
|
+
/lilac-flex off # disable flex (allow all discounts)
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
The threshold persists in `~/.pi/agent/extensions/lilac.json` as `flexThreshold` (a number `0`–`100`, or `null` for off) alongside `modelOverrides`, so it survives restarts. `/lilac-flex` updates it live — no restart needed.
|
|
252
|
+
|
|
253
|
+
Behavior notes:
|
|
254
|
+
|
|
255
|
+
- **Gating point:** flex checks at prompt-submission time. When blocked, the prompt is dropped (you get a warning notification) and you re-submit once the discount improves. It does **not** queue the prompt.
|
|
256
|
+
- **Scope:** only **interactive** (TUI-typed) prompts are gated. `rpc`/`print` (automation) and extension-injected messages are not gated, so flex never causes a silent failure in a pipeline or an extension loop. Flex only applies to the active **lilac** model; non-lilac models always pass.
|
|
257
|
+
- **No data / no discount entry = 0%.** A lilac model with no discount (list price), or before the first discount poll has data, counts as 0% and is blocked when flex is on. This matches how discounts are priced elsewhere in the extension.
|
|
258
|
+
- **Freshness:** when a prompt is blocked, the extension triggers an immediate `/status` refresh (throttled to once per ~5s) so you're not stuck on a stale low value from the 5-minute idle poll. The next submission sees the fresh discount. The footer status reflects the gate: `… · flex ≥75% ok` or `… · flex ≥75% blocked`.
|
|
259
|
+
- **Discount lock-in:** per Lilac, a discount is locked in when a request starts. Flex gates on the best-known discount at submit time, which is what gets locked in for that turn.
|
|
260
|
+
|
|
234
261
|
## Updating Models
|
|
235
262
|
|
|
236
263
|
Run the update script to fetch the latest models from Lilac's API:
|
package/index.ts
CHANGED
|
@@ -192,10 +192,14 @@ interface ModelOverride {
|
|
|
192
192
|
|
|
193
193
|
interface LilacConfig {
|
|
194
194
|
modelOverrides?: Record<string, ModelOverride>;
|
|
195
|
+
// Flex discount threshold: only allow interactive prompts to reach the LLM
|
|
196
|
+
// when the active lilac model's discountPercent is >= this. null/undefined =
|
|
197
|
+
// off (allow all). Set via /lilac-flex; persisted in lilac.json.
|
|
198
|
+
flexThreshold?: number | null;
|
|
195
199
|
}
|
|
196
200
|
|
|
197
201
|
const CONFIG_PATH = path.join(os.homedir(), ".pi", "agent", "extensions", "lilac.json");
|
|
198
|
-
const DEFAULT_CONFIG: LilacConfig = { modelOverrides: {} };
|
|
202
|
+
const DEFAULT_CONFIG: LilacConfig = { modelOverrides: {}, flexThreshold: null };
|
|
199
203
|
|
|
200
204
|
// Validate user-supplied modelOverrides from the config file. Non-object ids and
|
|
201
205
|
// non-object overrides are dropped silently so a malformed file doesn't crash
|
|
@@ -220,6 +224,23 @@ function parseModelOverrides(raw: unknown): Record<string, ModelOverride> | unde
|
|
|
220
224
|
return Object.keys(result).length > 0 ? result : undefined;
|
|
221
225
|
}
|
|
222
226
|
|
|
227
|
+
// Validate a flex discount threshold from the config file. A number in [0,100] is
|
|
228
|
+
// returned as-is; null / "off" / "" map to null (flex disabled); anything else
|
|
229
|
+
// returns undefined (treated as disabled by the gate, but not normalized so an
|
|
230
|
+
// invalid value isn't silently rewritten). Mirrors parseModelOverrides' lenient
|
|
231
|
+
// parsing so a malformed file doesn't crash the gate or model registration.
|
|
232
|
+
function parseFlexThreshold(raw: unknown): number | null | undefined {
|
|
233
|
+
if (raw === null) return null;
|
|
234
|
+
if (typeof raw === "number" && Number.isFinite(raw) && raw >= 0 && raw <= 100) return raw;
|
|
235
|
+
if (typeof raw === "string") {
|
|
236
|
+
const s = raw.trim().toLowerCase();
|
|
237
|
+
if (s === "" || s === "off" || s === "none") return null;
|
|
238
|
+
const n = Number(s);
|
|
239
|
+
if (Number.isFinite(n) && n >= 0 && n <= 100) return n;
|
|
240
|
+
}
|
|
241
|
+
return undefined;
|
|
242
|
+
}
|
|
243
|
+
|
|
223
244
|
// Reads ~/.pi/agent/extensions/lilac.json. Missing file → populate with defaults
|
|
224
245
|
// so the user can discover it, then return defaults. An existing-but-invalid file
|
|
225
246
|
// is left untouched (defaults returned) so a user's typo isn't silently wiped —
|
|
@@ -241,7 +262,10 @@ function loadConfig(): LilacConfig {
|
|
|
241
262
|
}
|
|
242
263
|
try {
|
|
243
264
|
const raw = JSON.parse(rawText);
|
|
244
|
-
return {
|
|
265
|
+
return {
|
|
266
|
+
modelOverrides: parseModelOverrides(raw.modelOverrides),
|
|
267
|
+
flexThreshold: parseFlexThreshold(raw.flexThreshold),
|
|
268
|
+
};
|
|
245
269
|
} catch {
|
|
246
270
|
// File exists but is invalid JSON — return defaults WITHOUT overwriting.
|
|
247
271
|
return { ...DEFAULT_CONFIG };
|
|
@@ -288,6 +312,29 @@ function getConfig(): LilacConfig {
|
|
|
288
312
|
return config;
|
|
289
313
|
}
|
|
290
314
|
|
|
315
|
+
// Read-modify-write the config file and refresh the in-memory cache. Used by
|
|
316
|
+
// /lilac-flex so a threshold change takes effect immediately (the input gate and
|
|
317
|
+
// footer read getConfig()) without a restart, and without clobbering the user's
|
|
318
|
+
// modelOverrides. Reads via loadConfig (which validates), so modelOverrides the
|
|
319
|
+
// user hand-edited since startup survive the spread. The file is normalized to a
|
|
320
|
+
// discoverable shape (modelOverrides: {}, flexThreshold: null always present) so
|
|
321
|
+
// /lilac-flex never strips the modelOverrides scaffold from the file.
|
|
322
|
+
function updateConfig(mutator: (cfg: LilacConfig) => LilacConfig): LilacConfig {
|
|
323
|
+
const next = mutator(loadConfig());
|
|
324
|
+
const toWrite: LilacConfig = {
|
|
325
|
+
modelOverrides: next.modelOverrides ?? {},
|
|
326
|
+
flexThreshold: next.flexThreshold ?? null,
|
|
327
|
+
};
|
|
328
|
+
try {
|
|
329
|
+
fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true });
|
|
330
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(toWrite, null, 2) + "\n");
|
|
331
|
+
} catch {
|
|
332
|
+
// Write failure is non-fatal — the in-memory cache below still updates.
|
|
333
|
+
}
|
|
334
|
+
config = next;
|
|
335
|
+
return next;
|
|
336
|
+
}
|
|
337
|
+
|
|
291
338
|
function activeOverrides(): Record<string, ModelOverride> {
|
|
292
339
|
return getConfig().modelOverrides ?? {};
|
|
293
340
|
}
|
|
@@ -656,10 +703,19 @@ function loadCachedDiscounts(): Map<string, JsonDiscount> | null {
|
|
|
656
703
|
|
|
657
704
|
function formatDiscountStatus(modelId?: string): string {
|
|
658
705
|
if (!modelId) return "supply: —";
|
|
659
|
-
|
|
660
|
-
const discount = latestDiscounts
|
|
661
|
-
|
|
662
|
-
|
|
706
|
+
const threshold = getConfig().flexThreshold ?? null;
|
|
707
|
+
const discount = latestDiscounts?.get(modelId);
|
|
708
|
+
let base: string;
|
|
709
|
+
if (discount) {
|
|
710
|
+
base = `supply: ${discount.supplyState} · sub-discount: ${discount.discountPercent}%`;
|
|
711
|
+
} else if (latestDiscounts) {
|
|
712
|
+
base = "supply: —";
|
|
713
|
+
} else {
|
|
714
|
+
base = "supply: checking…";
|
|
715
|
+
}
|
|
716
|
+
if (threshold == null) return base;
|
|
717
|
+
const pct = discount?.discountPercent ?? 0;
|
|
718
|
+
return `${base} · flex ≥${threshold}% ${pct >= threshold ? "ok" : "blocked"}`;
|
|
663
719
|
}
|
|
664
720
|
|
|
665
721
|
function dimStatus(ctx: any, text: string): string {
|
|
@@ -705,6 +761,50 @@ function syncStatus(ctx: any): void {
|
|
|
705
761
|
}
|
|
706
762
|
}
|
|
707
763
|
|
|
764
|
+
// Parse a /lilac-flex argument or custom-threshold input: "off"/"none"/"" → null
|
|
765
|
+
// (disabled), a number in [0,100] (optionally with a trailing %) → that number,
|
|
766
|
+
// anything else → undefined (invalid).
|
|
767
|
+
function parseFlexArg(raw: string): number | null | undefined {
|
|
768
|
+
const s = raw.trim().toLowerCase().replace(/%$/, "");
|
|
769
|
+
if (s === "" || s === "off" || s === "none") return null;
|
|
770
|
+
const n = Number(s);
|
|
771
|
+
if (!Number.isFinite(n) || n < 0 || n > 100) return undefined;
|
|
772
|
+
return Math.round(n);
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// Persist a flex threshold via updateConfig and report the resulting state against
|
|
776
|
+
// the live model's current discount. The footer is re-painted (syncStatus) so the
|
|
777
|
+
// flex indicator appears immediately. Used by the /lilac-flex command.
|
|
778
|
+
function applyFlexThreshold(value: number | null, ctx: any): void {
|
|
779
|
+
updateConfig((cfg) => ({ ...cfg, flexThreshold: value }));
|
|
780
|
+
const threshold = getConfig().flexThreshold ?? null;
|
|
781
|
+
const model = ctx.model;
|
|
782
|
+
const discount = model?.provider === "lilac" ? latestDiscounts?.get(model.id) : undefined;
|
|
783
|
+
const pct = discount?.discountPercent;
|
|
784
|
+
|
|
785
|
+
let msg: string;
|
|
786
|
+
let level: "info" | "warning";
|
|
787
|
+
if (threshold == null) {
|
|
788
|
+
msg = "lilac-flex: off — all discounts allowed";
|
|
789
|
+
level = "info";
|
|
790
|
+
} else if (pct == null) {
|
|
791
|
+
msg = `lilac-flex: ≥ ${threshold}% — no discount data yet; will block until the next poll`;
|
|
792
|
+
level = "warning";
|
|
793
|
+
} else if (pct >= threshold) {
|
|
794
|
+
msg = `lilac-flex: ≥ ${threshold}% — current ${pct}% discount allowed`;
|
|
795
|
+
level = "info";
|
|
796
|
+
} else {
|
|
797
|
+
msg = `lilac-flex: ≥ ${threshold}% — current ${pct}% discount blocked until it improves`;
|
|
798
|
+
level = "warning";
|
|
799
|
+
}
|
|
800
|
+
try {
|
|
801
|
+
ctx.ui.notify(msg, level);
|
|
802
|
+
} catch {
|
|
803
|
+
// notify is a no-op without a UI runner
|
|
804
|
+
}
|
|
805
|
+
syncStatus(ctx);
|
|
806
|
+
}
|
|
807
|
+
|
|
708
808
|
function discountsChanged(
|
|
709
809
|
a: Map<string, JsonDiscount> | null,
|
|
710
810
|
b: Map<string, JsonDiscount> | null,
|
|
@@ -735,6 +835,10 @@ let lastDiscountFetchTime = 0;
|
|
|
735
835
|
// they cooperate: a poll that just ran lets the next turn skip its own fetch
|
|
736
836
|
// within the TTL.
|
|
737
837
|
const STATUS_CACHE_TTL_MS = 60000;
|
|
838
|
+
// Throttle for the immediate /status refresh fired when flex blocks a prompt, so
|
|
839
|
+
// a user retrying repeatedly doesn't hammer the endpoint while still getting fresh
|
|
840
|
+
// data within seconds (vs. waiting on the 5-min idle poll).
|
|
841
|
+
const BLOCK_REFRESH_THROTTLE_MS = 5000;
|
|
738
842
|
// Lilac refreshes discounts ~every 10 minutes (per their docs: "Discounts refresh
|
|
739
843
|
// approximately every 10 minutes and are locked in when a request starts"). Poll
|
|
740
844
|
// every 5 min during idle — half the refresh window — so a long-idle session
|
|
@@ -803,6 +907,22 @@ export default function (pi: ExtensionAPI) {
|
|
|
803
907
|
}
|
|
804
908
|
}
|
|
805
909
|
|
|
910
|
+
// Cache + hot-swap freshly fetched discounts: update latestDiscounts, re-register
|
|
911
|
+
// the provider so other lilac models pick up the new price on their next request,
|
|
912
|
+
// and re-paint the footer from the LIVE model. Shared by the idle poll and the
|
|
913
|
+
// flex block refresh so the apply logic can't drift between them.
|
|
914
|
+
function applyFreshDiscounts(discounts: Map<string, JsonDiscount>, ctx: any): void {
|
|
915
|
+
cacheDiscounts(discounts);
|
|
916
|
+
latestDiscounts = discounts;
|
|
917
|
+
pi.registerProvider("lilac", {
|
|
918
|
+
baseUrl: BASE_URL,
|
|
919
|
+
apiKey: "$LILAC_API_KEY",
|
|
920
|
+
api: "openai-completions",
|
|
921
|
+
models: applyDiscounts(getListModels(), discounts),
|
|
922
|
+
});
|
|
923
|
+
syncStatus(ctx);
|
|
924
|
+
}
|
|
925
|
+
|
|
806
926
|
/**
|
|
807
927
|
* Background /status poll, fired every STATUS_POLL_INTERVAL_MS (5 min) from
|
|
808
928
|
* session_start to cover idle sessions. Mirrors the discount half of
|
|
@@ -821,16 +941,25 @@ export default function (pi: ExtensionAPI) {
|
|
|
821
941
|
syncStatus(ctx);
|
|
822
942
|
return;
|
|
823
943
|
}
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
944
|
+
applyFreshDiscounts(discounts, ctx);
|
|
945
|
+
}).catch(() => { /* network errors are non-fatal */ });
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
// Fire-and-forget /status refresh used when flex blocks a prompt, so the user
|
|
949
|
+
// isn't stuck on a stale low value from the 5-min idle poll. Throttled by
|
|
950
|
+
// BLOCK_REFRESH_THROTTLE_MS so repeated retries don't hammer the endpoint. Not
|
|
951
|
+
// signal-guarded (user-initiated, not session-scoped); network errors are non-fatal.
|
|
952
|
+
function triggerDiscountRefresh(ctx: any): void {
|
|
953
|
+
if (!cachedApiKey) return;
|
|
954
|
+
if (Date.now() - lastDiscountFetchTime < BLOCK_REFRESH_THROTTLE_MS) return;
|
|
955
|
+
fetchStatusDiscounts(cachedApiKey).then(discounts => {
|
|
956
|
+
if (!discounts) return;
|
|
957
|
+
lastDiscountFetchTime = Date.now();
|
|
958
|
+
if (!discountsChanged(latestDiscounts, discounts)) {
|
|
959
|
+
syncStatus(ctx);
|
|
960
|
+
return;
|
|
961
|
+
}
|
|
962
|
+
applyFreshDiscounts(discounts, ctx);
|
|
834
963
|
}).catch(() => { /* network errors are non-fatal */ });
|
|
835
964
|
}
|
|
836
965
|
|
|
@@ -1025,6 +1154,84 @@ export default function (pi: ExtensionAPI) {
|
|
|
1025
1154
|
};
|
|
1026
1155
|
});
|
|
1027
1156
|
|
|
1157
|
+
// lilac-flex: gate interactive prompts on the active lilac model's discount.
|
|
1158
|
+
// Only interactive prompts are gated — extension-injected messages are skipped
|
|
1159
|
+
// (programmatic; would loop) and rpc/print are skipped (a silent block would be
|
|
1160
|
+
// a confusing failure in automation). A missing discount entry or no data yet
|
|
1161
|
+
// counts as 0% (list price), consistent with applyDiscounts/applyDiscountInPlace.
|
|
1162
|
+
// When blocked, the prompt is dropped (handled) and an immediate /status refresh
|
|
1163
|
+
// is triggered so the next submission isn't waiting on the 5-min idle poll.
|
|
1164
|
+
pi.on("input", async (event, ctx) => {
|
|
1165
|
+
if (event.source !== "interactive") return { action: "continue" };
|
|
1166
|
+
|
|
1167
|
+
const threshold = getConfig().flexThreshold ?? null;
|
|
1168
|
+
if (threshold == null) return { action: "continue" };
|
|
1169
|
+
|
|
1170
|
+
const model = ctx.model;
|
|
1171
|
+
if (!model || model.provider !== "lilac") return { action: "continue" };
|
|
1172
|
+
|
|
1173
|
+
const discount = latestDiscounts?.get(model.id);
|
|
1174
|
+
const discountPercent = discount?.discountPercent ?? 0;
|
|
1175
|
+
if (discountPercent >= threshold) return { action: "continue" };
|
|
1176
|
+
|
|
1177
|
+
const desc = discount
|
|
1178
|
+
? `${discountPercent}% discount`
|
|
1179
|
+
: (latestDiscounts ? "no discount on this model (list price)" : "no discount data yet");
|
|
1180
|
+
ctx.ui.notify(
|
|
1181
|
+
`lilac-flex: ${desc} < ${threshold}% threshold — blocked until the next poll. Re-submit once the discount improves, or run /lilac-flex to adjust.`,
|
|
1182
|
+
"warning",
|
|
1183
|
+
);
|
|
1184
|
+
triggerDiscountRefresh(ctx);
|
|
1185
|
+
return { action: "handled" };
|
|
1186
|
+
});
|
|
1187
|
+
|
|
1188
|
+
pi.registerCommand("lilac-flex", {
|
|
1189
|
+
description: "Set lilac flex discount threshold — only respond at/above this discount",
|
|
1190
|
+
async handler(args, ctx) {
|
|
1191
|
+
if (!ctx.hasUI) {
|
|
1192
|
+
ctx.ui.notify("/lilac-flex requires interactive mode.", "error");
|
|
1193
|
+
return;
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
const arg = (args ?? "").trim();
|
|
1197
|
+
if (arg) {
|
|
1198
|
+
const value = parseFlexArg(arg);
|
|
1199
|
+
if (value === undefined) {
|
|
1200
|
+
ctx.ui.notify("Usage: /lilac-flex [off | 0-100]", "warning");
|
|
1201
|
+
return;
|
|
1202
|
+
}
|
|
1203
|
+
applyFlexThreshold(value, ctx);
|
|
1204
|
+
return;
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
const current = getConfig().flexThreshold ?? null;
|
|
1208
|
+
const labels = [
|
|
1209
|
+
"Off — allow all discounts",
|
|
1210
|
+
"≥ 50% discount",
|
|
1211
|
+
"≥ 75% discount",
|
|
1212
|
+
"Custom…",
|
|
1213
|
+
];
|
|
1214
|
+
const choice = await ctx.ui.select("Lilac flex: only respond at/above this discount", labels);
|
|
1215
|
+
if (choice === undefined) return; // cancelled
|
|
1216
|
+
|
|
1217
|
+
let value: number | null;
|
|
1218
|
+
if (choice === labels[0]) value = null;
|
|
1219
|
+
else if (choice === labels[1]) value = 50;
|
|
1220
|
+
else if (choice === labels[2]) value = 75;
|
|
1221
|
+
else {
|
|
1222
|
+
const input = await ctx.ui.input("Threshold (0–100, or 'off'):", String(current ?? ""));
|
|
1223
|
+
if (input === undefined) return; // cancelled
|
|
1224
|
+
const parsed = parseFlexArg(input);
|
|
1225
|
+
if (parsed === undefined) {
|
|
1226
|
+
ctx.ui.notify("Invalid threshold. Use a number 0–100 or 'off'.", "warning");
|
|
1227
|
+
return;
|
|
1228
|
+
}
|
|
1229
|
+
value = parsed;
|
|
1230
|
+
}
|
|
1231
|
+
applyFlexThreshold(value, ctx);
|
|
1232
|
+
},
|
|
1233
|
+
});
|
|
1234
|
+
|
|
1028
1235
|
pi.on("session_shutdown", () => {
|
|
1029
1236
|
revalidateAbort?.abort();
|
|
1030
1237
|
if (pollInterval) {
|
|
@@ -1034,5 +1241,5 @@ export default function (pi: ExtensionAPI) {
|
|
|
1034
1241
|
});
|
|
1035
1242
|
}
|
|
1036
1243
|
|
|
1037
|
-
export { fetchStatusDiscounts, applyDiscounts, applyDiscountInPlace, loadCachedDiscounts, cacheDiscounts, buildModels, applyModelOverride, parseModelOverrides, loadConfig, getConfig };
|
|
1244
|
+
export { fetchStatusDiscounts, applyDiscounts, applyDiscountInPlace, loadCachedDiscounts, cacheDiscounts, buildModels, applyModelOverride, parseModelOverrides, parseFlexThreshold, updateConfig, loadConfig, getConfig };
|
|
1038
1245
|
export type { JsonDiscount, JsonModel, PatchEntry, PatchData, ModelOverride, LilacConfig };
|
package/models.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-lilac-provider",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.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,10 +34,11 @@
|
|
|
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 && node scripts/test-preserved-thinking.ts && node scripts/test-model-overrides.ts",
|
|
37
|
+
"test": "node scripts/test-discounts.ts && node scripts/test-preserved-thinking.ts && node scripts/test-model-overrides.ts && node scripts/test-flex.ts",
|
|
38
38
|
"test:discounts": "node scripts/test-discounts.ts",
|
|
39
39
|
"test:thinking": "node scripts/test-preserved-thinking.ts",
|
|
40
40
|
"test:overrides": "node scripts/test-model-overrides.ts",
|
|
41
|
+
"test:flex": "node scripts/test-flex.ts",
|
|
41
42
|
"update-models": "node scripts/update-models.js"
|
|
42
43
|
}
|
|
43
44
|
}
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Tests for the lilac-flex feature: discount-threshold gating.
|
|
4
|
+
*
|
|
5
|
+
* Verifies, against the REAL exported helpers and registered handlers/commands
|
|
6
|
+
* from index.ts (not a re-implementation):
|
|
7
|
+
* - parseFlexThreshold: number in [0,100] passes; null/"off"/"" -> null;
|
|
8
|
+
* out-of-range / non-numeric / object -> undefined.
|
|
9
|
+
* - loadConfig: parses flexThreshold from an existing file; missing key ->
|
|
10
|
+
* undefined (off); invalid value -> undefined while modelOverrides survive.
|
|
11
|
+
* - updateConfig: sets flexThreshold, persists to disk, and PRESERVES the
|
|
12
|
+
* user's modelOverrides (doesn't clobber them); normalizes the file shape.
|
|
13
|
+
* - input gate (integration via the registered handler): blocks (handled) when
|
|
14
|
+
* discount < threshold; allows (continue) when >= threshold, when flex is
|
|
15
|
+
* off, for non-lilac models, for non-interactive sources, and for models
|
|
16
|
+
* with no discount entry (treated as 0% -> blocked when flex on).
|
|
17
|
+
* - /lilac-flex command (integration via the registered handler): direct arg
|
|
18
|
+
* ("/lilac-flex 75", "off", "50%"), picker presets, custom input, cancel,
|
|
19
|
+
* invalid arg, and non-interactive guard.
|
|
20
|
+
*
|
|
21
|
+
* Config FS + discount cache are isolated to a temp HOME so nothing touches the
|
|
22
|
+
* real ~/.pi.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import fs from "fs";
|
|
26
|
+
import os from "os";
|
|
27
|
+
import path from "path";
|
|
28
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
29
|
+
|
|
30
|
+
// Isolate config + cache to a temp HOME so loadConfig/cacheDiscounts never touch
|
|
31
|
+
// the real ~/.pi. Must be set before importing index.ts, which computes
|
|
32
|
+
// CONFIG_PATH / CACHE_PATH at module scope.
|
|
33
|
+
const tmpHome = `/tmp/pi-lilac-flex-test-${Date.now()}`;
|
|
34
|
+
fs.mkdirSync(tmpHome, { recursive: true });
|
|
35
|
+
process.env.HOME = tmpHome;
|
|
36
|
+
|
|
37
|
+
const {
|
|
38
|
+
default: registerLilac,
|
|
39
|
+
parseFlexThreshold,
|
|
40
|
+
loadConfig,
|
|
41
|
+
getConfig,
|
|
42
|
+
updateConfig,
|
|
43
|
+
cacheDiscounts,
|
|
44
|
+
} = await import("../index.ts");
|
|
45
|
+
|
|
46
|
+
let passed = 0;
|
|
47
|
+
let failed = 0;
|
|
48
|
+
function assert(condition: boolean, message: string) {
|
|
49
|
+
if (condition) {
|
|
50
|
+
console.log(` ✓ ${message}`);
|
|
51
|
+
passed++;
|
|
52
|
+
} else {
|
|
53
|
+
console.error(` ✗ ${message}`);
|
|
54
|
+
failed++;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function eq<T>(actual: T, expected: T, message: string) {
|
|
58
|
+
const ok = JSON.stringify(actual) === JSON.stringify(expected);
|
|
59
|
+
if (ok) {
|
|
60
|
+
console.log(` ✓ ${message}`);
|
|
61
|
+
passed++;
|
|
62
|
+
} else {
|
|
63
|
+
console.error(` ✗ ${message}\n expected: ${JSON.stringify(expected)}\n actual: ${JSON.stringify(actual)}`);
|
|
64
|
+
failed++;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const cfgPath = path.join(os.homedir(), ".pi", "agent", "extensions", "lilac.json");
|
|
69
|
+
const KIMI = "moonshotai/kimi-k2.6";
|
|
70
|
+
|
|
71
|
+
// ─── parseFlexThreshold ───────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
console.log("\n--- parseFlexThreshold ---");
|
|
74
|
+
eq(parseFlexThreshold(null), null, "null -> null (off)");
|
|
75
|
+
eq(parseFlexThreshold(0), 0, "0 -> 0");
|
|
76
|
+
eq(parseFlexThreshold(100), 100, "100 -> 100");
|
|
77
|
+
eq(parseFlexThreshold(75), 75, "75 -> 75");
|
|
78
|
+
eq(parseFlexThreshold(50.5), 50.5, "fractional number preserved");
|
|
79
|
+
eq(parseFlexThreshold(-1), undefined, "negative -> undefined");
|
|
80
|
+
eq(parseFlexThreshold(101), undefined, ">100 -> undefined");
|
|
81
|
+
eq(parseFlexThreshold(NaN), undefined, "NaN -> undefined");
|
|
82
|
+
eq(parseFlexThreshold("75"), 75, "numeric string -> number");
|
|
83
|
+
// Note: parseFlexThreshold is strict for the JSON file (number or off/none/null).
|
|
84
|
+
// Trailing-% stripping lives in parseFlexArg for typed command/input args.
|
|
85
|
+
eq(parseFlexThreshold("50%"), undefined, "string with trailing % -> undefined (strict; parseFlexArg handles %)");
|
|
86
|
+
eq(parseFlexThreshold("off"), null, "'off' -> null");
|
|
87
|
+
eq(parseFlexThreshold("OFF"), null, "'OFF' (case-insensitive) -> null");
|
|
88
|
+
eq(parseFlexThreshold("none"), null, "'none' -> null");
|
|
89
|
+
eq(parseFlexThreshold(""), null, "empty string -> null");
|
|
90
|
+
eq(parseFlexThreshold("abc"), undefined, "non-numeric string -> undefined");
|
|
91
|
+
eq(parseFlexThreshold(true), undefined, "boolean -> undefined");
|
|
92
|
+
eq(parseFlexThreshold({ a: 1 }), undefined, "object -> undefined");
|
|
93
|
+
eq(parseFlexThreshold([75]), undefined, "array -> undefined");
|
|
94
|
+
|
|
95
|
+
// ─── loadConfig: flexThreshold parsing ────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
console.log("\n--- loadConfig: flexThreshold ---");
|
|
98
|
+
|
|
99
|
+
// The config dir doesn't exist yet in the temp HOME; direct writes below bypass
|
|
100
|
+
// loadConfig's missing-file scaffold, so create it first.
|
|
101
|
+
fs.mkdirSync(path.dirname(cfgPath), { recursive: true });
|
|
102
|
+
|
|
103
|
+
{
|
|
104
|
+
fs.writeFileSync(cfgPath, JSON.stringify({ flexThreshold: 75 }));
|
|
105
|
+
const cfg = loadConfig();
|
|
106
|
+
assert(cfg.flexThreshold === 75, "file with flexThreshold:75 -> 75");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
{
|
|
110
|
+
fs.writeFileSync(cfgPath, JSON.stringify({ flexThreshold: null }));
|
|
111
|
+
const cfg = loadConfig();
|
|
112
|
+
assert(cfg.flexThreshold === null, "file with flexThreshold:null -> null");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
{
|
|
116
|
+
fs.writeFileSync(cfgPath, JSON.stringify({ unrelated: true }));
|
|
117
|
+
const cfg = loadConfig();
|
|
118
|
+
assert(cfg.flexThreshold === undefined, "file without flexThreshold key -> undefined (off)");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
{
|
|
122
|
+
// Invalid flexThreshold falls back to undefined, but modelOverrides still parse.
|
|
123
|
+
fs.writeFileSync(cfgPath, JSON.stringify({
|
|
124
|
+
flexThreshold: "not-a-number",
|
|
125
|
+
modelOverrides: { [KIMI]: { compat: { chatTemplateKwargs: { preserve_thinking: false } } } },
|
|
126
|
+
}));
|
|
127
|
+
const cfg = loadConfig();
|
|
128
|
+
assert(cfg.flexThreshold === undefined, "invalid flexThreshold -> undefined (off)");
|
|
129
|
+
assert((cfg.modelOverrides as any)?.[KIMI]?.compat?.chatTemplateKwargs?.preserve_thinking === false, "modelOverrides still parsed alongside invalid flexThreshold");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ─── updateConfig: round-trip + modelOverrides preservation ───────────────────
|
|
133
|
+
|
|
134
|
+
console.log("\n--- updateConfig ---");
|
|
135
|
+
|
|
136
|
+
{
|
|
137
|
+
// Start from a file with real modelOverrides, then set flex via updateConfig:
|
|
138
|
+
// the overrides must survive (read-merge-write, not clobber).
|
|
139
|
+
fs.writeFileSync(cfgPath, JSON.stringify({
|
|
140
|
+
modelOverrides: { [KIMI]: { compat: { chatTemplateKwargs: { preserve_thinking: false } } } },
|
|
141
|
+
}));
|
|
142
|
+
updateConfig((c) => ({ ...c, flexThreshold: 75 }));
|
|
143
|
+
const cfg = getConfig();
|
|
144
|
+
assert(cfg.flexThreshold === 75, "updateConfig sets flexThreshold to 75");
|
|
145
|
+
assert((cfg.modelOverrides as any)?.[KIMI]?.compat?.chatTemplateKwargs?.preserve_thinking === false, "updateConfig preserves existing modelOverrides");
|
|
146
|
+
const onDisk = JSON.parse(fs.readFileSync(cfgPath, "utf8"));
|
|
147
|
+
assert(onDisk.flexThreshold === 75, "file on disk has flexThreshold 75");
|
|
148
|
+
assert(onDisk.modelOverrides[KIMI].compat.chatTemplateKwargs.preserve_thinking === false, "file on disk preserves modelOverrides");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
{
|
|
152
|
+
// Setting to null persists null and keeps the modelOverrides scaffold shape.
|
|
153
|
+
updateConfig((c) => ({ ...c, flexThreshold: null }));
|
|
154
|
+
const onDisk = JSON.parse(fs.readFileSync(cfgPath, "utf8"));
|
|
155
|
+
assert(onDisk.flexThreshold === null, "updateConfig null -> file has flexThreshold null");
|
|
156
|
+
assert(onDisk.modelOverrides[KIMI].compat.chatTemplateKwargs.preserve_thinking === false, "modelOverrides still preserved after setting flex off");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ─── Setup: register extension + seed latestDiscounts from cache ──────────────
|
|
160
|
+
|
|
161
|
+
console.log("\n--- input gate ---");
|
|
162
|
+
|
|
163
|
+
// Seed the discount cache BEFORE registering so the extension's init picks it up
|
|
164
|
+
// via loadCachedDiscounts() (latestDiscounts). Kimi at 25%, glm-5.1 uncached.
|
|
165
|
+
cacheDiscounts(new Map([
|
|
166
|
+
[KIMI, { supplyState: "medium", discountPercent: 25, creditMultiplier: 0.75 }],
|
|
167
|
+
]));
|
|
168
|
+
|
|
169
|
+
const handlers = new Map<string, ((...args: any[]) => any)[]>();
|
|
170
|
+
const commands = new Map<string, { handler: (...args: any[]) => any }>();
|
|
171
|
+
|
|
172
|
+
const mockApi: ExtensionAPI = {
|
|
173
|
+
registerProvider: () => {},
|
|
174
|
+
on: (event: string, handler: (...args: any[]) => any) => {
|
|
175
|
+
if (!handlers.has(event)) handlers.set(event, []);
|
|
176
|
+
handlers.get(event)!.push(handler);
|
|
177
|
+
},
|
|
178
|
+
registerCommand: (name: string, opts: any) => {
|
|
179
|
+
commands.set(name, opts);
|
|
180
|
+
},
|
|
181
|
+
appendEntry: () => {},
|
|
182
|
+
exec: async () => ({ exitCode: 0, stdout: "", stderr: "" }),
|
|
183
|
+
} as any;
|
|
184
|
+
|
|
185
|
+
registerLilac(mockApi);
|
|
186
|
+
|
|
187
|
+
const inputHandlers = handlers.get("input") ?? [];
|
|
188
|
+
assert(inputHandlers.length === 1, "exactly one input handler registered");
|
|
189
|
+
|
|
190
|
+
function runInput(source: string, model: any) {
|
|
191
|
+
const notifications: { msg: string; level: string }[] = [];
|
|
192
|
+
const ctx = {
|
|
193
|
+
model,
|
|
194
|
+
ui: {
|
|
195
|
+
notify: (msg: string, level: string) => notifications.push({ msg, level }),
|
|
196
|
+
setStatus: () => {},
|
|
197
|
+
theme: { fg: (_c: string, t: string) => t },
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
return Promise.all(inputHandlers.map((h) => h({ source }, ctx))).then((results) => ({
|
|
201
|
+
result: results[0],
|
|
202
|
+
notifications,
|
|
203
|
+
}));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Reset flex to a known state for the gate suite.
|
|
207
|
+
updateConfig((c) => ({ ...c, flexThreshold: null }));
|
|
208
|
+
|
|
209
|
+
// flex off -> always allow
|
|
210
|
+
{
|
|
211
|
+
const { result, notifications } = await runInput("interactive", { id: KIMI, provider: "lilac" });
|
|
212
|
+
eq(result, { action: "continue" }, "flex off -> continue");
|
|
213
|
+
assert(notifications.length === 0, "flex off -> no notify");
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// flex 75, kimi at 25% -> blocked
|
|
217
|
+
updateConfig((c) => ({ ...c, flexThreshold: 75 }));
|
|
218
|
+
{
|
|
219
|
+
const { result, notifications } = await runInput("interactive", { id: KIMI, provider: "lilac" });
|
|
220
|
+
eq(result, { action: "handled" }, "25% < 75% threshold -> handled (blocked)");
|
|
221
|
+
assert(notifications.length === 1, "blocked -> one notify");
|
|
222
|
+
assert(notifications[0].level === "warning", "blocked notify is a warning");
|
|
223
|
+
assert(notifications[0].msg.includes("25%") && notifications[0].msg.includes("75%"), "blocked notify mentions current and threshold");
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// flex 20, kimi at 25% -> allowed (>= threshold)
|
|
227
|
+
updateConfig((c) => ({ ...c, flexThreshold: 20 }));
|
|
228
|
+
{
|
|
229
|
+
const { result, notifications } = await runInput("interactive", { id: KIMI, provider: "lilac" });
|
|
230
|
+
eq(result, { action: "continue" }, "25% >= 20% threshold -> continue");
|
|
231
|
+
assert(notifications.length === 0, "allowed -> no notify");
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// flex 75, non-lilac model -> allowed (gate only applies to lilac)
|
|
235
|
+
updateConfig((c) => ({ ...c, flexThreshold: 75 }));
|
|
236
|
+
{
|
|
237
|
+
const { result, notifications } = await runInput("interactive", { id: "anthropic/claude", provider: "anthropic" });
|
|
238
|
+
eq(result, { action: "continue" }, "non-lilac model -> continue");
|
|
239
|
+
assert(notifications.length === 0, "non-lilac model -> no notify");
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// flex 75, rpc source -> allowed (only interactive is gated)
|
|
243
|
+
{
|
|
244
|
+
const { result } = await runInput("rpc", { id: KIMI, provider: "lilac" });
|
|
245
|
+
eq(result, { action: "continue" }, "rpc source -> continue (not gated)");
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// flex 75, extension source -> allowed (no loop)
|
|
249
|
+
{
|
|
250
|
+
const { result } = await runInput("extension", { id: KIMI, provider: "lilac" });
|
|
251
|
+
eq(result, { action: "continue" }, "extension source -> continue (no loop)");
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// flex 75, lilac model with no discount entry -> 0% -> blocked, mentions list price
|
|
255
|
+
{
|
|
256
|
+
const { result, notifications } = await runInput("interactive", { id: "zai-org/glm-5.1", provider: "lilac" });
|
|
257
|
+
eq(result, { action: "handled" }, "uncached lilac model -> handled (0% < 75%)");
|
|
258
|
+
assert(notifications.length === 1, "uncached model blocked -> one notify");
|
|
259
|
+
assert(notifications[0].msg.includes("no discount on this model"), "uncached model notify mentions list price");
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// flex exactly equal to discount -> allowed (>= is inclusive)
|
|
263
|
+
updateConfig((c) => ({ ...c, flexThreshold: 25 }));
|
|
264
|
+
{
|
|
265
|
+
const { result } = await runInput("interactive", { id: KIMI, provider: "lilac" });
|
|
266
|
+
eq(result, { action: "continue" }, "25% >= 25% threshold (inclusive) -> continue");
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ─── /lilac-flex command ──────────────────────────────────────────────────────
|
|
270
|
+
|
|
271
|
+
console.log("\n--- /lilac-flex command ---");
|
|
272
|
+
|
|
273
|
+
assert(commands.has("lilac-flex"), "/lilac-flex command registered");
|
|
274
|
+
const cmd = commands.get("lilac-flex")!;
|
|
275
|
+
|
|
276
|
+
function runCmd(args: string, opts: { select?: string; input?: string; hasUI?: boolean; model?: any } = {}) {
|
|
277
|
+
const notifications: { msg: string; level: string }[] = [];
|
|
278
|
+
const ctx = {
|
|
279
|
+
hasUI: opts.hasUI ?? true,
|
|
280
|
+
model: opts.model ?? { id: KIMI, provider: "lilac" },
|
|
281
|
+
ui: {
|
|
282
|
+
notify: (msg: string, level: string) => notifications.push({ msg, level }),
|
|
283
|
+
setStatus: () => {},
|
|
284
|
+
theme: { fg: (_c: string, t: string) => t },
|
|
285
|
+
select: async (_title: string, _labels: string[]) => opts.select,
|
|
286
|
+
input: async (_title: string, _placeholder: string) => opts.input,
|
|
287
|
+
},
|
|
288
|
+
};
|
|
289
|
+
return cmd.handler(args, ctx).then(() => ({ notifications, flexThreshold: getConfig().flexThreshold ?? null }));
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// direct numeric arg
|
|
293
|
+
{
|
|
294
|
+
const { flexThreshold } = await runCmd("75");
|
|
295
|
+
assert(flexThreshold === 75, "/lilac-flex 75 -> flexThreshold 75");
|
|
296
|
+
assert(JSON.parse(fs.readFileSync(cfgPath, "utf8")).flexThreshold === 75, "/lilac-flex 75 persisted to disk");
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// trailing % accepted
|
|
300
|
+
{
|
|
301
|
+
const { flexThreshold } = await runCmd("50%");
|
|
302
|
+
assert(flexThreshold === 50, "/lilac-flex 50% -> flexThreshold 50");
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// off keyword
|
|
306
|
+
{
|
|
307
|
+
const { flexThreshold } = await runCmd("off");
|
|
308
|
+
assert(flexThreshold === null, "/lilac-flex off -> flexThreshold null");
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// invalid arg -> no change, usage notify
|
|
312
|
+
{
|
|
313
|
+
const { flexThreshold, notifications } = await runCmd("bogus");
|
|
314
|
+
assert(flexThreshold === null, "/lilac-flex bogus -> no change (still off)");
|
|
315
|
+
assert(notifications.some((n) => n.msg.includes("Usage")), "/lilac-flex bogus -> usage notify");
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// picker: "≥ 75% discount" preset
|
|
319
|
+
{
|
|
320
|
+
const { flexThreshold } = await runCmd("", { select: "≥ 75% discount" });
|
|
321
|
+
assert(flexThreshold === 75, "picker '≥ 75% discount' -> 75");
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// picker: "Off" preset
|
|
325
|
+
{
|
|
326
|
+
const { flexThreshold } = await runCmd("", { select: "Off — allow all discounts" });
|
|
327
|
+
assert(flexThreshold === null, "picker 'Off' -> null");
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// picker: "Custom…" then input "60"
|
|
331
|
+
{
|
|
332
|
+
const { flexThreshold } = await runCmd("", { select: "Custom…", input: "60" });
|
|
333
|
+
assert(flexThreshold === 60, "picker Custom + input 60 -> 60");
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// picker: custom input invalid -> no change, warning
|
|
337
|
+
{
|
|
338
|
+
const { flexThreshold, notifications } = await runCmd("", { select: "Custom…", input: "abc" });
|
|
339
|
+
assert(flexThreshold === 60, "invalid custom input -> no change (still 60)");
|
|
340
|
+
assert(notifications.some((n) => n.level === "warning" && n.msg.includes("Invalid")), "invalid custom input -> warning notify");
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// picker cancelled (select returns undefined) -> no change, no notify
|
|
344
|
+
{
|
|
345
|
+
const { flexThreshold, notifications } = await runCmd("", { select: undefined });
|
|
346
|
+
assert(flexThreshold === 60, "picker cancelled -> no change (still 60)");
|
|
347
|
+
assert(notifications.length === 0, "picker cancelled -> no notify");
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// non-interactive guard -> error notify, no change
|
|
351
|
+
{
|
|
352
|
+
const { flexThreshold, notifications } = await runCmd("75", { hasUI: false });
|
|
353
|
+
assert(flexThreshold === 60, "non-interactive -> no change (still 60)");
|
|
354
|
+
assert(notifications.some((n) => n.level === "error" && n.msg.includes("interactive")), "non-interactive -> error notify");
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// setting flex via command is visible to the gate (config cache in sync)
|
|
358
|
+
{
|
|
359
|
+
await runCmd("75");
|
|
360
|
+
const { result } = await runInput("interactive", { id: KIMI, provider: "lilac" });
|
|
361
|
+
eq(result, { action: "handled" }, "after /lilac-flex 75, gate blocks kimi@25%");
|
|
362
|
+
await runCmd("off");
|
|
363
|
+
const { result: afterOff } = await runInput("interactive", { id: KIMI, provider: "lilac" });
|
|
364
|
+
eq(afterOff, { action: "continue" }, "after /lilac-flex off, gate allows kimi@25%");
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// ─── Summary ───────────────────────────────────────────────────────────────────
|
|
368
|
+
|
|
369
|
+
console.log(`\n${failed === 0 ? "ALL PASS" : `${failed} FAILED`}`);
|
|
370
|
+
process.exit(failed === 0 ? 0 : 1);
|
|
@@ -166,9 +166,9 @@ const cfgPath = path.join(os.homedir(), ".pi", "agent", "extensions", "lilac.jso
|
|
|
166
166
|
// Fresh tmpHome: no config file -> loadConfig auto-populates the scaffold and returns defaults
|
|
167
167
|
assert(!fs.existsSync(cfgPath), "scaffold not present before first loadConfig");
|
|
168
168
|
const cfg = loadConfig();
|
|
169
|
-
eq(cfg, { modelOverrides: {} }, "missing file -> defaults (empty modelOverrides)");
|
|
169
|
+
eq(cfg, { modelOverrides: {}, flexThreshold: null }, "missing file -> defaults (empty modelOverrides, flex off)");
|
|
170
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");
|
|
171
|
+
eq(JSON.parse(fs.readFileSync(cfgPath, "utf8")), { modelOverrides: {}, flexThreshold: null }, "scaffold file contains the default shape");
|
|
172
172
|
}
|
|
173
173
|
|
|
174
174
|
{
|
|
@@ -192,7 +192,7 @@ const cfgPath = path.join(os.homedir(), ".pi", "agent", "extensions", "lilac.jso
|
|
|
192
192
|
// Existing file with invalid JSON -> defaults returned, file left UNTOUCHED (typo not wiped)
|
|
193
193
|
fs.writeFileSync(cfgPath, "not json {{{");
|
|
194
194
|
const cfg = loadConfig();
|
|
195
|
-
eq(cfg, { modelOverrides: {} }, "invalid JSON -> defaults");
|
|
195
|
+
eq(cfg, { modelOverrides: {}, flexThreshold: null }, "invalid JSON -> defaults");
|
|
196
196
|
assert(fs.readFileSync(cfgPath, "utf8") === "not json {{{", "invalid file is not overwritten (typo preserved)");
|
|
197
197
|
}
|
|
198
198
|
|