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 +16 -8
- package/index.ts +172 -63
- package/package.json +1 -1
- package/scripts/test-flex.ts +33 -78
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-
|
|
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
|
|
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
|
|
242
|
+
Set it via the **Flex threshold** row in [`/lilac-settings`](#settings-ui) — cycle `off` / `50` / `75`:
|
|
243
243
|
|
|
244
244
|
```
|
|
245
|
-
/lilac-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
|
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 = "
|
|
776
|
+
msg = "Flex: off — all discounts allowed";
|
|
788
777
|
level = "info";
|
|
789
778
|
} else if (pct == null) {
|
|
790
|
-
msg = `
|
|
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 = `
|
|
782
|
+
msg = `Flex: ≥ ${threshold}% — current ${pct}% discount allowed`;
|
|
794
783
|
level = "info";
|
|
795
784
|
} else {
|
|
796
|
-
msg = `
|
|
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 (
|
|
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
|
-
//
|
|
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
|
-
`
|
|
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
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
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
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
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
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
"
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
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.
|
|
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",
|
package/scripts/test-flex.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* Tests for the
|
|
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
|
-
* -
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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-
|
|
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: (
|
|
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-
|
|
270
|
-
|
|
271
|
-
console.log("\n--- /lilac-flex command ---");
|
|
267
|
+
// ─── applyFlexThreshold (flex configuration, driven by the /lilac-settings row) ─
|
|
272
268
|
|
|
273
|
-
|
|
274
|
-
const cmd = commands.get("lilac-flex")!;
|
|
269
|
+
console.log("\n--- applyFlexThreshold ---");
|
|
275
270
|
|
|
276
|
-
function
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
285
|
+
// set 75 (kimi seeded at 25% -> below threshold -> blocked-warning message)
|
|
331
286
|
{
|
|
332
|
-
const { flexThreshold } =
|
|
333
|
-
assert(flexThreshold ===
|
|
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
|
-
//
|
|
293
|
+
// off
|
|
337
294
|
{
|
|
338
|
-
const { flexThreshold, notifications } =
|
|
339
|
-
assert(flexThreshold ===
|
|
340
|
-
assert(notifications.some((n) => n.
|
|
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
|
-
//
|
|
300
|
+
// threshold below current discount -> allowed (info)
|
|
344
301
|
{
|
|
345
|
-
const {
|
|
346
|
-
assert(
|
|
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
|
-
//
|
|
306
|
+
// no discount data for model -> warning
|
|
351
307
|
{
|
|
352
|
-
const {
|
|
353
|
-
assert(
|
|
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
|
|
312
|
+
// setting flex via applyFlexThreshold is visible to the gate (config cache in sync)
|
|
358
313
|
{
|
|
359
|
-
|
|
314
|
+
runApply(75);
|
|
360
315
|
const { result } = await runInput("interactive", { id: KIMI, provider: "lilac" });
|
|
361
|
-
eq(result, { action: "handled" }, "after
|
|
362
|
-
|
|
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
|
|
319
|
+
eq(afterOff, { action: "continue" }, "after applyFlexThreshold(null), gate allows kimi@25%");
|
|
365
320
|
}
|
|
366
321
|
|
|
367
322
|
// ─── Summary ───────────────────────────────────────────────────────────────────
|