@henryqw/pi-multi-codex 0.3.16 → 0.3.18
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 -9
- package/extensions/multi-codex.ts +74 -35
- package/package.json +4 -3
package/README.md
CHANGED
|
@@ -4,8 +4,8 @@ Use multiple ChatGPT Codex OAuth accounts in Pi.
|
|
|
4
4
|
|
|
5
5
|
## Why
|
|
6
6
|
|
|
7
|
-
- **Created for**:
|
|
8
|
-
- **Advantage**: Before work starts, quota-aware routing selects the managed slot with the most remaining seven-day quota
|
|
7
|
+
- **Created for**: Use multiple ChatGPT Codex OAuth accounts from one Pi installation.
|
|
8
|
+
- **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
9
|
|
|
10
10
|
## Install
|
|
11
11
|
|
|
@@ -17,21 +17,26 @@ pi install npm:@henryqw/pi-multi-codex
|
|
|
17
17
|
|
|
18
18
|
| Package | Why |
|
|
19
19
|
| --- | --- |
|
|
20
|
-
| `@henryqw/pi-
|
|
20
|
+
| `@henryqw/pi-footer` | Improves. Shows the active slot's quota or five-hour block in the footer. |
|
|
21
21
|
| `@henryqw/pi-subagent` | Improves. Isolated children keep Main's active Codex slot. |
|
|
22
|
+
| `@henryqw/pi-task-models` | Improves. Numbered slots share one profile route. |
|
|
22
23
|
|
|
23
24
|
## Use
|
|
24
25
|
|
|
25
26
|
| Surface | Type | Purpose |
|
|
26
27
|
| --- | --- | --- |
|
|
27
28
|
| `/codex-add` | command | Create the next numbered slot, then authenticate that slot. |
|
|
28
|
-
| `/codex-status` | command | Show shared
|
|
29
|
+
| `/codex-status` | command | Show shared quota snapshots and five-hour blocks. Never waits on network. |
|
|
29
30
|
| `/codex-switch` | command | Pick an authenticated slot. |
|
|
30
31
|
|
|
31
|
-
|
|
32
|
+
A numbered slot is one Codex account position in Pi.
|
|
32
33
|
|
|
33
|
-
|
|
34
|
+
- The extension reads `auth.json`. It never writes or refreshes credentials.
|
|
35
|
+
- Before the first agent start, a fresh snapshot routes a managed Codex model to the eligible slot with the most seven-day quota.
|
|
36
|
+
- A slot whose reported five-hour window has reached 100% is excluded until that window resets.
|
|
37
|
+
- Routing preserves model ID and never changes in-progress work.
|
|
38
|
+
- The footer shows the active slot's fresh quota or five-hour block.
|
|
39
|
+
- Scoped sessions can switch only to exact scoped aliases.
|
|
40
|
+
- Restart or update model scope after adding a slot.
|
|
34
41
|
|
|
35
|
-
|
|
36
|
-
| --- | --- |
|
|
37
|
-
| `~/.pi/agent/config/pi-multi-codex/usage.json` | Generated credential-free quota cache, maintained by the extension. |
|
|
42
|
+
Generated credential-free quota cache: `~/.pi/agent/config/pi-multi-codex/usage.json`. The extension maintains it.
|
|
@@ -3,6 +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
7
|
import { lock } from "proper-lockfile";
|
|
7
8
|
import {
|
|
8
9
|
createAssistantMessageEventStream,
|
|
@@ -53,6 +54,7 @@ type UsageSnapshot = {
|
|
|
53
54
|
fetchedAt?: number;
|
|
54
55
|
remaining?: number;
|
|
55
56
|
reset?: number;
|
|
57
|
+
limitedUntil?: number | null;
|
|
56
58
|
};
|
|
57
59
|
|
|
58
60
|
type UsageLock = {
|
|
@@ -65,9 +67,10 @@ type UsageLock = {
|
|
|
65
67
|
type UsageState = {
|
|
66
68
|
slots: Map<number, UsageSnapshot>;
|
|
67
69
|
locks: Map<number, UsageLock>;
|
|
70
|
+
untrusted?: true;
|
|
68
71
|
};
|
|
69
72
|
|
|
70
|
-
type ParsedUsage = { remaining: number; reset: number; tier?: string };
|
|
73
|
+
type ParsedUsage = { remaining: number; reset: number; limitedUntil?: number; tier?: string };
|
|
71
74
|
|
|
72
75
|
const NATIVE_PROVIDER_ID = "openai-codex";
|
|
73
76
|
const CODEX_ALIAS_PATTERN = /^openai-codex-([2-9]|[1-9]\d+)$/;
|
|
@@ -80,7 +83,7 @@ const HEARTBEAT_MS = 5_000;
|
|
|
80
83
|
const OPERATION_TIMEOUT_MS = 10_000;
|
|
81
84
|
const CACHE_MUTEX_RETRY_MS = 20;
|
|
82
85
|
const CLEANUP_TIMEOUT_MS = 250;
|
|
83
|
-
const cachePath = () => join(
|
|
86
|
+
const cachePath = () => join(extensionConfigDir("pi-multi-codex"), "usage.json");
|
|
84
87
|
const cacheMutexPath = () => `${cachePath()}.mutex`;
|
|
85
88
|
|
|
86
89
|
function isRecord(value: unknown): value is JsonRecord {
|
|
@@ -171,9 +174,14 @@ function validTier(value: unknown): value is string {
|
|
|
171
174
|
function readSnapshot(value: unknown): UsageSnapshot | undefined {
|
|
172
175
|
if (!isRecord(value) || !validSlot(value.slot) || !validHash(value.accountHash) || !validTime(value.checkedAt)) return undefined;
|
|
173
176
|
const successful = value.fetchedAt !== undefined || value.remaining !== undefined || value.reset !== undefined;
|
|
174
|
-
if (successful && (
|
|
175
|
-
|
|
176
|
-
|
|
177
|
+
if (successful && (
|
|
178
|
+
!validTime(value.fetchedAt) ||
|
|
179
|
+
typeof value.remaining !== "number" ||
|
|
180
|
+
!Number.isFinite(value.remaining) ||
|
|
181
|
+
!validTime(value.reset) ||
|
|
182
|
+
(value.limitedUntil !== null && !validTime(value.limitedUntil))
|
|
183
|
+
)) return undefined;
|
|
184
|
+
if (!successful && value.limitedUntil !== undefined) return undefined;
|
|
177
185
|
return {
|
|
178
186
|
slot: value.slot,
|
|
179
187
|
accountHash: value.accountHash,
|
|
@@ -184,6 +192,7 @@ function readSnapshot(value: unknown): UsageSnapshot | undefined {
|
|
|
184
192
|
fetchedAt: value.fetchedAt as number,
|
|
185
193
|
remaining: Math.max(0, Math.min(100, value.remaining as number)),
|
|
186
194
|
reset: value.reset as number,
|
|
195
|
+
limitedUntil: value.limitedUntil as number | null,
|
|
187
196
|
}
|
|
188
197
|
: {}),
|
|
189
198
|
};
|
|
@@ -196,45 +205,52 @@ function readLock(value: unknown): UsageLock | undefined {
|
|
|
196
205
|
return { slot: value.slot, owner: value.owner, accountHash: value.accountHash, heartbeatAt: value.heartbeatAt };
|
|
197
206
|
}
|
|
198
207
|
|
|
199
|
-
function parseState(value: unknown): UsageState {
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
for (const snapshot of
|
|
208
|
+
function parseState(value: unknown): UsageState | undefined {
|
|
209
|
+
if (!isRecord(value) || !Array.isArray(value.slots) || !Array.isArray(value.locks)) return undefined;
|
|
210
|
+
const state = emptyState();
|
|
211
|
+
for (const snapshot of value.slots) {
|
|
203
212
|
const parsed = readSnapshot(snapshot);
|
|
204
|
-
if (parsed
|
|
213
|
+
if (!parsed || state.slots.has(parsed.slot)) return undefined;
|
|
214
|
+
state.slots.set(parsed.slot, parsed);
|
|
205
215
|
}
|
|
206
|
-
for (const lock of
|
|
216
|
+
for (const lock of value.locks) {
|
|
207
217
|
const parsed = readLock(lock);
|
|
208
|
-
if (parsed
|
|
218
|
+
if (!parsed || state.locks.has(parsed.slot)) return undefined;
|
|
219
|
+
state.locks.set(parsed.slot, parsed);
|
|
209
220
|
}
|
|
210
221
|
return state;
|
|
211
222
|
}
|
|
212
223
|
|
|
213
|
-
function emptyState(): UsageState {
|
|
214
|
-
return { slots: new Map(), locks: new Map() };
|
|
224
|
+
function emptyState(untrusted = false): UsageState {
|
|
225
|
+
return { slots: new Map(), locks: new Map(), ...(untrusted ? { untrusted: true } : {}) };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function isMissingCacheFile(error: unknown): boolean {
|
|
229
|
+
return Boolean(error && typeof error === "object" && "code" in error && String(error.code) === "ENOENT");
|
|
215
230
|
}
|
|
216
231
|
|
|
217
232
|
function loadStateSync(): UsageState {
|
|
218
233
|
try {
|
|
219
|
-
return parseState(JSON.parse(readFileSync(cachePath(), "utf8")));
|
|
220
|
-
} catch {
|
|
221
|
-
return emptyState();
|
|
234
|
+
return parseState(JSON.parse(readFileSync(cachePath(), "utf8"))) ?? emptyState(true);
|
|
235
|
+
} catch (error) {
|
|
236
|
+
return emptyState(!isMissingCacheFile(error));
|
|
222
237
|
}
|
|
223
238
|
}
|
|
224
239
|
|
|
225
240
|
async function loadState(signal?: AbortSignal): Promise<UsageState> {
|
|
226
241
|
try {
|
|
227
|
-
return parseState(JSON.parse(await readFile(cachePath(), { encoding: "utf8", signal })));
|
|
242
|
+
return parseState(JSON.parse(await readFile(cachePath(), { encoding: "utf8", signal }))) ?? emptyState(true);
|
|
228
243
|
} catch (error) {
|
|
229
244
|
signal?.throwIfAborted();
|
|
230
|
-
return emptyState();
|
|
245
|
+
return emptyState(!isMissingCacheFile(error));
|
|
231
246
|
}
|
|
232
247
|
}
|
|
233
248
|
|
|
234
249
|
async function saveState(state: UsageState, signal?: AbortSignal): Promise<void> {
|
|
235
250
|
signal?.throwIfAborted();
|
|
251
|
+
if (state.untrusted) return;
|
|
236
252
|
const file = cachePath();
|
|
237
|
-
const directory =
|
|
253
|
+
const directory = extensionConfigDir("pi-multi-codex");
|
|
238
254
|
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
239
255
|
const data = `${JSON.stringify({
|
|
240
256
|
slots: [...state.slots.values()].sort((a, b) => a.slot - b.slot),
|
|
@@ -254,7 +270,7 @@ async function saveState(state: UsageState, signal?: AbortSignal): Promise<void>
|
|
|
254
270
|
|
|
255
271
|
async function withCacheMutex<T>(operation: (signal: AbortSignal) => Promise<T>, signal?: AbortSignal): Promise<T> {
|
|
256
272
|
signal?.throwIfAborted();
|
|
257
|
-
await mkdir(
|
|
273
|
+
await mkdir(extensionConfigDir("pi-multi-codex"), { recursive: true, mode: 0o700 });
|
|
258
274
|
let release: (() => Promise<void>) | undefined;
|
|
259
275
|
while (!release) {
|
|
260
276
|
signal?.throwIfAborted();
|
|
@@ -295,6 +311,10 @@ function isFresh(snapshot: UsageSnapshot | undefined, identity: SlotIdentity, no
|
|
|
295
311
|
);
|
|
296
312
|
}
|
|
297
313
|
|
|
314
|
+
function isFiveHourLimited(snapshot: UsageSnapshot, now: number): boolean {
|
|
315
|
+
return validTime(snapshot.limitedUntil) && snapshot.limitedUntil > now;
|
|
316
|
+
}
|
|
317
|
+
|
|
298
318
|
function checkedRecently(snapshot: UsageSnapshot | undefined, identity: SlotIdentity, now: number): boolean {
|
|
299
319
|
return Boolean(
|
|
300
320
|
snapshot &&
|
|
@@ -324,20 +344,28 @@ function resetTime(window: JsonRecord, now: number): number | undefined {
|
|
|
324
344
|
return resetAfter === undefined || resetAfter < 0 ? undefined : now + resetAfter * 1000;
|
|
325
345
|
}
|
|
326
346
|
|
|
327
|
-
/** Extract seven-day
|
|
347
|
+
/** Extract seven-day quota and any active five-hour block. */
|
|
328
348
|
export function parseCodexUsage(value: unknown, now = Date.now()): ParsedUsage | undefined {
|
|
329
349
|
if (!isRecord(value)) return undefined;
|
|
330
350
|
const rateLimit = isRecord(value.rate_limit) ? value.rate_limit : value;
|
|
331
|
-
const windows = Object.
|
|
332
|
-
|
|
351
|
+
const windows = Object.values(rateLimit).flatMap((entry) => {
|
|
352
|
+
if (!isRecord(entry)) return [];
|
|
353
|
+
const used = numberValue(entry.used_percent);
|
|
354
|
+
const reset = resetTime(entry, now);
|
|
355
|
+
return used === undefined || reset === undefined ? [] : [{ value: entry, used, reset }];
|
|
356
|
+
});
|
|
333
357
|
const sevenDay = windows.find((window) => numberValue(window.value.limit_window_seconds) === 7 * 24 * 60 * 60)
|
|
334
358
|
?? (windows.length === 1 && numberValue(windows[0].value.limit_window_seconds) === undefined ? windows[0] : undefined);
|
|
335
|
-
if (!sevenDay) return undefined;
|
|
336
|
-
const
|
|
337
|
-
const
|
|
338
|
-
if (used === undefined || reset === undefined || reset <= now) return undefined;
|
|
359
|
+
if (!sevenDay || sevenDay.reset <= now) return undefined;
|
|
360
|
+
const fiveHour = windows.find((window) => numberValue(window.value.limit_window_seconds) === 5 * 60 * 60);
|
|
361
|
+
const limitedUntil = fiveHour && fiveHour.used >= 100 && fiveHour.reset > now ? fiveHour.reset : undefined;
|
|
339
362
|
const tier = validTier(value.plan_type) ? value.plan_type : validTier(value.tier) ? value.tier : undefined;
|
|
340
|
-
return {
|
|
363
|
+
return {
|
|
364
|
+
remaining: Math.max(0, Math.min(100, 100 - sevenDay.used)),
|
|
365
|
+
reset: sevenDay.reset,
|
|
366
|
+
...(limitedUntil ? { limitedUntil } : {}),
|
|
367
|
+
...(tier ? { tier } : {}),
|
|
368
|
+
};
|
|
341
369
|
}
|
|
342
370
|
|
|
343
371
|
function raceWithSignal<T>(operation: Promise<T>, signal: AbortSignal): Promise<T> {
|
|
@@ -392,11 +420,12 @@ class CodexQuotaStatus {
|
|
|
392
420
|
return loadState();
|
|
393
421
|
}
|
|
394
422
|
|
|
395
|
-
private change<T>(change: (state: UsageState) => { value: T; write: boolean }, signal?: AbortSignal): Promise<T> {
|
|
423
|
+
private change<T>(change: (state: UsageState) => { value: T; write: boolean }, signal?: AbortSignal): Promise<T | undefined> {
|
|
396
424
|
const operation = this.writes.then(() => withCacheMutex(async (operationSignal) => {
|
|
397
425
|
operationSignal.throwIfAborted();
|
|
398
426
|
const state = await loadState(operationSignal);
|
|
399
427
|
operationSignal.throwIfAborted();
|
|
428
|
+
if (state.untrusted) return undefined;
|
|
400
429
|
const result = change(state);
|
|
401
430
|
if (result.write) {
|
|
402
431
|
operationSignal.throwIfAborted();
|
|
@@ -441,6 +470,7 @@ class CodexQuotaStatus {
|
|
|
441
470
|
|
|
442
471
|
const now = Date.now();
|
|
443
472
|
const state = loadStateSync();
|
|
473
|
+
if (state.untrusted) return;
|
|
444
474
|
let dueAt: number | undefined;
|
|
445
475
|
for (const [slot, credential] of readCodexCredentials()) {
|
|
446
476
|
if (this.active.has(slot)) continue;
|
|
@@ -571,6 +601,7 @@ class CodexQuotaStatus {
|
|
|
571
601
|
fetchedAt: checkedAt,
|
|
572
602
|
remaining: outcome.remaining,
|
|
573
603
|
reset: outcome.reset,
|
|
604
|
+
limitedUntil: outcome.limitedUntil && outcome.limitedUntil > checkedAt ? outcome.limitedUntil : null,
|
|
574
605
|
});
|
|
575
606
|
} else {
|
|
576
607
|
state.slots.set(slot, previous?.accountHash === identity.accountHash && (!validTime(previous.reset) || previous.reset > checkedAt)
|
|
@@ -581,7 +612,7 @@ class CodexQuotaStatus {
|
|
|
581
612
|
return { value: true, write: true };
|
|
582
613
|
}, refresh.session.signal);
|
|
583
614
|
if (finished) this.onChange();
|
|
584
|
-
return finished;
|
|
615
|
+
return finished ?? false;
|
|
585
616
|
}
|
|
586
617
|
|
|
587
618
|
private async release(slot: number, owner: string, signal: AbortSignal): Promise<void> {
|
|
@@ -649,6 +680,9 @@ class CodexQuotaStatus {
|
|
|
649
680
|
}
|
|
650
681
|
const tier = snapshot.tier ? ` (${snapshot.tier})` : "";
|
|
651
682
|
const status = isFresh(snapshot, identity, now) ? "measured" : "stale";
|
|
683
|
+
if (isFiveHourLimited(snapshot, now)) {
|
|
684
|
+
return `Codex slot ${slot}${tier}: five-hour limit reached, resets in ${formatDuration(snapshot.limitedUntil! - now)} (${status})`;
|
|
685
|
+
}
|
|
652
686
|
return `Codex slot ${slot}${tier}: ${formatPercent(snapshot.remaining)}% remaining, resets in ${formatDuration(snapshot.reset - now)} (${status})`;
|
|
653
687
|
});
|
|
654
688
|
}
|
|
@@ -814,7 +848,7 @@ export default function multiCodex(pi: ExtensionAPI): void {
|
|
|
814
848
|
if ((slot !== 1 && !registered.has(slot)) || !allowsModel(ctx, model, slot)) return [];
|
|
815
849
|
const identity = identityFor(credential);
|
|
816
850
|
const snapshot = state.slots.get(slot);
|
|
817
|
-
if (!identity || !isFresh(snapshot, identity, now) || typeof snapshot?.remaining !== "number") return [];
|
|
851
|
+
if (!identity || !isFresh(snapshot, identity, now) || typeof snapshot?.remaining !== "number" || isFiveHourLimited(snapshot, now)) return [];
|
|
818
852
|
return [{ slot, remaining: snapshot.remaining }];
|
|
819
853
|
});
|
|
820
854
|
};
|
|
@@ -839,8 +873,13 @@ export default function multiCodex(pi: ExtensionAPI): void {
|
|
|
839
873
|
if (!identity || !snapshot || snapshot.accountHash !== identity.accountHash || !validTime(snapshot.fetchedAt) || typeof snapshot.remaining !== "number" || !validTime(snapshot.reset)) {
|
|
840
874
|
return `${prefix} · unavailable`;
|
|
841
875
|
}
|
|
842
|
-
|
|
843
|
-
|
|
876
|
+
const now = Date.now();
|
|
877
|
+
if (isFiveHourLimited(snapshot, now)) {
|
|
878
|
+
const text = `${prefix} · 5h limit · ${formatDuration(snapshot.limitedUntil! - now)}`;
|
|
879
|
+
return ctx.ui.theme?.fg ? ctx.ui.theme.fg("error", text) : text;
|
|
880
|
+
}
|
|
881
|
+
if (!isFresh(snapshot, identity, now)) return `${prefix} · stale`;
|
|
882
|
+
const text = `${prefix} · ${formatPercent(snapshot.remaining)}% · 7d ${formatDuration(snapshot.reset - now)}`;
|
|
844
883
|
const color = snapshot.remaining >= 50 ? "success" : snapshot.remaining >= 25 ? "warning" : "error";
|
|
845
884
|
return ctx.ui.theme?.fg ? ctx.ui.theme.fg(color, text) : text;
|
|
846
885
|
};
|
|
@@ -923,7 +962,7 @@ export default function multiCodex(pi: ExtensionAPI): void {
|
|
|
923
962
|
});
|
|
924
963
|
|
|
925
964
|
pi.registerCommand("codex-status", {
|
|
926
|
-
description: "show shared Codex
|
|
965
|
+
description: "show shared Codex quota status",
|
|
927
966
|
handler: async (_args, ctx: ExtensionContext) => {
|
|
928
967
|
const lines = await quota.statusLines();
|
|
929
968
|
ctx.ui.notify(lines.length ? lines.join("\n") : NO_ACCOUNTS_MESSAGE, "info");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@henryqw/pi-multi-codex",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.18",
|
|
4
4
|
"description": "Use multiple ChatGPT Codex OAuth accounts in Pi.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -14,9 +14,9 @@
|
|
|
14
14
|
},
|
|
15
15
|
"license": "MIT",
|
|
16
16
|
"files": [
|
|
17
|
+
"LICENSE",
|
|
17
18
|
"extensions",
|
|
18
|
-
"README.md"
|
|
19
|
-
"LICENSE"
|
|
19
|
+
"README.md"
|
|
20
20
|
],
|
|
21
21
|
"scripts": {
|
|
22
22
|
"test": "node --test test/*.test.ts",
|
|
@@ -44,6 +44,7 @@
|
|
|
44
44
|
]
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
|
+
"@henryqw/pi-config-store": "^0.1.0",
|
|
47
48
|
"proper-lockfile": "^4.1.2"
|
|
48
49
|
},
|
|
49
50
|
"devDependencies": {
|