pi-lilac-provider 1.6.2 → 1.7.1

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
@@ -84,12 +84,11 @@
84
84
  * Then use /model to select from available models
85
85
  */
86
86
 
87
- import type { ExtensionAPI, ModelRegistry } from "@earendil-works/pi-coding-agent";
87
+ import { getAgentDir, type ExtensionAPI, type ModelRegistry } from "@earendil-works/pi-coding-agent";
88
88
  import modelsData from "./models.json" with { type: "json" };
89
89
  import customModelsData from "./custom-models.json" with { type: "json" };
90
90
  import patchData from "./patch.json" with { type: "json" };
91
91
  import fs from "fs";
92
- import os from "os";
93
92
  import path from "path";
94
93
 
95
94
  // ─── Types ────────────────────────────────────────────────────────────────────
@@ -192,10 +191,14 @@ interface ModelOverride {
192
191
 
193
192
  interface LilacConfig {
194
193
  modelOverrides?: Record<string, ModelOverride>;
194
+ // Flex discount threshold: only allow interactive prompts to reach the LLM
195
+ // when the active lilac model's discountPercent is >= this. null/undefined =
196
+ // off (allow all). Set via /lilac-flex; persisted in lilac.json.
197
+ flexThreshold?: number | null;
195
198
  }
196
199
 
197
- const CONFIG_PATH = path.join(os.homedir(), ".pi", "agent", "extensions", "lilac.json");
198
- const DEFAULT_CONFIG: LilacConfig = { modelOverrides: {} };
200
+ const CONFIG_PATH = path.join(getAgentDir(), "extensions", "lilac.json");
201
+ const DEFAULT_CONFIG: LilacConfig = { modelOverrides: {}, flexThreshold: null };
199
202
 
200
203
  // Validate user-supplied modelOverrides from the config file. Non-object ids and
201
204
  // non-object overrides are dropped silently so a malformed file doesn't crash
@@ -220,6 +223,23 @@ function parseModelOverrides(raw: unknown): Record<string, ModelOverride> | unde
220
223
  return Object.keys(result).length > 0 ? result : undefined;
221
224
  }
222
225
 
226
+ // Validate a flex discount threshold from the config file. A number in [0,100] is
227
+ // returned as-is; null / "off" / "" map to null (flex disabled); anything else
228
+ // returns undefined (treated as disabled by the gate, but not normalized so an
229
+ // invalid value isn't silently rewritten). Mirrors parseModelOverrides' lenient
230
+ // parsing so a malformed file doesn't crash the gate or model registration.
231
+ function parseFlexThreshold(raw: unknown): number | null | undefined {
232
+ if (raw === null) return null;
233
+ if (typeof raw === "number" && Number.isFinite(raw) && raw >= 0 && raw <= 100) return raw;
234
+ if (typeof raw === "string") {
235
+ const s = raw.trim().toLowerCase();
236
+ if (s === "" || s === "off" || s === "none") return null;
237
+ const n = Number(s);
238
+ if (Number.isFinite(n) && n >= 0 && n <= 100) return n;
239
+ }
240
+ return undefined;
241
+ }
242
+
223
243
  // Reads ~/.pi/agent/extensions/lilac.json. Missing file → populate with defaults
224
244
  // so the user can discover it, then return defaults. An existing-but-invalid file
225
245
  // is left untouched (defaults returned) so a user's typo isn't silently wiped —
@@ -241,7 +261,10 @@ function loadConfig(): LilacConfig {
241
261
  }
242
262
  try {
243
263
  const raw = JSON.parse(rawText);
244
- return { modelOverrides: parseModelOverrides(raw.modelOverrides) };
264
+ return {
265
+ modelOverrides: parseModelOverrides(raw.modelOverrides),
266
+ flexThreshold: parseFlexThreshold(raw.flexThreshold),
267
+ };
245
268
  } catch {
246
269
  // File exists but is invalid JSON — return defaults WITHOUT overwriting.
247
270
  return { ...DEFAULT_CONFIG };
@@ -288,6 +311,29 @@ function getConfig(): LilacConfig {
288
311
  return config;
289
312
  }
290
313
 
314
+ // Read-modify-write the config file and refresh the in-memory cache. Used by
315
+ // /lilac-flex so a threshold change takes effect immediately (the input gate and
316
+ // footer read getConfig()) without a restart, and without clobbering the user's
317
+ // modelOverrides. Reads via loadConfig (which validates), so modelOverrides the
318
+ // user hand-edited since startup survive the spread. The file is normalized to a
319
+ // discoverable shape (modelOverrides: {}, flexThreshold: null always present) so
320
+ // /lilac-flex never strips the modelOverrides scaffold from the file.
321
+ function updateConfig(mutator: (cfg: LilacConfig) => LilacConfig): LilacConfig {
322
+ const next = mutator(loadConfig());
323
+ const toWrite: LilacConfig = {
324
+ modelOverrides: next.modelOverrides ?? {},
325
+ flexThreshold: next.flexThreshold ?? null,
326
+ };
327
+ try {
328
+ fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true });
329
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(toWrite, null, 2) + "\n");
330
+ } catch {
331
+ // Write failure is non-fatal — the in-memory cache below still updates.
332
+ }
333
+ config = next;
334
+ return next;
335
+ }
336
+
291
337
  function activeOverrides(): Record<string, ModelOverride> {
292
338
  return getConfig().modelOverrides ?? {};
293
339
  }
@@ -386,7 +432,7 @@ const PROVIDER_ID = "lilac";
386
432
  const BASE_URL = "https://api.getlilac.com/v1";
387
433
  const STATUS_URL = "https://api.getlilac.com/status";
388
434
  const MODELS_URL = `${BASE_URL}/models`;
389
- const CACHE_DIR = path.join(os.homedir(), ".pi", "agent", "cache");
435
+ const CACHE_DIR = path.join(getAgentDir(), "cache");
390
436
  const CACHE_PATH = path.join(CACHE_DIR, `${PROVIDER_ID}-models.json`);
391
437
  const DISCOUNT_CACHE_PATH = path.join(CACHE_DIR, `${PROVIDER_ID}-discounts.json`);
392
438
  const LIVE_FETCH_TIMEOUT_MS = 8000;
@@ -656,10 +702,19 @@ function loadCachedDiscounts(): Map<string, JsonDiscount> | null {
656
702
 
657
703
  function formatDiscountStatus(modelId?: string): string {
658
704
  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}%`;
705
+ const threshold = getConfig().flexThreshold ?? null;
706
+ const discount = latestDiscounts?.get(modelId);
707
+ let base: string;
708
+ if (discount) {
709
+ base = `supply: ${discount.supplyState} · sub-discount: ${discount.discountPercent}%`;
710
+ } else if (latestDiscounts) {
711
+ base = "supply: —";
712
+ } else {
713
+ base = "supply: checking…";
714
+ }
715
+ if (threshold == null) return base;
716
+ const pct = discount?.discountPercent ?? 0;
717
+ return `${base} · flex ≥${threshold}% ${pct >= threshold ? "ok" : "blocked"}`;
663
718
  }
664
719
 
665
720
  function dimStatus(ctx: any, text: string): string {
@@ -705,6 +760,50 @@ function syncStatus(ctx: any): void {
705
760
  }
706
761
  }
707
762
 
763
+ // Parse a /lilac-flex argument or custom-threshold input: "off"/"none"/"" → null
764
+ // (disabled), a number in [0,100] (optionally with a trailing %) → that number,
765
+ // anything else → undefined (invalid).
766
+ function parseFlexArg(raw: string): number | null | undefined {
767
+ const s = raw.trim().toLowerCase().replace(/%$/, "");
768
+ if (s === "" || s === "off" || s === "none") return null;
769
+ const n = Number(s);
770
+ if (!Number.isFinite(n) || n < 0 || n > 100) return undefined;
771
+ return Math.round(n);
772
+ }
773
+
774
+ // Persist a flex threshold via updateConfig and report the resulting state against
775
+ // the live model's current discount. The footer is re-painted (syncStatus) so the
776
+ // flex indicator appears immediately. Used by the /lilac-flex command.
777
+ function applyFlexThreshold(value: number | null, ctx: any): void {
778
+ updateConfig((cfg) => ({ ...cfg, flexThreshold: value }));
779
+ const threshold = getConfig().flexThreshold ?? null;
780
+ const model = ctx.model;
781
+ const discount = model?.provider === "lilac" ? latestDiscounts?.get(model.id) : undefined;
782
+ const pct = discount?.discountPercent;
783
+
784
+ let msg: string;
785
+ let level: "info" | "warning";
786
+ if (threshold == null) {
787
+ msg = "lilac-flex: off — all discounts allowed";
788
+ level = "info";
789
+ } else if (pct == null) {
790
+ msg = `lilac-flex: ≥ ${threshold}% — no discount data yet; will block until the next poll`;
791
+ level = "warning";
792
+ } else if (pct >= threshold) {
793
+ msg = `lilac-flex: ≥ ${threshold}% — current ${pct}% discount allowed`;
794
+ level = "info";
795
+ } else {
796
+ msg = `lilac-flex: ≥ ${threshold}% — current ${pct}% discount blocked until it improves`;
797
+ level = "warning";
798
+ }
799
+ try {
800
+ ctx.ui.notify(msg, level);
801
+ } catch {
802
+ // notify is a no-op without a UI runner
803
+ }
804
+ syncStatus(ctx);
805
+ }
806
+
708
807
  function discountsChanged(
709
808
  a: Map<string, JsonDiscount> | null,
710
809
  b: Map<string, JsonDiscount> | null,
@@ -735,6 +834,10 @@ let lastDiscountFetchTime = 0;
735
834
  // they cooperate: a poll that just ran lets the next turn skip its own fetch
736
835
  // within the TTL.
737
836
  const STATUS_CACHE_TTL_MS = 60000;
837
+ // Throttle for the immediate /status refresh fired when flex blocks a prompt, so
838
+ // a user retrying repeatedly doesn't hammer the endpoint while still getting fresh
839
+ // data within seconds (vs. waiting on the 5-min idle poll).
840
+ const BLOCK_REFRESH_THROTTLE_MS = 5000;
738
841
  // Lilac refreshes discounts ~every 10 minutes (per their docs: "Discounts refresh
739
842
  // approximately every 10 minutes and are locked in when a request starts"). Poll
740
843
  // every 5 min during idle — half the refresh window — so a long-idle session
@@ -803,6 +906,22 @@ export default function (pi: ExtensionAPI) {
803
906
  }
804
907
  }
805
908
 
909
+ // Cache + hot-swap freshly fetched discounts: update latestDiscounts, re-register
910
+ // the provider so other lilac models pick up the new price on their next request,
911
+ // and re-paint the footer from the LIVE model. Shared by the idle poll and the
912
+ // flex block refresh so the apply logic can't drift between them.
913
+ function applyFreshDiscounts(discounts: Map<string, JsonDiscount>, ctx: any): void {
914
+ cacheDiscounts(discounts);
915
+ latestDiscounts = discounts;
916
+ pi.registerProvider("lilac", {
917
+ baseUrl: BASE_URL,
918
+ apiKey: "$LILAC_API_KEY",
919
+ api: "openai-completions",
920
+ models: applyDiscounts(getListModels(), discounts),
921
+ });
922
+ syncStatus(ctx);
923
+ }
924
+
806
925
  /**
807
926
  * Background /status poll, fired every STATUS_POLL_INTERVAL_MS (5 min) from
808
927
  * session_start to cover idle sessions. Mirrors the discount half of
@@ -821,16 +940,25 @@ export default function (pi: ExtensionAPI) {
821
940
  syncStatus(ctx);
822
941
  return;
823
942
  }
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);
943
+ applyFreshDiscounts(discounts, ctx);
944
+ }).catch(() => { /* network errors are non-fatal */ });
945
+ }
946
+
947
+ // Fire-and-forget /status refresh used when flex blocks a prompt, so the user
948
+ // isn't stuck on a stale low value from the 5-min idle poll. Throttled by
949
+ // BLOCK_REFRESH_THROTTLE_MS so repeated retries don't hammer the endpoint. Not
950
+ // signal-guarded (user-initiated, not session-scoped); network errors are non-fatal.
951
+ function triggerDiscountRefresh(ctx: any): void {
952
+ if (!cachedApiKey) return;
953
+ if (Date.now() - lastDiscountFetchTime < BLOCK_REFRESH_THROTTLE_MS) return;
954
+ fetchStatusDiscounts(cachedApiKey).then(discounts => {
955
+ if (!discounts) return;
956
+ lastDiscountFetchTime = Date.now();
957
+ if (!discountsChanged(latestDiscounts, discounts)) {
958
+ syncStatus(ctx);
959
+ return;
960
+ }
961
+ applyFreshDiscounts(discounts, ctx);
834
962
  }).catch(() => { /* network errors are non-fatal */ });
835
963
  }
836
964
 
@@ -1025,6 +1153,84 @@ export default function (pi: ExtensionAPI) {
1025
1153
  };
1026
1154
  });
1027
1155
 
1156
+ // lilac-flex: gate interactive prompts on the active lilac model's discount.
1157
+ // Only interactive prompts are gated — extension-injected messages are skipped
1158
+ // (programmatic; would loop) and rpc/print are skipped (a silent block would be
1159
+ // a confusing failure in automation). A missing discount entry or no data yet
1160
+ // counts as 0% (list price), consistent with applyDiscounts/applyDiscountInPlace.
1161
+ // When blocked, the prompt is dropped (handled) and an immediate /status refresh
1162
+ // is triggered so the next submission isn't waiting on the 5-min idle poll.
1163
+ pi.on("input", async (event, ctx) => {
1164
+ if (event.source !== "interactive") return { action: "continue" };
1165
+
1166
+ const threshold = getConfig().flexThreshold ?? null;
1167
+ if (threshold == null) return { action: "continue" };
1168
+
1169
+ const model = ctx.model;
1170
+ if (!model || model.provider !== "lilac") return { action: "continue" };
1171
+
1172
+ const discount = latestDiscounts?.get(model.id);
1173
+ const discountPercent = discount?.discountPercent ?? 0;
1174
+ if (discountPercent >= threshold) return { action: "continue" };
1175
+
1176
+ const desc = discount
1177
+ ? `${discountPercent}% discount`
1178
+ : (latestDiscounts ? "no discount on this model (list price)" : "no discount data yet");
1179
+ ctx.ui.notify(
1180
+ `lilac-flex: ${desc} < ${threshold}% threshold — blocked until the next poll. Re-submit once the discount improves, or run /lilac-flex to adjust.`,
1181
+ "warning",
1182
+ );
1183
+ triggerDiscountRefresh(ctx);
1184
+ return { action: "handled" };
1185
+ });
1186
+
1187
+ pi.registerCommand("lilac-flex", {
1188
+ description: "Set lilac flex discount threshold — only respond at/above this discount",
1189
+ async handler(args, ctx) {
1190
+ if (!ctx.hasUI) {
1191
+ ctx.ui.notify("/lilac-flex requires interactive mode.", "error");
1192
+ return;
1193
+ }
1194
+
1195
+ const arg = (args ?? "").trim();
1196
+ if (arg) {
1197
+ const value = parseFlexArg(arg);
1198
+ if (value === undefined) {
1199
+ ctx.ui.notify("Usage: /lilac-flex [off | 0-100]", "warning");
1200
+ return;
1201
+ }
1202
+ applyFlexThreshold(value, ctx);
1203
+ return;
1204
+ }
1205
+
1206
+ const current = getConfig().flexThreshold ?? null;
1207
+ const labels = [
1208
+ "Off — allow all discounts",
1209
+ "≥ 50% discount",
1210
+ "≥ 75% discount",
1211
+ "Custom…",
1212
+ ];
1213
+ const choice = await ctx.ui.select("Lilac flex: only respond at/above this discount", labels);
1214
+ if (choice === undefined) return; // cancelled
1215
+
1216
+ let value: number | null;
1217
+ if (choice === labels[0]) value = null;
1218
+ else if (choice === labels[1]) value = 50;
1219
+ else if (choice === labels[2]) value = 75;
1220
+ else {
1221
+ const input = await ctx.ui.input("Threshold (0–100, or 'off'):", String(current ?? ""));
1222
+ if (input === undefined) return; // cancelled
1223
+ const parsed = parseFlexArg(input);
1224
+ if (parsed === undefined) {
1225
+ ctx.ui.notify("Invalid threshold. Use a number 0–100 or 'off'.", "warning");
1226
+ return;
1227
+ }
1228
+ value = parsed;
1229
+ }
1230
+ applyFlexThreshold(value, ctx);
1231
+ },
1232
+ });
1233
+
1028
1234
  pi.on("session_shutdown", () => {
1029
1235
  revalidateAbort?.abort();
1030
1236
  if (pollInterval) {
@@ -1034,5 +1240,5 @@ export default function (pi: ExtensionAPI) {
1034
1240
  });
1035
1241
  }
1036
1242
 
1037
- export { fetchStatusDiscounts, applyDiscounts, applyDiscountInPlace, loadCachedDiscounts, cacheDiscounts, buildModels, applyModelOverride, parseModelOverrides, loadConfig, getConfig };
1243
+ export { fetchStatusDiscounts, applyDiscounts, applyDiscountInPlace, loadCachedDiscounts, cacheDiscounts, buildModels, applyModelOverride, parseModelOverrides, parseFlexThreshold, updateConfig, loadConfig, getConfig };
1038
1244
  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.1",
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",
@@ -25,6 +25,12 @@
25
25
  ],
26
26
  "author": "",
27
27
  "license": "MIT",
28
+ "peerDependencies": {
29
+ "@earendil-works/pi-coding-agent": ">=0.74.0"
30
+ },
31
+ "devDependencies": {
32
+ "@earendil-works/pi-coding-agent": "0.79.8"
33
+ },
28
34
  "pi": {
29
35
  "extensions": [
30
36
  "./index.ts"
@@ -34,10 +40,11 @@
34
40
  "clean": "echo 'nothing to clean'",
35
41
  "build": "echo 'nothing to build'",
36
42
  "check": "echo 'nothing to check'",
37
- "test": "node scripts/test-discounts.ts && node scripts/test-preserved-thinking.ts && node scripts/test-model-overrides.ts",
43
+ "test": "node scripts/test-discounts.ts && node scripts/test-preserved-thinking.ts && node scripts/test-model-overrides.ts && node scripts/test-flex.ts",
38
44
  "test:discounts": "node scripts/test-discounts.ts",
39
45
  "test:thinking": "node scripts/test-preserved-thinking.ts",
40
46
  "test:overrides": "node scripts/test-model-overrides.ts",
47
+ "test:flex": "node scripts/test-flex.ts",
41
48
  "update-models": "node scripts/update-models.js"
42
49
  }
43
50
  }
@@ -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 path from "path";
27
+ import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
28
+
29
+ // Isolate config + cache to a temp agent dir so loadConfig/cacheDiscounts never touch
30
+ // the real ~/.pi. Must be set before importing index.ts, which computes
31
+ // CONFIG_PATH / CACHE_PATH at module scope.
32
+ const tmpHome = `/tmp/pi-lilac-flex-test-${Date.now()}`;
33
+ fs.mkdirSync(tmpHome, { recursive: true });
34
+ process.env.HOME = tmpHome;
35
+ process.env.PI_CODING_AGENT_DIR = path.join(tmpHome, ".pi", "agent");
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(getAgentDir(), "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);
@@ -20,19 +20,20 @@
20
20
  */
21
21
 
22
22
  import fs from "fs";
23
- import os from "os";
24
23
  import path from "path";
24
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
25
25
 
26
26
  const root = path.resolve(import.meta.dirname, "..");
27
27
  const modelsData = JSON.parse(fs.readFileSync(path.join(root, "models.json"), "utf8"));
28
28
  const customModelsData = JSON.parse(fs.readFileSync(path.join(root, "custom-models.json"), "utf8"));
29
29
  const patchData = JSON.parse(fs.readFileSync(path.join(root, "patch.json"), "utf8"));
30
30
 
31
- // Isolate config + cache to a temp HOME so loadConfig never touches the real ~/.pi.
31
+ // Isolate config + cache to a temp agent dir so loadConfig never touches the real ~/.pi.
32
32
  // Must be set before importing index.ts, which computes CONFIG_PATH at module scope.
33
33
  const tmpHome = `/tmp/pi-lilac-override-test-${Date.now()}`;
34
34
  fs.mkdirSync(tmpHome, { recursive: true });
35
35
  process.env.HOME = tmpHome;
36
+ process.env.PI_CODING_AGENT_DIR = path.join(tmpHome, ".pi", "agent");
36
37
 
37
38
  const {
38
39
  buildModels,
@@ -160,15 +161,15 @@ eq(parseModelOverrides({ id: 123 } as any), undefined, "non-object override (num
160
161
 
161
162
  console.log("\n--- loadConfig ---");
162
163
 
163
- const cfgPath = path.join(os.homedir(), ".pi", "agent", "extensions", "lilac.json");
164
+ const cfgPath = path.join(getAgentDir(), "extensions", "lilac.json");
164
165
 
165
166
  {
166
167
  // Fresh tmpHome: no config file -> loadConfig auto-populates the scaffold and returns defaults
167
168
  assert(!fs.existsSync(cfgPath), "scaffold not present before first loadConfig");
168
169
  const cfg = loadConfig();
169
- eq(cfg, { modelOverrides: {} }, "missing file -> defaults (empty modelOverrides)");
170
+ eq(cfg, { modelOverrides: {}, flexThreshold: null }, "missing file -> defaults (empty modelOverrides, flex off)");
170
171
  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
+ eq(JSON.parse(fs.readFileSync(cfgPath, "utf8")), { modelOverrides: {}, flexThreshold: null }, "scaffold file contains the default shape");
172
173
  }
173
174
 
174
175
  {
@@ -192,7 +193,7 @@ const cfgPath = path.join(os.homedir(), ".pi", "agent", "extensions", "lilac.jso
192
193
  // Existing file with invalid JSON -> defaults returned, file left UNTOUCHED (typo not wiped)
193
194
  fs.writeFileSync(cfgPath, "not json {{{");
194
195
  const cfg = loadConfig();
195
- eq(cfg, { modelOverrides: {} }, "invalid JSON -> defaults");
196
+ eq(cfg, { modelOverrides: {}, flexThreshold: null }, "invalid JSON -> defaults");
196
197
  assert(fs.readFileSync(cfgPath, "utf8") === "not json {{{", "invalid file is not overwritten (typo preserved)");
197
198
  }
198
199