@henryqw/pi-multi-codex 0.3.19 → 0.4.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 CHANGED
@@ -1,13 +1,13 @@
1
1
  # `@henryqw/pi-multi-codex`
2
2
 
3
- Use multiple ChatGPT Codex OAuth accounts in Pi.
3
+ Add multiple ChatGPT Codex OAuth accounts and start new Pi work on the slot with the most weekly quota.
4
4
 
5
5
  ![Pi showing Codex account quotas and the active footer slot](./example.png)
6
6
 
7
7
  ## Why
8
8
 
9
- - **Created for**: Use multiple ChatGPT Codex OAuth accounts from one Pi installation.
10
- - **Advantage**: Before work starts, quota-aware routing selects the eligible managed slot with the most remaining seven-day quota. It does not change in-progress sessions.
9
+ - **Created for**: Pi users who work across more than one Codex subscription.
10
+ - **Advantage**: Quota-aware routing starts with the eligible slot that has the most remaining seven-day quota. With automatic switching enabled, Pi can retry an HTTP 429 response on another eligible slot.
11
11
 
12
12
  ## Install
13
13
 
@@ -19,12 +19,16 @@ pi install npm:@henryqw/pi-multi-codex
19
19
 
20
20
  | Package | Why |
21
21
  | --- | --- |
22
- | `@henryqw/pi-footer` | Improves. Shows the active slot's quota or five-hour block in the footer. |
23
- | `@henryqw/pi-subagent` | Improves. Isolated children keep Main's active Codex slot. |
24
- | `@henryqw/pi-task-models` | Improves. Numbered slots share one profile route. |
22
+ | [`@henryqw/pi-footer`](https://pi.henry.wang/extensions/pi-footer) | Improves. Shows the active slot's quota or five-hour block in the footer. |
23
+ | [`@henryqw/pi-subagent`](https://pi.henry.wang/extensions/pi-subagent) | Improves. Isolated children keep Main's active Codex slot. |
24
+ | [`@henryqw/pi-task-models`](https://pi.henry.wang/extensions/pi-task-models) | Improves. Numbered slots share one profile route. |
25
25
 
26
26
  ## Use
27
27
 
28
+ Run `/login` and authenticate `OpenAI Codex` for slot 1 first. Run `/codex-add`, then run `/login` and select the new `OpenAI Codex #<n>` provider. Restart Pi or update model scope, then run `/codex-status`.
29
+
30
+ `/codex-status` lists the new slot. It shows cached quota when available, or `unavailable` until the first successful snapshot.
31
+
28
32
  | Surface | Type | Purpose |
29
33
  | --- | --- | --- |
30
34
  | `/codex-add` | command | Create the next numbered slot, then authenticate that slot. |
@@ -33,12 +37,28 @@ pi install npm:@henryqw/pi-multi-codex
33
37
 
34
38
  A numbered slot is one Codex account position in Pi.
35
39
 
40
+ ![Flowchart separating fresh-quota startup ranking from broader HTTP 429 failover](./docs/codex-routing-flow.svg)
41
+
36
42
  - The extension reads `auth.json`. It never writes or refreshes credentials.
37
- - Before the first agent start, a fresh snapshot routes a managed Codex model to the eligible slot with the most seven-day quota.
38
- - A slot whose reported five-hour window has reached 100% is excluded until that window resets.
39
- - Routing preserves model ID and never changes in-progress work.
43
+ - Before the first agent start, only fresh quota snapshots enter startup ranking. The slot with the most seven-day quota wins.
44
+ - After HTTP 429, failover can use authenticated, registered, scope-allowed, untried slots with stale or missing quota.
45
+ - Failover skips known active five-hour blocks. It ranks fresh known quota first, then unranked slots by slot number.
46
+ - Routing preserves the model ID.
47
+ - During one agent run, each eligible slot is tried at most once after HTTP 429 responses.
48
+ - Automatic retry stops when no untried eligible slot remains.
40
49
  - The footer shows the active slot's fresh quota or five-hour block.
41
50
  - Scoped sessions can switch only to exact scoped aliases.
42
- - Restart or update model scope after adding a slot.
51
+
52
+ ## Config
53
+
54
+ Automatic HTTP 429 switching is on by default. To disable it, create `~/.pi/agent/config/pi-multi-codex/config.json`:
55
+
56
+ ```json
57
+ {
58
+ "autoSwitchOn429": false
59
+ }
60
+ ```
61
+
62
+ The config must contain only `autoSwitchOn429` as a boolean. Invalid config is preserved and disables automatic switching.
43
63
 
44
64
  Generated credential-free quota cache: `~/.pi/agent/config/pi-multi-codex/usage.json`. The extension maintains it.
@@ -0,0 +1,119 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Codex startup ranking and HTTP 429 failover</title>
7
+ <link href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&amp;family=Geist:wght@400;500;600&amp;family=Geist+Mono:wght@400;500;600&amp;display=swap" rel="stylesheet">
8
+ <style>
9
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
10
+ body { min-height: 100vh; display: grid; place-items: center; padding: 32px; background: #f0eee9; color: #101828; }
11
+ .frame { width: min(1280px, 100%); }
12
+ .eyebrow { margin-bottom: 8px; color: #4c5665; font: 500 10px/1 'Geist Mono', monospace; letter-spacing: .18em; text-transform: uppercase; }
13
+ h1 { margin-bottom: 24px; font: 400 28px/1.15 'Instrument Serif', serif; }
14
+ svg { display: block; width: 100%; min-width: 960px; height: auto; }
15
+ </style>
16
+ </head>
17
+ <body>
18
+ <main class="frame">
19
+ <p class="eyebrow">Flowchart · @henryqw/pi-multi-codex</p>
20
+ <h1>Codex startup ranking and HTTP 429 failover</h1>
21
+ <svg width="1280" height="720" viewBox="0 0 1280 720" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="codex-routing-flow-title codex-routing-flow-desc">
22
+ <title id="codex-routing-flow-title">Codex startup ranking and HTTP 429 failover</title>
23
+ <desc id="codex-routing-flow-desc">At startup, only authenticated, registered, scope-allowed slots with fresh quota snapshots and no known active five-hour block are ranked; the slot with the highest seven-day remaining quota is chosen, or the current slot stays active when none qualify. Success keeps the active slot. Non-429 failures and HTTP 429 with autoSwitchOn429 disabled do not retry. With switching enabled, HTTP 429 marks the failed slot tried, then failover allows authenticated, registered, scope-allowed, untried slots with stale or missing quota, excludes known active five-hour blocks, ranks fresh known quota first and then unranked slots by slot number, and retries without returning to startup ranking.</desc>
24
+ <defs>
25
+ <style>
26
+ @import url(&quot;https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600&amp;family=Geist+Mono:wght@400;500;600&amp;display=swap&quot;);
27
+ .node { fill: #fff; stroke: #101828; stroke-width: 1; }
28
+ .node-state { fill: rgba(16,24,40,.05); stroke: #4c5665; stroke-width: 1; }
29
+ .node-soft { fill: rgba(16,24,40,.03); stroke: #101828; stroke-width: 1; }
30
+ .node-focal { fill: rgba(29,78,216,.08); stroke: #1d4ed8; stroke-width: 1.2; }
31
+ .name { fill: #101828; font: 600 20px 'Geist', sans-serif; text-anchor: middle; }
32
+ .diamond-name { fill: #101828; font: 600 18px 'Geist', sans-serif; text-anchor: middle; }
33
+ .sub { fill: #4c5665; font: 400 16px 'Geist Mono', monospace; text-anchor: middle; }
34
+ .edge { fill: none; stroke: #4c5665; stroke-width: 1.2; marker-end: url(#arrow); }
35
+ .label-mask { fill: #f0eee9; }
36
+ .label { fill: #4c5665; font: 500 16px 'Geist Mono', monospace; text-anchor: middle; letter-spacing: .04em; }
37
+ .note-label { fill: #4c5665; font: 500 16px 'Geist Mono', monospace; letter-spacing: .12em; }
38
+ .note { fill: #4c5665; font: 400 16px 'Geist', sans-serif; }
39
+ </style>
40
+ <marker id="arrow" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto"><polygon points="0 0,8 3,0 6" fill="#4c5665"/></marker>
41
+ </defs>
42
+
43
+ <rect width="1280" height="720" fill="#f0eee9"/>
44
+
45
+ <!-- Startup ranking uses only fresh, known quota. -->
46
+ <path class="edge" d="M508 104 H556"/>
47
+ <path class="edge" d="M864 104 H912"/>
48
+ <path class="edge" d="M1056 144 V192"/>
49
+
50
+ <!-- Outcomes split before the separate HTTP 429 failover pool. -->
51
+ <path class="edge" d="M936 240 H888"/>
52
+ <path class="edge" d="M1176 240 H1240 V348 H1024"/>
53
+ <path class="edge" d="M1056 288 V404 H300 V416"/>
54
+
55
+ <!-- Failover retries return to the request, never to startup ranking. -->
56
+ <path class="edge" d="M560 508 H584"/>
57
+ <path class="edge" d="M824 508 H856"/>
58
+ <path class="edge" d="M704 556 V604 H856"/>
59
+ <path class="edge" d="M1192 508 H1272 V104 H1200"/>
60
+
61
+ <rect class="label-mask" x="344" y="372" width="272" height="44" rx="2"/>
62
+ <text class="label" x="480" y="390">429 + SWITCH ON</text>
63
+ <text class="label" x="480" y="410">MARK FAILED SLOT TRIED</text>
64
+
65
+ <!-- Visually distinct candidate pools. -->
66
+ <rect class="node-focal" x="40" y="40" width="468" height="128" rx="6"/>
67
+ <text class="name" x="274" y="72">Startup ranking pool</text>
68
+ <text class="sub" x="274" y="100">AUTHENTICATED · REGISTERED · SCOPE-ALLOWED</text>
69
+ <text class="sub" x="274" y="124">FRESH USAGE SNAPSHOT REQUIRED</text>
70
+ <text class="sub" x="274" y="148">KNOWN 7-DAY REMAINING · NO ACTIVE 5H BLOCK</text>
71
+
72
+ <rect class="node" x="556" y="56" width="308" height="96" rx="6"/>
73
+ <text class="name" x="710" y="88">Choose startup route</text>
74
+ <text class="sub" x="710" y="116">HIGHEST 7-DAY REMAINING</text>
75
+ <text class="sub" x="710" y="140">NONE → KEEP CURRENT SLOT</text>
76
+
77
+ <rect class="node" x="912" y="64" width="288" height="80" rx="6"/>
78
+ <text class="name" x="1056" y="96">Send request</text>
79
+ <text class="sub" x="1056" y="124">ACTIVE SLOT · MODEL ID KEPT</text>
80
+
81
+ <polygon class="node" points="1056,192 1176,240 1056,288 936,240"/>
82
+ <text class="diamond-name" x="1056" y="247">Result</text>
83
+
84
+ <rect class="node-soft" x="648" y="200" width="240" height="80" rx="40"/>
85
+ <text class="name" x="768" y="232">Keep active slot</text>
86
+ <text class="sub" x="768" y="260">SUCCESS</text>
87
+
88
+ <rect class="node-soft" x="704" y="304" width="320" height="88" rx="44"/>
89
+ <text class="name" x="864" y="334">Stop without another slot</text>
90
+ <text class="sub" x="864" y="362">NON-429 FAILURE</text>
91
+ <text class="sub" x="864" y="382">429 + autoSwitchOn429=false</text>
92
+
93
+ <rect class="node-state" x="40" y="416" width="520" height="184" rx="6"/>
94
+ <text class="name" x="300" y="448">HTTP 429 failover pool</text>
95
+ <text class="sub" x="300" y="476">AUTHENTICATED · REGISTERED · SCOPE-ALLOWED</text>
96
+ <text class="sub" x="300" y="500">UNTRIED · STALE OR MISSING QUOTA ALLOWED</text>
97
+ <text class="sub" x="300" y="524">EXCLUDE: KNOWN ACTIVE 5H BLOCK</text>
98
+ <text class="sub" x="300" y="548">FRESH KNOWN: HIGHEST 7-DAY REMAINING FIRST</text>
99
+ <text class="sub" x="300" y="572">THEN UNRANKED: SLOT NUMBER ASCENDING</text>
100
+
101
+ <polygon class="node" points="704,460 824,508 704,556 584,508"/>
102
+ <text class="diamond-name" x="704" y="504">Failover</text>
103
+ <text class="diamond-name" x="704" y="528">candidate?</text>
104
+
105
+ <rect class="node-focal" x="856" y="468" width="336" height="80" rx="6"/>
106
+ <text class="name" x="1024" y="500">Switch slot and retry</text>
107
+ <text class="sub" x="1024" y="528">SAME REQUEST · MODEL ID KEPT</text>
108
+
109
+ <rect class="node-soft" x="856" y="568" width="336" height="72" rx="36"/>
110
+ <text class="name" x="1024" y="596">Stop failover</text>
111
+ <text class="sub" x="1024" y="620">NO ELIGIBLE UNTRIED SLOT</text>
112
+
113
+ <line x1="40" y1="660" x2="1240" y2="660" stroke="rgba(16,24,40,.141)" stroke-width="0.8"/>
114
+ <text class="note-label" x="40" y="692">RETRY</text>
115
+ <text class="note" x="120" y="692">Retries re-enter at Send request. Startup freshness is not checked again.</text>
116
+ </svg>
117
+ </main>
118
+ </body>
119
+ </html>
@@ -0,0 +1,97 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <svg width="1280" height="720" viewBox="0 0 1280 720" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="codex-routing-flow-title codex-routing-flow-desc">
3
+ <title id="codex-routing-flow-title">Codex startup ranking and HTTP 429 failover</title>
4
+ <desc id="codex-routing-flow-desc">At startup, only authenticated, registered, scope-allowed slots with fresh quota snapshots and no known active five-hour block are ranked; the slot with the highest seven-day remaining quota is chosen, or the current slot stays active when none qualify. Success keeps the active slot. Non-429 failures and HTTP 429 with autoSwitchOn429 disabled do not retry. With switching enabled, HTTP 429 marks the failed slot tried, then failover allows authenticated, registered, scope-allowed, untried slots with stale or missing quota, excludes known active five-hour blocks, ranks fresh known quota first and then unranked slots by slot number, and retries without returning to startup ranking.</desc>
5
+ <defs>
6
+ <style>
7
+ @import url(&quot;https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600&amp;family=Geist+Mono:wght@400;500;600&amp;display=swap&quot;);
8
+ .node { fill: #fff; stroke: #101828; stroke-width: 1; }
9
+ .node-state { fill: rgba(16,24,40,.05); stroke: #4c5665; stroke-width: 1; }
10
+ .node-soft { fill: rgba(16,24,40,.03); stroke: #101828; stroke-width: 1; }
11
+ .node-focal { fill: rgba(29,78,216,.08); stroke: #1d4ed8; stroke-width: 1.2; }
12
+ .name { fill: #101828; font: 600 20px 'Geist', sans-serif; text-anchor: middle; }
13
+ .diamond-name { fill: #101828; font: 600 18px 'Geist', sans-serif; text-anchor: middle; }
14
+ .sub { fill: #4c5665; font: 400 16px 'Geist Mono', monospace; text-anchor: middle; }
15
+ .edge { fill: none; stroke: #4c5665; stroke-width: 1.2; marker-end: url(#arrow); }
16
+ .label-mask { fill: #f0eee9; }
17
+ .label { fill: #4c5665; font: 500 16px 'Geist Mono', monospace; text-anchor: middle; letter-spacing: .04em; }
18
+ .note-label { fill: #4c5665; font: 500 16px 'Geist Mono', monospace; letter-spacing: .12em; }
19
+ .note { fill: #4c5665; font: 400 16px 'Geist', sans-serif; }
20
+ </style>
21
+ <marker id="arrow" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto"><polygon points="0 0,8 3,0 6" fill="#4c5665"/></marker>
22
+ </defs>
23
+
24
+ <rect width="1280" height="720" fill="#f0eee9"/>
25
+
26
+ <!-- Startup ranking uses only fresh, known quota. -->
27
+ <path class="edge" d="M508 104 H556"/>
28
+ <path class="edge" d="M864 104 H912"/>
29
+ <path class="edge" d="M1056 144 V192"/>
30
+
31
+ <!-- Outcomes split before the separate HTTP 429 failover pool. -->
32
+ <path class="edge" d="M936 240 H888"/>
33
+ <path class="edge" d="M1176 240 H1240 V348 H1024"/>
34
+ <path class="edge" d="M1056 288 V404 H300 V416"/>
35
+
36
+ <!-- Failover retries return to the request, never to startup ranking. -->
37
+ <path class="edge" d="M560 508 H584"/>
38
+ <path class="edge" d="M824 508 H856"/>
39
+ <path class="edge" d="M704 556 V604 H856"/>
40
+ <path class="edge" d="M1192 508 H1272 V104 H1200"/>
41
+
42
+ <rect class="label-mask" x="344" y="372" width="272" height="44" rx="2"/>
43
+ <text class="label" x="480" y="390">429 + SWITCH ON</text>
44
+ <text class="label" x="480" y="410">MARK FAILED SLOT TRIED</text>
45
+
46
+ <!-- Visually distinct candidate pools. -->
47
+ <rect class="node-focal" x="40" y="40" width="468" height="128" rx="6"/>
48
+ <text class="name" x="274" y="72">Startup ranking pool</text>
49
+ <text class="sub" x="274" y="100">AUTHENTICATED · REGISTERED · SCOPE-ALLOWED</text>
50
+ <text class="sub" x="274" y="124">FRESH USAGE SNAPSHOT REQUIRED</text>
51
+ <text class="sub" x="274" y="148">KNOWN 7-DAY REMAINING · NO ACTIVE 5H BLOCK</text>
52
+
53
+ <rect class="node" x="556" y="56" width="308" height="96" rx="6"/>
54
+ <text class="name" x="710" y="88">Choose startup route</text>
55
+ <text class="sub" x="710" y="116">HIGHEST 7-DAY REMAINING</text>
56
+ <text class="sub" x="710" y="140">NONE → KEEP CURRENT SLOT</text>
57
+
58
+ <rect class="node" x="912" y="64" width="288" height="80" rx="6"/>
59
+ <text class="name" x="1056" y="96">Send request</text>
60
+ <text class="sub" x="1056" y="124">ACTIVE SLOT · MODEL ID KEPT</text>
61
+
62
+ <polygon class="node" points="1056,192 1176,240 1056,288 936,240"/>
63
+ <text class="diamond-name" x="1056" y="247">Result</text>
64
+
65
+ <rect class="node-soft" x="648" y="200" width="240" height="80" rx="40"/>
66
+ <text class="name" x="768" y="232">Keep active slot</text>
67
+ <text class="sub" x="768" y="260">SUCCESS</text>
68
+
69
+ <rect class="node-soft" x="704" y="304" width="320" height="88" rx="44"/>
70
+ <text class="name" x="864" y="334">Stop without another slot</text>
71
+ <text class="sub" x="864" y="362">NON-429 FAILURE</text>
72
+ <text class="sub" x="864" y="382">429 + autoSwitchOn429=false</text>
73
+
74
+ <rect class="node-state" x="40" y="416" width="520" height="184" rx="6"/>
75
+ <text class="name" x="300" y="448">HTTP 429 failover pool</text>
76
+ <text class="sub" x="300" y="476">AUTHENTICATED · REGISTERED · SCOPE-ALLOWED</text>
77
+ <text class="sub" x="300" y="500">UNTRIED · STALE OR MISSING QUOTA ALLOWED</text>
78
+ <text class="sub" x="300" y="524">EXCLUDE: KNOWN ACTIVE 5H BLOCK</text>
79
+ <text class="sub" x="300" y="548">FRESH KNOWN: HIGHEST 7-DAY REMAINING FIRST</text>
80
+ <text class="sub" x="300" y="572">THEN UNRANKED: SLOT NUMBER ASCENDING</text>
81
+
82
+ <polygon class="node" points="704,460 824,508 704,556 584,508"/>
83
+ <text class="diamond-name" x="704" y="504">Failover</text>
84
+ <text class="diamond-name" x="704" y="528">candidate?</text>
85
+
86
+ <rect class="node-focal" x="856" y="468" width="336" height="80" rx="6"/>
87
+ <text class="name" x="1024" y="500">Switch slot and retry</text>
88
+ <text class="sub" x="1024" y="528">SAME REQUEST · MODEL ID KEPT</text>
89
+
90
+ <rect class="node-soft" x="856" y="568" width="336" height="72" rx="36"/>
91
+ <text class="name" x="1024" y="596">Stop failover</text>
92
+ <text class="sub" x="1024" y="620">NO ELIGIBLE UNTRIED SLOT</text>
93
+
94
+ <line x1="40" y1="660" x2="1240" y2="660" stroke="rgba(16,24,40,.141)" stroke-width="0.8"/>
95
+ <text class="note-label" x="40" y="692">RETRY</text>
96
+ <text class="note" x="120" y="692">Retries re-enter at Send request. Startup freshness is not checked again.</text>
97
+ </svg>
@@ -3,7 +3,7 @@ import { readFileSync } from "node:fs";
3
3
  import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
4
4
  import { join } from "node:path";
5
5
  import { setTimeout as delay } from "node:timers/promises";
6
- import { extensionConfigDir } from "@henryqw/pi-config-store";
6
+ import { createConfigStore, extensionConfigDir } from "@henryqw/pi-config-store";
7
7
  import { lock } from "proper-lockfile";
8
8
  import {
9
9
  createAssistantMessageEventStream,
@@ -71,6 +71,7 @@ type UsageState = {
71
71
  };
72
72
 
73
73
  type ParsedUsage = { remaining: number; reset: number; limitedUntil?: number; tier?: string };
74
+ type Config = { autoSwitchOn429: boolean };
74
75
 
75
76
  const NATIVE_PROVIDER_ID = "openai-codex";
76
77
  const CODEX_ALIAS_PATTERN = /^openai-codex-([2-9]|[1-9]\d+)$/;
@@ -86,6 +87,13 @@ const CLEANUP_TIMEOUT_MS = 250;
86
87
  const cachePath = () => join(extensionConfigDir("pi-multi-codex"), "usage.json");
87
88
  const cacheMutexPath = () => `${cachePath()}.mutex`;
88
89
 
90
+ function parseConfig(value: unknown): Config {
91
+ if (!isRecord(value) || Object.keys(value).length !== 1 || typeof value.autoSwitchOn429 !== "boolean") {
92
+ throw new Error("Config must contain only autoSwitchOn429:boolean.");
93
+ }
94
+ return { autoSwitchOn429: value.autoSwitchOn429 };
95
+ }
96
+
89
97
  function isRecord(value: unknown): value is JsonRecord {
90
98
  return typeof value === "object" && value !== null && !Array.isArray(value);
91
99
  }
@@ -817,10 +825,19 @@ export default function multiCodex(pi: ExtensionAPI): void {
817
825
  const native = builtinProviders().find((provider) => provider.id === NATIVE_PROVIDER_ID) as CodexProvider | undefined;
818
826
  if (!native) return;
819
827
  const quota = new CodexQuotaStatus();
828
+ const configStore = createConfigStore<Config>({
829
+ extensionId: "pi-multi-codex",
830
+ defaults: () => ({ autoSwitchOn429: true }),
831
+ parse: parseConfig,
832
+ });
820
833
  const registered = new Map<number, CodexProvider>();
834
+ const triedSlots = new Set<number>();
821
835
  let sessionContext: ExtensionContext | undefined;
822
836
  let automaticOpen = false;
823
837
  let automaticCandidate: Model<any> | undefined;
838
+ let autoSwitchOn429 = true;
839
+ let configWarned = false;
840
+ let providerRequest: { provider?: string; status?: number } | undefined;
824
841
 
825
842
  const registerSlot = (slot: number): void => {
826
843
  if (slot === 1 || registered.has(slot)) return;
@@ -863,6 +880,22 @@ export default function multiCodex(pi: ExtensionAPI): void {
863
880
  ?? Math.min(...tied.map((candidate) => candidate.slot));
864
881
  };
865
882
 
883
+ const failoverSlots = (ctx: ExtensionContext, model: Model<any>): number[] => {
884
+ const state = loadStateSync();
885
+ const now = Date.now();
886
+ return [...readCodexCredentials().entries()]
887
+ .flatMap(([slot, credential]) => {
888
+ if (triedSlots.has(slot) || (slot !== 1 && !registered.has(slot)) || !allowsModel(ctx, model, slot)) return [];
889
+ const identity = identityFor(credential);
890
+ const snapshot = state.slots.get(slot);
891
+ if (identity && snapshot?.accountHash === identity.accountHash && isFiveHourLimited(snapshot, now)) return [];
892
+ const remaining = identity && isFresh(snapshot, identity, now) ? snapshot?.remaining : undefined;
893
+ return [{ slot, remaining }];
894
+ })
895
+ .sort((left, right) => (right.remaining ?? -1) - (left.remaining ?? -1) || left.slot - right.slot)
896
+ .map(({ slot }) => slot);
897
+ };
898
+
866
899
  const footerText = (ctx: ExtensionContext): string | undefined => {
867
900
  const model = ctx.model;
868
901
  const slot = slotForProvider(model?.provider ?? "");
@@ -906,6 +939,17 @@ export default function multiCodex(pi: ExtensionAPI): void {
906
939
  pi.on("session_start", (_event, ctx) => {
907
940
  const slots = syncSlots();
908
941
  sessionContext = ctx;
942
+ triedSlots.clear();
943
+ providerRequest = undefined;
944
+ try {
945
+ autoSwitchOn429 = configStore.loadSync().value.autoSwitchOn429;
946
+ } catch {
947
+ autoSwitchOn429 = false;
948
+ if (!configWarned) {
949
+ configWarned = true;
950
+ ctx.ui.notify("Pi Multi Codex config is invalid. Automatic 429 switching is disabled; the file was left unchanged.", "warning");
951
+ }
952
+ }
909
953
  automaticOpen = isManagedProvider(ctx.model?.provider) && !sessionHasAgentWork(ctx);
910
954
  automaticCandidate = ctx.model;
911
955
  // Cache-only ranking happens before background refresh starts.
@@ -916,6 +960,8 @@ export default function multiCodex(pi: ExtensionAPI): void {
916
960
  });
917
961
 
918
962
  pi.on("before_agent_start", async (_event, ctx) => {
963
+ triedSlots.clear();
964
+ providerRequest = undefined;
919
965
  if (!automaticOpen) return;
920
966
  const model = ctx.model;
921
967
  updatePendingCandidate(ctx);
@@ -927,13 +973,62 @@ export default function multiCodex(pi: ExtensionAPI): void {
927
973
  updateFooter(ctx);
928
974
  });
929
975
 
976
+ pi.on("before_provider_request", (_event, ctx) => {
977
+ providerRequest = { provider: ctx.model?.provider };
978
+ });
979
+ pi.on("after_provider_response", (event) => {
980
+ if (providerRequest) providerRequest.status = event.status;
981
+ });
982
+ pi.on("message_end", async (event, ctx) => {
983
+ const request = providerRequest;
984
+ providerRequest = undefined;
985
+ if (
986
+ !autoSwitchOn429
987
+ || request?.status !== 429
988
+ || event.message.role !== "assistant"
989
+ || event.message.stopReason !== "error"
990
+ ) return;
991
+ const model = ctx.model;
992
+ const failedSlot = slotForProvider(event.message.provider);
993
+ if (
994
+ !model
995
+ || !failedSlot
996
+ || request.provider !== event.message.provider
997
+ || model.provider !== event.message.provider
998
+ || model.id !== event.message.model
999
+ ) return;
1000
+
1001
+ triedSlots.add(failedSlot);
1002
+ syncSlots();
1003
+ for (const slot of failoverSlots(ctx, model)) {
1004
+ const provider = providerForSlot(slot);
1005
+ if (!await pi.setModel({ ...model, provider })) continue;
1006
+ ctx.ui.notify(`Codex #${failedSlot} returned HTTP 429. Retrying with Codex #${slot}.`, "warning");
1007
+ updateFooter(ctx);
1008
+ return { message: { ...event.message, errorMessage: `HTTP 429: ${event.message.errorMessage ?? "Codex request was rate limited."}` } };
1009
+ }
1010
+
1011
+ return {
1012
+ message: {
1013
+ ...event.message,
1014
+ errorMessage: "Codex quota exceeded: all eligible account slots returned HTTP 429; automatic failover stopped.",
1015
+ },
1016
+ };
1017
+ });
1018
+
930
1019
  pi.on("agent_start", (_event, ctx) => {
931
1020
  automaticOpen = false;
932
1021
  if (!sessionHasAgentWork(ctx)) pi.appendEntry(AGENT_STARTED_ENTRY);
933
1022
  });
1023
+ pi.on("agent_settled", () => {
1024
+ triedSlots.clear();
1025
+ providerRequest = undefined;
1026
+ });
934
1027
  pi.on("session_shutdown", () => {
935
1028
  automaticOpen = false;
936
1029
  sessionContext = undefined;
1030
+ triedSlots.clear();
1031
+ providerRequest = undefined;
937
1032
  quota.stop();
938
1033
  });
939
1034
  pi.on("model_select", (_event, ctx) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-multi-codex",
3
- "version": "0.3.19",
3
+ "version": "0.4.1",
4
4
  "description": "Use multiple ChatGPT Codex OAuth accounts in Pi.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -15,6 +15,7 @@
15
15
  "license": "MIT",
16
16
  "files": [
17
17
  "LICENSE",
18
+ "docs",
18
19
  "extensions",
19
20
  "README.md"
20
21
  ],