pi-lilac-provider 1.7.2 → 1.8.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,7 +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
+ - **Flex (Discount Gating)** — Only let the LLM respond when the active model's discount meets a threshold you set (`/lilac-settings`)
27
27
  - **Idle GPU Scheduling** — Lilac leverages idle GPU capacity for cost-efficient inference
28
28
 
29
29
  ## Installation
@@ -220,7 +220,7 @@ Create `~/.pi/agent/extensions/lilac.json` (auto-populated with defaults on firs
220
220
  ```jsonc
221
221
  {
222
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.
223
+ // See "Flex (Discount Gating)" below. Set via /lilac-settings.
224
224
  "flexThreshold": null,
225
225
  "modelOverrides": {
226
226
  // Disable full-history reasoning for kimi-k2.6 (e.g. to save tokens):
@@ -239,16 +239,13 @@ The full set of overridable fields matches the model schema (`compat`, `thinking
239
239
 
240
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
241
 
242
- Set it interactively with the `/lilac-flex` command:
242
+ Set it via the **Flex threshold** row in [`/lilac-settings`](#settings-ui) — cycle `off` / `50` / `75`:
243
243
 
244
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)
245
+ /lilac-settings # open the settings panel → Flex threshold row
249
246
  ```
250
247
 
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.
248
+ 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-settings` updates it live — no restart needed. For a custom value (e.g. 60), edit `flexThreshold` in that file directly.
252
249
 
253
250
  Behavior notes:
254
251
 
@@ -258,6 +255,17 @@ Behavior notes:
258
255
  - **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
256
  - **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
257
 
258
+ ### Settings UI
259
+
260
+ `/lilac-settings` opens an interactive settings panel (mirrors pi core's `/settings` — bordered `SettingsList`, Esc to go back) to configure Lilac without editing JSON by hand:
261
+
262
+ - **Flex threshold** (`off` / `50` / `75`) — the spend-control gate from [Flex (Discount Gating)](#flex-discount-gating); custom values via `lilac.json`.
263
+ - **Preserved thinking** (nested submenu, one row per model) — toggles `clear_thinking` (GLM-5.2 / GLM-5.1) / `preserve_thinking` (Kimi K2.6) in `modelOverrides` between **Preserve Thinking** (keep full reasoning history across turns; the default, `clear_thinking: false` / `preserve_thinking: true`) and **Clear Thinking** (let the template drop older reasoning; saves tokens, but can degrade multi-turn recall / cause overthinking).
264
+
265
+ Changes write to `~/.pi/agent/extensions/lilac.json`, refresh the in-memory config, and re-register the provider, so they take effect immediately — no restart needed.
266
+
267
+ When you switch to — or start pi on — a Lilac model that carries a preserved-thinking flag (e.g. GLM-5.2 / GLM-5.1, Kimi K2.6), an info notification reports the state and how to change it, e.g. `Preserved thinking ON for glm-5.2 (clear_thinking: false) — suited for coding, but not for prose. Open /lilac-settings to change.` (OFF reads `... reasoning trimmed each turn (lighter; better for prose) ...`). It's an ordinary info notification (not a warning), so it doesn't paint bright yellow.
268
+
261
269
  ## Updating Models
262
270
 
263
271
  Run the update script to fetch the latest models from Lilac's API:
package/index.ts CHANGED
@@ -193,7 +193,7 @@ interface LilacConfig {
193
193
  modelOverrides?: Record<string, ModelOverride>;
194
194
  // Flex discount threshold: only allow interactive prompts to reach the LLM
195
195
  // when the active lilac model's discountPercent is >= this. null/undefined =
196
- // off (allow all). Set via /lilac-flex; persisted in lilac.json.
196
+ // off (allow all). Set via /lilac-settings; persisted in lilac.json.
197
197
  flexThreshold?: number | null;
198
198
  }
199
199
 
@@ -312,12 +312,12 @@ function getConfig(): LilacConfig {
312
312
  }
313
313
 
314
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
315
+ // /lilac-settings so a threshold change takes effect immediately (the input gate and
316
316
  // footer read getConfig()) without a restart, and without clobbering the user's
317
317
  // modelOverrides. Reads via loadConfig (which validates), so modelOverrides the
318
318
  // user hand-edited since startup survive the spread. The file is normalized to a
319
319
  // discoverable shape (modelOverrides: {}, flexThreshold: null always present) so
320
- // /lilac-flex never strips the modelOverrides scaffold from the file.
320
+ // /lilac-settings never strips the modelOverrides scaffold from the file.
321
321
  function updateConfig(mutator: (cfg: LilacConfig) => LilacConfig): LilacConfig {
322
322
  const next = mutator(loadConfig());
323
323
  const toWrite: LilacConfig = {
@@ -760,20 +760,9 @@ function syncStatus(ctx: any): void {
760
760
  }
761
761
  }
762
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
763
  // Persist a flex threshold via updateConfig and report the resulting state against
775
764
  // 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.
765
+ // flex indicator appears immediately. Used by the /lilac-settings flex row.
777
766
  function applyFlexThreshold(value: number | null, ctx: any): void {
778
767
  updateConfig((cfg) => ({ ...cfg, flexThreshold: value }));
779
768
  const threshold = getConfig().flexThreshold ?? null;
@@ -784,16 +773,16 @@ function applyFlexThreshold(value: number | null, ctx: any): void {
784
773
  let msg: string;
785
774
  let level: "info" | "warning";
786
775
  if (threshold == null) {
787
- msg = "lilac-flex: off — all discounts allowed";
776
+ msg = "Flex: off — all discounts allowed";
788
777
  level = "info";
789
778
  } else if (pct == null) {
790
- msg = `lilac-flex: ≥ ${threshold}% — no discount data yet; will block until the next poll`;
779
+ msg = `Flex: ≥ ${threshold}% — no discount data yet; will block until the next poll`;
791
780
  level = "warning";
792
781
  } else if (pct >= threshold) {
793
- msg = `lilac-flex: ≥ ${threshold}% — current ${pct}% discount allowed`;
782
+ msg = `Flex: ≥ ${threshold}% — current ${pct}% discount allowed`;
794
783
  level = "info";
795
784
  } else {
796
- msg = `lilac-flex: ≥ ${threshold}% — current ${pct}% discount blocked until it improves`;
785
+ msg = `Flex: ≥ ${threshold}% — current ${pct}% discount blocked until it improves`;
797
786
  level = "warning";
798
787
  }
799
788
  try {
@@ -862,6 +851,31 @@ export default function (pi: ExtensionAPI) {
862
851
  const customModels = customModelsData as JsonModel[];
863
852
  const patches = patchData as PatchData;
864
853
 
854
+ // Deferred model_select notify timer — see the model_select handler. Cleared on
855
+ // rapid re-switch and on session_shutdown so only the latest switch notifies.
856
+ let modelSelectNotifyTimer: ReturnType<typeof setTimeout> | null = null;
857
+ const MODEL_SELECT_NOTIFY_DELAY_MS = 250;
858
+
859
+ // Notify preserved-thinking state for a preserve-flag model. Computed from the
860
+ // build pipeline (config as source of truth, not event.model.compat), deferred
861
+ // so pi core's (and other extensions') notifications land first, and cancelled
862
+ // on re-switch/shutdown so only the latest shows. Always level "info" (not a
863
+ // warning) — the text conveys the coding/prose tradeoff.
864
+ function notifyPreservedThinkingFor(model: any, ctx: any): void {
865
+ if (!model || model.provider !== PROVIDER_ID) return;
866
+ const entry = collectPreserveState().find((e: any) => e.id === model.id);
867
+ if (!entry) return;
868
+ const flagValue = entry.flag === "clear_thinking" ? !entry.preserved : entry.preserved;
869
+ const msg = entry.preserved
870
+ ? `Preserved thinking ON for ${entry.name} (${entry.flag}: ${flagValue}) — suited for coding, but not for prose. Open /lilac-settings to change.`
871
+ : `Preserved thinking OFF for ${entry.name} (${entry.flag}: ${flagValue}) — reasoning trimmed each turn (lighter; better for prose). Open /lilac-settings to change.`;
872
+ if (modelSelectNotifyTimer) clearTimeout(modelSelectNotifyTimer);
873
+ modelSelectNotifyTimer = setTimeout(() => {
874
+ modelSelectNotifyTimer = null;
875
+ try { ctx.ui.notify(msg, "info"); } catch { /* notify is a no-op without a UI runner */ }
876
+ }, MODEL_SELECT_NOTIFY_DELAY_MS);
877
+ }
878
+
865
879
  // List-price models (patch applied, pre-discount), cached at module scope and
866
880
  // rebuilt only when the base set changes (see cacheModels). Used to recompute
867
881
  // the in-flight model's cost without compounding an already-applied discount.
@@ -981,6 +995,9 @@ export default function (pi: ExtensionAPI) {
981
995
  // syncStatus reads the LIVE ctx.model so a switch away from lilac before the
982
996
  // background fetch resolves never leaves a stale discount painted.
983
997
  syncStatus(ctx);
998
+ // Show the preserved-thinking notification on first load / resume if the
999
+ // active model carries a preserve flag (model_select may not fire on startup).
1000
+ notifyPreservedThinkingFor(ctx.model, ctx);
984
1001
 
985
1002
  // Fire-and-forget: resolve API key, then fetch live data in background.
986
1003
  // Provider and status are hot-swapped when results arrive.
@@ -1110,11 +1127,12 @@ export default function (pi: ExtensionAPI) {
1110
1127
  syncStatus(ctx);
1111
1128
  });
1112
1129
 
1113
- pi.on("model_select", async (_event, ctx) => {
1130
+ pi.on("model_select", async (event, ctx) => {
1114
1131
  // ctx.model is the live session model (pi sets state.model before emitting
1115
1132
  // this event), so syncStatus paints/clears consistently with every other
1116
1133
  // handler — one source of truth for the footer.
1117
1134
  syncStatus(ctx);
1135
+ notifyPreservedThinkingFor(event.model ?? ctx.model, ctx);
1118
1136
  });
1119
1137
 
1120
1138
  pi.on("session_tree", async (_event, ctx) => {
@@ -1153,7 +1171,7 @@ export default function (pi: ExtensionAPI) {
1153
1171
  };
1154
1172
  });
1155
1173
 
1156
- // lilac-flex: gate interactive prompts on the active lilac model's discount.
1174
+ // Flex: gate interactive prompts on the active lilac model's discount.
1157
1175
  // Only interactive prompts are gated — extension-injected messages are skipped
1158
1176
  // (programmatic; would loop) and rpc/print are skipped (a silent block would be
1159
1177
  // a confusing failure in automation). A missing discount entry or no data yet
@@ -1177,62 +1195,153 @@ export default function (pi: ExtensionAPI) {
1177
1195
  ? `${discountPercent}% discount`
1178
1196
  : (latestDiscounts ? "no discount on this model (list price)" : "no discount data yet");
1179
1197
  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.`,
1198
+ `Flex: ${desc} < ${threshold}% threshold — blocked until the next poll. Re-submit once the discount improves, or open /lilac-settings to adjust.`,
1181
1199
  "warning",
1182
1200
  );
1183
1201
  triggerDiscountRefresh(ctx);
1184
1202
  return { action: "handled" };
1185
1203
  });
1186
1204
 
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;
1205
+ // ─── /lilac-settings: settings UI (mirrors pi core /settings) ──────────────
1206
+ // Opens a SettingsList (lazy-imported from pi-tui) via ctx.ui.custom(). Toggles
1207
+ // write to ~/.pi/agent/extensions/lilac.json (modelOverrides for preserved
1208
+ // thinking; flexThreshold via the shared applyFlexThreshold helper), refresh
1209
+ // the in-memory config, bust the list-price model cache, and re-register the
1210
+ // provider so the change takes effect immediately.
1211
+ function collectPreserveState(): Array<{ id: string; name: string; flag: "clear_thinking" | "preserve_thinking"; preserved: boolean }> {
1212
+ const resolved = buildModels(loadStaleModels(embeddedModels), customModels, patches, activeOverrides());
1213
+ const out: Array<{ id: string; name: string; flag: "clear_thinking" | "preserve_thinking"; preserved: boolean }> = [];
1214
+ for (const m of resolved) {
1215
+ const kwargs = (m as any).compat?.chatTemplateKwargs;
1216
+ if (!kwargs || typeof kwargs !== "object") continue;
1217
+ if (typeof kwargs.clear_thinking === "boolean") {
1218
+ out.push({ id: m.id, name: (m as any).name || m.id, flag: "clear_thinking", preserved: kwargs.clear_thinking === false });
1219
+ } else if (typeof kwargs.preserve_thinking === "boolean") {
1220
+ out.push({ id: m.id, name: (m as any).name || m.id, flag: "preserve_thinking", preserved: kwargs.preserve_thinking === true });
1193
1221
  }
1222
+ }
1223
+ return out;
1224
+ }
1194
1225
 
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);
1226
+ pi.registerCommand("lilac-settings", {
1227
+ description: "Configure Lilac: flex discount threshold + preserved thinking per model",
1228
+ async handler(_args, ctx) {
1229
+ if (ctx.mode !== "tui") {
1230
+ ctx.ui.notify("/lilac-settings requires TUI mode.", "error");
1203
1231
  return;
1204
1232
  }
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);
1233
+ const { SettingsList, Container } = await import("@earendil-works/pi-tui");
1234
+ const { getSettingsListTheme, DynamicBorder } = await import("@earendil-works/pi-coding-agent");
1235
+
1236
+ const currentFlex = getConfig().flexThreshold ?? null;
1237
+
1238
+ await ctx.ui.custom((_tui, theme, _kb, done) => {
1239
+ const border = () => new DynamicBorder((s: string) => theme.fg("border", s));
1240
+ // SettingsList left-aligns the value column after the widest label (capped
1241
+ // at 30 cols). A label wider than 30 shifts that row's value out of
1242
+ // alignment, so cap model-name labels.
1243
+ const truncateLabel = (s: string) => (s.length > 30 ? s.slice(0, 27) + "..." : s);
1244
+
1245
+ const items: any[] = [
1246
+ {
1247
+ id: "flex",
1248
+ label: "Flex threshold",
1249
+ description: "Only respond when the active model's discount is at/above this. 'off' allows all. (Custom values: edit ~/.pi/agent/extensions/lilac.json.)",
1250
+ currentValue: currentFlex == null ? "off" : String(currentFlex),
1251
+ values: ["off", "50", "75"],
1252
+ },
1253
+ {
1254
+ id: "preserved-thinking",
1255
+ label: "Preserved thinking",
1256
+ description: "Per-model Preserve Thinking / Clear Thinking (full-history reasoning). Preserve Thinking keeps all turns' reasoning; Clear Thinking lets the template drop older reasoning (saves tokens, can hurt multi-turn recall / cause overthinking).",
1257
+ currentValue: "configure",
1258
+ submenu: (_currentValue: string, subDone: (v?: string) => void) => {
1259
+ // Re-read state on each open so toggles from a previous visit (which
1260
+ // wrote lilac.json + refreshed config) are reflected — a snapshot
1261
+ // captured at panel-open time would show stale values after a toggle.
1262
+ const fresh = collectPreserveState();
1263
+ const subItems = fresh.map((e) => ({
1264
+ id: `preserve:${e.id}`,
1265
+ label: truncateLabel(e.name),
1266
+ description: `${e.id} — Preserve Thinking keeps full reasoning history across turns; Clear Thinking lets the template drop older reasoning (saves tokens, can hurt multi-turn recall / cause overthinking).`,
1267
+ currentValue: e.preserved ? "Preserve Thinking" : "Clear Thinking",
1268
+ values: ["Preserve Thinking", "Clear Thinking"],
1269
+ }));
1270
+ const subList = new SettingsList(
1271
+ subItems,
1272
+ Math.min(subItems.length + 2, 15),
1273
+ getSettingsListTheme(),
1274
+ (id: string, newValue: string) => {
1275
+ const modelId = id.slice("preserve:".length);
1276
+ const entry = fresh.find((p) => p.id === modelId);
1277
+ if (!entry) return;
1278
+ const preservedOn = newValue === "Preserve Thinking";
1279
+ const flagValue = entry.flag === "clear_thinking" ? !preservedOn : preservedOn;
1280
+ updateConfig((cfg) => {
1281
+ const overrides = cfg.modelOverrides ?? (cfg.modelOverrides = {});
1282
+ const ov = overrides[modelId] ?? (overrides[modelId] = {});
1283
+ const compat = ov.compat ?? (ov.compat = {});
1284
+ const kwargs = compat.chatTemplateKwargs ?? (compat.chatTemplateKwargs = {});
1285
+ kwargs[entry.flag] = flagValue;
1286
+ return cfg;
1287
+ });
1288
+ listModelsCache = null;
1289
+ pi.registerProvider("lilac", {
1290
+ baseUrl: BASE_URL,
1291
+ apiKey: "$LILAC_API_KEY",
1292
+ api: "openai-completions",
1293
+ models: applyDiscounts(getListModels(), latestDiscounts),
1294
+ });
1295
+ syncStatus(ctx);
1296
+ ctx.ui.notify(`Preserved thinking ${preservedOn ? "on" : "off"} for ${entry.name} — takes effect now.`, "info");
1297
+ },
1298
+ () => subDone(),
1299
+ { enableSearch: true },
1300
+ );
1301
+ // The outer container's borders already frame the panel; return the
1302
+ // list directly so we don't render a second border pair.
1303
+ return subList;
1304
+ },
1305
+ },
1306
+ ];
1307
+
1308
+ const container = new Container();
1309
+ container.addChild(border());
1310
+
1311
+ const settingsList = new SettingsList(
1312
+ items,
1313
+ Math.min(items.length + 2, 15),
1314
+ getSettingsListTheme(),
1315
+ (id: string, newValue: string) => {
1316
+ if (id === "flex") {
1317
+ const value = newValue === "off" ? null : Number(newValue);
1318
+ applyFlexThreshold(value, ctx);
1319
+ }
1320
+ },
1321
+ () => done(undefined),
1322
+ { enableSearch: true },
1323
+ );
1324
+ container.addChild(settingsList);
1325
+ container.addChild(border());
1326
+
1327
+ return {
1328
+ render(width: number) {
1329
+ return container.render(width);
1330
+ },
1331
+ invalidate() {
1332
+ container.invalidate();
1333
+ },
1334
+ handleInput(data: string) {
1335
+ settingsList.handleInput?.(data);
1336
+ },
1337
+ };
1338
+ });
1231
1339
  },
1232
1340
  });
1233
1341
 
1234
1342
  pi.on("session_shutdown", () => {
1235
1343
  revalidateAbort?.abort();
1344
+ if (modelSelectNotifyTimer) { clearTimeout(modelSelectNotifyTimer); modelSelectNotifyTimer = null; }
1236
1345
  if (pollInterval) {
1237
1346
  clearInterval(pollInterval);
1238
1347
  pollInterval = null;
@@ -1240,5 +1349,5 @@ export default function (pi: ExtensionAPI) {
1240
1349
  });
1241
1350
  }
1242
1351
 
1243
- export { fetchStatusDiscounts, applyDiscounts, applyDiscountInPlace, loadCachedDiscounts, cacheDiscounts, buildModels, applyModelOverride, parseModelOverrides, parseFlexThreshold, updateConfig, loadConfig, getConfig };
1352
+ export { fetchStatusDiscounts, applyDiscounts, applyDiscountInPlace, loadCachedDiscounts, cacheDiscounts, buildModels, applyModelOverride, parseModelOverrides, parseFlexThreshold, updateConfig, loadConfig, getConfig, applyFlexThreshold };
1244
1353
  export type { JsonDiscount, JsonModel, PatchEntry, PatchData, ModelOverride, LilacConfig };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-lilac-provider",
3
- "version": "1.7.2",
3
+ "version": "1.8.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",
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Tests for the lilac-flex feature: discount-threshold gating.
3
+ * Tests for the Lilac flex feature: discount-threshold gating.
4
4
  *
5
5
  * Verifies, against the REAL exported helpers and registered handlers/commands
6
6
  * from index.ts (not a re-implementation):
@@ -14,9 +14,9 @@
14
14
  * discount < threshold; allows (continue) when >= threshold, when flex is
15
15
  * off, for non-lilac models, for non-interactive sources, and for models
16
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.
17
+ * - applyFlexThreshold (flex configuration, now driven by the /lilac-settings
18
+ * flex row): sets flexThreshold, persists to disk, reports state against the
19
+ * live model's discount, and is visible to the input gate.
20
20
  *
21
21
  * Config FS + discount cache are isolated to a temp HOME so nothing touches the
22
22
  * real ~/.pi.
@@ -29,7 +29,7 @@ import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent"
29
29
  // Isolate config + cache to a temp agent dir so loadConfig/cacheDiscounts never touch
30
30
  // the real ~/.pi. Must be set before importing index.ts, which computes
31
31
  // CONFIG_PATH / CACHE_PATH at module scope.
32
- const tmpHome = `/tmp/pi-lilac-flex-test-${Date.now()}`;
32
+ const tmpHome = `/tmp/pi-lilac-test-${Date.now()}`;
33
33
  fs.mkdirSync(tmpHome, { recursive: true });
34
34
  process.env.HOME = tmpHome;
35
35
  process.env.PI_CODING_AGENT_DIR = path.join(tmpHome, ".pi", "agent");
@@ -41,6 +41,7 @@ const {
41
41
  getConfig,
42
42
  updateConfig,
43
43
  cacheDiscounts,
44
+ applyFlexThreshold,
44
45
  } = await import("../index.ts");
45
46
 
46
47
  let passed = 0;
@@ -167,7 +168,6 @@ cacheDiscounts(new Map([
167
168
  ]));
168
169
 
169
170
  const handlers = new Map<string, ((...args: any[]) => any)[]>();
170
- const commands = new Map<string, { handler: (...args: any[]) => any }>();
171
171
 
172
172
  const mockApi: ExtensionAPI = {
173
173
  registerProvider: () => {},
@@ -175,9 +175,7 @@ const mockApi: ExtensionAPI = {
175
175
  if (!handlers.has(event)) handlers.set(event, []);
176
176
  handlers.get(event)!.push(handler);
177
177
  },
178
- registerCommand: (name: string, opts: any) => {
179
- commands.set(name, opts);
180
- },
178
+ registerCommand: () => {},
181
179
  appendEntry: () => {},
182
180
  exec: async () => ({ exitCode: 0, stdout: "", stderr: "" }),
183
181
  } as any;
@@ -266,102 +264,59 @@ updateConfig((c) => ({ ...c, flexThreshold: 25 }));
266
264
  eq(result, { action: "continue" }, "25% >= 25% threshold (inclusive) -> continue");
267
265
  }
268
266
 
269
- // ─── /lilac-flex command ──────────────────────────────────────────────────────
270
-
271
- console.log("\n--- /lilac-flex command ---");
267
+ // ─── applyFlexThreshold (flex configuration, driven by the /lilac-settings row) ─
272
268
 
273
- assert(commands.has("lilac-flex"), "/lilac-flex command registered");
274
- const cmd = commands.get("lilac-flex")!;
269
+ console.log("\n--- applyFlexThreshold ---");
275
270
 
276
- function runCmd(args: string, opts: { select?: string; input?: string; hasUI?: boolean; model?: any } = {}) {
271
+ function runApply(value: number | null, model: any = { id: KIMI, provider: "lilac" }) {
277
272
  const notifications: { msg: string; level: string }[] = [];
278
273
  const ctx = {
279
- hasUI: opts.hasUI ?? true,
280
- model: opts.model ?? { id: KIMI, provider: "lilac" },
274
+ model,
281
275
  ui: {
282
276
  notify: (msg: string, level: string) => notifications.push({ msg, level }),
283
277
  setStatus: () => {},
284
278
  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
279
  },
288
280
  };
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");
281
+ applyFlexThreshold(value, ctx);
282
+ return { notifications, flexThreshold: getConfig().flexThreshold ?? null };
328
283
  }
329
284
 
330
- // picker: "Custom…" then input "60"
285
+ // set 75 (kimi seeded at 25% -> below threshold -> blocked-warning message)
331
286
  {
332
- const { flexThreshold } = await runCmd("", { select: "Custom…", input: "60" });
333
- assert(flexThreshold === 60, "picker Custom + input 60 -> 60");
287
+ const { flexThreshold, notifications } = runApply(75);
288
+ assert(flexThreshold === 75, "applyFlexThreshold(75) -> flexThreshold 75");
289
+ assert(JSON.parse(fs.readFileSync(cfgPath, "utf8")).flexThreshold === 75, "persisted to disk");
290
+ assert(notifications.some((n) => n.msg.includes("75%") && n.level === "warning"), "75 with kimi@25% -> warning, mentions threshold");
334
291
  }
335
292
 
336
- // picker: custom input invalid -> no change, warning
293
+ // off
337
294
  {
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");
295
+ const { flexThreshold, notifications } = runApply(null);
296
+ assert(flexThreshold === null, "applyFlexThreshold(null) -> null");
297
+ assert(notifications.some((n) => n.msg.includes("off")), "off -> notify mentions off");
341
298
  }
342
299
 
343
- // picker cancelled (select returns undefined) -> no change, no notify
300
+ // threshold below current discount -> allowed (info)
344
301
  {
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");
302
+ const { notifications } = runApply(20);
303
+ assert(notifications.some((n) => n.level === "info" && n.msg.includes("allowed")), "20 with kimi@25% -> info, allowed");
348
304
  }
349
305
 
350
- // non-interactive guard -> error notify, no change
306
+ // no discount data for model -> warning
351
307
  {
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");
308
+ const { notifications } = runApply(75, { id: "zai-org/glm-5.1", provider: "lilac" });
309
+ assert(notifications.some((n) => n.level === "warning" && n.msg.includes("no discount data")), "uncached model + threshold -> warning, no discount data");
355
310
  }
356
311
 
357
- // setting flex via command is visible to the gate (config cache in sync)
312
+ // setting flex via applyFlexThreshold is visible to the gate (config cache in sync)
358
313
  {
359
- await runCmd("75");
314
+ runApply(75);
360
315
  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");
316
+ eq(result, { action: "handled" }, "after applyFlexThreshold(75), gate blocks kimi@25%");
317
+ runApply(null);
363
318
  const { result: afterOff } = await runInput("interactive", { id: KIMI, provider: "lilac" });
364
- eq(afterOff, { action: "continue" }, "after /lilac-flex off, gate allows kimi@25%");
319
+ eq(afterOff, { action: "continue" }, "after applyFlexThreshold(null), gate allows kimi@25%");
365
320
  }
366
321
 
367
322
  // ─── Summary ───────────────────────────────────────────────────────────────────