@henryqw/pi-multi-codex 0.3.8
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/LICENSE +21 -0
- package/README.md +19 -0
- package/extensions/multi-codex.ts +1032 -0
- package/package.json +52 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Henry Wang
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Pi Multi Codex
|
|
2
|
+
|
|
3
|
+
Use multiple ChatGPT Codex OAuth slots in Pi.
|
|
4
|
+
|
|
5
|
+
Native Pi auth remains owner of credentials. Run `/login` and select OpenAI
|
|
6
|
+
Codex for slot 1. Run `/codex-add`, then `/login` and select reported numbered
|
|
7
|
+
slot for each additional Codex OAuth credential.
|
|
8
|
+
|
|
9
|
+
Extension reads native `auth.json` and never writes or refreshes credentials.
|
|
10
|
+
|
|
11
|
+
`/codex-status` shows shared seven-day quota snapshots. Refresh runs in background;
|
|
12
|
+
status never waits for network. Credential-free snapshots live in
|
|
13
|
+
`config/pi-multi-codex/usage.json` under Pi agent directory.
|
|
14
|
+
|
|
15
|
+
Before first agent start, fresh snapshots route a managed Codex model to slot with
|
|
16
|
+
most seven-day quota. Routing preserves model ID and never changes in-progress work.
|
|
17
|
+
`/codex-switch` opens Pi selector for authenticated slots. Footer shows active slot
|
|
18
|
+
and fresh seven-day quota. Scoped sessions can switch only to exact scoped aliases;
|
|
19
|
+
restart or update model scope after adding slot.
|
|
@@ -0,0 +1,1032 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { lock } from "proper-lockfile";
|
|
6
|
+
import {
|
|
7
|
+
createAssistantMessageEventStream,
|
|
8
|
+
type AssistantMessageEventStream,
|
|
9
|
+
type ApiStreamOptions,
|
|
10
|
+
type AssistantMessage,
|
|
11
|
+
type AssistantMessageEvent,
|
|
12
|
+
type Context,
|
|
13
|
+
type DeferredCancelOptions,
|
|
14
|
+
type DeferredFetchOptions,
|
|
15
|
+
type Model,
|
|
16
|
+
type Provider,
|
|
17
|
+
type SimpleStreamOptions,
|
|
18
|
+
} from "@earendil-works/pi-ai";
|
|
19
|
+
import { builtinProviders } from "@earendil-works/pi-ai/providers/all";
|
|
20
|
+
import {
|
|
21
|
+
getAgentDir,
|
|
22
|
+
type ExtensionAPI,
|
|
23
|
+
type ExtensionContext,
|
|
24
|
+
} from "@earendil-works/pi-coding-agent";
|
|
25
|
+
|
|
26
|
+
type CodexModel = Model<"openai-codex-responses">;
|
|
27
|
+
type CodexProvider = Provider<"openai-codex-responses">;
|
|
28
|
+
type JsonRecord = Record<string, unknown>;
|
|
29
|
+
type Timer = ReturnType<typeof setInterval>;
|
|
30
|
+
|
|
31
|
+
type CodexOAuthCredential = JsonRecord & {
|
|
32
|
+
type: "oauth";
|
|
33
|
+
access: string;
|
|
34
|
+
refresh: string;
|
|
35
|
+
expires: number;
|
|
36
|
+
accountId?: string;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
type SlotIdentity = {
|
|
40
|
+
accountId: string;
|
|
41
|
+
accountHash: string;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
type SlotAuthResolver = (slot: number) => Promise<string | undefined>;
|
|
45
|
+
type QuotaRefresh = { generation: number; session: AbortController; resolveSlotAuth: SlotAuthResolver };
|
|
46
|
+
|
|
47
|
+
type UsageSnapshot = {
|
|
48
|
+
slot: number;
|
|
49
|
+
accountHash: string;
|
|
50
|
+
tier?: string;
|
|
51
|
+
checkedAt: number;
|
|
52
|
+
fetchedAt?: number;
|
|
53
|
+
remaining?: number;
|
|
54
|
+
reset?: number;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
type UsageLock = {
|
|
58
|
+
slot: number;
|
|
59
|
+
owner: string;
|
|
60
|
+
accountHash: string;
|
|
61
|
+
heartbeatAt: number;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
type UsageState = {
|
|
65
|
+
slots: Map<number, UsageSnapshot>;
|
|
66
|
+
locks: Map<number, UsageLock>;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
type ParsedUsage = { remaining: number; reset: number; tier?: string };
|
|
70
|
+
|
|
71
|
+
const NATIVE_PROVIDER_ID = "openai-codex";
|
|
72
|
+
const CODEX_ALIAS_PATTERN = /^openai-codex-([2-9]|[1-9]\d+)$/;
|
|
73
|
+
const NO_ACCOUNTS_MESSAGE = "No Codex OAuth accounts found. Run /login and select OpenAI Codex.";
|
|
74
|
+
const AGENT_STARTED_ENTRY = "pi-multi-codex:agent-started";
|
|
75
|
+
const USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
|
|
76
|
+
const REFRESH_MS = 5 * 60_000;
|
|
77
|
+
const LOCK_STALE_MS = 45_000;
|
|
78
|
+
const HEARTBEAT_MS = 5_000;
|
|
79
|
+
const OPERATION_TIMEOUT_MS = 10_000;
|
|
80
|
+
const CACHE_MUTEX_RETRY_MS = 20;
|
|
81
|
+
const CLEANUP_TIMEOUT_MS = 250;
|
|
82
|
+
const cachePath = () => join(getAgentDir(), "config", "pi-multi-codex", "usage.json");
|
|
83
|
+
const cacheMutexPath = () => `${cachePath()}.mutex`;
|
|
84
|
+
|
|
85
|
+
function isRecord(value: unknown): value is JsonRecord {
|
|
86
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function isOAuthCredential(value: unknown): value is CodexOAuthCredential {
|
|
90
|
+
return (
|
|
91
|
+
isRecord(value) &&
|
|
92
|
+
value.type === "oauth" &&
|
|
93
|
+
typeof value.access === "string" &&
|
|
94
|
+
typeof value.refresh === "string" &&
|
|
95
|
+
typeof value.expires === "number" &&
|
|
96
|
+
Number.isFinite(value.expires)
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function slotForProvider(providerId: string): number | undefined {
|
|
101
|
+
if (providerId === NATIVE_PROVIDER_ID) return 1;
|
|
102
|
+
const match = CODEX_ALIAS_PATTERN.exec(providerId);
|
|
103
|
+
const slot = match ? Number(match[1]) : undefined;
|
|
104
|
+
return validSlot(slot) ? slot : undefined;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function readCodexCredentials(): Map<number, CodexOAuthCredential> {
|
|
108
|
+
let parsed: unknown;
|
|
109
|
+
try {
|
|
110
|
+
parsed = JSON.parse(readFileSync(join(getAgentDir(), "auth.json"), "utf8"));
|
|
111
|
+
} catch {
|
|
112
|
+
return new Map();
|
|
113
|
+
}
|
|
114
|
+
if (!isRecord(parsed)) return new Map();
|
|
115
|
+
|
|
116
|
+
const credentials = new Map<number, CodexOAuthCredential>();
|
|
117
|
+
for (const [providerId, credential] of Object.entries(parsed)) {
|
|
118
|
+
const slot = slotForProvider(providerId);
|
|
119
|
+
if (slot && isOAuthCredential(credential)) credentials.set(slot, credential);
|
|
120
|
+
}
|
|
121
|
+
return credentials;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function discoverCodexSlots(): Set<number> {
|
|
125
|
+
return new Set(readCodexCredentials().keys());
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function accountIdFromAccessToken(access: string): string | undefined {
|
|
129
|
+
try {
|
|
130
|
+
const encoded = access.split(".")[1];
|
|
131
|
+
if (!encoded) return undefined;
|
|
132
|
+
const payload: unknown = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8"));
|
|
133
|
+
if (!isRecord(payload)) return undefined;
|
|
134
|
+
const auth = payload["https://api.openai.com/auth"];
|
|
135
|
+
if (!isRecord(auth) || typeof auth.chatgpt_account_id !== "string" || !auth.chatgpt_account_id) return undefined;
|
|
136
|
+
return auth.chatgpt_account_id;
|
|
137
|
+
} catch {
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function identityFor(credential: CodexOAuthCredential | undefined): SlotIdentity | undefined {
|
|
143
|
+
if (!credential) return undefined;
|
|
144
|
+
const accountId = typeof credential.accountId === "string" && credential.accountId
|
|
145
|
+
? credential.accountId
|
|
146
|
+
: accountIdFromAccessToken(credential.access);
|
|
147
|
+
if (!accountId) return undefined;
|
|
148
|
+
return {
|
|
149
|
+
accountId,
|
|
150
|
+
accountHash: createHash("sha256").update(accountId).digest("hex"),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function currentIdentity(slot: number): SlotIdentity | undefined {
|
|
155
|
+
return identityFor(readCodexCredentials().get(slot));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function validHash(value: unknown): value is string {
|
|
159
|
+
return typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function validSlot(value: unknown): value is number {
|
|
163
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function validTime(value: unknown): value is number {
|
|
167
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function validTier(value: unknown): value is string {
|
|
171
|
+
return typeof value === "string" && /^[a-zA-Z0-9._-]{1,64}$/.test(value);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function readSnapshot(value: unknown): UsageSnapshot | undefined {
|
|
175
|
+
if (!isRecord(value) || !validSlot(value.slot) || !validHash(value.accountHash) || !validTime(value.checkedAt)) return undefined;
|
|
176
|
+
const successful = value.fetchedAt !== undefined || value.remaining !== undefined || value.reset !== undefined;
|
|
177
|
+
if (successful && (!validTime(value.fetchedAt) || typeof value.remaining !== "number" || !Number.isFinite(value.remaining) || !validTime(value.reset))) {
|
|
178
|
+
return undefined;
|
|
179
|
+
}
|
|
180
|
+
return {
|
|
181
|
+
slot: value.slot,
|
|
182
|
+
accountHash: value.accountHash,
|
|
183
|
+
...(validTier(value.tier) ? { tier: value.tier } : {}),
|
|
184
|
+
checkedAt: value.checkedAt,
|
|
185
|
+
...(successful
|
|
186
|
+
? {
|
|
187
|
+
fetchedAt: value.fetchedAt as number,
|
|
188
|
+
remaining: Math.max(0, Math.min(100, value.remaining as number)),
|
|
189
|
+
reset: value.reset as number,
|
|
190
|
+
}
|
|
191
|
+
: {}),
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function readLock(value: unknown): UsageLock | undefined {
|
|
196
|
+
if (!isRecord(value) || !validSlot(value.slot) || typeof value.owner !== "string" || !value.owner || !validHash(value.accountHash) || !validTime(value.heartbeatAt)) {
|
|
197
|
+
return undefined;
|
|
198
|
+
}
|
|
199
|
+
return { slot: value.slot, owner: value.owner, accountHash: value.accountHash, heartbeatAt: value.heartbeatAt };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function parseState(value: unknown): UsageState {
|
|
203
|
+
const state: UsageState = { slots: new Map(), locks: new Map() };
|
|
204
|
+
if (!isRecord(value)) return state;
|
|
205
|
+
for (const snapshot of Array.isArray(value.slots) ? value.slots : []) {
|
|
206
|
+
const parsed = readSnapshot(snapshot);
|
|
207
|
+
if (parsed) state.slots.set(parsed.slot, parsed);
|
|
208
|
+
}
|
|
209
|
+
for (const lock of Array.isArray(value.locks) ? value.locks : []) {
|
|
210
|
+
const parsed = readLock(lock);
|
|
211
|
+
if (parsed) state.locks.set(parsed.slot, parsed);
|
|
212
|
+
}
|
|
213
|
+
return state;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function emptyState(): UsageState {
|
|
217
|
+
return { slots: new Map(), locks: new Map() };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function loadStateSync(): UsageState {
|
|
221
|
+
try {
|
|
222
|
+
return parseState(JSON.parse(readFileSync(cachePath(), "utf8")));
|
|
223
|
+
} catch {
|
|
224
|
+
return emptyState();
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function loadState(signal?: AbortSignal): Promise<UsageState> {
|
|
229
|
+
try {
|
|
230
|
+
return parseState(JSON.parse(await readFile(cachePath(), { encoding: "utf8", signal })));
|
|
231
|
+
} catch (error) {
|
|
232
|
+
signal?.throwIfAborted();
|
|
233
|
+
return emptyState();
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function saveState(state: UsageState, signal?: AbortSignal): Promise<void> {
|
|
238
|
+
signal?.throwIfAborted();
|
|
239
|
+
const file = cachePath();
|
|
240
|
+
const directory = join(getAgentDir(), "config", "pi-multi-codex");
|
|
241
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
242
|
+
const data = `${JSON.stringify({
|
|
243
|
+
slots: [...state.slots.values()].sort((a, b) => a.slot - b.slot),
|
|
244
|
+
locks: [...state.locks.values()].sort((a, b) => a.slot - b.slot),
|
|
245
|
+
})}\n`;
|
|
246
|
+
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
247
|
+
try {
|
|
248
|
+
await writeFile(temporary, data, { encoding: "utf8", mode: 0o600, flag: "wx", signal });
|
|
249
|
+
signal?.throwIfAborted();
|
|
250
|
+
await rename(temporary, file);
|
|
251
|
+
signal?.throwIfAborted();
|
|
252
|
+
await chmod(file, 0o600);
|
|
253
|
+
} finally {
|
|
254
|
+
await rm(temporary, { force: true }).catch(() => undefined);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function errorCode(error: unknown): string | undefined {
|
|
259
|
+
return error && typeof error === "object" && "code" in error ? String(error.code) : undefined;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function sleepUnref(ms: number, signal?: AbortSignal): Promise<void> {
|
|
263
|
+
return new Promise((resolve, reject) => {
|
|
264
|
+
if (signal?.aborted) return reject(signal.reason);
|
|
265
|
+
let abort: () => void;
|
|
266
|
+
const cleanup = () => signal?.removeEventListener("abort", abort);
|
|
267
|
+
const timer = setTimeout(() => {
|
|
268
|
+
cleanup();
|
|
269
|
+
resolve();
|
|
270
|
+
}, ms);
|
|
271
|
+
timer.unref?.();
|
|
272
|
+
abort = () => {
|
|
273
|
+
clearTimeout(timer);
|
|
274
|
+
cleanup();
|
|
275
|
+
reject(signal?.reason);
|
|
276
|
+
};
|
|
277
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
278
|
+
if (signal?.aborted) abort();
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function cleanupSignal(): AbortSignal {
|
|
283
|
+
const controller = new AbortController();
|
|
284
|
+
void sleepUnref(CLEANUP_TIMEOUT_MS).then(() => controller.abort());
|
|
285
|
+
return controller.signal;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async function withCacheMutex<T>(operation: (signal: AbortSignal) => Promise<T>, signal?: AbortSignal): Promise<T> {
|
|
289
|
+
signal?.throwIfAborted();
|
|
290
|
+
await mkdir(join(getAgentDir(), "config", "pi-multi-codex"), { recursive: true, mode: 0o700 });
|
|
291
|
+
let release: (() => Promise<void>) | undefined;
|
|
292
|
+
while (!release) {
|
|
293
|
+
signal?.throwIfAborted();
|
|
294
|
+
try {
|
|
295
|
+
const compromised = new AbortController();
|
|
296
|
+
release = await lock(cachePath(), {
|
|
297
|
+
lockfilePath: cacheMutexPath(),
|
|
298
|
+
realpath: false,
|
|
299
|
+
stale: LOCK_STALE_MS,
|
|
300
|
+
update: HEARTBEAT_MS,
|
|
301
|
+
retries: 0,
|
|
302
|
+
onCompromised: (error) => compromised.abort(error),
|
|
303
|
+
});
|
|
304
|
+
const operationSignal = signal ? AbortSignal.any([signal, compromised.signal]) : compromised.signal;
|
|
305
|
+
try {
|
|
306
|
+
operationSignal.throwIfAborted();
|
|
307
|
+
return await raceWithSignal(operation(operationSignal), operationSignal);
|
|
308
|
+
} finally {
|
|
309
|
+
await release().catch(() => undefined);
|
|
310
|
+
}
|
|
311
|
+
} catch (error) {
|
|
312
|
+
if (errorCode(error) !== "ELOCKED") throw error;
|
|
313
|
+
await sleepUnref(CACHE_MUTEX_RETRY_MS, signal);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
throw new Error("Cache mutex acquisition ended unexpectedly.");
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function isFresh(snapshot: UsageSnapshot | undefined, identity: SlotIdentity, now: number): boolean {
|
|
320
|
+
return Boolean(
|
|
321
|
+
snapshot &&
|
|
322
|
+
snapshot.accountHash === identity.accountHash &&
|
|
323
|
+
validTime(snapshot.fetchedAt) &&
|
|
324
|
+
validTime(snapshot.reset) &&
|
|
325
|
+
snapshot.fetchedAt <= now &&
|
|
326
|
+
snapshot.reset > now &&
|
|
327
|
+
now - snapshot.fetchedAt < REFRESH_MS,
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function checkedRecently(snapshot: UsageSnapshot | undefined, identity: SlotIdentity, now: number): boolean {
|
|
332
|
+
return Boolean(
|
|
333
|
+
snapshot &&
|
|
334
|
+
snapshot.accountHash === identity.accountHash &&
|
|
335
|
+
snapshot.checkedAt <= now &&
|
|
336
|
+
now - snapshot.checkedAt < REFRESH_MS &&
|
|
337
|
+
(!validTime(snapshot.fetchedAt) || (validTime(snapshot.reset) && snapshot.reset > now)),
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function owns(lock: UsageLock | undefined, slot: number, owner: string, accountHash: string): boolean {
|
|
342
|
+
return lock?.slot === slot && lock.owner === owner && lock.accountHash === accountHash;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function numberValue(value: unknown): number | undefined {
|
|
346
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function resetTime(window: JsonRecord, now: number): number | undefined {
|
|
350
|
+
const resetAt = window.reset_at;
|
|
351
|
+
if (typeof resetAt === "number" && Number.isFinite(resetAt)) return resetAt < 10_000_000_000 ? resetAt * 1000 : resetAt;
|
|
352
|
+
if (typeof resetAt === "string") {
|
|
353
|
+
const parsed = Date.parse(resetAt);
|
|
354
|
+
if (Number.isFinite(parsed)) return parsed;
|
|
355
|
+
}
|
|
356
|
+
const resetAfter = numberValue(window.reset_after_seconds);
|
|
357
|
+
return resetAfter === undefined || resetAfter < 0 ? undefined : now + resetAfter * 1000;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** Extract seven-day window, or only usable window when service reports one. */
|
|
361
|
+
export function parseCodexUsage(value: unknown, now = Date.now()): ParsedUsage | undefined {
|
|
362
|
+
if (!isRecord(value)) return undefined;
|
|
363
|
+
const rateLimit = isRecord(value.rate_limit) ? value.rate_limit : value;
|
|
364
|
+
const windows = Object.entries(rateLimit)
|
|
365
|
+
.flatMap(([name, entry]) => (isRecord(entry) && numberValue(entry.used_percent) !== undefined && resetTime(entry, now) !== undefined ? [{ name, value: entry }] : []));
|
|
366
|
+
const sevenDay = windows.find((window) => numberValue(window.value.limit_window_seconds) === 7 * 24 * 60 * 60)
|
|
367
|
+
?? (windows.length === 1 && numberValue(windows[0].value.limit_window_seconds) === undefined ? windows[0] : undefined);
|
|
368
|
+
if (!sevenDay) return undefined;
|
|
369
|
+
const used = numberValue(sevenDay.value.used_percent);
|
|
370
|
+
const reset = resetTime(sevenDay.value, now);
|
|
371
|
+
if (used === undefined || reset === undefined || reset <= now) return undefined;
|
|
372
|
+
const tier = validTier(value.plan_type) ? value.plan_type : validTier(value.tier) ? value.tier : undefined;
|
|
373
|
+
return { remaining: Math.max(0, Math.min(100, 100 - used)), reset, ...(tier ? { tier } : {}) };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function raceWithSignal<T>(operation: Promise<T>, signal: AbortSignal): Promise<T> {
|
|
377
|
+
if (signal.aborted) return Promise.reject(signal.reason);
|
|
378
|
+
return new Promise((resolve, reject) => {
|
|
379
|
+
const abort = () => reject(signal.reason);
|
|
380
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
381
|
+
operation.then(
|
|
382
|
+
(value) => {
|
|
383
|
+
signal.removeEventListener("abort", abort);
|
|
384
|
+
resolve(value);
|
|
385
|
+
},
|
|
386
|
+
(error: unknown) => {
|
|
387
|
+
signal.removeEventListener("abort", abort);
|
|
388
|
+
reject(error);
|
|
389
|
+
},
|
|
390
|
+
);
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function formatPercent(value: number): string {
|
|
395
|
+
return Number.isInteger(value) ? String(value) : value.toFixed(1).replace(/\.0$/, "");
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function formatDuration(ms: number): string {
|
|
399
|
+
if (ms <= 0) return "now";
|
|
400
|
+
const minutes = Math.ceil(ms / 60_000);
|
|
401
|
+
const days = Math.floor(minutes / (24 * 60));
|
|
402
|
+
const hours = Math.floor((minutes % (24 * 60)) / 60);
|
|
403
|
+
const mins = minutes % 60;
|
|
404
|
+
return [days ? `${days}d` : "", hours ? `${hours}h` : "", mins || (!days && !hours) ? `${mins}m` : ""].filter(Boolean).join(" ");
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
class CodexQuotaStatus {
|
|
408
|
+
private generation = 0;
|
|
409
|
+
private session: AbortController | undefined;
|
|
410
|
+
private refreshContext: QuotaRefresh | undefined;
|
|
411
|
+
private timer: Timer | undefined;
|
|
412
|
+
private readonly heartbeats = new Map<number, { owner: string; timer: Timer }>();
|
|
413
|
+
private readonly active = new Map<number, Promise<void>>();
|
|
414
|
+
private writes: Promise<void> = Promise.resolve();
|
|
415
|
+
private onChange: () => void = () => undefined;
|
|
416
|
+
|
|
417
|
+
setOnChange(onChange: () => void): void {
|
|
418
|
+
this.onChange = onChange;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
snapshot(slot: number): UsageSnapshot | undefined {
|
|
422
|
+
return loadStateSync().slots.get(slot);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
private async state(): Promise<UsageState> {
|
|
426
|
+
await this.writes;
|
|
427
|
+
return loadState();
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
private change<T>(change: (state: UsageState) => { value: T; write: boolean }, signal?: AbortSignal): Promise<T> {
|
|
431
|
+
const operation = this.writes.then(() => withCacheMutex(async (operationSignal) => {
|
|
432
|
+
operationSignal.throwIfAborted();
|
|
433
|
+
const state = await loadState(operationSignal);
|
|
434
|
+
operationSignal.throwIfAborted();
|
|
435
|
+
const result = change(state);
|
|
436
|
+
if (result.write) {
|
|
437
|
+
operationSignal.throwIfAborted();
|
|
438
|
+
await saveState(state, operationSignal);
|
|
439
|
+
}
|
|
440
|
+
return result.value;
|
|
441
|
+
}, signal));
|
|
442
|
+
this.writes = operation.then(() => undefined, () => undefined);
|
|
443
|
+
return operation;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
start(resolveSlotAuth: SlotAuthResolver): void {
|
|
447
|
+
this.stop();
|
|
448
|
+
const session = new AbortController();
|
|
449
|
+
this.session = session;
|
|
450
|
+
this.refreshContext = { generation: ++this.generation, session, resolveSlotAuth };
|
|
451
|
+
this.requestRefresh();
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
stop(): void {
|
|
455
|
+
this.generation++;
|
|
456
|
+
this.session?.abort();
|
|
457
|
+
this.session = undefined;
|
|
458
|
+
this.refreshContext = undefined;
|
|
459
|
+
if (this.timer) clearTimeout(this.timer);
|
|
460
|
+
this.timer = undefined;
|
|
461
|
+
for (const heartbeat of this.heartbeats.values()) clearInterval(heartbeat.timer);
|
|
462
|
+
this.heartbeats.clear();
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
private alive(generation: number, session: AbortController): boolean {
|
|
466
|
+
return this.generation === generation && this.session === session && !session.signal.aborted;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
refreshNow(): void {
|
|
470
|
+
this.requestRefresh();
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
private requestRefresh(): void {
|
|
474
|
+
const refresh = this.refreshContext;
|
|
475
|
+
if (!refresh || !this.alive(refresh.generation, refresh.session)) return;
|
|
476
|
+
void this.refreshDue(refresh.generation, refresh.session, refresh.resolveSlotAuth)
|
|
477
|
+
.catch(() => undefined)
|
|
478
|
+
.then(() => {
|
|
479
|
+
if (this.refreshContext === refresh) this.scheduleRefresh();
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
private scheduleRefresh(): void {
|
|
484
|
+
const refresh = this.refreshContext;
|
|
485
|
+
if (!refresh || !this.alive(refresh.generation, refresh.session)) return;
|
|
486
|
+
if (this.timer) clearTimeout(this.timer);
|
|
487
|
+
this.timer = undefined;
|
|
488
|
+
|
|
489
|
+
const now = Date.now();
|
|
490
|
+
const state = loadStateSync();
|
|
491
|
+
let dueAt: number | undefined;
|
|
492
|
+
for (const [slot, credential] of readCodexCredentials()) {
|
|
493
|
+
if (this.active.has(slot)) continue;
|
|
494
|
+
const identity = identityFor(credential);
|
|
495
|
+
if (!identity) continue;
|
|
496
|
+
const snapshot = state.slots.get(slot);
|
|
497
|
+
const lock = state.locks.get(slot);
|
|
498
|
+
const lockDue = lock && lock.accountHash === identity.accountHash && lock.heartbeatAt <= now && now - lock.heartbeatAt < LOCK_STALE_MS
|
|
499
|
+
? lock.heartbeatAt + LOCK_STALE_MS + 1
|
|
500
|
+
: undefined;
|
|
501
|
+
const snapshotDue = isFresh(snapshot, identity, now)
|
|
502
|
+
? Math.min(snapshot!.fetchedAt! + REFRESH_MS, snapshot!.reset!) + 1
|
|
503
|
+
: checkedRecently(snapshot, identity, now)
|
|
504
|
+
? snapshot!.checkedAt + REFRESH_MS + 1
|
|
505
|
+
: now;
|
|
506
|
+
const next = lockDue ?? snapshotDue;
|
|
507
|
+
dueAt = dueAt === undefined ? next : Math.min(dueAt, next);
|
|
508
|
+
}
|
|
509
|
+
if (dueAt === undefined) return;
|
|
510
|
+
const delay = Math.max(0, dueAt - now);
|
|
511
|
+
this.timer = setTimeout(() => {
|
|
512
|
+
this.timer = undefined;
|
|
513
|
+
if (this.refreshContext === refresh) this.requestRefresh();
|
|
514
|
+
}, delay);
|
|
515
|
+
this.timer.unref?.();
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
private async refreshDue(generation: number, session: AbortController, resolveSlotAuth: SlotAuthResolver): Promise<void> {
|
|
519
|
+
const credentials = readCodexCredentials();
|
|
520
|
+
const state = await this.state();
|
|
521
|
+
await Promise.all([...credentials.entries()].map(async ([slot, credential]) => {
|
|
522
|
+
if (!this.alive(generation, session) || this.active.has(slot)) return;
|
|
523
|
+
const identity = identityFor(credential);
|
|
524
|
+
if (!identity) return;
|
|
525
|
+
const snapshot = state.slots.get(slot);
|
|
526
|
+
if (isFresh(snapshot, identity, Date.now()) || checkedRecently(snapshot, identity, Date.now())) return;
|
|
527
|
+
this.launch(slot, identity, generation, session, resolveSlotAuth);
|
|
528
|
+
})).catch(() => undefined);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
private launch(
|
|
532
|
+
slot: number,
|
|
533
|
+
identity: SlotIdentity,
|
|
534
|
+
generation: number,
|
|
535
|
+
session: AbortController,
|
|
536
|
+
resolveSlotAuth: SlotAuthResolver,
|
|
537
|
+
): void {
|
|
538
|
+
if (this.active.has(slot)) return;
|
|
539
|
+
const task = this.refreshSlot(slot, identity, generation, session, resolveSlotAuth);
|
|
540
|
+
this.active.set(slot, task);
|
|
541
|
+
const cleanup = () => {
|
|
542
|
+
if (this.active.get(slot) !== task) return;
|
|
543
|
+
this.active.delete(slot);
|
|
544
|
+
if (this.refreshContext?.generation === generation) this.scheduleRefresh();
|
|
545
|
+
else this.requestRefresh();
|
|
546
|
+
};
|
|
547
|
+
void task.then(cleanup, cleanup);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
private async claim(slot: number, identity: SlotIdentity, signal: AbortSignal): Promise<string | undefined> {
|
|
551
|
+
const owner = randomUUID();
|
|
552
|
+
const claimed = await this.change((state) => {
|
|
553
|
+
const now = Date.now();
|
|
554
|
+
const snapshot = state.slots.get(slot);
|
|
555
|
+
if (isFresh(snapshot, identity, now) || checkedRecently(snapshot, identity, now)) {
|
|
556
|
+
return { value: false, write: false };
|
|
557
|
+
}
|
|
558
|
+
const lock = state.locks.get(slot);
|
|
559
|
+
if (lock && lock.accountHash === identity.accountHash && lock.heartbeatAt <= now && now - lock.heartbeatAt < LOCK_STALE_MS) {
|
|
560
|
+
return { value: false, write: false };
|
|
561
|
+
}
|
|
562
|
+
if (snapshot?.accountHash !== identity.accountHash) state.slots.delete(slot);
|
|
563
|
+
state.locks.set(slot, { slot, owner, accountHash: identity.accountHash, heartbeatAt: now });
|
|
564
|
+
return { value: true, write: true };
|
|
565
|
+
}, signal);
|
|
566
|
+
if (!claimed) return undefined;
|
|
567
|
+
|
|
568
|
+
const lock = (await this.state()).locks.get(slot);
|
|
569
|
+
return owns(lock, slot, owner, identity.accountHash) ? owner : undefined;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
private startHeartbeat(slot: number, owner: string, accountHash: string, signal: AbortSignal): void {
|
|
573
|
+
const timer = setInterval(() => {
|
|
574
|
+
void this.change((state) => {
|
|
575
|
+
const lock = state.locks.get(slot);
|
|
576
|
+
if (!lock || !owns(lock, slot, owner, accountHash)) return { value: false, write: false };
|
|
577
|
+
state.locks.set(slot, { ...lock, heartbeatAt: Date.now() });
|
|
578
|
+
return { value: true, write: true };
|
|
579
|
+
}, signal).then((kept) => {
|
|
580
|
+
if (!kept) this.stopHeartbeat(slot, owner);
|
|
581
|
+
}).catch(() => this.stopHeartbeat(slot, owner));
|
|
582
|
+
}, HEARTBEAT_MS);
|
|
583
|
+
timer.unref?.();
|
|
584
|
+
this.heartbeats.set(slot, { owner, timer });
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
private stopHeartbeat(slot: number, owner: string): void {
|
|
588
|
+
const heartbeat = this.heartbeats.get(slot);
|
|
589
|
+
if (!heartbeat || heartbeat.owner !== owner) return;
|
|
590
|
+
clearInterval(heartbeat.timer);
|
|
591
|
+
this.heartbeats.delete(slot);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
private async stillOwn(slot: number, owner: string, accountHash: string): Promise<boolean> {
|
|
595
|
+
return owns((await this.state()).locks.get(slot), slot, owner, accountHash);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
private identityStillCurrent(slot: number, accountHash: string): boolean {
|
|
599
|
+
return currentIdentity(slot)?.accountHash === accountHash;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
private async resolveAuth(slot: number, resolveSlotAuth: SlotAuthResolver, signal: AbortSignal): Promise<string | undefined> {
|
|
603
|
+
return raceWithSignal(resolveSlotAuth(slot), signal);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
private async finish(
|
|
607
|
+
slot: number,
|
|
608
|
+
owner: string,
|
|
609
|
+
identity: SlotIdentity,
|
|
610
|
+
outcome: ParsedUsage | undefined,
|
|
611
|
+
generation: number,
|
|
612
|
+
session: AbortController,
|
|
613
|
+
): Promise<boolean> {
|
|
614
|
+
if (!this.alive(generation, session)) return false;
|
|
615
|
+
const finished = await this.change((state) => {
|
|
616
|
+
if (!this.alive(generation, session) || !this.identityStillCurrent(slot, identity.accountHash)) {
|
|
617
|
+
return { value: false, write: false };
|
|
618
|
+
}
|
|
619
|
+
const lock = state.locks.get(slot);
|
|
620
|
+
if (!owns(lock, slot, owner, identity.accountHash)) return { value: false, write: false };
|
|
621
|
+
const previous = state.slots.get(slot);
|
|
622
|
+
const checkedAt = Date.now();
|
|
623
|
+
if (outcome && outcome.reset > checkedAt) {
|
|
624
|
+
state.slots.set(slot, {
|
|
625
|
+
slot,
|
|
626
|
+
accountHash: identity.accountHash,
|
|
627
|
+
...(outcome.tier ? { tier: outcome.tier } : {}),
|
|
628
|
+
checkedAt,
|
|
629
|
+
fetchedAt: checkedAt,
|
|
630
|
+
remaining: outcome.remaining,
|
|
631
|
+
reset: outcome.reset,
|
|
632
|
+
});
|
|
633
|
+
} else {
|
|
634
|
+
state.slots.set(slot, previous?.accountHash === identity.accountHash && (!validTime(previous.reset) || previous.reset > checkedAt)
|
|
635
|
+
? { ...previous, checkedAt }
|
|
636
|
+
: { slot, accountHash: identity.accountHash, checkedAt });
|
|
637
|
+
}
|
|
638
|
+
state.locks.delete(slot);
|
|
639
|
+
return { value: true, write: true };
|
|
640
|
+
}, session.signal);
|
|
641
|
+
if (finished) this.onChange();
|
|
642
|
+
return finished;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
private async release(slot: number, owner: string, signal: AbortSignal): Promise<void> {
|
|
646
|
+
await this.change((state) => {
|
|
647
|
+
const lock = state.locks.get(slot);
|
|
648
|
+
if (!lock || lock.owner !== owner) return { value: undefined, write: false };
|
|
649
|
+
state.locks.delete(slot);
|
|
650
|
+
return { value: undefined, write: true };
|
|
651
|
+
}, signal);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
private async refreshSlot(
|
|
655
|
+
slot: number,
|
|
656
|
+
identity: SlotIdentity,
|
|
657
|
+
generation: number,
|
|
658
|
+
session: AbortController,
|
|
659
|
+
resolveSlotAuth: SlotAuthResolver,
|
|
660
|
+
): Promise<void> {
|
|
661
|
+
let owner: string | undefined;
|
|
662
|
+
let discard = false;
|
|
663
|
+
try {
|
|
664
|
+
owner = await this.claim(slot, identity, session.signal);
|
|
665
|
+
if (!owner || !this.alive(generation, session)) return;
|
|
666
|
+
this.startHeartbeat(slot, owner, identity.accountHash, session.signal);
|
|
667
|
+
|
|
668
|
+
const authSignal = AbortSignal.any([session.signal, AbortSignal.timeout(OPERATION_TIMEOUT_MS)]);
|
|
669
|
+
const apiKey = await this.resolveAuth(slot, resolveSlotAuth, authSignal);
|
|
670
|
+
if (!apiKey) throw new Error("Codex OAuth auth did not provide a bearer token.");
|
|
671
|
+
if (!this.alive(generation, session) || !this.identityStillCurrent(slot, identity.accountHash) || !(await this.stillOwn(slot, owner, identity.accountHash))) {
|
|
672
|
+
discard = true;
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
const fetchSignal = AbortSignal.any([session.signal, AbortSignal.timeout(OPERATION_TIMEOUT_MS)]);
|
|
677
|
+
const response = await raceWithSignal(fetch(USAGE_URL, {
|
|
678
|
+
headers: {
|
|
679
|
+
Authorization: `Bearer ${apiKey}`,
|
|
680
|
+
"ChatGPT-Account-Id": identity.accountId,
|
|
681
|
+
},
|
|
682
|
+
signal: fetchSignal,
|
|
683
|
+
}), fetchSignal);
|
|
684
|
+
if (!response.ok) throw new Error(`Codex usage request failed (${response.status}).`);
|
|
685
|
+
const usage = parseCodexUsage(await raceWithSignal(response.json(), fetchSignal));
|
|
686
|
+
if (!usage) throw new Error("Codex usage response has no usable seven-day window.");
|
|
687
|
+
if (!this.identityStillCurrent(slot, identity.accountHash) || !(await this.stillOwn(slot, owner, identity.accountHash))) {
|
|
688
|
+
discard = true;
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
await this.finish(slot, owner, identity, usage, generation, session);
|
|
692
|
+
} catch {
|
|
693
|
+
if (!session.signal.aborted && !discard && owner) await this.finish(slot, owner, identity, undefined, generation, session);
|
|
694
|
+
} finally {
|
|
695
|
+
if (owner) {
|
|
696
|
+
this.stopHeartbeat(slot, owner);
|
|
697
|
+
await this.release(slot, owner, session.signal.aborted ? cleanupSignal() : session.signal).catch(() => undefined);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
async statusLines(): Promise<string[]> {
|
|
703
|
+
const credentials = readCodexCredentials();
|
|
704
|
+
const state = await this.state();
|
|
705
|
+
const now = Date.now();
|
|
706
|
+
return [...credentials.entries()]
|
|
707
|
+
.sort(([left], [right]) => left - right)
|
|
708
|
+
.map(([slot, credential]) => {
|
|
709
|
+
const identity = identityFor(credential);
|
|
710
|
+
const snapshot = state.slots.get(slot);
|
|
711
|
+
if (!identity || !snapshot || snapshot.accountHash !== identity.accountHash || !validTime(snapshot.fetchedAt) || typeof snapshot.remaining !== "number" || !validTime(snapshot.reset)) {
|
|
712
|
+
return `Codex slot ${slot}: unavailable`;
|
|
713
|
+
}
|
|
714
|
+
const tier = snapshot.tier ? ` (${snapshot.tier})` : "";
|
|
715
|
+
const status = isFresh(snapshot, identity, now) ? "measured" : "stale";
|
|
716
|
+
return `Codex slot ${slot}${tier}: ${formatPercent(snapshot.remaining)}% remaining, resets in ${formatDuration(snapshot.reset - now)} (${status})`;
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
function nativeModel(model: CodexModel): CodexModel {
|
|
722
|
+
return model.provider === NATIVE_PROVIDER_ID ? model : { ...model, provider: NATIVE_PROVIDER_ID };
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
function aliasModel(model: CodexModel, provider: string): CodexModel {
|
|
726
|
+
return model.provider === provider ? model : { ...model, provider };
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function nativeContext(context: Context, alias: string): Context {
|
|
730
|
+
return {
|
|
731
|
+
...context,
|
|
732
|
+
messages: context.messages.map((message) => {
|
|
733
|
+
if (message.role !== "assistant") return message;
|
|
734
|
+
const provider = message.provider === alias ? NATIVE_PROVIDER_ID : message.provider === NATIVE_PROVIDER_ID ? alias : message.provider;
|
|
735
|
+
if (provider === message.provider) return message;
|
|
736
|
+
const rewritten = { ...message, provider };
|
|
737
|
+
if (message.deferred) rewritten.deferred = { ...message.deferred, provider };
|
|
738
|
+
return rewritten;
|
|
739
|
+
}),
|
|
740
|
+
};
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
function aliasMessage(message: AssistantMessage, provider: string): AssistantMessage {
|
|
744
|
+
const aliased = { ...message, provider };
|
|
745
|
+
if (message.deferred) aliased.deferred = { ...message.deferred, provider };
|
|
746
|
+
return aliased;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function aliasEvent(event: AssistantMessageEvent, provider: string): AssistantMessageEvent {
|
|
750
|
+
if (event.type === "done") return { ...event, message: aliasMessage(event.message, provider) };
|
|
751
|
+
if (event.type === "error") return { ...event, error: aliasMessage(event.error, provider) };
|
|
752
|
+
return { ...event, partial: aliasMessage(event.partial, provider) };
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
function emptyErrorMessage(model: CodexModel, provider: string, error: unknown): AssistantMessage {
|
|
756
|
+
return {
|
|
757
|
+
role: "assistant",
|
|
758
|
+
content: [],
|
|
759
|
+
api: model.api,
|
|
760
|
+
provider,
|
|
761
|
+
model: model.id,
|
|
762
|
+
usage: {
|
|
763
|
+
input: 0,
|
|
764
|
+
output: 0,
|
|
765
|
+
cacheRead: 0,
|
|
766
|
+
cacheWrite: 0,
|
|
767
|
+
totalTokens: 0,
|
|
768
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
769
|
+
},
|
|
770
|
+
stopReason: "error",
|
|
771
|
+
errorMessage: error instanceof Error ? error.message : String(error),
|
|
772
|
+
timestamp: Date.now(),
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
function aliasStream(
|
|
777
|
+
source: AssistantMessageEventStream,
|
|
778
|
+
model: CodexModel,
|
|
779
|
+
provider: string,
|
|
780
|
+
): AssistantMessageEventStream {
|
|
781
|
+
const output = createAssistantMessageEventStream();
|
|
782
|
+
void (async () => {
|
|
783
|
+
try {
|
|
784
|
+
for await (const event of source) output.push(aliasEvent(event, provider));
|
|
785
|
+
output.end();
|
|
786
|
+
} catch (error) {
|
|
787
|
+
const message = emptyErrorMessage(model, provider, error);
|
|
788
|
+
output.push({ type: "error", reason: "error", error: message });
|
|
789
|
+
output.end();
|
|
790
|
+
}
|
|
791
|
+
})();
|
|
792
|
+
return output;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
export function createCodexAliasProvider(native: CodexProvider, slot: number): CodexProvider {
|
|
796
|
+
const providerId = `${NATIVE_PROVIDER_ID}-${slot}`;
|
|
797
|
+
const models = native.getModels().map((model) => aliasModel(model, providerId));
|
|
798
|
+
const provider: CodexProvider = {
|
|
799
|
+
id: providerId,
|
|
800
|
+
name: `OpenAI Codex #${slot}`,
|
|
801
|
+
baseUrl: native.baseUrl,
|
|
802
|
+
headers: native.headers,
|
|
803
|
+
auth: native.auth,
|
|
804
|
+
getModels: () => models,
|
|
805
|
+
stream: (model, context, options?: ApiStreamOptions<"openai-codex-responses">) =>
|
|
806
|
+
aliasStream(native.stream(nativeModel(model), nativeContext(context, providerId), options), model, providerId),
|
|
807
|
+
streamSimple: (model, context, options?: SimpleStreamOptions) =>
|
|
808
|
+
aliasStream(native.streamSimple(nativeModel(model), nativeContext(context, providerId), options), model, providerId),
|
|
809
|
+
};
|
|
810
|
+
|
|
811
|
+
if (native.filterModels) {
|
|
812
|
+
provider.filterModels = (available, credential) =>
|
|
813
|
+
native
|
|
814
|
+
.filterModels!(available.map(nativeModel), credential)
|
|
815
|
+
.map((model) => aliasModel(model, providerId));
|
|
816
|
+
}
|
|
817
|
+
if (native.fetchDeferred) {
|
|
818
|
+
provider.fetchDeferred = (model, handle, options?: DeferredFetchOptions) =>
|
|
819
|
+
aliasStream(
|
|
820
|
+
native.fetchDeferred!(nativeModel(model), { ...handle, provider: NATIVE_PROVIDER_ID }, options),
|
|
821
|
+
model,
|
|
822
|
+
providerId,
|
|
823
|
+
);
|
|
824
|
+
}
|
|
825
|
+
if (native.cancelDeferred) {
|
|
826
|
+
provider.cancelDeferred = (model, handle, options?: DeferredCancelOptions) =>
|
|
827
|
+
native.cancelDeferred!(nativeModel(model), { ...handle, provider: NATIVE_PROVIDER_ID }, options);
|
|
828
|
+
}
|
|
829
|
+
return provider;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
function providerForSlot(slot: number): string {
|
|
833
|
+
return slot === 1 ? NATIVE_PROVIDER_ID : `${NATIVE_PROVIDER_ID}-${slot}`;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
function isManagedProvider(provider: string | undefined): boolean {
|
|
837
|
+
return provider !== undefined && slotForProvider(provider) !== undefined;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
function sessionHasAgentWork(ctx: ExtensionContext): boolean {
|
|
841
|
+
return ctx.sessionManager?.getBranch?.().some((entry) =>
|
|
842
|
+
entry.type === "message" || (entry.type === "custom" && entry.customType === AGENT_STARTED_ENTRY),
|
|
843
|
+
) ?? false;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
export default function multiCodex(pi: ExtensionAPI): void {
|
|
847
|
+
const native = builtinProviders().find((provider) => provider.id === NATIVE_PROVIDER_ID) as CodexProvider | undefined;
|
|
848
|
+
if (!native) return;
|
|
849
|
+
const quota = new CodexQuotaStatus();
|
|
850
|
+
const registered = new Map<number, CodexProvider>();
|
|
851
|
+
let sessionContext: ExtensionContext | undefined;
|
|
852
|
+
let automaticOpen = false;
|
|
853
|
+
let automaticCandidate: Model<any> | undefined;
|
|
854
|
+
|
|
855
|
+
const registerSlot = (slot: number): void => {
|
|
856
|
+
if (slot === 1 || registered.has(slot)) return;
|
|
857
|
+
const provider = createCodexAliasProvider(native, slot);
|
|
858
|
+
pi.registerProvider(provider);
|
|
859
|
+
registered.set(slot, provider);
|
|
860
|
+
};
|
|
861
|
+
|
|
862
|
+
const syncSlots = (): Set<number> => {
|
|
863
|
+
const slots = discoverCodexSlots();
|
|
864
|
+
for (const slot of [...slots].sort((a, b) => a - b)) registerSlot(slot);
|
|
865
|
+
return slots;
|
|
866
|
+
};
|
|
867
|
+
|
|
868
|
+
const allowsModel = (ctx: ExtensionContext, model: Model<any>, slot: number): boolean => {
|
|
869
|
+
const scopedModels = ctx.scopedModels ?? [];
|
|
870
|
+
return scopedModels.length === 0 || scopedModels.some(({ model: scoped }) => scoped.provider === providerForSlot(slot) && scoped.id === model.id);
|
|
871
|
+
};
|
|
872
|
+
|
|
873
|
+
const freshSlots = (ctx: ExtensionContext, model: Model<any>): { slot: number; remaining: number }[] => {
|
|
874
|
+
const state = loadStateSync();
|
|
875
|
+
const now = Date.now();
|
|
876
|
+
return [...readCodexCredentials().entries()]
|
|
877
|
+
.flatMap(([slot, credential]) => {
|
|
878
|
+
if ((slot !== 1 && !registered.has(slot)) || !allowsModel(ctx, model, slot)) return [];
|
|
879
|
+
const identity = identityFor(credential);
|
|
880
|
+
const snapshot = state.slots.get(slot);
|
|
881
|
+
if (!identity || !isFresh(snapshot, identity, now) || typeof snapshot?.remaining !== "number") return [];
|
|
882
|
+
return [{ slot, remaining: snapshot.remaining }];
|
|
883
|
+
});
|
|
884
|
+
};
|
|
885
|
+
|
|
886
|
+
const selectFreshSlot = (ctx: ExtensionContext, model: Model<any>): number | undefined => {
|
|
887
|
+
const candidates = freshSlots(ctx, model);
|
|
888
|
+
if (candidates.length === 0) return undefined;
|
|
889
|
+
const currentSlot = slotForProvider(model.provider);
|
|
890
|
+
const remaining = Math.max(...candidates.map((candidate) => candidate.remaining));
|
|
891
|
+
const tied = candidates.filter((candidate) => candidate.remaining === remaining);
|
|
892
|
+
return tied.find((candidate) => candidate.slot === currentSlot)?.slot
|
|
893
|
+
?? Math.min(...tied.map((candidate) => candidate.slot));
|
|
894
|
+
};
|
|
895
|
+
|
|
896
|
+
const footerText = (ctx: ExtensionContext): string | undefined => {
|
|
897
|
+
const model = ctx.model;
|
|
898
|
+
const slot = slotForProvider(model?.provider ?? "");
|
|
899
|
+
if (!model || !slot) return undefined;
|
|
900
|
+
const identity = currentIdentity(slot);
|
|
901
|
+
const snapshot = quota.snapshot(slot);
|
|
902
|
+
const prefix = `Codex #${slot}`;
|
|
903
|
+
if (!identity || !snapshot || snapshot.accountHash !== identity.accountHash || !validTime(snapshot.fetchedAt) || typeof snapshot.remaining !== "number" || !validTime(snapshot.reset)) {
|
|
904
|
+
return `${prefix} · unavailable`;
|
|
905
|
+
}
|
|
906
|
+
if (!isFresh(snapshot, identity, Date.now())) return `${prefix} · stale`;
|
|
907
|
+
const text = `${prefix} · ${formatPercent(snapshot.remaining)}% · 7d ${formatDuration(snapshot.reset - Date.now())}`;
|
|
908
|
+
const color = snapshot.remaining >= 50 ? "success" : snapshot.remaining >= 25 ? "warning" : "error";
|
|
909
|
+
return ctx.ui.theme?.fg ? ctx.ui.theme.fg(color, text) : text;
|
|
910
|
+
};
|
|
911
|
+
|
|
912
|
+
const updateFooter = (ctx: ExtensionContext | undefined = sessionContext): void => {
|
|
913
|
+
if (ctx) ctx.ui.setStatus?.("pi-multi-codex", footerText(ctx));
|
|
914
|
+
};
|
|
915
|
+
|
|
916
|
+
const updatePendingCandidate = (ctx: ExtensionContext): void => {
|
|
917
|
+
if (!automaticOpen || !ctx.model || !isManagedProvider(ctx.model.provider)) return;
|
|
918
|
+
const slot = selectFreshSlot(ctx, ctx.model);
|
|
919
|
+
automaticCandidate = slot ? { ...ctx.model, provider: providerForSlot(slot) } : ctx.model;
|
|
920
|
+
};
|
|
921
|
+
|
|
922
|
+
quota.setOnChange(() => {
|
|
923
|
+
if (!sessionContext) return;
|
|
924
|
+
syncSlots();
|
|
925
|
+
updatePendingCandidate(sessionContext);
|
|
926
|
+
updateFooter();
|
|
927
|
+
});
|
|
928
|
+
|
|
929
|
+
syncSlots();
|
|
930
|
+
|
|
931
|
+
pi.on("session_start", (_event, ctx) => {
|
|
932
|
+
const slots = syncSlots();
|
|
933
|
+
sessionContext = ctx;
|
|
934
|
+
automaticOpen = isManagedProvider(ctx.model?.provider) && !sessionHasAgentWork(ctx);
|
|
935
|
+
automaticCandidate = ctx.model;
|
|
936
|
+
// Cache-only ranking happens before background refresh starts.
|
|
937
|
+
updatePendingCandidate(ctx);
|
|
938
|
+
updateFooter(ctx);
|
|
939
|
+
quota.start(async (slot) => (await ctx.modelRegistry.getProviderAuth(providerForSlot(slot)))?.auth.apiKey);
|
|
940
|
+
if (slots.size === 0) ctx.ui.notify(NO_ACCOUNTS_MESSAGE, "warning");
|
|
941
|
+
});
|
|
942
|
+
|
|
943
|
+
pi.on("before_agent_start", async (_event, ctx) => {
|
|
944
|
+
if (!automaticOpen) return;
|
|
945
|
+
const model = ctx.model;
|
|
946
|
+
updatePendingCandidate(ctx);
|
|
947
|
+
// Close before await: no timer, refresh, queued turn, or provider event can route later.
|
|
948
|
+
automaticOpen = false;
|
|
949
|
+
const candidate = automaticCandidate;
|
|
950
|
+
if (!model || !candidate || !isManagedProvider(model.provider) || candidate.id !== model.id || candidate.provider === model.provider) return;
|
|
951
|
+
await pi.setModel(candidate);
|
|
952
|
+
updateFooter(ctx);
|
|
953
|
+
});
|
|
954
|
+
|
|
955
|
+
pi.on("agent_start", (_event, ctx) => {
|
|
956
|
+
automaticOpen = false;
|
|
957
|
+
if (!sessionHasAgentWork(ctx)) pi.appendEntry(AGENT_STARTED_ENTRY);
|
|
958
|
+
});
|
|
959
|
+
pi.on("session_shutdown", () => {
|
|
960
|
+
automaticOpen = false;
|
|
961
|
+
sessionContext = undefined;
|
|
962
|
+
quota.stop();
|
|
963
|
+
});
|
|
964
|
+
pi.on("model_select", (_event, ctx) => {
|
|
965
|
+
// Explicit selector choice wins over startup routing.
|
|
966
|
+
automaticOpen = false;
|
|
967
|
+
syncSlots();
|
|
968
|
+
quota.refreshNow();
|
|
969
|
+
updateFooter(ctx);
|
|
970
|
+
});
|
|
971
|
+
|
|
972
|
+
pi.registerCommand("codex-add", {
|
|
973
|
+
description: "enroll another OpenAI Codex OAuth slot",
|
|
974
|
+
handler: async (_args, ctx) => {
|
|
975
|
+
const slots = syncSlots();
|
|
976
|
+
if (!slots.has(1)) {
|
|
977
|
+
ctx.ui.notify("Run /login and select OpenAI Codex for slot 1 first.", "warning");
|
|
978
|
+
return;
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
const used = new Set([...slots, ...registered.keys()]);
|
|
982
|
+
let slot = 2;
|
|
983
|
+
while (used.has(slot)) slot++;
|
|
984
|
+
registerSlot(slot);
|
|
985
|
+
ctx.ui.notify(`Codex slot ${slot} ready. Run /login and select OpenAI Codex #${slot}.`, "info");
|
|
986
|
+
},
|
|
987
|
+
});
|
|
988
|
+
|
|
989
|
+
pi.registerCommand("codex-status", {
|
|
990
|
+
description: "show shared Codex seven-day quota status",
|
|
991
|
+
handler: async (_args, ctx: ExtensionContext) => {
|
|
992
|
+
const lines = await quota.statusLines();
|
|
993
|
+
ctx.ui.notify(lines.length ? lines.join("\n") : "No Codex OAuth accounts found.", "info");
|
|
994
|
+
},
|
|
995
|
+
});
|
|
996
|
+
|
|
997
|
+
pi.registerCommand("codex-switch", {
|
|
998
|
+
description: "switch current Codex model to another authenticated slot",
|
|
999
|
+
handler: async (_args, ctx) => {
|
|
1000
|
+
const model = ctx.model;
|
|
1001
|
+
if (!model || !isManagedProvider(model.provider)) {
|
|
1002
|
+
ctx.ui.notify("Select an OpenAI Codex model before switching slots.", "warning");
|
|
1003
|
+
return;
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
const authenticated = [...syncSlots()]
|
|
1007
|
+
.filter((slot) => slot === 1 || registered.has(slot))
|
|
1008
|
+
.sort((left, right) => left - right);
|
|
1009
|
+
const scopedModels = ctx.scopedModels ?? [];
|
|
1010
|
+
const slots = authenticated.filter((slot) => allowsModel(ctx, model, slot));
|
|
1011
|
+
const currentSlot = slotForProvider(model.provider);
|
|
1012
|
+
if (slots.length === 0 || (scopedModels.length > 0 && authenticated.some((slot) => !allowsModel(ctx, model, slot)) && slots.every((slot) => slot === currentSlot))) {
|
|
1013
|
+
ctx.ui.notify(
|
|
1014
|
+
scopedModels.length > 0
|
|
1015
|
+
? "No authenticated Codex slot matches this session's model scope. Restart Pi or update scoped models."
|
|
1016
|
+
: "No authenticated Codex slots found. Run /login and select OpenAI Codex.",
|
|
1017
|
+
"warning",
|
|
1018
|
+
);
|
|
1019
|
+
return;
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
const choices = slots.map((slot) => `Codex #${slot}`);
|
|
1023
|
+
const selected = await ctx.ui.select("Switch Codex slot", choices);
|
|
1024
|
+
const index = selected ? choices.indexOf(selected) : -1;
|
|
1025
|
+
if (index < 0) return;
|
|
1026
|
+
automaticOpen = false;
|
|
1027
|
+
const provider = providerForSlot(slots[index]);
|
|
1028
|
+
if (provider !== model.provider) await pi.setModel({ ...model, provider });
|
|
1029
|
+
updateFooter(ctx);
|
|
1030
|
+
},
|
|
1031
|
+
});
|
|
1032
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@henryqw/pi-multi-codex",
|
|
3
|
+
"version": "0.3.8",
|
|
4
|
+
"description": "Use multiple ChatGPT Codex OAuth accounts in Pi.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package",
|
|
7
|
+
"pi",
|
|
8
|
+
"codex",
|
|
9
|
+
"oauth"
|
|
10
|
+
],
|
|
11
|
+
"type": "module",
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=22.19.0"
|
|
14
|
+
},
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"files": [
|
|
17
|
+
"extensions",
|
|
18
|
+
"README.md",
|
|
19
|
+
"LICENSE"
|
|
20
|
+
],
|
|
21
|
+
"scripts": {
|
|
22
|
+
"test": "node --test test/*.test.ts",
|
|
23
|
+
"typecheck": "tsc --noEmit --allowImportingTsExtensions --target ES2022 --module NodeNext --moduleResolution NodeNext --skipLibCheck extensions/*.ts test/*.test.ts",
|
|
24
|
+
"pack:check": "npm pack --dry-run"
|
|
25
|
+
},
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"@earendil-works/pi-ai": "*",
|
|
28
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
29
|
+
},
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "git+https://github.com/HenryQW/pi-packages.git",
|
|
33
|
+
"directory": "packages/pi-multi-codex"
|
|
34
|
+
},
|
|
35
|
+
"bugs": {
|
|
36
|
+
"url": "https://github.com/HenryQW/pi-packages/issues"
|
|
37
|
+
},
|
|
38
|
+
"publishConfig": {
|
|
39
|
+
"access": "public"
|
|
40
|
+
},
|
|
41
|
+
"pi": {
|
|
42
|
+
"extensions": [
|
|
43
|
+
"./extensions"
|
|
44
|
+
]
|
|
45
|
+
},
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"proper-lockfile": "^4.1.2"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@types/proper-lockfile": "^4.1.4"
|
|
51
|
+
}
|
|
52
|
+
}
|