pi-chatgpt-pool 0.2.2 → 0.2.3

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 CHANGED
@@ -38,7 +38,6 @@ The pool offers the ChatGPT subscription models: the OpenAI models that pi also
38
38
  | `/chatgpt-pool login <label>` | Fill in `/login` for that account, so Enter starts its sign-in |
39
39
  | `/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>)` |
40
40
  | `/chatgpt-pool remove <label>` | Remove an account. Run `/logout` for it first to delete its token |
41
- | `/chatgpt-pool spend <label>` | Spend one banked reset on that account, after you confirm |
42
41
  | `/chatgpt-pool strategy <name>` | Set the routing strategy: `fill-first`, `round-robin`, `least-used`, or `use-it-or-lose-it` |
43
42
  | `/chatgpt-pool reset` | Forget recorded limits, for example after a limit clears early |
44
43
 
@@ -46,13 +45,12 @@ The command autocompletes its actions and account labels.
46
45
 
47
46
  ## Pool panel
48
47
 
49
- `/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
+ `/chatgpt-pool` shows each account's sign-in status and any recorded limit countdown. Usage meters and banked resets are hidden for ChatGPT subscription-sharing sign-ins until a compatible usage endpoint is verified. Opening the panel does not send rejected Codex usage requests.
50
49
 
51
50
  | Key | Action |
52
51
  | --- | --- |
53
52
  | `↑` `↓` or `j` `k` | Select an account |
54
53
  | `enter` | Sign in to the selected account (fills in `/login` for it; press Enter again) |
55
- | `s` | Spend a banked reset on the selected account, after you confirm |
56
54
  | `c` | Clear the selected account's recorded limit |
57
55
  | `n` | Set the name the selected account shows in `/login` |
58
56
  | `d` | Remove the selected account |
@@ -63,7 +61,7 @@ The command autocompletes its actions and account labels.
63
61
 
64
62
  Over RPC the same actions are offered as a list instead.
65
63
 
66
- 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.`
64
+ While a pooled model is selected, the footer shows the account in use, for example `chatgpt: work`, plus how many accounts have recorded limits and when the next one returns. 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.`
67
65
 
68
66
  `/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:
69
67
 
@@ -86,7 +84,7 @@ A conversation stays on the same account while it works, so prompt caching and r
86
84
  | Least used | The account with the lowest usage % |
87
85
  | Use it or lose it | The account whose usage window resets soonest, so its remaining quota isn't wasted |
88
86
 
89
- 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.
87
+ 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 require usage data. The current ChatGPT sign-in flow cannot read it, so these strategies use the configured account order. Round robin spreads new conversations without usage data.
90
88
 
91
89
  ## How failover works
92
90
 
@@ -95,13 +93,11 @@ Choose one from the **Strategy** row in `/chatgpt-pool`, or run `/chatgpt-pool s
95
93
  - Reset times are saved in `chatgpt-pool.json`, so they survive restarts.
96
94
  - If every account is signed out or limited, the request fails with a message pointing to `/chatgpt-pool`.
97
95
 
98
- ## Banked resets
96
+ ## Usage and banked resets
99
97
 
100
- 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.
98
+ OpenAI's subscription-sharing sign-in tokens are not accepted by the Codex `backend-api/wham` usage service. Usage meters and banked-reset actions are hidden for these accounts. No reset is spent, and the pool never spends one automatically. Recorded limits and failover still work from inference errors.
101
99
 
102
- 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.
103
-
104
- Usage and banked resets come from ChatGPT's `backend-api/wham` endpoints, the same ones the Codex CLI uses.
100
+ Review actual usage in [ChatGPT Usage settings](https://chatgpt.com/settings/usage). Codex reset credits have not been verified to reset subscription-sharing limits.
105
101
 
106
102
  ## Releasing
107
103
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-chatgpt-pool",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
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",
package/src/index.ts CHANGED
@@ -114,7 +114,8 @@ export default function chatgptPool(pi: ExtensionAPI) {
114
114
  };
115
115
  const usageCache = new Map<string, { snapshot: UsageSnapshot; at: number }>();
116
116
 
117
- const rememberUsage = (accountId: string, usage: Usage) => {
117
+ const rememberUsage = (accountId: string, usage: Usage | undefined) => {
118
+ if (!usage) return;
118
119
  const snapshot = snapshotOf(usage);
119
120
  if (snapshot) usageCache.set(accountId, { snapshot, at: Date.now() });
120
121
  };
@@ -260,6 +261,7 @@ export default function chatgptPool(pi: ExtensionAPI) {
260
261
  if (!token || signal?.aborted) return { account, signedIn: !!token };
261
262
  try {
262
263
  const usage = await fetchUsage(token);
264
+ if (!usage) return { account, signedIn: true };
263
265
  rememberUsage(account.id, usage);
264
266
  const limitedUntil = usage.limitReached ? Math.max(...usage.windows.map((w) => w.resetsAt)) : undefined;
265
267
  if (limitedUntil !== account.resetsAt && (usage.limitReached || (account.resetsAt ?? 0) > Date.now())) {
@@ -285,8 +287,8 @@ export default function chatgptPool(pi: ExtensionAPI) {
285
287
  const describe = ({ account, signedIn, usage, error }: AccountStatus): string => {
286
288
  const limitedUntil = account.resetsAt && account.resetsAt > Date.now() ? account.resetsAt : undefined;
287
289
  if (!signedIn) return `${account.label} · not signed in, run /chatgpt-pool login ${account.label}`;
288
- const state = limitedUntil ? `limited until ${formatReset(limitedUntil)}` : "ready";
289
- if (!usage) return `${account.label} · ${state} · usage unavailable${error ? ` (${error})` : ""}`;
290
+ const state = limitedUntil ? `limited until ${formatReset(limitedUntil)}` : usage ? "ready" : "signed in";
291
+ if (!usage) return `${account.label} · ${state}${error ? ` · ${error}` : ""}`;
290
292
  const windows = usage.windows.map(
291
293
  (w) => `${formatWindow(w.windowSeconds)} ${Math.round(w.usedPercent)}%, resets ${formatReset(w.resetsAt)}`,
292
294
  );
@@ -350,7 +352,7 @@ export default function chatgptPool(pi: ExtensionAPI) {
350
352
  const token = await ctx.modelRegistry.getApiKeyForProvider(account.id);
351
353
  if (!token) return ctx.ui.notify(`ChatGPT (${account.label}) is not signed in.`, "warning");
352
354
  const usage = known ?? (await withLoader(ctx, `Checking ${account.label}...`, () => fetchUsage(token)));
353
- if (!usage) return;
355
+ if (!usage) return ctx.ui.notify("Banked resets are unavailable for this sign-in. No reset spent.", "info");
354
356
  const credit = [...usage.credits].sort(
355
357
  (a, b) => (a.expiresAt ?? Number.POSITIVE_INFINITY) - (b.expiresAt ?? Number.POSITIVE_INFINITY),
356
358
  )[0];
@@ -471,7 +473,6 @@ export default function chatgptPool(pi: ExtensionAPI) {
471
473
  const SUBCOMMANDS = [
472
474
  { value: "add", label: "add", description: "Add an account slot" },
473
475
  { value: "remove", label: "remove", description: "Remove an account" },
474
- { value: "spend", label: "spend", description: "Spend a banked reset, after confirming" },
475
476
  { value: "login", label: "login", description: "Sign in to an account" },
476
477
  { value: "name", label: "name", description: "Set the name an account shows in /login" },
477
478
  { value: "reset", label: "reset", description: "Forget recorded limits" },
@@ -479,7 +480,7 @@ export default function chatgptPool(pi: ExtensionAPI) {
479
480
  ];
480
481
 
481
482
  pi.registerCommand("chatgpt-pool", {
482
- description: "Manage pooled ChatGPT accounts: usage, banked resets, strategy, add, login, remove, name, spend, reset",
483
+ description: "Manage pooled ChatGPT accounts: sign-in, strategy, add, remove, name, reset",
483
484
  getArgumentCompletions(prefix) {
484
485
  const [action, ...rest] = prefix.split(" ");
485
486
  if (rest.length === 0) return SUBCOMMANDS.filter((item) => item.value.startsWith(action));
@@ -550,7 +551,7 @@ export default function chatgptPool(pi: ExtensionAPI) {
550
551
  return spend(ctx, account);
551
552
  }
552
553
 
553
- if (action) return ctx.ui.notify(`Unknown action "${action}". Use add, login, remove, name, spend, reset, or strategy.`, "warning");
554
+ if (action) return ctx.ui.notify(`Unknown action "${action}". Use add, login, remove, name, reset, or strategy.`, "warning");
554
555
  if (accounts.length === 0) {
555
556
  const label = (await ctx.ui.input("No ChatGPT accounts yet. Label for the first one", "personal"))?.trim();
556
557
  if (label) addAccount(ctx, label);
package/src/panel.ts CHANGED
@@ -54,6 +54,7 @@ export class PoolPanel implements Component {
54
54
  const poolAction = POOL_KEYS.get(data);
55
55
  const accountAction = ACCOUNT_KEYS.get(data);
56
56
  const account = this.statuses[this.selected]?.account;
57
+ if (accountAction === "spend" && !this.statuses[this.selected]?.usage?.banked) return;
57
58
  if (poolAction) this.done({ type: poolAction });
58
59
  else if (accountAction && account) this.done({ type: accountAction, accountId: account.id });
59
60
  }
@@ -70,7 +71,10 @@ export class PoolPanel implements Component {
70
71
  ];
71
72
  if (this.statuses.length === 0) lines.push(theme.fg("muted", "No accounts yet. Press a to add one."), "");
72
73
  this.statuses.forEach((status, index) => lines.push(...this.renderAccount(status, index === this.selected), ""));
73
- const hint = HINTS.find((text) => visibleWidth(text) <= width) ?? HINTS[HINTS.length - 1];
74
+ const hints = this.statuses[this.selected]?.usage?.banked
75
+ ? HINTS
76
+ : HINTS.map((text) => text.replace(/s spend(?: reset)? · /g, ""));
77
+ const hint = hints.find((text) => visibleWidth(text) <= width) ?? hints[hints.length - 1];
74
78
  lines.push(theme.fg("dim", hint), rule);
75
79
  return lines.map((line) => truncateToWidth(line, width));
76
80
  }
@@ -92,7 +96,7 @@ export class PoolPanel implements Component {
92
96
  ? theme.fg("muted", "not signed in")
93
97
  : limitedUntil
94
98
  ? theme.fg("warning", `limited · back in ${formatIn(limitedUntil - now)}`)
95
- : theme.fg("success", "ready");
99
+ : usage ? theme.fg("success", "ready") : theme.fg("muted", "signed in");
96
100
  const banked =
97
101
  usage && usage.banked > 0
98
102
  ? theme.fg("accent", ` ${usage.banked} banked reset${usage.banked === 1 ? "" : "s"}`) +
@@ -101,7 +105,7 @@ export class PoolPanel implements Component {
101
105
  const header = `${pointer}${name}${active} ${state}${banked}`;
102
106
 
103
107
  if (!signedIn) return [header, theme.fg("dim", " press enter to sign in")];
104
- if (!usage) return [header, theme.fg("dim", ` usage unavailable${error ? `: ${error}` : ""}`)];
108
+ if (!usage) return error ? [header, theme.fg("muted", ` ${error}`)] : [header];
105
109
  return [
106
110
  header,
107
111
  ...usage.windows.map((window) => {
package/src/usage.ts CHANGED
@@ -22,25 +22,31 @@ export type Usage = {
22
22
  };
23
23
  export type SpendOutcome = ConsumeResponse["code"];
24
24
 
25
- function accountIdFromToken(token: string): string | undefined {
25
+ type TokenClaims = { scope?: string; "https://api.openai.com/auth"?: { chatgpt_account_id?: string } };
26
+
27
+ function tokenClaims(token: string): TokenClaims | undefined {
26
28
  const payload = token.split(".")[1];
27
29
  if (!payload) return undefined;
28
30
  try {
29
- return JSON.parse(Buffer.from(payload, "base64url").toString("utf8"))["https://api.openai.com/auth"]
30
- ?.chatgpt_account_id;
31
+ return JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
31
32
  } catch {
32
33
  return undefined;
33
34
  }
34
35
  }
35
36
 
37
+ function supportsUsage(token: string): boolean {
38
+ const claims = tokenClaims(token);
39
+ return !(typeof claims?.scope === "string" && claims.scope.split(/\s+/).includes("chatgpt.tokens.use.direct"));
40
+ }
41
+
36
42
  async function backend<T>(token: string, path: string, init: RequestInit = {}): Promise<T> {
43
+ if (!supportsUsage(token)) throw new Error("Banked resets are unavailable for ChatGPT subscription-sharing sign-ins.");
37
44
  const headers = new Headers({ authorization: `Bearer ${token}`, "content-type": "application/json" });
38
- const accountId = accountIdFromToken(token);
45
+ const accountId = tokenClaims(token)?.["https://api.openai.com/auth"]?.chatgpt_account_id;
39
46
  if (accountId) headers.set("chatgpt-account-id", accountId);
40
47
  const response = await fetch(`${BACKEND}/${path}`, { ...init, headers, signal: AbortSignal.timeout(15_000) });
41
48
  if (!response.ok) {
42
- const body = await response.text().catch(() => "");
43
- throw new Error(`ChatGPT ${path} failed (${response.status})${body ? `: ${body.slice(0, 200)}` : ""}`);
49
+ throw new Error(`ChatGPT ${path} failed (${response.status}).`);
44
50
  }
45
51
  return response.json();
46
52
  }
@@ -50,7 +56,8 @@ function toWindow(window: Window | null | undefined): UsageWindow[] {
50
56
  return [{ usedPercent: window.used_percent, windowSeconds: window.limit_window_seconds, resetsAt: window.reset_at * 1000 }];
51
57
  }
52
58
 
53
- export async function fetchUsage(token: string): Promise<Usage> {
59
+ export async function fetchUsage(token: string): Promise<Usage | undefined> {
60
+ if (!supportsUsage(token)) return undefined;
54
61
  const [usage, credits] = await Promise.all([
55
62
  backend<UsageResponse>(token, "usage"),
56
63
  backend<CreditsResponse>(token, "rate-limit-reset-credits"),