@bitkyc08/opencodex 2.55.0 → 2.56.0
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-VuoiWj9J.js → index-D4zuyIxQ.js} +1 -1
- package/gui/dist/index.html +1 -1
- package/package.json +2 -1
- package/src/adapters/base.ts +21 -0
- package/src/adapters/cursor/transport-retry.ts +46 -1
- package/src/adapters/cursor.ts +4 -0
- package/src/adapters/kiro/adapter.ts +42 -1
- package/src/adapters/kiro-retry.ts +23 -4
- package/src/adapters/openai-chat/errors.ts +116 -0
- package/src/adapters/openai-chat/messages.ts +346 -0
- package/src/adapters/openai-chat/passthrough.ts +146 -0
- package/src/adapters/openai-chat/response-events.ts +117 -0
- package/src/adapters/openai-chat/tool-call-validation.ts +200 -0
- package/src/adapters/openai-chat/tool-schema.ts +477 -0
- package/src/adapters/openai-chat/wire.ts +50 -0
- package/src/adapters/openai-chat.ts +33 -1445
- package/src/adapters/openai-responses/canonical-forward.ts +202 -0
- package/src/adapters/openai-responses/image-gen.ts +406 -0
- package/src/adapters/openai-responses/internal.ts +3 -0
- package/src/adapters/openai-responses/passthrough.ts +611 -0
- package/src/adapters/openai-responses/prompt-cache.ts +83 -0
- package/src/adapters/openai-responses/reasoning.ts +220 -0
- package/src/adapters/openai-responses/request-strips.ts +185 -0
- package/src/adapters/openai-responses/tool-output-recovery.ts +509 -0
- package/src/adapters/openai-responses/tool-schema.ts +293 -0
- package/src/adapters/openai-responses/web-search.ts +156 -0
- package/src/adapters/openai-responses.ts +4 -2625
- package/src/bridge/errors.ts +34 -0
- package/src/bridge/internal.ts +174 -0
- package/src/bridge/response-json.ts +624 -0
- package/src/bridge/sse.ts +1444 -0
- package/src/bridge.ts +5 -2204
- package/src/chat/inbound.ts +12 -1
- package/src/codex/account-lifecycle.ts +3 -0
- package/src/codex/account-store.ts +71 -9
- package/src/codex/auth-api/account-list.ts +507 -0
- package/src/codex/auth-api/http.ts +32 -0
- package/src/codex/auth-api/login-flow.ts +554 -0
- package/src/codex/auth-api/login-state.ts +64 -0
- package/src/codex/auth-api/main-account-probe.ts +331 -0
- package/src/codex/auth-api/pool-mode-gate.ts +274 -0
- package/src/codex/auth-api/pool-quota-probe.ts +512 -0
- package/src/codex/auth-api/reset-credit-service.ts +422 -0
- package/src/codex/auth-api/routes.ts +425 -0
- package/src/codex/auth-api/runtime-config.ts +48 -0
- package/src/codex/auth-api.ts +27 -3118
- package/src/codex/auth-context.ts +95 -28
- package/src/codex/catalog/auto-review.ts +507 -0
- package/src/codex/catalog/build-entries.ts +981 -0
- package/src/codex/catalog/combo-member.ts +375 -0
- package/src/codex/catalog/derive-entry.ts +229 -0
- package/src/codex/catalog/effort.ts +0 -1
- package/src/codex/catalog/gated-native-warn.ts +63 -0
- package/src/codex/catalog/gather-capture.ts +533 -0
- package/src/codex/catalog/model-hints.ts +691 -0
- package/src/codex/catalog/model-visibility.ts +304 -0
- package/src/codex/catalog/provider-fetch.ts +52 -2942
- package/src/codex/catalog/provider-models.ts +685 -0
- package/src/codex/catalog/restore.ts +132 -0
- package/src/codex/catalog/retained-sync.ts +706 -0
- package/src/codex/catalog/routed-gather.ts +858 -0
- package/src/codex/catalog/subagent-roster.ts +176 -0
- package/src/codex/catalog/sync.ts +52 -2698
- package/src/codex/inject/config-toml.ts +563 -0
- package/src/codex/inject/remove.ts +192 -0
- package/src/codex/inject/restore.ts +540 -0
- package/src/codex/inject/routing-classify.ts +109 -0
- package/src/codex/inject/routing-target.ts +125 -0
- package/src/codex/inject.ts +81 -1436
- package/src/codex/lineage.ts +458 -0
- package/src/codex/pool-refresh-backoff.ts +152 -0
- package/src/codex/routing/active-account.ts +194 -0
- package/src/codex/routing/cooldown-math.ts +275 -0
- package/src/codex/routing/health-store.ts +402 -0
- package/src/codex/routing/probe-lease.ts +358 -0
- package/src/codex/routing/selection.ts +703 -0
- package/src/codex/routing/thread-affinity.ts +538 -0
- package/src/codex/routing.ts +353 -2234
- package/src/codex/shim-fingerprint.ts +223 -0
- package/src/codex/shim-inspect.ts +175 -0
- package/src/codex/shim-probe.ts +367 -0
- package/src/codex/shim-restore-lock.ts +169 -0
- package/src/codex/shim-state-file.ts +151 -0
- package/src/codex/shim-templates.ts +265 -0
- package/src/codex/shim.ts +48 -1268
- package/src/config/diagnostics.ts +705 -0
- package/src/config/feature-flags.ts +55 -0
- package/src/config/live-reconcile.ts +403 -0
- package/src/config/load-degrade.ts +880 -0
- package/src/config/mutation-lock.ts +244 -0
- package/src/config/openai-tier-backup.ts +268 -0
- package/src/config/persist-unlocked.ts +92 -0
- package/src/config/proxy-env.ts +188 -0
- package/src/config/salvage.ts +244 -0
- package/src/config/schema/config-schema.ts +640 -0
- package/src/config/schema/leaf-validators.ts +855 -0
- package/src/config/warn-memo.ts +28 -0
- package/src/config.ts +234 -4481
- package/src/generated/compatibility-version.json +539 -39
- package/src/lib/request-execution-budget.ts +69 -20
- package/src/lib/spend-reservation-ledger.ts +940 -0
- package/src/lib/upstream-retry.ts +55 -11
- package/src/lib/workflow-budget.ts +553 -30
- package/src/providers/quota/account-cache.ts +441 -0
- package/src/providers/quota/antigravity.ts +295 -0
- package/src/providers/quota/report-cache.ts +320 -0
- package/src/providers/quota/vendor-probes-key.ts +1243 -0
- package/src/providers/quota/vendor-probes-oauth.ts +590 -0
- package/src/providers/quota.ts +324 -3079
- package/src/providers/registry/entries-core.ts +1221 -0
- package/src/providers/registry/entries-extended.ts +1204 -0
- package/src/providers/registry/model-seeds.ts +908 -0
- package/src/providers/registry/types.ts +352 -0
- package/src/providers/registry.ts +24 -3536
- package/src/responses/continuation-ownership.ts +29 -0
- package/src/responses/state/replay-fingerprint.ts +80 -0
- package/src/responses/state/snapshot-codec.ts +104 -0
- package/src/responses/state/spill-failure.ts +118 -0
- package/src/responses/state/spill-queue.ts +665 -0
- package/src/responses/state/temp-recovery.ts +257 -0
- package/src/responses/state.ts +82 -1143
- package/src/routing/identity-domains.ts +449 -0
- package/src/routing/probe-lease.ts +511 -0
- package/src/server/index/bounded-request.ts +88 -0
- package/src/server/index/live-sideband.ts +565 -0
- package/src/server/index/serve-options.ts +1766 -0
- package/src/server/index/startup-warnings.ts +213 -0
- package/src/server/index/websocket-handler.ts +335 -0
- package/src/server/index.ts +40 -2547
- package/src/server/management/route-registry.ts +26 -23
- package/src/server/management/shared.ts +8 -5
- package/src/server/management/workflow-budget-routes.ts +133 -0
- package/src/server/management-api.ts +12 -0
- package/src/server/request-log-conversation.ts +9 -7
- package/src/server/request-log.ts +245 -1
- package/src/server/responses/account-change-state.ts +233 -0
- package/src/server/responses/adapter-continuation.ts +514 -0
- package/src/server/responses/adapter-delivery.ts +214 -0
- package/src/server/responses/adapter-dispatch.ts +971 -0
- package/src/server/responses/compact.ts +59 -4
- package/src/server/responses/completion-policy.ts +33 -0
- package/src/server/responses/core-auth.ts +527 -0
- package/src/server/responses/core-codex-account.ts +859 -0
- package/src/server/responses/core-combo-failure.ts +210 -0
- package/src/server/responses/core-combo.ts +707 -0
- package/src/server/responses/core-errors.ts +152 -0
- package/src/server/responses/core-lifetime.ts +95 -0
- package/src/server/responses/core-normalize.ts +350 -0
- package/src/server/responses/core-opaque-recovery.ts +380 -0
- package/src/server/responses/core-options.ts +159 -0
- package/src/server/responses/core-replay.ts +225 -0
- package/src/server/responses/core.ts +192 -8893
- package/src/server/responses/passthrough-delivery.ts +856 -0
- package/src/server/responses/passthrough-dispatch.ts +1476 -0
- package/src/server/responses/passthrough-execution.ts +54 -0
- package/src/server/responses/request-prepare.ts +970 -0
- package/src/server/responses/request-send-budget.ts +164 -0
- package/src/server/responses/request-sidecar-auth.ts +149 -0
- package/src/server/responses/request-transport.ts +744 -0
- package/src/server/responses/response-effects.ts +157 -0
- package/src/server/responses/run-turn-execution.ts +448 -0
- package/src/server/responses/sidecar-execution.ts +469 -0
- package/src/server/responses-image-gen-repair.ts +1 -1
- package/src/server/workflow-refusal.ts +84 -0
- package/src/types/config.ts +30 -0
- package/src/usage/log.ts +146 -0
- package/src/usage/summary.ts +171 -21
package/src/chat/inbound.ts
CHANGED
|
@@ -313,7 +313,18 @@ export function chatCompletionsToResponsesBody(raw: unknown): Rec {
|
|
|
313
313
|
// here keeps that adjacency intact.
|
|
314
314
|
const reasoningText = assistantReasoningText(msg);
|
|
315
315
|
if (reasoningText !== undefined) {
|
|
316
|
-
|
|
316
|
+
// `summary` is required on a reasoning input item by the OpenAI Responses API, and our
|
|
317
|
+
// own responsesRequestSchema marks it optional, so a summary-less item validated locally
|
|
318
|
+
// and was refused upstream with `Missing required parameter: 'input[N].summary'`. It also
|
|
319
|
+
// has to carry the text, not just satisfy the field: sanitizeReasoningInputContent blanks
|
|
320
|
+
// `content` for every destination except a `preserveResponsesReasoningContent` provider,
|
|
321
|
+
// so summary is the only channel that survives to a native backend. This mirrors the
|
|
322
|
+
// Claude ingress (src/claude/inbound.ts), which has always minted both.
|
|
323
|
+
input.push({
|
|
324
|
+
type: "reasoning",
|
|
325
|
+
summary: [{ type: "summary_text", text: reasoningText }],
|
|
326
|
+
content: [{ type: "reasoning_text", text: reasoningText }],
|
|
327
|
+
});
|
|
317
328
|
}
|
|
318
329
|
const blocks = assistantContentToBlocks(msg.content);
|
|
319
330
|
if (blocks.length > 0) input.push({ type: "message", role: "assistant", content: blocks });
|
|
@@ -11,6 +11,8 @@ import { getMainChatgptAccountId, readCodexTokensResult } from "./auth-collision
|
|
|
11
11
|
import { MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan } from "./main-account";
|
|
12
12
|
import { clearAccountQuota } from "./quota";
|
|
13
13
|
import { clearCodexUpstreamHealthForAccount, clearThreadAccountMapForAccount } from "./routing";
|
|
14
|
+
|
|
15
|
+
import { clearCodexPoolRefreshFailure } from "./pool-refresh-backoff";
|
|
14
16
|
import { invalidateCodexWebSocketsForAccount } from "./websocket-registry";
|
|
15
17
|
import { clearMainAccountCredentialPresence, clearMainAccountInfoCache, observeMainQuotaCredential, observeMainQuotaIdentity } from "./main-account-cache";
|
|
16
18
|
import { extractAccountIdClaims } from "../oauth/chatgpt";
|
|
@@ -41,6 +43,7 @@ export function purgeCodexAccountRuntimeState(accountId: string): void {
|
|
|
41
43
|
clearAccountQuota(accountId);
|
|
42
44
|
clearThreadAccountMapForAccount(accountId);
|
|
43
45
|
clearCodexUpstreamHealthForAccount(accountId);
|
|
46
|
+
clearCodexPoolRefreshFailure(accountId);
|
|
44
47
|
if (accountId === MAIN_CODEX_ACCOUNT_ID) {
|
|
45
48
|
clearMainAccountInfoCache();
|
|
46
49
|
clearMainAccountCredentialPresence();
|
|
@@ -17,6 +17,14 @@ import { isValidCodexAccountId } from "./account-id";
|
|
|
17
17
|
import type { PoolQuotaWriter } from "./quota-types";
|
|
18
18
|
import { CODEX_REFRESH_FLIGHT_CEILING_MS } from "./quota-recovery-timing";
|
|
19
19
|
|
|
20
|
+
import {
|
|
21
|
+
CodexPoolRefreshCooldownError,
|
|
22
|
+
clearCodexPoolRefreshFailure,
|
|
23
|
+
codexPoolRefreshFence,
|
|
24
|
+
isCodexPoolRefreshCooling,
|
|
25
|
+
noteCodexPoolRefreshFailure,
|
|
26
|
+
} from "./pool-refresh-backoff";
|
|
27
|
+
|
|
20
28
|
type LegacyCodexAccountStore = Record<string, CodexAccountCredentials>;
|
|
21
29
|
type CodexAccountStore = Record<string, CodexAccountCredentialRecord>;
|
|
22
30
|
type RawCodexAccountStore = Record<string, CodexAccountCredentials | CodexAccountCredentialRecord>;
|
|
@@ -480,6 +488,17 @@ export class TokenRefreshError extends Error {
|
|
|
480
488
|
}
|
|
481
489
|
}
|
|
482
490
|
|
|
491
|
+
/**
|
|
492
|
+
* The stored record or its refresh-grant fingerprint is gone. Retrying cannot
|
|
493
|
+
* conjure a missing credential, so callers must treat this as terminal.
|
|
494
|
+
*/
|
|
495
|
+
export class CodexCredentialUnavailableError extends Error {
|
|
496
|
+
constructor(message = "Codex account credential is unavailable; reauthenticate the account.") {
|
|
497
|
+
super(message);
|
|
498
|
+
this.name = "CodexCredentialUnavailableError";
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
483
502
|
export class CodexCredentialGenerationConflictError extends Error {
|
|
484
503
|
constructor(message = "Codex account changed during refresh") {
|
|
485
504
|
super(message);
|
|
@@ -514,6 +533,30 @@ export class CodexCredentialRefreshStaleError extends Error {
|
|
|
514
533
|
}
|
|
515
534
|
}
|
|
516
535
|
|
|
536
|
+
/**
|
|
537
|
+
* Terminal means the grant itself is dead, or there is no grant to refresh.
|
|
538
|
+
* Token-endpoint 5xx (`unknown`) and a generation CAS loss stay transient
|
|
539
|
+
* because those genuinely may clear (#2887).
|
|
540
|
+
*/
|
|
541
|
+
export function isTerminalCodexPoolRefreshFailure(error: unknown): boolean {
|
|
542
|
+
return (error instanceof TokenRefreshError && (error.reason === "revoked" || error.reason === "expired"))
|
|
543
|
+
|| error instanceof CodexCredentialUnavailableError;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function isOperationalCodexPoolRefreshFailure(error: unknown): boolean {
|
|
547
|
+
if (error instanceof CodexPoolRefreshCooldownError) return true;
|
|
548
|
+
if (error instanceof CodexCredentialRefreshBusyError) return true;
|
|
549
|
+
if (error instanceof CodexCredentialRefreshStaleError) return true;
|
|
550
|
+
if (error instanceof CodexCredentialRefreshLockTimeoutError) return true;
|
|
551
|
+
return error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError");
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function classifyCodexPoolRefreshFailureReason(error: unknown): string {
|
|
555
|
+
if (error instanceof TokenRefreshError) return error.reason;
|
|
556
|
+
if (error instanceof CodexCredentialGenerationConflictError) return "generation_conflict";
|
|
557
|
+
return "network";
|
|
558
|
+
}
|
|
559
|
+
|
|
517
560
|
/** Credential writers share the config mutation coordinator; contention is transient, not reauth. */
|
|
518
561
|
function withCredentialMutationLockSync<T>(fn: () => T): T {
|
|
519
562
|
try {
|
|
@@ -797,6 +840,11 @@ export async function forceRefreshCodexPoolToken(
|
|
|
797
840
|
settle({ kind: "failed", error: options.signal.reason });
|
|
798
841
|
throw options.signal.reason;
|
|
799
842
|
}
|
|
843
|
+
if (isCodexPoolRefreshCooling(id)) {
|
|
844
|
+
const error = new CodexPoolRefreshCooldownError();
|
|
845
|
+
settle({ kind: "failed", error });
|
|
846
|
+
throw error;
|
|
847
|
+
}
|
|
800
848
|
const completion = resolveCodexToken(
|
|
801
849
|
id,
|
|
802
850
|
{ rejectedGeneration: options.rejectedGeneration, rejectedAccessToken: options.rejectedAccessToken },
|
|
@@ -804,14 +852,28 @@ export async function forceRefreshCodexPoolToken(
|
|
|
804
852
|
// the credential, not for whoever happened to be waiting.
|
|
805
853
|
undefined,
|
|
806
854
|
);
|
|
855
|
+
// Captured before the flight settles, spent only if it fails. A reauthentication that lands
|
|
856
|
+
// while this is in the air replaces the grant and clears its failures; this fence is how the
|
|
857
|
+
// late failure knows it is talking about a credential that no longer exists.
|
|
858
|
+
const refreshFence = codexPoolRefreshFence(id);
|
|
807
859
|
completion.then(
|
|
808
|
-
resolved =>
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
860
|
+
resolved => {
|
|
861
|
+
clearCodexPoolRefreshFailure(id);
|
|
862
|
+
settle({
|
|
863
|
+
kind: "resolved",
|
|
864
|
+
provenance: classify(resolved),
|
|
865
|
+
generation: resolved.generation,
|
|
866
|
+
rotated: resolved.accessToken !== options.rejectedAccessToken,
|
|
867
|
+
});
|
|
868
|
+
},
|
|
869
|
+
error => {
|
|
870
|
+
if (isTerminalCodexPoolRefreshFailure(error) || isOperationalCodexPoolRefreshFailure(error)) {
|
|
871
|
+
if (isTerminalCodexPoolRefreshFailure(error)) clearCodexPoolRefreshFailure(id);
|
|
872
|
+
} else {
|
|
873
|
+
noteCodexPoolRefreshFailure(id, classifyCodexPoolRefreshFailureReason(error), undefined, refreshFence);
|
|
874
|
+
}
|
|
875
|
+
settle({ kind: "failed", error });
|
|
876
|
+
},
|
|
815
877
|
);
|
|
816
878
|
const result = await awaitOwnCancellation(completion, options.signal);
|
|
817
879
|
const provenance = classify(result);
|
|
@@ -851,9 +913,9 @@ async function resolveCodexToken(
|
|
|
851
913
|
if (callerSignal?.aborted) throw callerSignal.reason;
|
|
852
914
|
const record = readCodexAccountRecord(id);
|
|
853
915
|
const cred = record?.deletedAt == null ? record?.credential : undefined;
|
|
854
|
-
if (!record || !cred) throw new
|
|
916
|
+
if (!record || !cred) throw new CodexCredentialUnavailableError();
|
|
855
917
|
const refreshGrantFingerprint = recordGrantFingerprint(record);
|
|
856
|
-
if (!refreshGrantFingerprint) throw new
|
|
918
|
+
if (!refreshGrantFingerprint) throw new CodexCredentialUnavailableError();
|
|
857
919
|
|
|
858
920
|
// The freshness shortcut is exactly what makes a 401 on a time-valid token
|
|
859
921
|
// unrecoverable, so a forced caller skips it — but only while the stored credential
|
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
import { codexAccountLogLabel } from "../account-label";
|
|
2
|
+
import { getCodexAccountCredential, getValidCodexToken, isCodexAccountGenerationLive, readCodexAccountRecord } from "../account-store";
|
|
3
|
+
import { getAccountQuota, isCodexQuotaExhausted, setAccountQuotaFromParsed, withoutRetiredCodexQuota } from "../quota";
|
|
4
|
+
import type { StoredAccountQuota } from "../quota";
|
|
5
|
+
import { ConfigMutationLockError, mutatePersistedConfig } from "../../config";
|
|
6
|
+
import { reconcileMainCodexAccountRuntimeState } from "../account-lifecycle";
|
|
7
|
+
import { isCodexAccountPaused, setCodexAccountPaused } from "../account-pause";
|
|
8
|
+
import { getCodexAccountPriority } from "../account-priority";
|
|
9
|
+
import { clearThreadAccountMapForAccount, isCodexAccountPlanExcluded, reconcileCodexActiveAfterExclusion } from "../routing";
|
|
10
|
+
import { codexPlanValue, isThirtyDayOnlyCodexPlan } from "../plan";
|
|
11
|
+
import { isAccountNeedsReauth, markAccountNeedsReauth } from "../account-runtime-state";
|
|
12
|
+
import { getValidMainAccountToken, MainAccountTokenRefreshError, MAIN_CODEX_ACCOUNT_ID } from "../main-account";
|
|
13
|
+
import { captureConfigGeneration } from "../../lib/state-store-sweeper";
|
|
14
|
+
import { captureMainAccountIdentityGeneration, getMainAccountCredentialPresence, isMainAccountIdentityGenerationLive } from "../main-account-cache";
|
|
15
|
+
import type { CodexQuotaRefreshOutcome } from "../quota-refresh-outcome";
|
|
16
|
+
import { getMainAccountHardLockStatus } from "../main-account-hard-lock";
|
|
17
|
+
import type { MainAccountHardLockStatus } from "../main-account-hard-lock";
|
|
18
|
+
import { emailMaskingEnabled, projectEmail } from "../../lib/privacy";
|
|
19
|
+
import type { CodexAccount, OcxConfig } from "../../types";
|
|
20
|
+
import { oauthAccountHealthFields, projectCodexAccountHealth } from "../../oauth/health";
|
|
21
|
+
import type { OAuthAccountHealth, OAuthHealthLabel } from "../../oauth/health";
|
|
22
|
+
import { isSelectableCodexPoolAccount } from "../account-id";
|
|
23
|
+
import type { AdmissionLease } from "../../lib/admission";
|
|
24
|
+
import { tryAcquireNativeMainProfileClaim } from "../native-main-admission";
|
|
25
|
+
import { withNativeMainCredentialClaim, isNativeMainClaimUnavailable } from "./http";
|
|
26
|
+
import { fetchMainAccountInfoAttempt, EMPTY_MAIN_ACCOUNT_INFO, mainResetCreditsForCurrentIdentity } from "./main-account-probe";
|
|
27
|
+
import { fetchPoolAccountQuota, PoolQuotaProbeBusyError, POOL_QUOTA_REFRESH_CONCURRENCY } from "./pool-quota-probe";
|
|
28
|
+
import type { PoolQuotaResult } from "./pool-quota-probe";
|
|
29
|
+
import { getRuntimeConfig, configuredPoolAccount, mapWithConcurrency } from "./runtime-config";
|
|
30
|
+
|
|
31
|
+
export function quotaForPlan<T extends Omit<StoredAccountQuota, "updatedAt"> | StoredAccountQuota | null>(
|
|
32
|
+
quota: T,
|
|
33
|
+
plan: unknown,
|
|
34
|
+
): T | null {
|
|
35
|
+
const visible = withoutRetiredCodexQuota(quota);
|
|
36
|
+
if (!visible || !isThirtyDayOnlyCodexPlan(plan)) return visible;
|
|
37
|
+
const quotaWindows = visible;
|
|
38
|
+
return {
|
|
39
|
+
...(quotaWindows.monthlyPercent !== undefined ? { monthlyPercent: quotaWindows.monthlyPercent } : {}),
|
|
40
|
+
...(quotaWindows.monthlyResetAt !== undefined ? { monthlyResetAt: quotaWindows.monthlyResetAt } : {}),
|
|
41
|
+
// A 30-day plan can still carry a burst window, and it blocks the account on its own.
|
|
42
|
+
// Dropping it here would show a healthy card for an account upstream is refusing (#1791).
|
|
43
|
+
...(quotaWindows.shortPercent !== undefined ? { shortPercent: quotaWindows.shortPercent } : {}),
|
|
44
|
+
...(quotaWindows.shortResetAt !== undefined ? { shortResetAt: quotaWindows.shortResetAt } : {}),
|
|
45
|
+
...(quotaWindows.shortWindowSeconds !== undefined ? { shortWindowSeconds: quotaWindows.shortWindowSeconds } : {}),
|
|
46
|
+
...(quotaWindows.customWindows !== undefined ? { customWindows: quotaWindows.customWindows } : {}),
|
|
47
|
+
...(quotaWindows.resetCredits !== undefined ? { resetCredits: quotaWindows.resetCredits } : {}),
|
|
48
|
+
...("updatedAt" in quotaWindows ? { updatedAt: quotaWindows.updatedAt } : {}),
|
|
49
|
+
} as T;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The main account is the only account whose DTO quota comes from the raw WHAM parse
|
|
54
|
+
* result instead of the merged store: `poolAccountDto` serializes what
|
|
55
|
+
* `commitPoolQuotaResponse` read back out of `getAccountQuota()`, while the main DTO
|
|
56
|
+
* spreads `mainInfo.quota` directly. `/wham/usage` carries `rate_limit_reset_credits`
|
|
57
|
+
* only intermittently, and the store exists to bridge that gap
|
|
58
|
+
* (`setAccountQuotaFromParsed` carries an existing `resetCredits` forward when the new
|
|
59
|
+
* snapshot omits it), so the main card lost its ticket badge on every response that
|
|
60
|
+
* happened to omit the summary while pool cards kept theirs.
|
|
61
|
+
*
|
|
62
|
+
* Only `resetCredits` is carried, deliberately, and only from an identity-tagged
|
|
63
|
+
* in-process observation rather than the alias-keyed store. The window fields have
|
|
64
|
+
* *clearing* semantics — a monthly-only snapshot must drop a stale weekly value (#382) —
|
|
65
|
+
* so reinstating the whole stored object would resurrect a window the parse meant to
|
|
66
|
+
* clear whenever the store write was refused by generation gating. A freshly parsed value
|
|
67
|
+
* always wins, including `0`: zero is defined, so it never takes the fill branch.
|
|
68
|
+
*/
|
|
69
|
+
export function mainQuotaWithCarriedResetCredits(
|
|
70
|
+
parsed: Omit<StoredAccountQuota, "updatedAt">,
|
|
71
|
+
): StoredAccountQuota {
|
|
72
|
+
const carried = parsed.resetCredits === undefined
|
|
73
|
+
? mainResetCreditsForCurrentIdentity()
|
|
74
|
+
: undefined;
|
|
75
|
+
return {
|
|
76
|
+
...parsed,
|
|
77
|
+
...(carried !== undefined ? { resetCredits: carried } : {}),
|
|
78
|
+
updatedAt: getAccountQuota(MAIN_CODEX_ACCOUNT_ID)?.updatedAt ?? Date.now(),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Why an account needs the operator. `missing_credential`, `refresh_failed`, and
|
|
84
|
+
* `quota_unauthorized` are the three causes this surface tells apart on its own. `unauthorized`
|
|
85
|
+
* and `forbidden` exist because the shared health projection may return them; today
|
|
86
|
+
* `projectCodexAccountHealth` only ever produces `refresh_failed`, so accepting the full union
|
|
87
|
+
* keeps this field correct if that projection widens rather than silently dropping a reason.
|
|
88
|
+
*/
|
|
89
|
+
export type CodexAccountReauthReason =
|
|
90
|
+
| "missing_credential"
|
|
91
|
+
| "refresh_failed"
|
|
92
|
+
| "quota_unauthorized"
|
|
93
|
+
| "unauthorized"
|
|
94
|
+
| "forbidden";
|
|
95
|
+
|
|
96
|
+
export function poolAccountDto(
|
|
97
|
+
config: OcxConfig,
|
|
98
|
+
account: CodexAccount,
|
|
99
|
+
quotaResult: PoolQuotaResult,
|
|
100
|
+
hasCredential: boolean,
|
|
101
|
+
paused: boolean,
|
|
102
|
+
priority: number,
|
|
103
|
+
maskEmails: boolean,
|
|
104
|
+
): CodexAuthAccountDto {
|
|
105
|
+
const plan = codexPlanValue(account.plan);
|
|
106
|
+
const quota = quotaForPlan(quotaResult.quota, plan);
|
|
107
|
+
const runtimeReauth = isAccountNeedsReauth(account.id);
|
|
108
|
+
const needsReauth = !hasCredential || quotaResult.needsReauth || runtimeReauth;
|
|
109
|
+
const health = projectCodexAccountHealth({ accountId: account.id, needsReauth });
|
|
110
|
+
// `needsReauth` is an OR of three independent causes plus a persisted verdict resolved inside the
|
|
111
|
+
// health projection. Emitting only the boolean is what left #4212's reporter guessing which
|
|
112
|
+
// account took their model away and why, so name the cause they actually have to act on.
|
|
113
|
+
const reauthReason: CodexAccountReauthReason | undefined = !hasCredential
|
|
114
|
+
? "missing_credential"
|
|
115
|
+
: runtimeReauth
|
|
116
|
+
? "refresh_failed"
|
|
117
|
+
: quotaResult.needsReauth
|
|
118
|
+
? "quota_unauthorized"
|
|
119
|
+
: health.status === "reauth_required" ? health.reason : undefined;
|
|
120
|
+
return {
|
|
121
|
+
id: account.id,
|
|
122
|
+
email: projectEmail(account.email, maskEmails) ?? account.email,
|
|
123
|
+
...(account.alias !== undefined ? { alias: account.alias } : {}),
|
|
124
|
+
...(plan !== undefined ? { plan } : {}),
|
|
125
|
+
logLabel: codexAccountLogLabel(account),
|
|
126
|
+
isMain: false,
|
|
127
|
+
paused,
|
|
128
|
+
priority,
|
|
129
|
+
quota: quota ? { ...quota } : null,
|
|
130
|
+
needsReauth: needsReauth || health.status === "reauth_required",
|
|
131
|
+
...(reauthReason !== undefined ? { reauthReason } : {}),
|
|
132
|
+
...(isCodexAccountPlanExcluded(config, account.id) ? {
|
|
133
|
+
selectionExcludedReason: "plan_excluded" as const,
|
|
134
|
+
selectionExcludedPlan: codexPlanValue(config.codexAccounts?.find(row => row.id === account.id)?.plan),
|
|
135
|
+
} : {}),
|
|
136
|
+
hasCredential,
|
|
137
|
+
...(quotaResult.quotaProbeSkipped ? { quotaProbeSkipped: true as const } : {}),
|
|
138
|
+
...oauthAccountHealthFields("codex", account.id, health),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export interface CodexAuthAccountDto {
|
|
143
|
+
id: string;
|
|
144
|
+
alias?: string;
|
|
145
|
+
email: string;
|
|
146
|
+
plan?: string | null;
|
|
147
|
+
logLabel?: string;
|
|
148
|
+
isMain: boolean;
|
|
149
|
+
paused: boolean;
|
|
150
|
+
/** Selection order; higher is used earlier. Always present, 0 when unset. */
|
|
151
|
+
priority: number;
|
|
152
|
+
quota: (StoredAccountQuota | (Omit<StoredAccountQuota, "updatedAt"> & { updatedAt: number })) | null;
|
|
153
|
+
needsReauth?: boolean;
|
|
154
|
+
/**
|
|
155
|
+
* Which of the independent causes behind `needsReauth` fired. Present only when the account
|
|
156
|
+
* needs the operator; `/api/oauth/accounts` already carries the same field name.
|
|
157
|
+
*/
|
|
158
|
+
reauthReason?: CodexAccountReauthReason;
|
|
159
|
+
/** Automatic selection policy only; explicit routes retain their usual auth checks. */
|
|
160
|
+
selectionExcludedReason?: "plan_excluded";
|
|
161
|
+
selectionExcludedPlan?: string;
|
|
162
|
+
hasCredential: boolean;
|
|
163
|
+
health: OAuthAccountHealth;
|
|
164
|
+
healthLabel: OAuthHealthLabel;
|
|
165
|
+
healthSummary: string;
|
|
166
|
+
healthAction?: string;
|
|
167
|
+
quotaProbeSkipped?: true;
|
|
168
|
+
quotaRefresh?: CodexQuotaRefreshOutcome;
|
|
169
|
+
mainAccountHardLock?: MainAccountHardLockStatus;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export interface FreshPoolPlanUpdate {
|
|
173
|
+
accountId: string;
|
|
174
|
+
plan: string;
|
|
175
|
+
credentialGeneration: number;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Persist only validated plan leaves against the latest disk snapshot. A quota GET must not save
|
|
180
|
+
* the long-lived runtime object wholesale: unrelated manual/provider writes may have landed while
|
|
181
|
+
* WHAM requests were in flight. Missing or malformed files fail closed: a read path must not
|
|
182
|
+
* recreate a deleted config from the server's older in-memory snapshot.
|
|
183
|
+
*/
|
|
184
|
+
export function reconcileFreshPoolAccountPlans(runtimeConfig: OcxConfig, updates: FreshPoolPlanUpdate[]): void {
|
|
185
|
+
if (updates.length === 0) return;
|
|
186
|
+
let outcome: ReturnType<typeof mutatePersistedConfig<FreshPoolPlanUpdate[]>>;
|
|
187
|
+
try {
|
|
188
|
+
outcome = mutatePersistedConfig(persistedConfig => {
|
|
189
|
+
const accepted: FreshPoolPlanUpdate[] = [];
|
|
190
|
+
let changed = false;
|
|
191
|
+
for (const update of updates) {
|
|
192
|
+
if (!isCodexAccountGenerationLive(update.accountId, update.credentialGeneration)) continue;
|
|
193
|
+
const liveAccount = configuredPoolAccount(runtimeConfig, update.accountId);
|
|
194
|
+
const persistedAccount = configuredPoolAccount(persistedConfig, update.accountId);
|
|
195
|
+
if (!liveAccount || !persistedAccount) continue;
|
|
196
|
+
accepted.push(update);
|
|
197
|
+
if (persistedAccount.plan !== update.plan) {
|
|
198
|
+
persistedAccount.plan = update.plan;
|
|
199
|
+
// WHAM is the authoritative plan source: stamp provenance so a later JWT
|
|
200
|
+
// reconcile cannot overwrite this observation within the same credential
|
|
201
|
+
// generation (src/codex/plan-from-token.ts jwtMayWritePlan). Stamped only
|
|
202
|
+
// alongside a real plan change: a steady-state refresh whose plan is
|
|
203
|
+
// unchanged must stay write-free (no-config-write contract), and an
|
|
204
|
+
// unchanged value needs no fence — a JWT rewrite to the same text is a
|
|
205
|
+
// no-op under the caller's own equality check.
|
|
206
|
+
persistedAccount.planSource = "wham";
|
|
207
|
+
persistedAccount.planCredentialGeneration = update.credentialGeneration;
|
|
208
|
+
changed = true;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return { changed, value: accepted };
|
|
212
|
+
});
|
|
213
|
+
} catch (error) {
|
|
214
|
+
// Plan persistence is derived metadata on a read route. Contention must fail closed without
|
|
215
|
+
// turning account listing into a 500; a later refresh can retry against the latest files.
|
|
216
|
+
if (error instanceof ConfigMutationLockError) return;
|
|
217
|
+
throw error;
|
|
218
|
+
}
|
|
219
|
+
if (outcome.status === "unavailable") return;
|
|
220
|
+
for (const update of outcome.value) {
|
|
221
|
+
// A replacement immediately after the durable commit is allowed to supersede the result, but
|
|
222
|
+
// the long-lived object must never be updated from that stale generation.
|
|
223
|
+
if (!isCodexAccountGenerationLive(update.accountId, update.credentialGeneration)) continue;
|
|
224
|
+
const liveAccount = configuredPoolAccount(runtimeConfig, update.accountId);
|
|
225
|
+
if (liveAccount) {
|
|
226
|
+
liveAccount.plan = update.plan;
|
|
227
|
+
liveAccount.planSource = "wham";
|
|
228
|
+
liveAccount.planCredentialGeneration = update.credentialGeneration;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export interface CodexAuthAccountsSnapshot {
|
|
234
|
+
accounts: CodexAuthAccountDto[];
|
|
235
|
+
mainIdentityGeneration: number;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export async function listCodexAuthAccountsSnapshot(
|
|
239
|
+
config: OcxConfig,
|
|
240
|
+
forceRefresh = false,
|
|
241
|
+
options: { validatePending?: boolean } = {},
|
|
242
|
+
): Promise<CodexAuthAccountsSnapshot> {
|
|
243
|
+
const runtimeConfig = getRuntimeConfig(config);
|
|
244
|
+
const poolAccounts = (runtimeConfig.codexAccounts ?? []).filter(isSelectableCodexPoolAccount);
|
|
245
|
+
// One redaction decision for the whole snapshot, read once from the operator's config (#3859).
|
|
246
|
+
const maskEmails = emailMaskingEnabled(runtimeConfig);
|
|
247
|
+
const mainResult = await fetchMainAccountInfoAttempt(forceRefresh, 1);
|
|
248
|
+
const refreshedPool = await mapWithConcurrency(poolAccounts, POOL_QUOTA_REFRESH_CONCURRENCY, async account => {
|
|
249
|
+
const cred = getCodexAccountCredential(account.id);
|
|
250
|
+
let quotaResult: PoolQuotaResult;
|
|
251
|
+
if (!cred) {
|
|
252
|
+
quotaResult = { quota: null, needsReauth: true };
|
|
253
|
+
} else {
|
|
254
|
+
try {
|
|
255
|
+
quotaResult = await fetchPoolAccountQuota(account.id, forceRefresh, account.plan, getValidCodexToken, options.validatePending === true);
|
|
256
|
+
} catch (error) {
|
|
257
|
+
if (!(error instanceof PoolQuotaProbeBusyError)) throw error;
|
|
258
|
+
quotaResult = {
|
|
259
|
+
quota: getAccountQuota(account.id),
|
|
260
|
+
needsReauth: false,
|
|
261
|
+
credentialGeneration: readCodexAccountRecord(account.id)?.generation,
|
|
262
|
+
quotaProbeSkipped: true,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return { accountId: account.id, quotaResult };
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
// WHAM plan_type is authoritative only for the credential generation that fetched it. Collect
|
|
270
|
+
// changes after every parallel read settles, then apply one narrow disk patch for the batch.
|
|
271
|
+
const planUpdates = refreshedPool.flatMap(({ accountId, quotaResult }): FreshPoolPlanUpdate[] => {
|
|
272
|
+
const plan = quotaResult.freshPlan;
|
|
273
|
+
const credentialGeneration = quotaResult.freshCredentialGeneration;
|
|
274
|
+
return plan && credentialGeneration !== undefined
|
|
275
|
+
? [{ accountId, plan, credentialGeneration }]
|
|
276
|
+
: [];
|
|
277
|
+
});
|
|
278
|
+
reconcileFreshPoolAccountPlans(runtimeConfig, planUpdates);
|
|
279
|
+
|
|
280
|
+
const withQuota = refreshedPool.flatMap(({ accountId, quotaResult }) => {
|
|
281
|
+
const currentAccount = configuredPoolAccount(runtimeConfig, accountId);
|
|
282
|
+
if (!currentAccount) return [];
|
|
283
|
+
const currentCredential = getCodexAccountCredential(accountId);
|
|
284
|
+
if (!currentCredential) {
|
|
285
|
+
return [poolAccountDto(
|
|
286
|
+
runtimeConfig,
|
|
287
|
+
currentAccount,
|
|
288
|
+
{ quota: null, needsReauth: true },
|
|
289
|
+
false,
|
|
290
|
+
isCodexAccountPaused(runtimeConfig, accountId),
|
|
291
|
+
getCodexAccountPriority(runtimeConfig, accountId),
|
|
292
|
+
maskEmails,
|
|
293
|
+
)];
|
|
294
|
+
}
|
|
295
|
+
const resultGeneration = quotaResult.credentialGeneration ?? quotaResult.freshCredentialGeneration;
|
|
296
|
+
const generationLive = resultGeneration === undefined
|
|
297
|
+
|| isCodexAccountGenerationLive(accountId, resultGeneration);
|
|
298
|
+
const effectiveQuotaResult = !generationLive
|
|
299
|
+
? { quota: null, needsReauth: false }
|
|
300
|
+
: quotaResult;
|
|
301
|
+
// Response DTO can show the WHAM plan even when disk persistence fails closed (lock busy /
|
|
302
|
+
// missing config). Persistence still remains generation-gated via reconcileFreshPoolAccountPlans.
|
|
303
|
+
const dtoAccount = generationLive && quotaResult.freshPlan
|
|
304
|
+
? { ...currentAccount, plan: quotaResult.freshPlan }
|
|
305
|
+
: currentAccount;
|
|
306
|
+
return [poolAccountDto(
|
|
307
|
+
runtimeConfig,
|
|
308
|
+
dtoAccount,
|
|
309
|
+
effectiveQuotaResult,
|
|
310
|
+
true,
|
|
311
|
+
isCodexAccountPaused(runtimeConfig, accountId),
|
|
312
|
+
getCodexAccountPriority(runtimeConfig, accountId),
|
|
313
|
+
maskEmails,
|
|
314
|
+
)];
|
|
315
|
+
});
|
|
316
|
+
const fetchedMainGeneration = mainResult.identityGeneration ?? captureMainAccountIdentityGeneration();
|
|
317
|
+
const mainSnapshotLive = isMainAccountIdentityGenerationLive(fetchedMainGeneration);
|
|
318
|
+
const mainInfo = mainSnapshotLive ? mainResult.info : EMPTY_MAIN_ACCOUNT_INFO;
|
|
319
|
+
const hasMainCredential = mainSnapshotLive && mainResult.credentialChecked
|
|
320
|
+
? mainResult.hasCredential
|
|
321
|
+
: getMainAccountCredentialPresence() ?? false;
|
|
322
|
+
const mainMissingCredential = mainSnapshotLive && mainResult.credentialChecked && !hasMainCredential;
|
|
323
|
+
const mainNeedsReauth = mainMissingCredential || isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
|
|
324
|
+
const mainHealth = projectCodexAccountHealth({
|
|
325
|
+
accountId: MAIN_CODEX_ACCOUNT_ID,
|
|
326
|
+
needsReauth: mainNeedsReauth,
|
|
327
|
+
});
|
|
328
|
+
// The main row carries the same attribution as a pool row. Reaching this point without
|
|
329
|
+
// `mainMissingCredential` means the runtime reauth flag is what set `mainNeedsReauth`, so the
|
|
330
|
+
// cause is a refresh that did not complete.
|
|
331
|
+
const mainReauthReason: CodexAccountReauthReason | undefined = mainMissingCredential
|
|
332
|
+
? "missing_credential"
|
|
333
|
+
: mainNeedsReauth
|
|
334
|
+
? "refresh_failed"
|
|
335
|
+
: mainHealth.status === "reauth_required" ? mainHealth.reason : undefined;
|
|
336
|
+
const main: CodexAuthAccountDto = {
|
|
337
|
+
id: MAIN_CODEX_ACCOUNT_ID,
|
|
338
|
+
email: projectEmail(mainInfo.email, maskEmails) ?? "Codex App login",
|
|
339
|
+
plan: mainInfo.plan,
|
|
340
|
+
...(mainSnapshotLive && mainResult.quotaRefresh && mainResult.quotaRefreshGeneration !== undefined
|
|
341
|
+
&& isMainAccountIdentityGenerationLive(mainResult.quotaRefreshGeneration)
|
|
342
|
+
? { quotaRefresh: mainResult.quotaRefresh } : {}),
|
|
343
|
+
logLabel: "main",
|
|
344
|
+
isMain: true,
|
|
345
|
+
paused: isCodexAccountPaused(runtimeConfig, MAIN_CODEX_ACCOUNT_ID),
|
|
346
|
+
mainAccountHardLock: getMainAccountHardLockStatus(runtimeConfig),
|
|
347
|
+
priority: getCodexAccountPriority(runtimeConfig, MAIN_CODEX_ACCOUNT_ID),
|
|
348
|
+
hasCredential: hasMainCredential,
|
|
349
|
+
needsReauth: mainNeedsReauth,
|
|
350
|
+
...(mainReauthReason !== undefined ? { reauthReason: mainReauthReason } : {}),
|
|
351
|
+
quota: mainInfo.quota
|
|
352
|
+
? quotaForPlan(mainQuotaWithCarriedResetCredits(mainInfo.quota), mainInfo.plan)
|
|
353
|
+
: null,
|
|
354
|
+
...oauthAccountHealthFields("codex", MAIN_CODEX_ACCOUNT_ID, mainHealth),
|
|
355
|
+
};
|
|
356
|
+
return {
|
|
357
|
+
accounts: [main, ...withQuota],
|
|
358
|
+
mainIdentityGeneration: mainSnapshotLive
|
|
359
|
+
? fetchedMainGeneration
|
|
360
|
+
: captureMainAccountIdentityGeneration(),
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** One opted-in account's metadata; reuse the bounded WHAM 401 recovery and generation fence. */
|
|
365
|
+
export async function refreshCodexQuotaForActivation(config: OcxConfig, accountId: string): Promise<void> {
|
|
366
|
+
if (accountId === MAIN_CODEX_ACCOUNT_ID) {
|
|
367
|
+
const lease = tryAcquireNativeMainProfileClaim();
|
|
368
|
+
if (!lease) return;
|
|
369
|
+
try {
|
|
370
|
+
reconcileMainCodexAccountRuntimeState();
|
|
371
|
+
if (isAccountNeedsReauth(accountId)) return;
|
|
372
|
+
const identityGeneration = captureMainAccountIdentityGeneration();
|
|
373
|
+
const writerGeneration = captureConfigGeneration();
|
|
374
|
+
try {
|
|
375
|
+
// Refresh may need an exclusive claim; prepare before WHAM takes its shared claim.
|
|
376
|
+
if (!await getValidMainAccountToken({ preserveReauth: true })) return;
|
|
377
|
+
} catch (error) {
|
|
378
|
+
if (error instanceof MainAccountTokenRefreshError && error.reason === "reauth"
|
|
379
|
+
&& isMainAccountIdentityGenerationLive(identityGeneration)) {
|
|
380
|
+
markAccountNeedsReauth(accountId, writerGeneration);
|
|
381
|
+
}
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
if (isAccountNeedsReauth(accountId)) return;
|
|
385
|
+
await fetchMainAccountInfoAttempt(true, 1, lease, false, false);
|
|
386
|
+
} finally {
|
|
387
|
+
lease.release();
|
|
388
|
+
}
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
const account = configuredPoolAccount(config, accountId);
|
|
392
|
+
if (!account) return;
|
|
393
|
+
const writerGeneration = captureConfigGeneration();
|
|
394
|
+
const result = await fetchPoolAccountQuota(accountId, true, account.plan);
|
|
395
|
+
if (result.needsReauth && result.credentialGeneration !== undefined) {
|
|
396
|
+
markAccountNeedsReauth(accountId, writerGeneration, result.credentialGeneration);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
export async function listCodexAuthAccounts(
|
|
401
|
+
config: OcxConfig,
|
|
402
|
+
forceRefresh = false,
|
|
403
|
+
options: { validatePending?: boolean } = {},
|
|
404
|
+
): Promise<CodexAuthAccountDto[]> {
|
|
405
|
+
return (await listCodexAuthAccountsSnapshot(config, forceRefresh, options)).accounts;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
export interface PauseExhaustedResult {
|
|
409
|
+
pausedAccountIds: string[];
|
|
410
|
+
checkedAccountCount: number;
|
|
411
|
+
failedAccountCount: number;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export function selectFallbackAfterPause(config: OcxConfig, pausedActiveId: string): void {
|
|
415
|
+
reconcileCodexActiveAfterExclusion(config, pausedActiveId);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
export async function pauseExhaustedCodexAccounts(
|
|
419
|
+
config: OcxConfig,
|
|
420
|
+
persistPausedAccounts: () => void,
|
|
421
|
+
): Promise<PauseExhaustedResult> {
|
|
422
|
+
const poolAccounts = (config.codexAccounts ?? []).filter(account => !account.isMain);
|
|
423
|
+
const nativeMainLease = tryAcquireNativeMainProfileClaim();
|
|
424
|
+
try {
|
|
425
|
+
const performPause = async (mainLease?: AdmissionLease): Promise<PauseExhaustedResult> => {
|
|
426
|
+
const mainWork = async (): Promise<{
|
|
427
|
+
shouldPause: boolean;
|
|
428
|
+
checkedAccountCount: number;
|
|
429
|
+
failedAccountCount: number;
|
|
430
|
+
}> => {
|
|
431
|
+
if (!mainLease) return { shouldPause: false, checkedAccountCount: 0, failedAccountCount: 1 };
|
|
432
|
+
const mainResult = await fetchMainAccountInfoAttempt(true, 1, mainLease, true);
|
|
433
|
+
if (!mainResult.credentialChecked || !mainResult.hasCredential) {
|
|
434
|
+
return { shouldPause: false, checkedAccountCount: 0, failedAccountCount: 0 };
|
|
435
|
+
}
|
|
436
|
+
if (!mainResult.freshQuota || !mainResult.info.plan) {
|
|
437
|
+
return { shouldPause: false, checkedAccountCount: 0, failedAccountCount: 1 };
|
|
438
|
+
}
|
|
439
|
+
return {
|
|
440
|
+
shouldPause: !isCodexAccountPaused(config, MAIN_CODEX_ACCOUNT_ID)
|
|
441
|
+
&& isCodexQuotaExhausted(mainResult.freshQuota, mainResult.info.plan),
|
|
442
|
+
checkedAccountCount: 1,
|
|
443
|
+
failedAccountCount: 0,
|
|
444
|
+
};
|
|
445
|
+
};
|
|
446
|
+
const [mainResult, poolResults] = await Promise.all([
|
|
447
|
+
mainWork(),
|
|
448
|
+
mapWithConcurrency(poolAccounts, POOL_QUOTA_REFRESH_CONCURRENCY, async account => {
|
|
449
|
+
if (!getCodexAccountCredential(account.id)) return { account, quotaResult: null };
|
|
450
|
+
try {
|
|
451
|
+
return {
|
|
452
|
+
account,
|
|
453
|
+
quotaResult: await fetchPoolAccountQuota(account.id, true, account.plan),
|
|
454
|
+
};
|
|
455
|
+
} catch {
|
|
456
|
+
// Settle each pool probe independently so a busy/failing account cannot
|
|
457
|
+
// abandon an already-confirmed main decision before atomic publication.
|
|
458
|
+
return { account, quotaResult: null };
|
|
459
|
+
}
|
|
460
|
+
}),
|
|
461
|
+
]);
|
|
462
|
+
|
|
463
|
+
let checkedAccountCount = mainResult.checkedAccountCount;
|
|
464
|
+
let failedAccountCount = mainResult.failedAccountCount;
|
|
465
|
+
const exhaustedIds: string[] = mainResult.shouldPause ? [MAIN_CODEX_ACCOUNT_ID] : [];
|
|
466
|
+
for (const { account, quotaResult } of poolResults) {
|
|
467
|
+
const currentAccount = (config.codexAccounts ?? []).find(candidate => candidate.id === account.id && !candidate.isMain);
|
|
468
|
+
if (!currentAccount) continue;
|
|
469
|
+
const generation = quotaResult?.freshCredentialGeneration;
|
|
470
|
+
const plan = quotaResult?.freshPlan ?? currentAccount.plan;
|
|
471
|
+
if (!quotaResult?.freshQuota || generation === undefined || !isCodexAccountGenerationLive(account.id, generation) || !plan) {
|
|
472
|
+
failedAccountCount += 1;
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
checkedAccountCount += 1;
|
|
476
|
+
if (!isCodexAccountPaused(config, account.id) && isCodexQuotaExhausted(quotaResult.freshQuota, plan)) {
|
|
477
|
+
exhaustedIds.push(account.id);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
for (const id of exhaustedIds) {
|
|
482
|
+
setCodexAccountPaused(config, id, true);
|
|
483
|
+
clearThreadAccountMapForAccount(id);
|
|
484
|
+
}
|
|
485
|
+
for (const id of exhaustedIds) selectFallbackAfterPause(config, id);
|
|
486
|
+
const result = {
|
|
487
|
+
pausedAccountIds: exhaustedIds,
|
|
488
|
+
checkedAccountCount,
|
|
489
|
+
failedAccountCount,
|
|
490
|
+
};
|
|
491
|
+
// Persist while both the in-process admission and cross-process shared
|
|
492
|
+
// claim still own the physical-main identity used for the decision.
|
|
493
|
+
if (result.pausedAccountIds.length > 0) persistPausedAccounts();
|
|
494
|
+
return result;
|
|
495
|
+
};
|
|
496
|
+
|
|
497
|
+
if (!nativeMainLease) return await performPause();
|
|
498
|
+
try {
|
|
499
|
+
return await withNativeMainCredentialClaim(() => performPause(nativeMainLease));
|
|
500
|
+
} catch (error) {
|
|
501
|
+
if (isNativeMainClaimUnavailable(error)) return await performPause();
|
|
502
|
+
throw error;
|
|
503
|
+
}
|
|
504
|
+
} finally {
|
|
505
|
+
nativeMainLease?.release();
|
|
506
|
+
}
|
|
507
|
+
}
|