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 +1 -1
- package/index.ts +140 -29
- package/models.json +2 -1
- package/package.json +1 -1
- package/scripts/test-discounts.ts +255 -4
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
|
@@ -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
|
-
|
|
526
|
-
|
|
527
|
-
|
|
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
|
-
|
|
566
|
-
|
|
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
|
-
|
|
586
|
-
|
|
587
|
-
//
|
|
588
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
726
|
+
models: applyDiscounts(freshList, discounts),
|
|
615
727
|
});
|
|
616
|
-
|
|
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 (
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
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
|
-
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-lilac-provider",
|
|
3
|
-
"version": "1.1
|
|
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;
|