pi-lilac-provider 1.2.1 → 1.3.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/index.ts CHANGED
@@ -538,7 +538,19 @@ let cachedApiKey: string | undefined;
538
538
  let revalidateAbort: AbortController | null = null;
539
539
  let latestDiscounts: Map<string, JsonDiscount> | null = null;
540
540
  let lastDiscountFetchTime = 0;
541
- const STATUS_CACHE_TTL_MS = 30000;
541
+ // Turn-initiated /status fetches are throttled to once per TTL window so a burst
542
+ // of messages doesn't hammer the endpoint. Background polling (see
543
+ // STATUS_POLL_INTERVAL_MS) and turn fetches both stamp lastDiscountFetchTime, so
544
+ // they cooperate: a poll that just ran lets the next turn skip its own fetch
545
+ // within the TTL.
546
+ const STATUS_CACHE_TTL_MS = 60000;
547
+ // Lilac refreshes discounts ~every 10 minutes (per their docs: "Discounts refresh
548
+ // approximately every 10 minutes and are locked in when a request starts"). Poll
549
+ // on that cadence during idle so a long-idle session still catches supply/sub
550
+ // changes without waiting for the user to send a message — turn fetches alone
551
+ // only refresh on a user message and are TTL-throttled to 1/min.
552
+ const STATUS_POLL_INTERVAL_MS = 10 * 60 * 1000;
553
+ let pollInterval: ReturnType<typeof setInterval> | null = null;
542
554
  // List-price (patch-applied, pre-discount) models, cached until the base set
543
555
  // changes. Reset in cacheModels() so the next getListModels() rebuilds from the
544
556
  // refreshed disk cache / embedded set.
@@ -599,11 +611,49 @@ export default function (pi: ExtensionAPI) {
599
611
  }
600
612
  }
601
613
 
614
+ /**
615
+ * Background /status poll, fired every STATUS_POLL_INTERVAL_MS (10 min) from
616
+ * session_start to cover idle sessions. Mirrors the discount half of
617
+ * before_provider_request, but without an in-flight turn model to mutate: it
618
+ * only refreshes latestDiscounts, re-registers (so the next turn's models carry
619
+ * the new price), and re-paints the footer from the LIVE model. Passes the
620
+ * session AbortSignal so the fetch dies on session_shutdown or a subsequent
621
+ * session_start; bails on a missing API key or an aborted signal.
622
+ */
623
+ function pollStatusDiscounts(ctx: any, signal: AbortSignal): void {
624
+ if (signal.aborted || !cachedApiKey) return;
625
+ fetchStatusDiscounts(cachedApiKey, signal).then(discounts => {
626
+ if (signal.aborted || !discounts) return;
627
+ lastDiscountFetchTime = Date.now();
628
+ if (!discountsChanged(latestDiscounts, discounts)) {
629
+ syncStatus(ctx);
630
+ return;
631
+ }
632
+ cacheDiscounts(discounts);
633
+ latestDiscounts = discounts;
634
+ const freshList = getListModels();
635
+ pi.registerProvider("lilac", {
636
+ baseUrl: BASE_URL,
637
+ apiKey: "$LILAC_API_KEY",
638
+ api: "openai-completions",
639
+ models: applyDiscounts(freshList, discounts),
640
+ });
641
+ syncStatus(ctx);
642
+ }).catch(() => { /* network errors are non-fatal */ });
643
+ }
644
+
602
645
  pi.on("session_start", async (_event, ctx) => {
603
646
  revalidateAbort?.abort();
604
647
  revalidateAbort = new AbortController();
605
648
  const signal = revalidateAbort.signal;
606
649
 
650
+ // Tear down any poll interval left from a prior session before starting a
651
+ // fresh one (defensive; session_shutdown normally handles this).
652
+ if (pollInterval) {
653
+ clearInterval(pollInterval);
654
+ pollInterval = null;
655
+ }
656
+
607
657
  // Replay persisted discount state from session JSONL (synchronous, zero-latency)
608
658
  replayDiscountEvents(ctx);
609
659
 
@@ -653,6 +703,15 @@ export default function (pi: ExtensionAPI) {
653
703
  syncStatus(ctx);
654
704
  }).catch(() => { /* network errors are non-fatal */ });
655
705
  });
706
+
707
+ // Background poll for idle sessions: Lilac refreshes discounts ~every 10
708
+ // minutes, so poll on that cadence to catch supply/sub changes while the
709
+ // user is idle (turn fetches only run when a message is sent). The callback
710
+ // bails on a missing API key or an aborted/shut-down session. Cleared in
711
+ // session_shutdown and at the top of the next session_start.
712
+ pollInterval = setInterval(() => pollStatusDiscounts(ctx, signal), STATUS_POLL_INTERVAL_MS);
713
+ // Don't keep the process alive solely for discount polling.
714
+ pollInterval.unref?.();
656
715
  });
657
716
 
658
717
  pi.on("turn_end", async (_event, ctx) => {
@@ -776,6 +835,10 @@ export default function (pi: ExtensionAPI) {
776
835
 
777
836
  pi.on("session_shutdown", () => {
778
837
  revalidateAbort?.abort();
838
+ if (pollInterval) {
839
+ clearInterval(pollInterval);
840
+ pollInterval = null;
841
+ }
779
842
  });
780
843
  }
781
844
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-lilac-provider",
3
- "version": "1.2.1",
3
+ "version": "1.3.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",
@@ -11,12 +11,14 @@
11
11
  * 5. session_start replays persisted discount events and sets footer status.
12
12
  * 6. model_select sets/clears footer status for lilac/non-lilac models.
13
13
  * 7. turn_end appends discount entry to session JSONL.
14
- * 8. before_provider_request refreshes discounts with a 30s cache.
14
+ * 8. before_provider_request refreshes discounts with a 60s cache.
15
15
  * 9. formatDiscountStatus returns fallbacks when data is missing.
16
16
  * 10. applyDiscountInPlace mutates the in-flight model's cost in place,
17
17
  * recomputed from list price so re-applied discounts never compound.
18
18
  * 11. before_provider_request mutates the bound (in-flight) model object so the
19
19
  * current turn's cost calc sees the discount in real time.
20
+ * 12. session_start schedules a 10-minute background /status poll to cover idle
21
+ * sessions; session_shutdown clears it.
20
22
  */
21
23
 
22
24
  import type { ExtensionAPI, ModelRegistry } from "@earendil-works/pi-coding-agent";
@@ -596,15 +598,16 @@ assert(
596
598
 
597
599
  console.log("\n--- Test 15: before_provider_request clears status after switch ---");
598
600
 
599
- // Regression: every 30s (when the discount TTL expires) before_provider_request
601
+ // Regression: every 60s (when the discount TTL expires) before_provider_request
600
602
  // awaits a fresh /status fetch. If the user switches to a non-lilac model during
601
603
  // that await, the post-await paint must clear (live model) instead of re-painting
602
604
  // the stale captured in-flight lilac model. Cost mutation still targets the
603
605
  // captured in-flight model (Tests 12-13); only the DISPLAY follows the live model.
604
606
 
605
- // Force the 30s TTL to look expired so the fetch path executes.
607
+ // Force the 60s TTL to look expired so the fetch path executes. Offset 120s for
608
+ // a clear 2x margin over the 60s TTL (the original used 60s over a 30s TTL).
606
609
  const realDateNow = Date.now;
607
- Date.now = () => realDateNow.call(Date) + 60000;
610
+ Date.now = () => realDateNow.call(Date) + 120000;
608
611
  try {
609
612
  globalThis.fetch = mockFetch({
610
613
  "/status": {
@@ -697,6 +700,86 @@ assert(
697
700
  "deferred session_start paints the NEW lilac model's discount (glm), not the stale kimi capture",
698
701
  );
699
702
 
703
+ // ─── Test 17: session_start schedules a 10-min idle poll; shutdown clears it ─
704
+
705
+ console.log("\n--- Test 17: session_start schedules idle poll; session_shutdown clears it ---");
706
+
707
+ // Lilac refreshes discounts ~every 10 minutes. session_start must schedule a
708
+ // background /status poll at that cadence to cover idle sessions (turn fetches
709
+ // only run when the user sends a message), and session_shutdown must clear it so
710
+ // it neither leaks nor keeps the process alive. Wrap the global timer APIs to
711
+ // capture the scheduled delay + handle, then confirm shutdown clears it.
712
+
713
+ const realSetInterval = globalThis.setInterval.bind(globalThis);
714
+ const realClearInterval = globalThis.clearInterval.bind(globalThis);
715
+ let scheduledDelay: number | null = null;
716
+ let scheduledHandle: ReturnType<typeof setInterval> | null = null;
717
+ const clearedHandles = new Set<ReturnType<typeof setInterval>>();
718
+
719
+ globalThis.setInterval = ((fn: (...args: any[]) => void, delay?: number, ...rest: any[]) => {
720
+ scheduledDelay = delay ?? null;
721
+ const h = realSetInterval(fn, delay as any, ...rest);
722
+ scheduledHandle = h;
723
+ return h;
724
+ }) as any;
725
+ globalThis.clearInterval = ((handle: ReturnType<typeof setInterval>) => {
726
+ clearedHandles.add(handle);
727
+ return realClearInterval(handle);
728
+ }) as any;
729
+
730
+ try {
731
+ // Benign fetch mock so the session_start fire-and-forget /models + /status
732
+ // fetch doesn't hit the network. (The poll itself never fires — 10 min — so
733
+ // only the startup fetch needs mocking here.)
734
+ globalThis.fetch = mockFetch({
735
+ "/models": { body: { data: [] } },
736
+ "/status": {
737
+ body: {
738
+ models: [
739
+ {
740
+ id: "moonshotai/kimi-k2.6",
741
+ current_subscription_supply_state: "healthy",
742
+ current_subscription_discount_percent: 25,
743
+ current_subscription_credit_multiplier: "0.75",
744
+ },
745
+ ],
746
+ },
747
+ },
748
+ }) as any;
749
+
750
+ const pollCtx: any = {
751
+ modelRegistry: mockRegistry,
752
+ ui: mockUi,
753
+ model: { id: "moonshotai/kimi-k2.6", provider: "lilac" },
754
+ sessionManager: { getBranch: () => [] },
755
+ };
756
+
757
+ for (const handler of handlers.get("session_start") || []) {
758
+ await handler({}, pollCtx);
759
+ }
760
+
761
+ assert(
762
+ scheduledDelay === 10 * 60 * 1000,
763
+ "session_start schedules a 10-minute (600000ms) /status poll for idle sessions",
764
+ );
765
+ assert(scheduledHandle !== null, "poll interval handle was captured");
766
+ const capturedHandle = scheduledHandle;
767
+
768
+ for (const handler of handlers.get("session_shutdown") || []) {
769
+ await handler({}, pollCtx);
770
+ }
771
+
772
+ assert(
773
+ capturedHandle !== null && clearedHandles.has(capturedHandle),
774
+ "session_shutdown clears the scheduled poll interval (no leak / process-hang)",
775
+ );
776
+ } finally {
777
+ // Restore globals; clear any interval this test scheduled before it can fire.
778
+ if (scheduledHandle) realClearInterval(scheduledHandle);
779
+ globalThis.setInterval = realSetInterval as any;
780
+ globalThis.clearInterval = realClearInterval as any;
781
+ }
782
+
700
783
  // ─── Cleanup ──────────────────────────────────────────────────────────────────
701
784
 
702
785
  globalThis.fetch = originalFetch;