@nijaru/pi-peak-hours 0.0.2 → 0.0.4
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 +7 -6
- package/extensions/index.ts +119 -4
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -4,11 +4,11 @@ Correct pi's recorded cost for providers that bill by time of day, and show the
|
|
|
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
|
+
Both built-in entries follow what DeepSeek advertises: ×2 inside those windows and ×1 outside, because the published rate is the off-peak one and peak is a surcharge on it. Since the extension scales whatever rate pi recorded, an entry is right only where that rate really is the off-peak one — true of the OpenRouter route, which pi rates at `0.15/0.60`, and of the direct provider wherever `models.json` pins it to off-peak. Stock pi.dev rates direct as `deepseek-flash` at `0.30/1.20`, its peak number, so the configuration below inverts that one entry; the pair exists because each entry is scoped, covering the DeepSeek models on both routes without claiming unrelated OpenRouter models. Naming your own providers replaces both.
|
|
8
8
|
|
|
9
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. `/peak-hours status` names the schedule behind it.
|
|
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. 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 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.
|
|
12
12
|
|
|
13
13
|
## Install
|
|
14
14
|
|
|
@@ -25,7 +25,7 @@ Or copy `extensions/index.ts` to `~/.pi/agent/extensions/` for a single-machine
|
|
|
25
25
|
/peak-hours on|off # toggle correction; persists to pi-peak-hours.json
|
|
26
26
|
```
|
|
27
27
|
|
|
28
|
-
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, so a
|
|
28
|
+
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
29
|
|
|
30
30
|
## Configuration
|
|
31
31
|
|
|
@@ -94,15 +94,16 @@ Note that a coding plan is billed in credits against a subscription, so pi's cos
|
|
|
94
94
|
|
|
95
95
|
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.
|
|
96
96
|
|
|
97
|
-
A malformed field falls back to its default rather than disabling the extension; a malformed file warns
|
|
97
|
+
A malformed field falls back to its default rather than disabling the extension; a malformed file warns and resolves to defaults.
|
|
98
98
|
|
|
99
99
|
## Design notes
|
|
100
100
|
|
|
101
101
|
- **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.
|
|
102
|
-
- **
|
|
102
|
+
- **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.
|
|
103
|
+
- **Rate by request start.** DeepSeek bills by arrival time, so the multiplier is chosen from the logical request start captured in `before_provider_request`, not `message_end`. `message_start` only fires once the response begins, so it would misprice a request that spans a window boundary.
|
|
103
104
|
- **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.
|
|
104
105
|
- **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.
|
|
105
|
-
- **Coverage.** Assistant messages carry the bulk of a session's spend. Compaction and branch-summary usage,
|
|
106
|
+
- **Coverage.** 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.
|
|
106
107
|
|
|
107
108
|
## Stack
|
|
108
109
|
|
package/extensions/index.ts
CHANGED
|
@@ -21,6 +21,10 @@
|
|
|
21
21
|
* extension writes. A session on a provider or model no schedule covers never
|
|
22
22
|
* shows a rate, and switching models re-evaluates it.
|
|
23
23
|
*
|
|
24
|
+
* The indicator is a snapshot of the current hour, and pi fires no event while a
|
|
25
|
+
* session sits idle — so a timer armed at the next window boundary keeps it from
|
|
26
|
+
* quoting a rate that has already expired. See `arm`.
|
|
27
|
+
*
|
|
24
28
|
* Schedules are provider-scoped and there can be several, because the shape of
|
|
25
29
|
* the correction differs: DeepSeek's list price is its off-peak rate, so peak is
|
|
26
30
|
* a surcharge (×2), while Z.ai's coding plan and Alibaba's night discount are
|
|
@@ -165,6 +169,14 @@ export const NEUTRAL_SCHEDULE: Schedule = {
|
|
|
165
169
|
|
|
166
170
|
export const DEFAULT_ACTIVE = true;
|
|
167
171
|
|
|
172
|
+
/**
|
|
173
|
+
* Only an explicit boolean is a toggle. A malformed `active` falls back like
|
|
174
|
+
* every other field, so a typo disables nothing silently.
|
|
175
|
+
*/
|
|
176
|
+
export function parseActive(value: unknown): boolean {
|
|
177
|
+
return typeof value === "boolean" ? value : DEFAULT_ACTIVE;
|
|
178
|
+
}
|
|
179
|
+
|
|
168
180
|
/** "HH:MM" in UTC to minutes from midnight, or undefined when unparseable. */
|
|
169
181
|
export function parseClock(value: unknown): number | undefined {
|
|
170
182
|
if (typeof value !== "string") return undefined;
|
|
@@ -285,6 +297,60 @@ export function isPeakAt(startedAt: Date, schedule: Schedule): boolean {
|
|
|
285
297
|
return multiplierAt(startedAt, schedule) > 1;
|
|
286
298
|
}
|
|
287
299
|
|
|
300
|
+
const MS_PER_MINUTE = 60_000;
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Every instant the multiplier could move: the edges of each window, over a week
|
|
304
|
+
* of days plus a day of slack so a window that wraps midnight always contributes
|
|
305
|
+
* its closing edge and every weekday appears at least once. Sorted, and only
|
|
306
|
+
* ahead of `now`.
|
|
307
|
+
*/
|
|
308
|
+
function candidateEdges(now: Date, schedule: Schedule): number[] {
|
|
309
|
+
const nowMs = now.getTime();
|
|
310
|
+
const edges: number[] = [];
|
|
311
|
+
|
|
312
|
+
for (let offset = 0; offset <= 8; offset++) {
|
|
313
|
+
const midnight = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + offset);
|
|
314
|
+
const day = new Date(midnight).getUTCDay();
|
|
315
|
+
const previousDay = (day + 6) % 7;
|
|
316
|
+
|
|
317
|
+
for (const window of schedule.windows) {
|
|
318
|
+
// A window opens on its own day and closes on that day, or on the next one
|
|
319
|
+
// when it runs past midnight.
|
|
320
|
+
const closesOn = window.end > window.start ? day : previousDay;
|
|
321
|
+
if (schedule.days.includes(day)) edges.push(midnight + window.start * MS_PER_MINUTE);
|
|
322
|
+
if (schedule.days.includes(closesOn)) edges.push(midnight + window.end * MS_PER_MINUTE);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return edges.filter((edge) => edge > nowMs).sort((left, right) => left - right);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* The earliest instant after `now` at which the schedule's multiplier changes,
|
|
331
|
+
* or undefined when it never does.
|
|
332
|
+
*
|
|
333
|
+
* A window edge is not enough: windows can overlap, so the edge that opens one
|
|
334
|
+
* may land inside another and leave the rate where it was. Testing the
|
|
335
|
+
* multiplier on both sides of each edge keeps the footer's trigger and the
|
|
336
|
+
* footer's value on the same function, and leaves a schedule whose multiplier
|
|
337
|
+
* never moves — a `multiplier` equal to its `outside`, say — with no timer at
|
|
338
|
+
* all.
|
|
339
|
+
*/
|
|
340
|
+
export function nextTransitionAt(now: Date, schedule: Schedule): Date | undefined {
|
|
341
|
+
if (schedule.windows.length === 0) return undefined;
|
|
342
|
+
|
|
343
|
+
// Edges are ordered, so the multiplier is constant between consecutive ones:
|
|
344
|
+
// the first edge that moves it is the first change after `now`.
|
|
345
|
+
for (const edge of candidateEdges(now, schedule)) {
|
|
346
|
+
if (multiplierAt(new Date(edge), schedule) !== multiplierAt(new Date(edge - 1), schedule)) {
|
|
347
|
+
return new Date(edge);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return undefined;
|
|
352
|
+
}
|
|
353
|
+
|
|
288
354
|
/** Glob match supporting `*` only, which is all a provider/model filter needs. */
|
|
289
355
|
export function matchesPattern(pattern: string, value: string): boolean {
|
|
290
356
|
if (pattern === "*") return true;
|
|
@@ -483,18 +549,56 @@ export default function peakHours(pi: ExtensionAPI, options: PeakHoursOptions =
|
|
|
483
549
|
const configPath = options.configPath ?? CONFIG_PATH;
|
|
484
550
|
let config = readConfig(configPath) ?? {};
|
|
485
551
|
let schedules = resolveSchedules(config);
|
|
486
|
-
let active = config.active
|
|
552
|
+
let active = parseActive(config.active);
|
|
487
553
|
const adjusted = adjustedMessages();
|
|
488
554
|
let pendingStart: number | undefined;
|
|
555
|
+
let requestStartMs: number | undefined;
|
|
556
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
489
557
|
|
|
490
558
|
function refresh(): void {
|
|
491
559
|
config = readConfig(configPath) ?? {};
|
|
492
560
|
schedules = resolveSchedules(config);
|
|
493
|
-
active = config.active
|
|
561
|
+
active = parseActive(config.active);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function disarm(): void {
|
|
565
|
+
if (timer === undefined) return;
|
|
566
|
+
clearTimeout(timer);
|
|
567
|
+
timer = undefined;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Keep the footer in step with the window it quotes. pi fires no event while a
|
|
572
|
+
* session sits idle, so a session idle across a boundary would keep describing
|
|
573
|
+
* a rate that has expired. Armed only for a `status` that is on screen, and
|
|
574
|
+
* from the instant that status was computed: `now` is the one clock reading
|
|
575
|
+
* shared by the rate and the boundary that ends it, so the last millisecond of
|
|
576
|
+
* a window cannot arm the timer past its own edge.
|
|
577
|
+
*/
|
|
578
|
+
function arm(ctx: ExtensionContext, model: ModelRate | undefined, status: string | undefined, now: Date): void {
|
|
579
|
+
disarm();
|
|
580
|
+
// Nothing to keep fresh, or no footer to keep fresh in.
|
|
581
|
+
if (status === undefined || !ctx.hasUI || !model) return;
|
|
582
|
+
const schedule = scheduleFor(schedules, model.provider, model.id);
|
|
583
|
+
if (!schedule) return;
|
|
584
|
+
|
|
585
|
+
const next = nextTransitionAt(now, schedule);
|
|
586
|
+
if (!next) return;
|
|
587
|
+
// Floored, so a boundary crossed between reading the clock and arming the
|
|
588
|
+
// timer cannot spin it; unreferenced, so a pending window never keeps pi
|
|
589
|
+
// alive after its work is done.
|
|
590
|
+
timer = setTimeout(() => {
|
|
591
|
+
timer = undefined;
|
|
592
|
+
updateStatus(ctx);
|
|
593
|
+
}, Math.max(1000, next.getTime() - now.getTime()));
|
|
594
|
+
timer.unref?.();
|
|
494
595
|
}
|
|
495
596
|
|
|
496
597
|
function updateStatus(ctx: ExtensionContext, model: ModelRate | undefined = ctx.model): void {
|
|
497
|
-
|
|
598
|
+
const now = new Date();
|
|
599
|
+
const status = statusIndicator(schedules, active, model, now);
|
|
600
|
+
ctx.ui.setStatus(STATUS_KEY, status);
|
|
601
|
+
arm(ctx, model, status, now);
|
|
498
602
|
}
|
|
499
603
|
|
|
500
604
|
function notifyStatus(ctx: ExtensionContext, model: ModelRate | undefined = ctx.model): void {
|
|
@@ -532,6 +636,8 @@ export default function peakHours(pi: ExtensionAPI, options: PeakHoursOptions =
|
|
|
532
636
|
|
|
533
637
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
534
638
|
pendingStart = undefined;
|
|
639
|
+
requestStartMs = undefined;
|
|
640
|
+
disarm();
|
|
535
641
|
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
536
642
|
});
|
|
537
643
|
|
|
@@ -545,8 +651,17 @@ export default function peakHours(pi: ExtensionAPI, options: PeakHoursOptions =
|
|
|
545
651
|
updateStatus(ctx);
|
|
546
652
|
});
|
|
547
653
|
|
|
654
|
+
// The provider bills when the request arrives, and `message_start` is emitted
|
|
655
|
+
// only once the response begins, so capture the logical request start here and
|
|
656
|
+
// fall back to the message clock when no request was observed.
|
|
657
|
+
pi.on("before_provider_request", async () => {
|
|
658
|
+
if (active) requestStartMs = Date.now();
|
|
659
|
+
});
|
|
660
|
+
|
|
548
661
|
pi.on("message_start", async (event) => {
|
|
549
|
-
if (event.message.role
|
|
662
|
+
if (event.message.role !== "assistant") return;
|
|
663
|
+
pendingStart = requestStartMs ?? Date.now();
|
|
664
|
+
requestStartMs = undefined;
|
|
550
665
|
});
|
|
551
666
|
|
|
552
667
|
pi.on("message_end", async (event, ctx) => {
|
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.4",
|
|
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": [
|
|
@@ -34,8 +34,8 @@
|
|
|
34
34
|
"check": "bun test && oxlint extensions/ tests/ && tsc --noEmit"
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|
|
37
|
-
"@earendil-works/pi-ai": "0.
|
|
38
|
-
"@earendil-works/pi-coding-agent": "0.
|
|
37
|
+
"@earendil-works/pi-ai": "0.87.0",
|
|
38
|
+
"@earendil-works/pi-coding-agent": "0.87.0",
|
|
39
39
|
"@types/bun": "latest",
|
|
40
40
|
"oxlint": "latest",
|
|
41
41
|
"typescript": "^6.0.3"
|