@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.
- package/LICENSE +21 -0
- package/README.md +73 -0
- package/package.json +53 -0
- 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
|
+
}
|