pi-chatgpt-pool 0.1.0 → 0.2.1
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 +24 -3
- package/package.json +4 -2
- package/src/format.ts +30 -0
- package/src/index.ts +107 -25
- package/src/panel.ts +112 -0
- package/src/pool.ts +9 -1
package/README.md
CHANGED
|
@@ -20,7 +20,7 @@ Run `/chatgpt-pool` and choose **+ Add account**, or add them directly:
|
|
|
20
20
|
/login
|
|
21
21
|
```
|
|
22
22
|
|
|
23
|
-
`/login` lists each account as `ChatGPT (<label>)`. Sign in to each one with a different ChatGPT account. Then pick a pooled model:
|
|
23
|
+
`/login` lists each account as `ChatGPT (<label>)`, or under a name you choose with `/chatgpt-pool name`. Renaming keeps the account signed in. Sign in to each one with a different ChatGPT account. Then pick a pooled model:
|
|
24
24
|
|
|
25
25
|
```text
|
|
26
26
|
/model chatgpt/gpt-5.6-sol
|
|
@@ -32,14 +32,35 @@ The pool offers the ChatGPT subscription models: the OpenAI models that pi also
|
|
|
32
32
|
|
|
33
33
|
| Command | What it does |
|
|
34
34
|
| --- | --- |
|
|
35
|
-
| `/chatgpt-pool` | Open the pool
|
|
35
|
+
| `/chatgpt-pool` | Open the pool panel (see below) |
|
|
36
36
|
| `/chatgpt-pool add <label>` | Add an account slot. Sign in to it with `/login` |
|
|
37
|
+
| `/chatgpt-pool name <label> <name>` | Set the name the account shows in `/login`, for example `Work (jal@mastra.ai)`. Leave the name empty to go back to `ChatGPT (<label>)` |
|
|
37
38
|
| `/chatgpt-pool remove <label>` | Remove an account. Run `/logout` for it first to delete its token |
|
|
38
39
|
| `/chatgpt-pool spend <label>` | Spend one banked reset on that account, after you confirm |
|
|
39
40
|
| `/chatgpt-pool strategy <name>` | Set the routing strategy: `fill-first`, `round-robin`, `least-used`, or `use-it-or-lose-it` |
|
|
40
41
|
| `/chatgpt-pool reset` | Forget recorded limits, for example after a limit clears early |
|
|
41
42
|
|
|
42
|
-
The command autocompletes its actions and account labels.
|
|
43
|
+
The command autocompletes its actions and account labels.
|
|
44
|
+
|
|
45
|
+
## Pool panel
|
|
46
|
+
|
|
47
|
+
`/chatgpt-pool` opens a panel with every account, its state (ready, limited with a countdown, or not signed in), a usage bar per window with the time until it resets, and its banked resets.
|
|
48
|
+
|
|
49
|
+
| Key | Action |
|
|
50
|
+
| --- | --- |
|
|
51
|
+
| `↑` `↓` or `j` `k` | Select an account |
|
|
52
|
+
| `s` | Spend a banked reset on the selected account, after you confirm |
|
|
53
|
+
| `c` | Clear the selected account's recorded limit |
|
|
54
|
+
| `n` | Set the name the selected account shows in `/login` |
|
|
55
|
+
| `d` | Remove the selected account |
|
|
56
|
+
| `a` | Add an account |
|
|
57
|
+
| `t` | Choose the strategy |
|
|
58
|
+
| `r` | Refresh usage |
|
|
59
|
+
| `esc` or `q` | Close |
|
|
60
|
+
|
|
61
|
+
Over RPC the same actions are offered as a list instead.
|
|
62
|
+
|
|
63
|
+
While a pooled model is selected, the footer shows the account in use with its usage and reset time, for example `chatgpt: work 42% · resets in 3d 4h`, plus how many accounts are limited. When a conversation moves to another account, pi shows a notice such as `ChatGPT pool: personal is limited (back in 1h 30m), switched to work.`
|
|
43
64
|
|
|
44
65
|
`/chatgpt-pool` and `spend` need the interactive UI; in `--print` or `--mode json` they stop with an error instead of spending anything. From a shell, use pi's own commands:
|
|
45
66
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-chatgpt-pool",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Pool several Sign in with ChatGPT accounts in pi, with routing strategies, failover, and banked resets",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -37,11 +37,13 @@
|
|
|
37
37
|
},
|
|
38
38
|
"peerDependencies": {
|
|
39
39
|
"@earendil-works/pi-ai": "*",
|
|
40
|
-
"@earendil-works/pi-coding-agent": "*"
|
|
40
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
41
|
+
"@earendil-works/pi-tui": "*"
|
|
41
42
|
},
|
|
42
43
|
"devDependencies": {
|
|
43
44
|
"@earendil-works/pi-ai": "^0.99.1",
|
|
44
45
|
"@earendil-works/pi-coding-agent": "^0.99.1",
|
|
46
|
+
"@earendil-works/pi-tui": "^0.99.1",
|
|
45
47
|
"@types/node": "^24.0.0",
|
|
46
48
|
"typescript": "^5.9.0"
|
|
47
49
|
},
|
package/src/format.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export function formatWindow(seconds: number): string {
|
|
2
|
+
if (seconds % 604800 === 0) return seconds === 604800 ? "weekly" : `${seconds / 604800}-week`;
|
|
3
|
+
if (seconds % 86400 === 0) return seconds === 86400 ? "daily" : `${seconds / 86400}-day`;
|
|
4
|
+
if (seconds % 3600 === 0) return `${seconds / 3600}h`;
|
|
5
|
+
return `${Math.round(seconds / 60)}m`;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function formatReset(at: number): string {
|
|
9
|
+
const date = new Date(at);
|
|
10
|
+
const time = date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
|
|
11
|
+
return date.toDateString() === new Date().toDateString()
|
|
12
|
+
? `at ${time}`
|
|
13
|
+
: `${date.toLocaleDateString([], { weekday: "short", month: "short", day: "numeric" })} at ${time}`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function formatIn(ms: number): string {
|
|
17
|
+
const minutes = Math.max(1, Math.ceil(ms / 60_000));
|
|
18
|
+
const days = Math.floor(minutes / 1440);
|
|
19
|
+
const hours = Math.floor((minutes % 1440) / 60);
|
|
20
|
+
const rest = minutes % 60;
|
|
21
|
+
if (days > 0) return hours > 0 ? `${days}d ${hours}h` : `${days}d`;
|
|
22
|
+
if (hours > 0) return rest > 0 ? `${hours}h ${rest}m` : `${hours}h`;
|
|
23
|
+
return `${rest}m`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function usageBar(percent: number, width: number) {
|
|
27
|
+
const clamped = Math.min(100, Math.max(0, percent));
|
|
28
|
+
const filled = Math.round((clamped / 100) * width);
|
|
29
|
+
return { filled: "█".repeat(filled), empty: "░".repeat(width - filled) };
|
|
30
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -6,7 +6,9 @@ import { BorderedLoader, type ExtensionAPI, type ExtensionContext, getAgentDir }
|
|
|
6
6
|
import {
|
|
7
7
|
type Account,
|
|
8
8
|
isStrategy,
|
|
9
|
+
failoverMessage,
|
|
9
10
|
limitCooldown,
|
|
11
|
+
loginName,
|
|
10
12
|
nextAccountId,
|
|
11
13
|
orderAccounts,
|
|
12
14
|
parseResetAt,
|
|
@@ -15,6 +17,8 @@ import {
|
|
|
15
17
|
type Strategy,
|
|
16
18
|
type UsageSnapshot,
|
|
17
19
|
} from "./pool.ts";
|
|
20
|
+
import { formatIn, formatReset, formatWindow } from "./format.ts";
|
|
21
|
+
import { type AccountStatus, type PanelAction, PoolPanel } from "./panel.ts";
|
|
18
22
|
import { fetchUsage, spendReset, type Usage } from "./usage.ts";
|
|
19
23
|
|
|
20
24
|
const POOL_PROVIDER = "chatgpt";
|
|
@@ -76,7 +80,7 @@ function subscriptionModels(providers: readonly Provider[]): Model<Api>[] {
|
|
|
76
80
|
}
|
|
77
81
|
|
|
78
82
|
function accountProvider(openai: Provider, account: Account, onReset: ResetListener): Provider {
|
|
79
|
-
const name =
|
|
83
|
+
const name = loginName(account);
|
|
80
84
|
const signIn = openai.auth.oauth;
|
|
81
85
|
if (!signIn) throw new Error("pi's openai provider has no Sign in with ChatGPT flow; update pi to 0.99.1 or later");
|
|
82
86
|
const models: Model<Api>[] = subscriptionModels(builtinProviders()).map((model) => ({
|
|
@@ -96,20 +100,6 @@ function accountProvider(openai: Provider, account: Account, onReset: ResetListe
|
|
|
96
100
|
};
|
|
97
101
|
}
|
|
98
102
|
|
|
99
|
-
function formatWindow(seconds: number): string {
|
|
100
|
-
if (seconds % 604800 === 0) return seconds === 604800 ? "weekly" : `${seconds / 604800}-week`;
|
|
101
|
-
if (seconds % 3600 === 0) return `${seconds / 3600}h`;
|
|
102
|
-
return `${Math.round(seconds / 60)}m`;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
function formatReset(at: number): string {
|
|
106
|
-
const date = new Date(at);
|
|
107
|
-
const time = date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
|
|
108
|
-
return date.toDateString() === new Date().toDateString()
|
|
109
|
-
? `at ${time}`
|
|
110
|
-
: `${date.toLocaleDateString([], { weekday: "short", month: "short", day: "numeric" })} at ${time}`;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
103
|
export default function chatgptPool(pi: ExtensionAPI) {
|
|
114
104
|
const providers = builtinProviders();
|
|
115
105
|
const openai = providers.find((provider) => provider.id === "openai");
|
|
@@ -142,6 +132,14 @@ export default function chatgptPool(pi: ExtensionAPI) {
|
|
|
142
132
|
);
|
|
143
133
|
return new Map([...usageCache].map(([id, entry]) => [id, entry.snapshot]));
|
|
144
134
|
};
|
|
135
|
+
const refreshUsage = async (ctx: ExtensionContext, accountId: string) => {
|
|
136
|
+
const cached = usageCache.get(accountId);
|
|
137
|
+
if (cached && Date.now() - cached.at < USAGE_TTL_MS) return;
|
|
138
|
+
const token = await ctx.modelRegistry.getApiKeyForProvider(accountId).catch(() => undefined);
|
|
139
|
+
if (!token) return;
|
|
140
|
+
await fetchUsage(token).then((usage) => rememberUsage(accountId, usage), () => undefined);
|
|
141
|
+
};
|
|
142
|
+
|
|
145
143
|
const reportedResets = new Map<string, number>();
|
|
146
144
|
const onReset: ResetListener = (accountId, resetsAt) => reportedResets.set(accountId, resetsAt);
|
|
147
145
|
|
|
@@ -165,11 +163,16 @@ export default function chatgptPool(pi: ExtensionAPI) {
|
|
|
165
163
|
if (provider !== POOL_PROVIDER) return ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
166
164
|
const now = Date.now();
|
|
167
165
|
const active = accounts.find((account) => account.id === lastRouted);
|
|
166
|
+
const snapshot = active && usageCache.get(active.id)?.snapshot;
|
|
168
167
|
const limited = accounts.filter((account) => (account.resetsAt ?? 0) > now);
|
|
169
168
|
const parts = [`chatgpt: ${active?.label ?? "pool"}`];
|
|
169
|
+
if (snapshot) {
|
|
170
|
+
parts[0] += ` ${Math.round(snapshot.usedPercent)}%`;
|
|
171
|
+
parts.push(`resets in ${formatIn(snapshot.resetsAt - now)}`);
|
|
172
|
+
}
|
|
170
173
|
if (limited.length > 0) {
|
|
171
174
|
const next = Math.min(...limited.map((account) => account.resetsAt ?? now));
|
|
172
|
-
parts.push(`${limited.length} limited, next back ${
|
|
175
|
+
parts.push(`${limited.length} limited, next back in ${formatIn(next - now)}`);
|
|
173
176
|
}
|
|
174
177
|
ctx.ui.setStatus(STATUS_KEY, parts.join(" · "));
|
|
175
178
|
};
|
|
@@ -212,10 +215,18 @@ export default function chatgptPool(pi: ExtensionAPI) {
|
|
|
212
215
|
: "Every ChatGPT account in the pool is signed out or limited. Run /chatgpt-pool to see reset times and banked resets.",
|
|
213
216
|
);
|
|
214
217
|
}
|
|
218
|
+
if (sticky !== undefined && sticky !== routed.provider && ctx.hasUI) {
|
|
219
|
+
const from = accounts.find((candidate) => candidate.id === sticky);
|
|
220
|
+
const until = from?.resetsAt && from.resetsAt > Date.now() ? ` (back in ${formatIn(from.resetsAt - Date.now())})` : "";
|
|
221
|
+
ctx.ui.notify(`ChatGPT pool: ${from?.label ?? sticky} is limited${until}, switched to ${account.label}.`, "info");
|
|
222
|
+
}
|
|
215
223
|
if (lastRouted !== routed.provider) {
|
|
216
224
|
lastRouted = routed.provider;
|
|
217
225
|
updateStatus(ctx);
|
|
218
226
|
}
|
|
227
|
+
refreshUsage(ctx, routed.provider)
|
|
228
|
+
.then(() => updateStatus(ctx, POOL_PROVIDER))
|
|
229
|
+
.catch(() => undefined);
|
|
219
230
|
return { model: routed, thinkingLevel: request.thinkingLevel };
|
|
220
231
|
},
|
|
221
232
|
});
|
|
@@ -237,13 +248,11 @@ export default function chatgptPool(pi: ExtensionAPI) {
|
|
|
237
248
|
return {
|
|
238
249
|
message: {
|
|
239
250
|
...message,
|
|
240
|
-
errorMessage:
|
|
251
|
+
errorMessage: failoverMessage(account.label),
|
|
241
252
|
},
|
|
242
253
|
};
|
|
243
254
|
});
|
|
244
255
|
|
|
245
|
-
type AccountStatus = { account: Account; signedIn: boolean; usage?: Usage; error?: string };
|
|
246
|
-
|
|
247
256
|
const loadStatuses = (ctx: ExtensionContext, signal?: AbortSignal): Promise<AccountStatus[]> =>
|
|
248
257
|
Promise.all(
|
|
249
258
|
accounts.map(async (account): Promise<AccountStatus> => {
|
|
@@ -275,7 +284,7 @@ export default function chatgptPool(pi: ExtensionAPI) {
|
|
|
275
284
|
|
|
276
285
|
const describe = ({ account, signedIn, usage, error }: AccountStatus): string => {
|
|
277
286
|
const limitedUntil = account.resetsAt && account.resetsAt > Date.now() ? account.resetsAt : undefined;
|
|
278
|
-
if (!signedIn) return `${account.label} · not signed in, run /login and pick
|
|
287
|
+
if (!signedIn) return `${account.label} · not signed in, run /login and pick ${loginName(account)}`;
|
|
279
288
|
const state = limitedUntil ? `limited until ${formatReset(limitedUntil)}` : "ready";
|
|
280
289
|
if (!usage) return `${account.label} · ${state} · usage unavailable${error ? ` (${error})` : ""}`;
|
|
281
290
|
const windows = usage.windows.map(
|
|
@@ -297,7 +306,25 @@ export default function chatgptPool(pi: ExtensionAPI) {
|
|
|
297
306
|
update((config) => ({ ...config, accounts: [...config.accounts, account] }));
|
|
298
307
|
registerPooledModels();
|
|
299
308
|
pi.registerProvider(accountProvider(openai, account, onReset));
|
|
300
|
-
ctx.ui.notify(`Added
|
|
309
|
+
ctx.ui.notify(`Added ${loginName(account)}. Run /login and pick it to sign in.`, "info");
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
const setLoginName = (ctx: ExtensionContext, account: Account, name: string) => {
|
|
313
|
+
const next = { ...account, name: name.trim() || undefined };
|
|
314
|
+
update((config) => ({
|
|
315
|
+
...config,
|
|
316
|
+
accounts: config.accounts.map((candidate) => (candidate.id === account.id ? next : candidate)),
|
|
317
|
+
}));
|
|
318
|
+
pi.registerProvider(accountProvider(openai, next, onReset));
|
|
319
|
+
ctx.ui.notify(`${account.label} now shows as "${loginName(next)}" in /login. Its sign-in is unchanged.`, "info");
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
const askLoginName = async (ctx: ExtensionContext, account: Account) => {
|
|
323
|
+
const name = await ctx.ui.input(
|
|
324
|
+
`Name shown in /login for ${account.label} (empty for "ChatGPT (${account.label})")`,
|
|
325
|
+
loginName(account),
|
|
326
|
+
);
|
|
327
|
+
if (name !== undefined) setLoginName(ctx, account, name);
|
|
301
328
|
};
|
|
302
329
|
|
|
303
330
|
const removeAccount = (ctx: ExtensionContext, account: Account) => {
|
|
@@ -352,7 +379,52 @@ export default function chatgptPool(pi: ExtensionAPI) {
|
|
|
352
379
|
if (next && isStrategy(next)) setStrategy(ctx, next);
|
|
353
380
|
};
|
|
354
381
|
|
|
382
|
+
const runPanel = async (ctx: ExtensionContext) => {
|
|
383
|
+
let statuses = await withLoader(ctx, "Checking ChatGPT accounts...", (signal) => loadStatuses(ctx, signal));
|
|
384
|
+
while (statuses) {
|
|
385
|
+
updateStatus(ctx);
|
|
386
|
+
const current = statuses.map((status) => ({
|
|
387
|
+
...status,
|
|
388
|
+
account: accounts.find((account) => account.id === status.account.id) ?? status.account,
|
|
389
|
+
}));
|
|
390
|
+
const strategyName = STRATEGIES[strategy].split(":")[0];
|
|
391
|
+
const action = await ctx.ui.custom<PanelAction>(
|
|
392
|
+
(tui, theme, _keybindings, done) => new PoolPanel(tui, theme, current, strategyName, lastRouted, done),
|
|
393
|
+
);
|
|
394
|
+
if (!action || action.type === "close") return;
|
|
395
|
+
const status = "accountId" in action ? current.find((entry) => entry.account.id === action.accountId) : undefined;
|
|
396
|
+
let reload = false;
|
|
397
|
+
if (action.type === "refresh") {
|
|
398
|
+
reload = true;
|
|
399
|
+
} else if (action.type === "add") {
|
|
400
|
+
const label = (await ctx.ui.input("Label for the new ChatGPT account", "work"))?.trim();
|
|
401
|
+
if (label) addAccount(ctx, label);
|
|
402
|
+
reload = !!label;
|
|
403
|
+
} else if (action.type === "strategy") {
|
|
404
|
+
await chooseStrategy(ctx);
|
|
405
|
+
} else if (status && action.type === "spend") {
|
|
406
|
+
await spend(ctx, status.account, status.usage);
|
|
407
|
+
reload = true;
|
|
408
|
+
} else if (status && action.type === "name") {
|
|
409
|
+
await askLoginName(ctx, status.account);
|
|
410
|
+
} else if (status && action.type === "clear") {
|
|
411
|
+
setResetsAt(status.account.id, undefined);
|
|
412
|
+
ctx.ui.notify(`ChatGPT (${status.account.label}) is back in rotation.`, "info");
|
|
413
|
+
} else if (status && action.type === "remove") {
|
|
414
|
+
if (await ctx.ui.confirm(`Remove ${status.account.label}?`, "It will no longer be used by the pool.")) {
|
|
415
|
+
removeAccount(ctx, status.account);
|
|
416
|
+
statuses = statuses.filter((entry) => entry.account.id !== status.account.id);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
if (reload) {
|
|
420
|
+
usageCache.clear();
|
|
421
|
+
statuses = await withLoader(ctx, "Checking ChatGPT accounts...", (signal) => loadStatuses(ctx, signal));
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
};
|
|
425
|
+
|
|
355
426
|
const openPool = async (ctx: ExtensionContext) => {
|
|
427
|
+
if (ctx.mode === "tui") return runPanel(ctx);
|
|
356
428
|
const statuses = await withLoader(ctx, "Checking ChatGPT accounts...", (signal) => loadStatuses(ctx, signal));
|
|
357
429
|
if (!statuses) return;
|
|
358
430
|
updateStatus(ctx);
|
|
@@ -390,12 +462,13 @@ export default function chatgptPool(pi: ExtensionAPI) {
|
|
|
390
462
|
{ value: "add", label: "add", description: "Add an account slot" },
|
|
391
463
|
{ value: "remove", label: "remove", description: "Remove an account" },
|
|
392
464
|
{ value: "spend", label: "spend", description: "Spend a banked reset, after confirming" },
|
|
465
|
+
{ value: "name", label: "name", description: "Set the name an account shows in /login" },
|
|
393
466
|
{ value: "reset", label: "reset", description: "Forget recorded limits" },
|
|
394
467
|
{ value: "strategy", label: "strategy", description: "Choose how new conversations pick an account" },
|
|
395
468
|
];
|
|
396
469
|
|
|
397
470
|
pi.registerCommand("chatgpt-pool", {
|
|
398
|
-
description: "Manage pooled ChatGPT accounts: usage, banked resets, strategy, add, remove, spend, reset",
|
|
471
|
+
description: "Manage pooled ChatGPT accounts: usage, banked resets, strategy, add, remove, name, spend, reset",
|
|
399
472
|
getArgumentCompletions(prefix) {
|
|
400
473
|
const [action, ...rest] = prefix.split(" ");
|
|
401
474
|
if (rest.length === 0) return SUBCOMMANDS.filter((item) => item.value.startsWith(action));
|
|
@@ -404,7 +477,8 @@ export default function chatgptPool(pi: ExtensionAPI) {
|
|
|
404
477
|
.filter(([name]) => name.startsWith(rest.join(" ")))
|
|
405
478
|
.map(([name, description]) => ({ value: `strategy ${name}`, label: name, description }));
|
|
406
479
|
}
|
|
407
|
-
if (action !== "remove" && action !== "spend") return null;
|
|
480
|
+
if (action !== "remove" && action !== "spend" && action !== "name") return null;
|
|
481
|
+
if (action === "name" && rest.length > 1) return null;
|
|
408
482
|
const partial = rest.join(" ");
|
|
409
483
|
return accounts
|
|
410
484
|
.filter((account) => account.label.startsWith(partial))
|
|
@@ -425,8 +499,16 @@ export default function chatgptPool(pi: ExtensionAPI) {
|
|
|
425
499
|
return removeAccount(ctx, account);
|
|
426
500
|
}
|
|
427
501
|
|
|
502
|
+
if (action === "name") {
|
|
503
|
+
const [target = "", ...nameParts] = rest;
|
|
504
|
+
const account = findAccount(target);
|
|
505
|
+
if (!account) return ctx.ui.notify("Usage: /chatgpt-pool name <label> <name shown in /login>", "warning");
|
|
506
|
+
if (nameParts.length > 0 || !ctx.hasUI) return setLoginName(ctx, account, nameParts.join(" "));
|
|
507
|
+
return askLoginName(ctx, account);
|
|
508
|
+
}
|
|
509
|
+
|
|
428
510
|
if (action === "reset") {
|
|
429
|
-
update((config) => ({ ...config, accounts: config.accounts.map((
|
|
511
|
+
update((config) => ({ ...config, accounts: config.accounts.map((account) => ({ ...account, resetsAt: undefined })) }));
|
|
430
512
|
updateStatus(ctx);
|
|
431
513
|
return ctx.ui.notify("Cleared recorded limits. Every signed-in account is back in rotation.", "info");
|
|
432
514
|
}
|
|
@@ -451,7 +533,7 @@ export default function chatgptPool(pi: ExtensionAPI) {
|
|
|
451
533
|
return spend(ctx, account);
|
|
452
534
|
}
|
|
453
535
|
|
|
454
|
-
if (action) return ctx.ui.notify(`Unknown action "${action}". Use add, remove, spend, reset, or strategy.`, "warning");
|
|
536
|
+
if (action) return ctx.ui.notify(`Unknown action "${action}". Use add, remove, name, spend, reset, or strategy.`, "warning");
|
|
455
537
|
if (accounts.length === 0) {
|
|
456
538
|
const label = (await ctx.ui.input("No ChatGPT accounts yet. Label for the first one", "personal"))?.trim();
|
|
457
539
|
if (label) addAccount(ctx, label);
|
package/src/panel.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { type Component, Key, matchesKey, truncateToWidth, type TUI, visibleWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import { formatIn, formatWindow, usageBar } from "./format.ts";
|
|
4
|
+
import { type Account, loginName } from "./pool.ts";
|
|
5
|
+
import type { Usage } from "./usage.ts";
|
|
6
|
+
|
|
7
|
+
export type AccountStatus = { account: Account; signedIn: boolean; usage?: Usage; error?: string };
|
|
8
|
+
|
|
9
|
+
export type PanelAction =
|
|
10
|
+
| { type: "close" }
|
|
11
|
+
| { type: "refresh" }
|
|
12
|
+
| { type: "add" }
|
|
13
|
+
| { type: "strategy" }
|
|
14
|
+
| { type: "spend" | "clear" | "remove" | "name"; accountId: string };
|
|
15
|
+
|
|
16
|
+
const ACCOUNT_KEYS = new Map<string, "spend" | "clear" | "remove" | "name">([
|
|
17
|
+
["s", "spend"],
|
|
18
|
+
["c", "clear"],
|
|
19
|
+
["d", "remove"],
|
|
20
|
+
["n", "name"],
|
|
21
|
+
]);
|
|
22
|
+
const POOL_KEYS = new Map<string, "refresh" | "add" | "strategy">([
|
|
23
|
+
["r", "refresh"],
|
|
24
|
+
["a", "add"],
|
|
25
|
+
["t", "strategy"],
|
|
26
|
+
]);
|
|
27
|
+
const BAR_WIDTH = 16;
|
|
28
|
+
const HINTS = [
|
|
29
|
+
"↑↓ select · s spend reset · c clear limit · n login name · d remove · a add · t strategy · r refresh · esc close",
|
|
30
|
+
"↑↓ · s spend · c clear · n name · d remove · a add · t strategy · r refresh · esc",
|
|
31
|
+
"s spend · c clear · n name · d del · a add · esc",
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
export class PoolPanel implements Component {
|
|
35
|
+
private selected = 0;
|
|
36
|
+
|
|
37
|
+
constructor(
|
|
38
|
+
private readonly tui: TUI,
|
|
39
|
+
private readonly theme: Theme,
|
|
40
|
+
private readonly statuses: readonly AccountStatus[],
|
|
41
|
+
private readonly strategyName: string,
|
|
42
|
+
private readonly activeId: string | undefined,
|
|
43
|
+
private readonly done: (action: PanelAction) => void,
|
|
44
|
+
) {}
|
|
45
|
+
|
|
46
|
+
handleInput(data: string): void {
|
|
47
|
+
if (matchesKey(data, Key.escape) || data === "q") return this.done({ type: "close" });
|
|
48
|
+
if (matchesKey(data, Key.up) || data === "k") return this.move(-1);
|
|
49
|
+
if (matchesKey(data, Key.down) || data === "j") return this.move(1);
|
|
50
|
+
const poolAction = POOL_KEYS.get(data);
|
|
51
|
+
const accountAction = ACCOUNT_KEYS.get(data);
|
|
52
|
+
const account = this.statuses[this.selected]?.account;
|
|
53
|
+
if (poolAction) this.done({ type: poolAction });
|
|
54
|
+
else if (accountAction && account) this.done({ type: accountAction, accountId: account.id });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
invalidate(): void {}
|
|
58
|
+
|
|
59
|
+
render(width: number): string[] {
|
|
60
|
+
const { theme } = this;
|
|
61
|
+
const rule = theme.fg("borderAccent", "─".repeat(width));
|
|
62
|
+
const lines = [
|
|
63
|
+
rule,
|
|
64
|
+
`${theme.fg("accent", theme.bold("ChatGPT pool"))} ${theme.fg("muted", `strategy: ${this.strategyName}`)}`,
|
|
65
|
+
"",
|
|
66
|
+
];
|
|
67
|
+
if (this.statuses.length === 0) lines.push(theme.fg("muted", "No accounts yet. Press a to add one."), "");
|
|
68
|
+
this.statuses.forEach((status, index) => lines.push(...this.renderAccount(status, index === this.selected), ""));
|
|
69
|
+
const hint = HINTS.find((text) => visibleWidth(text) <= width) ?? HINTS[HINTS.length - 1];
|
|
70
|
+
lines.push(theme.fg("dim", hint), rule);
|
|
71
|
+
return lines.map((line) => truncateToWidth(line, width));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
private move(delta: number): void {
|
|
75
|
+
if (this.statuses.length === 0) return;
|
|
76
|
+
this.selected = (this.selected + delta + this.statuses.length) % this.statuses.length;
|
|
77
|
+
this.tui.requestRender();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private renderAccount({ account, signedIn, usage, error }: AccountStatus, selected: boolean): string[] {
|
|
81
|
+
const { theme } = this;
|
|
82
|
+
const now = Date.now();
|
|
83
|
+
const pointer = selected ? theme.fg("accent", "› ") : " ";
|
|
84
|
+
const name = selected ? theme.fg("accent", theme.bold(account.label)) : theme.bold(account.label);
|
|
85
|
+
const active = account.id === this.activeId ? theme.fg("muted", " (in use)") : "";
|
|
86
|
+
const limitedUntil = account.resetsAt && account.resetsAt > now ? account.resetsAt : undefined;
|
|
87
|
+
const state = !signedIn
|
|
88
|
+
? theme.fg("muted", "not signed in")
|
|
89
|
+
: limitedUntil
|
|
90
|
+
? theme.fg("warning", `limited · back in ${formatIn(limitedUntil - now)}`)
|
|
91
|
+
: theme.fg("success", "ready");
|
|
92
|
+
const banked =
|
|
93
|
+
usage && usage.banked > 0
|
|
94
|
+
? theme.fg("accent", ` ${usage.banked} banked reset${usage.banked === 1 ? "" : "s"}`) +
|
|
95
|
+
(usage.usableNow > 0 ? theme.fg("success", ` (${usage.usableNow} usable now)`) : "")
|
|
96
|
+
: "";
|
|
97
|
+
const header = `${pointer}${name}${active} ${state}${banked}`;
|
|
98
|
+
|
|
99
|
+
if (!signedIn) return [header, theme.fg("dim", ` run /login and pick ${loginName(account)}`)];
|
|
100
|
+
if (!usage) return [header, theme.fg("dim", ` usage unavailable${error ? `: ${error}` : ""}`)];
|
|
101
|
+
return [
|
|
102
|
+
header,
|
|
103
|
+
...usage.windows.map((window) => {
|
|
104
|
+
const bar = usageBar(window.usedPercent, BAR_WIDTH);
|
|
105
|
+
const color = window.usedPercent >= 90 ? "error" : window.usedPercent >= 70 ? "warning" : "success";
|
|
106
|
+
const label = formatWindow(window.windowSeconds).padEnd(7);
|
|
107
|
+
const percent = `${Math.round(window.usedPercent)}%`.padStart(4);
|
|
108
|
+
return ` ${theme.fg("muted", label)} ${theme.fg(color, bar.filled)}${theme.fg("dim", bar.empty)} ${percent} ${theme.fg("muted", `resets in ${formatIn(window.resetsAt - now)}`)}`;
|
|
109
|
+
}),
|
|
110
|
+
];
|
|
111
|
+
}
|
|
112
|
+
}
|
package/src/pool.ts
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
|
-
export type Account = { id: string; label: string; resetsAt?: number };
|
|
1
|
+
export type Account = { id: string; label: string; name?: string; resetsAt?: number };
|
|
2
|
+
|
|
3
|
+
export function loginName(account: Account): string {
|
|
4
|
+
return account.name || `ChatGPT (${account.label})`;
|
|
5
|
+
}
|
|
2
6
|
|
|
3
7
|
const USAGE_LIMIT = /usage.?limit|subscription_sharing_usage_limit_exceeded|insufficient_quota|quota/i;
|
|
4
8
|
const RATE_LIMIT = /rate.?limit|too many requests|\b429\b/i;
|
|
5
9
|
|
|
10
|
+
export function failoverMessage(label: string): string {
|
|
11
|
+
return `ChatGPT account "${label}" hit its rate limit; switching to another account in the pool.`;
|
|
12
|
+
}
|
|
13
|
+
|
|
6
14
|
export const USAGE_COOLDOWN_MS = 60 * 60 * 1000;
|
|
7
15
|
export const RATE_COOLDOWN_MS = 60 * 1000;
|
|
8
16
|
|