pi-lilac-provider 1.2.0 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -77,7 +77,7 @@ pi
77
77
  | GLM 5.2 | 524K | ❌ | ✅ | $0.90 | $0.27 | $3.00 |
78
78
  | Kimi K2.6 | 262K | ✅ | ✅ | $0.70 | $0.20 | $3.50 |
79
79
  | MiniMax M2.7 | 205K | ❌ | ✅ | $0.30 | $0.06 | $1.20 |
80
- | MiniMax M3 | 1.0M | | ✅ | $0.28 | $0.05 | $1.10 |
80
+ | MiniMax M3 | 1.0M | | ✅ | $0.28 | $0.05 | $1.10 |
81
81
 
82
82
  *Costs are per million tokens. Prices subject to change — check [getlilac.com](https://getlilac.com/) for current pricing.*
83
83
 
package/index.ts CHANGED
@@ -479,6 +479,41 @@ function dimStatus(ctx: any, text: string): string {
479
479
  }
480
480
  }
481
481
 
482
+ /**
483
+ * Paint the footer status from the LIVE session model — the single source of
484
+ * truth for the display. ctx.model is a lazy getter (not a snapshot), so this
485
+ * reflects the currently-selected model even when called from a deferred
486
+ * post-await callback that captured a different (stale) model at hook start.
487
+ *
488
+ * Set for lilac models, cleared for everything else. Every status paint site
489
+ * calls this, so whichever handler runs last wins — including after a switch
490
+ * to a non-lilac model, fixing a race where deferred post-await setStatus()
491
+ * calls re-painted a stale captured lilac model's discount over the clear
492
+ * that model_select had just issued.
493
+ *
494
+ * Only the DISPLAY follows the live model. Cost mutation (applyDiscountInPlace)
495
+ * and registerProvider() in before_provider_request still target the turn's
496
+ * captured in-flight model — that's the object pi bound and whose .cost
497
+ * calculateCost() reads, and it may legitimately differ from the live model
498
+ * after a mid-turn /model switch.
499
+ *
500
+ * Wrapped in try/catch: ctx.model / ctx.ui assert the extension runner is
501
+ * still active and throw if the session ended mid-fetch, so a late deferred
502
+ * callback after session_shutdown no-ops instead of throwing.
503
+ */
504
+ function syncStatus(ctx: any): void {
505
+ try {
506
+ const model = ctx.model;
507
+ if (model?.provider === "lilac") {
508
+ ctx.ui.setStatus("lilac", dimStatus(ctx, formatDiscountStatus(model.id)));
509
+ } else {
510
+ ctx.ui.setStatus("lilac", undefined);
511
+ }
512
+ } catch {
513
+ // Runner stale (session ended mid-fetch) — nothing to paint.
514
+ }
515
+ }
516
+
482
517
  function discountsChanged(
483
518
  a: Map<string, JsonDiscount> | null,
484
519
  b: Map<string, JsonDiscount> | null,
@@ -572,11 +607,10 @@ export default function (pi: ExtensionAPI) {
572
607
  // Replay persisted discount state from session JSONL (synchronous, zero-latency)
573
608
  replayDiscountEvents(ctx);
574
609
 
575
- // Show status immediately with replayed/cached data — don't block pi startup
576
- const model = ctx.model;
577
- if (model?.provider === "lilac") {
578
- ctx.ui.setStatus("lilac", dimStatus(ctx, formatDiscountStatus(model.id)));
579
- }
610
+ // Show status immediately with replayed/cached data — don't block pi startup.
611
+ // syncStatus reads the LIVE ctx.model so a switch away from lilac before the
612
+ // background fetch resolves never leaves a stale discount painted.
613
+ syncStatus(ctx);
580
614
 
581
615
  // Fire-and-forget: resolve API key, then fetch live data in background.
582
616
  // Provider and status are hot-swapped when results arrive.
@@ -613,9 +647,10 @@ export default function (pi: ExtensionAPI) {
613
647
  });
614
648
  }
615
649
 
616
- if (model?.provider === "lilac") {
617
- ctx.ui.setStatus("lilac", dimStatus(ctx, formatDiscountStatus(model.id)));
618
- }
650
+ // Re-paint from the LIVE model: if the user switched to a non-lilac
651
+ // model (or a different lilac model) during the fetch, this reflects
652
+ // the new selection instead of re-showing the stale captured model.
653
+ syncStatus(ctx);
619
654
  }).catch(() => { /* network errors are non-fatal */ });
620
655
  });
621
656
  });
@@ -652,7 +687,10 @@ export default function (pi: ExtensionAPI) {
652
687
  // returned unchanged) and recomputes from list price so it never compounds
653
688
  // a previously-applied factor.
654
689
  applyDiscountInPlace(inFlightModel, listModels, latestDiscounts);
655
- ctx.ui.setStatus("lilac", dimStatus(ctx, formatDiscountStatus(inFlightModel.id)));
690
+ // Display follows the LIVE model (clears if the user switched away during
691
+ // this turn); the cost mutation above still targets the captured in-flight
692
+ // model pi bound for this turn.
693
+ syncStatus(ctx);
656
694
 
657
695
  if (!cachedApiKey) return;
658
696
 
@@ -667,7 +705,7 @@ export default function (pi: ExtensionAPI) {
667
705
  lastDiscountFetchTime = now;
668
706
 
669
707
  if (!discountsChanged(latestDiscounts, discounts)) {
670
- ctx.ui.setStatus("lilac", dimStatus(ctx, formatDiscountStatus(inFlightModel.id)));
708
+ syncStatus(ctx);
671
709
  return;
672
710
  }
673
711
 
@@ -687,23 +725,22 @@ export default function (pi: ExtensionAPI) {
687
725
  api: "openai-completions",
688
726
  models: applyDiscounts(freshList, discounts),
689
727
  });
690
- ctx.ui.setStatus("lilac", dimStatus(ctx, formatDiscountStatus(inFlightModel.id)));
728
+ // Display reflects the LIVE model: post-await the user may have switched to
729
+ // a non-lilac model, so syncStatus clears instead of re-painting the stale
730
+ // captured in-flight lilac model's discount.
731
+ syncStatus(ctx);
691
732
  });
692
733
 
693
- pi.on("model_select", async (event, ctx) => {
694
- if (event.model.provider === "lilac") {
695
- ctx.ui.setStatus("lilac", dimStatus(ctx, formatDiscountStatus(event.model.id)));
696
- } else {
697
- ctx.ui.setStatus("lilac", undefined);
698
- }
734
+ pi.on("model_select", async (_event, ctx) => {
735
+ // ctx.model is the live session model (pi sets state.model before emitting
736
+ // this event), so syncStatus paints/clears consistently with every other
737
+ // handler — one source of truth for the footer.
738
+ syncStatus(ctx);
699
739
  });
700
740
 
701
741
  pi.on("session_tree", async (_event, ctx) => {
702
742
  replayDiscountEvents(ctx);
703
- const model = ctx.model;
704
- if (model?.provider === "lilac") {
705
- ctx.ui.setStatus("lilac", dimStatus(ctx, formatDiscountStatus(model.id)));
706
- }
743
+ syncStatus(ctx);
707
744
  });
708
745
 
709
746
  // vLLM's streaming parser intermittently emits finish_reason: "tool_calls" without
package/models.json CHANGED
@@ -119,7 +119,8 @@
119
119
  "name": "MiniMax M3",
120
120
  "reasoning": true,
121
121
  "input": [
122
- "text"
122
+ "text",
123
+ "image"
123
124
  ],
124
125
  "cost": {
125
126
  "input": 0.28,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-lilac-provider",
3
- "version": "1.2.0",
3
+ "version": "1.2.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",
@@ -286,7 +286,7 @@ for (const handler of handlers.get("model_select") || []) {
286
286
  previousModel: undefined,
287
287
  source: "set",
288
288
  },
289
- { ui: mockUi }
289
+ { ui: mockUi, model: { id: "moonshotai/kimi-k2.6", provider: "lilac" } }
290
290
  );
291
291
  }
292
292
  assert(statuses.get("lilac") === "supply: healthy · sub-discount: 25%", "model_select keeps status for lilac model");
@@ -302,7 +302,7 @@ for (const handler of handlers.get("model_select") || []) {
302
302
  previousModel: undefined,
303
303
  source: "set",
304
304
  },
305
- { ui: mockUi }
305
+ { ui: mockUi, model: { id: "claude-sonnet-4", provider: "anthropic" } }
306
306
  );
307
307
  }
308
308
  assert(statuses.get("lilac") === undefined, "model_select clears status for non-lilac model");
@@ -359,7 +359,7 @@ for (const handler of handlers.get("model_select") || []) {
359
359
  previousModel: undefined,
360
360
  source: "set",
361
361
  },
362
- { ui: mockUi },
362
+ { ui: mockUi, model: { id: "some/unknown-model", provider: "lilac" } },
363
363
  );
364
364
  }
365
365
  assert(statuses.get("lilac") === "supply: —", "unknown model shows fallback dash");
@@ -373,7 +373,7 @@ for (const handler of handlers.get("model_select") || []) {
373
373
  previousModel: undefined,
374
374
  source: "set",
375
375
  },
376
- { ui: mockUi },
376
+ { ui: mockUi, model: { id: "moonshotai/kimi-k2.6", provider: "lilac" } },
377
377
  );
378
378
  }
379
379
  assert(
@@ -541,6 +541,162 @@ assert(boundModel.cost.input === 0.525, "bound model cost mutated in place to 0.
541
541
  assert(boundModel.cost.output === 2.625, "bound model output mutated to 3.50 * 0.75 = 2.625");
542
542
  assert(boundModel.cost.cacheRead === 0.15, "bound model cacheRead mutated to 0.20 * 0.75 = 0.15");
543
543
 
544
+ // ─── Test 14: session_start clears status after switch during fetch ─────────
545
+
546
+ console.log("\n--- Test 14: session_start clears status after switch ---");
547
+
548
+ // Regression: the session_start background fetch resolves up to ~8s after hook
549
+ // start. If the user switches to a non-lilac model during that window, the
550
+ // deferred callback must NOT re-paint the stale captured lilac model's discount
551
+ // over the clear that model_select issued. syncStatus() reads the LIVE ctx.model
552
+ // (a lazy getter in production), so mutating startCtx.model mid-flight mimics
553
+ // the session model changing and the deferred paint clears instead.
554
+ globalThis.fetch = mockFetch({
555
+ "/models": { body: { data: [] } }, // no live models → liveModels null
556
+ "/status": {
557
+ body: {
558
+ models: [{
559
+ id: "moonshotai/kimi-k2.6",
560
+ current_subscription_supply_state: "healthy",
561
+ current_subscription_discount_percent: 25,
562
+ current_subscription_credit_multiplier: "0.75",
563
+ }],
564
+ },
565
+ },
566
+ }) as any;
567
+
568
+ statuses.clear();
569
+ const startCtx: any = {
570
+ modelRegistry: mockRegistry,
571
+ ui: mockUi,
572
+ model: { id: "moonshotai/kimi-k2.6", provider: "lilac" }, // start on lilac
573
+ sessionManager: { getBranch: () => [] },
574
+ };
575
+ for (const handler of handlers.get("session_start") || []) {
576
+ await handler({}, startCtx);
577
+ }
578
+ // Immediate paint reflects the lilac model (still selected at hook start).
579
+ assert(
580
+ statuses.get("lilac") === "supply: healthy · sub-discount: 25%",
581
+ "immediate session_start paint shows lilac discount",
582
+ );
583
+
584
+ // While the background fetch is in flight, the user switches to a non-lilac model.
585
+ startCtx.model = { id: "claude-sonnet-4", provider: "anthropic" };
586
+
587
+ // Flush the deferred .then() chain (resolveApiKey → fetch → syncStatus).
588
+ await new Promise((r) => setTimeout(r, 100));
589
+
590
+ assert(
591
+ statuses.get("lilac") === undefined,
592
+ "deferred session_start clears status after switch to non-lilac (no stale re-paint)",
593
+ );
594
+
595
+ // ─── Test 15: before_provider_request clears status after switch ─────────────
596
+
597
+ console.log("\n--- Test 15: before_provider_request clears status after switch ---");
598
+
599
+ // Regression: every 30s (when the discount TTL expires) before_provider_request
600
+ // awaits a fresh /status fetch. If the user switches to a non-lilac model during
601
+ // that await, the post-await paint must clear (live model) instead of re-painting
602
+ // the stale captured in-flight lilac model. Cost mutation still targets the
603
+ // captured in-flight model (Tests 12-13); only the DISPLAY follows the live model.
604
+
605
+ // Force the 30s TTL to look expired so the fetch path executes.
606
+ const realDateNow = Date.now;
607
+ Date.now = () => realDateNow.call(Date) + 60000;
608
+ try {
609
+ globalThis.fetch = mockFetch({
610
+ "/status": {
611
+ body: {
612
+ // CHANGED discount (low/10%) vs the cached healthy/25% → exercises the
613
+ // registerProvider + syncStatus path, not just the unchanged early-return.
614
+ models: [{
615
+ id: "moonshotai/kimi-k2.6",
616
+ current_subscription_supply_state: "low",
617
+ current_subscription_discount_percent: 10,
618
+ current_subscription_credit_multiplier: "0.90",
619
+ }],
620
+ },
621
+ },
622
+ }) as any;
623
+
624
+ statuses.clear();
625
+ // Live model holder; reassigning .model mid-await mimics ctx.model being a
626
+ // lazy getter to the session model.
627
+ const bprCtx: any = {
628
+ ui: mockUi,
629
+ model: { id: "moonshotai/kimi-k2.6", provider: "lilac" },
630
+ };
631
+
632
+ // Kick off before_provider_request; it captures inFlightModel=lilac, mutates
633
+ // cost, paints, then awaits the fetch (TTL expired).
634
+ const bprPromise = (async () => {
635
+ for (const handler of handlers.get("before_provider_request") || []) {
636
+ await handler({ type: "before_provider_request", payload: {} }, bprCtx);
637
+ }
638
+ })();
639
+
640
+ // Switch to non-lilac while the fetch is in flight (before the await resumes).
641
+ bprCtx.model = { id: "claude-sonnet-4", provider: "anthropic" };
642
+
643
+ await bprPromise;
644
+
645
+ assert(
646
+ statuses.get("lilac") === undefined,
647
+ "post-await syncStatus clears after switch to non-lilac (no stale re-paint)",
648
+ );
649
+ } finally {
650
+ Date.now = realDateNow;
651
+ }
652
+
653
+ // ─── Test 16: session_start shows new lilac model after switch ───────────────
654
+
655
+ console.log("\n--- Test 16: session_start shows new lilac model after switch ---");
656
+
657
+ // Companion to Test 14: switching to a DIFFERENT lilac model during the fetch
658
+ // must paint the NEW model's discount, proving syncStatus reads the live model
659
+ // (not a stale capture that would show the old model's discount).
660
+ globalThis.fetch = mockFetch({
661
+ "/models": { body: { data: [] } },
662
+ "/status": {
663
+ body: {
664
+ models: [
665
+ {
666
+ id: "moonshotai/kimi-k2.6",
667
+ current_subscription_supply_state: "healthy",
668
+ current_subscription_discount_percent: 25,
669
+ current_subscription_credit_multiplier: "0.75",
670
+ },
671
+ {
672
+ id: "zai-org/glm-5.1",
673
+ current_subscription_supply_state: "high",
674
+ current_subscription_discount_percent: 50,
675
+ current_subscription_credit_multiplier: "0.50",
676
+ },
677
+ ],
678
+ },
679
+ },
680
+ }) as any;
681
+
682
+ statuses.clear();
683
+ const startCtx2: any = {
684
+ modelRegistry: mockRegistry,
685
+ ui: mockUi,
686
+ model: { id: "moonshotai/kimi-k2.6", provider: "lilac" }, // start on kimi
687
+ sessionManager: { getBranch: () => [] },
688
+ };
689
+ for (const handler of handlers.get("session_start") || []) {
690
+ await handler({}, startCtx2);
691
+ }
692
+ // Switch to a different lilac model (glm) during the in-flight fetch.
693
+ startCtx2.model = { id: "zai-org/glm-5.1", provider: "lilac" };
694
+ await new Promise((r) => setTimeout(r, 100));
695
+ assert(
696
+ statuses.get("lilac") === "supply: high · sub-discount: 50%",
697
+ "deferred session_start paints the NEW lilac model's discount (glm), not the stale kimi capture",
698
+ );
699
+
544
700
  // ─── Cleanup ──────────────────────────────────────────────────────────────────
545
701
 
546
702
  globalThis.fetch = originalFetch;