pi-lilac-provider 1.1.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
@@ -304,6 +304,8 @@ function cacheModels(models: JsonModel[]): void {
304
304
  } catch {
305
305
  // Cache write failure is non-fatal
306
306
  }
307
+ // Base model set changed; force getListModels() to rebuild list prices.
308
+ listModelsCache = null;
307
309
  }
308
310
 
309
311
  function mergeWithEmbedded(liveModels: JsonModel[], embeddedModels: JsonModel[]): JsonModel[] {
@@ -398,6 +400,41 @@ function applyDiscounts(models: JsonModel[], discounts: Map<string, JsonDiscount
398
400
  });
399
401
  }
400
402
 
403
+ /**
404
+ * Apply the current discount to a single model's cost IN PLACE.
405
+ *
406
+ * applyDiscounts() returns discounted COPIES for the registry; those copies
407
+ * cannot reach the model pi already bound for the current turn. This mutates
408
+ * that bound object directly so the current turn's calculateCost() (in pi-ai)
409
+ * reads the discounted price. Cost is always recomputed from list price — the
410
+ * patch-applied, pre-discount value from buildModels() — so re-applying a
411
+ * changed discount never compounds a factor already present on the object.
412
+ * cacheWrite is left untouched (Lilac does not discount it, matching
413
+ * applyDiscounts).
414
+ *
415
+ * Targets the object pi bound for the turn, captured before any await or
416
+ * registerProvider() call. registerProvider() refreshes the session model for
417
+ * SUBSEQUENT turns only (via prepareNextTurn); it cannot affect the current
418
+ * turn's cost calc, which is why the in-place mutation is required.
419
+ */
420
+ function applyDiscountInPlace(
421
+ model: { id: string; cost: { input: number; output: number; cacheRead: number; cacheWrite: number } } | undefined,
422
+ listModels: JsonModel[],
423
+ discounts: Map<string, JsonDiscount> | null,
424
+ ): void {
425
+ if (!model?.cost) return;
426
+ const list = listModels.find(m => m.id === model.id);
427
+ if (!list) return;
428
+ // A missing discount entry means list price (factor 1) — e.g. a model whose
429
+ // discount was removed since it was last priced.
430
+ const rawFactor = discounts?.get(model.id)?.creditMultiplier;
431
+ const factor = Number.isFinite(rawFactor) && rawFactor !== undefined ? rawFactor : 1;
432
+ const applyFactor = (n: number) => n > 0 ? Math.round(n * factor * 10000) / 10000 : n;
433
+ model.cost.input = applyFactor(list.cost.input);
434
+ model.cost.output = applyFactor(list.cost.output);
435
+ model.cost.cacheRead = applyFactor(list.cost.cacheRead);
436
+ }
437
+
401
438
  function cacheDiscounts(discounts: Map<string, JsonDiscount>): void {
402
439
  try {
403
440
  fs.mkdirSync(CACHE_DIR, { recursive: true });
@@ -442,6 +479,41 @@ function dimStatus(ctx: any, text: string): string {
442
479
  }
443
480
  }
444
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
+
445
517
  function discountsChanged(
446
518
  a: Map<string, JsonDiscount> | null,
447
519
  b: Map<string, JsonDiscount> | null,
@@ -467,6 +539,10 @@ let revalidateAbort: AbortController | null = null;
467
539
  let latestDiscounts: Map<string, JsonDiscount> | null = null;
468
540
  let lastDiscountFetchTime = 0;
469
541
  const STATUS_CACHE_TTL_MS = 30000;
542
+ // List-price (patch-applied, pre-discount) models, cached until the base set
543
+ // changes. Reset in cacheModels() so the next getListModels() rebuilds from the
544
+ // refreshed disk cache / embedded set.
545
+ let listModelsCache: JsonModel[] | null = null;
470
546
 
471
547
  async function resolveApiKey(modelRegistry: ModelRegistry): Promise<void> {
472
548
  cachedApiKey = await modelRegistry.getApiKeyForProvider("lilac") ?? undefined;
@@ -479,6 +555,16 @@ export default function (pi: ExtensionAPI) {
479
555
  const customModels = customModelsData as JsonModel[];
480
556
  const patches = patchData as PatchData;
481
557
 
558
+ // List-price models (patch applied, pre-discount), cached at module scope and
559
+ // rebuilt only when the base set changes (see cacheModels). Used to recompute
560
+ // the in-flight model's cost without compounding an already-applied discount.
561
+ function getListModels(): JsonModel[] {
562
+ if (!listModelsCache) {
563
+ listModelsCache = buildModels(loadStaleModels(embeddedModels), customModels, patches);
564
+ }
565
+ return listModelsCache;
566
+ }
567
+
482
568
  const staleBase = loadStaleModels(embeddedModels);
483
569
  latestDiscounts = loadCachedDiscounts();
484
570
  const staleModels = applyDiscounts(buildModels(staleBase, customModels, patches), latestDiscounts);
@@ -521,11 +607,10 @@ export default function (pi: ExtensionAPI) {
521
607
  // Replay persisted discount state from session JSONL (synchronous, zero-latency)
522
608
  replayDiscountEvents(ctx);
523
609
 
524
- // Show status immediately with replayed/cached data — don't block pi startup
525
- const model = ctx.model;
526
- if (model?.provider === "lilac") {
527
- ctx.ui.setStatus("lilac", dimStatus(ctx, formatDiscountStatus(model.id)));
528
- }
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);
529
614
 
530
615
  // Fire-and-forget: resolve API key, then fetch live data in background.
531
616
  // Provider and status are hot-swapped when results arrive.
@@ -562,9 +647,10 @@ export default function (pi: ExtensionAPI) {
562
647
  });
563
648
  }
564
649
 
565
- if (model?.provider === "lilac") {
566
- ctx.ui.setStatus("lilac", dimStatus(ctx, formatDiscountStatus(model.id)));
567
- }
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);
568
654
  }).catch(() => { /* network errors are non-fatal */ });
569
655
  });
570
656
  });
@@ -582,10 +668,29 @@ export default function (pi: ExtensionAPI) {
582
668
  });
583
669
 
584
670
  pi.on("before_provider_request", async (_event, ctx) => {
585
- if (ctx.model?.provider !== "lilac") return;
586
-
587
- // Always show status for active lilac model
588
- ctx.ui.setStatus("lilac", dimStatus(ctx, formatDiscountStatus(ctx.model.id)));
671
+ // Capture the in-flight model BEFORE any await or registerProvider() call.
672
+ // ctx.model is a lazy getter to the live session model; at hook start (before
673
+ // any refresh) it is the same object pi's agent loop bound as the model for
674
+ // THIS turn — the exact object whose .cost calculateCost() reads when the
675
+ // response arrives. registerProvider() can only refresh the session model
676
+ // for SUBSEQUENT turns (prepareNextTurn), so to affect the current turn's
677
+ // cost we mutate this object in place. Capturing before the await also guards
678
+ // against a concurrent session_start /status sync swapping the reference
679
+ // mid-hook.
680
+ const inFlightModel = ctx.model;
681
+ if (!inFlightModel || inFlightModel.provider !== "lilac") return;
682
+
683
+ const listModels = getListModels();
684
+
685
+ // Align the in-flight model with the best-known discount right away. This
686
+ // covers turns that don't trigger a fresh fetch (within TTL, or the fetch
687
+ // returned unchanged) and recomputes from list price so it never compounds
688
+ // a previously-applied factor.
689
+ applyDiscountInPlace(inFlightModel, listModels, latestDiscounts);
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);
589
694
 
590
695
  if (!cachedApiKey) return;
591
696
 
@@ -596,40 +701,46 @@ export default function (pi: ExtensionAPI) {
596
701
 
597
702
  const discounts = await fetchStatusDiscounts(cachedApiKey);
598
703
  if (!discounts) return;
704
+
705
+ lastDiscountFetchTime = now;
706
+
599
707
  if (!discountsChanged(latestDiscounts, discounts)) {
600
- lastDiscountFetchTime = now;
601
- ctx.ui.setStatus("lilac", dimStatus(ctx, formatDiscountStatus(ctx.model.id)));
708
+ syncStatus(ctx);
602
709
  return;
603
710
  }
604
711
 
605
- lastDiscountFetchTime = now;
606
712
  cacheDiscounts(discounts);
607
713
  latestDiscounts = discounts;
608
714
 
609
- const base = loadStaleModels(embeddedModels);
715
+ // Re-read list prices in case a concurrent /models sync (session_start)
716
+ // invalidated the list-price cache during the await above. Then re-apply the
717
+ // freshly-fetched discount so THIS turn (not just later ones) is costed at
718
+ // the new price, and re-register so other lilac models pick it up on their
719
+ // next request.
720
+ const freshList = getListModels();
721
+ applyDiscountInPlace(inFlightModel, freshList, discounts);
610
722
  pi.registerProvider("lilac", {
611
723
  baseUrl: BASE_URL,
612
724
  apiKey: "$LILAC_API_KEY",
613
725
  api: "openai-completions",
614
- models: applyDiscounts(buildModels(base, customModels, patches), discounts),
726
+ models: applyDiscounts(freshList, discounts),
615
727
  });
616
- ctx.ui.setStatus("lilac", dimStatus(ctx, formatDiscountStatus(ctx.model.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);
617
732
  });
618
733
 
619
- pi.on("model_select", async (event, ctx) => {
620
- if (event.model.provider === "lilac") {
621
- ctx.ui.setStatus("lilac", dimStatus(ctx, formatDiscountStatus(event.model.id)));
622
- } else {
623
- ctx.ui.setStatus("lilac", undefined);
624
- }
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);
625
739
  });
626
740
 
627
741
  pi.on("session_tree", async (_event, ctx) => {
628
742
  replayDiscountEvents(ctx);
629
- const model = ctx.model;
630
- if (model?.provider === "lilac") {
631
- ctx.ui.setStatus("lilac", dimStatus(ctx, formatDiscountStatus(model.id)));
632
- }
743
+ syncStatus(ctx);
633
744
  });
634
745
 
635
746
  // vLLM's streaming parser intermittently emits finish_reason: "tool_calls" without
@@ -668,5 +779,5 @@ export default function (pi: ExtensionAPI) {
668
779
  });
669
780
  }
670
781
 
671
- export { fetchStatusDiscounts, applyDiscounts, loadCachedDiscounts, cacheDiscounts };
782
+ export { fetchStatusDiscounts, applyDiscounts, applyDiscountInPlace, loadCachedDiscounts, cacheDiscounts };
672
783
  export type { JsonDiscount, JsonModel, PatchEntry, PatchData };
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.1.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",
@@ -13,6 +13,10 @@
13
13
  * 7. turn_end appends discount entry to session JSONL.
14
14
  * 8. before_provider_request refreshes discounts with a 30s cache.
15
15
  * 9. formatDiscountStatus returns fallbacks when data is missing.
16
+ * 10. applyDiscountInPlace mutates the in-flight model's cost in place,
17
+ * recomputed from list price so re-applied discounts never compound.
18
+ * 11. before_provider_request mutates the bound (in-flight) model object so the
19
+ * current turn's cost calc sees the discount in real time.
16
20
  */
17
21
 
18
22
  import type { ExtensionAPI, ModelRegistry } from "@earendil-works/pi-coding-agent";
@@ -43,6 +47,7 @@ const {
43
47
  default: registerLilac,
44
48
  fetchStatusDiscounts,
45
49
  applyDiscounts,
50
+ applyDiscountInPlace,
46
51
  loadCachedDiscounts,
47
52
  cacheDiscounts,
48
53
  } = await import("../index.ts");
@@ -281,7 +286,7 @@ for (const handler of handlers.get("model_select") || []) {
281
286
  previousModel: undefined,
282
287
  source: "set",
283
288
  },
284
- { ui: mockUi }
289
+ { ui: mockUi, model: { id: "moonshotai/kimi-k2.6", provider: "lilac" } }
285
290
  );
286
291
  }
287
292
  assert(statuses.get("lilac") === "supply: healthy · sub-discount: 25%", "model_select keeps status for lilac model");
@@ -297,7 +302,7 @@ for (const handler of handlers.get("model_select") || []) {
297
302
  previousModel: undefined,
298
303
  source: "set",
299
304
  },
300
- { ui: mockUi }
305
+ { ui: mockUi, model: { id: "claude-sonnet-4", provider: "anthropic" } }
301
306
  );
302
307
  }
303
308
  assert(statuses.get("lilac") === undefined, "model_select clears status for non-lilac model");
@@ -354,7 +359,7 @@ for (const handler of handlers.get("model_select") || []) {
354
359
  previousModel: undefined,
355
360
  source: "set",
356
361
  },
357
- { ui: mockUi },
362
+ { ui: mockUi, model: { id: "some/unknown-model", provider: "lilac" } },
358
363
  );
359
364
  }
360
365
  assert(statuses.get("lilac") === "supply: —", "unknown model shows fallback dash");
@@ -368,7 +373,7 @@ for (const handler of handlers.get("model_select") || []) {
368
373
  previousModel: undefined,
369
374
  source: "set",
370
375
  },
371
- { ui: mockUi },
376
+ { ui: mockUi, model: { id: "moonshotai/kimi-k2.6", provider: "lilac" } },
372
377
  );
373
378
  }
374
379
  assert(
@@ -446,6 +451,252 @@ const kimiFinal = afterSecond.config.models.find((m: any) => m.id === "moonshota
446
451
  assert(kimiFinal.discount.discountPercent === 25, "final registration uses correct discount (not stale from first re-register)");
447
452
  assert(kimiFinal.cost.input === 0.525, "final registration uses correct cost (0.70 * 0.75)");
448
453
 
454
+ // ─── Test 12: applyDiscountInPlace mutates the in-flight model ───────────────
455
+
456
+ console.log("\n--- Test 12: applyDiscountInPlace (in-flight mutation) ---");
457
+
458
+ // applyDiscountInPlace closes the one-turn gap: registerProvider() refreshes
459
+ // the registry for LATER turns, but the model pi already bound for the current
460
+ // turn is a separate object whose .cost calculateCost() reads. We mutate THAT
461
+ // object in place, recomputed from list price so a changed discount never
462
+ // compounds on top of an already-applied factor.
463
+
464
+ const listModels = [
465
+ { id: "moonshotai/kimi-k2.6", name: "Kimi K2.6", cost: { input: 0.70, output: 3.50, cacheRead: 0.20, cacheWrite: 0 } },
466
+ ] as any[];
467
+
468
+ // A model object that is "already bound" for the turn — the same reference pi's
469
+ // agent loop holds. It currently carries an OLD discount (factor 0.75).
470
+ const inFlight = {
471
+ id: "moonshotai/kimi-k2.6",
472
+ cost: { input: 0.525, output: 2.625, cacheRead: 0.15, cacheWrite: 0 },
473
+ };
474
+ const inFlightRef = inFlight;
475
+
476
+ // New discount: factor 0.50 (changed). Must recompute from LIST price (0.70),
477
+ // NOT compound on top of the already-applied 0.75 (which would give 0.2625).
478
+ const newDiscounts = new Map([
479
+ ["moonshotai/kimi-k2.6", { supplyState: "high", discountPercent: 50, creditMultiplier: 0.50 }],
480
+ ]);
481
+ applyDiscountInPlace(inFlight, listModels, newDiscounts);
482
+ assert(inFlight === inFlightRef, "mutates the same object in place (no new object created)");
483
+ assert(inFlight.cost.input === 0.35, "recomputed from list price: 0.70 * 0.50 = 0.35 (not compounded 0.2625)");
484
+ assert(inFlight.cost.output === 1.75, "output = 3.50 * 0.50 = 1.75");
485
+ assert(inFlight.cost.cacheRead === 0.1, "cacheRead = 0.20 * 0.50 = 0.1");
486
+ assert(inFlight.cost.cacheWrite === 0, "cacheWrite left untouched (Lilac does not discount it)");
487
+
488
+ // Idempotency + no-compounding: applying the same factor again yields the same
489
+ // values (always recomputed from list price, never compounding).
490
+ applyDiscountInPlace(inFlight, listModels, newDiscounts);
491
+ assert(inFlight.cost.input === 0.35, "re-applying same factor is idempotent (no compounding)");
492
+
493
+ // Discount removed (model no longer in /status) → reverts to list price.
494
+ const emptyDiscounts = new Map();
495
+ applyDiscountInPlace(inFlight, listModels, emptyDiscounts);
496
+ assert(inFlight.cost.input === 0.70, "removed discount reverts to list price (factor 1)");
497
+ assert(inFlight.cost.output === 3.50, "removed discount: output reverts to list price");
498
+
499
+ // null discounts → list price, no throw.
500
+ applyDiscountInPlace(inFlight, listModels, null);
501
+ assert(inFlight.cost.input === 0.70, "null discounts → list price");
502
+
503
+ // Unknown model id (not in listModels) → no-op, no throw.
504
+ const unknown = { id: "some/unknown-model", cost: { input: 9.99, output: 9.99, cacheRead: 9.99, cacheWrite: 0 } };
505
+ applyDiscountInPlace(unknown, listModels, newDiscounts);
506
+ assert(unknown.cost.input === 9.99, "unknown model id → no mutation (no list-price match)");
507
+
508
+ // undefined model → no throw.
509
+ applyDiscountInPlace(undefined, listModels, newDiscounts);
510
+ assert(true, "undefined model does not throw");
511
+
512
+ // ─── Test 13: before_provider_request mutates the bound (in-flight) model ────
513
+
514
+ console.log("\n--- Test 13: before_provider_request mutates the bound model ---");
515
+
516
+ // Simulate the object pi's agent loop bound for the turn. It holds an OUTDATED
517
+ // cost (list price, as if no discount had been applied yet). This same reference
518
+ // is what calculateCost() reads for the current turn — so the handler MUST mutate
519
+ // it in place, not replace it. latestDiscounts currently holds kimi at 0.75 (from
520
+ // Test 4), and the TTL is fresh, so the handler takes the within-TTL path (which
521
+ // previously did NOT touch the in-flight model at all).
522
+ const boundModel = {
523
+ id: "moonshotai/kimi-k2.6",
524
+ provider: "lilac",
525
+ cost: { input: 0.70, output: 3.50, cacheRead: 0.20, cacheWrite: 0 }, // list price (stale)
526
+ };
527
+ const boundRef = boundModel;
528
+
529
+ for (const handler of handlers.get("before_provider_request") || []) {
530
+ await handler(
531
+ { type: "before_provider_request", payload: {} },
532
+ {
533
+ ui: mockUi,
534
+ model: boundModel,
535
+ }
536
+ );
537
+ }
538
+
539
+ assert(boundModel === boundRef, "handler did not replace the bound model object (same reference)");
540
+ assert(boundModel.cost.input === 0.525, "bound model cost mutated in place to 0.70 * 0.75 = 0.525");
541
+ assert(boundModel.cost.output === 2.625, "bound model output mutated to 3.50 * 0.75 = 2.625");
542
+ assert(boundModel.cost.cacheRead === 0.15, "bound model cacheRead mutated to 0.20 * 0.75 = 0.15");
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
+
449
700
  // ─── Cleanup ──────────────────────────────────────────────────────────────────
450
701
 
451
702
  globalThis.fetch = originalFetch;