@khaosoigai/pi-ds-pricing-regime 0.1.0
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/LICENSE +21 -0
- package/README.md +166 -0
- package/extensions/deepseek-peak-offpeak.ts +294 -0
- package/package.json +63 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 khaosoigai
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# pi-ds-pricing-regime
|
|
2
|
+
|
|
3
|
+
A [pi](https://github.com/earendil-works/pi-coding-agent) extension that shows
|
|
4
|
+
whether DeepSeek is currently in **peak** or **off-peak** billing mode, in the
|
|
5
|
+
bottom-left of the footer. It reads the machine's clock, so all boundary times
|
|
6
|
+
display in the machine's local timezone — nothing is hardcoded.
|
|
7
|
+
|
|
8
|
+
## What it shows
|
|
9
|
+
|
|
10
|
+
| State | Footer text (bottom-left) |
|
|
11
|
+
| --- | --- |
|
|
12
|
+
| Peak hours | `⚡ DeepSeek PEAK — until 12:00 local` (amber) |
|
|
13
|
+
| Off-peak | `🌙 DeepSeek off-peak — next peak 09:00 local` (green) |
|
|
14
|
+
| Weekend (Beijing time) | `🌙 DeepSeek off-peak (weekend flat rate) — next peak Monday 09:00 local` (green) |
|
|
15
|
+
|
|
16
|
+
When the next boundary is not today (e.g. on a weekend morning), the label
|
|
17
|
+
includes the boundary's local weekday so `09:00` is not mistaken for today's
|
|
18
|
+
clock time.
|
|
19
|
+
|
|
20
|
+
The status refreshes every 30s, so it flips exactly at hour boundaries.
|
|
21
|
+
|
|
22
|
+
The indicator is shown **only while the selected model comes from the DeepSeek
|
|
23
|
+
provider** (`ctx.model.provider === "deepseek"`) — the regime is DeepSeek API
|
|
24
|
+
pricing, so it is meaningless for other providers. Switching models with
|
|
25
|
+
`/model` or `Ctrl+P` clears or restores the status immediately. The provider id
|
|
26
|
+
is a constant (`DEEPSEEK_PROVIDER`) at the top of the extension.
|
|
27
|
+
|
|
28
|
+
## Pairing with a configurable footer
|
|
29
|
+
|
|
30
|
+
The extension publishes plain pi statuses (`ctx.ui.setStatus`), so it works
|
|
31
|
+
with the **built-in footer** out of the box — no companion extension required.
|
|
32
|
+
It publishes two keys, sorted alphabetically there:
|
|
33
|
+
|
|
34
|
+
| Status key | Shows |
|
|
35
|
+
| --- | --- |
|
|
36
|
+
| `zz-deepseek-regime` | `⚡ DeepSeek PEAK — until 12:00 local` (see table above) |
|
|
37
|
+
| `zz-deepseek-balance` | `💰 ¥12.34` — DeepSeek platform credit balance, warning-coloured under the `LOW_BALANCE` thresholds |
|
|
38
|
+
|
|
39
|
+
For a configurable multi-line footer we recommend
|
|
40
|
+
[pi-footer](https://github.com/wobondar/pi-footer) (`pi install
|
|
41
|
+
npm:pi-footer`). It renders any published status through its `external-status`
|
|
42
|
+
widget, so this extension needs no changes — add widgets keyed to the status
|
|
43
|
+
keys above on their own line, e.g. `Tavily: 73%` (from a usage tracker) →
|
|
44
|
+
regime → balance, ordered exactly as configured and hidden when empty. Hide
|
|
45
|
+
the same keys from pi-footer's automatic extension-status row
|
|
46
|
+
(`extensionStatusRow.hiddenKeys` in `~/.pi/agent/extensions/pi-footer.json`)
|
|
47
|
+
to avoid showing them twice. Removing pi-footer (`pi remove npm:pi-footer`)
|
|
48
|
+
restores the built-in footer with these statuses intact.
|
|
49
|
+
|
|
50
|
+
## The regime
|
|
51
|
+
|
|
52
|
+
Source: [DeepSeek API pricing](https://api-docs.deepseek.com/quick_start/pricing)
|
|
53
|
+
|
|
54
|
+
- **Peak hours (UTC):** `01:00–04:00` and `06:00–10:00`; all other hours are
|
|
55
|
+
off-peak at half the peak rates.
|
|
56
|
+
- **Live since:** `2026-08-16T16:00:00Z` (before that, billing was flat).
|
|
57
|
+
- **Weekends:** since `2026-08-22T16:00:00Z` (= 2026-08-23 00:00 Beijing), Saturdays and
|
|
58
|
+
Sundays in Beijing calendar time have no peak tiers — every call bills at the uniform
|
|
59
|
+
off-peak rate. The next peak after a live weekend is Monday 01:00 UTC; late UTC Sunday
|
|
60
|
+
evenings are already Monday in Beijing, so weekday rules resume there.
|
|
61
|
+
|
|
62
|
+
All are constants at the top of
|
|
63
|
+
[`extensions/deepseek-peak-offpeak.ts`](extensions/deepseek-peak-offpeak.ts)
|
|
64
|
+
(`PEAK_WINDOWS`, `WEEKEND_OFFPEAK_UTC`, `DEEPSEEK_PROVIDER`) — edit them if
|
|
65
|
+
DeepSeek changes the regime.
|
|
66
|
+
|
|
67
|
+
## Install
|
|
68
|
+
|
|
69
|
+
Install as a pi package from npm:
|
|
70
|
+
|
|
71
|
+
```sh
|
|
72
|
+
pi install npm:@khaosoigai/pi-ds-pricing-regime
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The package is structured as a pi package: `package.json` declares a
|
|
76
|
+
[`pi` manifest](https://pi.dev/docs/packages) loading `./extensions`, and the
|
|
77
|
+
package is listed with the `pi-package` keyword for the [package gallery](https://pi.dev/packages).
|
|
78
|
+
|
|
79
|
+
### From git (alternative)
|
|
80
|
+
|
|
81
|
+
The source repo can also be installed directly over SSH (works because the
|
|
82
|
+
repo is private and SSH keys are configured):
|
|
83
|
+
|
|
84
|
+
```sh
|
|
85
|
+
pi install git:git@github.com:khaosoi/pi-ds-pricing-regime
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Pi clones it to `~/.pi/agent/git/github.com/khaosoi/pi-ds-pricing-regime/` and
|
|
89
|
+
runs `npm install` there; restart pi (or `/reload`) to load it. The settings
|
|
90
|
+
entry has no pinned ref, so `pi update --extensions` pulls latest `main`.
|
|
91
|
+
Pin a ref with `pi install git:git@github.com:khaosoi/pi-ds-pricing-regime@<ref>`.
|
|
92
|
+
|
|
93
|
+
> Alternative: copy `extensions/deepseek-peak-offpeak.ts` into `.pi/extensions/`
|
|
94
|
+
> of a project for a project-local install.
|
|
95
|
+
|
|
96
|
+
## Development
|
|
97
|
+
|
|
98
|
+
```sh
|
|
99
|
+
just format # format TypeScript, JavaScript, JSON, and config files
|
|
100
|
+
just format-check # verify formatting without changing files
|
|
101
|
+
just lint # run Biome lint rules
|
|
102
|
+
just check # format-check + lint
|
|
103
|
+
just build # check + typecheck + tests — the release gate
|
|
104
|
+
just typecheck # tsc against pi's types (resolved via the peer dep in node_modules)
|
|
105
|
+
just test # node:test — unit, smoke, and timezone matrix tests
|
|
106
|
+
just coverage # run tests with Node's built-in coverage report (100% currently)
|
|
107
|
+
just pack # npm pack --dry-run: inspect the future tarball
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Biome formats and lints TypeScript, JavaScript, JSON, and configuration files.
|
|
111
|
+
The Markdown documentation and `justfile` are intentionally outside these
|
|
112
|
+
recipes. Or use the equivalent npm scripts directly (`npm run format`,
|
|
113
|
+
`npm run format:check`, `npm run lint`, `npm run check`, `npm test`, and
|
|
114
|
+
`npm run coverage`).
|
|
115
|
+
|
|
116
|
+
No test framework is needed: Node ≥ 23 runs the TypeScript sources directly
|
|
117
|
+
(type stripping), and tests use the built-in `node:test` runner. Tests never
|
|
118
|
+
modify the operating system clock; clock-dependent tests use only Node's
|
|
119
|
+
process-local virtual timers.
|
|
120
|
+
|
|
121
|
+
- `test/regime.test.ts` — window boundaries, next-boundary math, weekend
|
|
122
|
+
flat-rate rule.
|
|
123
|
+
- `test/smoke.test.ts` — loads the real extension with a mock pi context and
|
|
124
|
+
mocked timers: peak / off-peak / weekend status text, and timer lifecycle.
|
|
125
|
+
- `test/timezone-matrix.test.ts` — runs `scripts/timezone-matrix.mjs` under
|
|
126
|
+
fixed-offset zones (UTC+8, UTC+10, UTC-5) and real DST zones
|
|
127
|
+
(America/New_York, Australia/Sydney), asserting exact local-time labels
|
|
128
|
+
including the midnight wrap to the next day's peak and regime boundaries
|
|
129
|
+
that land on DST transition instants.
|
|
130
|
+
|
|
131
|
+
CI (`.github/workflows/ci.yml`) runs formatting/lint checks, typecheck, and
|
|
132
|
+
41 tests on Node 24 for every push/PR.
|
|
133
|
+
|
|
134
|
+
## Publishing
|
|
135
|
+
|
|
136
|
+
The package is published to npm as `@khaosoigai/pi-ds-pricing-regime`. The
|
|
137
|
+
metadata is already in place:
|
|
138
|
+
|
|
139
|
+
- scoped name `@khaosoigai/pi-ds-pricing-regime` (name reserved to your npm account)
|
|
140
|
+
- MIT license (`LICENSE` + `license` field)
|
|
141
|
+
- `repository`/`bugs`/`homepage` point at `github.com/khaosoi/pi-ds-pricing-regime`
|
|
142
|
+
— adjust if the repo gets a different name
|
|
143
|
+
- `publishConfig.access: "public"` so the scoped package publishes publicly
|
|
144
|
+
- `prepublishOnly` gate: runs typecheck + tests before every publish
|
|
145
|
+
|
|
146
|
+
To release a new version:
|
|
147
|
+
|
|
148
|
+
1. `npm run preview:package` — confirm the tarball contains only `extensions/`,
|
|
149
|
+
`LICENSE`, `README.md`
|
|
150
|
+
2. Bump `version` in `package.json`
|
|
151
|
+
3. Merge to `main` and tag the release
|
|
152
|
+
4. `npm login` (account `khaosoigai`) and `npm publish` — the `prepublishOnly`
|
|
153
|
+
gate re-runs check, typecheck, and tests as the final safety net
|
|
154
|
+
|
|
155
|
+
## Notes
|
|
156
|
+
|
|
157
|
+
- Labels show the *next regime boundary* in local time, not the full schedule,
|
|
158
|
+
to keep the footer compact (e.g. `until 12:00 local` rather than
|
|
159
|
+
`09:00–12:00, 14:00–18:00`).
|
|
160
|
+
- Boundary labels are rendered from the boundary's *actual instant*
|
|
161
|
+
(`nextBoundaryUtc` → `formatLocalTime`), so they stay correct even when a
|
|
162
|
+
DST transition falls between now and the boundary (covered by the DST
|
|
163
|
+
scenarios in the timezone matrix).
|
|
164
|
+
- A boundary on a later local date is prefixed with its local weekday name
|
|
165
|
+
(`formatLocalDayPrefix`), so Saturday morning reads `next peak Monday 09:00
|
|
166
|
+
local` rather than a bare `09:00` that looks like today.
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DeepSeek Peak / Off-Peak Footer Indicator
|
|
3
|
+
*
|
|
4
|
+
* Shows in the footer (bottom-left) whether DeepSeek is currently in
|
|
5
|
+
* peak or off-peak billing, computed from the machine's local clock.
|
|
6
|
+
* All boundary times in the status are shown in the machine's local
|
|
7
|
+
* timezone — no timezone is hardcoded.
|
|
8
|
+
*
|
|
9
|
+
* A second status shows the DeepSeek platform credit balance (fetched from
|
|
10
|
+
* api.deepseek.com/user/balance with the provider's resolved API key, cached
|
|
11
|
+
* and throttled to BALANCE_REFRESH_MS). It is likewise only shown while a
|
|
12
|
+
* DeepSeek model is selected; switching models clears or restores both.
|
|
13
|
+
*
|
|
14
|
+
* The status is only displayed while the selected model comes from the
|
|
15
|
+
* DeepSeek provider (`ctx.model.provider === "deepseek"`); switching models
|
|
16
|
+
* clears or restores it immediately.
|
|
17
|
+
*
|
|
18
|
+
* DeepSeek's regime (per api-docs.deepseek.com/quick_start/pricing):
|
|
19
|
+
* Peak hours (UTC): 01:00–04:00 and 06:00–10:00
|
|
20
|
+
* All other hours are off-peak (half the peak rates).
|
|
21
|
+
* Peak/off-peak billing has been live since 2026-08-16T16:00:00Z.
|
|
22
|
+
*
|
|
23
|
+
* Since 2026-08-22T16:00:00Z (= 2026-08-23 00:00 Beijing), Saturdays and
|
|
24
|
+
* Sundays (Beijing calendar time) have no peak tiers at all: every call
|
|
25
|
+
* is billed at the uniform off-peak rate.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
29
|
+
|
|
30
|
+
/** Peak windows as [start, end) UTC hours. Edit here if DeepSeek changes the regime. */
|
|
31
|
+
export const PEAK_WINDOWS: ReadonlyArray<readonly [number, number]> = [
|
|
32
|
+
[1, 4], // 01:00–04:00 UTC
|
|
33
|
+
[6, 10], // 06:00–10:00 UTC
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Weekend flat-rate rule: from 2026-08-22T16:00:00Z (2026-08-23 00:00
|
|
38
|
+
* Beijing), Saturdays and Sundays in Beijing time are off-peak all day.
|
|
39
|
+
*/
|
|
40
|
+
export const WEEKEND_OFFPEAK_UTC = Date.UTC(2026, 7, 22, 16, 0, 0);
|
|
41
|
+
|
|
42
|
+
/** Provider id whose models the billing indicator is shown for. Edit here if the provider id changes. */
|
|
43
|
+
export const DEEPSEEK_PROVIDER = "deepseek";
|
|
44
|
+
|
|
45
|
+
/** China observes a fixed UTC+8 offset year-round (no DST). */
|
|
46
|
+
const BEIJING_OFFSET_MS = 8 * 3_600_000;
|
|
47
|
+
const DAY_MS = 86_400_000;
|
|
48
|
+
|
|
49
|
+
// Pi's footer sorts extension statuses alphabetically by key, left to right.
|
|
50
|
+
// "zz-" prefix keeps these statuses right of others (e.g. "tavily-usage" stays
|
|
51
|
+
// on the left margin). The keys are never displayed — only the status text.
|
|
52
|
+
export const STATUS_KEY = "zz-deepseek-regime";
|
|
53
|
+
export const BALANCE_KEY = "zz-deepseek-balance";
|
|
54
|
+
const REFRESH_MS = 30_000; // refresh a few times per minute so the local-time label stays current
|
|
55
|
+
|
|
56
|
+
/** DeepSeek platform origin for the balance API (api-docs.deepseek.com/api-create-user-balance). */
|
|
57
|
+
export const BALANCE_ORIGIN = "https://api.deepseek.com";
|
|
58
|
+
/** Minimum interval between balance API calls. The balance only changes when credits are granted or topped up, so a few minutes is ample. */
|
|
59
|
+
export const BALANCE_REFRESH_MS = 5 * 60_000;
|
|
60
|
+
/** Balance colour thresholds in native currency units; currencies not listed fall back to FALLBACK_LOW_BALANCE. */
|
|
61
|
+
export const LOW_BALANCE: Readonly<Record<string, number>> = { CNY: 20, USD: 5 };
|
|
62
|
+
export const FALLBACK_LOW_BALANCE = 10;
|
|
63
|
+
/** Currency code → symbol; currencies not listed render as "12.34 EUR". */
|
|
64
|
+
const CURRENCY_SYMBOLS: Readonly<Record<string, string>> = { CNY: "¥", USD: "$" };
|
|
65
|
+
|
|
66
|
+
/** True when `now` falls on a Saturday or Sunday in Beijing calendar time. */
|
|
67
|
+
export function isWeekendBeijing(now: Date): boolean {
|
|
68
|
+
const day = new Date(now.getTime() + BEIJING_OFFSET_MS).getUTCDay();
|
|
69
|
+
return day === 0 || day === 6;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* True when `now` (UTC) falls inside a peak window.
|
|
74
|
+
* Once the weekend rule is live (WEEKEND_OFFPEAK_UTC), Saturdays and Sundays
|
|
75
|
+
* in Beijing time never peak. Before that instant the legacy tiered schedule
|
|
76
|
+
* applied every day of the week.
|
|
77
|
+
*/
|
|
78
|
+
export function inPeak(now: Date): boolean {
|
|
79
|
+
if (now.getTime() >= WEEKEND_OFFPEAK_UTC && isWeekendBeijing(now)) return false;
|
|
80
|
+
const hour = now.getUTCHours();
|
|
81
|
+
return PEAK_WINDOWS.some(([start, end]) => hour >= start && hour < end);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The next regime boundary after `now`, as a UTC hour (0–23), assuming the
|
|
86
|
+
* weekday tiered schedule (used for the peak → end-of-window computation).
|
|
87
|
+
* - If currently peak: the end of the current peak window.
|
|
88
|
+
* - If currently off-peak: the start of the next peak window (wrapping to tomorrow).
|
|
89
|
+
*/
|
|
90
|
+
export function nextBoundaryUtcHour(now: Date): number {
|
|
91
|
+
const minute = now.getUTCHours() * 60 + now.getUTCMinutes();
|
|
92
|
+
const boundaries = inPeak(now)
|
|
93
|
+
? PEAK_WINDOWS.map(([, end]) => end * 60)
|
|
94
|
+
: PEAK_WINDOWS.map(([start]) => start * 60);
|
|
95
|
+
const next = boundaries.find((b) => b > minute);
|
|
96
|
+
return Math.floor((next ?? boundaries[0]) / 60);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** "HH:MM" wall-clock for an instant, in the machine's local timezone. */
|
|
100
|
+
export function formatLocalTime(at: Date): string {
|
|
101
|
+
const hh = String(at.getHours()).padStart(2, "0");
|
|
102
|
+
const mm = String(at.getMinutes()).padStart(2, "0");
|
|
103
|
+
return `${hh}:${mm}`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const DAY_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"] as const;
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* "" when `at` falls on the same local calendar date as `now`; otherwise the
|
|
110
|
+
* English weekday name of `at` followed by a space (e.g. "Monday "). Used so a
|
|
111
|
+
* boundary that is not today is not mistaken for today's clock time — e.g. on
|
|
112
|
+
* Saturday morning the next peak reads "Monday 09:00 local", not "09:00 local".
|
|
113
|
+
*/
|
|
114
|
+
export function formatLocalDayPrefix(now: Date, at: Date): string {
|
|
115
|
+
const sameLocalDay =
|
|
116
|
+
now.getFullYear() === at.getFullYear() && now.getMonth() === at.getMonth() && now.getDate() === at.getDate();
|
|
117
|
+
return sameLocalDay ? "" : `${DAY_NAMES[at.getDay()]} `;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The next regime boundary after `now`, as an absolute UTC instant.
|
|
122
|
+
* Peak → end of the current window; off-peak → start of the next window,
|
|
123
|
+
* scanning forward over UTC days (up to `maxScanDays`) while skipping
|
|
124
|
+
* candidate instants that fall on a live Beijing weekend (the next peak after
|
|
125
|
+
* Friday is Monday 01:00 UTC). Uses `now`'s own date, so the local-time label
|
|
126
|
+
* stays correct even when a DST transition falls between `now` and the
|
|
127
|
+
* boundary.
|
|
128
|
+
*/
|
|
129
|
+
export function nextBoundaryUtc(now: Date, maxScanDays = 9): Date {
|
|
130
|
+
const dayStart = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
|
|
131
|
+
if (inPeak(now)) {
|
|
132
|
+
return new Date(dayStart + nextBoundaryUtcHour(now) * 3_600_000);
|
|
133
|
+
}
|
|
134
|
+
for (let d = 0; d < maxScanDays; d++) {
|
|
135
|
+
const utcDayStart = dayStart + d * DAY_MS;
|
|
136
|
+
for (const [start] of PEAK_WINDOWS) {
|
|
137
|
+
const at = utcDayStart + start * 3_600_000;
|
|
138
|
+
if (at <= now.getTime()) continue;
|
|
139
|
+
if (at >= WEEKEND_OFFPEAK_UTC && isWeekendBeijing(new Date(at))) continue;
|
|
140
|
+
return new Date(at);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
throw new Error("no peak window found within the next 9 days");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* True when the given model (e.g. `ctx.model`) is served by the DeepSeek
|
|
148
|
+
* provider, i.e. when the peak/off-peak regime actually applies to it.
|
|
149
|
+
*/
|
|
150
|
+
export function isDeepSeekModel(model: { provider?: string } | undefined | null): boolean {
|
|
151
|
+
return model?.provider === DEEPSEEK_PROVIDER;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Parse a DeepSeek `/user/balance` response body into the first balance entry.
|
|
156
|
+
* Returns undefined when the shape is not as documented (proxy response, API
|
|
157
|
+
* change, error JSON) so callers can keep showing the previous value.
|
|
158
|
+
*/
|
|
159
|
+
export function parseBalance(body: unknown): { total: number; currency: string; isAvailable: boolean } | undefined {
|
|
160
|
+
if (typeof body !== "object" || body === null) return undefined;
|
|
161
|
+
const record = body as Record<string, unknown>;
|
|
162
|
+
const infos = Array.isArray(record.balance_infos) ? record.balance_infos : [];
|
|
163
|
+
const info = infos[0];
|
|
164
|
+
if (typeof info !== "object" || info === null) return undefined;
|
|
165
|
+
const entry = info as Record<string, unknown>;
|
|
166
|
+
const total = Number(entry.total_balance);
|
|
167
|
+
if (!Number.isFinite(total)) return undefined;
|
|
168
|
+
return {
|
|
169
|
+
total,
|
|
170
|
+
currency: typeof entry.currency === "string" ? entry.currency : "",
|
|
171
|
+
isAvailable: record.is_available !== false,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The rendered balance status: "💰 ¥12.34", low-balances in the theme's
|
|
177
|
+
* warning colour. Precision: integers lose the decimals ("¥12"), anything
|
|
178
|
+
* else keeps two. Unknown currencies render as "12.34 EUR".
|
|
179
|
+
*/
|
|
180
|
+
export function formatBalance(balance: { total: number; currency: string }): {
|
|
181
|
+
text: string;
|
|
182
|
+
low: boolean;
|
|
183
|
+
} {
|
|
184
|
+
const { total, currency } = balance;
|
|
185
|
+
const amount = Number.isInteger(total) ? String(total) : total.toFixed(2);
|
|
186
|
+
const symbol = CURRENCY_SYMBOLS[currency];
|
|
187
|
+
const text = symbol ? `💰 ${symbol}${amount}` : `💰 ${amount}${currency ? ` ${currency}` : ""}`;
|
|
188
|
+
const threshold = LOW_BALANCE[currency] ?? FALLBACK_LOW_BALANCE;
|
|
189
|
+
return { text, low: total < threshold };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** The status text for `now`, or undefined to clear the status. */
|
|
193
|
+
export function statusText(now: Date): { text: string; color: "warning" | "success" } {
|
|
194
|
+
const boundary = nextBoundaryUtc(now);
|
|
195
|
+
const label = `${formatLocalDayPrefix(now, boundary)}${formatLocalTime(boundary)}`;
|
|
196
|
+
if (inPeak(now)) {
|
|
197
|
+
return { color: "warning", text: `⚡ DeepSeek PEAK — until ${label} local` };
|
|
198
|
+
}
|
|
199
|
+
if (now.getTime() >= WEEKEND_OFFPEAK_UTC && isWeekendBeijing(now)) {
|
|
200
|
+
return {
|
|
201
|
+
color: "success",
|
|
202
|
+
text: `🌙 DeepSeek off-peak (weekend flat rate) — next peak ${label} local`,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
return {
|
|
206
|
+
color: "success",
|
|
207
|
+
text: `🌙 DeepSeek off-peak — next peak ${label} local`,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export default function (pi: ExtensionAPI) {
|
|
212
|
+
let timer: ReturnType<typeof setInterval> | undefined;
|
|
213
|
+
let balance: { text: string; low: boolean } | undefined;
|
|
214
|
+
let lastFetched = 0;
|
|
215
|
+
let inflight: Promise<void> | undefined;
|
|
216
|
+
|
|
217
|
+
const applyBalance = (ctx: ExtensionContext) => {
|
|
218
|
+
if (balance) {
|
|
219
|
+
ctx.ui.setStatus(BALANCE_KEY, ctx.ui.theme.fg(balance.low ? "warning" : "success", balance.text));
|
|
220
|
+
} else {
|
|
221
|
+
ctx.ui.setStatus(BALANCE_KEY, undefined);
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Fetch the credit balance once and cache it. Fire-and-forget: failures
|
|
227
|
+
* (no stored key, network errors, non-2xx, unexpected body) leave the
|
|
228
|
+
* previous value untouched, and lastFetched was advanced at call time so
|
|
229
|
+
* a broken endpoint is retried no more than once per BALANCE_REFRESH_MS.
|
|
230
|
+
*/
|
|
231
|
+
const fetchBalance = (ctx: ExtensionContext) => {
|
|
232
|
+
if (inflight) return;
|
|
233
|
+
lastFetched = Date.now();
|
|
234
|
+
inflight = (async () => {
|
|
235
|
+
try {
|
|
236
|
+
const auth = await ctx.modelRegistry?.getProviderAuth?.(DEEPSEEK_PROVIDER);
|
|
237
|
+
const apiKey = auth?.auth?.apiKey;
|
|
238
|
+
if (!apiKey) return;
|
|
239
|
+
// A custom baseUrl (proxy) is respected for the balance endpoint too;
|
|
240
|
+
// proxies that don't implement /user/balance just fail and keep the cache.
|
|
241
|
+
const origin = auth.auth.baseUrl ? new URL(auth.auth.baseUrl).origin : BALANCE_ORIGIN;
|
|
242
|
+
const res = await fetch(`${origin}/user/balance`, {
|
|
243
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
244
|
+
});
|
|
245
|
+
if (!res.ok) return;
|
|
246
|
+
const parsed = parseBalance(await res.json());
|
|
247
|
+
if (parsed) {
|
|
248
|
+
balance = formatBalance(parsed);
|
|
249
|
+
if (!parsed.isAvailable) balance.low = true; // credits exist but can't pay — flag it
|
|
250
|
+
applyBalance(ctx);
|
|
251
|
+
}
|
|
252
|
+
} catch {
|
|
253
|
+
// keep showing the cached value
|
|
254
|
+
} finally {
|
|
255
|
+
inflight = undefined;
|
|
256
|
+
}
|
|
257
|
+
})();
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
const update = async (ctx: ExtensionContext, model: ExtensionContext["model"]) => {
|
|
261
|
+
if (!isDeepSeekModel(model)) {
|
|
262
|
+
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
263
|
+
ctx.ui.setStatus(BALANCE_KEY, undefined);
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
const { text, color } = statusText(new Date());
|
|
267
|
+
ctx.ui.setStatus(STATUS_KEY, ctx.ui.theme.fg(color, text));
|
|
268
|
+
applyBalance(ctx);
|
|
269
|
+
if (Date.now() - lastFetched >= BALANCE_REFRESH_MS) fetchBalance(ctx);
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
273
|
+
if (timer) clearInterval(timer);
|
|
274
|
+
await update(ctx, ctx.model);
|
|
275
|
+
timer = setInterval(() => void update(ctx, ctx.model), REFRESH_MS);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
// Balance can drop mid-session (usage debits, new grants) — re-check on the
|
|
279
|
+
// throttled schedule after each turn as well.
|
|
280
|
+
pi.on("turn_end", async (_event, ctx) => {
|
|
281
|
+
await update(ctx, ctx.model);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
pi.on("model_select", async (event, ctx) => {
|
|
285
|
+
await update(ctx, ctx.model ?? event.model);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
pi.on("session_shutdown", async () => {
|
|
289
|
+
if (timer) {
|
|
290
|
+
clearInterval(timer);
|
|
291
|
+
timer = undefined;
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@khaosoigai/pi-ds-pricing-regime",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Pi extension: shows DeepSeek peak/off-peak billing mode in the footer, timezone-aware",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi",
|
|
7
|
+
"pi-package",
|
|
8
|
+
"pi-extension",
|
|
9
|
+
"deepseek",
|
|
10
|
+
"peak",
|
|
11
|
+
"off-peak",
|
|
12
|
+
"pricing"
|
|
13
|
+
],
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"pi": {
|
|
16
|
+
"extensions": [
|
|
17
|
+
"./extensions"
|
|
18
|
+
]
|
|
19
|
+
},
|
|
20
|
+
"author": {
|
|
21
|
+
"name": "khaosoigai",
|
|
22
|
+
"url": "https://github.com/khaosoi"
|
|
23
|
+
},
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/khaosoi/pi-ds-pricing-regime.git"
|
|
27
|
+
},
|
|
28
|
+
"bugs": {
|
|
29
|
+
"url": "https://github.com/khaosoi/pi-ds-pricing-regime/issues"
|
|
30
|
+
},
|
|
31
|
+
"homepage": "https://github.com/khaosoi/pi-ds-pricing-regime#readme",
|
|
32
|
+
"type": "module",
|
|
33
|
+
"files": [
|
|
34
|
+
"extensions",
|
|
35
|
+
"LICENSE",
|
|
36
|
+
"README.md"
|
|
37
|
+
],
|
|
38
|
+
"engines": {
|
|
39
|
+
"node": ">=23"
|
|
40
|
+
},
|
|
41
|
+
"scripts": {
|
|
42
|
+
"format": "biome format extensions test scripts package.json tsconfig.json biome.json --write",
|
|
43
|
+
"format:check": "biome format extensions test scripts package.json tsconfig.json biome.json",
|
|
44
|
+
"lint": "biome lint extensions test scripts package.json tsconfig.json biome.json",
|
|
45
|
+
"check": "npm run format:check && npm run lint",
|
|
46
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
47
|
+
"test": "node --test",
|
|
48
|
+
"coverage": "node --test --experimental-test-coverage",
|
|
49
|
+
"preview:package": "npm pack --dry-run",
|
|
50
|
+
"prepublishOnly": "npm run check && npm run typecheck && npm test"
|
|
51
|
+
},
|
|
52
|
+
"peerDependencies": {
|
|
53
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
54
|
+
},
|
|
55
|
+
"publishConfig": {
|
|
56
|
+
"access": "public"
|
|
57
|
+
},
|
|
58
|
+
"devDependencies": {
|
|
59
|
+
"@biomejs/biome": "2.5.8",
|
|
60
|
+
"@types/node": "^22.10.0",
|
|
61
|
+
"typescript": "^5.7.0"
|
|
62
|
+
}
|
|
63
|
+
}
|