pi-lilac-provider 1.2.0 → 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/README.md +1 -1
- package/index.ts +122 -22
- package/models.json +2 -1
- package/package.json +1 -1
- package/scripts/test-discounts.ts +244 -5
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 |
|
|
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,
|
|
@@ -503,7 +538,19 @@ let cachedApiKey: string | undefined;
|
|
|
503
538
|
let revalidateAbort: AbortController | null = null;
|
|
504
539
|
let latestDiscounts: Map<string, JsonDiscount> | null = null;
|
|
505
540
|
let lastDiscountFetchTime = 0;
|
|
506
|
-
|
|
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;
|
|
507
554
|
// List-price (patch-applied, pre-discount) models, cached until the base set
|
|
508
555
|
// changes. Reset in cacheModels() so the next getListModels() rebuilds from the
|
|
509
556
|
// refreshed disk cache / embedded set.
|
|
@@ -564,19 +611,56 @@ export default function (pi: ExtensionAPI) {
|
|
|
564
611
|
}
|
|
565
612
|
}
|
|
566
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
|
+
|
|
567
645
|
pi.on("session_start", async (_event, ctx) => {
|
|
568
646
|
revalidateAbort?.abort();
|
|
569
647
|
revalidateAbort = new AbortController();
|
|
570
648
|
const signal = revalidateAbort.signal;
|
|
571
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
|
+
|
|
572
657
|
// Replay persisted discount state from session JSONL (synchronous, zero-latency)
|
|
573
658
|
replayDiscountEvents(ctx);
|
|
574
659
|
|
|
575
|
-
// Show status immediately with replayed/cached data — don't block pi startup
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
}
|
|
660
|
+
// Show status immediately with replayed/cached data — don't block pi startup.
|
|
661
|
+
// syncStatus reads the LIVE ctx.model so a switch away from lilac before the
|
|
662
|
+
// background fetch resolves never leaves a stale discount painted.
|
|
663
|
+
syncStatus(ctx);
|
|
580
664
|
|
|
581
665
|
// Fire-and-forget: resolve API key, then fetch live data in background.
|
|
582
666
|
// Provider and status are hot-swapped when results arrive.
|
|
@@ -613,11 +697,21 @@ export default function (pi: ExtensionAPI) {
|
|
|
613
697
|
});
|
|
614
698
|
}
|
|
615
699
|
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
700
|
+
// Re-paint from the LIVE model: if the user switched to a non-lilac
|
|
701
|
+
// model (or a different lilac model) during the fetch, this reflects
|
|
702
|
+
// the new selection instead of re-showing the stale captured model.
|
|
703
|
+
syncStatus(ctx);
|
|
619
704
|
}).catch(() => { /* network errors are non-fatal */ });
|
|
620
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?.();
|
|
621
715
|
});
|
|
622
716
|
|
|
623
717
|
pi.on("turn_end", async (_event, ctx) => {
|
|
@@ -652,7 +746,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
652
746
|
// returned unchanged) and recomputes from list price so it never compounds
|
|
653
747
|
// a previously-applied factor.
|
|
654
748
|
applyDiscountInPlace(inFlightModel, listModels, latestDiscounts);
|
|
655
|
-
|
|
749
|
+
// Display follows the LIVE model (clears if the user switched away during
|
|
750
|
+
// this turn); the cost mutation above still targets the captured in-flight
|
|
751
|
+
// model pi bound for this turn.
|
|
752
|
+
syncStatus(ctx);
|
|
656
753
|
|
|
657
754
|
if (!cachedApiKey) return;
|
|
658
755
|
|
|
@@ -667,7 +764,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
667
764
|
lastDiscountFetchTime = now;
|
|
668
765
|
|
|
669
766
|
if (!discountsChanged(latestDiscounts, discounts)) {
|
|
670
|
-
|
|
767
|
+
syncStatus(ctx);
|
|
671
768
|
return;
|
|
672
769
|
}
|
|
673
770
|
|
|
@@ -687,23 +784,22 @@ export default function (pi: ExtensionAPI) {
|
|
|
687
784
|
api: "openai-completions",
|
|
688
785
|
models: applyDiscounts(freshList, discounts),
|
|
689
786
|
});
|
|
690
|
-
|
|
787
|
+
// Display reflects the LIVE model: post-await the user may have switched to
|
|
788
|
+
// a non-lilac model, so syncStatus clears instead of re-painting the stale
|
|
789
|
+
// captured in-flight lilac model's discount.
|
|
790
|
+
syncStatus(ctx);
|
|
691
791
|
});
|
|
692
792
|
|
|
693
|
-
pi.on("model_select", async (
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
}
|
|
793
|
+
pi.on("model_select", async (_event, ctx) => {
|
|
794
|
+
// ctx.model is the live session model (pi sets state.model before emitting
|
|
795
|
+
// this event), so syncStatus paints/clears consistently with every other
|
|
796
|
+
// handler — one source of truth for the footer.
|
|
797
|
+
syncStatus(ctx);
|
|
699
798
|
});
|
|
700
799
|
|
|
701
800
|
pi.on("session_tree", async (_event, ctx) => {
|
|
702
801
|
replayDiscountEvents(ctx);
|
|
703
|
-
|
|
704
|
-
if (model?.provider === "lilac") {
|
|
705
|
-
ctx.ui.setStatus("lilac", dimStatus(ctx, formatDiscountStatus(model.id)));
|
|
706
|
-
}
|
|
802
|
+
syncStatus(ctx);
|
|
707
803
|
});
|
|
708
804
|
|
|
709
805
|
// vLLM's streaming parser intermittently emits finish_reason: "tool_calls" without
|
|
@@ -739,6 +835,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
739
835
|
|
|
740
836
|
pi.on("session_shutdown", () => {
|
|
741
837
|
revalidateAbort?.abort();
|
|
838
|
+
if (pollInterval) {
|
|
839
|
+
clearInterval(pollInterval);
|
|
840
|
+
pollInterval = null;
|
|
841
|
+
}
|
|
742
842
|
});
|
|
743
843
|
}
|
|
744
844
|
|
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.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
|
|
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";
|
|
@@ -286,7 +288,7 @@ for (const handler of handlers.get("model_select") || []) {
|
|
|
286
288
|
previousModel: undefined,
|
|
287
289
|
source: "set",
|
|
288
290
|
},
|
|
289
|
-
{ ui: mockUi }
|
|
291
|
+
{ ui: mockUi, model: { id: "moonshotai/kimi-k2.6", provider: "lilac" } }
|
|
290
292
|
);
|
|
291
293
|
}
|
|
292
294
|
assert(statuses.get("lilac") === "supply: healthy · sub-discount: 25%", "model_select keeps status for lilac model");
|
|
@@ -302,7 +304,7 @@ for (const handler of handlers.get("model_select") || []) {
|
|
|
302
304
|
previousModel: undefined,
|
|
303
305
|
source: "set",
|
|
304
306
|
},
|
|
305
|
-
{ ui: mockUi }
|
|
307
|
+
{ ui: mockUi, model: { id: "claude-sonnet-4", provider: "anthropic" } }
|
|
306
308
|
);
|
|
307
309
|
}
|
|
308
310
|
assert(statuses.get("lilac") === undefined, "model_select clears status for non-lilac model");
|
|
@@ -359,7 +361,7 @@ for (const handler of handlers.get("model_select") || []) {
|
|
|
359
361
|
previousModel: undefined,
|
|
360
362
|
source: "set",
|
|
361
363
|
},
|
|
362
|
-
{ ui: mockUi },
|
|
364
|
+
{ ui: mockUi, model: { id: "some/unknown-model", provider: "lilac" } },
|
|
363
365
|
);
|
|
364
366
|
}
|
|
365
367
|
assert(statuses.get("lilac") === "supply: —", "unknown model shows fallback dash");
|
|
@@ -373,7 +375,7 @@ for (const handler of handlers.get("model_select") || []) {
|
|
|
373
375
|
previousModel: undefined,
|
|
374
376
|
source: "set",
|
|
375
377
|
},
|
|
376
|
-
{ ui: mockUi },
|
|
378
|
+
{ ui: mockUi, model: { id: "moonshotai/kimi-k2.6", provider: "lilac" } },
|
|
377
379
|
);
|
|
378
380
|
}
|
|
379
381
|
assert(
|
|
@@ -541,6 +543,243 @@ assert(boundModel.cost.input === 0.525, "bound model cost mutated in place to 0.
|
|
|
541
543
|
assert(boundModel.cost.output === 2.625, "bound model output mutated to 3.50 * 0.75 = 2.625");
|
|
542
544
|
assert(boundModel.cost.cacheRead === 0.15, "bound model cacheRead mutated to 0.20 * 0.75 = 0.15");
|
|
543
545
|
|
|
546
|
+
// ─── Test 14: session_start clears status after switch during fetch ─────────
|
|
547
|
+
|
|
548
|
+
console.log("\n--- Test 14: session_start clears status after switch ---");
|
|
549
|
+
|
|
550
|
+
// Regression: the session_start background fetch resolves up to ~8s after hook
|
|
551
|
+
// start. If the user switches to a non-lilac model during that window, the
|
|
552
|
+
// deferred callback must NOT re-paint the stale captured lilac model's discount
|
|
553
|
+
// over the clear that model_select issued. syncStatus() reads the LIVE ctx.model
|
|
554
|
+
// (a lazy getter in production), so mutating startCtx.model mid-flight mimics
|
|
555
|
+
// the session model changing and the deferred paint clears instead.
|
|
556
|
+
globalThis.fetch = mockFetch({
|
|
557
|
+
"/models": { body: { data: [] } }, // no live models → liveModels null
|
|
558
|
+
"/status": {
|
|
559
|
+
body: {
|
|
560
|
+
models: [{
|
|
561
|
+
id: "moonshotai/kimi-k2.6",
|
|
562
|
+
current_subscription_supply_state: "healthy",
|
|
563
|
+
current_subscription_discount_percent: 25,
|
|
564
|
+
current_subscription_credit_multiplier: "0.75",
|
|
565
|
+
}],
|
|
566
|
+
},
|
|
567
|
+
},
|
|
568
|
+
}) as any;
|
|
569
|
+
|
|
570
|
+
statuses.clear();
|
|
571
|
+
const startCtx: any = {
|
|
572
|
+
modelRegistry: mockRegistry,
|
|
573
|
+
ui: mockUi,
|
|
574
|
+
model: { id: "moonshotai/kimi-k2.6", provider: "lilac" }, // start on lilac
|
|
575
|
+
sessionManager: { getBranch: () => [] },
|
|
576
|
+
};
|
|
577
|
+
for (const handler of handlers.get("session_start") || []) {
|
|
578
|
+
await handler({}, startCtx);
|
|
579
|
+
}
|
|
580
|
+
// Immediate paint reflects the lilac model (still selected at hook start).
|
|
581
|
+
assert(
|
|
582
|
+
statuses.get("lilac") === "supply: healthy · sub-discount: 25%",
|
|
583
|
+
"immediate session_start paint shows lilac discount",
|
|
584
|
+
);
|
|
585
|
+
|
|
586
|
+
// While the background fetch is in flight, the user switches to a non-lilac model.
|
|
587
|
+
startCtx.model = { id: "claude-sonnet-4", provider: "anthropic" };
|
|
588
|
+
|
|
589
|
+
// Flush the deferred .then() chain (resolveApiKey → fetch → syncStatus).
|
|
590
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
591
|
+
|
|
592
|
+
assert(
|
|
593
|
+
statuses.get("lilac") === undefined,
|
|
594
|
+
"deferred session_start clears status after switch to non-lilac (no stale re-paint)",
|
|
595
|
+
);
|
|
596
|
+
|
|
597
|
+
// ─── Test 15: before_provider_request clears status after switch ─────────────
|
|
598
|
+
|
|
599
|
+
console.log("\n--- Test 15: before_provider_request clears status after switch ---");
|
|
600
|
+
|
|
601
|
+
// Regression: every 60s (when the discount TTL expires) before_provider_request
|
|
602
|
+
// awaits a fresh /status fetch. If the user switches to a non-lilac model during
|
|
603
|
+
// that await, the post-await paint must clear (live model) instead of re-painting
|
|
604
|
+
// the stale captured in-flight lilac model. Cost mutation still targets the
|
|
605
|
+
// captured in-flight model (Tests 12-13); only the DISPLAY follows the live model.
|
|
606
|
+
|
|
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).
|
|
609
|
+
const realDateNow = Date.now;
|
|
610
|
+
Date.now = () => realDateNow.call(Date) + 120000;
|
|
611
|
+
try {
|
|
612
|
+
globalThis.fetch = mockFetch({
|
|
613
|
+
"/status": {
|
|
614
|
+
body: {
|
|
615
|
+
// CHANGED discount (low/10%) vs the cached healthy/25% → exercises the
|
|
616
|
+
// registerProvider + syncStatus path, not just the unchanged early-return.
|
|
617
|
+
models: [{
|
|
618
|
+
id: "moonshotai/kimi-k2.6",
|
|
619
|
+
current_subscription_supply_state: "low",
|
|
620
|
+
current_subscription_discount_percent: 10,
|
|
621
|
+
current_subscription_credit_multiplier: "0.90",
|
|
622
|
+
}],
|
|
623
|
+
},
|
|
624
|
+
},
|
|
625
|
+
}) as any;
|
|
626
|
+
|
|
627
|
+
statuses.clear();
|
|
628
|
+
// Live model holder; reassigning .model mid-await mimics ctx.model being a
|
|
629
|
+
// lazy getter to the session model.
|
|
630
|
+
const bprCtx: any = {
|
|
631
|
+
ui: mockUi,
|
|
632
|
+
model: { id: "moonshotai/kimi-k2.6", provider: "lilac" },
|
|
633
|
+
};
|
|
634
|
+
|
|
635
|
+
// Kick off before_provider_request; it captures inFlightModel=lilac, mutates
|
|
636
|
+
// cost, paints, then awaits the fetch (TTL expired).
|
|
637
|
+
const bprPromise = (async () => {
|
|
638
|
+
for (const handler of handlers.get("before_provider_request") || []) {
|
|
639
|
+
await handler({ type: "before_provider_request", payload: {} }, bprCtx);
|
|
640
|
+
}
|
|
641
|
+
})();
|
|
642
|
+
|
|
643
|
+
// Switch to non-lilac while the fetch is in flight (before the await resumes).
|
|
644
|
+
bprCtx.model = { id: "claude-sonnet-4", provider: "anthropic" };
|
|
645
|
+
|
|
646
|
+
await bprPromise;
|
|
647
|
+
|
|
648
|
+
assert(
|
|
649
|
+
statuses.get("lilac") === undefined,
|
|
650
|
+
"post-await syncStatus clears after switch to non-lilac (no stale re-paint)",
|
|
651
|
+
);
|
|
652
|
+
} finally {
|
|
653
|
+
Date.now = realDateNow;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// ─── Test 16: session_start shows new lilac model after switch ───────────────
|
|
657
|
+
|
|
658
|
+
console.log("\n--- Test 16: session_start shows new lilac model after switch ---");
|
|
659
|
+
|
|
660
|
+
// Companion to Test 14: switching to a DIFFERENT lilac model during the fetch
|
|
661
|
+
// must paint the NEW model's discount, proving syncStatus reads the live model
|
|
662
|
+
// (not a stale capture that would show the old model's discount).
|
|
663
|
+
globalThis.fetch = mockFetch({
|
|
664
|
+
"/models": { body: { data: [] } },
|
|
665
|
+
"/status": {
|
|
666
|
+
body: {
|
|
667
|
+
models: [
|
|
668
|
+
{
|
|
669
|
+
id: "moonshotai/kimi-k2.6",
|
|
670
|
+
current_subscription_supply_state: "healthy",
|
|
671
|
+
current_subscription_discount_percent: 25,
|
|
672
|
+
current_subscription_credit_multiplier: "0.75",
|
|
673
|
+
},
|
|
674
|
+
{
|
|
675
|
+
id: "zai-org/glm-5.1",
|
|
676
|
+
current_subscription_supply_state: "high",
|
|
677
|
+
current_subscription_discount_percent: 50,
|
|
678
|
+
current_subscription_credit_multiplier: "0.50",
|
|
679
|
+
},
|
|
680
|
+
],
|
|
681
|
+
},
|
|
682
|
+
},
|
|
683
|
+
}) as any;
|
|
684
|
+
|
|
685
|
+
statuses.clear();
|
|
686
|
+
const startCtx2: any = {
|
|
687
|
+
modelRegistry: mockRegistry,
|
|
688
|
+
ui: mockUi,
|
|
689
|
+
model: { id: "moonshotai/kimi-k2.6", provider: "lilac" }, // start on kimi
|
|
690
|
+
sessionManager: { getBranch: () => [] },
|
|
691
|
+
};
|
|
692
|
+
for (const handler of handlers.get("session_start") || []) {
|
|
693
|
+
await handler({}, startCtx2);
|
|
694
|
+
}
|
|
695
|
+
// Switch to a different lilac model (glm) during the in-flight fetch.
|
|
696
|
+
startCtx2.model = { id: "zai-org/glm-5.1", provider: "lilac" };
|
|
697
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
698
|
+
assert(
|
|
699
|
+
statuses.get("lilac") === "supply: high · sub-discount: 50%",
|
|
700
|
+
"deferred session_start paints the NEW lilac model's discount (glm), not the stale kimi capture",
|
|
701
|
+
);
|
|
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
|
+
|
|
544
783
|
// ─── Cleanup ──────────────────────────────────────────────────────────────────
|
|
545
784
|
|
|
546
785
|
globalThis.fetch = originalFetch;
|