@nijaru/pi-peak-hours 0.0.6 → 0.0.8
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 +17 -10
- package/extensions/index.ts +71 -67
- package/extensions/request-pricing.ts +147 -0
- package/package.json +5 -4
package/README.md
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
# pi-peak-hours
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
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
|
|
11
|
+
The status line quotes the selected model's scheduled catalog estimate — `· $0.30/$1.20/M` during a peak window, `· ▼ 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
|
-
|
|
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
|
|
@@ -96,20 +102,21 @@ Note that a coding plan is billed in credits against a subscription, so pi's cos
|
|
|
96
102
|
|
|
97
103
|
OpenRouter publishes the same windows machine-readably for models it hosts (`pricing.overrides` with `utc_days` / `utc_start` / `utc_end` on `https://openrouter.ai/api/v1/models`), but this extension reads its schedule from config rather than the network: the correction is a rate difference, not a price lookup, and a fetch would add a failure mode to a handler that must never throw.
|
|
98
104
|
|
|
99
|
-
A malformed field falls back to its default rather than disabling the extension; a malformed file warns and resolves to defaults.
|
|
105
|
+
A malformed field falls back to its default rather than disabling the extension; a malformed file warns and resolves to defaults. `/peak-hours on|off` refuses to rewrite a malformed, unreadable, or non-object config and reports that the save failed, leaving the existing file untouched.
|
|
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
|
|
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`.
|
|
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
|
|
106
|
-
- **Scaled once.**
|
|
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.**
|
|
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.
|
|
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
|
|
package/extensions/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* pi-peak-hours —
|
|
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
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
18
|
+
* schedule, `· $0.30/$1.20/M` at peak and
|
|
19
|
+
* `· ▼ 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.
|
|
40
|
-
*
|
|
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
|
|
56
|
-
*
|
|
54
|
+
* - Unknown or malformed configuration falls back to the defaults with a
|
|
55
|
+
* warning; no path throws from a message handler.
|
|
57
56
|
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
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
|
/**
|
|
@@ -428,29 +423,12 @@ export function statusIndicator(
|
|
|
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
|
|
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;
|
|
@@ -466,16 +444,16 @@ export function readConfig(path: string): ConfigFile | undefined {
|
|
|
466
444
|
}
|
|
467
445
|
}
|
|
468
446
|
|
|
469
|
-
/**
|
|
470
|
-
export function writeActive(path: string, active: boolean):
|
|
447
|
+
/** Refuse to replace unreadable/malformed config; preserve unknown valid keys. */
|
|
448
|
+
export function writeActive(path: string, active: boolean): boolean {
|
|
471
449
|
try {
|
|
472
|
-
const
|
|
473
|
-
|
|
450
|
+
const config: unknown = existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) : {};
|
|
451
|
+
if (!isRecord(config)) return false;
|
|
474
452
|
mkdirSync(dirname(path), { recursive: true });
|
|
475
|
-
writeFileSync(path, `${JSON.stringify(
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
453
|
+
writeFileSync(path, `${JSON.stringify({ ...config, active }, null, 2)}\n`, "utf8");
|
|
454
|
+
return true;
|
|
455
|
+
} catch {
|
|
456
|
+
return false;
|
|
479
457
|
}
|
|
480
458
|
}
|
|
481
459
|
|
|
@@ -527,14 +505,23 @@ function adjustedMessages(): WeakSet<object> {
|
|
|
527
505
|
|
|
528
506
|
export default function peakHours(pi: ExtensionAPI, options: PeakHoursOptions = {}): void {
|
|
529
507
|
const configPath = options.configPath ?? CONFIG_PATH;
|
|
530
|
-
let config = readConfig(configPath)
|
|
508
|
+
let config = readConfig(configPath);
|
|
531
509
|
let schedules = resolveSchedules(config);
|
|
532
510
|
let active = parseActive(config.active);
|
|
533
511
|
const adjusted = adjustedMessages();
|
|
534
512
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
513
|
+
const pricing = requestPricing(pi, {
|
|
514
|
+
covers: (model) => scheduleFor(schedules, model.provider, model.id) !== undefined,
|
|
515
|
+
snapshot(model) {
|
|
516
|
+
// Refresh replaces schedules rather than mutating them. Capturing this
|
|
517
|
+
// resolved schedule and toggle freezes the decision for this request.
|
|
518
|
+
const schedule = active ? scheduleFor(schedules, model.provider, model.id) : undefined;
|
|
519
|
+
return (timestamp) => schedule ? multiplierAt(new Date(timestamp), schedule) : 1;
|
|
520
|
+
},
|
|
521
|
+
}, disarm);
|
|
535
522
|
|
|
536
523
|
function refresh(): void {
|
|
537
|
-
config = readConfig(configPath)
|
|
524
|
+
config = readConfig(configPath);
|
|
538
525
|
schedules = resolveSchedules(config);
|
|
539
526
|
active = parseActive(config.active);
|
|
540
527
|
}
|
|
@@ -562,7 +549,7 @@ export default function peakHours(pi: ExtensionAPI, options: PeakHoursOptions =
|
|
|
562
549
|
|
|
563
550
|
const next = nextTransitionAt(now, schedule);
|
|
564
551
|
if (!next) return;
|
|
565
|
-
//
|
|
552
|
+
// Clamped, so a boundary crossed between reading the clock and arming the
|
|
566
553
|
// timer cannot spin it; unreferenced, so a pending window never keeps pi
|
|
567
554
|
// alive after its work is done.
|
|
568
555
|
timer = setTimeout(() => {
|
|
@@ -595,30 +582,34 @@ export default function peakHours(pi: ExtensionAPI, options: PeakHoursOptions =
|
|
|
595
582
|
ctx.ui.notify(`Peak hours: not applied to ${model.provider}/${model.id}. ${describeAll(schedules)}`, "info");
|
|
596
583
|
return;
|
|
597
584
|
}
|
|
598
|
-
const
|
|
585
|
+
const now = new Date();
|
|
586
|
+
const multiplier = multiplierAt(now, schedule);
|
|
599
587
|
const rate = describeRate(model.cost, multiplier);
|
|
600
|
-
const
|
|
601
|
-
|
|
602
|
-
? `peak ×${multiplier} right now`
|
|
603
|
-
: multiplier < 1
|
|
604
|
-
? `off-peak ×${multiplier} right now`
|
|
605
|
-
: "no correction right now";
|
|
588
|
+
const rateState = rateStateAt(now, schedule);
|
|
589
|
+
const state = rateState === "none" ? "no correction right now" : `${rateState} ×${multiplier} right now`;
|
|
606
590
|
const detail = rate ? `${state} (${rate})` : state;
|
|
607
|
-
ctx.ui.notify(`Peak hours: ${detail}. ${describeSchedule(schedule)}
|
|
591
|
+
ctx.ui.notify(`Peak hours estimate: ${detail}. ${describeSchedule(schedule)}. Costs are not an invoice.`, "info");
|
|
608
592
|
}
|
|
609
593
|
|
|
610
594
|
pi.on("session_start", async (_event, ctx) => {
|
|
611
595
|
refresh();
|
|
612
|
-
|
|
596
|
+
await pricing.start(ctx);
|
|
597
|
+
if (pricing.isCurrent(ctx)) updateStatus(ctx);
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
pi.on("before_agent_start", async (_event, ctx) => {
|
|
601
|
+
await pricing.reconcile(ctx);
|
|
613
602
|
});
|
|
614
603
|
|
|
615
604
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
616
|
-
|
|
605
|
+
pricing.stop();
|
|
617
606
|
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
618
607
|
});
|
|
619
608
|
|
|
620
609
|
pi.on("model_select", async (event, ctx) => {
|
|
621
|
-
|
|
610
|
+
refresh();
|
|
611
|
+
await pricing.reconcile(ctx, event.model);
|
|
612
|
+
if (pricing.isCurrent(ctx)) updateStatus(ctx, event.model);
|
|
622
613
|
});
|
|
623
614
|
|
|
624
615
|
// The window boundary can pass between messages, and the footer quotes a price
|
|
@@ -632,6 +623,10 @@ export default function peakHours(pi: ExtensionAPI, options: PeakHoursOptions =
|
|
|
632
623
|
if (message.role !== "assistant") return;
|
|
633
624
|
if (adjusted.has(message)) return;
|
|
634
625
|
if (!message.usage?.cost) return;
|
|
626
|
+
if (handledUsage.has(message.usage)) {
|
|
627
|
+
updateStatus(ctx);
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
635
630
|
|
|
636
631
|
// Pi providers stamp their output at invocation, before HTTP dispatch.
|
|
637
632
|
// This clock belongs to the foreground response even when the global
|
|
@@ -655,6 +650,7 @@ export default function peakHours(pi: ExtensionAPI, options: PeakHoursOptions =
|
|
|
655
650
|
}
|
|
656
651
|
|
|
657
652
|
adjusted.add(message);
|
|
653
|
+
handledUsage.add(message.usage);
|
|
658
654
|
updateStatus(ctx);
|
|
659
655
|
const replacement = {
|
|
660
656
|
...message,
|
|
@@ -663,10 +659,11 @@ export default function peakHours(pi: ExtensionAPI, options: PeakHoursOptions =
|
|
|
663
659
|
cost: applyMultiplier(message.usage.cost, multiplier),
|
|
664
660
|
},
|
|
665
661
|
};
|
|
666
|
-
// pi
|
|
667
|
-
//
|
|
668
|
-
// replays the returned value instead.
|
|
662
|
+
// pi consumes the returned replacement, so the original identity carries
|
|
663
|
+
// the guard for a retry that re-delivers it; registering the replacement
|
|
664
|
+
// covers a caller that replays the returned value instead.
|
|
669
665
|
adjusted.add(replacement);
|
|
666
|
+
handledUsage.add(replacement.usage);
|
|
670
667
|
return { message: replacement };
|
|
671
668
|
});
|
|
672
669
|
|
|
@@ -681,6 +678,8 @@ export default function peakHours(pi: ExtensionAPI, options: PeakHoursOptions =
|
|
|
681
678
|
const arg = args.trim().toLowerCase();
|
|
682
679
|
if (!arg || arg === "status") {
|
|
683
680
|
refresh();
|
|
681
|
+
await pricing.reconcile(ctx);
|
|
682
|
+
if (!pricing.isCurrent(ctx)) return;
|
|
684
683
|
updateStatus(ctx);
|
|
685
684
|
notifyStatus(ctx);
|
|
686
685
|
return;
|
|
@@ -689,8 +688,13 @@ export default function peakHours(pi: ExtensionAPI, options: PeakHoursOptions =
|
|
|
689
688
|
ctx.ui.notify("Usage: /peak-hours [on|off|status]", "error");
|
|
690
689
|
return;
|
|
691
690
|
}
|
|
692
|
-
writeActive(configPath, arg === "on")
|
|
691
|
+
if (!writeActive(configPath, arg === "on")) {
|
|
692
|
+
ctx.ui.notify(`Peak hours: failed to save configuration at ${configPath}; keeping the previous setting.`, "error");
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
693
695
|
refresh();
|
|
696
|
+
await pricing.reconcile(ctx);
|
|
697
|
+
if (!pricing.isCurrent(ctx)) return;
|
|
694
698
|
updateStatus(ctx);
|
|
695
699
|
notifyStatus(ctx);
|
|
696
700
|
},
|
|
@@ -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.
|
|
3
|
+
"version": "0.0.8",
|
|
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.
|
|
38
|
-
"@earendil-works/pi-coding-agent": "1.
|
|
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": [
|