@nijaru/pi-peak-hours 0.0.6 → 0.0.7

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
@@ -1,14 +1,16 @@
1
1
  # pi-peak-hours
2
2
 
3
- Correct pi's recorded cost for providers that bill by time of day, and show the rate in force in the footer.
3
+ Estimate time-of-day pricing from pi's recorded cost, and show the current estimated rate in the footer.
4
4
 
5
5
  DeepSeek is the current example. Its peak windows are `01:00–04:00` and `06:00–10:00` UTC, Monday to Friday, and off-peak is half the peak rate. pi has no notion of time-of-day pricing: `usage.cost` is computed once from the model's flat rates when usage is finalized, so a session is over- or understated by up to 2x depending on which side of the schedule the recorded rate sits.
6
6
 
7
7
  The default covers DeepSeek direct only: pi records it at the published **peak** rate, so correction is ×1 inside the windows and ×0.5 outside. Reseller routes bill flat — OpenRouter prices each model per key and publishes no time-of-day overrides — so a default multiplier would corrupt their cost instead of correcting it. Give a route an explicit schedule only when its provider really bills by time of day. Custom `models.json` rates also need matching multipliers.
8
8
 
9
- This extension rewrites `usage.cost` on the finalized assistant message, so the footer total, the per-model breakdown, and the HTML export all reflect the billed rate. The status line quotes the rate in force for the selected model — `· ▲ peak $0.30/$1.20/M` during a peak window, `· $0.15/$0.60/M` off it — with `▲ peak` or `▼ off-peak` added when the hour costs more or less than the rate pi recorded. With stock peak-base prices, the peak indicator is absent and off-peak is marked as a discount. `/peak-hours status` names the schedule behind it.
9
+ With a compatible [pi-provider-compaction](https://github.com/nijaru/pi-provider-compaction) companion loaded, this extension scales existing `usage.cost` before request completion reaches stream consumers or cache observers. It covers observed foreground requests, compaction, branch summaries, and cache warming for scheduled providers. Without the companion, it still corrects finalized foreground assistant messages through `message_end`; auxiliary usage stays unchanged. Both paths preserve token counts and existing cost corrections such as `/fast`.
10
10
 
11
- The quoted rate is pi's recorded rate scaled by the schedule, so it always agrees with the cost written to the session. It describes the hour in force rather than a particular request, though: a request that started before a boundary is billed by its start time while the footer has already moved to the new hour. Where pi's rate table is stale or wrong for your route, the quoted rate is stale in the same way — the extension corrects the shape of the bill, not the base rate.
11
+ The status line quotes the selected model's scheduled catalog estimate — `· est. $0.30/$1.20/M` during a peak window, `· est. ▼ off-peak $0.15/$0.60/M` off it. `▲ peak` or `▼ off-peak` marks a rate above or below pi's recorded rate. With stock peak-base prices, peak has no indicator and off-peak is marked as a discount. `/peak-hours status` names the schedule.
12
+
13
+ The footer describes the current hour, not a particular request: a request invoked before a boundary uses its invocation timestamp even after the footer moves to the new hour. Request-specific corrections can also differ from the quoted catalog rate. Stale base prices remain stale; this extension applies a multiplier, not a price lookup. All costs are estimates, not invoices: server arrival times and Chinese public holidays are not modeled.
12
14
 
13
15
  ## Install
14
16
 
@@ -16,7 +18,9 @@ The quoted rate is pi's recorded rate scaled by the schedule, so it always agree
16
18
  pi install npm:@nijaru/pi-peak-hours
17
19
  ```
18
20
 
19
- Or copy `extensions/index.ts` to `~/.pi/agent/extensions/` for a single-machine install.
21
+ For a manual install, copy both files from `extensions/` into `~/.pi/agent/extensions/peak-hours/`, keeping `index.ts` and `request-pricing.ts` together.
22
+
23
+ Request-wide pricing requires a companion version that supports the `pi-provider-compaction:observe-usage` event. Older or absent companions leave foreground correction usable; no second provider wrapper is installed.
20
24
 
21
25
  ## Usage
22
26
 
@@ -25,6 +29,8 @@ Or copy `extensions/index.ts` to `~/.pi/agent/extensions/` for a single-machine
25
29
  /peak-hours on|off # toggle correction; persists to pi-peak-hours.json
26
30
  ```
27
31
 
32
+ `/peak-hours status` also reloads the configuration. With the companion, configuration and on/off changes apply to new requests; requests already dispatched keep their sampled decision.
33
+
28
34
  The footer status is a separate line under the stats bar. It shows the model's current rate while a schedule covers it, and clears when correction is off, no schedule applies, or pi records no cost for the model. It re-evaluates on model switch, on every turn, and when a message is finalized — and it arms a timer for the next instant the rate it quotes actually changes, so a session sitting idle across 04:00 UTC flips to the new rate on time instead of at its next turn.
29
35
 
30
36
  ## Configuration
@@ -100,16 +106,17 @@ A malformed field falls back to its default rather than disabling the extension;
100
106
 
101
107
  ## Design notes
102
108
 
103
- - **Only the selected model drives the indicator.** Schedules are provider- and model-scoped, so the footer never shows a rate for a service that does not bill by time of day. A rate above the recorded one reads `peak`, below it `off-peak`, and a discount is never labelled a peak. The quoted rate and the written cost come from the same multiplication, so they cannot disagree.
109
+ - **Only the selected model drives the indicator.** Schedules are provider- and model-scoped, so the footer shows no rate for uncovered models. A rate above the recorded one reads `peak`, below it `off-peak`. The `est.` label applies even with request-wide coverage.
104
110
  - **The footer follows the window it quotes.** pi has no render tick and fires no event while a session is idle, and a rate that changes at 04:00 would otherwise stay on screen until the next turn. The extension arms a single timer for the next instant the quoted rate changes and re-arms it after each fire, from the same clock reading as the status it is keeping fresh. It arms nothing when there is no status on screen, and the timer is unreferenced and cleared on session shutdown, so it neither holds pi open nor outlives its session.
105
- - **Rate by provider invocation.** Pi providers timestamp each assistant message before preparing and sending its HTTP request. That timestamp selects the multiplier, not response completion or the shared payload hook (which also fires for background cache warming). Server arrival can differ across a boundary, so this remains an estimate.
106
- - **Scaled once.** Retries and overflow recovery can re-deliver the same message object, so adjustments are guarded by identity; the replacement returned to pi is registered too, so a replayed replacement cannot be scaled twice.
111
+ - **Rate by provider invocation.** Pi providers timestamp each assistant message before preparing and sending its HTTP request. That timestamp selects the multiplier, not completion time or the shared payload hook. With the companion, active state and schedules are captured at request dispatch; without it, foreground fallback uses the configuration at `message_end`.
112
+ - **Scaled once.** Global weak identity guards protect usage and foreground message objects across loaded copies, retries, and returned replacements. Usage observed at dispatch remains handled even when its decision is unchanged (×1 or inactive), so fallback cannot overwrite it after a config change.
113
+ - **Shared ownership.** The client requests usage-only leases for covered available models and the current model, deduplicated by provider. The companion scopes callbacks to the owning session. Retired leases are reacquired on reconciliation; shutdown and SDK disposal remove only this client's leases. Neither catalogs nor unrelated provider slots are replaced.
107
114
  - **Only cost moves.** The four cost fields are scaled and `total` is recomputed from them, matching pi's own invariant. Token counts are untouched, so rate-from-cost derivations stay self-consistent — including pi's cache-miss accounting, which derives its paid and read rates from the same message and scales with it.
108
- - **Coverage.** The default weekday schedule does not encode Chinese public holidays, which DeepSeek treats as off-peak. Holiday costs can therefore be overstated; totals are not an invoice. Assistant messages carry the bulk of a session's spend. Compaction and branch-summary usage, prompt-cache-warming refreshes, and token usage reported by tools also consume budget but cannot be adjusted after the fact, so totals stay an estimate rather than an invoice.
115
+ - **Coverage.** Only requests observed by the companion's safe dispatcher receive request-wide correction. Unavailable models, unsupported provider registrations, and usage reported independently by tools can remain uncorrected. Without the companion, coverage is foreground assistant messages only. The default weekday schedule does not encode Chinese public holidays, which DeepSeek treats as off-peak; holiday costs can therefore be overstated.
109
116
 
110
117
  ## Stack
111
118
 
112
- TypeScript, Bun. Pi extension API (`@earendil-works/pi-coding-agent`). `@earendil-works/pi-ai` is a dev dependency only — the handlers use pi's own `Model` and usage shapes structurally, so nothing from it ships. No build step — pi loads the extension directly.
119
+ TypeScript, Bun. `@earendil-works/pi-coding-agent` and `@earendil-works/pi-ai` are host-provided peers; the latter supplies session-resource cleanup for SDK disposal. No build step — pi loads `extensions/index.ts` directly.
113
120
 
114
121
  ## Testing
115
122
 
@@ -1,5 +1,5 @@
1
1
  /**
2
- * pi-peak-hours — correct recorded cost for providers that bill by time of day.
2
+ * pi-peak-hours — estimate time-of-day cost from pi's existing recorded cost.
3
3
  *
4
4
  * DeepSeek is the clearest example: a 2x multiplier applies during peak windows
5
5
  * (01:00-04:00 and 06:00-10:00 UTC, Monday-Friday, as of September 2026) and the
@@ -8,18 +8,17 @@
8
8
  * finalized — so a session is over- or understated by up to 2x, in whichever
9
9
  * direction the recorded rate sits from the billed one.
10
10
  *
11
- * This extension rewrites `usage.cost` on the finalized assistant message in
12
- * `message_end`, the documented hook for replacing a message. The footer
13
- * recomputes session totals from persisted entries on every render, so the
14
- * corrected number appears in the footer, the per-model breakdown, and the
15
- * HTML export without further plumbing.
11
+ * With pi-provider-compaction, usage-only leases correct request usage before
12
+ * stream consumers and cache observers see it. Without the companion, the
13
+ * documented `message_end` replacement hook still corrects foreground messages.
14
+ * Neither path changes catalogs, tokens, or other cost corrections.
16
15
  *
17
16
  * The footer indicator is scoped to the selected model and quotes the rate that
18
17
  * applies to it right now, in dollars per million tokens: with the default
19
- * schedule, `· $0.30/$1.20/M` at peak and `· ▼ off-peak $0.15/$0.60/M` off it.
20
- * The rate is the one pi recorded scaled by the schedule, so it always agrees
21
- * with the cost this extension writes. A session on a provider or model no
22
- * schedule covers never shows a rate, and switching models re-evaluates it.
18
+ * schedule, `· est. $0.30/$1.20/M` at peak and
19
+ * `· est. ▼ off-peak $0.15/$0.60/M` off it. This is a scheduled catalog estimate;
20
+ * request-specific corrections such as /fast can change the actual usage cost.
21
+ * Uncovered models show no rate; switching models re-evaluates it.
23
22
  *
24
23
  * The indicator is a snapshot of the current hour, and pi fires no event while a
25
24
  * session sits idle — so a timer armed at the next window boundary keeps it from
@@ -36,8 +35,8 @@
36
35
  *
37
36
  * - The rate is chosen from the request's START time, not its end. DeepSeek
38
37
  * bills by when a request arrives, so a request spanning a window boundary
39
- * would otherwise be billed on the wrong side of it. `message_start` gives
40
- * the start; `message_end` gives the end.
38
+ * would otherwise be billed on the wrong side of it. The provider's message
39
+ * timestamp records invocation; completion wall-clock time is not used.
41
40
  * - A schedule declares the multiplier INSIDE its windows and `outside` for
42
41
  * every other hour. Which side pi's recorded rate sits on is not assumed:
43
42
  * encode it in the config, and only the difference is applied. Only DeepSeek
@@ -52,18 +51,19 @@
52
51
  * modified, so any rate derived from cost/tokens stays self-consistent
53
52
  * (cache-miss accounting derives paid and read rates from the same message
54
53
  * and scales with it).
55
- * - Unknown or malformed configuration leaves the extension inert after a
56
- * single warning; it never throws inside a message handler.
54
+ * - Unknown or malformed configuration falls back to the defaults with a
55
+ * warning; no path throws from a message handler.
57
56
  *
58
- * Coverage limit: assistant messages carry the bulk of a session's spend.
59
- * Compaction and branch-summary usage and token usage reported by tools also
60
- * feed footer totals, but pi exposes no hook that can adjust them afterwards,
61
- * so those stay at list rates and the footer stays an estimate, not an invoice.
57
+ * All costs remain estimates, not invoices: server arrival, Chinese public
58
+ * holidays, and usage outside observed provider requests are not modeled.
62
59
  */
63
60
 
64
61
  import { getAgentDir, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
65
62
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
66
63
  import { dirname, join } from "node:path";
64
+ import { applyMultiplier, handledUsage, requestPricing } from "./request-pricing.ts";
65
+
66
+ export { applyMultiplier } from "./request-pricing.ts";
67
67
 
68
68
  export const STATUS_KEY = "peak-hours";
69
69
  /**
@@ -271,11 +271,6 @@ export function multiplierAt(startedAt: Date, schedule: Schedule): number {
271
271
  return schedule.outside;
272
272
  }
273
273
 
274
- /** Whether the rate in force costs more than the one pi recorded. */
275
- export function isPeakAt(startedAt: Date, schedule: Schedule): boolean {
276
- return multiplierAt(startedAt, schedule) > 1;
277
- }
278
-
279
274
  const MS_PER_MINUTE = 60_000;
280
275
 
281
276
  /**
@@ -420,37 +415,20 @@ export function statusIndicator(
420
415
 
421
416
  switch (rateStateAt(now, schedule)) {
422
417
  case "peak":
423
- return `· ${STATUS_PEAK} ${rate}`;
418
+ return `· est. ${STATUS_PEAK} ${rate}`;
424
419
  case "off-peak":
425
- return `· ${STATUS_OFF_PEAK} ${rate}`;
420
+ return `· est. ${STATUS_OFF_PEAK} ${rate}`;
426
421
  default:
427
- return `· ${rate}`;
422
+ return `· est. ${rate}`;
428
423
  }
429
424
  }
430
425
 
431
- export interface CostBreakdown extends RateCard {
432
- total: number;
433
- }
434
-
435
- /** Scale every rate field and recompute `total` from them, as pi itself does. */
436
- export function applyMultiplier(cost: CostBreakdown, multiplier: number): CostBreakdown {
437
- const scaled: CostBreakdown = {
438
- input: cost.input * multiplier,
439
- output: cost.output * multiplier,
440
- cacheRead: cost.cacheRead * multiplier,
441
- cacheWrite: cost.cacheWrite * multiplier,
442
- total: 0,
443
- };
444
- scaled.total = scaled.input + scaled.output + scaled.cacheRead + scaled.cacheWrite;
445
- return scaled;
446
- }
447
-
448
426
  function isRecord(value: unknown): value is Record<string, unknown> {
449
427
  return typeof value === "object" && value !== null && !Array.isArray(value);
450
428
  }
451
429
 
452
430
  /** Missing or malformed config uses defaults; parsing failures are reported. */
453
- export function readConfig(path: string): ConfigFile | undefined {
431
+ export function readConfig(path: string): ConfigFile {
454
432
  if (!existsSync(path)) return {};
455
433
  try {
456
434
  const parsed = JSON.parse(readFileSync(path, "utf8")) as unknown;
@@ -469,10 +447,8 @@ export function readConfig(path: string): ConfigFile | undefined {
469
447
  /** Persist `active` while preserving keys this version does not model. */
470
448
  export function writeActive(path: string, active: boolean): void {
471
449
  try {
472
- const existing = readConfig(path) ?? {};
473
- const merged = isRecord(existing) ? { ...existing, active } : { active };
474
450
  mkdirSync(dirname(path), { recursive: true });
475
- writeFileSync(path, `${JSON.stringify(merged, null, 2)}\n`, "utf8");
451
+ writeFileSync(path, `${JSON.stringify({ ...readConfig(path), active }, null, 2)}\n`, "utf8");
476
452
  } catch (error) {
477
453
  const message = error instanceof Error ? error.message : String(error);
478
454
  console.warn(`[${STATUS_KEY}] Failed to write ${path}: ${message}`);
@@ -527,14 +503,23 @@ function adjustedMessages(): WeakSet<object> {
527
503
 
528
504
  export default function peakHours(pi: ExtensionAPI, options: PeakHoursOptions = {}): void {
529
505
  const configPath = options.configPath ?? CONFIG_PATH;
530
- let config = readConfig(configPath) ?? {};
506
+ let config = readConfig(configPath);
531
507
  let schedules = resolveSchedules(config);
532
508
  let active = parseActive(config.active);
533
509
  const adjusted = adjustedMessages();
534
510
  let timer: ReturnType<typeof setTimeout> | undefined;
511
+ const pricing = requestPricing(pi, {
512
+ covers: (model) => scheduleFor(schedules, model.provider, model.id) !== undefined,
513
+ snapshot(model) {
514
+ // Refresh replaces schedules rather than mutating them. Capturing this
515
+ // resolved schedule and toggle freezes the decision for this request.
516
+ const schedule = active ? scheduleFor(schedules, model.provider, model.id) : undefined;
517
+ return (timestamp) => schedule ? multiplierAt(new Date(timestamp), schedule) : 1;
518
+ },
519
+ }, disarm);
535
520
 
536
521
  function refresh(): void {
537
- config = readConfig(configPath) ?? {};
522
+ config = readConfig(configPath);
538
523
  schedules = resolveSchedules(config);
539
524
  active = parseActive(config.active);
540
525
  }
@@ -562,7 +547,7 @@ export default function peakHours(pi: ExtensionAPI, options: PeakHoursOptions =
562
547
 
563
548
  const next = nextTransitionAt(now, schedule);
564
549
  if (!next) return;
565
- // Floored, so a boundary crossed between reading the clock and arming the
550
+ // Clamped, so a boundary crossed between reading the clock and arming the
566
551
  // timer cannot spin it; unreferenced, so a pending window never keeps pi
567
552
  // alive after its work is done.
568
553
  timer = setTimeout(() => {
@@ -595,30 +580,34 @@ export default function peakHours(pi: ExtensionAPI, options: PeakHoursOptions =
595
580
  ctx.ui.notify(`Peak hours: not applied to ${model.provider}/${model.id}. ${describeAll(schedules)}`, "info");
596
581
  return;
597
582
  }
598
- const multiplier = multiplierAt(new Date(), schedule);
583
+ const now = new Date();
584
+ const multiplier = multiplierAt(now, schedule);
599
585
  const rate = describeRate(model.cost, multiplier);
600
- const state =
601
- multiplier > 1
602
- ? `peak ×${multiplier} right now`
603
- : multiplier < 1
604
- ? `off-peak ×${multiplier} right now`
605
- : "no correction right now";
586
+ const rateState = rateStateAt(now, schedule);
587
+ const state = rateState === "none" ? "no correction right now" : `${rateState} ×${multiplier} right now`;
606
588
  const detail = rate ? `${state} (${rate})` : state;
607
- ctx.ui.notify(`Peak hours: ${detail}. ${describeSchedule(schedule)}`, "info");
589
+ ctx.ui.notify(`Peak hours estimate: ${detail}. ${describeSchedule(schedule)}. Costs are not an invoice.`, "info");
608
590
  }
609
591
 
610
592
  pi.on("session_start", async (_event, ctx) => {
611
593
  refresh();
612
- updateStatus(ctx);
594
+ await pricing.start(ctx);
595
+ if (pricing.isCurrent(ctx)) updateStatus(ctx);
596
+ });
597
+
598
+ pi.on("before_agent_start", async (_event, ctx) => {
599
+ await pricing.reconcile(ctx);
613
600
  });
614
601
 
615
602
  pi.on("session_shutdown", async (_event, ctx) => {
616
- disarm();
603
+ pricing.stop();
617
604
  ctx.ui.setStatus(STATUS_KEY, undefined);
618
605
  });
619
606
 
620
607
  pi.on("model_select", async (event, ctx) => {
621
- updateStatus(ctx, event.model);
608
+ refresh();
609
+ await pricing.reconcile(ctx, event.model);
610
+ if (pricing.isCurrent(ctx)) updateStatus(ctx, event.model);
622
611
  });
623
612
 
624
613
  // The window boundary can pass between messages, and the footer quotes a price
@@ -632,6 +621,10 @@ export default function peakHours(pi: ExtensionAPI, options: PeakHoursOptions =
632
621
  if (message.role !== "assistant") return;
633
622
  if (adjusted.has(message)) return;
634
623
  if (!message.usage?.cost) return;
624
+ if (handledUsage.has(message.usage)) {
625
+ updateStatus(ctx);
626
+ return;
627
+ }
635
628
 
636
629
  // Pi providers stamp their output at invocation, before HTTP dispatch.
637
630
  // This clock belongs to the foreground response even when the global
@@ -655,6 +648,7 @@ export default function peakHours(pi: ExtensionAPI, options: PeakHoursOptions =
655
648
  }
656
649
 
657
650
  adjusted.add(message);
651
+ handledUsage.add(message.usage);
658
652
  updateStatus(ctx);
659
653
  const replacement = {
660
654
  ...message,
@@ -663,10 +657,11 @@ export default function peakHours(pi: ExtensionAPI, options: PeakHoursOptions =
663
657
  cost: applyMultiplier(message.usage.cost, multiplier),
664
658
  },
665
659
  };
666
- // pi copies this onto the original, so the original's identity already
667
- // carries the guard; registering the replacement covers a caller that
668
- // replays the returned value instead.
660
+ // pi consumes the returned replacement, so the original identity carries
661
+ // the guard for a retry that re-delivers it; registering the replacement
662
+ // covers a caller that replays the returned value instead.
669
663
  adjusted.add(replacement);
664
+ handledUsage.add(replacement.usage);
670
665
  return { message: replacement };
671
666
  });
672
667
 
@@ -681,6 +676,8 @@ export default function peakHours(pi: ExtensionAPI, options: PeakHoursOptions =
681
676
  const arg = args.trim().toLowerCase();
682
677
  if (!arg || arg === "status") {
683
678
  refresh();
679
+ await pricing.reconcile(ctx);
680
+ if (!pricing.isCurrent(ctx)) return;
684
681
  updateStatus(ctx);
685
682
  notifyStatus(ctx);
686
683
  return;
@@ -691,6 +688,8 @@ export default function peakHours(pi: ExtensionAPI, options: PeakHoursOptions =
691
688
  }
692
689
  writeActive(configPath, arg === "on");
693
690
  refresh();
691
+ await pricing.reconcile(ctx);
692
+ if (!pricing.isCurrent(ctx)) return;
694
693
  updateStatus(ctx);
695
694
  notifyStatus(ctx);
696
695
  },
@@ -0,0 +1,147 @@
1
+ import { registerSessionResourceCleanup, type Usage } from "@earendil-works/pi-ai";
2
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+
4
+ export const OBSERVE_USAGE = "pi-provider-compaction:observe-usage";
5
+ export const USAGE_OBSERVER_READY = "pi-provider-compaction:usage-observer-ready";
6
+ type TargetModel = NonNullable<ExtensionContext["model"]>;
7
+ type ModelKey = Pick<TargetModel, "provider" | "id">;
8
+
9
+ export interface UsageLease {
10
+ provider: string;
11
+ api: TargetModel["api"];
12
+ isInstalled(): boolean;
13
+ remove(): void;
14
+ }
15
+
16
+ export interface UsageRequest {
17
+ context: ExtensionContext;
18
+ model: TargetModel;
19
+ priceFor(model: ModelKey): (timestamp: number, usage: Usage) => void;
20
+ respond(lease: UsageLease | undefined): void;
21
+ handled?: boolean;
22
+ }
23
+
24
+ // Shared with foreground fallback and separately loaded copies of the extension.
25
+ // Unchanged decisions (1x, disabled, uncovered) are handled too: a later config
26
+ // refresh must not reprice a request whose decision was sampled at dispatch.
27
+ const PRICED_USAGE = Symbol.for("pi-peak-hours.priced-usage");
28
+ const shared = globalThis as typeof globalThis & { [PRICED_USAGE]?: WeakSet<object> };
29
+ export const handledUsage = shared[PRICED_USAGE] ??= new WeakSet<object>();
30
+
31
+ export function applyMultiplier(cost: Usage["cost"], multiplier: number): Usage["cost"] {
32
+ const scaled = {
33
+ ...cost,
34
+ input: cost.input * multiplier,
35
+ output: cost.output * multiplier,
36
+ cacheRead: cost.cacheRead * multiplier,
37
+ cacheWrite: cost.cacheWrite * multiplier,
38
+ };
39
+ scaled.total = scaled.input + scaled.output + scaled.cacheRead + scaled.cacheWrite;
40
+ return scaled;
41
+ }
42
+
43
+ interface PricingRules {
44
+ covers(model: ModelKey): boolean;
45
+ /** Called at dispatch; return a timestamp selector with no mutable config. */
46
+ snapshot(model: ModelKey): (timestamp: number) => number;
47
+ }
48
+
49
+ function leaseKey(target: Pick<TargetModel, "provider" | "api">): string {
50
+ return JSON.stringify([target.provider, target.api]);
51
+ }
52
+
53
+ /** Usage-only client of the companion's safe shared provider dispatcher. */
54
+ export function requestPricing(pi: ExtensionAPI, rules: PricingRules, onRetire: () => void) {
55
+ let owner: { context: ExtensionContext; sessionId: string; revision: number; available: readonly TargetModel[] } | undefined;
56
+ const leases = new Map<string, UsageLease>();
57
+ let unsubscribeReady: (() => void) | undefined;
58
+ let unregisterCleanup: (() => void) | undefined;
59
+
60
+ function owns(context: ExtensionContext): boolean {
61
+ return owner !== undefined && context.modelRegistry === owner.context.modelRegistry &&
62
+ context.sessionManager.getSessionId() === owner.sessionId;
63
+ }
64
+
65
+ function stop(): void {
66
+ owner = undefined;
67
+ unsubscribeReady?.();
68
+ unsubscribeReady = undefined;
69
+ unregisterCleanup?.();
70
+ unregisterCleanup = undefined;
71
+ for (const lease of leases.values()) lease.remove();
72
+ leases.clear();
73
+ onRetire();
74
+ }
75
+
76
+ function priceFor(model: ModelKey): (timestamp: number, usage: Usage) => void {
77
+ const selectMultiplier = rules.snapshot(model);
78
+ return (timestamp, usage) => {
79
+ if (handledUsage.has(usage)) return;
80
+ handledUsage.add(usage);
81
+ const multiplier = selectMultiplier(timestamp);
82
+ if (multiplier !== 1) usage.cost = applyMultiplier(usage.cost, multiplier);
83
+ };
84
+ }
85
+
86
+ async function reconcile(context: ExtensionContext, model = context.model): Promise<void> {
87
+ if (!owns(context) || !owner) return;
88
+ const current = owner;
89
+ current.context = context;
90
+ const revision = ++current.revision;
91
+ const live = () => owner === current && current.revision === revision;
92
+ for (const [key, lease] of leases) {
93
+ if (!lease.isInstalled()) { lease.remove(); leases.delete(key); }
94
+ }
95
+ const tryModel = (candidate: TargetModel) => {
96
+ if (!live() || !rules.covers(candidate) || leases.has(leaseKey(candidate))) return;
97
+ const request: UsageRequest = {
98
+ context: current.context,
99
+ model: candidate,
100
+ priceFor,
101
+ respond(lease) {
102
+ if (!lease) return;
103
+ const key = leaseKey(lease);
104
+ if (!live() || leases.has(key) || !lease.isInstalled()) { lease.remove(); return; }
105
+ leases.set(key, lease);
106
+ },
107
+ };
108
+ pi.events.emit(OBSERVE_USAGE, request);
109
+ };
110
+ // Do not await catalog/auth discovery before covering the selected model:
111
+ // the companion's ready notification is synchronous and can precede dispatch.
112
+ if (model) tryModel(model);
113
+ // A source-first start can answer immediately. A client-first start has
114
+ // already discovered available models by the time the source says ready;
115
+ // retry those synchronously too, before any auxiliary request can dispatch.
116
+ for (const candidate of current.available) tryModel(candidate);
117
+ try {
118
+ const available = await context.modelRegistry.getAvailable();
119
+ if (!live()) return;
120
+ current.available = available;
121
+ // A lease covers only its provider/API pair. Keep trying other APIs
122
+ // even after one succeeds, and other candidates when a lease fails.
123
+ for (const candidate of available) tryModel(candidate);
124
+ } catch { /* Optional discovery failure leaves foreground fallback usable. */ }
125
+ // Keep acquired leases until retirement, even after schedule removal, so
126
+ // requests already in flight can finish with their dispatch snapshots.
127
+ }
128
+
129
+ return {
130
+ async start(context: ExtensionContext): Promise<void> {
131
+ stop();
132
+ const sessionId = context.sessionManager.getSessionId();
133
+ owner = { context, sessionId, revision: 0, available: [] };
134
+ unsubscribeReady = pi.events.on(USAGE_OBSERVER_READY, (value) => {
135
+ const ready = value as { context?: ExtensionContext } | undefined;
136
+ if (ready?.context && owns(ready.context) && owner) void reconcile(owner.context);
137
+ });
138
+ unregisterCleanup = registerSessionResourceCleanup((disposedId) => {
139
+ if (disposedId === undefined || disposedId === sessionId) stop();
140
+ });
141
+ await reconcile(context);
142
+ },
143
+ reconcile,
144
+ isCurrent: owns,
145
+ stop,
146
+ };
147
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nijaru/pi-peak-hours",
3
- "version": "0.0.6",
3
+ "version": "0.0.7",
4
4
  "type": "module",
5
5
  "description": "Correct pi's recorded cost for providers that bill by time of day, with a peak multiplier in the footer status.",
6
6
  "keywords": [
@@ -24,7 +24,7 @@
24
24
  },
25
25
  "pi": {
26
26
  "extensions": [
27
- "./extensions"
27
+ "./extensions/index.ts"
28
28
  ]
29
29
  },
30
30
  "scripts": {
@@ -34,13 +34,14 @@
34
34
  "check": "bun test && oxlint extensions/ tests/ && tsc --noEmit"
35
35
  },
36
36
  "devDependencies": {
37
- "@earendil-works/pi-ai": "1.3.0",
38
- "@earendil-works/pi-coding-agent": "1.3.0",
37
+ "@earendil-works/pi-ai": "1.4.0",
38
+ "@earendil-works/pi-coding-agent": "1.4.0",
39
39
  "@types/bun": "latest",
40
40
  "oxlint": "latest",
41
41
  "typescript": "^6.0.3"
42
42
  },
43
43
  "peerDependencies": {
44
+ "@earendil-works/pi-ai": "*",
44
45
  "@earendil-works/pi-coding-agent": "*"
45
46
  },
46
47
  "files": [