@nijaru/pi-peak-hours 0.0.3 → 0.0.5
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 +11 -9
- package/extensions/index.ts +27 -54
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -4,9 +4,9 @@ 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
|
+
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. `/peak-hours status` names the schedule behind it.
|
|
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.
|
|
10
10
|
|
|
11
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
|
|
|
@@ -29,13 +29,13 @@ The footer status is a separate line under the stats bar. It shows the model's c
|
|
|
29
29
|
|
|
30
30
|
## Configuration
|
|
31
31
|
|
|
32
|
-
Optional. `~/.pi/agent/extensions/pi-peak-hours.json` overlays the DeepSeek schedule; anything you leave out keeps its default.
|
|
32
|
+
Optional. `~/.pi/agent/extensions/pi-peak-hours.json` overlays the DeepSeek schedule; anything you leave out keeps its default. A `schedules` list replaces the default outright instead of overlaying it.
|
|
33
33
|
|
|
34
34
|
```json
|
|
35
35
|
{
|
|
36
36
|
"active": true,
|
|
37
|
-
"multiplier":
|
|
38
|
-
"outside":
|
|
37
|
+
"multiplier": 1,
|
|
38
|
+
"outside": 0.5,
|
|
39
39
|
"windows": [["01:00", "04:00"], ["06:00", "10:00"]],
|
|
40
40
|
"days": ["mon", "tue", "wed", "thu", "fri"],
|
|
41
41
|
"providers": ["deepseek"],
|
|
@@ -51,7 +51,7 @@ Times are **UTC**. `days` accepts names or `0`–`6` (`0` is Sunday). `providers
|
|
|
51
51
|
"windows": [{ "start": "22:00", "end": "08:00", "multiplier": 0.4 }]
|
|
52
52
|
```
|
|
53
53
|
|
|
54
|
-
**Pick the side that matches your own rate card.** The extension applies the difference from the rate pi recorded; it does not guess which side that is. The built-in
|
|
54
|
+
**Pick the side that matches your own rate card.** The extension applies the difference from the rate pi recorded; it does not guess which side that is. The built-in schedule encodes today's pi.dev catalog — DeepSeek direct at its **peak** rate — and if your catalog changes, or you override rates in `models.json` as below, say so in the config instead of relying on the default. `/peak-hours status` reports what it would charge right now, and the numbers are in the provider's docs.
|
|
55
55
|
|
|
56
56
|
```json
|
|
57
57
|
// Your models.json pins DeepSeek direct to the off-peak rate: scale up at peak.
|
|
@@ -63,7 +63,7 @@ Times are **UTC**. `days` accepts names or `0`–`6` (`0` is Sunday). `providers
|
|
|
63
63
|
|
|
64
64
|
### Other providers
|
|
65
65
|
|
|
66
|
-
A `schedules` list replaces the built-in
|
|
66
|
+
A `schedules` list replaces the built-in schedule with one entry per service. Each entry starts from a clean slate rather than inheriting DeepSeek's windows.
|
|
67
67
|
|
|
68
68
|
**Z.ai coding plan** — the standard credit rate during the weekday peak window, half of it everywhere else (docs.z.ai/devpack). This is inverted relative to DeepSeek: `06:00–10:00` UTC Monday–Friday is the *expensive* side, so the windows match and the multiplier lands below 1.
|
|
69
69
|
|
|
@@ -92,6 +92,8 @@ Note that a coding plan is billed in credits against a subscription, so pi's cos
|
|
|
92
92
|
}
|
|
93
93
|
```
|
|
94
94
|
|
|
95
|
+
**Xiaomi MiMo Token Plan** — subscription credits rather than the pay-as-you-go API (which is flat-rate): the night rate consumes 0.8× credits from 00:00–08:00 Beijing (16:00–24:00 UTC) daily ([docs](https://mimo.mi.com/static/docs/price/token-plan.md)), as a `schedules` entry: `{ "providers": ["xiaomi-token-plan-*"], "windows": [["16:00", "00:00"]], "multiplier": 0.8, "outside": 1 }`.
|
|
96
|
+
|
|
95
97
|
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
98
|
|
|
97
99
|
A malformed field falls back to its default rather than disabling the extension; a malformed file warns and resolves to defaults.
|
|
@@ -100,10 +102,10 @@ A malformed field falls back to its default rather than disabling the extension;
|
|
|
100
102
|
|
|
101
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.
|
|
102
104
|
- **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
|
|
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.
|
|
104
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.
|
|
105
107
|
- **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.
|
|
106
|
-
- **Coverage.** Assistant messages carry the bulk of a session's spend. Compaction and branch-summary usage,
|
|
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.
|
|
107
109
|
|
|
108
110
|
## Stack
|
|
109
111
|
|
package/extensions/index.ts
CHANGED
|
@@ -15,22 +15,22 @@
|
|
|
15
15
|
* HTML export without further plumbing.
|
|
16
16
|
*
|
|
17
17
|
* The footer indicator is scoped to the selected model and quotes the rate that
|
|
18
|
-
* applies to it right now, in dollars per million tokens:
|
|
19
|
-
* $0.30/$1.20/M` at peak
|
|
20
|
-
* recorded scaled by the schedule, so it always agrees
|
|
21
|
-
* extension writes. A session on a provider or model no
|
|
22
|
-
* shows a rate, and switching models re-evaluates it.
|
|
18
|
+
* 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.
|
|
23
23
|
*
|
|
24
24
|
* The indicator is a snapshot of the current hour, and pi fires no event while a
|
|
25
25
|
* session sits idle — so a timer armed at the next window boundary keeps it from
|
|
26
26
|
* quoting a rate that has already expired. See `arm`.
|
|
27
27
|
*
|
|
28
|
-
* Schedules are provider-scoped and there can be several, because the
|
|
29
|
-
*
|
|
30
|
-
* a
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
28
|
+
* Schedules are provider-scoped and there can be several, because the recorded
|
|
29
|
+
* rate sits on a different side of each: pi records DeepSeek direct at its peak
|
|
30
|
+
* rate, so off-peak is a discount (×0.5), while a catalog pinned at the off-peak
|
|
31
|
+
* number would make peak a surcharge (×2). A multiplier above 1 reads as `peak`,
|
|
32
|
+
* below 1 as `off-peak`, and the footer names the direction so a discount is
|
|
33
|
+
* never reported as a peak.
|
|
34
34
|
*
|
|
35
35
|
* Design constraints:
|
|
36
36
|
*
|
|
@@ -40,10 +40,11 @@
|
|
|
40
40
|
* the start; `message_end` gives the end.
|
|
41
41
|
* - A schedule declares the multiplier INSIDE its windows and `outside` for
|
|
42
42
|
* every other hour. Which side pi's recorded rate sits on is not assumed:
|
|
43
|
-
* encode it in the config, and only the difference is applied.
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
43
|
+
* encode it in the config, and only the difference is applied. Only DeepSeek
|
|
44
|
+
* direct has a built-in schedule: reseller routes are priced per key at flat
|
|
45
|
+
* rates with no time-of-day billing (OpenRouter's DeepSeek routes publish no
|
|
46
|
+
* `pricing.overrides`), so a default multiplier would corrupt their cost
|
|
47
|
+
* instead of correcting it.
|
|
47
48
|
* - A message is never scaled twice. Retries and overflow recovery can deliver
|
|
48
49
|
* the same message object again, so adjustments are guarded by identity.
|
|
49
50
|
* - Only the four cost fields are touched, and `total` is recomputed from
|
|
@@ -119,10 +120,10 @@ export interface ConfigFile extends ScheduleConfig {
|
|
|
119
120
|
|
|
120
121
|
export const DAY_NAMES = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"] as const;
|
|
121
122
|
|
|
122
|
-
/** DeepSeek
|
|
123
|
+
/** Stock Pi rates DeepSeek direct at peak; halve those rates outside its weekday windows. */
|
|
123
124
|
export const DEFAULT_SCHEDULE: Schedule = {
|
|
124
|
-
multiplier:
|
|
125
|
-
outside:
|
|
125
|
+
multiplier: 1,
|
|
126
|
+
outside: 0.5,
|
|
126
127
|
windows: [
|
|
127
128
|
{ start: 60, end: 240 },
|
|
128
129
|
{ start: 360, end: 600 },
|
|
@@ -132,26 +133,6 @@ export const DEFAULT_SCHEDULE: Schedule = {
|
|
|
132
133
|
models: ["*"],
|
|
133
134
|
};
|
|
134
135
|
|
|
135
|
-
/**
|
|
136
|
-
* The same model resold by OpenRouter, which pi's catalog rates at the off-peak
|
|
137
|
-
* price while its own `deepseek` provider is rated at peak. Without this the
|
|
138
|
-
* route silently keeps pi's flat rate.
|
|
139
|
-
*/
|
|
140
|
-
export const OPENROUTER_SCHEDULE: Schedule = {
|
|
141
|
-
multiplier: 2,
|
|
142
|
-
outside: 1,
|
|
143
|
-
windows: [
|
|
144
|
-
{ start: 60, end: 240 },
|
|
145
|
-
{ start: 360, end: 600 },
|
|
146
|
-
],
|
|
147
|
-
days: [1, 2, 3, 4, 5],
|
|
148
|
-
providers: ["openrouter"],
|
|
149
|
-
models: ["deepseek/*"],
|
|
150
|
-
};
|
|
151
|
-
|
|
152
|
-
/** What a config that says nothing about schedules resolves to. */
|
|
153
|
-
export const DEFAULT_SCHEDULES: Schedule[] = [DEFAULT_SCHEDULE, OPENROUTER_SCHEDULE];
|
|
154
|
-
|
|
155
136
|
/**
|
|
156
137
|
* Base for schedules listed explicitly in `schedules`: no windows, no
|
|
157
138
|
* correction, every provider and model. An entry supplies its own shape rather
|
|
@@ -261,16 +242,14 @@ function parseDays(value: unknown): number[] | undefined {
|
|
|
261
242
|
|
|
262
243
|
/**
|
|
263
244
|
* Every schedule the config describes. An explicit `schedules` list replaces the
|
|
264
|
-
*
|
|
265
|
-
* built-in OpenRouter route once it names its own providers.
|
|
245
|
+
* default outright; a flat config overlays the DeepSeek one.
|
|
266
246
|
*/
|
|
267
247
|
export function resolveSchedules(config: ConfigFile): Schedule[] {
|
|
268
248
|
if (Array.isArray(config.schedules)) {
|
|
269
249
|
const entries = config.schedules.filter(isRecord).map((entry) => resolveSchedule(entry, NEUTRAL_SCHEDULE));
|
|
270
250
|
if (entries.length > 0) return entries;
|
|
271
251
|
}
|
|
272
|
-
|
|
273
|
-
return config.providers === undefined ? [schedule, OPENROUTER_SCHEDULE] : [schedule];
|
|
252
|
+
return [resolveSchedule(config)];
|
|
274
253
|
}
|
|
275
254
|
|
|
276
255
|
/** Whether a window covers the given instant, including windows that wrap midnight. */
|
|
@@ -470,7 +449,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
470
449
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
471
450
|
}
|
|
472
451
|
|
|
473
|
-
/**
|
|
452
|
+
/** Missing or malformed config uses defaults; parsing failures are reported. */
|
|
474
453
|
export function readConfig(path: string): ConfigFile | undefined {
|
|
475
454
|
if (!existsSync(path)) return {};
|
|
476
455
|
try {
|
|
@@ -490,7 +469,7 @@ export function readConfig(path: string): ConfigFile | undefined {
|
|
|
490
469
|
/** Persist `active` while preserving keys this version does not model. */
|
|
491
470
|
export function writeActive(path: string, active: boolean): void {
|
|
492
471
|
try {
|
|
493
|
-
const existing =
|
|
472
|
+
const existing = readConfig(path) ?? {};
|
|
494
473
|
const merged = isRecord(existing) ? { ...existing, active } : { active };
|
|
495
474
|
mkdirSync(dirname(path), { recursive: true });
|
|
496
475
|
writeFileSync(path, `${JSON.stringify(merged, null, 2)}\n`, "utf8");
|
|
@@ -551,7 +530,6 @@ export default function peakHours(pi: ExtensionAPI, options: PeakHoursOptions =
|
|
|
551
530
|
let schedules = resolveSchedules(config);
|
|
552
531
|
let active = parseActive(config.active);
|
|
553
532
|
const adjusted = adjustedMessages();
|
|
554
|
-
let pendingStart: number | undefined;
|
|
555
533
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
556
534
|
|
|
557
535
|
function refresh(): void {
|
|
@@ -634,7 +612,6 @@ export default function peakHours(pi: ExtensionAPI, options: PeakHoursOptions =
|
|
|
634
612
|
});
|
|
635
613
|
|
|
636
614
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
637
|
-
pendingStart = undefined;
|
|
638
615
|
disarm();
|
|
639
616
|
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
640
617
|
});
|
|
@@ -649,20 +626,16 @@ export default function peakHours(pi: ExtensionAPI, options: PeakHoursOptions =
|
|
|
649
626
|
updateStatus(ctx);
|
|
650
627
|
});
|
|
651
628
|
|
|
652
|
-
pi.on("message_start", async (event) => {
|
|
653
|
-
if (event.message.role === "assistant") pendingStart = Date.now();
|
|
654
|
-
});
|
|
655
|
-
|
|
656
629
|
pi.on("message_end", async (event, ctx) => {
|
|
657
630
|
const message = event.message;
|
|
658
631
|
if (message.role !== "assistant") return;
|
|
659
632
|
if (adjusted.has(message)) return;
|
|
660
633
|
if (!message.usage?.cost) return;
|
|
661
634
|
|
|
662
|
-
//
|
|
663
|
-
//
|
|
664
|
-
|
|
665
|
-
|
|
635
|
+
// Pi providers stamp their output at invocation, before HTTP dispatch.
|
|
636
|
+
// This clock belongs to the foreground response even when the global
|
|
637
|
+
// payload hook is replayed by background cache warming.
|
|
638
|
+
const startedAt = message.timestamp;
|
|
666
639
|
|
|
667
640
|
if (!active) {
|
|
668
641
|
updateStatus(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.5",
|
|
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": "
|
|
38
|
-
"@earendil-works/pi-coding-agent": "
|
|
37
|
+
"@earendil-works/pi-ai": "1.2.0",
|
|
38
|
+
"@earendil-works/pi-coding-agent": "1.2.0",
|
|
39
39
|
"@types/bun": "latest",
|
|
40
40
|
"oxlint": "latest",
|
|
41
41
|
"typescript": "^6.0.3"
|