@henryqw/pi-multi-codex 0.3.19 → 0.4.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/README.md +16 -2
- package/extensions/multi-codex.ts +96 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@ Use multiple ChatGPT Codex OAuth accounts in Pi.
|
|
|
7
7
|
## Why
|
|
8
8
|
|
|
9
9
|
- **Created for**: Use multiple ChatGPT Codex OAuth accounts from one Pi installation.
|
|
10
|
-
- **Advantage**:
|
|
10
|
+
- **Advantage**: Quota-aware routing starts with the eligible slot that has the most remaining seven-day quota. If that slot returns HTTP 429, Pi retries with another eligible slot.
|
|
11
11
|
|
|
12
12
|
## Install
|
|
13
13
|
|
|
@@ -36,9 +36,23 @@ A numbered slot is one Codex account position in Pi.
|
|
|
36
36
|
- The extension reads `auth.json`. It never writes or refreshes credentials.
|
|
37
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
38
|
- A slot whose reported five-hour window has reached 100% is excluded until that window resets.
|
|
39
|
-
- Routing preserves model ID
|
|
39
|
+
- Routing preserves the model ID.
|
|
40
|
+
- During one agent run, each eligible slot is tried at most once after HTTP 429 responses.
|
|
41
|
+
- Automatic retry stops when no untried eligible slot remains.
|
|
40
42
|
- The footer shows the active slot's fresh quota or five-hour block.
|
|
41
43
|
- Scoped sessions can switch only to exact scoped aliases.
|
|
42
44
|
- Restart or update model scope after adding a slot.
|
|
43
45
|
|
|
46
|
+
## Config
|
|
47
|
+
|
|
48
|
+
Automatic HTTP 429 switching is on by default. To disable it, create `~/.pi/agent/config/pi-multi-codex/config.json`:
|
|
49
|
+
|
|
50
|
+
```json
|
|
51
|
+
{
|
|
52
|
+
"autoSwitchOn429": false
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
The config must contain only `autoSwitchOn429` as a boolean. Invalid config is preserved and disables automatic switching.
|
|
57
|
+
|
|
44
58
|
Generated credential-free quota cache: `~/.pi/agent/config/pi-multi-codex/usage.json`. The extension maintains it.
|
|
@@ -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) => {
|