@henryqw/pi-multi-codex 3.1.0 → 3.1.2
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 +14 -14
- package/extensions/multi-codex.ts +35 -50
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# `@henryqw/pi-multi-codex`
|
|
2
2
|
|
|
3
|
-
Add multiple ChatGPT Codex OAuth accounts and start Pi work on the eligible slot with the most
|
|
3
|
+
Add multiple ChatGPT Codex OAuth accounts and start Pi work on the eligible slot with the most remaining quota. The extension switches away from active five-hour quota blocks and can retry an HTTP 429 on another eligible slot.
|
|
4
4
|
|
|
5
5
|

|
|
6
6
|

|
|
@@ -11,42 +11,42 @@ Add multiple ChatGPT Codex OAuth accounts and start Pi work on the eligible slot
|
|
|
11
11
|
pi install npm:@henryqw/pi-multi-codex
|
|
12
12
|
```
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
This package requires Pi 1.1.0 or later in the 1.x series.
|
|
15
15
|
|
|
16
16
|
## Works with
|
|
17
17
|
|
|
18
18
|
| Package | Relationship | Purpose |
|
|
19
19
|
| --- | --- | --- |
|
|
20
|
-
| [`@henryqw/pi-footer`](https://pi.henry.wang/extensions/pi-footer) | Improves | Shows the
|
|
20
|
+
| [`@henryqw/pi-footer`](https://pi.henry.wang/extensions/pi-footer) | Improves | Shows the remaining quota or five-hour quota block of the active slot in the footer. |
|
|
21
21
|
| [`@henryqw/pi-subagent`](https://pi.henry.wang/extensions/pi-subagent) | Improves | Isolated children keep Main's active Codex slot. |
|
|
22
22
|
| [`@henryqw/pi-task-models`](https://pi.henry.wang/extensions/pi-task-models) | Improves | Numbered slots share one profile route. |
|
|
23
23
|
|
|
24
24
|
## Use
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
First, run `/login` and authenticate `OpenAI Codex (legacy)` for slot 1. Run `/codex-add`. Then run `/login` and select the new `OpenAI Codex #<n>` provider. Restart Pi or update model scope. Then run `/codex-status`. The status lists the new slot with cached quota when available, or `unavailable` until the first successful usage snapshot.
|
|
27
27
|
|
|
28
28
|
| Surface | Type | Purpose |
|
|
29
29
|
| --- | --- | --- |
|
|
30
30
|
| `/codex-add` | command | For people: create the next numbered slot, then authenticate that slot. |
|
|
31
|
-
| `/codex-status` | command | For people: show shared
|
|
31
|
+
| `/codex-status` | command | For people: show shared usage snapshots and five-hour quota blocks. It never waits on the network. |
|
|
32
32
|
| `/codex-switch` | command | For people: select an authenticated, scope-allowed slot for the current Codex model. |
|
|
33
33
|
| Pi model selector (`OpenAI Codex (legacy)`, `OpenAI Codex #<n>`) | ui | Choose an authenticated account slot. |
|
|
34
|
-
| Pi status `pi-multi-codex` | ui | Report the active slot's fresh quota, five-hour limit countdown, or stale/unavailable state
|
|
34
|
+
| Pi status `pi-multi-codex` | ui | Report the active slot's fresh quota, five-hour limit countdown, or stale/unavailable state. `@henryqw/pi-footer` displays it in the footer. |
|
|
35
35
|
|
|
36
36
|
The footer and `/codex-status` show the five-hour reset countdown for Free, Go, and Plus tiers. They show the seven-day reset countdown for Pro Lite and other tiers. A numbered slot is one Codex account position in Pi.
|
|
37
37
|
|
|
38
38
|
## Flow
|
|
39
39
|
|
|
40
|
-
- Before the first agent start, only fresh
|
|
40
|
+
- Before the first agent start, only fresh usage snapshots enter startup ranking. The slot with the most remaining quota wins.
|
|
41
41
|
- An explicit model selection always wins for the next agent start.
|
|
42
|
-
- Before later agent starts, a known active five-hour block switches to the eligible fresh slot with the most
|
|
42
|
+
- Before later agent starts, a known active five-hour quota block switches to the eligible fresh slot with the most remaining quota.
|
|
43
43
|
- After HTTP 429, failover can use authenticated, registered, scope-allowed, untried slots with stale or missing quota.
|
|
44
|
-
- Failover skips known active five-hour blocks. It ranks fresh known quota first, then unranked slots by slot number.
|
|
44
|
+
- Failover skips known active five-hour quota blocks. It ranks fresh known quota first, then unranked slots by slot number.
|
|
45
45
|
- Routing preserves the model ID.
|
|
46
46
|
- Only physical Codex models are slots. A virtual model listed under `openai-codex`, such as `openai-codex/auto`, is never routed to a slot and shows no quota status.
|
|
47
|
-
- During one agent run, each eligible slot is tried at most
|
|
47
|
+
- During one agent run, each eligible slot is tried at most one time after HTTP 429 responses.
|
|
48
48
|
- Automatic retry stops when no untried eligible slot remains.
|
|
49
|
-
- The footer shows the
|
|
49
|
+
- The footer shows the fresh quota or five-hour quota block of the active slot.
|
|
50
50
|
|
|
51
51
|
## Config
|
|
52
52
|
|
|
@@ -56,14 +56,14 @@ Package-owned: `~/.pi/agent/config/pi-multi-codex/config.json`
|
|
|
56
56
|
| --- | --- | --- | --- |
|
|
57
57
|
| `autoSwitchOn429` | Switches to another eligible slot after an HTTP 429. | Boolean. | `true` when the config file is missing |
|
|
58
58
|
|
|
59
|
-
Edit this file yourself
|
|
59
|
+
Edit this file yourself. The extension does not create or write it. A missing file uses the default `true`, and config changes take effect at the next session start. Set `autoSwitchOn429` to `false` to disable automatic switching. A present config must contain only this field as a boolean. If the config is invalid, the extension keeps the file, shows a warning, and disables automatic switching for the session. Fix the file and start a new session to recover.
|
|
60
60
|
|
|
61
61
|
## State and storage
|
|
62
62
|
|
|
63
|
-
The extension
|
|
63
|
+
The extension keeps a generated, credential-free quota cache at `~/.pi/agent/config/pi-multi-codex/usage.json`.
|
|
64
64
|
|
|
65
65
|
## Limits and recovery
|
|
66
66
|
|
|
67
|
-
The extension reads `auth.json` to identify account slots but does not directly edit credentials. When it asks Pi's auth resolver for a token to check quota, Pi
|
|
67
|
+
The extension reads `auth.json` to identify account slots but does not directly edit credentials. When it asks Pi's auth resolver for a token to check quota, Pi can refresh expired OAuth credentials and update its auth store.
|
|
68
68
|
|
|
69
69
|
Scoped sessions can switch only to exact scoped aliases.
|
|
@@ -260,7 +260,6 @@ async function loadState(signal?: AbortSignal): Promise<UsageState> {
|
|
|
260
260
|
|
|
261
261
|
async function saveState(state: UsageState, signal?: AbortSignal): Promise<void> {
|
|
262
262
|
signal?.throwIfAborted();
|
|
263
|
-
if (state.untrusted) return;
|
|
264
263
|
const file = cachePath();
|
|
265
264
|
const data = `${JSON.stringify({
|
|
266
265
|
slots: [...state.slots.values()].sort((a, b) => a.slot - b.slot),
|
|
@@ -272,11 +271,11 @@ async function saveState(state: UsageState, signal?: AbortSignal): Promise<void>
|
|
|
272
271
|
async function withCacheMutex<T>(operation: (signal: AbortSignal) => Promise<T>, signal?: AbortSignal): Promise<T> {
|
|
273
272
|
signal?.throwIfAborted();
|
|
274
273
|
await mkdir(extensionConfigDir("pi-multi-codex"), { recursive: true, mode: 0o700 });
|
|
275
|
-
|
|
276
|
-
|
|
274
|
+
const compromised = new AbortController();
|
|
275
|
+
let release: () => Promise<void>;
|
|
276
|
+
for (;;) {
|
|
277
277
|
signal?.throwIfAborted();
|
|
278
278
|
try {
|
|
279
|
-
const compromised = new AbortController();
|
|
280
279
|
release = await lock(cachePath(), {
|
|
281
280
|
lockfilePath: cacheMutexPath(),
|
|
282
281
|
realpath: false,
|
|
@@ -285,19 +284,19 @@ async function withCacheMutex<T>(operation: (signal: AbortSignal) => Promise<T>,
|
|
|
285
284
|
retries: 0,
|
|
286
285
|
onCompromised: (error) => compromised.abort(error),
|
|
287
286
|
});
|
|
288
|
-
|
|
289
|
-
try {
|
|
290
|
-
operationSignal.throwIfAborted();
|
|
291
|
-
return await raceWithSignal(operation(operationSignal), operationSignal);
|
|
292
|
-
} finally {
|
|
293
|
-
await release().catch(() => undefined);
|
|
294
|
-
}
|
|
287
|
+
break;
|
|
295
288
|
} catch (error) {
|
|
296
289
|
if (!(error && typeof error === "object" && "code" in error && String(error.code) === "ELOCKED")) throw error;
|
|
297
290
|
await delay(CACHE_MUTEX_RETRY_MS, undefined, { signal, ref: false });
|
|
298
291
|
}
|
|
299
292
|
}
|
|
300
|
-
|
|
293
|
+
const operationSignal = signal ? AbortSignal.any([signal, compromised.signal]) : compromised.signal;
|
|
294
|
+
try {
|
|
295
|
+
operationSignal.throwIfAborted();
|
|
296
|
+
return await raceWithSignal(operation(operationSignal), operationSignal);
|
|
297
|
+
} finally {
|
|
298
|
+
await release().catch(() => undefined);
|
|
299
|
+
}
|
|
301
300
|
}
|
|
302
301
|
|
|
303
302
|
function isMeasured(snapshot: UsageSnapshot | undefined): snapshot is MeasuredSnapshot {
|
|
@@ -434,15 +433,10 @@ class CodexQuotaStatus {
|
|
|
434
433
|
|
|
435
434
|
private change<T>(change: (state: UsageState) => { value: T; write: boolean }, signal?: AbortSignal): Promise<T | undefined> {
|
|
436
435
|
const operation = this.writes.then(() => withCacheMutex(async (operationSignal) => {
|
|
437
|
-
operationSignal.throwIfAborted();
|
|
438
436
|
const state = await loadState(operationSignal);
|
|
439
|
-
operationSignal.throwIfAborted();
|
|
440
437
|
if (state.untrusted) return undefined;
|
|
441
438
|
const result = change(state);
|
|
442
|
-
if (result.write)
|
|
443
|
-
operationSignal.throwIfAborted();
|
|
444
|
-
await saveState(state, operationSignal);
|
|
445
|
-
}
|
|
439
|
+
if (result.write) await saveState(state, operationSignal);
|
|
446
440
|
return result.value;
|
|
447
441
|
}, signal));
|
|
448
442
|
this.writes = operation.then(() => undefined, () => undefined);
|
|
@@ -468,7 +462,6 @@ class CodexQuotaStatus {
|
|
|
468
462
|
const refresh = this.refreshContext;
|
|
469
463
|
if (!refresh) return;
|
|
470
464
|
void this.refreshDue(refresh)
|
|
471
|
-
.catch(() => undefined)
|
|
472
465
|
.then(() => {
|
|
473
466
|
if (this.refreshContext === refresh) this.scheduleRefresh();
|
|
474
467
|
});
|
|
@@ -507,20 +500,21 @@ class CodexQuotaStatus {
|
|
|
507
500
|
this.timer = undefined;
|
|
508
501
|
if (this.refreshContext === refresh) this.requestRefresh();
|
|
509
502
|
}, delay);
|
|
510
|
-
this.timer.unref
|
|
503
|
+
this.timer.unref();
|
|
511
504
|
}
|
|
512
505
|
|
|
513
506
|
private async refreshDue(refresh: QuotaRefresh): Promise<void> {
|
|
514
507
|
const credentials = readCodexCredentials();
|
|
515
508
|
const state = await this.state();
|
|
516
|
-
|
|
517
|
-
|
|
509
|
+
const now = Date.now();
|
|
510
|
+
for (const [slot, credential] of credentials) {
|
|
511
|
+
if (this.refreshContext !== refresh || this.active.has(slot)) continue;
|
|
518
512
|
const identity = identityFor(credential);
|
|
519
|
-
if (!identity)
|
|
513
|
+
if (!identity) continue;
|
|
520
514
|
const snapshot = state.slots.get(slot);
|
|
521
|
-
if (isFresh(snapshot, identity,
|
|
515
|
+
if (isFresh(snapshot, identity, now) || checkedRecently(snapshot, identity, now)) continue;
|
|
522
516
|
this.launch(slot, identity, refresh);
|
|
523
|
-
}
|
|
517
|
+
}
|
|
524
518
|
}
|
|
525
519
|
|
|
526
520
|
private launch(slot: number, identity: SlotIdentity, refresh: QuotaRefresh): void {
|
|
@@ -569,7 +563,7 @@ class CodexQuotaStatus {
|
|
|
569
563
|
if (!kept) this.stopHeartbeat(slot, owner);
|
|
570
564
|
}).catch(() => this.stopHeartbeat(slot, owner));
|
|
571
565
|
}, HEARTBEAT_MS);
|
|
572
|
-
timer.unref
|
|
566
|
+
timer.unref();
|
|
573
567
|
this.heartbeats.set(slot, { owner, timer });
|
|
574
568
|
}
|
|
575
569
|
|
|
@@ -662,7 +656,7 @@ class CodexQuotaStatus {
|
|
|
662
656
|
signal: fetchSignal,
|
|
663
657
|
});
|
|
664
658
|
if (!response.ok) throw new Error(`Codex usage request failed (${response.status}).`);
|
|
665
|
-
const usage = parseCodexUsage(await
|
|
659
|
+
const usage = parseCodexUsage(await response.json());
|
|
666
660
|
if (!usage) throw new Error("Codex usage response has no usable seven-day window.");
|
|
667
661
|
if (!this.identityStillCurrent(slot, identity.accountHash) || !(await this.stillOwn(slot, owner, identity.accountHash))) {
|
|
668
662
|
discard = true;
|
|
@@ -703,10 +697,6 @@ class CodexQuotaStatus {
|
|
|
703
697
|
}
|
|
704
698
|
}
|
|
705
699
|
|
|
706
|
-
function nativeModel(model: CodexModel): CodexModel {
|
|
707
|
-
return model.provider === NATIVE_PROVIDER_ID ? model : { ...model, provider: NATIVE_PROVIDER_ID };
|
|
708
|
-
}
|
|
709
|
-
|
|
710
700
|
function aliasModel(model: CodexModel, provider: string): CodexModel {
|
|
711
701
|
return model.provider === provider ? model : { ...model, provider };
|
|
712
702
|
}
|
|
@@ -717,10 +707,7 @@ function nativeContext(context: TranscriptContext, alias: string): TranscriptCon
|
|
|
717
707
|
messages: context.messages.map((message) => {
|
|
718
708
|
if (message.role !== "assistant") return message;
|
|
719
709
|
const provider = message.provider === alias ? NATIVE_PROVIDER_ID : message.provider === NATIVE_PROVIDER_ID ? alias : message.provider;
|
|
720
|
-
|
|
721
|
-
const rewritten = { ...message, provider };
|
|
722
|
-
if (message.deferred) rewritten.deferred = { ...message.deferred, provider };
|
|
723
|
-
return rewritten;
|
|
710
|
+
return provider === message.provider ? message : aliasMessage(message, provider);
|
|
724
711
|
}),
|
|
725
712
|
};
|
|
726
713
|
}
|
|
@@ -788,28 +775,28 @@ export function createCodexAliasProvider(native: CodexProvider, slot: number): C
|
|
|
788
775
|
auth: native.auth,
|
|
789
776
|
getModels: () => models,
|
|
790
777
|
stream: (model, context, options?: ApiStreamOptions<"openai-codex-responses">) =>
|
|
791
|
-
aliasStream(native.stream(
|
|
778
|
+
aliasStream(native.stream(aliasModel(model, NATIVE_PROVIDER_ID), nativeContext(context, providerId), options), model, providerId),
|
|
792
779
|
streamSimple: (model, context, options?: SimpleStreamOptions) =>
|
|
793
|
-
aliasStream(native.streamSimple(
|
|
780
|
+
aliasStream(native.streamSimple(aliasModel(model, NATIVE_PROVIDER_ID), nativeContext(context, providerId), options), model, providerId),
|
|
794
781
|
};
|
|
795
782
|
|
|
796
783
|
if (native.filterModels) {
|
|
797
784
|
provider.filterModels = (available, credential) =>
|
|
798
785
|
native
|
|
799
|
-
.filterModels!(available.map(
|
|
786
|
+
.filterModels!(available.map((model) => aliasModel(model, NATIVE_PROVIDER_ID)), credential)
|
|
800
787
|
.map((model) => aliasModel(model, providerId));
|
|
801
788
|
}
|
|
802
789
|
if (native.fetchDeferred) {
|
|
803
790
|
provider.fetchDeferred = (model, handle, options?: DeferredFetchOptions) =>
|
|
804
791
|
aliasStream(
|
|
805
|
-
native.fetchDeferred!(
|
|
792
|
+
native.fetchDeferred!(aliasModel(model, NATIVE_PROVIDER_ID), { ...handle, provider: NATIVE_PROVIDER_ID }, options),
|
|
806
793
|
model,
|
|
807
794
|
providerId,
|
|
808
795
|
);
|
|
809
796
|
}
|
|
810
797
|
if (native.cancelDeferred) {
|
|
811
798
|
provider.cancelDeferred = (model, handle, options?: DeferredCancelOptions) =>
|
|
812
|
-
native.cancelDeferred!(
|
|
799
|
+
native.cancelDeferred!(aliasModel(model, NATIVE_PROVIDER_ID), { ...handle, provider: NATIVE_PROVIDER_ID }, options);
|
|
813
800
|
}
|
|
814
801
|
return provider;
|
|
815
802
|
}
|
|
@@ -824,9 +811,9 @@ function isManagedModel(model: Model<any> | undefined): model is CodexModel {
|
|
|
824
811
|
}
|
|
825
812
|
|
|
826
813
|
function sessionHasAgentWork(ctx: ExtensionContext): boolean {
|
|
827
|
-
return ctx.sessionManager
|
|
814
|
+
return ctx.sessionManager.getBranch().some((entry) =>
|
|
828
815
|
entry.type === "message" || (entry.type === "custom" && entry.customType === AGENT_STARTED_ENTRY),
|
|
829
|
-
)
|
|
816
|
+
);
|
|
830
817
|
}
|
|
831
818
|
|
|
832
819
|
export default function multiCodex(pi: ExtensionAPI): void {
|
|
@@ -863,7 +850,7 @@ export default function multiCodex(pi: ExtensionAPI): void {
|
|
|
863
850
|
};
|
|
864
851
|
|
|
865
852
|
const allowsModel = (ctx: ExtensionContext, model: Model<any>, slot: number): boolean => {
|
|
866
|
-
const scopedModels = ctx.scopedModels
|
|
853
|
+
const scopedModels = ctx.scopedModels;
|
|
867
854
|
return scopedModels.length === 0 || scopedModels.some(({ model: scoped }) => scoped.provider === providerForSlot(slot) && scoped.id === model.id);
|
|
868
855
|
};
|
|
869
856
|
|
|
@@ -919,17 +906,17 @@ export default function multiCodex(pi: ExtensionAPI): void {
|
|
|
919
906
|
const now = Date.now();
|
|
920
907
|
if (isFiveHourLimited(snapshot, now)) {
|
|
921
908
|
const text = `${prefix} · 5h limit · ${formatDuration(snapshot.limitedUntil! - now)}`;
|
|
922
|
-
return ctx.ui.theme
|
|
909
|
+
return ctx.ui.theme.fg("error", text);
|
|
923
910
|
}
|
|
924
911
|
if (!isFresh(snapshot, identity, now)) return `${prefix} · stale`;
|
|
925
912
|
const display = displayedReset(snapshot, now);
|
|
926
913
|
const text = `${prefix} · ${formatPercent(snapshot.remaining)}% · ${display.label} ${formatDuration(display.time - now)}`;
|
|
927
914
|
const color = snapshot.remaining >= 50 ? "success" : snapshot.remaining >= 25 ? "warning" : "error";
|
|
928
|
-
return ctx.ui.theme
|
|
915
|
+
return ctx.ui.theme.fg(color, text);
|
|
929
916
|
};
|
|
930
917
|
|
|
931
918
|
const updateFooter = (ctx: ExtensionContext | undefined = sessionContext): void => {
|
|
932
|
-
if (ctx) ctx.ui.setStatus
|
|
919
|
+
if (ctx) ctx.ui.setStatus("pi-multi-codex", footerText(ctx));
|
|
933
920
|
};
|
|
934
921
|
|
|
935
922
|
const setModelAutomatically = async (model: Model<any>): Promise<boolean> => {
|
|
@@ -1099,10 +1086,8 @@ export default function multiCodex(pi: ExtensionAPI): void {
|
|
|
1099
1086
|
return;
|
|
1100
1087
|
}
|
|
1101
1088
|
|
|
1102
|
-
const authenticated = [...syncSlots()]
|
|
1103
|
-
|
|
1104
|
-
.sort((left, right) => left - right);
|
|
1105
|
-
const scopedModels = ctx.scopedModels ?? [];
|
|
1089
|
+
const authenticated = [...syncSlots()].sort((left, right) => left - right);
|
|
1090
|
+
const scopedModels = ctx.scopedModels;
|
|
1106
1091
|
const slots = authenticated.filter((slot) => allowsModel(ctx, model, slot));
|
|
1107
1092
|
const currentSlot = slotForProvider(model.provider);
|
|
1108
1093
|
if (slots.length === 0 || (scopedModels.length > 0 && authenticated.some((slot) => !allowsModel(ctx, model, slot)) && slots.every((slot) => slot === currentSlot))) {
|