pi-chatgpt-pool 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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Justin Levine
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,89 @@
1
+ # pi-chatgpt-pool
2
+
3
+ Pool several ChatGPT accounts in [pi](https://github.com/earendil-works/pi) using OpenAI's Sign in with ChatGPT. You pick one model, and each request goes to a signed-in account. When an account hits a rate limit or its usage limit, pi retries the request on the next account.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pi install npm:pi-chatgpt-pool
9
+ ```
10
+
11
+ Requires pi 0.99.1 or later. The plugin uses pi's own Sign in with ChatGPT flow, which 0.99.0 shipped without.
12
+
13
+ ## Set up accounts
14
+
15
+ Run `/chatgpt-pool` and choose **+ Add account**, or add them directly:
16
+
17
+ ```text
18
+ /chatgpt-pool add personal
19
+ /chatgpt-pool add work
20
+ /login
21
+ ```
22
+
23
+ `/login` lists each account as `ChatGPT (<label>)`. Sign in to each one with a different ChatGPT account. Then pick a pooled model:
24
+
25
+ ```text
26
+ /model chatgpt/gpt-5.6-sol
27
+ ```
28
+
29
+ The pool offers the ChatGPT subscription models: the OpenAI models that pi also lists for OpenAI Codex, such as `gpt-6.1-sol` and `gpt-5.6-sol`. New models appear when pi's catalog adds them. Pooled models show up once you've added at least one account. Each account also shows up as its own provider (`chatgpt-1`, `chatgpt-2`, ...) if you want to pin one.
30
+
31
+ ## Commands
32
+
33
+ | Command | What it does |
34
+ | --- | --- |
35
+ | `/chatgpt-pool` | Open the pool: each account's usage, reset times, and banked resets. Pick an account to spend a reset, clear its limit, or remove it |
36
+ | `/chatgpt-pool add <label>` | Add an account slot. Sign in to it with `/login` |
37
+ | `/chatgpt-pool remove <label>` | Remove an account. Run `/logout` for it first to delete its token |
38
+ | `/chatgpt-pool spend <label>` | Spend one banked reset on that account, after you confirm |
39
+ | `/chatgpt-pool strategy <name>` | Set the routing strategy: `fill-first`, `round-robin`, `least-used`, or `use-it-or-lose-it` |
40
+ | `/chatgpt-pool reset` | Forget recorded limits, for example after a limit clears early |
41
+
42
+ The command autocompletes its actions and account labels. The footer shows `chatgpt: <account>` while a pooled model is selected, plus how many accounts are limited and when the next one comes back.
43
+
44
+ `/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
+
46
+ ```bash
47
+ pi auth check --provider chatgpt-1 # is this account signed in?
48
+ pi --list-models chatgpt # pooled models
49
+ pi --model chatgpt/gpt-5.6-sol # start pi on the pool
50
+ ```
51
+
52
+ Accounts are stored in `~/.pi/agent/chatgpt-pool.json`. Tokens are stored by pi in `~/.pi/agent/auth.json`.
53
+
54
+ ## Strategies
55
+
56
+ A conversation stays on the same account while it works, so prompt caching and reasoning replay keep working. The strategy decides where each new conversation starts, and which account a limited conversation moves to.
57
+
58
+ | Strategy | New conversations go to |
59
+ | --- | --- |
60
+ | Fill first (default) | The first ready account, in the order you added them |
61
+ | Round robin | The next account in turn, so load spreads evenly |
62
+ | Least used | The account with the lowest usage % |
63
+ | Use it or lose it | The account whose usage window resets soonest, so its remaining quota isn't wasted |
64
+
65
+ Choose one from the **Strategy** row in `/chatgpt-pool`, or run `/chatgpt-pool strategy <name>`. It's saved in `chatgpt-pool.json`, and the round-robin position is shared across pi sessions. Least used and use it or lose it read usage from ChatGPT, cached for 5 minutes; accounts whose usage can't be read go last.
66
+
67
+ ## How failover works
68
+
69
+ - When an account hits a limit, the plugin reads the reset time from the 429 response (`resets_at` in the body, `retry-after`, or the rate-limit reset headers) and skips that account until then. If the response has no reset time, it waits 1 minute for a rate limit or 1 hour for a usage limit.
70
+ - The next request goes to the first ready account in the current strategy's order.
71
+ - Reset times are saved in `chatgpt-pool.json`, so they survive restarts.
72
+ - If every account is signed out or limited, the request fails with a message pointing to `/chatgpt-pool`.
73
+
74
+ ## Banked resets
75
+
76
+ ChatGPT gives some accounts reset credits that clear a usage limit early. `/chatgpt-pool` shows how many each account has banked and how many apply right now.
77
+
78
+ The plugin never spends one on its own. Pick the account in `/chatgpt-pool`, or run `/chatgpt-pool spend <label>`. It picks the credit that expires first and asks you to confirm. It warns you when ChatGPT reports nothing to reset.
79
+
80
+ Usage and banked resets come from ChatGPT's `backend-api/wham` endpoints, the same ones the Codex CLI uses.
81
+
82
+ ## Releasing
83
+
84
+ ```bash
85
+ npm version patch # or minor / major: bumps package.json, commits, and tags
86
+ git push --follow-tags
87
+ ```
88
+
89
+ Pushing a `v*` tag runs `.github/workflows/publish.yml`. It checks that the tag matches `package.json`, runs the type check and tests, publishes to npm with provenance through npm trusted publishing, and creates a GitHub release with generated notes.
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "pi-chatgpt-pool",
3
+ "version": "0.1.0",
4
+ "description": "Pool several Sign in with ChatGPT accounts in pi, with routing strategies, failover, and banked resets",
5
+ "keywords": [
6
+ "pi-package",
7
+ "pi-extension",
8
+ "openai",
9
+ "chatgpt",
10
+ "oauth",
11
+ "failover"
12
+ ],
13
+ "license": "MIT",
14
+ "author": "Justin Levine",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/jal-co/pi-chatgpt-pool.git"
18
+ },
19
+ "homepage": "https://github.com/jal-co/pi-chatgpt-pool#readme",
20
+ "bugs": {
21
+ "url": "https://github.com/jal-co/pi-chatgpt-pool/issues"
22
+ },
23
+ "type": "module",
24
+ "files": [
25
+ "src",
26
+ "README.md",
27
+ "LICENSE"
28
+ ],
29
+ "pi": {
30
+ "extensions": [
31
+ "./src/index.ts"
32
+ ]
33
+ },
34
+ "scripts": {
35
+ "typecheck": "tsc --noEmit",
36
+ "test": "node --test test/*.test.ts"
37
+ },
38
+ "peerDependencies": {
39
+ "@earendil-works/pi-ai": "*",
40
+ "@earendil-works/pi-coding-agent": "*"
41
+ },
42
+ "devDependencies": {
43
+ "@earendil-works/pi-ai": "^0.99.1",
44
+ "@earendil-works/pi-coding-agent": "^0.99.1",
45
+ "@types/node": "^24.0.0",
46
+ "typescript": "^5.9.0"
47
+ },
48
+ "engines": {
49
+ "node": ">=22.18"
50
+ }
51
+ }
package/src/index.ts ADDED
@@ -0,0 +1,463 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { getSupportedThinkingLevels, type Api, type Model, type Provider } from "@earendil-works/pi-ai";
4
+ import { builtinProviders } from "@earendil-works/pi-ai/providers/all";
5
+ import { BorderedLoader, type ExtensionAPI, type ExtensionContext, getAgentDir } from "@earendil-works/pi-coding-agent";
6
+ import {
7
+ type Account,
8
+ isStrategy,
9
+ limitCooldown,
10
+ nextAccountId,
11
+ orderAccounts,
12
+ parseResetAt,
13
+ pickAccount,
14
+ STRATEGIES,
15
+ type Strategy,
16
+ type UsageSnapshot,
17
+ } from "./pool.ts";
18
+ import { fetchUsage, spendReset, type Usage } from "./usage.ts";
19
+
20
+ const POOL_PROVIDER = "chatgpt";
21
+ const STATUS_KEY = "chatgpt-pool";
22
+ const CHATGPT_COMPAT = {
23
+ supportsLongCacheRetention: false,
24
+ supportsExplicitPromptCacheMode: false,
25
+ supportsMaxOutputTokens: false,
26
+ };
27
+
28
+ function configPath(): string {
29
+ return join(getAgentDir(), "chatgpt-pool.json");
30
+ }
31
+
32
+ type PoolConfig = { strategy: Strategy; rotation: number; accounts: Account[] };
33
+ const USAGE_TTL_MS = 5 * 60 * 1000;
34
+
35
+ function loadConfig(): PoolConfig {
36
+ if (!existsSync(configPath())) return { strategy: "fill-first", rotation: 0, accounts: [] };
37
+ const raw = JSON.parse(readFileSync(configPath(), "utf8"));
38
+ return {
39
+ strategy: isStrategy(raw.strategy ?? "") ? raw.strategy : "fill-first",
40
+ rotation: Number.isInteger(raw.rotation) ? raw.rotation : 0,
41
+ accounts: raw.accounts ?? [],
42
+ };
43
+ }
44
+
45
+ function saveConfig(config: PoolConfig): void {
46
+ mkdirSync(dirname(configPath()), { recursive: true });
47
+ writeFileSync(configPath(), `${JSON.stringify(config, null, 2)}\n`);
48
+ }
49
+
50
+ function snapshotOf(usage: Usage): UsageSnapshot | undefined {
51
+ if (usage.windows.length === 0) return undefined;
52
+ return {
53
+ usedPercent: usage.limitReached ? 100 : Math.max(...usage.windows.map((w) => w.usedPercent)),
54
+ resetsAt: Math.min(...usage.windows.map((w) => w.resetsAt)),
55
+ };
56
+ }
57
+
58
+ type ResetListener = (accountId: string, resetsAt: number) => void;
59
+
60
+ function limitAwareFetch(accountId: string, onReset: ResetListener, base: typeof fetch = fetch): typeof fetch {
61
+ return async (input, init) => {
62
+ const response = await base(input, init);
63
+ if (response.status === 429) {
64
+ const resetsAt = parseResetAt(response.headers, await response.clone().text(), Date.now());
65
+ if (resetsAt !== undefined) onReset(accountId, resetsAt);
66
+ }
67
+ return response;
68
+ };
69
+ }
70
+
71
+ function subscriptionModels(providers: readonly Provider[]): Model<Api>[] {
72
+ const openai = providers.find((provider) => provider.id === "openai");
73
+ if (!openai) throw new Error("pi-chatgpt-pool needs pi's built-in openai provider");
74
+ const codex = new Set(providers.find((provider) => provider.id === "openai-codex")?.getModels().map((model) => model.id));
75
+ return openai.getModels().filter((model) => codex.has(model.id));
76
+ }
77
+
78
+ function accountProvider(openai: Provider, account: Account, onReset: ResetListener): Provider {
79
+ const name = `ChatGPT (${account.label})`;
80
+ const signIn = openai.auth.oauth;
81
+ 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
+ const models: Model<Api>[] = subscriptionModels(builtinProviders()).map((model) => ({
83
+ ...model,
84
+ provider: account.id,
85
+ compat: { ...model.compat, ...CHATGPT_COMPAT },
86
+ }));
87
+ return {
88
+ id: account.id,
89
+ name,
90
+ baseUrl: openai.baseUrl,
91
+ auth: { oauth: { ...signIn, name } },
92
+ getModels: () => models,
93
+ stream: (model, context, options) => openai.stream(model, context, options),
94
+ streamSimple: (model, context, options) =>
95
+ openai.streamSimple(model, context, { ...options, fetch: limitAwareFetch(account.id, onReset, options?.fetch) }),
96
+ };
97
+ }
98
+
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
+ export default function chatgptPool(pi: ExtensionAPI) {
114
+ const providers = builtinProviders();
115
+ const openai = providers.find((provider) => provider.id === "openai");
116
+ if (!openai) throw new Error("pi-chatgpt-pool needs pi's built-in openai provider");
117
+
118
+ let { strategy, accounts } = loadConfig();
119
+ const update = (change: (config: PoolConfig) => PoolConfig) => {
120
+ const next = change(loadConfig());
121
+ saveConfig(next);
122
+ ({ strategy, accounts } = next);
123
+ return next;
124
+ };
125
+ const usageCache = new Map<string, { snapshot: UsageSnapshot; at: number }>();
126
+
127
+ const rememberUsage = (accountId: string, usage: Usage) => {
128
+ const snapshot = snapshotOf(usage);
129
+ if (snapshot) usageCache.set(accountId, { snapshot, at: Date.now() });
130
+ };
131
+
132
+ const freshUsage = async (ctx: ExtensionContext, signal?: AbortSignal) => {
133
+ if (strategy !== "least-used" && strategy !== "use-it-or-lose-it") return new Map<string, UsageSnapshot>();
134
+ await Promise.all(
135
+ accounts.map(async (account) => {
136
+ const cached = usageCache.get(account.id);
137
+ if (cached && Date.now() - cached.at < USAGE_TTL_MS) return;
138
+ const token = await ctx.modelRegistry.getApiKeyForProvider(account.id).catch(() => undefined);
139
+ if (!token || signal?.aborted) return;
140
+ await fetchUsage(token).then((usage) => rememberUsage(account.id, usage), () => undefined);
141
+ }),
142
+ );
143
+ return new Map([...usageCache].map(([id, entry]) => [id, entry.snapshot]));
144
+ };
145
+ const reportedResets = new Map<string, number>();
146
+ const onReset: ResetListener = (accountId, resetsAt) => reportedResets.set(accountId, resetsAt);
147
+
148
+ const setResetsAt = (accountId: string, resetsAt: number | undefined) => {
149
+ update((config) => ({
150
+ ...config,
151
+ accounts: config.accounts.map((account) => (account.id === accountId ? { ...account, resetsAt } : account)),
152
+ }));
153
+ };
154
+
155
+ const isUsable = (ctx: ExtensionContext, accountId: string, modelId: string) => {
156
+ const account = accounts.find((candidate) => candidate.id === accountId);
157
+ const model = ctx.modelRegistry.find(accountId, modelId);
158
+ return (account?.resetsAt ?? 0) <= Date.now() && !!model && ctx.modelRegistry.hasConfiguredAuth(model);
159
+ };
160
+
161
+ for (const account of accounts) pi.registerProvider(accountProvider(openai, account, onReset));
162
+
163
+ let lastRouted: string | undefined;
164
+ const updateStatus = (ctx: ExtensionContext, provider = ctx.model?.provider) => {
165
+ if (provider !== POOL_PROVIDER) return ctx.ui.setStatus(STATUS_KEY, undefined);
166
+ const now = Date.now();
167
+ const active = accounts.find((account) => account.id === lastRouted);
168
+ const limited = accounts.filter((account) => (account.resetsAt ?? 0) > now);
169
+ const parts = [`chatgpt: ${active?.label ?? "pool"}`];
170
+ if (limited.length > 0) {
171
+ const next = Math.min(...limited.map((account) => account.resetsAt ?? now));
172
+ parts.push(`${limited.length} limited, next back ${formatReset(next)}`);
173
+ }
174
+ ctx.ui.setStatus(STATUS_KEY, parts.join(" · "));
175
+ };
176
+
177
+ pi.on("session_start", (_event, ctx) => updateStatus(ctx));
178
+ pi.on("model_select", (event, ctx) => updateStatus(ctx, event.model.provider));
179
+
180
+ let pooledModelsRegistered = false;
181
+ const registerPooledModels = () => {
182
+ if (pooledModelsRegistered || accounts.length === 0) return;
183
+ pooledModelsRegistered = true;
184
+ for (const model of subscriptionModels(providers)) registerPooledModel(model);
185
+ };
186
+
187
+ const registerPooledModel = (model: Model<Api>) =>
188
+ pi.registerVirtualModel({
189
+ provider: POOL_PROVIDER,
190
+ id: model.id,
191
+ name: `${model.name} (ChatGPT pool)`,
192
+ thinkingLevels: getSupportedThinkingLevels(model),
193
+ contextWindow: model.contextWindow,
194
+ maxTokens: model.maxTokens,
195
+ input: model.input,
196
+ async route(request, ctx) {
197
+ const sticky = (request.failed ?? request.previous)?.model.provider;
198
+ const usable = (id: string) => isUsable(ctx, id, model.id);
199
+ const keepSticky = sticky !== undefined && usable(sticky);
200
+ const usage = keepSticky ? new Map<string, UsageSnapshot>() : await freshUsage(ctx, request.signal);
201
+ const rotation =
202
+ !keepSticky && sticky === undefined && strategy === "round-robin"
203
+ ? update((config) => ({ ...config, rotation: config.rotation + 1 })).rotation - 1
204
+ : 0;
205
+ const ordered = keepSticky ? accounts : orderAccounts(accounts, strategy, usage, rotation);
206
+ const account = pickAccount(ordered, usable, sticky);
207
+ const routed = account && ctx.modelRegistry.find(account.id, model.id);
208
+ if (!routed) {
209
+ throw new Error(
210
+ accounts.length === 0
211
+ ? "No ChatGPT accounts in the pool. Run /chatgpt-pool to add one, then /login."
212
+ : "Every ChatGPT account in the pool is signed out or limited. Run /chatgpt-pool to see reset times and banked resets.",
213
+ );
214
+ }
215
+ if (lastRouted !== routed.provider) {
216
+ lastRouted = routed.provider;
217
+ updateStatus(ctx);
218
+ }
219
+ return { model: routed, thinkingLevel: request.thinkingLevel };
220
+ },
221
+ });
222
+
223
+ registerPooledModels();
224
+
225
+ pi.on("message_end", (event, ctx) => {
226
+ const message = event.message;
227
+ if (message.role !== "assistant" || message.stopReason !== "error") return;
228
+ const account = accounts.find((candidate) => candidate.id === message.provider);
229
+ const reported = account && reportedResets.get(account.id);
230
+ if (account) reportedResets.delete(account.id);
231
+ const cooldown = limitCooldown(message.errorMessage);
232
+ if (!account || (cooldown === undefined && reported === undefined)) return;
233
+ setResetsAt(account.id, reported ?? Date.now() + (cooldown ?? 0));
234
+ updateStatus(ctx);
235
+ if (ctx.model?.provider !== POOL_PROVIDER) return;
236
+ if (!pickAccount(accounts, (id) => isUsable(ctx, id, message.model), undefined)) return;
237
+ return {
238
+ message: {
239
+ ...message,
240
+ errorMessage: `ChatGPT account "${account.label}" is rate limited; switching to another account in the pool.`,
241
+ },
242
+ };
243
+ });
244
+
245
+ type AccountStatus = { account: Account; signedIn: boolean; usage?: Usage; error?: string };
246
+
247
+ const loadStatuses = (ctx: ExtensionContext, signal?: AbortSignal): Promise<AccountStatus[]> =>
248
+ Promise.all(
249
+ accounts.map(async (account): Promise<AccountStatus> => {
250
+ const token = await ctx.modelRegistry.getApiKeyForProvider(account.id).catch(() => undefined);
251
+ if (!token || signal?.aborted) return { account, signedIn: !!token };
252
+ try {
253
+ const usage = await fetchUsage(token);
254
+ rememberUsage(account.id, usage);
255
+ const limitedUntil = usage.limitReached ? Math.max(...usage.windows.map((w) => w.resetsAt)) : undefined;
256
+ if (limitedUntil !== account.resetsAt && (usage.limitReached || (account.resetsAt ?? 0) > Date.now())) {
257
+ setResetsAt(account.id, limitedUntil);
258
+ }
259
+ return { account, signedIn: true, usage };
260
+ } catch (error) {
261
+ return { account, signedIn: true, error: error instanceof Error ? error.message : String(error) };
262
+ }
263
+ }),
264
+ );
265
+
266
+ const withLoader = <T>(ctx: ExtensionContext, message: string, work: (signal?: AbortSignal) => Promise<T>) => {
267
+ if (ctx.mode !== "tui") return work();
268
+ return ctx.ui.custom<T | undefined>((tui, theme, _keybindings, done) => {
269
+ const loader = new BorderedLoader(tui, theme, message);
270
+ loader.onAbort = () => done(undefined);
271
+ work(loader.signal).then(done, () => done(undefined));
272
+ return loader;
273
+ });
274
+ };
275
+
276
+ const describe = ({ account, signedIn, usage, error }: AccountStatus): string => {
277
+ const limitedUntil = account.resetsAt && account.resetsAt > Date.now() ? account.resetsAt : undefined;
278
+ if (!signedIn) return `${account.label} · not signed in, run /login and pick ChatGPT (${account.label})`;
279
+ const state = limitedUntil ? `limited until ${formatReset(limitedUntil)}` : "ready";
280
+ if (!usage) return `${account.label} · ${state} · usage unavailable${error ? ` (${error})` : ""}`;
281
+ const windows = usage.windows.map(
282
+ (w) => `${formatWindow(w.windowSeconds)} ${Math.round(w.usedPercent)}%, resets ${formatReset(w.resetsAt)}`,
283
+ );
284
+ const banked =
285
+ usage.banked === 0
286
+ ? "no banked resets"
287
+ : `${usage.banked} banked${usage.usableNow > 0 ? `, ${usage.usableNow} usable now` : ""}`;
288
+ return [account.label, state, ...windows, banked].join(" · ");
289
+ };
290
+
291
+ const findAccount = (label: string) =>
292
+ accounts.find((candidate) => candidate.label === label || candidate.id === label);
293
+
294
+ const addAccount = (ctx: ExtensionContext, label: string) => {
295
+ if (findAccount(label)) return ctx.ui.notify(`There is already an account named "${label}".`, "warning");
296
+ const account = { id: nextAccountId(loadConfig().accounts), label };
297
+ update((config) => ({ ...config, accounts: [...config.accounts, account] }));
298
+ registerPooledModels();
299
+ pi.registerProvider(accountProvider(openai, account, onReset));
300
+ ctx.ui.notify(`Added ChatGPT (${label}). Run /login and pick it to sign in.`, "info");
301
+ };
302
+
303
+ const removeAccount = (ctx: ExtensionContext, account: Account) => {
304
+ update((config) => ({ ...config, accounts: config.accounts.filter((candidate) => candidate.id !== account.id) }));
305
+ pi.unregisterProvider(account.id);
306
+ updateStatus(ctx);
307
+ ctx.ui.notify(
308
+ `Removed ChatGPT (${account.label}). Its token stays in auth.json under ${account.id} until you remove it.`,
309
+ "info",
310
+ );
311
+ };
312
+
313
+ const spend = async (ctx: ExtensionContext, account: Account, known?: Usage) => {
314
+ const token = await ctx.modelRegistry.getApiKeyForProvider(account.id);
315
+ if (!token) return ctx.ui.notify(`ChatGPT (${account.label}) is not signed in.`, "warning");
316
+ const usage = known ?? (await withLoader(ctx, `Checking ${account.label}...`, () => fetchUsage(token)));
317
+ if (!usage) return;
318
+ const credit = [...usage.credits].sort(
319
+ (a, b) => (a.expiresAt ?? Number.POSITIVE_INFINITY) - (b.expiresAt ?? Number.POSITIVE_INFINITY),
320
+ )[0];
321
+ if (!credit) return ctx.ui.notify(`ChatGPT (${account.label}) has no banked resets.`, "info");
322
+ const warning =
323
+ usage.usableNow === 0 ? " ChatGPT reports nothing to reset right now, so this may not change anything." : "";
324
+ const confirmed = await ctx.ui.confirm(
325
+ `Spend a banked reset on ${account.label}?`,
326
+ `Uses "${credit.title}"${credit.expiresAt ? `, expiring ${formatReset(credit.expiresAt)}` : ""}. ${usage.banked} banked in total.${warning}`,
327
+ );
328
+ if (!confirmed) return ctx.ui.notify("No reset spent.", "info");
329
+ const outcome = await spendReset(token, credit.id);
330
+ if (outcome === "reset") setResetsAt(account.id, undefined);
331
+ updateStatus(ctx);
332
+ ctx.ui.notify(
333
+ {
334
+ reset: `Spent a banked reset. ChatGPT (${account.label}) is ready again.`,
335
+ nothing_to_reset: `ChatGPT (${account.label}) had nothing to reset, so no reset was spent.`,
336
+ no_credit: `ChatGPT (${account.label}) has no banked resets left.`,
337
+ already_redeemed: "That reset was already spent.",
338
+ }[outcome],
339
+ outcome === "reset" ? "info" : "warning",
340
+ );
341
+ };
342
+
343
+ const setStrategy = (ctx: ExtensionContext, next: Strategy) => {
344
+ update((config) => ({ ...config, strategy: next }));
345
+ ctx.ui.notify(`${STRATEGIES[next]}.`, "info");
346
+ };
347
+
348
+ const chooseStrategy = async (ctx: ExtensionContext) => {
349
+ const labels = Object.values(STRATEGIES).map((text) => (text === STRATEGIES[strategy] ? `${text} (current)` : text));
350
+ const picked = await ctx.ui.select("Routing strategy for new conversations", labels);
351
+ const next = Object.keys(STRATEGIES).find((_key, index) => labels[index] === picked);
352
+ if (next && isStrategy(next)) setStrategy(ctx, next);
353
+ };
354
+
355
+ const openPool = async (ctx: ExtensionContext) => {
356
+ const statuses = await withLoader(ctx, "Checking ChatGPT accounts...", (signal) => loadStatuses(ctx, signal));
357
+ if (!statuses) return;
358
+ updateStatus(ctx);
359
+ const addOption = "+ Add account";
360
+ const strategyOption = `Strategy: ${STRATEGIES[strategy].split(":")[0]}`;
361
+ const options = [...statuses.map(describe), addOption, strategyOption];
362
+ const picked = await ctx.ui.select("ChatGPT pool", options);
363
+ if (!picked) return;
364
+ if (picked === strategyOption) return chooseStrategy(ctx);
365
+ if (picked === addOption) {
366
+ const label = (await ctx.ui.input("Label for the new ChatGPT account", "work"))?.trim();
367
+ if (label) addAccount(ctx, label);
368
+ return;
369
+ }
370
+ const status = statuses[options.indexOf(picked)];
371
+ const actions = [
372
+ ...(status.usage && status.usage.banked > 0 ? [`Spend a banked reset (${status.usage.banked} banked)`] : []),
373
+ ...(status.account.resetsAt && status.account.resetsAt > Date.now() ? ["Clear recorded limit"] : []),
374
+ "Remove from pool",
375
+ ];
376
+ const action = await ctx.ui.select(`ChatGPT (${status.account.label})`, actions);
377
+ if (!action) return;
378
+ if (action.startsWith("Spend")) return spend(ctx, status.account, status.usage);
379
+ if (action === "Clear recorded limit") {
380
+ setResetsAt(status.account.id, undefined);
381
+ updateStatus(ctx);
382
+ return ctx.ui.notify(`ChatGPT (${status.account.label}) is back in rotation.`, "info");
383
+ }
384
+ if (await ctx.ui.confirm(`Remove ${status.account.label}?`, "It will no longer be used by the pool.")) {
385
+ removeAccount(ctx, status.account);
386
+ }
387
+ };
388
+
389
+ const SUBCOMMANDS = [
390
+ { value: "add", label: "add", description: "Add an account slot" },
391
+ { value: "remove", label: "remove", description: "Remove an account" },
392
+ { value: "spend", label: "spend", description: "Spend a banked reset, after confirming" },
393
+ { value: "reset", label: "reset", description: "Forget recorded limits" },
394
+ { value: "strategy", label: "strategy", description: "Choose how new conversations pick an account" },
395
+ ];
396
+
397
+ pi.registerCommand("chatgpt-pool", {
398
+ description: "Manage pooled ChatGPT accounts: usage, banked resets, strategy, add, remove, spend, reset",
399
+ getArgumentCompletions(prefix) {
400
+ const [action, ...rest] = prefix.split(" ");
401
+ if (rest.length === 0) return SUBCOMMANDS.filter((item) => item.value.startsWith(action));
402
+ if (action === "strategy") {
403
+ return Object.entries(STRATEGIES)
404
+ .filter(([name]) => name.startsWith(rest.join(" ")))
405
+ .map(([name, description]) => ({ value: `strategy ${name}`, label: name, description }));
406
+ }
407
+ if (action !== "remove" && action !== "spend") return null;
408
+ const partial = rest.join(" ");
409
+ return accounts
410
+ .filter((account) => account.label.startsWith(partial))
411
+ .map((account) => ({ value: `${action} ${account.label}`, label: account.label, description: account.id }));
412
+ },
413
+ async handler(args, ctx) {
414
+ const [action = "", ...rest] = args.trim().split(/\s+/);
415
+ const label = rest.join(" ");
416
+
417
+ if (action === "add") {
418
+ if (!label) return ctx.ui.notify("Usage: /chatgpt-pool add <label>", "warning");
419
+ return addAccount(ctx, label);
420
+ }
421
+
422
+ if (action === "remove") {
423
+ const account = findAccount(label);
424
+ if (!account) return ctx.ui.notify(`No pooled account named "${label}"`, "warning");
425
+ return removeAccount(ctx, account);
426
+ }
427
+
428
+ if (action === "reset") {
429
+ update((config) => ({ ...config, accounts: config.accounts.map(({ id, label }) => ({ id, label })) }));
430
+ updateStatus(ctx);
431
+ return ctx.ui.notify("Cleared recorded limits. Every signed-in account is back in rotation.", "info");
432
+ }
433
+
434
+ if (action === "strategy") {
435
+ if (isStrategy(label)) return setStrategy(ctx, label);
436
+ if (label || !ctx.hasUI) {
437
+ return ctx.ui.notify(`Usage: /chatgpt-pool strategy <${Object.keys(STRATEGIES).join(" | ")}>`, "warning");
438
+ }
439
+ return chooseStrategy(ctx);
440
+ }
441
+
442
+ if (!ctx.hasUI) {
443
+ throw new Error(
444
+ "/chatgpt-pool needs the interactive UI to show usage or spend a banked reset. Use `pi auth check --provider chatgpt-1` to check an account from a script.",
445
+ );
446
+ }
447
+
448
+ if (action === "spend") {
449
+ const account = findAccount(label);
450
+ if (!account) return ctx.ui.notify("Usage: /chatgpt-pool spend <label>", "warning");
451
+ return spend(ctx, account);
452
+ }
453
+
454
+ if (action) return ctx.ui.notify(`Unknown action "${action}". Use add, remove, spend, reset, or strategy.`, "warning");
455
+ if (accounts.length === 0) {
456
+ const label = (await ctx.ui.input("No ChatGPT accounts yet. Label for the first one", "personal"))?.trim();
457
+ if (label) addAccount(ctx, label);
458
+ return;
459
+ }
460
+ await openPool(ctx);
461
+ },
462
+ });
463
+ }
package/src/pool.ts ADDED
@@ -0,0 +1,118 @@
1
+ export type Account = { id: string; label: string; resetsAt?: number };
2
+
3
+ const USAGE_LIMIT = /usage.?limit|subscription_sharing_usage_limit_exceeded|insufficient_quota|quota/i;
4
+ const RATE_LIMIT = /rate.?limit|too many requests|\b429\b/i;
5
+
6
+ export const USAGE_COOLDOWN_MS = 60 * 60 * 1000;
7
+ export const RATE_COOLDOWN_MS = 60 * 1000;
8
+
9
+ export function limitCooldown(errorMessage: string | undefined): number | undefined {
10
+ if (!errorMessage) return undefined;
11
+ if (USAGE_LIMIT.test(errorMessage)) return USAGE_COOLDOWN_MS;
12
+ if (RATE_LIMIT.test(errorMessage)) return RATE_COOLDOWN_MS;
13
+ return undefined;
14
+ }
15
+
16
+ const DURATION_PART = /(\d+(?:\.\d+)?)(ms|h|m|s)/g;
17
+ const DURATION_MS = new Map([
18
+ ["h", 3_600_000],
19
+ ["m", 60_000],
20
+ ["s", 1000],
21
+ ["ms", 1],
22
+ ]);
23
+
24
+ function parseDurationMs(value: string): number | undefined {
25
+ if (/^\d+(\.\d+)?$/.test(value)) return Number(value) * 1000;
26
+ let total = 0;
27
+ let matched = "";
28
+ for (const [part, amount, unit] of value.matchAll(DURATION_PART)) {
29
+ total += Number(amount) * (DURATION_MS.get(unit) ?? 0);
30
+ matched += part;
31
+ }
32
+ return matched === value ? total : undefined;
33
+ }
34
+
35
+ function secondsOrMs(value: number): number {
36
+ return value < 1e12 ? value * 1000 : value;
37
+ }
38
+
39
+ export function parseResetAt(headers: Headers, body: string, now: number): number | undefined {
40
+ const candidates: number[] = [];
41
+ const addIn = (ms: number | undefined) => {
42
+ if (ms !== undefined && Number.isFinite(ms) && ms > 0) candidates.push(now + ms);
43
+ };
44
+ const addAt = (at: number) => {
45
+ if (Number.isFinite(at) && at > now) candidates.push(at);
46
+ };
47
+
48
+ const retryAfterMs = headers.get("retry-after-ms");
49
+ if (retryAfterMs) addIn(Number(retryAfterMs));
50
+
51
+ const retryAfter = headers.get("retry-after")?.trim();
52
+ if (retryAfter) {
53
+ if (/^\d+(\.\d+)?$/.test(retryAfter)) addIn(Number(retryAfter) * 1000);
54
+ else addAt(Date.parse(retryAfter));
55
+ }
56
+
57
+ for (const [name, value] of headers) {
58
+ if (name.startsWith("x-ratelimit-reset") || /^x-codex-.*reset-after-seconds$/.test(name)) {
59
+ addIn(parseDurationMs(value.trim()));
60
+ } else if (/^x-codex-.*reset-at$/.test(name)) {
61
+ addAt(secondsOrMs(Number(value)));
62
+ }
63
+ }
64
+
65
+ const match = body.match(/"resets_at"\s*:\s*(\d+(?:\.\d+)?)/);
66
+ if (match) addAt(secondsOrMs(Number(match[1])));
67
+ const inSeconds = body.match(/"resets_in_seconds"\s*:\s*(\d+(?:\.\d+)?)/);
68
+ if (inSeconds) addIn(Number(inSeconds[1]) * 1000);
69
+
70
+ return candidates.length > 0 ? Math.max(...candidates) : undefined;
71
+ }
72
+
73
+ export function pickAccount(
74
+ accounts: readonly Account[],
75
+ isUsable: (id: string) => boolean,
76
+ sticky: string | undefined,
77
+ ): Account | undefined {
78
+ const usable = accounts.filter((account) => isUsable(account.id));
79
+ return usable.find((account) => account.id === sticky) ?? usable[0];
80
+ }
81
+
82
+ export function nextAccountId(accounts: readonly Account[]): string {
83
+ const highest = Math.max(0, ...accounts.map((account) => Number(account.id.split("-").pop()) || 0));
84
+ return `chatgpt-${highest + 1}`;
85
+ }
86
+
87
+ export const STRATEGIES = {
88
+ "fill-first": "Fill first: use accounts in order, save the later ones for when the first runs out",
89
+ "round-robin": "Round robin: start each new conversation on the next account",
90
+ "least-used": "Least used: start new conversations on the account with the lowest usage",
91
+ "use-it-or-lose-it": "Use it or lose it: prefer the account whose usage window resets soonest",
92
+ };
93
+ export type Strategy = keyof typeof STRATEGIES;
94
+ export type UsageSnapshot = { usedPercent: number; resetsAt: number };
95
+
96
+ export function isStrategy(value: string): value is Strategy {
97
+ return Object.hasOwn(STRATEGIES, value);
98
+ }
99
+
100
+ export function orderAccounts(
101
+ accounts: readonly Account[],
102
+ strategy: Strategy,
103
+ usage: ReadonlyMap<string, UsageSnapshot>,
104
+ rotation: number,
105
+ ): Account[] {
106
+ if (strategy === "fill-first" || accounts.length === 0) return [...accounts];
107
+ if (strategy === "round-robin") {
108
+ const start = rotation % accounts.length;
109
+ return [...accounts.slice(start), ...accounts.slice(0, start)];
110
+ }
111
+ const known = accounts.filter((account) => usage.has(account.id) && (usage.get(account.id)?.usedPercent ?? 100) < 100);
112
+ const rest = accounts.filter((account) => !known.includes(account));
113
+ const key = (account: Account) => {
114
+ const snapshot = usage.get(account.id);
115
+ return strategy === "least-used" ? (snapshot?.usedPercent ?? 100) : (snapshot?.resetsAt ?? Number.POSITIVE_INFINITY);
116
+ };
117
+ return [...known.sort((a, b) => key(a) - key(b)), ...rest];
118
+ }
package/src/usage.ts ADDED
@@ -0,0 +1,80 @@
1
+ import { randomUUID } from "node:crypto";
2
+
3
+ const BACKEND = "https://chatgpt.com/backend-api/wham";
4
+
5
+ type Window = { used_percent: number; limit_window_seconds: number; reset_at: number };
6
+ type UsageResponse = {
7
+ rate_limit?: { limit_reached?: boolean; primary_window?: Window | null; secondary_window?: Window | null } | null;
8
+ rate_limit_reset_credits?: { available_count?: number; applicable_available_count?: number } | null;
9
+ };
10
+ type CreditsResponse = {
11
+ credits: { id: string; status: string; title?: string | null; expires_at?: string | null }[];
12
+ };
13
+ type ConsumeResponse = { code: "reset" | "nothing_to_reset" | "no_credit" | "already_redeemed" };
14
+
15
+ export type UsageWindow = { usedPercent: number; windowSeconds: number; resetsAt: number };
16
+ export type Usage = {
17
+ limitReached: boolean;
18
+ windows: UsageWindow[];
19
+ banked: number;
20
+ usableNow: number;
21
+ credits: { id: string; title: string; expiresAt?: number }[];
22
+ };
23
+ export type SpendOutcome = ConsumeResponse["code"];
24
+
25
+ function accountIdFromToken(token: string): string | undefined {
26
+ const payload = token.split(".")[1];
27
+ if (!payload) return undefined;
28
+ try {
29
+ return JSON.parse(Buffer.from(payload, "base64url").toString("utf8"))["https://api.openai.com/auth"]
30
+ ?.chatgpt_account_id;
31
+ } catch {
32
+ return undefined;
33
+ }
34
+ }
35
+
36
+ async function backend<T>(token: string, path: string, init: RequestInit = {}): Promise<T> {
37
+ const headers = new Headers({ authorization: `Bearer ${token}`, "content-type": "application/json" });
38
+ const accountId = accountIdFromToken(token);
39
+ if (accountId) headers.set("chatgpt-account-id", accountId);
40
+ const response = await fetch(`${BACKEND}/${path}`, { ...init, headers, signal: AbortSignal.timeout(15_000) });
41
+ if (!response.ok) {
42
+ const body = await response.text().catch(() => "");
43
+ throw new Error(`ChatGPT ${path} failed (${response.status})${body ? `: ${body.slice(0, 200)}` : ""}`);
44
+ }
45
+ return response.json();
46
+ }
47
+
48
+ function toWindow(window: Window | null | undefined): UsageWindow[] {
49
+ if (!window) return [];
50
+ return [{ usedPercent: window.used_percent, windowSeconds: window.limit_window_seconds, resetsAt: window.reset_at * 1000 }];
51
+ }
52
+
53
+ export async function fetchUsage(token: string): Promise<Usage> {
54
+ const [usage, credits] = await Promise.all([
55
+ backend<UsageResponse>(token, "usage"),
56
+ backend<CreditsResponse>(token, "rate-limit-reset-credits"),
57
+ ]);
58
+ const limit = usage.rate_limit;
59
+ return {
60
+ limitReached: limit?.limit_reached === true,
61
+ windows: [...toWindow(limit?.primary_window), ...toWindow(limit?.secondary_window)],
62
+ banked: usage.rate_limit_reset_credits?.available_count ?? 0,
63
+ usableNow: usage.rate_limit_reset_credits?.applicable_available_count ?? 0,
64
+ credits: credits.credits
65
+ .filter((credit) => credit.status === "available")
66
+ .map((credit) => ({
67
+ id: credit.id,
68
+ title: credit.title || "Reset",
69
+ expiresAt: credit.expires_at ? Date.parse(credit.expires_at) : undefined,
70
+ })),
71
+ };
72
+ }
73
+
74
+ export async function spendReset(token: string, creditId: string): Promise<SpendOutcome> {
75
+ const response = await backend<ConsumeResponse>(token, "rate-limit-reset-credits/consume", {
76
+ method: "POST",
77
+ body: JSON.stringify({ redeem_request_id: randomUUID(), credit_id: creditId }),
78
+ });
79
+ return response.code;
80
+ }