@coseung2/opencodex 2.8.0-cs.12 → 2.8.0-cs.14
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/gui/dist/assets/index-MUpaVatk.js +67 -0
- package/gui/dist/index.html +1 -1
- package/package.json +3 -3
- package/packages/ocx-notch/README.md +3 -2
- package/src/adapters/cursor/discovery.ts +6 -2
- package/src/adapters/cursor/effort-map.ts +3 -0
- package/src/adapters/google-antigravity-replay.ts +24 -0
- package/src/adapters/google.ts +16 -11
- package/src/chat/inbound.ts +5 -11
- package/src/cli/account-api.ts +9 -1
- package/src/cli/account-extended.ts +4 -1
- package/src/codex/account-label.ts +14 -1
- package/src/codex/account-lifecycle.ts +12 -1
- package/src/codex/account-namespaces.ts +21 -0
- package/src/codex/account-priority.ts +49 -0
- package/src/codex/account-store.ts +73 -10
- package/src/codex/auth-api.ts +312 -45
- package/src/codex/auth-context.ts +61 -16
- package/src/codex/catalog/metadata.ts +34 -12
- package/src/codex/catalog/parsing.ts +8 -1
- package/src/codex/catalog/provider-fetch.ts +24 -6
- package/src/codex/catalog.ts +1 -1
- package/src/codex/pool-rotation.ts +51 -4
- package/src/codex/quota.ts +154 -35
- package/src/codex/routing.ts +133 -33
- package/src/codex/warmup.ts +204 -82
- package/src/config.ts +84 -1
- package/src/lib/bounded-body.ts +13 -6
- package/src/lib/bun-stream-caps.ts +5 -6
- package/src/lib/redact.ts +13 -0
- package/src/oauth/index.ts +79 -12
- package/src/oauth/log.ts +3 -1
- package/src/oauth/store.ts +31 -8
- package/src/oauth/token-guardian.ts +130 -9
- package/src/providers/antigravity-models.ts +53 -24
- package/src/providers/codex-capacity.ts +303 -0
- package/src/providers/model-rename-migration.ts +147 -0
- package/src/providers/model-rename-startup.ts +29 -0
- package/src/providers/quota.ts +126 -16
- package/src/providers/registry.ts +262 -38
- package/src/responses/parser.ts +19 -12
- package/src/responses/spill-store.ts +14 -1
- package/src/responses/state.ts +108 -14
- package/src/router.ts +6 -0
- package/src/server/index.ts +9 -1
- package/src/server/management/logs-usage-routes.ts +1 -0
- package/src/server/management/oauth-account-routes.ts +8 -1
- package/src/server/relay.ts +10 -42
- package/src/server/request-log.ts +42 -1
- package/src/server/responses/compact.ts +16 -4
- package/src/server/responses/core.ts +217 -59
- package/src/server/responses/empty-completion-guard.ts +275 -0
- package/src/server/responses/encrypted-payload.ts +54 -39
- package/src/server/responses/fetch-helpers.ts +24 -3
- package/src/server/responses/ws-upstream.ts +318 -0
- package/src/server/sse-frame-buffer.ts +292 -0
- package/src/server/ws-bridge.ts +17 -11
- package/src/types.ts +13 -1
- package/src/usage/log.ts +24 -0
- package/src/usage/summary.ts +152 -2
- package/vendor/ocx-notch/win32-x64/ocx-notch.exe +0 -0
- package/gui/dist/assets/index-GgF1IpQa.js +0 -67
package/src/codex/auth-api.ts
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
|
-
import { ConfigMutationLockError, loadConfig, mutatePersistedConfig, saveConfigPreservingClaudeCode } from "../config";
|
|
1
|
+
import { ConfigMutationLockError, loadConfig, mutatePersistedConfig, reconcileLiveConfigFromDisk, saveConfigPreservingClaudeCode } from "../config";
|
|
2
2
|
import { withCodexAccountLogLabel } from "./account-label";
|
|
3
3
|
import {
|
|
4
4
|
getCodexAccountCredential,
|
|
5
5
|
getValidCodexToken,
|
|
6
6
|
isCodexAccountGenerationLive,
|
|
7
|
+
clearCodexAccountQuotaPauseOwnership,
|
|
8
|
+
markCodexAccountQuotaValidationPending,
|
|
7
9
|
markCodexAccountValidated,
|
|
8
10
|
readCodexAccountRecord,
|
|
9
11
|
saveCodexAccountCredential,
|
|
12
|
+
tombstoneCodexAccountIfGeneration,
|
|
10
13
|
CodexCredentialGenerationConflictError,
|
|
11
14
|
CodexCredentialRefreshLockTimeoutError,
|
|
12
15
|
CodexCredentialRefreshBusyError,
|
|
@@ -14,19 +17,36 @@ import {
|
|
|
14
17
|
TokenRefreshError,
|
|
15
18
|
} from "./account-store";
|
|
16
19
|
import { deleteCodexAccount, reconcileMainCodexAccountRuntimeState } from "./account-lifecycle";
|
|
20
|
+
import {
|
|
21
|
+
appendDefaultCodexAccountNamespace,
|
|
22
|
+
initializeDefaultCodexAccountNamespaces,
|
|
23
|
+
} from "./account-namespaces";
|
|
17
24
|
import { isCodexAccountPaused, setCodexAccountPaused } from "./account-pause";
|
|
25
|
+
import {
|
|
26
|
+
clearCodexAccountPin,
|
|
27
|
+
getCodexAccountPriority,
|
|
28
|
+
isCodexAccountPriorityKey,
|
|
29
|
+
pinnedCodexAccountId,
|
|
30
|
+
setCodexAccountPin,
|
|
31
|
+
setCodexAccountPriority,
|
|
32
|
+
} from "./account-priority";
|
|
18
33
|
import {
|
|
19
34
|
clearCodexAccountCooldown,
|
|
20
35
|
clearThreadAccountMapForAccount,
|
|
21
36
|
getEffectiveActiveCodexAccountId,
|
|
37
|
+
isEffectiveCodexAccountPinned,
|
|
22
38
|
reconcileCodexActiveAfterExclusion,
|
|
23
39
|
resetCodexRoutingForManualSelection,
|
|
24
40
|
} from "./routing";
|
|
25
41
|
import {
|
|
42
|
+
DEFAULT_ACCOUNT_PRIORITY,
|
|
43
|
+
MAX_ACCOUNT_PRIORITY,
|
|
44
|
+
MIN_ACCOUNT_PRIORITY,
|
|
26
45
|
normalizeAccountPoolStickyLimit,
|
|
27
46
|
normalizeAccountPoolStrategy,
|
|
28
47
|
parseAccountPoolStickyLimit,
|
|
29
48
|
parseAccountPoolStrategy,
|
|
49
|
+
parseAccountPriority,
|
|
30
50
|
} from "./pool-rotation";
|
|
31
51
|
import { checkAccountIdCollision, getMainChatgptAccountId, readCodexTokens, readCodexTokensResult } from "./auth-collision";
|
|
32
52
|
export { checkAccountIdCollision, getMainChatgptAccountId } from "./auth-collision";
|
|
@@ -35,6 +55,7 @@ import { clearAccountNeedsReauth, isAccountNeedsReauth, markAccountNeedsReauth }
|
|
|
35
55
|
import {
|
|
36
56
|
clearAccountQuota,
|
|
37
57
|
getAccountQuota,
|
|
58
|
+
isCodexFiveHourQuotaPlan,
|
|
38
59
|
isCodexQuotaExhausted,
|
|
39
60
|
listAccountQuotas,
|
|
40
61
|
parseUsageQuota,
|
|
@@ -63,7 +84,7 @@ import {
|
|
|
63
84
|
} from "./main-account-cache";
|
|
64
85
|
export { clearMainAccountInfoCache } from "./main-account-cache";
|
|
65
86
|
import { maskEmail } from "../lib/privacy";
|
|
66
|
-
import {
|
|
87
|
+
import { codexWarmupFailureReason, isCodexWarmupQuotaFailure, warmCodexAccount } from "./warmup";
|
|
67
88
|
export { maskEmail } from "../lib/privacy";
|
|
68
89
|
import type { CodexAccount, OcxConfig } from "../types";
|
|
69
90
|
import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers";
|
|
@@ -83,6 +104,7 @@ import {
|
|
|
83
104
|
} from "./account-id";
|
|
84
105
|
import { codexAccountIdNamespaceCollisionError } from "./account-namespace-match";
|
|
85
106
|
import { ResourceAdmissionError } from "../lib/admission";
|
|
107
|
+
import { requestCodexQuotaWarmupRetry } from "../oauth/token-guardian";
|
|
86
108
|
|
|
87
109
|
function jsonResponse(data: unknown, status = 200): Response {
|
|
88
110
|
return new Response(JSON.stringify(data), {
|
|
@@ -165,6 +187,7 @@ function poolAccountDto(
|
|
|
165
187
|
quotaResult: PoolQuotaResult,
|
|
166
188
|
hasCredential: boolean,
|
|
167
189
|
paused: boolean,
|
|
190
|
+
priority: number,
|
|
168
191
|
): CodexAuthAccountDto {
|
|
169
192
|
const quota = quotaForPlan(quotaResult.quota, account.plan);
|
|
170
193
|
const needsReauth = !hasCredential || quotaResult.needsReauth || isAccountNeedsReauth(account.id);
|
|
@@ -177,6 +200,7 @@ function poolAccountDto(
|
|
|
177
200
|
...(account.logLabel !== undefined ? { logLabel: account.logLabel } : {}),
|
|
178
201
|
isMain: false,
|
|
179
202
|
paused,
|
|
203
|
+
priority,
|
|
180
204
|
quota: quota ? { ...quota } : null,
|
|
181
205
|
needsReauth,
|
|
182
206
|
hasCredential,
|
|
@@ -248,19 +272,17 @@ async function verifyCodexAccountWarmup(
|
|
|
248
272
|
accountId: string,
|
|
249
273
|
accessToken: string,
|
|
250
274
|
chatgptAccountId: string,
|
|
251
|
-
): Promise<{ ok: true; validatedAt: number } | { ok: false; response: Response }> {
|
|
275
|
+
): Promise<{ ok: true; validatedAt: number } | { ok: false; response: Response; error: unknown }> {
|
|
252
276
|
try {
|
|
253
277
|
await warmCodexAccount({ accessToken, chatgptAccountId });
|
|
254
278
|
return { ok: true, validatedAt: Date.now() };
|
|
255
279
|
} catch (err) {
|
|
256
280
|
const reason = codexWarmupFailureReason(err);
|
|
257
|
-
const upstream = err instanceof CodexWarmupError ? err.upstreamDetail : undefined;
|
|
258
281
|
return {
|
|
259
282
|
ok: false,
|
|
283
|
+
error: err,
|
|
260
284
|
response: jsonResponse({
|
|
261
|
-
error:
|
|
262
|
-
? `Codex account warmup failed: ${upstream}`
|
|
263
|
-
: "Codex account warmup failed. Reauthenticate the account and try again.",
|
|
285
|
+
error: "Codex account warmup failed. Reauthenticate the account and try again.",
|
|
264
286
|
code: "codex_warmup_failed",
|
|
265
287
|
reason,
|
|
266
288
|
accountId,
|
|
@@ -269,6 +291,67 @@ async function verifyCodexAccountWarmup(
|
|
|
269
291
|
}
|
|
270
292
|
}
|
|
271
293
|
|
|
294
|
+
interface FreshRegistrationQuota {
|
|
295
|
+
quota: Omit<StoredAccountQuota, "updatedAt">;
|
|
296
|
+
plan?: string;
|
|
297
|
+
email?: string;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async function fetchFreshRegistrationQuota(
|
|
301
|
+
accessToken: string,
|
|
302
|
+
chatgptAccountId: string,
|
|
303
|
+
configuredPlan?: string,
|
|
304
|
+
): Promise<FreshRegistrationQuota | null> {
|
|
305
|
+
try {
|
|
306
|
+
const resp = await fetch("https://chatgpt.com/backend-api/wham/usage", {
|
|
307
|
+
headers: { Authorization: `Bearer ${accessToken}`, "ChatGPT-Account-Id": chatgptAccountId },
|
|
308
|
+
signal: AbortSignal.timeout(8000),
|
|
309
|
+
});
|
|
310
|
+
if (!resp.ok) return null;
|
|
311
|
+
const data = (await resp.json()) as WhamUsageResponse;
|
|
312
|
+
const plan = nonEmptyPlan(data.plan_type) ?? nonEmptyPlan(configuredPlan) ?? undefined;
|
|
313
|
+
const quota = parseUsageQuota({ ...data, ...(plan ? { plan_type: plan } : {}) });
|
|
314
|
+
if (!quota) return null;
|
|
315
|
+
return {
|
|
316
|
+
quota,
|
|
317
|
+
...(plan ? { plan } : {}),
|
|
318
|
+
...(typeof data.email === "string" && data.email.trim() ? { email: data.email } : {}),
|
|
319
|
+
};
|
|
320
|
+
} catch {
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function exhaustedQuotaRetryAt(
|
|
326
|
+
quota: Omit<StoredAccountQuota, "updatedAt">,
|
|
327
|
+
plan?: string | null,
|
|
328
|
+
nowMs: number = Date.now(),
|
|
329
|
+
): number | undefined {
|
|
330
|
+
const monthlyOnly = isThirtyDayOnlyPlan(plan);
|
|
331
|
+
const resetSeconds: number[] = [];
|
|
332
|
+
if (!monthlyOnly && isCodexFiveHourQuotaPlan(plan)
|
|
333
|
+
&& quota.fiveHourPercent !== undefined && quota.fiveHourPercent >= 100
|
|
334
|
+
&& quota.fiveHourResetAt !== undefined) resetSeconds.push(quota.fiveHourResetAt);
|
|
335
|
+
if (!monthlyOnly && quota.weeklyPercent !== undefined && quota.weeklyPercent >= 100
|
|
336
|
+
&& quota.weeklyResetAt !== undefined) resetSeconds.push(quota.weeklyResetAt);
|
|
337
|
+
if (quota.monthlyPercent !== undefined && quota.monthlyPercent >= 100
|
|
338
|
+
&& quota.monthlyResetAt !== undefined) resetSeconds.push(quota.monthlyResetAt);
|
|
339
|
+
const retryAtMs = resetSeconds.length > 0 ? Math.max(...resetSeconds) * 1000 : undefined;
|
|
340
|
+
return retryAtMs !== undefined && Number.isFinite(retryAtMs) && retryAtMs > nowMs
|
|
341
|
+
? retryAtMs
|
|
342
|
+
: undefined;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function mayDeferQuotaWarmup(
|
|
346
|
+
warmup: { ok: false; error: unknown },
|
|
347
|
+
fresh: FreshRegistrationQuota | null,
|
|
348
|
+
): fresh is FreshRegistrationQuota {
|
|
349
|
+
return isCodexWarmupQuotaFailure(warmup.error)
|
|
350
|
+
&& fresh !== null
|
|
351
|
+
&& isCodexQuotaExhausted(fresh.quota, fresh.plan)
|
|
352
|
+
&& exhaustedQuotaRetryAt(fresh.quota, fresh.plan) !== undefined;
|
|
353
|
+
}
|
|
354
|
+
|
|
272
355
|
function expireCodexAuthFlow(flowId: string | null, error = "Login cancelled"): void {
|
|
273
356
|
const ids = flowId
|
|
274
357
|
? [flowId]
|
|
@@ -311,6 +394,71 @@ function saveRuntimeConfig(sourceConfig: OcxConfig, nextConfig: OcxConfig): void
|
|
|
311
394
|
Object.assign(sourceConfig, nextConfig);
|
|
312
395
|
}
|
|
313
396
|
|
|
397
|
+
type CodexAccountConfigCommit = { ok: true } | { ok: false; error: string };
|
|
398
|
+
|
|
399
|
+
function commitCodexAccountConfig(
|
|
400
|
+
sourceConfig: OcxConfig,
|
|
401
|
+
account: CodexAccount,
|
|
402
|
+
mode: "create" | "reauth",
|
|
403
|
+
credentialGeneration: number,
|
|
404
|
+
quotaPause: "add" | "release" | "preserve",
|
|
405
|
+
): CodexAccountConfigCommit {
|
|
406
|
+
const liveBaseline = structuredClone(sourceConfig);
|
|
407
|
+
const applyMutation = (persisted: OcxConfig) => {
|
|
408
|
+
if (!isCodexAccountGenerationLive(account.id, credentialGeneration)) {
|
|
409
|
+
return { changed: false, value: { ok: false, error: "Codex account changed while login was being saved" } as CodexAccountConfigCommit };
|
|
410
|
+
}
|
|
411
|
+
const conflict = mode === "create"
|
|
412
|
+
? codexAccountIdNamespaceCollisionError(persisted.codexAccountNamespaces, account.id)
|
|
413
|
+
?? ((persisted.codexAccounts ?? []).some(candidate => candidate.id === account.id)
|
|
414
|
+
? `Account id already exists: ${account.id}`
|
|
415
|
+
: undefined)
|
|
416
|
+
: (configuredPoolAccount(persisted, account.id)
|
|
417
|
+
? undefined
|
|
418
|
+
: "Pool account was removed while login was in progress. Add it again as a new account.");
|
|
419
|
+
if (conflict) {
|
|
420
|
+
return { changed: false, value: { ok: false, error: conflict } as CodexAccountConfigCommit };
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const accounts = persisted.codexAccounts ?? [];
|
|
424
|
+
const existingIdx = accounts.findIndex(candidate => candidate.id === account.id);
|
|
425
|
+
let committedAccount: CodexAccount;
|
|
426
|
+
if (existingIdx >= 0) {
|
|
427
|
+
committedAccount = withCodexAccountLogLabel({
|
|
428
|
+
...accounts[existingIdx],
|
|
429
|
+
...account,
|
|
430
|
+
isMain: false,
|
|
431
|
+
}, accounts);
|
|
432
|
+
accounts[existingIdx] = committedAccount;
|
|
433
|
+
} else {
|
|
434
|
+
committedAccount = withCodexAccountLogLabel({ ...account, isMain: false }, accounts);
|
|
435
|
+
accounts.push(committedAccount);
|
|
436
|
+
}
|
|
437
|
+
persisted.codexAccounts = accounts;
|
|
438
|
+
if (mode === "create" && persisted.codexAccountPickerEnabled !== undefined) {
|
|
439
|
+
initializeDefaultCodexAccountNamespaces(persisted);
|
|
440
|
+
appendDefaultCodexAccountNamespace(persisted, committedAccount);
|
|
441
|
+
}
|
|
442
|
+
if (quotaPause === "add") setCodexAccountPaused(persisted, account.id, true);
|
|
443
|
+
else if (quotaPause === "release") setCodexAccountPaused(persisted, account.id, false);
|
|
444
|
+
return { changed: true, value: { ok: true } as CodexAccountConfigCommit };
|
|
445
|
+
};
|
|
446
|
+
const outcome = mutatePersistedConfig(applyMutation);
|
|
447
|
+
if (outcome.status === "unavailable") {
|
|
448
|
+
if (outcome.reason === "missing") {
|
|
449
|
+
const fallback = structuredClone(sourceConfig);
|
|
450
|
+
const applied = applyMutation(fallback);
|
|
451
|
+
if (!applied.value.ok) return applied.value;
|
|
452
|
+
saveRuntimeConfig(sourceConfig, fallback);
|
|
453
|
+
return { ok: true };
|
|
454
|
+
}
|
|
455
|
+
return { ok: false, error: `Configuration could not be updated (${outcome.reason})` };
|
|
456
|
+
}
|
|
457
|
+
if (!outcome.value.ok) return outcome.value;
|
|
458
|
+
reconcileLiveConfigFromDisk(sourceConfig, liveBaseline);
|
|
459
|
+
return { ok: true };
|
|
460
|
+
}
|
|
461
|
+
|
|
314
462
|
async function mapWithConcurrency<T, R>(
|
|
315
463
|
items: T[],
|
|
316
464
|
concurrency: number,
|
|
@@ -364,7 +512,7 @@ interface MainAccountInfoFetchResult {
|
|
|
364
512
|
}
|
|
365
513
|
|
|
366
514
|
export async function fetchMainAccountInfo(forceRefresh = false): Promise<MainAccountInfo> {
|
|
367
|
-
const { info } = await fetchMainAccountInfoAttempt(forceRefresh, 1);
|
|
515
|
+
const { info } = await fetchMainAccountInfoAttempt(forceRefresh, 1, forceRefresh);
|
|
368
516
|
return info;
|
|
369
517
|
}
|
|
370
518
|
|
|
@@ -373,16 +521,21 @@ const EMPTY_MAIN_ACCOUNT_INFO: MainAccountInfo = { email: null, plan: null, quot
|
|
|
373
521
|
async function retryMainAccountInfoIfIdentityChanged(
|
|
374
522
|
requestAccountId: string | null,
|
|
375
523
|
retriesRemaining: number,
|
|
524
|
+
explicitRefresh: boolean,
|
|
376
525
|
): Promise<MainAccountInfoFetchResult | null> {
|
|
377
526
|
const currentAccountId = getMainChatgptAccountId();
|
|
378
527
|
if (currentAccountId === null || currentAccountId === requestAccountId) return null;
|
|
379
528
|
reconcileMainCodexAccountRuntimeState();
|
|
380
529
|
return retriesRemaining > 0
|
|
381
|
-
? fetchMainAccountInfoAttempt(true, retriesRemaining - 1)
|
|
530
|
+
? fetchMainAccountInfoAttempt(true, retriesRemaining - 1, explicitRefresh)
|
|
382
531
|
: { info: EMPTY_MAIN_ACCOUNT_INFO };
|
|
383
532
|
}
|
|
384
533
|
|
|
385
|
-
async function fetchMainAccountInfoAttempt(
|
|
534
|
+
async function fetchMainAccountInfoAttempt(
|
|
535
|
+
forceRefresh: boolean,
|
|
536
|
+
retriesRemaining: number,
|
|
537
|
+
explicitRefresh = forceRefresh,
|
|
538
|
+
): Promise<MainAccountInfoFetchResult> {
|
|
386
539
|
const writerGeneration = captureConfigGeneration();
|
|
387
540
|
reconcileMainCodexAccountRuntimeState();
|
|
388
541
|
const tokenRead = readCodexTokensResult();
|
|
@@ -409,7 +562,7 @@ async function fetchMainAccountInfoAttempt(forceRefresh: boolean, retriesRemaini
|
|
|
409
562
|
});
|
|
410
563
|
if (!resp.ok) {
|
|
411
564
|
const terminalAuthFailure = await isTerminalMainAuthResponse(resp);
|
|
412
|
-
const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining);
|
|
565
|
+
const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, explicitRefresh);
|
|
413
566
|
if (retried) return retried;
|
|
414
567
|
if (terminalAuthFailure) {
|
|
415
568
|
clearMainAccountInfoCache();
|
|
@@ -418,7 +571,7 @@ async function fetchMainAccountInfoAttempt(forceRefresh: boolean, retriesRemaini
|
|
|
418
571
|
return { info: EMPTY_MAIN_ACCOUNT_INFO };
|
|
419
572
|
}
|
|
420
573
|
const data = (await resp.json()) as WhamUsageResponse;
|
|
421
|
-
const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining);
|
|
574
|
+
const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, explicitRefresh);
|
|
422
575
|
if (retried) return retried;
|
|
423
576
|
const plan = nonEmptyPlan(data.plan_type) ?? nonEmptyPlan(cached?.plan) ?? nonEmptyPlan(getMainAccountPlan());
|
|
424
577
|
const quota = parseUsageQuota({ ...data, ...(plan ? { plan_type: plan } : {}) });
|
|
@@ -430,7 +583,9 @@ async function fetchMainAccountInfoAttempt(forceRefresh: boolean, retriesRemaini
|
|
|
430
583
|
ts: Date.now(),
|
|
431
584
|
};
|
|
432
585
|
setMainAccountInfoCache(result);
|
|
433
|
-
|
|
586
|
+
// A successful quota probe only proves /wham/usage accepted the token. Only an
|
|
587
|
+
// operator-requested refresh may retract a quarantine raised by Responses traffic.
|
|
588
|
+
if (explicitRefresh) clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
|
|
434
589
|
// Mirror main quota + plan into the shared stores so the rotation engine can
|
|
435
590
|
// score and auto-switch the main account exactly like a pool account (Option A).
|
|
436
591
|
setMainAccountPlan(result.plan);
|
|
@@ -443,7 +598,7 @@ async function fetchMainAccountInfoAttempt(forceRefresh: boolean, retriesRemaini
|
|
|
443
598
|
...(freshResetCredits !== undefined ? { freshResetCredits } : {}),
|
|
444
599
|
};
|
|
445
600
|
} catch {
|
|
446
|
-
const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining);
|
|
601
|
+
const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, explicitRefresh);
|
|
447
602
|
return retried ?? { info: EMPTY_MAIN_ACCOUNT_INFO };
|
|
448
603
|
}
|
|
449
604
|
}
|
|
@@ -514,6 +669,8 @@ export interface CodexAuthAccountDto {
|
|
|
514
669
|
logLabel?: string;
|
|
515
670
|
isMain: boolean;
|
|
516
671
|
paused: boolean;
|
|
672
|
+
/** Selection order; higher is considered first. */
|
|
673
|
+
priority: number;
|
|
517
674
|
quota: (StoredAccountQuota | (Omit<StoredAccountQuota, "updatedAt"> & { updatedAt: number })) | null;
|
|
518
675
|
needsReauth?: boolean;
|
|
519
676
|
hasCredential: boolean;
|
|
@@ -750,6 +907,10 @@ export function clearCodexQuotaPrimeState(): void {
|
|
|
750
907
|
primeInFlight = null;
|
|
751
908
|
}
|
|
752
909
|
|
|
910
|
+
export function effectiveCodexAuthAccountId(config: OcxConfig): string {
|
|
911
|
+
return getEffectiveActiveCodexAccountId(config) ?? MAIN_CODEX_ACCOUNT_ID;
|
|
912
|
+
}
|
|
913
|
+
|
|
753
914
|
export async function listCodexAuthAccounts(config: OcxConfig, forceRefresh = false): Promise<CodexAuthAccountDto[]> {
|
|
754
915
|
const runtimeConfig = getRuntimeConfig(config);
|
|
755
916
|
const poolAccounts = (runtimeConfig.codexAccounts ?? []).filter(isSelectableCodexPoolAccount);
|
|
@@ -796,6 +957,7 @@ export async function listCodexAuthAccounts(config: OcxConfig, forceRefresh = fa
|
|
|
796
957
|
{ quota: null, needsReauth: true },
|
|
797
958
|
false,
|
|
798
959
|
isCodexAccountPaused(runtimeConfig, accountId),
|
|
960
|
+
getCodexAccountPriority(runtimeConfig, accountId),
|
|
799
961
|
)];
|
|
800
962
|
}
|
|
801
963
|
const resultGeneration = quotaResult.credentialGeneration ?? quotaResult.freshCredentialGeneration;
|
|
@@ -814,6 +976,7 @@ export async function listCodexAuthAccounts(config: OcxConfig, forceRefresh = fa
|
|
|
814
976
|
effectiveQuotaResult,
|
|
815
977
|
true,
|
|
816
978
|
isCodexAccountPaused(runtimeConfig, accountId),
|
|
979
|
+
getCodexAccountPriority(runtimeConfig, accountId),
|
|
817
980
|
)];
|
|
818
981
|
});
|
|
819
982
|
const hasMainCredential = readCodexTokens() !== null;
|
|
@@ -828,9 +991,15 @@ export async function listCodexAuthAccounts(config: OcxConfig, forceRefresh = fa
|
|
|
828
991
|
plan: mainInfo.plan,
|
|
829
992
|
isMain: true,
|
|
830
993
|
paused: isCodexAccountPaused(runtimeConfig, MAIN_CODEX_ACCOUNT_ID),
|
|
994
|
+
priority: getCodexAccountPriority(runtimeConfig, MAIN_CODEX_ACCOUNT_ID),
|
|
831
995
|
hasCredential: hasMainCredential,
|
|
832
996
|
needsReauth: mainNeedsReauth,
|
|
833
|
-
quota: mainInfo.quota ? {
|
|
997
|
+
quota: mainInfo.quota ? {
|
|
998
|
+
...quotaForPlan({
|
|
999
|
+
...mainInfo.quota,
|
|
1000
|
+
updatedAt: getAccountQuota(MAIN_CODEX_ACCOUNT_ID)?.updatedAt ?? Date.now(),
|
|
1001
|
+
}, mainInfo.plan),
|
|
1002
|
+
} : null,
|
|
834
1003
|
...oauthAccountHealthFields("codex", MAIN_CODEX_ACCOUNT_ID, mainHealth),
|
|
835
1004
|
};
|
|
836
1005
|
return [main, ...withQuota];
|
|
@@ -937,24 +1106,52 @@ export async function handleCodexAuthAPI(
|
|
|
937
1106
|
const payload = decodeJwtPayload(body.accessToken);
|
|
938
1107
|
const exp = typeof payload?.exp === "number" ? payload.exp * 1000 : Date.now() + 3600_000;
|
|
939
1108
|
const warmup = await verifyCodexAccountWarmup(body.id, body.accessToken, derivedAccountId);
|
|
940
|
-
|
|
1109
|
+
const freshQuota = !warmup.ok && isCodexWarmupQuotaFailure(warmup.error)
|
|
1110
|
+
? await fetchFreshRegistrationQuota(body.accessToken, derivedAccountId, body.plan)
|
|
1111
|
+
: null;
|
|
1112
|
+
const quotaDeferred = !warmup.ok && mayDeferQuotaWarmup(warmup, freshQuota);
|
|
1113
|
+
if (!warmup.ok && !quotaDeferred) return warmup.response;
|
|
941
1114
|
const latestConfig = getRuntimeConfig(config);
|
|
942
1115
|
const commitConflict = codexAccountPersistenceConflict(latestConfig, body.id, "create");
|
|
943
1116
|
if (commitConflict) return jsonResponse({ error: commitConflict }, 400);
|
|
944
|
-
saveCodexAccountCredential(body.id, {
|
|
1117
|
+
const credentialGeneration = saveCodexAccountCredential(body.id, {
|
|
945
1118
|
accessToken: body.accessToken,
|
|
946
1119
|
refreshToken: body.refreshToken,
|
|
947
1120
|
expiresAt: exp,
|
|
948
1121
|
chatgptAccountId: derivedAccountId,
|
|
949
1122
|
});
|
|
950
|
-
|
|
1123
|
+
const commit = commitCodexAccountConfig(
|
|
1124
|
+
config,
|
|
1125
|
+
{
|
|
1126
|
+
id: body.id,
|
|
1127
|
+
email: body.email,
|
|
1128
|
+
plan: freshQuota?.plan ?? body.plan,
|
|
1129
|
+
isMain: false,
|
|
1130
|
+
},
|
|
1131
|
+
"create",
|
|
1132
|
+
credentialGeneration,
|
|
1133
|
+
quotaDeferred ? "add" : "preserve",
|
|
1134
|
+
);
|
|
1135
|
+
if (!commit.ok) {
|
|
1136
|
+
tombstoneCodexAccountIfGeneration(body.id, credentialGeneration);
|
|
1137
|
+
return jsonResponse({ error: commit.error }, 409);
|
|
1138
|
+
}
|
|
1139
|
+
if (quotaDeferred) {
|
|
1140
|
+
markCodexAccountQuotaValidationPending(
|
|
1141
|
+
body.id,
|
|
1142
|
+
codexWarmupFailureReason(warmup.error),
|
|
1143
|
+
exhaustedQuotaRetryAt(freshQuota.quota, freshQuota.plan),
|
|
1144
|
+
);
|
|
1145
|
+
} else if (warmup.ok) {
|
|
1146
|
+
markCodexAccountValidated(body.id, warmup.validatedAt, credentialGeneration);
|
|
1147
|
+
}
|
|
951
1148
|
clearAccountNeedsReauth(body.id);
|
|
952
|
-
const accounts = latestConfig.codexAccounts ?? [];
|
|
953
|
-
accounts.push(withCodexAccountLogLabel({ id: body.id, email: body.email, plan: body.plan, isMain: false }, accounts));
|
|
954
|
-
latestConfig.codexAccounts = accounts;
|
|
955
|
-
saveRuntimeConfig(config, latestConfig);
|
|
956
1149
|
reconcileLiveStateStores();
|
|
957
|
-
|
|
1150
|
+
if (quotaDeferred) {
|
|
1151
|
+
setAccountQuotaFromParsed(body.id, freshQuota.quota);
|
|
1152
|
+
requestCodexQuotaWarmupRetry();
|
|
1153
|
+
}
|
|
1154
|
+
return jsonResponse({ ok: true, ...(quotaDeferred ? { validation: "pending_quota_reset" } : {}) });
|
|
958
1155
|
}
|
|
959
1156
|
|
|
960
1157
|
if (url.pathname === "/api/codex-auth/accounts" && req.method === "DELETE") {
|
|
@@ -1002,6 +1199,13 @@ export async function handleCodexAuthAPI(
|
|
|
1002
1199
|
const exists = id === MAIN_CODEX_ACCOUNT_ID
|
|
1003
1200
|
|| (runtimeConfig.codexAccounts ?? []).some(account => isSelectableCodexPoolAccount(account) && account.id === id);
|
|
1004
1201
|
if (!exists) return jsonResponse({ error: "Account not found" }, 404);
|
|
1202
|
+
const record = id === MAIN_CODEX_ACCOUNT_ID ? null : readCodexAccountRecord(id);
|
|
1203
|
+
if (body.paused === false && record?.lastCodexValidationStatus === "quota_pending") {
|
|
1204
|
+
return jsonResponse({ error: "Account validation is waiting for its quota reset" }, 409);
|
|
1205
|
+
}
|
|
1206
|
+
if (body.paused === true && record?.codexQuotaPauseOwned === true) {
|
|
1207
|
+
clearCodexAccountQuotaPauseOwnership(id);
|
|
1208
|
+
}
|
|
1005
1209
|
|
|
1006
1210
|
setCodexAccountPaused(runtimeConfig, id, body.paused);
|
|
1007
1211
|
if (body.paused) {
|
|
@@ -1018,6 +1222,42 @@ export async function handleCodexAuthAPI(
|
|
|
1018
1222
|
});
|
|
1019
1223
|
}
|
|
1020
1224
|
|
|
1225
|
+
if (url.pathname === "/api/codex-auth/accounts/priority" && req.method === "PUT") {
|
|
1226
|
+
let parsedBody: unknown;
|
|
1227
|
+
try { parsedBody = await req.json(); } catch { return jsonResponse({ error: "Invalid JSON" }, 400); }
|
|
1228
|
+
if (typeof parsedBody !== "object" || parsedBody === null || Array.isArray(parsedBody)) {
|
|
1229
|
+
return jsonResponse({ error: "body must be an object" }, 400);
|
|
1230
|
+
}
|
|
1231
|
+
const body = parsedBody as { id?: unknown; priority?: unknown };
|
|
1232
|
+
const id = typeof body.id === "string" ? body.id.trim() : "";
|
|
1233
|
+
if (!isCodexAccountPriorityKey(id)) return jsonResponse({ error: "Invalid account id format" }, 400);
|
|
1234
|
+
|
|
1235
|
+
let priority = DEFAULT_ACCOUNT_PRIORITY;
|
|
1236
|
+
if (body.priority !== null) {
|
|
1237
|
+
const parsed = parseAccountPriority(body.priority);
|
|
1238
|
+
if (parsed === null) {
|
|
1239
|
+
return jsonResponse({
|
|
1240
|
+
error: `priority must be null or an integer ${MIN_ACCOUNT_PRIORITY}-${MAX_ACCOUNT_PRIORITY}`,
|
|
1241
|
+
}, 400);
|
|
1242
|
+
}
|
|
1243
|
+
priority = parsed;
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
const runtimeConfig = getRuntimeConfig(config);
|
|
1247
|
+
const exists = id === MAIN_CODEX_ACCOUNT_ID
|
|
1248
|
+
|| (runtimeConfig.codexAccounts ?? []).some(account => isSelectableCodexPoolAccount(account) && account.id === id);
|
|
1249
|
+
if (!exists) return jsonResponse({ error: "Account not found" }, 404);
|
|
1250
|
+
setCodexAccountPriority(runtimeConfig, id, priority);
|
|
1251
|
+
clearCodexAccountPin(runtimeConfig);
|
|
1252
|
+
saveRuntimeConfig(config, runtimeConfig);
|
|
1253
|
+
return jsonResponse({
|
|
1254
|
+
ok: true,
|
|
1255
|
+
id,
|
|
1256
|
+
priority,
|
|
1257
|
+
activeCodexAccountId: getEffectiveActiveCodexAccountId(runtimeConfig) ?? null,
|
|
1258
|
+
});
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1021
1261
|
if (url.pathname === "/api/codex-auth/accounts/pause-exhausted" && req.method === "PUT") {
|
|
1022
1262
|
const runtimeConfig = getRuntimeConfig(config);
|
|
1023
1263
|
const result = await pauseExhaustedCodexAccounts(runtimeConfig);
|
|
@@ -1078,6 +1318,8 @@ export async function handleCodexAuthAPI(
|
|
|
1078
1318
|
if (!exists) return jsonResponse({ error: "Account not found" }, 400);
|
|
1079
1319
|
}
|
|
1080
1320
|
runtimeConfig.activeCodexAccountId = body.accountId ?? undefined;
|
|
1321
|
+
if (body.accountId == null) clearCodexAccountPin(runtimeConfig);
|
|
1322
|
+
else setCodexAccountPin(runtimeConfig, targetAccountId);
|
|
1081
1323
|
resetCodexRoutingForManualSelection(targetAccountId);
|
|
1082
1324
|
saveRuntimeConfig(config, runtimeConfig);
|
|
1083
1325
|
return jsonResponse({ ok: true, activeCodexAccountId: body.accountId, appliesImmediately: true });
|
|
@@ -1087,6 +1329,8 @@ export async function handleCodexAuthAPI(
|
|
|
1087
1329
|
const runtimeConfig = getRuntimeConfig(config);
|
|
1088
1330
|
return jsonResponse({
|
|
1089
1331
|
activeCodexAccountId: getEffectiveActiveCodexAccountId(runtimeConfig) ?? null,
|
|
1332
|
+
pinned: isEffectiveCodexAccountPinned(runtimeConfig),
|
|
1333
|
+
pinnedAccountId: pinnedCodexAccountId(runtimeConfig) ?? null,
|
|
1090
1334
|
autoSwitchThreshold: runtimeConfig.autoSwitchThreshold ?? 80,
|
|
1091
1335
|
upstreamFailoverThreshold: runtimeConfig.upstreamFailoverThreshold ?? 3,
|
|
1092
1336
|
accountPoolStrategy: normalizeAccountPoolStrategy(runtimeConfig.accountPoolStrategy),
|
|
@@ -1377,7 +1621,10 @@ export async function handleCodexAuthAPI(
|
|
|
1377
1621
|
}
|
|
1378
1622
|
|
|
1379
1623
|
const warmup = await verifyCodexAccountWarmup(accountId, cred.access, oauthAccountId);
|
|
1380
|
-
|
|
1624
|
+
const freshRegistrationQuota = quota ? { quota, ...(plan ? { plan } : {}) } : null;
|
|
1625
|
+
const quotaDeferred = !warmup.ok
|
|
1626
|
+
&& mayDeferQuotaWarmup(warmup, freshRegistrationQuota);
|
|
1627
|
+
if (!warmup.ok && !quotaDeferred) {
|
|
1381
1628
|
const body = await warmup.response.json().catch(() => ({})) as { error?: string; reason?: string };
|
|
1382
1629
|
setCodexLoginState(flowId, {
|
|
1383
1630
|
status: "error",
|
|
@@ -1391,6 +1638,7 @@ export async function handleCodexAuthAPI(
|
|
|
1391
1638
|
const latestConfig = getRuntimeConfig(config);
|
|
1392
1639
|
const accounts = latestConfig.codexAccounts ?? [];
|
|
1393
1640
|
const existingIdx = accounts.findIndex(account => account.id === accountId);
|
|
1641
|
+
const existingAccount = existingIdx >= 0 ? accounts[existingIdx] : undefined;
|
|
1394
1642
|
const commitConflict = codexAccountPersistenceConflict(
|
|
1395
1643
|
latestConfig,
|
|
1396
1644
|
accountId,
|
|
@@ -1406,37 +1654,56 @@ export async function handleCodexAuthAPI(
|
|
|
1406
1654
|
break;
|
|
1407
1655
|
}
|
|
1408
1656
|
|
|
1409
|
-
|
|
1657
|
+
const releaseOwnedQuotaPause = reauth
|
|
1658
|
+
&& readCodexAccountRecord(accountId)?.codexQuotaPauseOwned === true;
|
|
1659
|
+
const quotaPauseOwned = !reauth
|
|
1660
|
+
|| releaseOwnedQuotaPause
|
|
1661
|
+
|| !isCodexAccountPaused(latestConfig, accountId);
|
|
1662
|
+
const credentialGeneration = saveCodexAccountCredential(accountId, {
|
|
1410
1663
|
accessToken: cred.access,
|
|
1411
1664
|
refreshToken: cred.refresh,
|
|
1412
1665
|
expiresAt: cred.expires,
|
|
1413
1666
|
chatgptAccountId: oauthAccountId,
|
|
1414
1667
|
});
|
|
1668
|
+
const configCommit = commitCodexAccountConfig(
|
|
1669
|
+
config,
|
|
1670
|
+
{
|
|
1671
|
+
id: accountId,
|
|
1672
|
+
email,
|
|
1673
|
+
plan: plan ?? existingAccount?.plan,
|
|
1674
|
+
isMain: false,
|
|
1675
|
+
},
|
|
1676
|
+
reauth ? "reauth" : "create",
|
|
1677
|
+
credentialGeneration,
|
|
1678
|
+
quotaDeferred ? "add" : releaseOwnedQuotaPause ? "release" : "preserve",
|
|
1679
|
+
);
|
|
1680
|
+
if (!configCommit.ok) {
|
|
1681
|
+
if (!reauth) tombstoneCodexAccountIfGeneration(accountId, credentialGeneration);
|
|
1682
|
+
setCodexLoginState(flowId, {
|
|
1683
|
+
status: "error",
|
|
1684
|
+
error: configCommit.error,
|
|
1685
|
+
doneAt: Date.now(),
|
|
1686
|
+
});
|
|
1687
|
+
completed = true;
|
|
1688
|
+
break;
|
|
1689
|
+
}
|
|
1415
1690
|
// A successful reauthentication replaces the credential generation. Do not let a
|
|
1416
1691
|
// failed optional WHAM probe make the replacement inherit quota from the old record.
|
|
1417
1692
|
if (reauth) clearAccountQuota(accountId);
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
...accounts[existingIdx],
|
|
1428
|
-
email,
|
|
1429
|
-
plan: plan ?? accounts[existingIdx].plan,
|
|
1430
|
-
isMain: false,
|
|
1431
|
-
}, accounts);
|
|
1432
|
-
latestConfig.codexAccounts = accounts;
|
|
1433
|
-
saveRuntimeConfig(config, latestConfig);
|
|
1434
|
-
} else {
|
|
1435
|
-
accounts.push(withCodexAccountLogLabel({ id: accountId, email, plan, isMain: false }, accounts));
|
|
1436
|
-
latestConfig.codexAccounts = accounts;
|
|
1437
|
-
saveRuntimeConfig(config, latestConfig);
|
|
1693
|
+
if (quotaDeferred) {
|
|
1694
|
+
markCodexAccountQuotaValidationPending(
|
|
1695
|
+
accountId,
|
|
1696
|
+
codexWarmupFailureReason(warmup.error),
|
|
1697
|
+
exhaustedQuotaRetryAt(freshRegistrationQuota.quota, freshRegistrationQuota.plan),
|
|
1698
|
+
quotaPauseOwned,
|
|
1699
|
+
);
|
|
1700
|
+
} else if (warmup.ok) {
|
|
1701
|
+
markCodexAccountValidated(accountId, warmup.validatedAt, credentialGeneration);
|
|
1438
1702
|
}
|
|
1703
|
+
clearAccountNeedsReauth(accountId);
|
|
1439
1704
|
reconcileLiveStateStores();
|
|
1705
|
+
if (quota) setAccountQuotaFromParsed(accountId, quota);
|
|
1706
|
+
if (quotaDeferred) requestCodexQuotaWarmupRetry();
|
|
1440
1707
|
setCodexLoginState(flowId, { status: "done", accountId, email, doneAt: Date.now() });
|
|
1441
1708
|
completed = true;
|
|
1442
1709
|
}
|