@victor-software-house/pi-multicodex 2.2.1 → 2.3.1
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/account-manager.ts +122 -278
- package/commands.ts +7 -17
- package/hooks.ts +26 -21
- package/index.ts +2 -4
- package/package.json +6 -3
- package/schemas/codex-accounts.schema.json +77 -0
- package/selection.ts +20 -16
- package/status.ts +1 -5
- package/storage.ts +155 -18
- package/stream-wrapper.ts +1 -1
- package/usage.ts +11 -0
package/account-manager.ts
CHANGED
|
@@ -23,17 +23,40 @@ type StateChangeHandler = () => void;
|
|
|
23
23
|
|
|
24
24
|
export class AccountManager {
|
|
25
25
|
private data: StorageData;
|
|
26
|
+
private piAuthAccount?: Account;
|
|
26
27
|
private usageCache = new Map<string, CodexUsageSnapshot>();
|
|
27
28
|
private refreshPromises = new Map<string, Promise<string>>();
|
|
28
29
|
private warningHandler?: WarningHandler;
|
|
29
30
|
private manualEmail?: string;
|
|
30
31
|
private stateChangeHandlers = new Set<StateChangeHandler>();
|
|
31
32
|
private warnedAuthFailureEmails = new Set<string>();
|
|
33
|
+
private readyPromise: Promise<void> = Promise.resolve();
|
|
34
|
+
private readyResolve?: () => void;
|
|
32
35
|
|
|
33
36
|
constructor() {
|
|
34
37
|
this.data = loadStorage();
|
|
35
38
|
}
|
|
36
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Mark the account manager as initializing. The returned promise
|
|
42
|
+
* resolves when {@link markReady} is called. Stream requests wait
|
|
43
|
+
* on {@link waitUntilReady} so they don't race the startup refresh.
|
|
44
|
+
*/
|
|
45
|
+
beginInitialization(): void {
|
|
46
|
+
this.readyPromise = new Promise<void>((resolve) => {
|
|
47
|
+
this.readyResolve = resolve;
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
markReady(): void {
|
|
52
|
+
this.readyResolve?.();
|
|
53
|
+
this.readyResolve = undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
waitUntilReady(): Promise<void> {
|
|
57
|
+
return this.readyPromise;
|
|
58
|
+
}
|
|
59
|
+
|
|
37
60
|
private save(): void {
|
|
38
61
|
saveStorage(this.data);
|
|
39
62
|
}
|
|
@@ -52,13 +75,21 @@ export class AccountManager {
|
|
|
52
75
|
}
|
|
53
76
|
|
|
54
77
|
getAccounts(): Account[] {
|
|
78
|
+
if (this.piAuthAccount) {
|
|
79
|
+
return [...this.data.accounts, this.piAuthAccount];
|
|
80
|
+
}
|
|
55
81
|
return this.data.accounts;
|
|
56
82
|
}
|
|
57
83
|
|
|
58
84
|
getAccount(email: string): Account | undefined {
|
|
85
|
+
if (this.piAuthAccount?.email === email) return this.piAuthAccount;
|
|
59
86
|
return this.data.accounts.find((a) => a.email === email);
|
|
60
87
|
}
|
|
61
88
|
|
|
89
|
+
isPiAuthAccount(account: Account): boolean {
|
|
90
|
+
return this.piAuthAccount !== undefined && account === this.piAuthAccount;
|
|
91
|
+
}
|
|
92
|
+
|
|
62
93
|
setWarningHandler(handler?: WarningHandler): void {
|
|
63
94
|
this.warningHandler = handler;
|
|
64
95
|
}
|
|
@@ -72,32 +103,14 @@ export class AccountManager {
|
|
|
72
103
|
return;
|
|
73
104
|
}
|
|
74
105
|
this.warnedAuthFailureEmails.add(account.email);
|
|
75
|
-
const hint = account
|
|
76
|
-
? "/
|
|
106
|
+
const hint = this.isPiAuthAccount(account)
|
|
107
|
+
? "/login openai-codex"
|
|
77
108
|
: `/multicodex reauth ${account.email}`;
|
|
78
109
|
this.warningHandler?.(
|
|
79
110
|
`Multicodex skipped ${account.email} during rotation: ${normalizeUnknownError(error)}. Account is flagged in /multicodex accounts. Run ${hint} to repair it.`,
|
|
80
111
|
);
|
|
81
112
|
}
|
|
82
113
|
|
|
83
|
-
private updateAccountEmail(account: Account, email: string): boolean {
|
|
84
|
-
if (account.email === email) return false;
|
|
85
|
-
const previousEmail = account.email;
|
|
86
|
-
account.email = email;
|
|
87
|
-
if (this.data.activeEmail === previousEmail) {
|
|
88
|
-
this.data.activeEmail = email;
|
|
89
|
-
}
|
|
90
|
-
if (this.manualEmail === previousEmail) {
|
|
91
|
-
this.manualEmail = email;
|
|
92
|
-
}
|
|
93
|
-
const cached = this.usageCache.get(previousEmail);
|
|
94
|
-
if (cached) {
|
|
95
|
-
this.usageCache.delete(previousEmail);
|
|
96
|
-
this.usageCache.set(email, cached);
|
|
97
|
-
}
|
|
98
|
-
return true;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
114
|
private removeAccountRecord(account: Account): boolean {
|
|
102
115
|
const index = this.data.accounts.findIndex(
|
|
103
116
|
(candidate) => candidate.email === account.email,
|
|
@@ -117,25 +130,7 @@ export class AccountManager {
|
|
|
117
130
|
return true;
|
|
118
131
|
}
|
|
119
132
|
|
|
120
|
-
private
|
|
121
|
-
refreshToken: string,
|
|
122
|
-
excludeEmail?: string,
|
|
123
|
-
): Account | undefined {
|
|
124
|
-
return this.data.accounts.find(
|
|
125
|
-
(account) =>
|
|
126
|
-
account.refreshToken === refreshToken && account.email !== excludeEmail,
|
|
127
|
-
);
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
private applyCredentials(
|
|
131
|
-
account: Account,
|
|
132
|
-
creds: OAuthCredentials,
|
|
133
|
-
options?: {
|
|
134
|
-
importSource?: "pi-openai-codex";
|
|
135
|
-
importMode?: "linked" | "synthetic";
|
|
136
|
-
importFingerprint?: string;
|
|
137
|
-
},
|
|
138
|
-
): boolean {
|
|
133
|
+
private applyCredentials(account: Account, creds: OAuthCredentials): boolean {
|
|
139
134
|
const accountId =
|
|
140
135
|
typeof creds.accountId === "string" ? creds.accountId : undefined;
|
|
141
136
|
let changed = false;
|
|
@@ -155,24 +150,6 @@ export class AccountManager {
|
|
|
155
150
|
account.accountId = accountId;
|
|
156
151
|
changed = true;
|
|
157
152
|
}
|
|
158
|
-
if (
|
|
159
|
-
options?.importSource &&
|
|
160
|
-
account.importSource !== options.importSource
|
|
161
|
-
) {
|
|
162
|
-
account.importSource = options.importSource;
|
|
163
|
-
changed = true;
|
|
164
|
-
}
|
|
165
|
-
if (options?.importMode && account.importMode !== options.importMode) {
|
|
166
|
-
account.importMode = options.importMode;
|
|
167
|
-
changed = true;
|
|
168
|
-
}
|
|
169
|
-
if (
|
|
170
|
-
options?.importFingerprint &&
|
|
171
|
-
account.importFingerprint !== options.importFingerprint
|
|
172
|
-
) {
|
|
173
|
-
account.importFingerprint = options.importFingerprint;
|
|
174
|
-
changed = true;
|
|
175
|
-
}
|
|
176
153
|
if (account.needsReauth) {
|
|
177
154
|
account.needsReauth = undefined;
|
|
178
155
|
this.warnedAuthFailureEmails.delete(account.email);
|
|
@@ -181,66 +158,28 @@ export class AccountManager {
|
|
|
181
158
|
return changed;
|
|
182
159
|
}
|
|
183
160
|
|
|
184
|
-
addOrUpdateAccount(
|
|
185
|
-
email
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
preserveActive?: boolean;
|
|
192
|
-
},
|
|
193
|
-
): Account {
|
|
194
|
-
const existing = this.getAccount(email);
|
|
195
|
-
const duplicate = existing
|
|
196
|
-
? undefined
|
|
197
|
-
: this.findAccountByRefreshToken(creds.refresh);
|
|
198
|
-
let target = existing ?? duplicate;
|
|
199
|
-
let changed = false;
|
|
200
|
-
|
|
201
|
-
if (target) {
|
|
202
|
-
if (
|
|
203
|
-
duplicate?.importSource === "pi-openai-codex" &&
|
|
204
|
-
duplicate.email !== email &&
|
|
205
|
-
!this.getAccount(email)
|
|
206
|
-
) {
|
|
207
|
-
changed = this.updateAccountEmail(duplicate, email) || changed;
|
|
208
|
-
}
|
|
209
|
-
changed =
|
|
210
|
-
this.applyCredentials(target, creds, {
|
|
211
|
-
...options,
|
|
212
|
-
importMode:
|
|
213
|
-
options?.importMode ??
|
|
214
|
-
(duplicate?.importMode === "synthetic" ? "linked" : undefined),
|
|
215
|
-
}) || changed;
|
|
216
|
-
} else {
|
|
217
|
-
target = {
|
|
218
|
-
email,
|
|
219
|
-
accessToken: creds.access,
|
|
220
|
-
refreshToken: creds.refresh,
|
|
221
|
-
expiresAt: creds.expires,
|
|
222
|
-
accountId:
|
|
223
|
-
typeof creds.accountId === "string" ? creds.accountId : undefined,
|
|
224
|
-
importSource: options?.importSource,
|
|
225
|
-
importMode: options?.importMode,
|
|
226
|
-
importFingerprint: options?.importFingerprint,
|
|
227
|
-
};
|
|
228
|
-
this.data.accounts.push(target);
|
|
229
|
-
changed = true;
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
if (!options?.preserveActive) {
|
|
233
|
-
if (this.data.activeEmail !== target.email) {
|
|
234
|
-
this.setActiveAccount(target.email);
|
|
235
|
-
return target;
|
|
161
|
+
addOrUpdateAccount(email: string, creds: OAuthCredentials): Account {
|
|
162
|
+
const existing = this.data.accounts.find((a) => a.email === email);
|
|
163
|
+
if (existing) {
|
|
164
|
+
const changed = this.applyCredentials(existing, creds);
|
|
165
|
+
if (changed) {
|
|
166
|
+
this.save();
|
|
167
|
+
this.notifyStateChanged();
|
|
236
168
|
}
|
|
169
|
+
return existing;
|
|
237
170
|
}
|
|
238
171
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
172
|
+
const account: Account = {
|
|
173
|
+
email,
|
|
174
|
+
accessToken: creds.access,
|
|
175
|
+
refreshToken: creds.refresh,
|
|
176
|
+
expiresAt: creds.expires,
|
|
177
|
+
accountId:
|
|
178
|
+
typeof creds.accountId === "string" ? creds.accountId : undefined,
|
|
179
|
+
};
|
|
180
|
+
this.data.accounts.push(account);
|
|
181
|
+
this.setActiveAccount(email);
|
|
182
|
+
return account;
|
|
244
183
|
}
|
|
245
184
|
|
|
246
185
|
getActiveAccount(): Account | undefined {
|
|
@@ -286,151 +225,40 @@ export class AccountManager {
|
|
|
286
225
|
this.notifyStateChanged();
|
|
287
226
|
}
|
|
288
227
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
);
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
private getSyntheticImportedAccount(): Account | undefined {
|
|
304
|
-
return this.data.accounts.find(
|
|
305
|
-
(account) =>
|
|
306
|
-
account.importSource === "pi-openai-codex" &&
|
|
307
|
-
account.importMode === "synthetic",
|
|
308
|
-
);
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
private findManagedImportedTarget(imported: {
|
|
312
|
-
identifier: string;
|
|
313
|
-
credentials: OAuthCredentials;
|
|
314
|
-
}): Account | undefined {
|
|
315
|
-
const byRefreshToken = this.data.accounts.find(
|
|
316
|
-
(account) =>
|
|
317
|
-
account.importMode !== "synthetic" &&
|
|
318
|
-
account.refreshToken === imported.credentials.refresh,
|
|
319
|
-
);
|
|
320
|
-
if (byRefreshToken) {
|
|
321
|
-
return byRefreshToken;
|
|
228
|
+
/**
|
|
229
|
+
* Read pi's openai-codex auth from auth.json and expose it as a
|
|
230
|
+
* memory-only ephemeral account. Never persists to codex-accounts.json.
|
|
231
|
+
* If the identity already exists as a managed account, skip it.
|
|
232
|
+
*/
|
|
233
|
+
async loadPiAuth(): Promise<void> {
|
|
234
|
+
const imported = await loadImportedOpenAICodexAuth();
|
|
235
|
+
if (!imported) {
|
|
236
|
+
this.piAuthAccount = undefined;
|
|
237
|
+
this.notifyStateChanged();
|
|
238
|
+
return;
|
|
322
239
|
}
|
|
323
240
|
|
|
324
|
-
|
|
325
|
-
(
|
|
326
|
-
account.importMode !== "synthetic" &&
|
|
327
|
-
account.email === imported.identifier,
|
|
241
|
+
const alreadyManaged = this.data.accounts.find(
|
|
242
|
+
(a) => a.email === imported.identifier,
|
|
328
243
|
);
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
private clearImportedLink(account: Account): boolean {
|
|
332
|
-
let changed = false;
|
|
333
|
-
if (account.importSource) {
|
|
334
|
-
account.importSource = undefined;
|
|
335
|
-
changed = true;
|
|
336
|
-
}
|
|
337
|
-
if (account.importMode) {
|
|
338
|
-
account.importMode = undefined;
|
|
339
|
-
changed = true;
|
|
340
|
-
}
|
|
341
|
-
if (account.importFingerprint) {
|
|
342
|
-
account.importFingerprint = undefined;
|
|
343
|
-
changed = true;
|
|
344
|
-
}
|
|
345
|
-
return changed;
|
|
346
|
-
}
|
|
347
244
|
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
if (!account) return false;
|
|
351
|
-
const changed = this.clearImportedLink(account);
|
|
352
|
-
if (changed) {
|
|
353
|
-
this.save();
|
|
245
|
+
if (alreadyManaged) {
|
|
246
|
+
this.piAuthAccount = undefined;
|
|
354
247
|
this.notifyStateChanged();
|
|
355
|
-
|
|
356
|
-
return changed;
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
async syncImportedOpenAICodexAuth(): Promise<boolean> {
|
|
360
|
-
const imported = await loadImportedOpenAICodexAuth();
|
|
361
|
-
if (!imported) return false;
|
|
362
|
-
|
|
363
|
-
const linkedImported = this.getLinkedImportedAccount();
|
|
364
|
-
const syntheticImported = this.getSyntheticImportedAccount();
|
|
365
|
-
const currentImported = linkedImported ?? syntheticImported;
|
|
366
|
-
if (
|
|
367
|
-
currentImported?.importFingerprint === imported.fingerprint &&
|
|
368
|
-
(currentImported.importMode !== "synthetic" ||
|
|
369
|
-
currentImported.email === imported.identifier)
|
|
370
|
-
) {
|
|
371
|
-
return false;
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
const matchingAccount = this.findManagedImportedTarget(imported);
|
|
375
|
-
if (matchingAccount) {
|
|
376
|
-
let changed = this.applyCredentials(
|
|
377
|
-
matchingAccount,
|
|
378
|
-
imported.credentials,
|
|
379
|
-
{
|
|
380
|
-
importSource: "pi-openai-codex",
|
|
381
|
-
importMode: "linked",
|
|
382
|
-
importFingerprint: imported.fingerprint,
|
|
383
|
-
},
|
|
384
|
-
);
|
|
385
|
-
if (linkedImported && linkedImported !== matchingAccount) {
|
|
386
|
-
changed = this.clearImportedLink(linkedImported) || changed;
|
|
387
|
-
}
|
|
388
|
-
if (syntheticImported) {
|
|
389
|
-
changed = this.removeAccountRecord(syntheticImported) || changed;
|
|
390
|
-
}
|
|
391
|
-
if (changed) {
|
|
392
|
-
this.save();
|
|
393
|
-
this.notifyStateChanged();
|
|
394
|
-
}
|
|
395
|
-
return changed;
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
if (linkedImported) {
|
|
399
|
-
const changed = this.clearImportedLink(linkedImported);
|
|
400
|
-
if (changed) {
|
|
401
|
-
this.save();
|
|
402
|
-
this.notifyStateChanged();
|
|
403
|
-
}
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
if (syntheticImported) {
|
|
407
|
-
let changed = false;
|
|
408
|
-
if (syntheticImported.email !== imported.identifier) {
|
|
409
|
-
changed = this.updateAccountEmail(
|
|
410
|
-
syntheticImported,
|
|
411
|
-
imported.identifier,
|
|
412
|
-
);
|
|
413
|
-
}
|
|
414
|
-
changed =
|
|
415
|
-
this.applyCredentials(syntheticImported, imported.credentials, {
|
|
416
|
-
importSource: "pi-openai-codex",
|
|
417
|
-
importMode: "synthetic",
|
|
418
|
-
importFingerprint: imported.fingerprint,
|
|
419
|
-
}) || changed;
|
|
420
|
-
if (changed) {
|
|
421
|
-
this.save();
|
|
422
|
-
this.notifyStateChanged();
|
|
423
|
-
}
|
|
424
|
-
return changed;
|
|
248
|
+
return;
|
|
425
249
|
}
|
|
426
250
|
|
|
427
|
-
this.
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
251
|
+
this.piAuthAccount = {
|
|
252
|
+
email: imported.identifier,
|
|
253
|
+
accessToken: imported.credentials.access,
|
|
254
|
+
refreshToken: imported.credentials.refresh,
|
|
255
|
+
expiresAt: imported.credentials.expires,
|
|
256
|
+
accountId:
|
|
257
|
+
typeof imported.credentials.accountId === "string"
|
|
258
|
+
? imported.credentials.accountId
|
|
259
|
+
: undefined,
|
|
260
|
+
};
|
|
261
|
+
this.notifyStateChanged();
|
|
434
262
|
}
|
|
435
263
|
|
|
436
264
|
getAvailableManualAccount(options?: {
|
|
@@ -449,21 +277,29 @@ export class AccountManager {
|
|
|
449
277
|
const account = this.getAccount(email);
|
|
450
278
|
if (account) {
|
|
451
279
|
account.quotaExhaustedUntil = until;
|
|
452
|
-
this.
|
|
280
|
+
if (!this.isPiAuthAccount(account)) {
|
|
281
|
+
this.save();
|
|
282
|
+
}
|
|
453
283
|
this.notifyStateChanged();
|
|
454
284
|
}
|
|
455
285
|
}
|
|
456
286
|
|
|
457
287
|
clearAllQuotaExhaustion(): number {
|
|
458
288
|
let cleared = 0;
|
|
459
|
-
|
|
289
|
+
let managedChanged = false;
|
|
290
|
+
for (const account of this.getAccounts()) {
|
|
460
291
|
if (account.quotaExhaustedUntil) {
|
|
461
292
|
account.quotaExhaustedUntil = undefined;
|
|
462
293
|
cleared += 1;
|
|
294
|
+
if (!this.isPiAuthAccount(account)) {
|
|
295
|
+
managedChanged = true;
|
|
296
|
+
}
|
|
463
297
|
}
|
|
464
298
|
}
|
|
465
|
-
if (
|
|
299
|
+
if (managedChanged) {
|
|
466
300
|
this.save();
|
|
301
|
+
}
|
|
302
|
+
if (cleared > 0) {
|
|
467
303
|
this.notifyStateChanged();
|
|
468
304
|
}
|
|
469
305
|
return cleared;
|
|
@@ -489,7 +325,9 @@ export class AccountManager {
|
|
|
489
325
|
|
|
490
326
|
private markNeedsReauth(account: Account): void {
|
|
491
327
|
account.needsReauth = true;
|
|
492
|
-
this.
|
|
328
|
+
if (!this.isPiAuthAccount(account)) {
|
|
329
|
+
this.save();
|
|
330
|
+
}
|
|
493
331
|
this.notifyStateChanged();
|
|
494
332
|
}
|
|
495
333
|
|
|
@@ -561,7 +399,7 @@ export class AccountManager {
|
|
|
561
399
|
}): Promise<Account | undefined> {
|
|
562
400
|
const now = Date.now();
|
|
563
401
|
this.clearExpiredExhaustion(now);
|
|
564
|
-
const accounts = this.
|
|
402
|
+
const accounts = this.getAccounts();
|
|
565
403
|
await this.refreshUsageIfStale(accounts, options);
|
|
566
404
|
|
|
567
405
|
const selected = pickBestAccount(accounts, this.usageCache, {
|
|
@@ -569,7 +407,14 @@ export class AccountManager {
|
|
|
569
407
|
now,
|
|
570
408
|
});
|
|
571
409
|
if (selected) {
|
|
572
|
-
this.
|
|
410
|
+
if (this.isPiAuthAccount(selected)) {
|
|
411
|
+
// Don't persist ephemeral pi auth email to disk — it would
|
|
412
|
+
// become a stale activeEmail after restart.
|
|
413
|
+
this.data.activeEmail = selected.email;
|
|
414
|
+
this.notifyStateChanged();
|
|
415
|
+
} else {
|
|
416
|
+
this.setActiveAccount(selected.email);
|
|
417
|
+
}
|
|
573
418
|
}
|
|
574
419
|
return selected;
|
|
575
420
|
}
|
|
@@ -590,22 +435,28 @@ export class AccountManager {
|
|
|
590
435
|
}
|
|
591
436
|
|
|
592
437
|
private clearExpiredExhaustion(now: number): void {
|
|
593
|
-
let
|
|
594
|
-
|
|
438
|
+
let managedChanged = false;
|
|
439
|
+
let anyChanged = false;
|
|
440
|
+
for (const account of this.getAccounts()) {
|
|
595
441
|
if (account.quotaExhaustedUntil && account.quotaExhaustedUntil <= now) {
|
|
596
442
|
account.quotaExhaustedUntil = undefined;
|
|
597
|
-
|
|
443
|
+
anyChanged = true;
|
|
444
|
+
if (!this.isPiAuthAccount(account)) {
|
|
445
|
+
managedChanged = true;
|
|
446
|
+
}
|
|
598
447
|
}
|
|
599
448
|
}
|
|
600
|
-
if (
|
|
449
|
+
if (managedChanged) {
|
|
601
450
|
this.save();
|
|
451
|
+
}
|
|
452
|
+
if (anyChanged) {
|
|
602
453
|
this.notifyStateChanged();
|
|
603
454
|
}
|
|
604
455
|
}
|
|
605
456
|
|
|
606
457
|
async ensureValidToken(account: Account): Promise<string> {
|
|
607
458
|
if (account.needsReauth) {
|
|
608
|
-
const hint = account
|
|
459
|
+
const hint = this.isPiAuthAccount(account)
|
|
609
460
|
? "/login openai-codex"
|
|
610
461
|
: `/multicodex use ${account.email}`;
|
|
611
462
|
throw new Error(
|
|
@@ -617,10 +468,8 @@ export class AccountManager {
|
|
|
617
468
|
return account.accessToken;
|
|
618
469
|
}
|
|
619
470
|
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
if (account.importSource === "pi-openai-codex") {
|
|
623
|
-
return this.ensureValidTokenForImportedAccount(account);
|
|
471
|
+
if (this.isPiAuthAccount(account)) {
|
|
472
|
+
return this.ensureValidTokenForPiAuth(account);
|
|
624
473
|
}
|
|
625
474
|
|
|
626
475
|
const inflight = this.refreshPromises.get(account.email);
|
|
@@ -655,20 +504,15 @@ export class AccountManager {
|
|
|
655
504
|
}
|
|
656
505
|
|
|
657
506
|
/**
|
|
658
|
-
* Read-only
|
|
659
|
-
*
|
|
660
|
-
* MultiCodex may read auth.json to mirror pi's currently active Codex auth,
|
|
661
|
-
* but it must never refresh or write auth.json itself.
|
|
507
|
+
* Read-only refresh for the ephemeral pi auth account.
|
|
508
|
+
* Re-reads auth.json for fresh tokens. Never writes anything.
|
|
662
509
|
*/
|
|
663
|
-
private async
|
|
664
|
-
account: Account,
|
|
665
|
-
): Promise<string> {
|
|
510
|
+
private async ensureValidTokenForPiAuth(account: Account): Promise<string> {
|
|
666
511
|
const latest = await loadImportedOpenAICodexAuth();
|
|
667
512
|
if (latest && Date.now() < latest.credentials.expires - 5 * 60 * 1000) {
|
|
668
513
|
account.accessToken = latest.credentials.access;
|
|
669
514
|
account.refreshToken = latest.credentials.refresh;
|
|
670
515
|
account.expiresAt = latest.credentials.expires;
|
|
671
|
-
account.importFingerprint = latest.fingerprint;
|
|
672
516
|
const accountId =
|
|
673
517
|
typeof latest.credentials.accountId === "string"
|
|
674
518
|
? latest.credentials.accountId
|
|
@@ -676,14 +520,14 @@ export class AccountManager {
|
|
|
676
520
|
if (accountId) {
|
|
677
521
|
account.accountId = accountId;
|
|
678
522
|
}
|
|
679
|
-
this.save();
|
|
680
523
|
this.notifyStateChanged();
|
|
681
524
|
return account.accessToken;
|
|
682
525
|
}
|
|
683
526
|
|
|
684
|
-
this.
|
|
527
|
+
this.piAuthAccount = undefined;
|
|
528
|
+
this.notifyStateChanged();
|
|
685
529
|
throw new Error(
|
|
686
|
-
`${account.email}:
|
|
530
|
+
`${account.email}: pi auth expired — run /login openai-codex`,
|
|
687
531
|
);
|
|
688
532
|
}
|
|
689
533
|
}
|
package/commands.ts
CHANGED
|
@@ -102,19 +102,14 @@ function getAccountTags(
|
|
|
102
102
|
const manual = accountManager.getManualAccount();
|
|
103
103
|
const quotaHit =
|
|
104
104
|
account.quotaExhaustedUntil && account.quotaExhaustedUntil > Date.now();
|
|
105
|
-
const imported = account.importSource
|
|
106
|
-
? account.importMode === "synthetic"
|
|
107
|
-
? "pi auth only"
|
|
108
|
-
: "pi auth"
|
|
109
|
-
: null;
|
|
110
105
|
return [
|
|
111
106
|
active?.email === account.email ? "active" : null,
|
|
112
107
|
manual?.email === account.email ? "manual" : null,
|
|
108
|
+
accountManager.isPiAuthAccount(account) ? "pi auth" : null,
|
|
113
109
|
account.needsReauth ? "needs reauth" : null,
|
|
114
110
|
isPlaceholderAccount(account) ? "placeholder" : null,
|
|
115
111
|
quotaHit ? "quota" : null,
|
|
116
112
|
isUsageUntouched(usage) ? "untouched" : null,
|
|
117
|
-
imported,
|
|
118
113
|
].filter((value): value is string => Boolean(value));
|
|
119
114
|
}
|
|
120
115
|
|
|
@@ -245,11 +240,7 @@ async function loginAndActivateAccount(
|
|
|
245
240
|
onPrompt: async ({ message }) => (await ctx.ui.input(message)) || "",
|
|
246
241
|
});
|
|
247
242
|
|
|
248
|
-
const existing = accountManager.getAccount(identifier);
|
|
249
243
|
const account = accountManager.addOrUpdateAccount(identifier, creds);
|
|
250
|
-
if (existing?.importSource) {
|
|
251
|
-
accountManager.detachImportedAuth(account.email);
|
|
252
|
-
}
|
|
253
244
|
accountManager.setManualAccount(account.email);
|
|
254
245
|
ctx.ui.notify(`Now using ${account.email}`, "info");
|
|
255
246
|
return account.email;
|
|
@@ -659,7 +650,6 @@ async function runAccountsSubcommand(
|
|
|
659
650
|
statusController: ReturnType<typeof createUsageStatusController>,
|
|
660
651
|
rest: string,
|
|
661
652
|
): Promise<void> {
|
|
662
|
-
await accountManager.syncImportedOpenAICodexAuth();
|
|
663
653
|
await accountManager.refreshUsageForAllAccounts();
|
|
664
654
|
|
|
665
655
|
if (rest) {
|
|
@@ -722,7 +712,7 @@ async function runRotationSubcommand(
|
|
|
722
712
|
"Current policy: manual account first, then untouched accounts, then earliest weekly reset, then random fallback.",
|
|
723
713
|
"If token validation fails before a request starts, MultiCodex skips that account and retries another one.",
|
|
724
714
|
"If a request hits quota or rate limit before any output streams, MultiCodex marks the account on cooldown and retries.",
|
|
725
|
-
"
|
|
715
|
+
"If pi auth is active, it participates in rotation as an ephemeral account without being persisted.",
|
|
726
716
|
];
|
|
727
717
|
|
|
728
718
|
if (!ctx.hasUI) {
|
|
@@ -751,8 +741,10 @@ async function runVerifySubcommand(
|
|
|
751
741
|
): Promise<void> {
|
|
752
742
|
const storageWritable = await isWritableDirectoryFor(STORAGE_FILE);
|
|
753
743
|
const settingsWritable = await isWritableDirectoryFor(SETTINGS_FILE);
|
|
754
|
-
const authImported = await accountManager.syncImportedOpenAICodexAuth();
|
|
755
744
|
await statusController.loadPreferences(ctx);
|
|
745
|
+
const hasPiAuth = accountManager
|
|
746
|
+
.getAccounts()
|
|
747
|
+
.some((a) => accountManager.isPiAuthAccount(a));
|
|
756
748
|
const accounts = accountManager.getAccounts().length;
|
|
757
749
|
const active = accountManager.getActiveAccount()?.email ?? "none";
|
|
758
750
|
const needsReauth = accountManager.getAccountsNeedingReauth().length;
|
|
@@ -760,7 +752,7 @@ async function runVerifySubcommand(
|
|
|
760
752
|
|
|
761
753
|
if (!ctx.hasUI) {
|
|
762
754
|
ctx.ui.notify(
|
|
763
|
-
`verify: ${ok ? "PASS" : "WARN"} storage=${storageWritable ? "ok" : "fail"} settings=${settingsWritable ? "ok" : "fail"} accounts=${accounts} active=${active}
|
|
755
|
+
`verify: ${ok ? "PASS" : "WARN"} storage=${storageWritable ? "ok" : "fail"} settings=${settingsWritable ? "ok" : "fail"} accounts=${accounts} active=${active} piAuth=${hasPiAuth ? "loaded" : "none"} needsReauth=${needsReauth}`,
|
|
764
756
|
ok ? "info" : "warning",
|
|
765
757
|
);
|
|
766
758
|
return;
|
|
@@ -771,8 +763,8 @@ async function runVerifySubcommand(
|
|
|
771
763
|
`settings directory writable: ${settingsWritable ? "yes" : "no"}`,
|
|
772
764
|
`managed accounts: ${accounts}`,
|
|
773
765
|
`active account: ${active}`,
|
|
766
|
+
`pi auth (ephemeral): ${hasPiAuth ? "loaded" : "none"}`,
|
|
774
767
|
`accounts needing re-authentication: ${needsReauth}`,
|
|
775
|
-
`auth import changed state: ${authImported ? "yes" : "no"}`,
|
|
776
768
|
];
|
|
777
769
|
await ctx.ui.select(`MultiCodex Verify (${ok ? "PASS" : "WARN"})`, lines);
|
|
778
770
|
}
|
|
@@ -871,7 +863,6 @@ async function runRefreshSubcommand(
|
|
|
871
863
|
statusController: ReturnType<typeof createUsageStatusController>,
|
|
872
864
|
rest: string,
|
|
873
865
|
): Promise<void> {
|
|
874
|
-
await accountManager.syncImportedOpenAICodexAuth();
|
|
875
866
|
if (!rest || rest === "all") {
|
|
876
867
|
if (!ctx.hasUI || rest === "all") {
|
|
877
868
|
await refreshAllAccounts(ctx, accountManager);
|
|
@@ -892,7 +883,6 @@ async function runReauthSubcommand(
|
|
|
892
883
|
statusController: ReturnType<typeof createUsageStatusController>,
|
|
893
884
|
rest: string,
|
|
894
885
|
): Promise<void> {
|
|
895
|
-
await accountManager.syncImportedOpenAICodexAuth();
|
|
896
886
|
if (rest) {
|
|
897
887
|
await reauthenticateAccount(pi, ctx, accountManager, rest);
|
|
898
888
|
await statusController.refreshFor(ctx);
|
package/hooks.ts
CHANGED
|
@@ -6,28 +6,33 @@ async function refreshAndActivateBestAccount(
|
|
|
6
6
|
accountManager: AccountManager,
|
|
7
7
|
warningHandler?: WarningHandler,
|
|
8
8
|
): Promise<void> {
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
accountManager.beginInitialization();
|
|
10
|
+
try {
|
|
11
|
+
await accountManager.loadPiAuth();
|
|
12
|
+
await accountManager.refreshUsageForAllAccounts({ force: true });
|
|
11
13
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
14
|
+
const needsReauth = accountManager.getAccountsNeedingReauth();
|
|
15
|
+
if (needsReauth.length > 0) {
|
|
16
|
+
const hints = needsReauth.map((a) => {
|
|
17
|
+
const cmd = accountManager.isPiAuthAccount(a)
|
|
18
|
+
? "/login openai-codex"
|
|
19
|
+
: `/multicodex use ${a.email}`;
|
|
20
|
+
return `${a.email} (${cmd})`;
|
|
21
|
+
});
|
|
22
|
+
warningHandler?.(
|
|
23
|
+
`Multicodex: ${needsReauth.length} account(s) need re-authentication: ${hints.join(", ")}`,
|
|
24
|
+
);
|
|
25
|
+
}
|
|
24
26
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
const manual = accountManager.getAvailableManualAccount();
|
|
28
|
+
if (manual) return;
|
|
29
|
+
if (accountManager.hasManualAccount()) {
|
|
30
|
+
accountManager.clearManualAccount();
|
|
31
|
+
}
|
|
32
|
+
await accountManager.activateBestAccount();
|
|
33
|
+
} finally {
|
|
34
|
+
accountManager.markReady();
|
|
29
35
|
}
|
|
30
|
-
await accountManager.activateBestAccount();
|
|
31
36
|
}
|
|
32
37
|
|
|
33
38
|
export function handleSessionStart(
|
|
@@ -35,12 +40,12 @@ export function handleSessionStart(
|
|
|
35
40
|
warningHandler?: WarningHandler,
|
|
36
41
|
): void {
|
|
37
42
|
if (accountManager.getAccounts().length === 0) return;
|
|
38
|
-
|
|
43
|
+
refreshAndActivateBestAccount(accountManager, warningHandler).catch(() => {});
|
|
39
44
|
}
|
|
40
45
|
|
|
41
46
|
export function handleNewSessionSwitch(
|
|
42
47
|
accountManager: AccountManager,
|
|
43
48
|
warningHandler?: WarningHandler,
|
|
44
49
|
): void {
|
|
45
|
-
|
|
50
|
+
refreshAndActivateBestAccount(accountManager, warningHandler).catch(() => {});
|
|
46
51
|
}
|
package/index.ts
CHANGED
|
@@ -1,8 +1,5 @@
|
|
|
1
1
|
export { AccountManager } from "./account-manager";
|
|
2
|
-
export {
|
|
3
|
-
loadImportedOpenAICodexAuth,
|
|
4
|
-
parseImportedOpenAICodexAuth,
|
|
5
|
-
} from "./auth";
|
|
2
|
+
export { parseImportedOpenAICodexAuth } from "./auth";
|
|
6
3
|
export { default } from "./extension";
|
|
7
4
|
export {
|
|
8
5
|
buildMulticodexProviderConfig,
|
|
@@ -25,6 +22,7 @@ export { createStreamWrapper } from "./stream-wrapper";
|
|
|
25
22
|
export type { CodexUsageSnapshot } from "./usage";
|
|
26
23
|
export {
|
|
27
24
|
formatResetAt,
|
|
25
|
+
getMaxUsedPercent,
|
|
28
26
|
getNextResetAt,
|
|
29
27
|
getWeeklyResetAt,
|
|
30
28
|
isUsageUntouched,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@victor-software-house/pi-multicodex",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.1",
|
|
4
4
|
"description": "Codex account rotation extension for pi",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -48,12 +48,14 @@
|
|
|
48
48
|
"usage.ts",
|
|
49
49
|
"README.md",
|
|
50
50
|
"LICENSE",
|
|
51
|
-
"assets/**"
|
|
51
|
+
"assets/**",
|
|
52
|
+
"schemas/**"
|
|
52
53
|
],
|
|
53
54
|
"scripts": {
|
|
54
55
|
"lint": "biome check .",
|
|
55
56
|
"test": "vitest run -c vitest.config.ts",
|
|
56
57
|
"tsgo": "tsgo -p tsconfig.json",
|
|
58
|
+
"generate:schema": "bun scripts/generate-schema.ts",
|
|
57
59
|
"check": "pnpm lint && pnpm tsgo && pnpm test",
|
|
58
60
|
"pack:dry": "npm pack --dry-run",
|
|
59
61
|
"release:dry": "pnpm exec semantic-release --dry-run"
|
|
@@ -98,6 +100,7 @@
|
|
|
98
100
|
"node": "24.14.0"
|
|
99
101
|
},
|
|
100
102
|
"dependencies": {
|
|
101
|
-
"pi-provider-utils": "^0.0.0"
|
|
103
|
+
"pi-provider-utils": "^0.0.0",
|
|
104
|
+
"zod": "^4.3.6"
|
|
102
105
|
}
|
|
103
106
|
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"type": "object",
|
|
4
|
+
"properties": {
|
|
5
|
+
"$schema": {
|
|
6
|
+
"description": "JSON Schema reference for editor support",
|
|
7
|
+
"type": "string"
|
|
8
|
+
},
|
|
9
|
+
"version": {
|
|
10
|
+
"type": "integer",
|
|
11
|
+
"exclusiveMinimum": 0,
|
|
12
|
+
"maximum": 9007199254740991,
|
|
13
|
+
"description": "Storage schema version"
|
|
14
|
+
},
|
|
15
|
+
"accounts": {
|
|
16
|
+
"type": "array",
|
|
17
|
+
"items": {
|
|
18
|
+
"$ref": "#/$defs/Account"
|
|
19
|
+
},
|
|
20
|
+
"description": "Managed account entries"
|
|
21
|
+
},
|
|
22
|
+
"activeEmail": {
|
|
23
|
+
"description": "Currently active account email",
|
|
24
|
+
"type": "string"
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"required": ["version", "accounts"],
|
|
28
|
+
"additionalProperties": false,
|
|
29
|
+
"id": "MultiCodexStorage",
|
|
30
|
+
"description": "MultiCodex managed account storage",
|
|
31
|
+
"$defs": {
|
|
32
|
+
"Account": {
|
|
33
|
+
"type": "object",
|
|
34
|
+
"properties": {
|
|
35
|
+
"email": {
|
|
36
|
+
"type": "string",
|
|
37
|
+
"minLength": 1,
|
|
38
|
+
"description": "Account email identifier"
|
|
39
|
+
},
|
|
40
|
+
"accessToken": {
|
|
41
|
+
"type": "string",
|
|
42
|
+
"minLength": 1,
|
|
43
|
+
"description": "OAuth access token (JWT)"
|
|
44
|
+
},
|
|
45
|
+
"refreshToken": {
|
|
46
|
+
"type": "string",
|
|
47
|
+
"minLength": 1,
|
|
48
|
+
"description": "OAuth refresh token"
|
|
49
|
+
},
|
|
50
|
+
"expiresAt": {
|
|
51
|
+
"type": "number",
|
|
52
|
+
"description": "Token expiry timestamp (ms since epoch)"
|
|
53
|
+
},
|
|
54
|
+
"accountId": {
|
|
55
|
+
"description": "OpenAI account ID",
|
|
56
|
+
"type": "string"
|
|
57
|
+
},
|
|
58
|
+
"lastUsed": {
|
|
59
|
+
"description": "Last manual selection timestamp (ms)",
|
|
60
|
+
"type": "number"
|
|
61
|
+
},
|
|
62
|
+
"quotaExhaustedUntil": {
|
|
63
|
+
"description": "Quota cooldown expiry (ms)",
|
|
64
|
+
"type": "number"
|
|
65
|
+
},
|
|
66
|
+
"needsReauth": {
|
|
67
|
+
"description": "Account needs re-authentication",
|
|
68
|
+
"type": "boolean"
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
"required": ["email", "accessToken", "refreshToken", "expiresAt"],
|
|
72
|
+
"additionalProperties": false,
|
|
73
|
+
"id": "Account",
|
|
74
|
+
"description": "A managed OpenAI Codex account"
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
package/selection.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Account } from "./storage";
|
|
2
2
|
import {
|
|
3
3
|
type CodexUsageSnapshot,
|
|
4
|
+
getMaxUsedPercent,
|
|
4
5
|
getWeeklyResetAt,
|
|
5
6
|
isUsageUntouched,
|
|
6
7
|
} from "./usage";
|
|
@@ -15,20 +16,26 @@ function pickRandomAccount(accounts: Account[]): Account | undefined {
|
|
|
15
16
|
return accounts[Math.floor(Math.random() * accounts.length)];
|
|
16
17
|
}
|
|
17
18
|
|
|
18
|
-
function
|
|
19
|
+
function pickLowestUsageAccount(
|
|
19
20
|
accounts: Account[],
|
|
20
21
|
usageByEmail: Map<string, CodexUsageSnapshot>,
|
|
21
22
|
): Account | undefined {
|
|
22
23
|
const candidates = accounts
|
|
23
|
-
.map((account) =>
|
|
24
|
-
account
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
)
|
|
31
|
-
.sort((a, b) =>
|
|
24
|
+
.map((account) => {
|
|
25
|
+
const usage = usageByEmail.get(account.email);
|
|
26
|
+
return {
|
|
27
|
+
account,
|
|
28
|
+
usedPercent: getMaxUsedPercent(usage) ?? 100,
|
|
29
|
+
resetAt: getWeeklyResetAt(usage) ?? Number.MAX_SAFE_INTEGER,
|
|
30
|
+
};
|
|
31
|
+
})
|
|
32
|
+
.sort((a, b) => {
|
|
33
|
+
// Primary: lowest usage first
|
|
34
|
+
const usageDiff = a.usedPercent - b.usedPercent;
|
|
35
|
+
if (usageDiff !== 0) return usageDiff;
|
|
36
|
+
// Tiebreaker: earliest weekly reset first
|
|
37
|
+
return a.resetAt - b.resetAt;
|
|
38
|
+
});
|
|
32
39
|
|
|
33
40
|
return candidates[0]?.account;
|
|
34
41
|
}
|
|
@@ -55,16 +62,13 @@ export function pickBestAccount(
|
|
|
55
62
|
|
|
56
63
|
if (untouched.length > 0) {
|
|
57
64
|
return (
|
|
58
|
-
|
|
65
|
+
pickLowestUsageAccount(untouched, usageByEmail) ??
|
|
59
66
|
pickRandomAccount(untouched)
|
|
60
67
|
);
|
|
61
68
|
}
|
|
62
69
|
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
usageByEmail,
|
|
66
|
-
);
|
|
67
|
-
if (earliestWeeklyReset) return earliestWeeklyReset;
|
|
70
|
+
const lowestUsage = pickLowestUsageAccount(withUsage, usageByEmail);
|
|
71
|
+
if (lowestUsage) return lowestUsage;
|
|
68
72
|
|
|
69
73
|
return pickRandomAccount(available);
|
|
70
74
|
}
|
package/status.ts
CHANGED
|
@@ -411,11 +411,7 @@ export function createUsageStatusController(accountManager: AccountManager) {
|
|
|
411
411
|
|
|
412
412
|
renderCachedStatus(ctx, livePreviewPreferences ?? preferences);
|
|
413
413
|
|
|
414
|
-
|
|
415
|
-
if (!activeAccount) {
|
|
416
|
-
await accountManager.syncImportedOpenAICodexAuth();
|
|
417
|
-
activeAccount = accountManager.getActiveAccount();
|
|
418
|
-
}
|
|
414
|
+
const activeAccount = accountManager.getActiveAccount();
|
|
419
415
|
if (!activeAccount) {
|
|
420
416
|
ctx.ui.setStatus(
|
|
421
417
|
STATUS_KEY,
|
package/storage.ts
CHANGED
|
@@ -1,38 +1,169 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import { getAgentPath } from "pi-provider-utils/agent-paths";
|
|
4
|
+
import { z } from "zod";
|
|
4
5
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Schema
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
const CURRENT_VERSION = 1;
|
|
11
|
+
|
|
12
|
+
const SCHEMA_URL =
|
|
13
|
+
"https://raw.githubusercontent.com/victor-software-house/pi-multicodex/main/schemas/codex-accounts.schema.json";
|
|
14
|
+
|
|
15
|
+
const AccountSchema = z
|
|
16
|
+
.object({
|
|
17
|
+
email: z.string().min(1).meta({ description: "Account email identifier" }),
|
|
18
|
+
accessToken: z
|
|
19
|
+
.string()
|
|
20
|
+
.min(1)
|
|
21
|
+
.meta({ description: "OAuth access token (JWT)" }),
|
|
22
|
+
refreshToken: z
|
|
23
|
+
.string()
|
|
24
|
+
.min(1)
|
|
25
|
+
.meta({ description: "OAuth refresh token" }),
|
|
26
|
+
expiresAt: z
|
|
27
|
+
.number()
|
|
28
|
+
.meta({ description: "Token expiry timestamp (ms since epoch)" }),
|
|
29
|
+
accountId: z.string().optional().meta({ description: "OpenAI account ID" }),
|
|
30
|
+
lastUsed: z
|
|
31
|
+
.number()
|
|
32
|
+
.optional()
|
|
33
|
+
.meta({ description: "Last manual selection timestamp (ms)" }),
|
|
34
|
+
quotaExhaustedUntil: z
|
|
35
|
+
.number()
|
|
36
|
+
.optional()
|
|
37
|
+
.meta({ description: "Quota cooldown expiry (ms)" }),
|
|
38
|
+
needsReauth: z
|
|
39
|
+
.boolean()
|
|
40
|
+
.optional()
|
|
41
|
+
.meta({ description: "Account needs re-authentication" }),
|
|
42
|
+
})
|
|
43
|
+
.meta({ id: "Account", description: "A managed OpenAI Codex account" });
|
|
44
|
+
|
|
45
|
+
export const StorageSchema = z
|
|
46
|
+
.object({
|
|
47
|
+
$schema: z
|
|
48
|
+
.string()
|
|
49
|
+
.optional()
|
|
50
|
+
.meta({ description: "JSON Schema reference for editor support" }),
|
|
51
|
+
version: z
|
|
52
|
+
.number()
|
|
53
|
+
.int()
|
|
54
|
+
.positive()
|
|
55
|
+
.meta({ description: "Storage schema version" }),
|
|
56
|
+
accounts: z
|
|
57
|
+
.array(AccountSchema)
|
|
58
|
+
.meta({ description: "Managed account entries" }),
|
|
59
|
+
activeEmail: z
|
|
60
|
+
.string()
|
|
61
|
+
.optional()
|
|
62
|
+
.meta({ description: "Currently active account email" }),
|
|
63
|
+
})
|
|
64
|
+
.meta({
|
|
65
|
+
id: "MultiCodexStorage",
|
|
66
|
+
description: "MultiCodex managed account storage",
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
export type Account = z.infer<typeof AccountSchema>;
|
|
70
|
+
export type StorageData = z.infer<typeof StorageSchema>;
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// Migration
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
const LEGACY_FIELDS = [
|
|
77
|
+
"importSource",
|
|
78
|
+
"importMode",
|
|
79
|
+
"importFingerprint",
|
|
80
|
+
] as const;
|
|
81
|
+
|
|
82
|
+
function stripLegacyFields(raw: Record<string, unknown>): boolean {
|
|
83
|
+
let stripped = false;
|
|
84
|
+
for (const key of LEGACY_FIELDS) {
|
|
85
|
+
if (key in raw) {
|
|
86
|
+
delete raw[key];
|
|
87
|
+
stripped = true;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return stripped;
|
|
17
91
|
}
|
|
18
92
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
93
|
+
function migrateRawStorage(raw: unknown): StorageData {
|
|
94
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
95
|
+
return { version: CURRENT_VERSION, accounts: [], activeEmail: undefined };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const record = raw as Record<string, unknown>;
|
|
99
|
+
|
|
100
|
+
// Strip legacy import fields from each account
|
|
101
|
+
const rawAccounts = Array.isArray(record.accounts) ? record.accounts : [];
|
|
102
|
+
for (const entry of rawAccounts) {
|
|
103
|
+
if (entry && typeof entry === "object" && !Array.isArray(entry)) {
|
|
104
|
+
stripLegacyFields(entry as Record<string, unknown>);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Add version if missing (pre-v1 files)
|
|
109
|
+
if (!("version" in record) || typeof record.version !== "number") {
|
|
110
|
+
record.version = CURRENT_VERSION;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const result = StorageSchema.safeParse(record);
|
|
114
|
+
if (result.success) {
|
|
115
|
+
return result.data;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Schema validation failed — salvage what we can
|
|
119
|
+
const accounts: Account[] = [];
|
|
120
|
+
for (const entry of rawAccounts) {
|
|
121
|
+
const parsed = AccountSchema.safeParse(entry);
|
|
122
|
+
if (parsed.success) {
|
|
123
|
+
accounts.push(parsed.data);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return { version: CURRENT_VERSION, accounts, activeEmail: undefined };
|
|
22
127
|
}
|
|
23
128
|
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
// I/O
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
|
|
24
133
|
export const STORAGE_FILE = getAgentPath("codex-accounts.json");
|
|
25
134
|
|
|
26
135
|
export function loadStorage(): StorageData {
|
|
27
136
|
try {
|
|
28
137
|
if (fs.existsSync(STORAGE_FILE)) {
|
|
29
|
-
|
|
138
|
+
const text = fs.readFileSync(STORAGE_FILE, "utf-8");
|
|
139
|
+
const raw = JSON.parse(text) as Record<string, unknown>;
|
|
140
|
+
const needsMigration =
|
|
141
|
+
!("version" in raw) ||
|
|
142
|
+
raw.version !== CURRENT_VERSION ||
|
|
143
|
+
needsLegacyStrip(raw);
|
|
144
|
+
const data = migrateRawStorage(raw);
|
|
145
|
+
if (needsMigration) {
|
|
146
|
+
saveStorage(data);
|
|
147
|
+
}
|
|
148
|
+
return data;
|
|
30
149
|
}
|
|
31
150
|
} catch (error) {
|
|
32
151
|
console.error("Failed to load multicodex accounts:", error);
|
|
33
152
|
}
|
|
34
153
|
|
|
35
|
-
return { accounts: [] };
|
|
154
|
+
return { version: CURRENT_VERSION, accounts: [], activeEmail: undefined };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function needsLegacyStrip(raw: Record<string, unknown>): boolean {
|
|
158
|
+
const accounts = Array.isArray(raw.accounts) ? raw.accounts : [];
|
|
159
|
+
for (const entry of accounts) {
|
|
160
|
+
if (entry && typeof entry === "object" && !Array.isArray(entry)) {
|
|
161
|
+
for (const key of LEGACY_FIELDS) {
|
|
162
|
+
if (key in (entry as Record<string, unknown>)) return true;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return false;
|
|
36
167
|
}
|
|
37
168
|
|
|
38
169
|
export function saveStorage(data: StorageData): void {
|
|
@@ -41,7 +172,13 @@ export function saveStorage(data: StorageData): void {
|
|
|
41
172
|
if (!fs.existsSync(dir)) {
|
|
42
173
|
fs.mkdirSync(dir, { recursive: true });
|
|
43
174
|
}
|
|
44
|
-
|
|
175
|
+
const output = {
|
|
176
|
+
$schema: SCHEMA_URL,
|
|
177
|
+
version: CURRENT_VERSION,
|
|
178
|
+
accounts: data.accounts,
|
|
179
|
+
activeEmail: data.activeEmail,
|
|
180
|
+
};
|
|
181
|
+
fs.writeFileSync(STORAGE_FILE, JSON.stringify(output, null, 2));
|
|
45
182
|
} catch (error) {
|
|
46
183
|
console.error("Failed to save multicodex accounts:", error);
|
|
47
184
|
}
|
package/stream-wrapper.ts
CHANGED
|
@@ -39,7 +39,7 @@ export function createStreamWrapper(
|
|
|
39
39
|
|
|
40
40
|
(async () => {
|
|
41
41
|
try {
|
|
42
|
-
await accountManager.
|
|
42
|
+
await accountManager.waitUntilReady();
|
|
43
43
|
const excludedEmails = new Set<string>();
|
|
44
44
|
for (let attempt = 0; attempt <= MAX_ROTATION_RETRIES; attempt++) {
|
|
45
45
|
const now = Date.now();
|
package/usage.ts
CHANGED
|
@@ -66,6 +66,17 @@ export function getNextResetAt(usage?: CodexUsageSnapshot): number | undefined {
|
|
|
66
66
|
return Math.min(...candidates);
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
export function getMaxUsedPercent(
|
|
70
|
+
usage?: CodexUsageSnapshot,
|
|
71
|
+
): number | undefined {
|
|
72
|
+
const candidates = [
|
|
73
|
+
usage?.primary?.usedPercent,
|
|
74
|
+
usage?.secondary?.usedPercent,
|
|
75
|
+
].filter((value): value is number => typeof value === "number");
|
|
76
|
+
if (candidates.length === 0) return undefined;
|
|
77
|
+
return Math.max(...candidates);
|
|
78
|
+
}
|
|
79
|
+
|
|
69
80
|
export function getWeeklyResetAt(
|
|
70
81
|
usage?: CodexUsageSnapshot,
|
|
71
82
|
): number | undefined {
|