@henryqw/pi-multi-codex 0.3.16 → 0.3.17
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 +70 -32
- package/package.json +3 -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.
|
|
@@ -53,6 +53,7 @@ type UsageSnapshot = {
|
|
|
53
53
|
fetchedAt?: number;
|
|
54
54
|
remaining?: number;
|
|
55
55
|
reset?: number;
|
|
56
|
+
limitedUntil?: number | null;
|
|
56
57
|
};
|
|
57
58
|
|
|
58
59
|
type UsageLock = {
|
|
@@ -65,9 +66,10 @@ type UsageLock = {
|
|
|
65
66
|
type UsageState = {
|
|
66
67
|
slots: Map<number, UsageSnapshot>;
|
|
67
68
|
locks: Map<number, UsageLock>;
|
|
69
|
+
untrusted?: true;
|
|
68
70
|
};
|
|
69
71
|
|
|
70
|
-
type ParsedUsage = { remaining: number; reset: number; tier?: string };
|
|
72
|
+
type ParsedUsage = { remaining: number; reset: number; limitedUntil?: number; tier?: string };
|
|
71
73
|
|
|
72
74
|
const NATIVE_PROVIDER_ID = "openai-codex";
|
|
73
75
|
const CODEX_ALIAS_PATTERN = /^openai-codex-([2-9]|[1-9]\d+)$/;
|
|
@@ -171,9 +173,14 @@ function validTier(value: unknown): value is string {
|
|
|
171
173
|
function readSnapshot(value: unknown): UsageSnapshot | undefined {
|
|
172
174
|
if (!isRecord(value) || !validSlot(value.slot) || !validHash(value.accountHash) || !validTime(value.checkedAt)) return undefined;
|
|
173
175
|
const successful = value.fetchedAt !== undefined || value.remaining !== undefined || value.reset !== undefined;
|
|
174
|
-
if (successful && (
|
|
175
|
-
|
|
176
|
-
|
|
176
|
+
if (successful && (
|
|
177
|
+
!validTime(value.fetchedAt) ||
|
|
178
|
+
typeof value.remaining !== "number" ||
|
|
179
|
+
!Number.isFinite(value.remaining) ||
|
|
180
|
+
!validTime(value.reset) ||
|
|
181
|
+
(value.limitedUntil !== null && !validTime(value.limitedUntil))
|
|
182
|
+
)) return undefined;
|
|
183
|
+
if (!successful && value.limitedUntil !== undefined) return undefined;
|
|
177
184
|
return {
|
|
178
185
|
slot: value.slot,
|
|
179
186
|
accountHash: value.accountHash,
|
|
@@ -184,6 +191,7 @@ function readSnapshot(value: unknown): UsageSnapshot | undefined {
|
|
|
184
191
|
fetchedAt: value.fetchedAt as number,
|
|
185
192
|
remaining: Math.max(0, Math.min(100, value.remaining as number)),
|
|
186
193
|
reset: value.reset as number,
|
|
194
|
+
limitedUntil: value.limitedUntil as number | null,
|
|
187
195
|
}
|
|
188
196
|
: {}),
|
|
189
197
|
};
|
|
@@ -196,43 +204,50 @@ function readLock(value: unknown): UsageLock | undefined {
|
|
|
196
204
|
return { slot: value.slot, owner: value.owner, accountHash: value.accountHash, heartbeatAt: value.heartbeatAt };
|
|
197
205
|
}
|
|
198
206
|
|
|
199
|
-
function parseState(value: unknown): UsageState {
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
for (const snapshot of
|
|
207
|
+
function parseState(value: unknown): UsageState | undefined {
|
|
208
|
+
if (!isRecord(value) || !Array.isArray(value.slots) || !Array.isArray(value.locks)) return undefined;
|
|
209
|
+
const state = emptyState();
|
|
210
|
+
for (const snapshot of value.slots) {
|
|
203
211
|
const parsed = readSnapshot(snapshot);
|
|
204
|
-
if (parsed
|
|
212
|
+
if (!parsed || state.slots.has(parsed.slot)) return undefined;
|
|
213
|
+
state.slots.set(parsed.slot, parsed);
|
|
205
214
|
}
|
|
206
|
-
for (const lock of
|
|
215
|
+
for (const lock of value.locks) {
|
|
207
216
|
const parsed = readLock(lock);
|
|
208
|
-
if (parsed
|
|
217
|
+
if (!parsed || state.locks.has(parsed.slot)) return undefined;
|
|
218
|
+
state.locks.set(parsed.slot, parsed);
|
|
209
219
|
}
|
|
210
220
|
return state;
|
|
211
221
|
}
|
|
212
222
|
|
|
213
|
-
function emptyState(): UsageState {
|
|
214
|
-
return { slots: new Map(), locks: new Map() };
|
|
223
|
+
function emptyState(untrusted = false): UsageState {
|
|
224
|
+
return { slots: new Map(), locks: new Map(), ...(untrusted ? { untrusted: true } : {}) };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function isMissingCacheFile(error: unknown): boolean {
|
|
228
|
+
return Boolean(error && typeof error === "object" && "code" in error && String(error.code) === "ENOENT");
|
|
215
229
|
}
|
|
216
230
|
|
|
217
231
|
function loadStateSync(): UsageState {
|
|
218
232
|
try {
|
|
219
|
-
return parseState(JSON.parse(readFileSync(cachePath(), "utf8")));
|
|
220
|
-
} catch {
|
|
221
|
-
return emptyState();
|
|
233
|
+
return parseState(JSON.parse(readFileSync(cachePath(), "utf8"))) ?? emptyState(true);
|
|
234
|
+
} catch (error) {
|
|
235
|
+
return emptyState(!isMissingCacheFile(error));
|
|
222
236
|
}
|
|
223
237
|
}
|
|
224
238
|
|
|
225
239
|
async function loadState(signal?: AbortSignal): Promise<UsageState> {
|
|
226
240
|
try {
|
|
227
|
-
return parseState(JSON.parse(await readFile(cachePath(), { encoding: "utf8", signal })));
|
|
241
|
+
return parseState(JSON.parse(await readFile(cachePath(), { encoding: "utf8", signal }))) ?? emptyState(true);
|
|
228
242
|
} catch (error) {
|
|
229
243
|
signal?.throwIfAborted();
|
|
230
|
-
return emptyState();
|
|
244
|
+
return emptyState(!isMissingCacheFile(error));
|
|
231
245
|
}
|
|
232
246
|
}
|
|
233
247
|
|
|
234
248
|
async function saveState(state: UsageState, signal?: AbortSignal): Promise<void> {
|
|
235
249
|
signal?.throwIfAborted();
|
|
250
|
+
if (state.untrusted) return;
|
|
236
251
|
const file = cachePath();
|
|
237
252
|
const directory = join(getAgentDir(), "config", "pi-multi-codex");
|
|
238
253
|
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
@@ -295,6 +310,10 @@ function isFresh(snapshot: UsageSnapshot | undefined, identity: SlotIdentity, no
|
|
|
295
310
|
);
|
|
296
311
|
}
|
|
297
312
|
|
|
313
|
+
function isFiveHourLimited(snapshot: UsageSnapshot, now: number): boolean {
|
|
314
|
+
return validTime(snapshot.limitedUntil) && snapshot.limitedUntil > now;
|
|
315
|
+
}
|
|
316
|
+
|
|
298
317
|
function checkedRecently(snapshot: UsageSnapshot | undefined, identity: SlotIdentity, now: number): boolean {
|
|
299
318
|
return Boolean(
|
|
300
319
|
snapshot &&
|
|
@@ -324,20 +343,28 @@ function resetTime(window: JsonRecord, now: number): number | undefined {
|
|
|
324
343
|
return resetAfter === undefined || resetAfter < 0 ? undefined : now + resetAfter * 1000;
|
|
325
344
|
}
|
|
326
345
|
|
|
327
|
-
/** Extract seven-day
|
|
346
|
+
/** Extract seven-day quota and any active five-hour block. */
|
|
328
347
|
export function parseCodexUsage(value: unknown, now = Date.now()): ParsedUsage | undefined {
|
|
329
348
|
if (!isRecord(value)) return undefined;
|
|
330
349
|
const rateLimit = isRecord(value.rate_limit) ? value.rate_limit : value;
|
|
331
|
-
const windows = Object.
|
|
332
|
-
|
|
350
|
+
const windows = Object.values(rateLimit).flatMap((entry) => {
|
|
351
|
+
if (!isRecord(entry)) return [];
|
|
352
|
+
const used = numberValue(entry.used_percent);
|
|
353
|
+
const reset = resetTime(entry, now);
|
|
354
|
+
return used === undefined || reset === undefined ? [] : [{ value: entry, used, reset }];
|
|
355
|
+
});
|
|
333
356
|
const sevenDay = windows.find((window) => numberValue(window.value.limit_window_seconds) === 7 * 24 * 60 * 60)
|
|
334
357
|
?? (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;
|
|
358
|
+
if (!sevenDay || sevenDay.reset <= now) return undefined;
|
|
359
|
+
const fiveHour = windows.find((window) => numberValue(window.value.limit_window_seconds) === 5 * 60 * 60);
|
|
360
|
+
const limitedUntil = fiveHour && fiveHour.used >= 100 && fiveHour.reset > now ? fiveHour.reset : undefined;
|
|
339
361
|
const tier = validTier(value.plan_type) ? value.plan_type : validTier(value.tier) ? value.tier : undefined;
|
|
340
|
-
return {
|
|
362
|
+
return {
|
|
363
|
+
remaining: Math.max(0, Math.min(100, 100 - sevenDay.used)),
|
|
364
|
+
reset: sevenDay.reset,
|
|
365
|
+
...(limitedUntil ? { limitedUntil } : {}),
|
|
366
|
+
...(tier ? { tier } : {}),
|
|
367
|
+
};
|
|
341
368
|
}
|
|
342
369
|
|
|
343
370
|
function raceWithSignal<T>(operation: Promise<T>, signal: AbortSignal): Promise<T> {
|
|
@@ -392,11 +419,12 @@ class CodexQuotaStatus {
|
|
|
392
419
|
return loadState();
|
|
393
420
|
}
|
|
394
421
|
|
|
395
|
-
private change<T>(change: (state: UsageState) => { value: T; write: boolean }, signal?: AbortSignal): Promise<T> {
|
|
422
|
+
private change<T>(change: (state: UsageState) => { value: T; write: boolean }, signal?: AbortSignal): Promise<T | undefined> {
|
|
396
423
|
const operation = this.writes.then(() => withCacheMutex(async (operationSignal) => {
|
|
397
424
|
operationSignal.throwIfAborted();
|
|
398
425
|
const state = await loadState(operationSignal);
|
|
399
426
|
operationSignal.throwIfAborted();
|
|
427
|
+
if (state.untrusted) return undefined;
|
|
400
428
|
const result = change(state);
|
|
401
429
|
if (result.write) {
|
|
402
430
|
operationSignal.throwIfAborted();
|
|
@@ -441,6 +469,7 @@ class CodexQuotaStatus {
|
|
|
441
469
|
|
|
442
470
|
const now = Date.now();
|
|
443
471
|
const state = loadStateSync();
|
|
472
|
+
if (state.untrusted) return;
|
|
444
473
|
let dueAt: number | undefined;
|
|
445
474
|
for (const [slot, credential] of readCodexCredentials()) {
|
|
446
475
|
if (this.active.has(slot)) continue;
|
|
@@ -571,6 +600,7 @@ class CodexQuotaStatus {
|
|
|
571
600
|
fetchedAt: checkedAt,
|
|
572
601
|
remaining: outcome.remaining,
|
|
573
602
|
reset: outcome.reset,
|
|
603
|
+
limitedUntil: outcome.limitedUntil && outcome.limitedUntil > checkedAt ? outcome.limitedUntil : null,
|
|
574
604
|
});
|
|
575
605
|
} else {
|
|
576
606
|
state.slots.set(slot, previous?.accountHash === identity.accountHash && (!validTime(previous.reset) || previous.reset > checkedAt)
|
|
@@ -581,7 +611,7 @@ class CodexQuotaStatus {
|
|
|
581
611
|
return { value: true, write: true };
|
|
582
612
|
}, refresh.session.signal);
|
|
583
613
|
if (finished) this.onChange();
|
|
584
|
-
return finished;
|
|
614
|
+
return finished ?? false;
|
|
585
615
|
}
|
|
586
616
|
|
|
587
617
|
private async release(slot: number, owner: string, signal: AbortSignal): Promise<void> {
|
|
@@ -649,6 +679,9 @@ class CodexQuotaStatus {
|
|
|
649
679
|
}
|
|
650
680
|
const tier = snapshot.tier ? ` (${snapshot.tier})` : "";
|
|
651
681
|
const status = isFresh(snapshot, identity, now) ? "measured" : "stale";
|
|
682
|
+
if (isFiveHourLimited(snapshot, now)) {
|
|
683
|
+
return `Codex slot ${slot}${tier}: five-hour limit reached, resets in ${formatDuration(snapshot.limitedUntil! - now)} (${status})`;
|
|
684
|
+
}
|
|
652
685
|
return `Codex slot ${slot}${tier}: ${formatPercent(snapshot.remaining)}% remaining, resets in ${formatDuration(snapshot.reset - now)} (${status})`;
|
|
653
686
|
});
|
|
654
687
|
}
|
|
@@ -814,7 +847,7 @@ export default function multiCodex(pi: ExtensionAPI): void {
|
|
|
814
847
|
if ((slot !== 1 && !registered.has(slot)) || !allowsModel(ctx, model, slot)) return [];
|
|
815
848
|
const identity = identityFor(credential);
|
|
816
849
|
const snapshot = state.slots.get(slot);
|
|
817
|
-
if (!identity || !isFresh(snapshot, identity, now) || typeof snapshot?.remaining !== "number") return [];
|
|
850
|
+
if (!identity || !isFresh(snapshot, identity, now) || typeof snapshot?.remaining !== "number" || isFiveHourLimited(snapshot, now)) return [];
|
|
818
851
|
return [{ slot, remaining: snapshot.remaining }];
|
|
819
852
|
});
|
|
820
853
|
};
|
|
@@ -839,8 +872,13 @@ export default function multiCodex(pi: ExtensionAPI): void {
|
|
|
839
872
|
if (!identity || !snapshot || snapshot.accountHash !== identity.accountHash || !validTime(snapshot.fetchedAt) || typeof snapshot.remaining !== "number" || !validTime(snapshot.reset)) {
|
|
840
873
|
return `${prefix} · unavailable`;
|
|
841
874
|
}
|
|
842
|
-
|
|
843
|
-
|
|
875
|
+
const now = Date.now();
|
|
876
|
+
if (isFiveHourLimited(snapshot, now)) {
|
|
877
|
+
const text = `${prefix} · 5h limit · ${formatDuration(snapshot.limitedUntil! - now)}`;
|
|
878
|
+
return ctx.ui.theme?.fg ? ctx.ui.theme.fg("error", text) : text;
|
|
879
|
+
}
|
|
880
|
+
if (!isFresh(snapshot, identity, now)) return `${prefix} · stale`;
|
|
881
|
+
const text = `${prefix} · ${formatPercent(snapshot.remaining)}% · 7d ${formatDuration(snapshot.reset - now)}`;
|
|
844
882
|
const color = snapshot.remaining >= 50 ? "success" : snapshot.remaining >= 25 ? "warning" : "error";
|
|
845
883
|
return ctx.ui.theme?.fg ? ctx.ui.theme.fg(color, text) : text;
|
|
846
884
|
};
|
|
@@ -923,7 +961,7 @@ export default function multiCodex(pi: ExtensionAPI): void {
|
|
|
923
961
|
});
|
|
924
962
|
|
|
925
963
|
pi.registerCommand("codex-status", {
|
|
926
|
-
description: "show shared Codex
|
|
964
|
+
description: "show shared Codex quota status",
|
|
927
965
|
handler: async (_args, ctx: ExtensionContext) => {
|
|
928
966
|
const lines = await quota.statusLines();
|
|
929
967
|
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.17",
|
|
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",
|