@cwillam/pi-minimax-usage 1.0.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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +73 -0
  3. package/package.json +53 -0
  4. package/src/index.ts +418 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Christoph Willam
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,73 @@
1
+ # pi-minimax-usage
2
+
3
+ Pi extension that puts your MiniMax Token Plan usage in the status bar.
4
+
5
+ ```
6
+ MiniMax 5h●43% ↺3h12m wk●12% ↺4d8h
7
+ ```
8
+
9
+ Two windows: the 5-hour rolling quota and the weekly quota, each as used-percent with a reset countdown. Colour shifts at 70 % (yellow) and 90 % (red). The block vanishes when you switch away from MiniMax.
10
+
11
+ > Not affiliated with, endorsed by, or sponsored by MiniMax Inc. "MiniMax" is a trademark of MiniMax Inc. This extension only reads the publicly documented quota endpoint.
12
+
13
+ ## Install
14
+
15
+ ```
16
+ pi install npm:@cwillam/pi-minimax-usage
17
+ ```
18
+
19
+ Make sure the `minimax` provider is configured in pi (API key available). Restart Pi, switch to a MiniMax model, the block shows up in the footer within about a second.
20
+
21
+ ## Slash command
22
+
23
+ ```
24
+ /minimax-usage show current snapshot
25
+ /minimax-usage refresh force-poll now
26
+ /minimax-usage help command help
27
+ ```
28
+
29
+ ## Settings
30
+
31
+ Optional, under `minimaxUsage` in `~/.pi/agent/settings.json`:
32
+
33
+ ```jsonc
34
+ {
35
+ "minimaxUsage": {
36
+ "refreshIntervalMs": 30000, // min 5000, default 60000
37
+ "host": "global", // "global" or "cn"
38
+ "endpoint": "https://www.minimax.io/v1/token_plan/remains" // overrides host, must be on the allowlist
39
+ }
40
+ }
41
+ ```
42
+
43
+ The `endpoint` setting only accepts the two documented MiniMax URLs (exact match). Anything else is ignored, falls back to `host`.
44
+
45
+ ## How it works
46
+
47
+ On session start or model switch, it grabs the API key for the `minimax` provider via `ctx.modelRegistry.getApiKeyForProvider()` and polls `GET /v1/token_plan/remains` every `refreshIntervalMs`. The response's `general` bucket gives two `*_remaining_percent` fields (5h + weekly); the extension inverts them to consumed-% and paints the footer.
48
+
49
+ Wire-format details the parser handles:
50
+
51
+ - HTTP 200 even on auth failure — the real code lives in `base_resp.status_code` (`1004` = no key, `2049` = wrong host)
52
+ - The `*_remaining_percent` fields are **remaining**, not consumed — inverted to match the rest of the app
53
+ - Timestamps are epoch milliseconds, not seconds
54
+ - Only the `general` bucket is shown; video/other buckets are ignored
55
+
56
+ ## Troubleshooting
57
+
58
+ | Symptom | Cause | Fix |
59
+ | --- | --- | --- |
60
+ | `MiniMax no api key` | No credential for `minimax` provider | Configure it like any other pi provider |
61
+ | `MiniMax 1004` or `MiniMax invalid key` | Key rejected for this host | Flip `host` between `global` / `cn`, or set `endpoint` to the matching URL |
62
+ | Block doesn't appear | Active provider isn't `minimax` | Check with `/model` |
63
+ | Stale data after model switch | Cached state cleared on switch | Next poll (~1 s) repopulates it |
64
+
65
+ Run `/minimax-usage` to see the current snapshot, error, and last-poll timestamp.
66
+
67
+ ## Credits
68
+
69
+ Wire-format field names came from [ai-usagebar (Rust)](https://docs.rs/ai-usagebar/latest/ai_usagebar/minimax/types/index.html). Architectural pattern borrowed from [@d3ara1n/pi-usage-block](https://github.com/d3ara1n/pi-extensions).
70
+
71
+ ## License
72
+
73
+ MIT — see [LICENSE](./LICENSE).
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@cwillam/pi-minimax-usage",
3
+ "version": "1.0.0",
4
+ "description": "Pi extension: live MiniMax Token Plan usage (5h + weekly) in the status bar. Not affiliated with MiniMax Inc.",
5
+ "type": "module",
6
+ "main": "./src/index.ts",
7
+ "files": [
8
+ "src",
9
+ "README.md",
10
+ "LICENSE"
11
+ ],
12
+ "scripts": {
13
+ "check": "tsc --noEmit --strict --target es2022 --module esnext --moduleResolution bundler src/index.ts"
14
+ },
15
+ "keywords": [
16
+ "pi-package",
17
+ "pi",
18
+ "pi-extension",
19
+ "minimax",
20
+ "minimax-api",
21
+ "minimax-token-plan",
22
+ "usage",
23
+ "status-bar",
24
+ "quota"
25
+ ],
26
+ "license": "MIT",
27
+ "author": {
28
+ "name": "Christoph Willam",
29
+ "email": "info@cwillam.de"
30
+ },
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "https://github.com/cwillam/pi-minimax-usage.git"
34
+ },
35
+ "bugs": {
36
+ "url": "https://github.com/cwillam/pi-minimax-usage/issues"
37
+ },
38
+ "homepage": "https://github.com/cwillam/pi-minimax-usage#readme",
39
+ "pi": {
40
+ "extensions": [
41
+ "./src/index.ts"
42
+ ]
43
+ },
44
+ "peerDependencies": {
45
+ "@earendil-works/pi-coding-agent": "*"
46
+ },
47
+ "engines": {
48
+ "node": ">=20"
49
+ },
50
+ "devDependencies": {
51
+ "typescript": "^5.9.3"
52
+ }
53
+ }
package/src/index.ts ADDED
@@ -0,0 +1,418 @@
1
+ /**
2
+ * minimax-usage — footer status block + slash command for the MiniMax Token Plan.
3
+ *
4
+ * Polls the documented quota endpoint `GET /v1/token_plan/remains` and renders
5
+ * two windows (5h rolling + weekly) as used-percentage with a reset countdown.
6
+ * Not affiliated with MiniMax Inc. — see LICENSE.
7
+ *
8
+ * Wire-format quirks the parser handles:
9
+ * - HTTP 200 even on auth failure — real status lives in `base_resp.status_code`
10
+ * (1004 = no key, 2049 = wrong host for key)
11
+ * - `current_interval_remaining_percent` is REMAINING, not consumed — inverted here
12
+ * - All timestamps are epoch milliseconds (NOT seconds)
13
+ * - `general` bucket covers text/coding; other buckets are ignored
14
+ *
15
+ * Optional settings (under `minimaxUsage` in pi's settings.json):
16
+ * - `refreshIntervalMs` (number, default 60000)
17
+ * - `endpoint` (string, default https://www.minimax.io/v1/token_plan/remains)
18
+ * - `host` ("global" | "cn", default "global") — flips the endpoint domain
19
+ */
20
+ import type {
21
+ ExtensionAPI,
22
+ ExtensionContext,
23
+ ExtensionCommandContext,
24
+ Theme as PiTheme,
25
+ } from "@earendil-works/pi-coding-agent";
26
+
27
+ const STATUS_KEY = "minimax-usage";
28
+ const COMMAND_NAME = "minimax-usage";
29
+ const PROVIDER_ID = "minimax";
30
+ const DEFAULT_POLL_MS = 60_000;
31
+
32
+ const ENDPOINTS = {
33
+ global: "https://www.minimax.io/v1/token_plan/remains",
34
+ cn: "https://www.minimaxi.com/v1/token_plan/remains",
35
+ } as const;
36
+ type Host = keyof typeof ENDPOINTS;
37
+
38
+ type QuotaWindow = {
39
+ period: string;
40
+ used: number;
41
+ limit: number;
42
+ resetAt?: Date;
43
+ };
44
+
45
+ type ExtensionSettings = {
46
+ minimaxUsage?: {
47
+ refreshIntervalMs?: number;
48
+ endpoint?: string;
49
+ host?: Host;
50
+ };
51
+ };
52
+
53
+ type StatusSnapshot = {
54
+ active: boolean;
55
+ hasKey: boolean;
56
+ lastPollAt: Date | undefined;
57
+ windows: QuotaWindow[] | undefined;
58
+ error: string | undefined;
59
+ endpoint: string;
60
+ nextPollInMs: number | undefined;
61
+ };
62
+
63
+ type PollingState = {
64
+ ctx: ExtensionContext | undefined;
65
+ alive: boolean;
66
+ activeProviderId: string | undefined;
67
+ apiKey: string | undefined;
68
+ lastWindows: QuotaWindow[] | undefined;
69
+ lastError: string | undefined;
70
+ lastPollAt: Date | undefined;
71
+ nextPollTimer: ReturnType<typeof setTimeout> | undefined;
72
+ intervalTimer: ReturnType<typeof setInterval> | undefined;
73
+ intervalMs: number;
74
+ endpoint: string;
75
+ };
76
+
77
+ const state: PollingState = {
78
+ ctx: undefined,
79
+ alive: false,
80
+ activeProviderId: undefined,
81
+ apiKey: undefined,
82
+ lastWindows: undefined,
83
+ lastError: undefined,
84
+ lastPollAt: undefined,
85
+ nextPollTimer: undefined,
86
+ intervalTimer: undefined,
87
+ intervalMs: DEFAULT_POLL_MS,
88
+ endpoint: ENDPOINTS.global,
89
+ };
90
+
91
+ function getSettings(): ExtensionSettings["minimaxUsage"] {
92
+ // `settings` exists on the runtime context but isn't surfaced in the
93
+ // public ExtensionContext type; access via a typed cast.
94
+ const raw = (
95
+ state.ctx as unknown as { settings?: ExtensionSettings } | undefined
96
+ )?.settings;
97
+ return raw?.minimaxUsage;
98
+ }
99
+
100
+ /** Allowlist of acceptable endpoint URLs (defence against SSRF via `endpoint` setting). */
101
+ const ALLOWED_ENDPOINTS: ReadonlySet<string> = new Set(
102
+ Object.values(ENDPOINTS),
103
+ );
104
+
105
+ function resolveEndpoint(): string {
106
+ const cfg = getSettings();
107
+ // Honour `endpoint` only if it matches an allowlisted MiniMax URL exactly.
108
+ if (cfg?.endpoint && ALLOWED_ENDPOINTS.has(cfg.endpoint)) return cfg.endpoint;
109
+ return ENDPOINTS[cfg?.host ?? "global"];
110
+ }
111
+
112
+ function resolveIntervalMs(): number {
113
+ const ms = getSettings()?.refreshIntervalMs;
114
+ return Number.isFinite(ms) && (ms as number) >= 5_000
115
+ ? (ms as number)
116
+ : DEFAULT_POLL_MS;
117
+ }
118
+
119
+ // ── formatting ────────────────────────────────────────────────────────────
120
+
121
+ function fmtPct(used: number, limit: number): string {
122
+ if (!Number.isFinite(used) || !Number.isFinite(limit) || limit <= 0)
123
+ return "—";
124
+ return `${Math.round((used / limit) * 100)}%`;
125
+ }
126
+
127
+ function fmtCountdown(ms: number): string {
128
+ if (ms <= 0) return "now";
129
+ const m = Math.round(ms / 60_000);
130
+ if (m < 60) return `${m}m`;
131
+ const h = Math.floor(m / 60);
132
+ const rm = m % 60;
133
+ return rm ? `${h}h${rm}m` : `${h}h`;
134
+ }
135
+
136
+ function fmtAge(seconds: number): string {
137
+ if (seconds < 60) return `${Math.round(seconds)}s ago`;
138
+ const m = Math.round(seconds / 60);
139
+ if (m < 60) return `${m}m ago`;
140
+ return `${Math.floor(m / 60)}h${m % 60}m ago`;
141
+ }
142
+
143
+ function parseEpochMs(raw: unknown): Date | undefined {
144
+ const n = Number(raw);
145
+ if (!Number.isFinite(n) || n <= 0) return undefined;
146
+ const d = new Date(n);
147
+ return Number.isNaN(d.getTime()) ? undefined : d;
148
+ }
149
+
150
+ type Level = "success" | "warning" | "error";
151
+
152
+ function usageLevel(used: number, limit: number): Level {
153
+ if (limit <= 0) return "success";
154
+ const r = used / limit;
155
+ if (r >= 0.9) return "error";
156
+ if (r >= 0.7) return "warning";
157
+ return "success";
158
+ }
159
+
160
+ // ── parser ────────────────────────────────────────────────────────────────
161
+
162
+ type Envelope = {
163
+ base_resp?: { status_code?: number; status_msg?: string };
164
+ model_remains?: Array<{
165
+ model_name?: string;
166
+ end_time?: number | string;
167
+ weekly_end_time?: number | string;
168
+ current_interval_remaining_percent?: number | string;
169
+ current_weekly_remaining_percent?: number | string;
170
+ }>;
171
+ };
172
+
173
+ function parseQuota(envelope: Envelope): {
174
+ windows: QuotaWindow[];
175
+ error?: string;
176
+ } {
177
+ const code = envelope.base_resp?.status_code;
178
+ if (code !== undefined && code !== 0) {
179
+ return {
180
+ windows: [],
181
+ error: envelope.base_resp?.status_msg ?? `status ${code}`,
182
+ };
183
+ }
184
+ const rows = Array.isArray(envelope.model_remains)
185
+ ? envelope.model_remains
186
+ : [];
187
+ const windows: QuotaWindow[] = [];
188
+ for (const r of rows) {
189
+ if (r.model_name !== "general") continue; // text/coding bucket only
190
+ const intervalRemain = Number(r.current_interval_remaining_percent);
191
+ if (Number.isFinite(intervalRemain)) {
192
+ windows.push({
193
+ period: "5h",
194
+ used: Math.max(0, 100 - intervalRemain),
195
+ limit: 100,
196
+ resetAt: parseEpochMs(r.end_time),
197
+ });
198
+ }
199
+ const weekRemain = Number(r.current_weekly_remaining_percent);
200
+ if (Number.isFinite(weekRemain)) {
201
+ windows.push({
202
+ period: "wk",
203
+ used: Math.max(0, 100 - weekRemain),
204
+ limit: 100,
205
+ resetAt: parseEpochMs(r.weekly_end_time),
206
+ });
207
+ }
208
+ }
209
+ return { windows };
210
+ }
211
+
212
+ // ── network ───────────────────────────────────────────────────────────────
213
+
214
+ async function pollOnce(): Promise<void> {
215
+ if (!state.ctx || !state.alive || !state.apiKey) return;
216
+ try {
217
+ const resp = await fetch(state.endpoint, {
218
+ headers: { Authorization: `Bearer ${state.apiKey}` },
219
+ });
220
+ const data = (await resp.json()) as Envelope;
221
+ const parsed = parseQuota(data);
222
+ if (parsed.error) {
223
+ state.lastError = parsed.error;
224
+ } else if (parsed.windows.length > 0) {
225
+ state.lastWindows = parsed.windows;
226
+ state.lastError = undefined;
227
+ }
228
+ state.lastPollAt = new Date();
229
+ } catch (err) {
230
+ state.lastError = err instanceof Error ? err.message : String(err);
231
+ state.lastPollAt = new Date();
232
+ }
233
+ }
234
+
235
+ // ── render ────────────────────────────────────────────────────────────────
236
+
237
+ function render(): void {
238
+ if (!state.ctx || !state.alive) return;
239
+ if (state.activeProviderId !== PROVIDER_ID) {
240
+ state.ctx.ui.setStatus(STATUS_KEY, undefined);
241
+ return;
242
+ }
243
+ const theme: PiTheme = state.ctx.ui.theme;
244
+ if (!state.apiKey) {
245
+ state.ctx.ui.setStatus(
246
+ STATUS_KEY,
247
+ `${theme.fg("dim", "MiniMax")} ${theme.fg("warning", "no api key")}`,
248
+ );
249
+ return;
250
+ }
251
+ if (state.lastError) {
252
+ state.ctx.ui.setStatus(
253
+ STATUS_KEY,
254
+ `${theme.fg("dim", "MiniMax")} ${theme.fg("warning", state.lastError)}`,
255
+ );
256
+ return;
257
+ }
258
+ if (!state.lastWindows?.length) return; // first poll pending
259
+
260
+ const parts = state.lastWindows.map((w) => {
261
+ const lvl = usageLevel(w.used, w.limit);
262
+ let text = `${theme.fg("dim", w.period)}${theme.fg(lvl, "●")}${theme.fg("dim", fmtPct(w.used, w.limit))}`;
263
+ if (w.resetAt) {
264
+ const remaining = w.resetAt.getTime() - Date.now();
265
+ text += theme.fg("dim", ` ↺${fmtCountdown(remaining)}`);
266
+ }
267
+ return text;
268
+ });
269
+ state.ctx.ui.setStatus(
270
+ STATUS_KEY,
271
+ `${theme.fg("dim", "MiniMax")} ${parts.join(" ")}`,
272
+ );
273
+ }
274
+
275
+ function snapshot(): StatusSnapshot {
276
+ return {
277
+ active: state.activeProviderId === PROVIDER_ID,
278
+ hasKey: Boolean(state.apiKey),
279
+ lastPollAt: state.lastPollAt,
280
+ windows: state.lastWindows,
281
+ error: state.lastError,
282
+ endpoint: state.endpoint,
283
+ nextPollInMs:
284
+ state.alive && state.activeProviderId === PROVIDER_ID && state.apiKey
285
+ ? state.intervalMs
286
+ : undefined,
287
+ };
288
+ }
289
+
290
+ function formatSnapshotForNotify(snap: StatusSnapshot): string {
291
+ if (!snap.active)
292
+ return "MiniMax is not the active provider — block is hidden.";
293
+ if (!snap.hasKey) return "No API key configured for provider 'minimax'.";
294
+ if (snap.error) return `Last error: ${snap.error}`;
295
+ if (!snap.windows?.length)
296
+ return "No quota data yet — first poll pending or empty response.";
297
+ const lines = snap.windows.map((w) => {
298
+ const reset = w.resetAt
299
+ ? ` resets in ${fmtCountdown(w.resetAt.getTime() - Date.now())}`
300
+ : "";
301
+ return ` ${w.period}: ${fmtPct(w.used, w.limit)} used${reset}`;
302
+ });
303
+ const lastPoll = snap.lastPollAt
304
+ ? fmtAge((Date.now() - snap.lastPollAt.getTime()) / 1000)
305
+ : "never";
306
+ return [
307
+ `MiniMax Token Plan (${snap.endpoint})`,
308
+ ...lines,
309
+ `last poll: ${lastPoll}`,
310
+ ].join("\n");
311
+ }
312
+
313
+ // ── lifecycle ─────────────────────────────────────────────────────────────
314
+
315
+ async function startPolling(): Promise<void> {
316
+ stopPolling();
317
+ if (state.activeProviderId !== PROVIDER_ID || !state.ctx) return;
318
+ state.endpoint = resolveEndpoint();
319
+ state.intervalMs = resolveIntervalMs();
320
+ state.apiKey =
321
+ await state.ctx.modelRegistry?.getApiKeyForProvider?.(PROVIDER_ID);
322
+ render();
323
+ if (!state.apiKey) return;
324
+ await pollOnce();
325
+ render();
326
+ state.intervalTimer = setInterval(() => {
327
+ if (state.alive) void pollOnce().then(render);
328
+ }, state.intervalMs);
329
+ }
330
+
331
+ function stopPolling(): void {
332
+ if (state.intervalTimer) clearInterval(state.intervalTimer);
333
+ if (state.nextPollTimer) clearTimeout(state.nextPollTimer);
334
+ state.intervalTimer = undefined;
335
+ state.nextPollTimer = undefined;
336
+ }
337
+
338
+ function resetState(): void {
339
+ state.lastWindows = undefined;
340
+ state.lastError = undefined;
341
+ state.lastPollAt = undefined;
342
+ state.apiKey = undefined;
343
+ }
344
+
345
+ // ── entrypoint ────────────────────────────────────────────────────────────
346
+
347
+ export default function (pi: ExtensionAPI): void {
348
+ pi.on("model_select", (event) => {
349
+ state.activeProviderId = event.model.provider;
350
+ if (state.activeProviderId === PROVIDER_ID) {
351
+ void startPolling();
352
+ } else {
353
+ stopPolling();
354
+ resetState();
355
+ }
356
+ render();
357
+ });
358
+
359
+ pi.on("session_start", async (_e, ctx) => {
360
+ if (!ctx.hasUI) return;
361
+ state.ctx = ctx;
362
+ state.alive = true;
363
+ state.activeProviderId = ctx.model?.provider;
364
+ await startPolling();
365
+ });
366
+
367
+ pi.on("session_shutdown", () => {
368
+ state.alive = false;
369
+ stopPolling();
370
+ state.ctx = undefined;
371
+ state.activeProviderId = undefined;
372
+ resetState();
373
+ });
374
+
375
+ pi.registerCommand(COMMAND_NAME, {
376
+ description: "MiniMax Token Plan usage: show status, refresh, or help",
377
+ handler: async (args: string, cmdCtx: ExtensionCommandContext) => {
378
+ const sub = args.trim().split(/\s+/)[0]?.toLowerCase() ?? "";
379
+ if (sub === "refresh") {
380
+ if (state.activeProviderId !== PROVIDER_ID) {
381
+ cmdCtx.ui.notify("MiniMax is not the active provider.", "warning");
382
+ return;
383
+ }
384
+ if (!state.apiKey) {
385
+ cmdCtx.ui.notify(
386
+ "No API key configured for provider 'minimax'.",
387
+ "warning",
388
+ );
389
+ return;
390
+ }
391
+ await pollOnce();
392
+ render();
393
+ cmdCtx.ui.notify(formatSnapshotForNotify(snapshot()), "info");
394
+ return;
395
+ }
396
+ if (sub === "help" || sub === "--help" || sub === "-h") {
397
+ cmdCtx.ui.notify(
398
+ [
399
+ `${COMMAND_NAME} — MiniMax Token Plan usage`,
400
+ "",
401
+ "Usage:",
402
+ ` /${COMMAND_NAME} show current status`,
403
+ ` /${COMMAND_NAME} refresh force-poll the quota endpoint now`,
404
+ ` /${COMMAND_NAME} help this help`,
405
+ "",
406
+ "Configuration (in pi's settings.json, under `minimaxUsage`):",
407
+ " refreshIntervalMs: poll interval in ms (min 5000, default 60000)",
408
+ " host: 'global' (default) or 'cn'",
409
+ " endpoint: full override URL",
410
+ ].join("\n"),
411
+ "info",
412
+ );
413
+ return;
414
+ }
415
+ cmdCtx.ui.notify(formatSnapshotForNotify(snapshot()), "info");
416
+ },
417
+ });
418
+ }