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 +27 -0
- package/index.ts +227 -21
- package/models.json +1 -1
- package/package.json +9 -2
- package/scripts/test-flex.ts +370 -0
- package/scripts/test-model-overrides.ts +7 -6
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
|
|
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(
|
|
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 {
|
|
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(
|
|
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
|
-
|
|
660
|
-
const discount = latestDiscounts
|
|
661
|
-
|
|
662
|
-
|
|
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
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-lilac-provider",
|
|
3
|
-
"version": "1.
|
|
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
|
|
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(
|
|
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
|
|