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 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 { modelOverrides: parseModelOverrides(raw.modelOverrides) };
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
- if (!latestDiscounts) return "supply: checking…";
660
- const discount = latestDiscounts.get(modelId);
661
- if (!discount) return "supply: —";
662
- return `supply: ${discount.supplyState} · sub-discount: ${discount.discountPercent}%`;
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
- cacheDiscounts(discounts);
825
- latestDiscounts = discounts;
826
- const freshList = getListModels();
827
- pi.registerProvider("lilac", {
828
- baseUrl: BASE_URL,
829
- apiKey: "$LILAC_API_KEY",
830
- api: "openai-completions",
831
- models: applyDiscounts(freshList, discounts),
832
- });
833
- syncStatus(ctx);
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
@@ -102,7 +102,7 @@
102
102
  "cost": {
103
103
  "input": 0.3,
104
104
  "output": 1.2,
105
- "cacheRead": 0.06,
105
+ "cacheRead": 0.055,
106
106
  "cacheWrite": 0
107
107
  },
108
108
  "contextWindow": 204800,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-lilac-provider",
3
- "version": "1.6.2",
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