@nijaru/pi-peak-hours 0.0.1 → 0.0.2

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
@@ -2,9 +2,13 @@
2
2
 
3
3
  Correct pi's recorded cost for providers that bill by time of day, and show the rate in force in the footer.
4
4
 
5
- DeepSeek is the current example. Its published prices are the **off-peak** rates, and a **2x** multiplier applies during peak windows — `01:00–04:00` and `06:00–10:00` UTC, Monday to Friday, with every other hour (including all weekend) off-peak. 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 running during a peak window understates its cost by up to 2x.
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
- 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. While peak is in force the status shows `▲ peak`; off-peak it clears. The indicator stays generic because the multiplier differs per service — `/peak-hours status` names the schedule and the rate in force.
7
+ Two schedules ship built in, because pi's own catalog is inconsistent about that side: `deepseek/deepseek-flash` direct is listed at its **peak** rate while the same model through OpenRouter is listed at its **off-peak** rate. The DeepSeek schedule therefore scales the OpenRouter route by 2 during peak windows and leaves the direct provider alone; naming your own providers in the config replaces both.
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.
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.
8
12
 
9
13
  ## Install
10
14
 
@@ -21,14 +25,17 @@ Or copy `extensions/index.ts` to `~/.pi/agent/extensions/` for a single-machine
21
25
  /peak-hours on|off # toggle correction; persists to pi-peak-hours.json
22
26
  ```
23
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 window boundary crossed mid-session is picked up by the next turn.
29
+
24
30
  ## Configuration
25
31
 
26
- Optional. `~/.pi/agent/extensions/pi-peak-hours.json` overlays the built-in 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. The OpenRouter route for the same models is covered out of the box, and setting `providers` yourself replaces both built-in schedules with the one you describe.
27
33
 
28
34
  ```json
29
35
  {
30
36
  "active": true,
31
37
  "multiplier": 2,
38
+ "outside": 1,
32
39
  "windows": [["01:00", "04:00"], ["06:00", "10:00"]],
33
40
  "days": ["mon", "tue", "wed", "thu", "fri"],
34
41
  "providers": ["deepseek"],
@@ -36,20 +43,70 @@ Optional. `~/.pi/agent/extensions/pi-peak-hours.json` overlays the built-in Deep
36
43
  }
37
44
  ```
38
45
 
39
- Times are **UTC**. `days` accepts names or `0`–`6` (`0` is Sunday). `providers` and `models` accept `*` globs, so an OpenRouter route (`"providers": ["openrouter"], "models": ["deepseek/*"]`) picks up the same schedule.
46
+ Times are **UTC**. `days` accepts names or `0`–`6` (`0` is Sunday). `providers` and `models` accept `*` globs, so a self-hosted gateway or a reseller (`"providers": ["tokenrouter"], "models": ["deepseek/*"]`) can pick up the same schedule.
47
+
48
+ `multiplier` applies **inside** `windows` and `outside` applies everywhere else. Either may be below 1, because not every provider prices the same way round. A window may carry its own `multiplier`, which wins for that window:
49
+
50
+ ```json
51
+ "windows": [{ "start": "22:00", "end": "08:00", "multiplier": 0.4 }]
52
+ ```
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 pair encodes today's pi.dev catalog — DeepSeek direct at its **peak** rate, the same model through OpenRouter at its **off-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
+
56
+ ```json
57
+ // Your models.json pins DeepSeek direct to the off-peak rate: scale up at peak.
58
+ { "multiplier": 2, "outside": 1 }
59
+
60
+ // Stock pi.dev lists DeepSeek direct at the peak rate: scale down off-peak.
61
+ { "providers": ["deepseek"], "multiplier": 1, "outside": 0.5 }
62
+ ```
63
+
64
+ ### Other providers
65
+
66
+ A `schedules` list replaces the built-in schedules with one entry per service. Each entry starts from a clean slate rather than inheriting DeepSeek's windows.
67
+
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
+
70
+ ```json
71
+ {
72
+ "schedules": [
73
+ { "providers": ["zai"], "windows": [["06:00", "10:00"]], "days": ["mon", "tue", "wed", "thu", "fri"],
74
+ "multiplier": 1, "outside": 0.5 }
75
+ ]
76
+ }
77
+ ```
78
+
79
+ Note that a coding plan is billed in credits against a subscription, so pi's cost for it is a list-price estimate either way.
80
+
81
+ **Alibaba Model Studio (Qwen)** — a limited-time night discount: night is `22:00–08:00` UTC+8 (`14:00–00:00` UTC) and the percentage is quoted per model, so the schedule is per model too. The example below is the published `night 60% off / daytime 20% off` shape.
82
+
83
+ ```json
84
+ {
85
+ "schedules": [
86
+ { "providers": ["qwen"], "models": ["qwen3.8-max"],
87
+ "windows": [
88
+ { "start": "14:00", "end": "00:00", "multiplier": 0.4 },
89
+ { "start": "00:00", "end": "14:00", "multiplier": 0.8 }
90
+ ], "outside": 1 }
91
+ ]
92
+ }
93
+ ```
94
+
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.
40
96
 
41
97
  A malformed field falls back to its default rather than disabling the extension; a malformed file warns once and resolves to defaults.
42
98
 
43
99
  ## Design notes
44
100
 
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.
45
102
  - **Rate by request start.** DeepSeek bills by arrival time, so the multiplier is chosen from the `message_start` timestamp rather than `message_end`. A request spanning a window boundary would otherwise land on the wrong side.
46
- - **Scaled once.** Retries and overflow recovery can re-deliver the same message object, so adjustments are guarded by identity.
103
+ - **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.
47
104
  - **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.
48
105
  - **Coverage.** Assistant messages carry the bulk of a session's spend. Compaction and branch-summary usage, plus token usage reported by tools, also feed the footer but cannot be adjusted after the fact, so totals stay an estimate rather than an invoice.
49
106
 
50
107
  ## Stack
51
108
 
52
- TypeScript, Bun. Pi extension API (`@earendil-works/pi-coding-agent`, `@earendil-works/pi-ai`). No build step — pi loads the extension directly.
109
+ 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.
53
110
 
54
111
  ## Testing
55
112
 
@@ -1,12 +1,12 @@
1
1
  /**
2
2
  * pi-peak-hours — correct recorded cost for providers that bill by time of day.
3
3
  *
4
- * DeepSeek is the clearest example: its published prices are the off-peak
5
- * rates, and a 2x multiplier applies during peak windows (01:00-04:00 and
6
- * 06:00-10:00 UTC, Monday-Friday, as of September 2026). pi has no notion of
7
- * time-of-day pricing — `usage.cost` is computed once from the model's flat
8
- * rates when usage is finalized — so the footer understates such sessions by
9
- * up to 2x while a peak window is open.
4
+ * DeepSeek is the clearest example: a 2x multiplier applies during peak windows
5
+ * (01:00-04:00 and 06:00-10:00 UTC, Monday-Friday, as of September 2026) and the
6
+ * off-peak rate is half of it. pi has no notion of time-of-day pricing —
7
+ * `usage.cost` is computed once from the model's flat rates when usage is
8
+ * finalized — so a session is over- or understated by up to 2x, in whichever
9
+ * direction the recorded rate sits from the billed one.
10
10
  *
11
11
  * This extension rewrites `usage.cost` on the finalized assistant message in
12
12
  * `message_end`, the documented hook for replacing a message. The footer
@@ -14,12 +14,32 @@
14
14
  * corrected number appears in the footer, the per-model breakdown, and the
15
15
  * HTML export without further plumbing.
16
16
  *
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: `· ▲ peak
19
+ * $0.30/$1.20/M` at peak, `· $0.15/$0.60/M` off it. The rate is the one pi
20
+ * recorded scaled by the schedule, so it always agrees with the cost this
21
+ * extension writes. A session on a provider or model no schedule covers never
22
+ * shows a rate, and switching models re-evaluates it.
23
+ *
24
+ * Schedules are provider-scoped and there can be several, because the shape of
25
+ * the correction differs: DeepSeek's list price is its off-peak rate, so peak is
26
+ * a surcharge (×2), while Z.ai's coding plan and Alibaba's night discount are
27
+ * priced the other way round and land below the recorded rate. A multiplier
28
+ * above 1 reads as `peak`, below 1 as `off-peak`, and the footer names the
29
+ * direction so a discount is never reported as a peak.
30
+ *
17
31
  * Design constraints:
18
32
  *
19
33
  * - The rate is chosen from the request's START time, not its end. DeepSeek
20
34
  * bills by when a request arrives, so a request spanning a window boundary
21
35
  * would otherwise be billed on the wrong side of it. `message_start` gives
22
36
  * the start; `message_end` gives the end.
37
+ * - A schedule declares the multiplier INSIDE its windows and `outside` for
38
+ * every other hour. Which side pi's recorded rate sits on is not assumed:
39
+ * encode it in the config, and only the difference is applied. pi's own
40
+ * catalog disagrees with itself here — it lists DeepSeek direct at peak rates
41
+ * and the same model through OpenRouter at off-peak rates — so a schedule that
42
+ * guesses would double-charge one of them.
23
43
  * - A message is never scaled twice. Retries and overflow recovery can deliver
24
44
  * the same message object again, so adjustments are guarded by identity.
25
45
  * - Only the four cost fields are touched, and `total` is recomputed from
@@ -37,7 +57,6 @@
37
57
  */
38
58
 
39
59
  import { getAgentDir, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
40
- import type { AssistantMessage } from "@earendil-works/pi-ai";
41
60
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
42
61
  import { dirname, join } from "node:path";
43
62
 
@@ -48,6 +67,10 @@ export const STATUS_KEY = "peak-hours";
48
67
  * `/peak-hours status`, which names the schedule and the rate in force.
49
68
  */
50
69
  export const STATUS_PEAK = "▲ peak";
70
+ /** Shown when the rate in force is below the recorded one, e.g. an off-peak discount. */
71
+ export const STATUS_OFF_PEAK = "▼ off-peak";
72
+ /** Rates are quoted per million tokens, like every provider rate card. */
73
+ export const RATE_UNIT = "/M";
51
74
  export const COMMAND_PEAK_HOURS = "peak-hours";
52
75
  export const CONFIG_BASENAME = "pi-peak-hours.json";
53
76
  export const CONFIG_PATH = join(getAgentDir(), "extensions", CONFIG_BASENAME);
@@ -56,10 +79,17 @@ export const CONFIG_PATH = join(getAgentDir(), "extensions", CONFIG_BASENAME);
56
79
  export interface ClockWindow {
57
80
  start: number;
58
81
  end: number;
82
+ /** Overrides the schedule multiplier inside this window. */
83
+ multiplier?: number;
59
84
  }
60
85
 
86
+ /**
87
+ * The rate in force inside `windows` versus `outside` them. Either side may be
88
+ * the cheaper one: above 1 is a surcharge, below 1 a discount.
89
+ */
61
90
  export interface Schedule {
62
91
  multiplier: number;
92
+ outside: number;
63
93
  windows: ClockWindow[];
64
94
  /** 0 = Sunday, matching Date#getUTCDay. */
65
95
  days: number[];
@@ -67,20 +97,28 @@ export interface Schedule {
67
97
  models: string[];
68
98
  }
69
99
 
70
- export interface ConfigFile {
71
- active?: boolean;
100
+ /** Everything a single schedule accepts. */
101
+ export interface ScheduleConfig {
72
102
  multiplier?: number;
73
- windows?: [string, string][];
103
+ outside?: number;
104
+ windows?: (readonly [string, string] | { start: string; end: string; multiplier?: number })[];
74
105
  days?: (string | number)[];
75
106
  providers?: string[];
76
107
  models?: string[];
77
108
  }
78
109
 
110
+ export interface ConfigFile extends ScheduleConfig {
111
+ active?: boolean;
112
+ /** Provider-scoped schedules, each resolved on its own rather than over the DeepSeek default. */
113
+ schedules?: ScheduleConfig[];
114
+ }
115
+
79
116
  export const DAY_NAMES = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"] as const;
80
117
 
81
118
  /** DeepSeek's published schedule: peak mornings on UTC weekdays, everything else off-peak. */
82
119
  export const DEFAULT_SCHEDULE: Schedule = {
83
120
  multiplier: 2,
121
+ outside: 1,
84
122
  windows: [
85
123
  { start: 60, end: 240 },
86
124
  { start: 360, end: 600 },
@@ -90,6 +128,41 @@ export const DEFAULT_SCHEDULE: Schedule = {
90
128
  models: ["*"],
91
129
  };
92
130
 
131
+ /**
132
+ * The same model resold by OpenRouter, which pi's catalog rates at the off-peak
133
+ * price while its own `deepseek` provider is rated at peak. Without this the
134
+ * route silently keeps pi's flat rate.
135
+ */
136
+ export const OPENROUTER_SCHEDULE: Schedule = {
137
+ multiplier: 2,
138
+ outside: 1,
139
+ windows: [
140
+ { start: 60, end: 240 },
141
+ { start: 360, end: 600 },
142
+ ],
143
+ days: [1, 2, 3, 4, 5],
144
+ providers: ["openrouter"],
145
+ models: ["deepseek/*"],
146
+ };
147
+
148
+ /** What a config that says nothing about schedules resolves to. */
149
+ export const DEFAULT_SCHEDULES: Schedule[] = [DEFAULT_SCHEDULE, OPENROUTER_SCHEDULE];
150
+
151
+ /**
152
+ * Base for schedules listed explicitly in `schedules`: no windows, no
153
+ * correction, every provider and model. An entry supplies its own shape rather
154
+ * than inheriting DeepSeek's, which would be a surprising default for a
155
+ * different service.
156
+ */
157
+ export const NEUTRAL_SCHEDULE: Schedule = {
158
+ multiplier: 1,
159
+ outside: 1,
160
+ windows: [],
161
+ days: [0, 1, 2, 3, 4, 5, 6],
162
+ providers: ["*"],
163
+ models: ["*"],
164
+ };
165
+
93
166
  export const DEFAULT_ACTIVE = true;
94
167
 
95
168
  /** "HH:MM" in UTC to minutes from midnight, or undefined when unparseable. */
@@ -116,69 +189,100 @@ function parseWindows(value: unknown): ClockWindow[] | undefined {
116
189
  if (!Array.isArray(value)) return undefined;
117
190
  const windows: ClockWindow[] = [];
118
191
  for (const entry of value) {
119
- if (!Array.isArray(entry) || entry.length !== 2) return undefined;
120
- const start = parseClock(entry[0]);
121
- const end = parseClock(entry[1]);
122
- if (start === undefined || end === undefined || start === end) return undefined;
123
- windows.push({ start, end });
192
+ let start: unknown;
193
+ let end: unknown;
194
+ let multiplier: unknown;
195
+ if (Array.isArray(entry) && entry.length === 2) {
196
+ [start, end] = entry;
197
+ } else if (isRecord(entry)) {
198
+ start = entry.start;
199
+ end = entry.end;
200
+ multiplier = entry.multiplier;
201
+ } else {
202
+ return undefined;
203
+ }
204
+ const startMinutes = parseClock(start);
205
+ const endMinutes = parseClock(end);
206
+ if (startMinutes === undefined || endMinutes === undefined || startMinutes === endMinutes) return undefined;
207
+ const window: ClockWindow = { start: startMinutes, end: endMinutes };
208
+ if (multiplier !== undefined) {
209
+ const parsed = parseMultiplier(multiplier);
210
+ if (parsed === undefined) return undefined;
211
+ window.multiplier = parsed;
212
+ }
213
+ windows.push(window);
124
214
  }
125
215
  return windows;
126
216
  }
127
217
 
218
+ /** A positive finite multiplier: above 1 for a surcharge, below 1 for a discount. */
219
+ export function parseMultiplier(value: unknown): number | undefined {
220
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return undefined;
221
+ return value;
222
+ }
223
+
128
224
  function parseStringList(value: unknown): string[] | undefined {
129
225
  if (!Array.isArray(value)) return undefined;
130
226
  const items = value.filter((item): item is string => typeof item === "string" && item.trim() !== "");
131
227
  return items.length === value.length ? items.map((item) => item.trim()) : undefined;
132
228
  }
133
229
 
134
- /** Overlay a parsed config file onto the defaults. Invalid fields fall back to the default. */
135
- export function resolveSchedule(config: ConfigFile): Schedule {
136
- const schedule: Schedule = {
137
- multiplier: DEFAULT_SCHEDULE.multiplier,
138
- windows: DEFAULT_SCHEDULE.windows,
139
- days: DEFAULT_SCHEDULE.days,
140
- providers: DEFAULT_SCHEDULE.providers,
141
- models: DEFAULT_SCHEDULE.models,
230
+ /** Overlay a parsed config onto `base`. Invalid fields keep the base value. */
231
+ export function resolveSchedule(config: ScheduleConfig, base: Schedule = DEFAULT_SCHEDULE): Schedule {
232
+ return {
233
+ multiplier: parseMultiplier(config.multiplier) ?? base.multiplier,
234
+ outside: parseMultiplier(config.outside) ?? base.outside,
235
+ // Copied, so a resolved schedule never aliases an exported default.
236
+ windows: parseWindows(config.windows) ?? base.windows.map((window) => ({ ...window })),
237
+ days: parseDays(config.days) ?? [...base.days],
238
+ providers: parseStringList(config.providers) ?? [...base.providers],
239
+ models: parseStringList(config.models) ?? [...base.models],
142
240
  };
241
+ }
143
242
 
144
- if (typeof config.multiplier === "number" && Number.isFinite(config.multiplier) && config.multiplier >= 1) {
145
- schedule.multiplier = config.multiplier;
146
- }
147
- const windows = parseWindows(config.windows);
148
- if (windows) schedule.windows = windows;
149
- if (Array.isArray(config.days)) {
150
- const days = config.days.map(parseDay);
151
- if (days.every((day): day is number => day !== undefined)) schedule.days = days;
152
- }
153
- const providers = parseStringList(config.providers);
154
- if (providers) schedule.providers = providers;
155
- const models = parseStringList(config.models);
156
- if (models) schedule.models = models;
243
+ /** Day names or numbers, or undefined when the list is absent or partly invalid. */
244
+ function parseDays(value: unknown): number[] | undefined {
245
+ if (!Array.isArray(value)) return undefined;
246
+ const days = value.map(parseDay);
247
+ return days.every((day): day is number => day !== undefined) ? days : undefined;
248
+ }
157
249
 
158
- return schedule;
250
+ /**
251
+ * Every schedule the config describes. An explicit `schedules` list replaces the
252
+ * built-in schedules; a flat config overlays the DeepSeek one and drops the
253
+ * built-in OpenRouter route once it names its own providers.
254
+ */
255
+ export function resolveSchedules(config: ConfigFile): Schedule[] {
256
+ if (Array.isArray(config.schedules)) {
257
+ const entries = config.schedules.filter(isRecord).map((entry) => resolveSchedule(entry, NEUTRAL_SCHEDULE));
258
+ if (entries.length > 0) return entries;
259
+ }
260
+ const schedule = resolveSchedule(config);
261
+ return config.providers === undefined ? [schedule, OPENROUTER_SCHEDULE] : [schedule];
159
262
  }
160
263
 
161
- /** Whether a request that started at `startedAt` falls inside a peak window. */
162
- export function isPeakAt(startedAt: Date, schedule: Schedule): boolean {
264
+ /** Whether a window covers the given instant, including windows that wrap midnight. */
265
+ function windowCovers(window: ClockWindow, startedAt: Date, days: number[]): boolean {
163
266
  const minutes = startedAt.getUTCHours() * 60 + startedAt.getUTCMinutes();
164
267
  const day = startedAt.getUTCDay();
165
268
 
269
+ if (window.end > window.start) return days.includes(day) && minutes >= window.start && minutes < window.end;
270
+ // A window that runs past midnight started on the previous UTC day.
271
+ const previous = (day + 6) % 7;
272
+ return (days.includes(day) && minutes >= window.start) || (days.includes(previous) && minutes < window.end);
273
+ }
274
+
275
+ /** The multiplier in force at `startedAt`: a window's own, the schedule's, or `outside`. */
276
+ export function multiplierAt(startedAt: Date, schedule: Schedule): number {
166
277
  for (const window of schedule.windows) {
167
- if (window.end > window.start) {
168
- if (schedule.days.includes(day) && minutes >= window.start && minutes < window.end) return true;
169
- continue;
170
- }
171
- // A window that runs past midnight started on the previous UTC day.
172
- const previous = (day + 6) % 7;
173
- if (schedule.days.includes(day) && minutes >= window.start) return true;
174
- if (schedule.days.includes(previous) && minutes < window.end) return true;
278
+ if (windowCovers(window, startedAt, schedule.days)) return window.multiplier ?? schedule.multiplier;
175
279
  }
176
-
177
- return false;
280
+ return schedule.outside;
178
281
  }
179
282
 
180
- export function multiplierAt(startedAt: Date, schedule: Schedule): number {
181
- return isPeakAt(startedAt, schedule) ? schedule.multiplier : 1;
283
+ /** Whether the rate in force costs more than the one pi recorded. */
284
+ export function isPeakAt(startedAt: Date, schedule: Schedule): boolean {
285
+ return multiplierAt(startedAt, schedule) > 1;
182
286
  }
183
287
 
184
288
  /** Glob match supporting `*` only, which is all a provider/model filter needs. */
@@ -195,11 +299,91 @@ export function matchesSchedule(schedule: Schedule, provider: string, model: str
195
299
  );
196
300
  }
197
301
 
198
- export interface CostBreakdown {
302
+ /** The provider/model pair a request belongs to, as pi reports it. */
303
+ export interface ModelKey {
304
+ provider: string;
305
+ id: string;
306
+ }
307
+
308
+ /** Per-million-token rates, in the shape pi records them. */
309
+ export interface RateCard {
199
310
  input: number;
200
311
  output: number;
201
312
  cacheRead: number;
202
313
  cacheWrite: number;
314
+ }
315
+
316
+ /** A selected model with the rates pi would charge it at, which the schedule scales. */
317
+ export interface ModelRate extends ModelKey {
318
+ cost: RateCard;
319
+ }
320
+
321
+ /** The first schedule covering a provider/model pair. */
322
+ export function scheduleFor(schedules: Schedule[], provider: string, model: string): Schedule | undefined {
323
+ return schedules.find((schedule) => matchesSchedule(schedule, provider, model));
324
+ }
325
+
326
+ /** Whether the rate in force costs more (`peak`), less (`off-peak`), or the same (`none`). */
327
+ export type RateState = "peak" | "off-peak" | "none";
328
+
329
+ export function rateStateAt(when: Date, schedule: Schedule): RateState {
330
+ const multiplier = multiplierAt(when, schedule);
331
+ if (multiplier > 1) return "peak";
332
+ if (multiplier < 1) return "off-peak";
333
+ return "none";
334
+ }
335
+
336
+ /**
337
+ * The rate in force for a model, e.g. `$0.30/$1.20/M`. Empty when the rate in
338
+ * force is free, which keeps free models out of the footer instead of printing a
339
+ * zero rate.
340
+ */
341
+ export function describeRate(cost: RateCard, multiplier: number): string {
342
+ const input = cost.input * multiplier;
343
+ const output = cost.output * multiplier;
344
+ if (input <= 0 && output <= 0) return "";
345
+ return `$${formatRate(input)}/$${formatRate(output)}${RATE_UNIT}`;
346
+ }
347
+
348
+ /** Up to three decimals without trailing zeros, and finer for a smaller rate. */
349
+ function formatRate(value: number): string {
350
+ for (const decimals of [3, 6]) {
351
+ const text = value.toFixed(decimals).replace(/\.?0+$/, "");
352
+ if (text !== "" && text !== "0") return text;
353
+ }
354
+ return "0";
355
+ }
356
+
357
+ /**
358
+ * The footer indicator for a selected model: the rate in force, prefixed with
359
+ * `peak` or `off-peak` when it differs from the rate pi recorded. Undefined when
360
+ * correction is disabled, no model is selected, no schedule covers it, or it has
361
+ * no recorded cost to report.
362
+ */
363
+ export function statusIndicator(
364
+ schedules: Schedule[],
365
+ active: boolean,
366
+ model: ModelRate | undefined,
367
+ now: Date = new Date(),
368
+ ): string | undefined {
369
+ if (!active || !model) return undefined;
370
+ const schedule = scheduleFor(schedules, model.provider, model.id);
371
+ if (!schedule) return undefined;
372
+
373
+ const rate = describeRate(model.cost, multiplierAt(now, schedule));
374
+ if (!rate) return undefined;
375
+
376
+ switch (rateStateAt(now, schedule)) {
377
+ case "peak":
378
+ return `· ${STATUS_PEAK} ${rate}`;
379
+ case "off-peak":
380
+ return `· ${STATUS_OFF_PEAK} ${rate}`;
381
+ default:
382
+ return `· ${rate}`;
383
+ }
384
+ }
385
+
386
+ export interface CostBreakdown extends RateCard {
203
387
  total: number;
204
388
  }
205
389
 
@@ -250,50 +434,95 @@ export function writeActive(path: string, active: boolean): void {
250
434
  }
251
435
  }
252
436
 
437
+ function formatClock(minutes: number): string {
438
+ return `${String(Math.floor(minutes / 60)).padStart(2, "0")}:${String(minutes % 60).padStart(2, "0")}`;
439
+ }
440
+
253
441
  function describeSchedule(schedule: Schedule): string {
254
442
  const windows = schedule.windows
255
443
  .map((window) => {
256
- const fmt = (minutes: number) =>
257
- `${String(Math.floor(minutes / 60)).padStart(2, "0")}:${String(minutes % 60).padStart(2, "0")}`;
258
- return `${fmt(window.start)}-${fmt(window.end)}`;
444
+ const multiplier = window.multiplier !== undefined ? ` ×${window.multiplier}` : "";
445
+ return `${formatClock(window.start)}-${formatClock(window.end)}${multiplier}`;
259
446
  })
260
447
  .join(", ");
261
448
  const days = schedule.days.map((day) => DAY_NAMES[day]).join(",");
262
- return `${windows} UTC (${days}) ×${schedule.multiplier} for ${schedule.providers.join(",")}`;
449
+ const inside = windows ? `×${schedule.multiplier} in ${windows}` : `×${schedule.multiplier} always`;
450
+ return `${inside}, ×${schedule.outside} outside (${days} UTC) for ${schedule.providers.join(",")}`;
263
451
  }
264
452
 
265
- export default function peakHours(pi: ExtensionAPI): void {
266
- let config = readConfig(CONFIG_PATH) ?? {};
267
- let schedule = resolveSchedule(config);
453
+ function describeAll(schedules: Schedule[]): string {
454
+ const configured = schedules.filter(
455
+ (schedule) => schedule.windows.length > 0 || schedule.multiplier !== 1 || schedule.outside !== 1,
456
+ );
457
+ if (configured.length === 0) return "no schedule configured";
458
+ return configured.map(describeSchedule).join(" | ");
459
+ }
460
+
461
+ export interface PeakHoursOptions {
462
+ /** Config file to read; defaults to the one in pi's agent directory. */
463
+ configPath?: string;
464
+ }
465
+
466
+ /**
467
+ * Messages already scaled. Retries re-deliver the same object, and pi applies a
468
+ * returned replacement by copying it onto that same object in place, so object
469
+ * identity is what identifies a message here.
470
+ *
471
+ * Shared across extension instances through a global: with the npm package and a
472
+ * local checkout both loaded, each would otherwise keep its own set and scale
473
+ * every message twice.
474
+ */
475
+ const ADJUSTED_KEY = Symbol.for("pi-peak-hours.adjusted");
476
+ function adjustedMessages(): WeakSet<object> {
477
+ const registry = globalThis as unknown as Record<symbol, WeakSet<object> | undefined>;
478
+ registry[ADJUSTED_KEY] ??= new WeakSet<object>();
479
+ return registry[ADJUSTED_KEY];
480
+ }
481
+
482
+ export default function peakHours(pi: ExtensionAPI, options: PeakHoursOptions = {}): void {
483
+ const configPath = options.configPath ?? CONFIG_PATH;
484
+ let config = readConfig(configPath) ?? {};
485
+ let schedules = resolveSchedules(config);
268
486
  let active = config.active ?? DEFAULT_ACTIVE;
269
- /** Messages already scaled; retries can re-deliver the same object. */
270
- const adjusted = new WeakSet<AssistantMessage>();
487
+ const adjusted = adjustedMessages();
271
488
  let pendingStart: number | undefined;
272
489
 
273
490
  function refresh(): void {
274
- config = readConfig(CONFIG_PATH) ?? {};
275
- schedule = resolveSchedule(config);
491
+ config = readConfig(configPath) ?? {};
492
+ schedules = resolveSchedules(config);
276
493
  active = config.active ?? DEFAULT_ACTIVE;
277
494
  }
278
495
 
279
- function updateStatus(ctx: ExtensionContext): void {
280
- if (!active) {
281
- ctx.ui.setStatus(STATUS_KEY, undefined);
282
- return;
283
- }
284
- const multiplier = multiplierAt(new Date(), schedule);
285
- ctx.ui.setStatus(STATUS_KEY, multiplier > 1 ? `· ${STATUS_PEAK}` : undefined);
496
+ function updateStatus(ctx: ExtensionContext, model: ModelRate | undefined = ctx.model): void {
497
+ ctx.ui.setStatus(STATUS_KEY, statusIndicator(schedules, active, model));
286
498
  }
287
499
 
288
- function notifyStatus(ctx: ExtensionContext): void {
500
+ function notifyStatus(ctx: ExtensionContext, model: ModelRate | undefined = ctx.model): void {
289
501
  if (!active) {
290
502
  ctx.ui.notify(`Peak hours: off.`, "info");
291
503
  return;
292
504
  }
293
- const now = new Date();
294
- const multiplier = multiplierAt(now, schedule);
295
- const state = multiplier > 1 ? `peak ×${multiplier} right now` : "off-peak right now";
296
- ctx.ui.notify(`Peak hours: ${state}. ${describeSchedule(schedule)}`, "info");
505
+ // Schedules are provider-scoped, so name why nothing applies to a model none
506
+ // of them cover instead of reporting a rate that will never be charged.
507
+ if (!model) {
508
+ ctx.ui.notify(`Peak hours: no model selected. ${describeAll(schedules)}`, "info");
509
+ return;
510
+ }
511
+ const schedule = scheduleFor(schedules, model.provider, model.id);
512
+ if (!schedule) {
513
+ ctx.ui.notify(`Peak hours: not applied to ${model.provider}/${model.id}. ${describeAll(schedules)}`, "info");
514
+ return;
515
+ }
516
+ const multiplier = multiplierAt(new Date(), schedule);
517
+ const rate = describeRate(model.cost, multiplier);
518
+ const state =
519
+ multiplier > 1
520
+ ? `peak ×${multiplier} right now`
521
+ : multiplier < 1
522
+ ? `off-peak ×${multiplier} right now`
523
+ : "no correction right now";
524
+ const detail = rate ? `${state} (${rate})` : state;
525
+ ctx.ui.notify(`Peak hours: ${detail}. ${describeSchedule(schedule)}`, "info");
297
526
  }
298
527
 
299
528
  pi.on("session_start", async (_event, ctx) => {
@@ -306,7 +535,13 @@ export default function peakHours(pi: ExtensionAPI): void {
306
535
  ctx.ui.setStatus(STATUS_KEY, undefined);
307
536
  });
308
537
 
309
- pi.on("model_select", async (_event, ctx) => {
538
+ pi.on("model_select", async (event, ctx) => {
539
+ updateStatus(ctx, event.model);
540
+ });
541
+
542
+ // The window boundary can pass between messages, and the footer quotes a price
543
+ // that changes with it, so re-evaluate whenever a turn starts.
544
+ pi.on("turn_start", async (_event, ctx) => {
310
545
  updateStatus(ctx);
311
546
  });
312
547
 
@@ -325,7 +560,12 @@ export default function peakHours(pi: ExtensionAPI): void {
325
560
  const startedAt = pendingStart ?? message.timestamp ?? Date.now();
326
561
  pendingStart = undefined;
327
562
 
328
- if (!active || !matchesSchedule(schedule, message.provider, message.model)) {
563
+ if (!active) {
564
+ updateStatus(ctx);
565
+ return;
566
+ }
567
+ const schedule = scheduleFor(schedules, message.provider, message.model);
568
+ if (!schedule) {
329
569
  updateStatus(ctx);
330
570
  return;
331
571
  }
@@ -338,15 +578,18 @@ export default function peakHours(pi: ExtensionAPI): void {
338
578
 
339
579
  adjusted.add(message);
340
580
  updateStatus(ctx);
341
- return {
342
- message: {
343
- ...message,
344
- usage: {
345
- ...message.usage,
346
- cost: applyMultiplier(message.usage.cost, multiplier),
347
- },
581
+ const replacement = {
582
+ ...message,
583
+ usage: {
584
+ ...message.usage,
585
+ cost: applyMultiplier(message.usage.cost, multiplier),
348
586
  },
349
587
  };
588
+ // pi copies this onto the original, so the original's identity already
589
+ // carries the guard; registering the replacement covers a caller that
590
+ // replays the returned value instead.
591
+ adjusted.add(replacement);
592
+ return { message: replacement };
350
593
  });
351
594
 
352
595
  pi.registerCommand(COMMAND_PEAK_HOURS, {
@@ -368,7 +611,7 @@ export default function peakHours(pi: ExtensionAPI): void {
368
611
  ctx.ui.notify("Usage: /peak-hours [on|off|status]", "error");
369
612
  return;
370
613
  }
371
- writeActive(CONFIG_PATH, arg === "on");
614
+ writeActive(configPath, arg === "on");
372
615
  refresh();
373
616
  updateStatus(ctx);
374
617
  notifyStatus(ctx);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nijaru/pi-peak-hours",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
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": [
@@ -41,7 +41,6 @@
41
41
  "typescript": "^6.0.3"
42
42
  },
43
43
  "peerDependencies": {
44
- "@earendil-works/pi-ai": "*",
45
44
  "@earendil-works/pi-coding-agent": "*"
46
45
  },
47
46
  "files": [