@bitkyc08/opencodex 2.55.0 → 2.57.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/bin/ocx.mjs +10 -0
- package/gui/dist/assets/{index-BBOZWGB6.css → index-C5-RdDmD.css} +1 -1
- package/gui/dist/assets/{index-VuoiWj9J.js → index-Cz7CLdif.js} +21 -21
- package/gui/dist/index.html +2 -2
- package/package.json +4 -3
- package/src/adapters/base.ts +21 -0
- package/src/adapters/codebuddy/adapter.ts +2 -1
- package/src/adapters/codebuddy/scaffold-guard.ts +248 -0
- package/src/adapters/command-code.ts +1 -1
- package/src/adapters/cursor/envelope-echo.ts +8 -2
- package/src/adapters/cursor/transport-retry.ts +46 -1
- package/src/adapters/cursor.ts +4 -0
- package/src/adapters/google.ts +7 -7
- package/src/adapters/kiro/adapter.ts +42 -1
- package/src/adapters/kiro/payload.ts +17 -3
- package/src/adapters/kiro/reasoning.ts +70 -7
- package/src/adapters/kiro/stream.ts +8 -2
- package/src/adapters/kiro/wire.ts +2 -1
- package/src/adapters/kiro-events.ts +21 -13
- 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-name-registry.ts +166 -0
- package/src/adapters/openai-chat/tool-schema.ts +495 -0
- package/src/adapters/openai-chat/wire.ts +50 -0
- package/src/adapters/openai-chat.ts +40 -1452
- 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 +642 -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 +58 -0
- package/src/bridge/internal.ts +174 -0
- package/src/bridge/response-json.ts +630 -0
- package/src/bridge/sse.ts +1462 -0
- package/src/bridge.ts +5 -2204
- package/src/chat/inbound.ts +12 -1
- package/src/claude/desktop-profile.ts +66 -9
- package/src/claude/outbound.ts +18 -0
- package/src/cli/account-main.ts +1 -1
- package/src/cli/capabilities.ts +2 -2
- package/src/cli/combo.ts +10 -1
- package/src/cli/index.ts +48 -5
- package/src/cli/registry.ts +2 -1
- package/src/cli/system-command.ts +4 -4
- package/src/clients/config-export.ts +7 -3
- package/src/codex/account-label.ts +14 -3
- package/src/codex/account-lifecycle.ts +3 -0
- package/src/codex/account-store.ts +184 -35
- package/src/codex/account-usability.ts +21 -0
- 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 +566 -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 +431 -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 +252 -35
- package/src/codex/catalog/aggregation.ts +80 -1
- 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 +305 -0
- package/src/codex/catalog/provider-fetch.ts +52 -2942
- package/src/codex/catalog/provider-models.ts +685 -0
- package/src/codex/catalog/remote.ts +30 -0
- package/src/codex/catalog/restore.ts +132 -0
- package/src/codex/catalog/retained-sync.ts +714 -0
- package/src/codex/catalog/routed-gather.ts +895 -0
- package/src/codex/catalog/subagent-roster.ts +176 -0
- package/src/codex/catalog/sync.ts +52 -2698
- package/src/codex/cli-install-provenance.ts +7 -1
- package/src/codex/convergence.ts +7 -2
- package/src/codex/desktop-app/types.ts +11 -2
- package/src/codex/desktop-app/windows.ts +5 -5
- package/src/codex/inject/config-toml.ts +563 -0
- package/src/codex/inject/remove.ts +192 -0
- package/src/codex/inject/restore.ts +567 -0
- package/src/codex/inject/routing-classify.ts +109 -0
- package/src/codex/inject/routing-target.ts +125 -0
- package/src/codex/inject.ts +89 -1444
- package/src/codex/lineage.ts +458 -0
- package/src/codex/model-entitlements.ts +152 -15
- package/src/codex/pool-refresh-backoff.ts +161 -0
- package/src/codex/quota-rejection.ts +104 -15
- package/src/codex/routing/active-account.ts +194 -0
- package/src/codex/routing/cache-affinity.ts +70 -0
- package/src/codex/routing/cooldown-math.ts +285 -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 +780 -0
- package/src/codex/routing/thread-affinity.ts +586 -0
- package/src/codex/routing/transient-hold-dispatch.ts +141 -0
- package/src/codex/routing.ts +370 -2271
- 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/codex/warmup.ts +1 -1
- package/src/combos/failover.ts +85 -0
- package/src/combos/request.ts +17 -10
- package/src/combos/types.ts +23 -2
- 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/pending-teardown.ts +31 -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 +649 -121
- package/src/images/loop.ts +1 -1
- package/src/lib/errors.ts +17 -0
- package/src/lib/request-execution-budget.ts +198 -23
- package/src/lib/spend-reservation-ledger.ts +958 -0
- package/src/lib/state-store-registrations.ts +6 -2
- package/src/lib/test-home-guard.ts +85 -1
- package/src/lib/upstream-retry.ts +132 -21
- package/src/lib/windows-elevation.ts +76 -14
- package/src/lib/workflow-budget.ts +553 -30
- package/src/oauth/index.ts +2 -2
- package/src/oauth/key-providers.ts +2 -2
- package/src/providers/kiro-models.ts +4 -3
- package/src/providers/label.ts +19 -1
- package/src/providers/model-discovery.ts +16 -0
- 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 +1228 -0
- package/src/providers/registry/entries-extended.ts +1213 -0
- package/src/providers/registry/model-seeds.ts +912 -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/reasoning-envelope.ts +6 -3
- 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 +456 -0
- package/src/routing/probe-lease.ts +613 -0
- package/src/server/chat-completions.ts +3 -1
- package/src/server/chat-native.ts +37 -9
- package/src/server/index/bounded-request.ts +88 -0
- package/src/server/index/live-sideband.ts +601 -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 +339 -0
- package/src/server/index.ts +45 -2552
- package/src/server/inspection-tee.ts +107 -0
- package/src/server/live.ts +46 -1
- package/src/server/management/combo-routes.ts +10 -1
- 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/relay-eager.ts +2 -0
- package/src/server/relay.ts +14 -19
- package/src/server/request-log-conversation.ts +9 -7
- package/src/server/request-log.ts +372 -4
- package/src/server/response-log-body.ts +153 -0
- package/src/server/responses/account-change-state.ts +307 -0
- package/src/server/responses/adapter-continuation.ts +540 -0
- package/src/server/responses/adapter-delivery.ts +208 -0
- package/src/server/responses/adapter-dispatch.ts +1042 -0
- package/src/server/responses/codex-ws-wire.ts +5 -0
- package/src/server/responses/collaboration.ts +74 -4
- package/src/server/responses/combo-session-recall.ts +68 -8
- package/src/server/responses/compact.ts +113 -17
- package/src/server/responses/completion-policy.ts +33 -0
- package/src/server/responses/core-auth.ts +529 -0
- package/src/server/responses/core-codex-account.ts +907 -0
- package/src/server/responses/core-combo-failure.ts +210 -0
- package/src/server/responses/core-combo.ts +787 -0
- package/src/server/responses/core-errors.ts +170 -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 +298 -0
- package/src/server/responses/core.ts +192 -8893
- package/src/server/responses/encrypted-payload.ts +0 -1
- package/src/server/responses/input-admission.ts +126 -6
- package/src/server/responses/passthrough-delivery.ts +869 -0
- package/src/server/responses/passthrough-dispatch.ts +1494 -0
- package/src/server/responses/passthrough-error.ts +38 -2
- package/src/server/responses/passthrough-execution.ts +54 -0
- package/src/server/responses/request-prepare.ts +1080 -0
- package/src/server/responses/request-send-budget.ts +259 -0
- package/src/server/responses/request-sidecar-auth.ts +149 -0
- package/src/server/responses/request-spend.ts +147 -0
- package/src/server/responses/request-transport.ts +803 -0
- package/src/server/responses/response-effects.ts +157 -0
- package/src/server/responses/run-turn-execution.ts +476 -0
- package/src/server/responses/sidecar-execution.ts +463 -0
- package/src/server/responses/terminal-guard.ts +65 -4
- package/src/server/responses-image-gen-repair.ts +1 -1
- package/src/server/responses-undeclared-tool-guard.ts +9 -5
- package/src/server/workflow-refusal.ts +84 -0
- package/src/service/windows-ops.ts +210 -16
- package/src/service/windows-scheduler.ts +28 -21
- package/src/service.ts +1 -1
- package/src/types/config.ts +34 -1
- package/src/types/request.ts +8 -5
- package/src/types/tools.ts +24 -0
- package/src/types.ts +2 -0
- package/src/update/index.ts +10 -0
- package/src/update/stop-contract.d.mts +1 -0
- package/src/update/stop-contract.mjs +19 -0
- package/src/update/stop-decision.d.mts +1 -1
- package/src/update/stop-decision.mjs +12 -3
- package/src/usage/log.ts +147 -1
- package/src/usage/summary.ts +171 -21
- package/src/vision/anthropic-describe.ts +1 -1
- package/src/vision/describe.ts +5 -5
- package/src/web-search/anthropic-executor.ts +1 -1
- package/src/web-search/exa-executor.ts +1 -1
- package/src/web-search/executor.ts +1 -1
- package/src/web-search/gemini-executor.ts +1 -1
- package/src/web-search/loop.ts +1 -1
- package/src/web-search/ollama-executor.ts +1 -1
- package/src/web-search/parse.ts +67 -14
- package/src/web-search/passthrough-bridge.ts +64 -31
- package/src/web-search/xai-executor.ts +1 -1
package/src/codex/auth-api.ts
CHANGED
|
@@ -1,96 +1,5 @@
|
|
|
1
|
-
import { CODEX_ACCOUNT_LOG_LABEL_RE } from "./account-label";
|
|
2
|
-
import { poolQuotaHistoryIdentity } from "./account-store";
|
|
3
|
-
import { estimateCodexQuotaCapacity, insufficientCodexCapacity, type CodexCapacityResult } from "./quota-capacity";
|
|
4
|
-
import { readUsageSnapshotForManagement } from "../usage/log";
|
|
5
|
-
import { capturePoolQuotaWriter } from "./account-store";
|
|
6
|
-
import type { PoolQuotaWriter } from "./quota-types";
|
|
7
|
-
import { getAccountQuotaHistory, isValidWhamHistoryObservation } from "./quota";
|
|
8
|
-
import {
|
|
9
|
-
ConfigMutationLockError,
|
|
10
|
-
loadConfig,
|
|
11
|
-
mutatePersistedConfig,
|
|
12
|
-
saveConfigPreservingClaudeCode,
|
|
13
|
-
withConfigMutationLockSync,
|
|
14
|
-
} from "../config";
|
|
15
|
-
import { codexAccountLogLabel, withCodexAccountLogLabel } from "./account-label";
|
|
16
|
-
import {
|
|
17
|
-
getCodexAccountCredential,
|
|
18
|
-
getValidCodexToken,
|
|
19
|
-
isCodexAccountGenerationLive,
|
|
20
|
-
forceRefreshCodexPoolToken,
|
|
21
|
-
markCodexAccountValidated,
|
|
22
|
-
markCodexAccountValidationFailed,
|
|
23
|
-
readCodexAccountRecord,
|
|
24
|
-
saveCodexAccountCredential,
|
|
25
|
-
CodexCredentialGenerationConflictError,
|
|
26
|
-
CodexCredentialRefreshLockTimeoutError,
|
|
27
|
-
CodexCredentialRefreshBusyError,
|
|
28
|
-
CodexCredentialRefreshStaleError,
|
|
29
|
-
TokenRefreshError,
|
|
30
|
-
} from "./account-store";
|
|
31
|
-
import { deleteCodexAccount, reconcileMainCodexAccountRuntimeState } from "./account-lifecycle";
|
|
32
|
-
import {
|
|
33
|
-
appendDefaultCodexAccountNamespace,
|
|
34
|
-
codexAccountPickerEnabled,
|
|
35
|
-
} from "./account-namespaces";
|
|
36
|
-
import {
|
|
37
|
-
catalogRefreshIsPending,
|
|
38
|
-
normalizeCatalogDisposition,
|
|
39
|
-
} from "./catalog-refresh-status";
|
|
40
|
-
import { isCodexAccountPaused, setCodexAccountPaused } from "./account-pause";
|
|
41
|
-
import {
|
|
42
|
-
clearCodexAccountPin,
|
|
43
|
-
getCodexAccountPriority,
|
|
44
|
-
isCodexAccountPriorityKey,
|
|
45
|
-
pinnedCodexAccountId,
|
|
46
|
-
setCodexAccountPin,
|
|
47
|
-
setCodexAccountPriority,
|
|
48
|
-
} from "./account-priority";
|
|
49
|
-
import {
|
|
50
|
-
claimDueCodexQuotaRecoveryProbes,
|
|
51
|
-
codexQuotaScopeForModel,
|
|
52
|
-
claimManualResetCooldowns,
|
|
53
|
-
settleManualResetCooldown,
|
|
54
|
-
type ManualResetCooldownClaim,
|
|
55
|
-
type ManualResetRefreshLineage,
|
|
56
|
-
clearCodexAccountCooldown,
|
|
57
|
-
clearThreadAccountMapForAccount,
|
|
58
|
-
getEffectiveActiveCodexAccountId,
|
|
59
|
-
isEffectiveCodexAccountPinned,
|
|
60
|
-
isCodexAccountPlanExcluded,
|
|
61
|
-
reconcileCodexActiveAfterExclusion,
|
|
62
|
-
resetCodexRoutingForManualSelection,
|
|
63
|
-
settleCodexQuotaRecoveryProbe,
|
|
64
|
-
} from "./routing";
|
|
65
|
-
import {
|
|
66
|
-
DEFAULT_ACCOUNT_PRIORITY,
|
|
67
|
-
MAX_ACCOUNT_PRIORITY,
|
|
68
|
-
MIN_ACCOUNT_PRIORITY,
|
|
69
|
-
normalizeAccountPoolStickyLimit,
|
|
70
|
-
normalizeCodexAccountPoolStrategy,
|
|
71
|
-
parseAccountPoolStickyLimit,
|
|
72
|
-
parseCodexAccountPoolStrategy,
|
|
73
|
-
parseAccountPriority,
|
|
74
|
-
} from "./pool-rotation";
|
|
75
|
-
import { checkAccountIdCollision, getMainChatgptAccountId, readCodexTokens, readCodexTokensResult } from "./auth-collision";
|
|
76
|
-
import { codexPlanValue, isThirtyDayOnlyCodexPlan } from "./plan";
|
|
77
1
|
export { checkAccountIdCollision, getMainChatgptAccountId } from "./auth-collision";
|
|
78
2
|
export { clearAccountNeedsReauth, isAccountNeedsReauth, markAccountNeedsReauth } from "./account-runtime-state";
|
|
79
|
-
import { clearAccountNeedsReauth, isAccountNeedsReauth, markAccountNeedsReauth } from "./account-runtime-state";
|
|
80
|
-
import {
|
|
81
|
-
clearAccountQuota,
|
|
82
|
-
getAccountQuota,
|
|
83
|
-
isCompleteCodexQuotaRecoverySnapshot,
|
|
84
|
-
isCodexQuotaExhausted,
|
|
85
|
-
listAccountQuotas,
|
|
86
|
-
parseMainPolicyUsageQuota,
|
|
87
|
-
parseUsageQuota,
|
|
88
|
-
setAccountQuotaFromParsed,
|
|
89
|
-
updateAccountQuota,
|
|
90
|
-
withoutRetiredCodexQuota,
|
|
91
|
-
type StoredAccountQuota,
|
|
92
|
-
type WhamUsageResponse,
|
|
93
|
-
} from "./quota";
|
|
94
3
|
export {
|
|
95
4
|
applyAccountQuotaFromUpstreamHeaders,
|
|
96
5
|
clearAccountQuota,
|
|
@@ -99,3036 +8,36 @@ export {
|
|
|
99
8
|
setAccountQuotaFromParsed,
|
|
100
9
|
updateAccountQuota,
|
|
101
10
|
} from "./quota";
|
|
102
|
-
import { extractAccountId } from "../oauth/chatgpt";
|
|
103
|
-
import {
|
|
104
|
-
getMainAccountPlan, getValidMainAccountToken, isMainAccountTokenVerifiablyLive,
|
|
105
|
-
MainAccountTokenRefreshError, MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan,
|
|
106
|
-
} from "./main-account";
|
|
107
|
-
import { captureConfigGeneration, registerStateSweepAfterTick } from "../lib/state-store-sweeper";
|
|
108
|
-
import { reconcileLiveStateStores } from "../lib/state-store-registrations";
|
|
109
|
-
import {
|
|
110
|
-
captureMainAccountIdentityGeneration,
|
|
111
|
-
clearMainAccountInfoCache,
|
|
112
|
-
getMainAccountCredentialPresence,
|
|
113
|
-
getMainAccountInfoCache,
|
|
114
|
-
getMainQuotaCredentialGeneration,
|
|
115
|
-
isMainAccountIdentityGenerationLive,
|
|
116
|
-
isMainQuotaWriterLive,
|
|
117
|
-
type MainQuotaWriter,
|
|
118
|
-
matchesMainQuotaCredential,
|
|
119
|
-
observeMainQuotaCredential,
|
|
120
|
-
setMainAccountCredentialPresence,
|
|
121
|
-
setMainAccountInfoCache,
|
|
122
|
-
type MainAccountInfo,
|
|
123
|
-
} from "./main-account-cache";
|
|
124
11
|
export { clearMainAccountInfoCache } from "./main-account-cache";
|
|
125
|
-
import type { CodexQuotaRefreshOutcome } from "./quota-refresh-outcome";
|
|
126
|
-
import { getMainAccountHardLockStatus, type MainAccountHardLockStatus } from "./main-account-hard-lock";
|
|
127
|
-
import { observeMainReserveRevocation } from "./reserve-availability";
|
|
128
|
-
import { emailMaskingEnabled, projectEmail } from "../lib/privacy";
|
|
129
|
-
import { codexWarmupFailureReason, isCodexWarmupProvisioningFailure, warmCodexAccount } from "./warmup";
|
|
130
12
|
export { maskEmail } from "../lib/privacy";
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
} from "./
|
|
155
|
-
import {
|
|
156
|
-
import {
|
|
157
|
-
import {
|
|
158
|
-
import { withNativeMainSharedClaim } from "./native-main-claim";
|
|
159
|
-
import { resolveNativeProfileContext } from "./native-profile-store";
|
|
160
|
-
import { NativeProfileError } from "./native-profile-types";
|
|
161
|
-
import { WHAM_REQUEST_TIMEOUT_MS } from "./quota-recovery-timing";
|
|
162
|
-
import {
|
|
163
|
-
claimQuotaRecovery,
|
|
164
|
-
quotaRecoveryTerminalFor,
|
|
165
|
-
releaseQuotaRecovery,
|
|
166
|
-
settleQuotaRecovery,
|
|
167
|
-
settleQuotaRecoveryTerminal,
|
|
168
|
-
} from "./quota-401-recovery";
|
|
169
|
-
|
|
170
|
-
function isNativeMainClaimUnavailable(error: unknown): error is NativeProfileError {
|
|
171
|
-
return error instanceof NativeProfileError
|
|
172
|
-
&& (error.code === "NATIVE_MAIN_CLAIM_BUSY" || error.code === "NATIVE_MAIN_CLAIM_UNAVAILABLE");
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
function withNativeMainCredentialClaim<T>(operation: () => Promise<T>): Promise<T> {
|
|
176
|
-
return withNativeMainSharedClaim(resolveNativeProfileContext(), operation);
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
function jsonResponse(data: unknown, status = 200): Response {
|
|
180
|
-
return new Response(JSON.stringify(data), {
|
|
181
|
-
status,
|
|
182
|
-
headers: { "Content-Type": "application/json" },
|
|
183
|
-
});
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
function nativeMainProfileBusyResponse(): Response {
|
|
187
|
-
const response = jsonResponse({ error: "server_busy", code: "server_busy" }, 503);
|
|
188
|
-
response.headers.set("Retry-After", "1");
|
|
189
|
-
return response;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
const CODEX_CREDENTIAL_PERSISTENCE_ERROR = "Account was saved, but credential setup did not complete. Reauthenticate or remove the account.";
|
|
193
|
-
const CODEX_CREDENTIAL_PERSISTENCE_CODE = "codex_credential_persistence_failed";
|
|
194
|
-
|
|
195
|
-
const MAX_CODEX_LOGIN_STATE_ROWS = 32;
|
|
196
|
-
const CODEX_LOGIN_TERMINAL_TTL_MS = 300_000;
|
|
197
|
-
interface CodexLoginStateRow {
|
|
198
|
-
status: string;
|
|
199
|
-
startedAt: number;
|
|
200
|
-
accountId?: string;
|
|
201
|
-
email?: string;
|
|
202
|
-
error?: string;
|
|
203
|
-
code?: string;
|
|
204
|
-
needsReauth?: boolean;
|
|
205
|
-
catalogRefreshPending?: boolean;
|
|
206
|
-
validationPending?: boolean;
|
|
207
|
-
doneAt?: number;
|
|
208
|
-
}
|
|
209
|
-
const codexAuthLoginState = new Map<string, CodexLoginStateRow>();
|
|
210
|
-
export class CodexLoginStateBusyError extends ResourceAdmissionError {
|
|
211
|
-
constructor() { super("codex_login_state_rows", MAX_CODEX_LOGIN_STATE_ROWS); this.name = "CodexLoginStateBusyError"; }
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
function setCodexLoginState(flowId: string, patch: Partial<CodexLoginStateRow>): void {
|
|
215
|
-
const row = codexAuthLoginState.get(flowId);
|
|
216
|
-
if (row) Object.assign(row, patch);
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
function pruneCodexLoginState(now = Date.now()): void {
|
|
220
|
-
for (const [id, row] of codexAuthLoginState) {
|
|
221
|
-
if (row.doneAt !== undefined && now - row.doneAt >= CODEX_LOGIN_TERMINAL_TTL_MS) codexAuthLoginState.delete(id);
|
|
222
|
-
}
|
|
223
|
-
while (codexAuthLoginState.size >= MAX_CODEX_LOGIN_STATE_ROWS) {
|
|
224
|
-
const terminal = [...codexAuthLoginState].filter(([, row]) => row.doneAt !== undefined)
|
|
225
|
-
.sort((a, b) => (a[1].doneAt ?? 0) - (b[1].doneAt ?? 0))[0];
|
|
226
|
-
if (!terminal) break;
|
|
227
|
-
codexAuthLoginState.delete(terminal[0]);
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
function configuredPoolAccount(config: OcxConfig, accountId: string): CodexAccount | null {
|
|
232
|
-
if (!isValidCodexAccountId(accountId)) return null;
|
|
233
|
-
return (config.codexAccounts ?? [])
|
|
234
|
-
.find(account => account.id === accountId && isSelectableCodexPoolAccount(account)) ?? null;
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
function codexAccountPersistenceConflict(
|
|
238
|
-
config: OcxConfig,
|
|
239
|
-
accountId: string,
|
|
240
|
-
mode: "create" | "reauth",
|
|
241
|
-
): string | undefined {
|
|
242
|
-
if (mode === "reauth") {
|
|
243
|
-
return configuredPoolAccount(config, accountId)
|
|
244
|
-
? undefined
|
|
245
|
-
: "Pool account was removed while login was in progress. Add it again as a new account.";
|
|
246
|
-
}
|
|
247
|
-
const namespaceCollision = codexAccountIdNamespaceCollisionError(config.codexAccountNamespaces, accountId);
|
|
248
|
-
if (namespaceCollision) return namespaceCollision;
|
|
249
|
-
return (config.codexAccounts ?? []).some(account => account.id === accountId)
|
|
250
|
-
|| Boolean(getCodexAccountCredential(accountId))
|
|
251
|
-
? `Account id already exists: ${accountId}`
|
|
252
|
-
: undefined;
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
function quotaForPlan<T extends Omit<StoredAccountQuota, "updatedAt"> | StoredAccountQuota | null>(
|
|
256
|
-
quota: T,
|
|
257
|
-
plan: unknown,
|
|
258
|
-
): T | null {
|
|
259
|
-
const visible = withoutRetiredCodexQuota(quota);
|
|
260
|
-
if (!visible || !isThirtyDayOnlyCodexPlan(plan)) return visible;
|
|
261
|
-
const quotaWindows = visible;
|
|
262
|
-
return {
|
|
263
|
-
...(quotaWindows.monthlyPercent !== undefined ? { monthlyPercent: quotaWindows.monthlyPercent } : {}),
|
|
264
|
-
...(quotaWindows.monthlyResetAt !== undefined ? { monthlyResetAt: quotaWindows.monthlyResetAt } : {}),
|
|
265
|
-
// A 30-day plan can still carry a burst window, and it blocks the account on its own.
|
|
266
|
-
// Dropping it here would show a healthy card for an account upstream is refusing (#1791).
|
|
267
|
-
...(quotaWindows.shortPercent !== undefined ? { shortPercent: quotaWindows.shortPercent } : {}),
|
|
268
|
-
...(quotaWindows.shortResetAt !== undefined ? { shortResetAt: quotaWindows.shortResetAt } : {}),
|
|
269
|
-
...(quotaWindows.shortWindowSeconds !== undefined ? { shortWindowSeconds: quotaWindows.shortWindowSeconds } : {}),
|
|
270
|
-
...(quotaWindows.customWindows !== undefined ? { customWindows: quotaWindows.customWindows } : {}),
|
|
271
|
-
...(quotaWindows.resetCredits !== undefined ? { resetCredits: quotaWindows.resetCredits } : {}),
|
|
272
|
-
...("updatedAt" in quotaWindows ? { updatedAt: quotaWindows.updatedAt } : {}),
|
|
273
|
-
} as T;
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
/**
|
|
277
|
-
* Last reset-credit count this process parsed for the main account, tagged with the
|
|
278
|
-
* physical ChatGPT account it was read from.
|
|
279
|
-
*
|
|
280
|
-
* It is deliberately memory-only. The quota store is keyed by the stable `__main__`
|
|
281
|
-
* ALIAS, and `~/.codex/auth.json` can be swapped for another account while the proxy is
|
|
282
|
-
* not running — `reconcileMainCodexAccountRuntimeState` only purges alias-keyed state
|
|
283
|
-
* when it observes the id CHANGE, and its first observation after a restart has nothing
|
|
284
|
-
* to compare against. A disk-hydrated `__main__` entry can therefore belong to the
|
|
285
|
-
* previous login, so filling the DTO from it would show one account's tickets on
|
|
286
|
-
* another's card. Pool accounts have no such hole because their store key IS the account
|
|
287
|
-
* id. Binding the value to `requestAccountId` keeps the fill honest: after a restart the
|
|
288
|
-
* badge simply waits for the first usage response that carries the summary.
|
|
289
|
-
*/
|
|
290
|
-
let mainResetCreditsProvenance: { accountId: string; credits: number } | null = null;
|
|
291
|
-
|
|
292
|
-
function rememberMainResetCredits(accountId: string | null, credits: number | undefined): void {
|
|
293
|
-
if (accountId === null || credits === undefined) return;
|
|
294
|
-
mainResetCreditsProvenance = { accountId, credits };
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
/** Forget the remembered count when the physical main identity is no longer the same. */
|
|
298
|
-
function mainResetCreditsForCurrentIdentity(): number | undefined {
|
|
299
|
-
if (!mainResetCreditsProvenance) return undefined;
|
|
300
|
-
const currentAccountId = getMainChatgptAccountId();
|
|
301
|
-
if (currentAccountId === null) return undefined;
|
|
302
|
-
if (currentAccountId !== mainResetCreditsProvenance.accountId) {
|
|
303
|
-
mainResetCreditsProvenance = null;
|
|
304
|
-
return undefined;
|
|
305
|
-
}
|
|
306
|
-
return mainResetCreditsProvenance.credits;
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
/**
|
|
310
|
-
* The main account is the only account whose DTO quota comes from the raw WHAM parse
|
|
311
|
-
* result instead of the merged store: `poolAccountDto` serializes what
|
|
312
|
-
* `commitPoolQuotaResponse` read back out of `getAccountQuota()`, while the main DTO
|
|
313
|
-
* spreads `mainInfo.quota` directly. `/wham/usage` carries `rate_limit_reset_credits`
|
|
314
|
-
* only intermittently, and the store exists to bridge that gap
|
|
315
|
-
* (`setAccountQuotaFromParsed` carries an existing `resetCredits` forward when the new
|
|
316
|
-
* snapshot omits it), so the main card lost its ticket badge on every response that
|
|
317
|
-
* happened to omit the summary while pool cards kept theirs.
|
|
318
|
-
*
|
|
319
|
-
* Only `resetCredits` is carried, deliberately, and only from an identity-tagged
|
|
320
|
-
* in-process observation rather than the alias-keyed store. The window fields have
|
|
321
|
-
* *clearing* semantics — a monthly-only snapshot must drop a stale weekly value (#382) —
|
|
322
|
-
* so reinstating the whole stored object would resurrect a window the parse meant to
|
|
323
|
-
* clear whenever the store write was refused by generation gating. A freshly parsed value
|
|
324
|
-
* always wins, including `0`: zero is defined, so it never takes the fill branch.
|
|
325
|
-
*/
|
|
326
|
-
function mainQuotaWithCarriedResetCredits(
|
|
327
|
-
parsed: Omit<StoredAccountQuota, "updatedAt">,
|
|
328
|
-
): StoredAccountQuota {
|
|
329
|
-
const carried = parsed.resetCredits === undefined
|
|
330
|
-
? mainResetCreditsForCurrentIdentity()
|
|
331
|
-
: undefined;
|
|
332
|
-
return {
|
|
333
|
-
...parsed,
|
|
334
|
-
...(carried !== undefined ? { resetCredits: carried } : {}),
|
|
335
|
-
updatedAt: getAccountQuota(MAIN_CODEX_ACCOUNT_ID)?.updatedAt ?? Date.now(),
|
|
336
|
-
};
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
/**
|
|
340
|
-
* Why an account needs the operator. `missing_credential`, `refresh_failed`, and
|
|
341
|
-
* `quota_unauthorized` are the three causes this surface tells apart on its own. `unauthorized`
|
|
342
|
-
* and `forbidden` exist because the shared health projection may return them; today
|
|
343
|
-
* `projectCodexAccountHealth` only ever produces `refresh_failed`, so accepting the full union
|
|
344
|
-
* keeps this field correct if that projection widens rather than silently dropping a reason.
|
|
345
|
-
*/
|
|
346
|
-
export type CodexAccountReauthReason =
|
|
347
|
-
| "missing_credential"
|
|
348
|
-
| "refresh_failed"
|
|
349
|
-
| "quota_unauthorized"
|
|
350
|
-
| "unauthorized"
|
|
351
|
-
| "forbidden";
|
|
352
|
-
|
|
353
|
-
function poolAccountDto(
|
|
354
|
-
config: OcxConfig,
|
|
355
|
-
account: CodexAccount,
|
|
356
|
-
quotaResult: PoolQuotaResult,
|
|
357
|
-
hasCredential: boolean,
|
|
358
|
-
paused: boolean,
|
|
359
|
-
priority: number,
|
|
360
|
-
maskEmails: boolean,
|
|
361
|
-
): CodexAuthAccountDto {
|
|
362
|
-
const plan = codexPlanValue(account.plan);
|
|
363
|
-
const quota = quotaForPlan(quotaResult.quota, plan);
|
|
364
|
-
const runtimeReauth = isAccountNeedsReauth(account.id);
|
|
365
|
-
const needsReauth = !hasCredential || quotaResult.needsReauth || runtimeReauth;
|
|
366
|
-
const health = projectCodexAccountHealth({ accountId: account.id, needsReauth });
|
|
367
|
-
// `needsReauth` is an OR of three independent causes plus a persisted verdict resolved inside the
|
|
368
|
-
// health projection. Emitting only the boolean is what left #4212's reporter guessing which
|
|
369
|
-
// account took their model away and why, so name the cause they actually have to act on.
|
|
370
|
-
const reauthReason: CodexAccountReauthReason | undefined = !hasCredential
|
|
371
|
-
? "missing_credential"
|
|
372
|
-
: runtimeReauth
|
|
373
|
-
? "refresh_failed"
|
|
374
|
-
: quotaResult.needsReauth
|
|
375
|
-
? "quota_unauthorized"
|
|
376
|
-
: health.status === "reauth_required" ? health.reason : undefined;
|
|
377
|
-
return {
|
|
378
|
-
id: account.id,
|
|
379
|
-
email: projectEmail(account.email, maskEmails) ?? account.email,
|
|
380
|
-
...(account.alias !== undefined ? { alias: account.alias } : {}),
|
|
381
|
-
...(plan !== undefined ? { plan } : {}),
|
|
382
|
-
logLabel: codexAccountLogLabel(account),
|
|
383
|
-
isMain: false,
|
|
384
|
-
paused,
|
|
385
|
-
priority,
|
|
386
|
-
quota: quota ? { ...quota } : null,
|
|
387
|
-
needsReauth: needsReauth || health.status === "reauth_required",
|
|
388
|
-
...(reauthReason !== undefined ? { reauthReason } : {}),
|
|
389
|
-
...(isCodexAccountPlanExcluded(config, account.id) ? {
|
|
390
|
-
selectionExcludedReason: "plan_excluded" as const,
|
|
391
|
-
selectionExcludedPlan: codexPlanValue(config.codexAccounts?.find(row => row.id === account.id)?.plan),
|
|
392
|
-
} : {}),
|
|
393
|
-
hasCredential,
|
|
394
|
-
...(quotaResult.quotaProbeSkipped ? { quotaProbeSkipped: true as const } : {}),
|
|
395
|
-
...oauthAccountHealthFields("codex", account.id, health),
|
|
396
|
-
};
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
interface ResetCreditAuth {
|
|
400
|
-
isMain: boolean;
|
|
401
|
-
accessToken: string;
|
|
402
|
-
chatgptAccountId: string;
|
|
403
|
-
nativeMainLease?: AdmissionLease;
|
|
404
|
-
nativeMainSharedClaimHeld?: true;
|
|
405
|
-
poolGeneration?: number;
|
|
406
|
-
mainProof?: MainResetQuotaProof;
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
async function withResetCreditAuth<T>(
|
|
410
|
-
runtimeConfig: OcxConfig,
|
|
411
|
-
accountId: string,
|
|
412
|
-
operation: (auth: ResetCreditAuth) => Promise<T>,
|
|
413
|
-
): Promise<{ ok: true; value: T } | { ok: false; response: Response }> {
|
|
414
|
-
if (accountId === MAIN_CODEX_ACCOUNT_ID) {
|
|
415
|
-
if (hasLegacyMainCodexPoolAccount(runtimeConfig.codexAccounts)) {
|
|
416
|
-
return { ok: false, response: jsonResponse({ error: "Remove the legacy __main__ pool row before using the Desktop account" }, 409) };
|
|
417
|
-
}
|
|
418
|
-
const nativeMainLease = tryAcquireNativeMainProfileClaim();
|
|
419
|
-
if (!nativeMainLease) return { ok: false, response: nativeMainProfileBusyResponse() };
|
|
420
|
-
try {
|
|
421
|
-
try {
|
|
422
|
-
return await withNativeMainCredentialClaim(async () => {
|
|
423
|
-
const tokens = readCodexTokens();
|
|
424
|
-
if (!tokens) {
|
|
425
|
-
return { ok: false, response: jsonResponse({ error: "Main Codex account not logged in" }, 401) };
|
|
426
|
-
}
|
|
427
|
-
reconcileMainCodexAccountRuntimeState();
|
|
428
|
-
const physicalId = extractAccountId(tokens.id_token, tokens.access_token) ?? tokens.account_id;
|
|
429
|
-
const writer = physicalId === tokens.account_id
|
|
430
|
-
? observeMainQuotaCredential(tokens.access_token, tokens.account_id) : undefined;
|
|
431
|
-
return {
|
|
432
|
-
ok: true,
|
|
433
|
-
value: await operation({
|
|
434
|
-
isMain: true,
|
|
435
|
-
...(writer ? { mainProof: { writer, credentialGeneration: getMainQuotaCredentialGeneration() } } : {}),
|
|
436
|
-
accessToken: tokens.access_token,
|
|
437
|
-
chatgptAccountId: tokens.account_id,
|
|
438
|
-
nativeMainLease,
|
|
439
|
-
nativeMainSharedClaimHeld: true,
|
|
440
|
-
}),
|
|
441
|
-
};
|
|
442
|
-
});
|
|
443
|
-
} catch (error) {
|
|
444
|
-
if (isNativeMainClaimUnavailable(error)) {
|
|
445
|
-
return { ok: false, response: nativeMainProfileBusyResponse() };
|
|
446
|
-
}
|
|
447
|
-
throw error;
|
|
448
|
-
}
|
|
449
|
-
} finally {
|
|
450
|
-
nativeMainLease.release();
|
|
451
|
-
}
|
|
452
|
-
}
|
|
453
|
-
if (!isValidCodexAccountId(accountId)) {
|
|
454
|
-
return { ok: false, response: jsonResponse({ error: "Invalid account id format" }, 400) };
|
|
455
|
-
}
|
|
456
|
-
if (!configuredPoolAccount(runtimeConfig, accountId)) {
|
|
457
|
-
return { ok: false, response: jsonResponse({ error: "Unknown Codex account" }, 404) };
|
|
458
|
-
}
|
|
459
|
-
const cred = await getValidCodexToken(accountId);
|
|
460
|
-
return {
|
|
461
|
-
ok: true,
|
|
462
|
-
value: await operation({
|
|
463
|
-
isMain: false,
|
|
464
|
-
poolGeneration: cred.generation,
|
|
465
|
-
accessToken: cred.accessToken,
|
|
466
|
-
chatgptAccountId: cred.chatgptAccountId,
|
|
467
|
-
}),
|
|
468
|
-
};
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
function safeResetCreditsDto(input: unknown): { credits: { granted_at: string; expires_at: string }[]; available_count?: number } {
|
|
472
|
-
const obj = typeof input === "object" && input !== null ? input as Record<string, unknown> : {};
|
|
473
|
-
const rawCredits = Array.isArray(obj.credits) ? obj.credits : [];
|
|
474
|
-
const credits = rawCredits.flatMap((raw): { granted_at: string; expires_at: string }[] => {
|
|
475
|
-
if (typeof raw !== "object" || raw === null) return [];
|
|
476
|
-
const credit = raw as Record<string, unknown>;
|
|
477
|
-
return typeof credit.granted_at === "string" && typeof credit.expires_at === "string"
|
|
478
|
-
? [{ granted_at: credit.granted_at, expires_at: credit.expires_at }]
|
|
479
|
-
: [];
|
|
480
|
-
});
|
|
481
|
-
const rawAvailable = (obj.rate_limit_reset_credits as { available_count?: unknown } | null | undefined)?.available_count
|
|
482
|
-
?? obj.available_count;
|
|
483
|
-
return {
|
|
484
|
-
credits,
|
|
485
|
-
...(typeof rawAvailable === "number" && Number.isFinite(rawAvailable) ? { available_count: rawAvailable } : {}),
|
|
486
|
-
};
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
function safeResetCreditConsumeDto(input: unknown): { code: string } {
|
|
490
|
-
const obj = typeof input === "object" && input !== null ? input as Record<string, unknown> : {};
|
|
491
|
-
return { code: typeof obj.code === "string" ? obj.code : "unknown" };
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
/**
|
|
495
|
-
* Background reset-credit access for the auto-redeemer (#822). Goes through the same
|
|
496
|
-
* account/lease wrapper as the management routes, but takes a caller-owned
|
|
497
|
-
* `redeem_request_id` so a journaled id can be replayed idempotently after a crash.
|
|
498
|
-
* Throws on any auth or upstream failure; the caller treats a throw on consume as ambiguous.
|
|
499
|
-
*/
|
|
500
|
-
export function createResetCreditWhamClient(config: OcxConfig, accountId: string): {
|
|
501
|
-
inspect: () => Promise<{ credits: { granted_at: string; expires_at: string }[] }>;
|
|
502
|
-
consume: (redeemRequestId: string) => Promise<{ code: string }>;
|
|
503
|
-
} {
|
|
504
|
-
const run = async <T>(operation: (auth: ResetCreditAuth) => Promise<T>): Promise<T> => {
|
|
505
|
-
const result = await withResetCreditAuth(getRuntimeConfig(config), accountId, operation);
|
|
506
|
-
if (result.ok) return result.value;
|
|
507
|
-
throw new Error(`reset-credit auth unavailable (${result.response.status})`);
|
|
508
|
-
};
|
|
509
|
-
return {
|
|
510
|
-
inspect: () => run(async auth => {
|
|
511
|
-
const resp = await fetch("https://chatgpt.com/backend-api/wham/rate-limit-reset-credits", {
|
|
512
|
-
headers: { Authorization: `Bearer ${auth.accessToken}`, "ChatGPT-Account-Id": auth.chatgptAccountId },
|
|
513
|
-
signal: AbortSignal.timeout(8000),
|
|
514
|
-
});
|
|
515
|
-
if (!resp.ok) { await resp.body?.cancel().catch(() => {}); throw new Error(`upstream ${resp.status}`); }
|
|
516
|
-
const parsed = await readResetCreditJson(resp, AbortSignal.timeout(8000));
|
|
517
|
-
if (!parsed.ok) throw new Error("invalid upstream reset-credit response");
|
|
518
|
-
return { credits: safeResetCreditsDto(parsed.value).credits };
|
|
519
|
-
}),
|
|
520
|
-
consume: redeemRequestId => run(async auth => {
|
|
521
|
-
const resp = await fetch("https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume", {
|
|
522
|
-
method: "POST",
|
|
523
|
-
headers: {
|
|
524
|
-
Authorization: `Bearer ${auth.accessToken}`,
|
|
525
|
-
"ChatGPT-Account-Id": auth.chatgptAccountId,
|
|
526
|
-
"Content-Type": "application/json",
|
|
527
|
-
},
|
|
528
|
-
body: JSON.stringify({ redeem_request_id: redeemRequestId }),
|
|
529
|
-
signal: AbortSignal.timeout(10_000),
|
|
530
|
-
});
|
|
531
|
-
if (!resp.ok) { await resp.body?.cancel().catch(() => {}); throw new Error(`upstream ${resp.status}`); }
|
|
532
|
-
return safeResetCreditConsumeDto(await resp.json());
|
|
533
|
-
}),
|
|
534
|
-
};
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
type ResetCreditJsonRead =
|
|
538
|
-
| { ok: true; value: unknown }
|
|
539
|
-
| { ok: false };
|
|
540
|
-
|
|
541
|
-
function cancelResponseBodyWithoutWaiting(body: ReadableStream<Uint8Array> | null): void {
|
|
542
|
-
if (!body) return;
|
|
543
|
-
try {
|
|
544
|
-
void body.cancel().catch(() => undefined);
|
|
545
|
-
} catch {
|
|
546
|
-
// Some stream implementations throw synchronously from cancel().
|
|
547
|
-
}
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
async function readResetCreditJson(
|
|
551
|
-
response: Response,
|
|
552
|
-
signal: AbortSignal,
|
|
553
|
-
): Promise<ResetCreditJsonRead> {
|
|
554
|
-
const declaredLength = Number(response.headers.get("content-length"));
|
|
555
|
-
if (Number.isSafeInteger(declaredLength)
|
|
556
|
-
&& declaredLength >= 0
|
|
557
|
-
&& declaredLength > BOUNDED_BODY_MAX_BYTES) {
|
|
558
|
-
cancelResponseBodyWithoutWaiting(response.body);
|
|
559
|
-
return { ok: false };
|
|
560
|
-
}
|
|
561
|
-
try {
|
|
562
|
-
const body = await readBoundedResponseBody(response, {
|
|
563
|
-
signal,
|
|
564
|
-
maxBytes: BOUNDED_BODY_MAX_BYTES,
|
|
565
|
-
fatalUtf8: true,
|
|
566
|
-
});
|
|
567
|
-
if (!body.displaySafe || body.truncated || !body.text.trim()) return { ok: false };
|
|
568
|
-
return { ok: true, value: JSON.parse(body.text) as unknown };
|
|
569
|
-
} catch {
|
|
570
|
-
return { ok: false };
|
|
571
|
-
}
|
|
572
|
-
}
|
|
573
|
-
|
|
574
|
-
function manualImportDisabledResponse(): Response {
|
|
575
|
-
return jsonResponse({
|
|
576
|
-
error: "Manual Codex account import is disabled. Use OAuth login to add a pool account.",
|
|
577
|
-
code: "manual_import_disabled",
|
|
578
|
-
}, 403);
|
|
579
|
-
}
|
|
580
|
-
|
|
581
|
-
async function verifyCodexAccountWarmup(
|
|
582
|
-
accountId: string,
|
|
583
|
-
accessToken: string,
|
|
584
|
-
chatgptAccountId: string,
|
|
585
|
-
): Promise<{ ok: true; validatedAt: number } | { ok: false; response: Response }> {
|
|
586
|
-
try {
|
|
587
|
-
await warmCodexAccount({ accessToken, chatgptAccountId });
|
|
588
|
-
return { ok: true, validatedAt: Date.now() };
|
|
589
|
-
} catch (err) {
|
|
590
|
-
const reason = codexWarmupFailureReason(err);
|
|
591
|
-
return {
|
|
592
|
-
ok: false,
|
|
593
|
-
response: jsonResponse({
|
|
594
|
-
// Every fallback model was refused for a provisioning reason, so telling the operator to
|
|
595
|
-
// reauthenticate sends them back through a login that already succeeded.
|
|
596
|
-
error: isCodexWarmupProvisioningFailure(err)
|
|
597
|
-
? "Codex account warmup failed. Verify account model access or provisioning and try again."
|
|
598
|
-
: "Codex account warmup failed. Reauthenticate the account and try again.",
|
|
599
|
-
code: "codex_warmup_failed",
|
|
600
|
-
reason,
|
|
601
|
-
accountId,
|
|
602
|
-
}, 401),
|
|
603
|
-
};
|
|
604
|
-
}
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
function expireCodexAuthFlow(flowId: string | null, error = "Login cancelled"): void {
|
|
608
|
-
const ids = flowId
|
|
609
|
-
? [flowId]
|
|
610
|
-
: [...codexAuthLoginState].filter(([, state]) => state.status === "pending").map(([id]) => id);
|
|
611
|
-
for (const id of ids) {
|
|
612
|
-
let owner = codexAuthLoginState.get(id);
|
|
613
|
-
if (!owner) {
|
|
614
|
-
pruneCodexLoginState();
|
|
615
|
-
if (codexAuthLoginState.size >= MAX_CODEX_LOGIN_STATE_ROWS) continue;
|
|
616
|
-
owner = { status: "error", startedAt: Date.now() };
|
|
617
|
-
codexAuthLoginState.set(id, owner);
|
|
618
|
-
}
|
|
619
|
-
Object.assign(owner, { status: "error", error, doneAt: Date.now() });
|
|
620
|
-
setTimeout(() => { if (codexAuthLoginState.get(id) === owner) codexAuthLoginState.delete(id); }, 30_000);
|
|
621
|
-
}
|
|
622
|
-
}
|
|
623
|
-
|
|
624
|
-
const MAIN_CACHE_TTL = 5 * 60_000;
|
|
625
|
-
const POOL_CACHE_TTL = 5 * 60_000;
|
|
626
|
-
const POOL_QUOTA_REFRESH_CONCURRENCY = 4;
|
|
627
|
-
|
|
628
|
-
function nonEmptyPlan(value: unknown): string | null {
|
|
629
|
-
return codexPlanValue(value) ?? null;
|
|
630
|
-
}
|
|
631
|
-
|
|
632
|
-
function isRuntimeConfig(config: OcxConfig): boolean {
|
|
633
|
-
return !!config && typeof config === "object" && !!config.providers;
|
|
634
|
-
}
|
|
635
|
-
|
|
636
|
-
function getRuntimeConfig(config: OcxConfig): OcxConfig {
|
|
637
|
-
return isRuntimeConfig(config) ? config : loadConfig();
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
function saveRuntimeConfig(sourceConfig: OcxConfig, nextConfig: OcxConfig): void {
|
|
641
|
-
saveConfigPreservingClaudeCode(nextConfig);
|
|
642
|
-
if (sourceConfig === nextConfig || !isRuntimeConfig(sourceConfig)) return;
|
|
643
|
-
for (const key of Object.keys(sourceConfig) as Array<keyof OcxConfig>) {
|
|
644
|
-
delete sourceConfig[key];
|
|
645
|
-
}
|
|
646
|
-
Object.assign(sourceConfig, nextConfig);
|
|
647
|
-
}
|
|
648
|
-
|
|
649
|
-
interface StagedNewCodexAccountState {
|
|
650
|
-
credential: CodexAccountCredentials;
|
|
651
|
-
validatedAt?: number;
|
|
652
|
-
}
|
|
653
|
-
|
|
654
|
-
type PersistNewCodexAccountOutcome =
|
|
655
|
-
| { status: "committed"; pickerVisibilityChanged: boolean }
|
|
656
|
-
| { status: "publication-failed"; pickerVisibilityChanged: boolean };
|
|
657
|
-
|
|
658
|
-
function codexCredentialPersistenceFailure(accountId: string, catalogRefreshPending: boolean) {
|
|
659
|
-
return {
|
|
660
|
-
error: CODEX_CREDENTIAL_PERSISTENCE_ERROR,
|
|
661
|
-
code: CODEX_CREDENTIAL_PERSISTENCE_CODE,
|
|
662
|
-
accountId,
|
|
663
|
-
needsReauth: true as const,
|
|
664
|
-
...(catalogRefreshPending ? { catalogRefreshPending: true as const } : {}),
|
|
665
|
-
};
|
|
666
|
-
}
|
|
667
|
-
|
|
668
|
-
/** Persist config before publishing secret or runtime state under the shared mutation coordinator. */
|
|
669
|
-
function persistNewCodexAccount(
|
|
670
|
-
sourceConfig: OcxConfig,
|
|
671
|
-
runtimeConfig: OcxConfig,
|
|
672
|
-
addedAccount: CodexAccount,
|
|
673
|
-
staged: StagedNewCodexAccountState,
|
|
674
|
-
): PersistNewCodexAccountOutcome {
|
|
675
|
-
return withConfigMutationLockSync(() => {
|
|
676
|
-
const previousConfig = { ...runtimeConfig };
|
|
677
|
-
let pickerVisibilityChanged: boolean;
|
|
678
|
-
try {
|
|
679
|
-
const accounts = [...(runtimeConfig.codexAccounts ?? [])];
|
|
680
|
-
const retainedPickerBindingRestored = codexAccountPickerEnabled(runtimeConfig)
|
|
681
|
-
&& Object.values(runtimeConfig.codexAccountNamespaces ?? {}).includes(addedAccount.id);
|
|
682
|
-
accounts.push(addedAccount);
|
|
683
|
-
runtimeConfig.codexAccounts = accounts;
|
|
684
|
-
|
|
685
|
-
// Presence of the explicit flag distinguishes a dashboard-managed map from
|
|
686
|
-
// a hand-authored legacy map. Preserve manual maps exactly.
|
|
687
|
-
const tracksPickerNamespaces = runtimeConfig.codexAccountPickerEnabled !== undefined;
|
|
688
|
-
if (tracksPickerNamespaces && runtimeConfig.codexAccountNamespaces) {
|
|
689
|
-
runtimeConfig.codexAccountNamespaces = { ...runtimeConfig.codexAccountNamespaces };
|
|
690
|
-
}
|
|
691
|
-
const namespaceAdded = tracksPickerNamespaces
|
|
692
|
-
&& appendDefaultCodexAccountNamespace(runtimeConfig, addedAccount);
|
|
693
|
-
pickerVisibilityChanged = namespaceAdded || retainedPickerBindingRestored;
|
|
694
|
-
saveRuntimeConfig(sourceConfig, runtimeConfig);
|
|
695
|
-
} catch (error) {
|
|
696
|
-
for (const key of Object.keys(runtimeConfig) as Array<keyof OcxConfig>) {
|
|
697
|
-
delete runtimeConfig[key];
|
|
698
|
-
}
|
|
699
|
-
Object.assign(runtimeConfig, previousConfig);
|
|
700
|
-
throw error;
|
|
701
|
-
}
|
|
702
|
-
|
|
703
|
-
try {
|
|
704
|
-
const generation = saveCodexAccountCredential(addedAccount.id, staged.credential, {
|
|
705
|
-
validationPending: staged.validatedAt === undefined,
|
|
706
|
-
});
|
|
707
|
-
if (staged.validatedAt !== undefined) markCodexAccountValidated(addedAccount.id, staged.validatedAt, generation);
|
|
708
|
-
clearAccountNeedsReauth(addedAccount.id);
|
|
709
|
-
} catch {
|
|
710
|
-
// Config is already durable. Return the failure outcome through the coordinator so its
|
|
711
|
-
// generation commit is not rolled back while config.json remains changed.
|
|
712
|
-
return { status: "publication-failed" as const, pickerVisibilityChanged };
|
|
713
|
-
}
|
|
714
|
-
return { status: "committed" as const, pickerVisibilityChanged };
|
|
715
|
-
});
|
|
716
|
-
}
|
|
717
|
-
|
|
718
|
-
/** Bounded catalog-convergence callback supplied by the management dispatcher. */
|
|
719
|
-
export type CodexAuthCatalogConvergence = () => Promise<CatalogDisposition>;
|
|
720
|
-
|
|
721
|
-
interface AccountNamespaceCatalogRefresh {
|
|
722
|
-
catalogRefreshPending: boolean;
|
|
723
|
-
}
|
|
724
|
-
|
|
725
|
-
/** Collapse post-persistence convergence into the one public recovery bit. */
|
|
726
|
-
async function convergeAccountNamespaceCatalog(
|
|
727
|
-
config: OcxConfig,
|
|
728
|
-
changed: boolean,
|
|
729
|
-
convergeCodexCatalog?: CodexAuthCatalogConvergence,
|
|
730
|
-
): Promise<AccountNamespaceCatalogRefresh> {
|
|
731
|
-
if (!changed || !codexAccountPickerEnabled(config)) {
|
|
732
|
-
return { catalogRefreshPending: false };
|
|
733
|
-
}
|
|
734
|
-
if (!convergeCodexCatalog) return { catalogRefreshPending: true };
|
|
735
|
-
|
|
736
|
-
try {
|
|
737
|
-
const catalogRefresh = normalizeCatalogDisposition(await convergeCodexCatalog());
|
|
738
|
-
if (!catalogRefresh) return { catalogRefreshPending: true };
|
|
739
|
-
return { catalogRefreshPending: catalogRefreshIsPending(catalogRefresh) };
|
|
740
|
-
} catch {
|
|
741
|
-
return { catalogRefreshPending: true };
|
|
742
|
-
}
|
|
743
|
-
}
|
|
744
|
-
|
|
745
|
-
async function mapWithConcurrency<T, R>(
|
|
746
|
-
items: T[],
|
|
747
|
-
concurrency: number,
|
|
748
|
-
mapper: (item: T) => Promise<R>,
|
|
749
|
-
): Promise<R[]> {
|
|
750
|
-
const results = new Array<R>(items.length);
|
|
751
|
-
let next = 0;
|
|
752
|
-
const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
|
|
753
|
-
while (next < items.length) {
|
|
754
|
-
const index = next++;
|
|
755
|
-
results[index] = await mapper(items[index]!);
|
|
756
|
-
}
|
|
757
|
-
});
|
|
758
|
-
await Promise.all(workers);
|
|
759
|
-
return results;
|
|
760
|
-
}
|
|
761
|
-
|
|
762
|
-
const MAIN_TERMINAL_AUTH_CODES = new Set([
|
|
763
|
-
"invalid_workspace_selected",
|
|
764
|
-
"invalid_refresh_token",
|
|
765
|
-
]);
|
|
766
|
-
|
|
767
|
-
/**
|
|
768
|
-
* A WHAM 401 is not itself proof the local credential died. Upstream edges can
|
|
769
|
-
* transiently reject a still-valid access token (region/anti-abuse/rotation
|
|
770
|
-
* races), and fail-closing on every bare 401 makes a healthy main account flip
|
|
771
|
-
* needs-reauth on the next GUI quota poll. Only treat the response as terminal
|
|
772
|
-
* when the body carries a known terminal code or the local access token is not
|
|
773
|
-
* verifiably live (`accessTokenLive`). Liveness must be strict: a JWT whose
|
|
774
|
-
* `exp` cannot be decoded is NOT live — an undecodable token that vouched for
|
|
775
|
-
* itself would make a real 401 permanently transient.
|
|
776
|
-
*/
|
|
777
|
-
async function isTerminalMainAuthResponse(resp: Response, accessTokenLive: boolean): Promise<boolean> {
|
|
778
|
-
if (resp.status === 401) {
|
|
779
|
-
if (!accessTokenLive) return true;
|
|
780
|
-
const code = await readMainAuthErrorCode(resp);
|
|
781
|
-
return typeof code === "string" && MAIN_TERMINAL_AUTH_CODES.has(code);
|
|
782
|
-
}
|
|
783
|
-
if (resp.status !== 403) return false;
|
|
784
|
-
const code = await readMainAuthErrorCode(resp);
|
|
785
|
-
return typeof code === "string" && MAIN_TERMINAL_AUTH_CODES.has(code);
|
|
786
|
-
}
|
|
787
|
-
|
|
788
|
-
async function readMainAuthErrorCode(resp: Response): Promise<unknown> {
|
|
789
|
-
try {
|
|
790
|
-
const body = await readBoundedResponseBody(resp, { totalTimeoutMs: 1_000, inactivityTimeoutMs: 1_000 });
|
|
791
|
-
if (!body.displaySafe) return undefined;
|
|
792
|
-
const parsed = JSON.parse(body.text) as {
|
|
793
|
-
detail?: { code?: unknown } | string;
|
|
794
|
-
error?: { code?: unknown } | string;
|
|
795
|
-
code?: unknown;
|
|
796
|
-
};
|
|
797
|
-
const code = typeof parsed.detail === "object" && parsed.detail !== null
|
|
798
|
-
? parsed.detail.code
|
|
799
|
-
: typeof parsed.error === "object" && parsed.error !== null
|
|
800
|
-
? parsed.error.code
|
|
801
|
-
: parsed.code;
|
|
802
|
-
return code;
|
|
803
|
-
} catch {
|
|
804
|
-
return undefined;
|
|
805
|
-
}
|
|
806
|
-
}
|
|
807
|
-
|
|
808
|
-
interface MainResetQuotaProof {
|
|
809
|
-
writer: MainQuotaWriter;
|
|
810
|
-
credentialGeneration: number;
|
|
811
|
-
}
|
|
812
|
-
|
|
813
|
-
interface MainAccountInfoFetchResult {
|
|
814
|
-
info: MainAccountInfo;
|
|
815
|
-
resetRecoveryProof?: MainResetQuotaProof & { dispatchSequence: number };
|
|
816
|
-
/** Ephemeral result of this attempt, omitted when no WHAM request was made. */
|
|
817
|
-
quotaRefresh?: CodexQuotaRefreshOutcome;
|
|
818
|
-
/** Internal dispatch fence for diagnostics only; never copied into a public DTO or cache. */
|
|
819
|
-
quotaRefreshGeneration?: number;
|
|
820
|
-
/** Whether this attempt safely inspected the physical native-main credential. */
|
|
821
|
-
credentialChecked: boolean;
|
|
822
|
-
/** Meaningful only when credentialChecked is true. */
|
|
823
|
-
hasCredential: boolean;
|
|
824
|
-
/** Main identity generation captured while the native-main claim was held. */
|
|
825
|
-
identityGeneration?: number;
|
|
826
|
-
/** Present only when this call freshly parsed a WHAM usage response. */
|
|
827
|
-
freshQuota?: Omit<StoredAccountQuota, "updatedAt">;
|
|
828
|
-
/** Present only when this call's WHAM response included `rate_limit_reset_credits.available_count`. */
|
|
829
|
-
freshResetCredits?: number;
|
|
830
|
-
}
|
|
831
|
-
|
|
832
|
-
export interface MainAccountInfoSnapshot {
|
|
833
|
-
info: MainAccountInfo;
|
|
834
|
-
mainIdentityGeneration: number;
|
|
835
|
-
quotaRefresh?: CodexQuotaRefreshOutcome;
|
|
836
|
-
}
|
|
837
|
-
|
|
838
|
-
export async function fetchMainAccountInfoSnapshot(forceRefresh = false): Promise<MainAccountInfoSnapshot> {
|
|
839
|
-
const result = await fetchMainAccountInfoAttempt(forceRefresh, 1);
|
|
840
|
-
return {
|
|
841
|
-
info: result.info,
|
|
842
|
-
...(result.quotaRefresh && result.quotaRefreshGeneration !== undefined
|
|
843
|
-
&& isMainAccountIdentityGenerationLive(result.quotaRefreshGeneration)
|
|
844
|
-
? { quotaRefresh: result.quotaRefresh } : {}),
|
|
845
|
-
mainIdentityGeneration: result.identityGeneration ?? captureMainAccountIdentityGeneration(),
|
|
846
|
-
};
|
|
847
|
-
}
|
|
848
|
-
|
|
849
|
-
export async function fetchMainAccountInfo(forceRefresh = false): Promise<MainAccountInfo> {
|
|
850
|
-
return (await fetchMainAccountInfoSnapshot(forceRefresh)).info;
|
|
851
|
-
}
|
|
852
|
-
|
|
853
|
-
const EMPTY_MAIN_ACCOUNT_INFO: MainAccountInfo = { email: null, plan: null, quota: null };
|
|
854
|
-
|
|
855
|
-
async function retryMainAccountInfoIfIdentityChanged(
|
|
856
|
-
requestAccountId: string | null,
|
|
857
|
-
retriesRemaining: number,
|
|
858
|
-
nativeMainLease: AdmissionLease,
|
|
859
|
-
explicitRefresh: boolean,
|
|
860
|
-
): Promise<MainAccountInfoFetchResult | null> {
|
|
861
|
-
const currentAccountId = getMainChatgptAccountId();
|
|
862
|
-
if (currentAccountId === null || currentAccountId === requestAccountId) return null;
|
|
863
|
-
reconcileMainCodexAccountRuntimeState();
|
|
864
|
-
return retriesRemaining > 0
|
|
865
|
-
? fetchMainAccountInfoWhileOwned(true, retriesRemaining - 1, nativeMainLease, explicitRefresh)
|
|
866
|
-
: { info: EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: true, hasCredential: true };
|
|
867
|
-
}
|
|
868
|
-
|
|
869
|
-
async function fetchMainAccountInfoAttempt(
|
|
870
|
-
forceRefresh: boolean,
|
|
871
|
-
retriesRemaining: number,
|
|
872
|
-
existingNativeMainLease?: AdmissionLease,
|
|
873
|
-
nativeMainSharedClaimHeld = false,
|
|
874
|
-
explicitRefresh: boolean = forceRefresh,
|
|
875
|
-
): Promise<MainAccountInfoFetchResult> {
|
|
876
|
-
const nativeMainLease = existingNativeMainLease ?? tryAcquireNativeMainProfileClaim();
|
|
877
|
-
if (!nativeMainLease) {
|
|
878
|
-
return {
|
|
879
|
-
info: EMPTY_MAIN_ACCOUNT_INFO,
|
|
880
|
-
credentialChecked: false,
|
|
881
|
-
hasCredential: false,
|
|
882
|
-
identityGeneration: captureMainAccountIdentityGeneration(),
|
|
883
|
-
};
|
|
884
|
-
}
|
|
885
|
-
try {
|
|
886
|
-
const operation = async () => ({
|
|
887
|
-
...await fetchMainAccountInfoWhileOwned(forceRefresh, retriesRemaining, nativeMainLease, explicitRefresh),
|
|
888
|
-
identityGeneration: captureMainAccountIdentityGeneration(),
|
|
889
|
-
});
|
|
890
|
-
if (nativeMainSharedClaimHeld) return await operation();
|
|
891
|
-
try {
|
|
892
|
-
return await withNativeMainCredentialClaim(operation);
|
|
893
|
-
} catch (error) {
|
|
894
|
-
if (isNativeMainClaimUnavailable(error)) {
|
|
895
|
-
return {
|
|
896
|
-
info: EMPTY_MAIN_ACCOUNT_INFO,
|
|
897
|
-
credentialChecked: false,
|
|
898
|
-
hasCredential: false,
|
|
899
|
-
identityGeneration: captureMainAccountIdentityGeneration(),
|
|
900
|
-
};
|
|
901
|
-
}
|
|
902
|
-
throw error;
|
|
903
|
-
}
|
|
904
|
-
} finally {
|
|
905
|
-
if (!existingNativeMainLease) nativeMainLease.release();
|
|
906
|
-
}
|
|
907
|
-
}
|
|
908
|
-
|
|
909
|
-
async function fetchMainAccountInfoWhileOwned(
|
|
910
|
-
forceRefresh: boolean,
|
|
911
|
-
retriesRemaining: number,
|
|
912
|
-
nativeMainLease: AdmissionLease,
|
|
913
|
-
/**
|
|
914
|
-
* Whether the *caller* asked for this refresh. `forceRefresh` also means "bypass the
|
|
915
|
-
* cache", and `retryMainAccountInfoIfIdentityChanged` re-enters with it set purely to
|
|
916
|
-
* re-read after the identity changed. Keeping the two apart stops that retry from
|
|
917
|
-
* promoting a background poll into operator intent below.
|
|
918
|
-
*/
|
|
919
|
-
explicitRefresh: boolean = forceRefresh,
|
|
920
|
-
): Promise<MainAccountInfoFetchResult> {
|
|
921
|
-
const writerGeneration = captureConfigGeneration();
|
|
922
|
-
reconcileMainCodexAccountRuntimeState();
|
|
923
|
-
const tokenRead = readCodexTokensResult();
|
|
924
|
-
setMainAccountCredentialPresence(tokenRead.status === "ok");
|
|
925
|
-
if (tokenRead.status !== "ok") {
|
|
926
|
-
// A local read failure is NOT proof of sign-out: a missing file can be a non-atomic rewrite
|
|
927
|
-
// gap, and malformed JSON can be a half-written file. Clearing the cache and marking the
|
|
928
|
-
// account for reauth here destroyed healthy email/plan/quota state and pinned a working
|
|
929
|
-
// account as unusable. Preserve what we already know and let the caller retry; request
|
|
930
|
-
// routing stays fail-closed because getMainAccountToken() re-reads the file itself, and the
|
|
931
|
-
// account DTO still reports hasCredential=false while the file is unreadable.
|
|
932
|
-
const preserved = getMainAccountInfoCache();
|
|
933
|
-
return { info: preserved ?? EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: true, hasCredential: false };
|
|
934
|
-
}
|
|
935
|
-
const tokens = tokenRead.tokens;
|
|
936
|
-
const requestAccountId = extractAccountId(tokens.id_token, tokens.access_token) ?? (tokens.account_id || null);
|
|
937
|
-
const cached = getMainAccountInfoCache();
|
|
938
|
-
if (!forceRefresh && cached && Date.now() - cached.ts < MAIN_CACHE_TTL) {
|
|
939
|
-
return { info: cached, credentialChecked: true, hasCredential: true };
|
|
940
|
-
}
|
|
941
|
-
// Bind quota to the owned credential and the account actually selected by WHAM's header.
|
|
942
|
-
// A conflicting legacy token/account tuple is not evidence for the new policy.
|
|
943
|
-
const mainQuotaWriter = requestAccountId === tokens.account_id
|
|
944
|
-
? observeMainQuotaCredential(tokens.access_token, tokens.account_id)
|
|
945
|
-
: undefined;
|
|
946
|
-
const mainQuotaCredentialGeneration = getMainQuotaCredentialGeneration();
|
|
947
|
-
// Keep diagnostics separate from authentication and freshness policy. Never serialize errors.
|
|
948
|
-
const quotaSignal = AbortSignal.timeout(WHAM_REQUEST_TIMEOUT_MS);
|
|
949
|
-
let quotaPhase: "request" | "body" | "decode" | "publish" = "request";
|
|
950
|
-
let quotaRefreshGeneration = captureMainAccountIdentityGeneration();
|
|
951
|
-
try {
|
|
952
|
-
const dispatchSequence = ++quotaDispatchSequence;
|
|
953
|
-
const resp = await fetch("https://chatgpt.com/backend-api/wham/usage", {
|
|
954
|
-
headers: { Authorization: `Bearer ${tokens.access_token}`, "ChatGPT-Account-Id": tokens.account_id },
|
|
955
|
-
signal: quotaSignal,
|
|
956
|
-
});
|
|
957
|
-
quotaPhase = "publish";
|
|
958
|
-
if (!resp.ok) {
|
|
959
|
-
const terminalAuthFailure = await isTerminalMainAuthResponse(resp, isMainAccountTokenVerifiablyLive());
|
|
960
|
-
const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease, explicitRefresh);
|
|
961
|
-
if (retried) return retried;
|
|
962
|
-
if (dispatchSequence < mainQuotaPublishedSequence) {
|
|
963
|
-
return { info: getMainAccountInfoCache() ?? EMPTY_MAIN_ACCOUNT_INFO,
|
|
964
|
-
credentialChecked: true, hasCredential: true };
|
|
965
|
-
}
|
|
966
|
-
if (terminalAuthFailure) {
|
|
967
|
-
// Account for this attempt's own synchronous invalidation, never prior external drift.
|
|
968
|
-
const diagnosticStillLive = isMainAccountIdentityGenerationLive(quotaRefreshGeneration);
|
|
969
|
-
clearMainAccountInfoCache();
|
|
970
|
-
if (diagnosticStillLive) quotaRefreshGeneration = captureMainAccountIdentityGeneration();
|
|
971
|
-
markAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID, writerGeneration);
|
|
972
|
-
}
|
|
973
|
-
return {
|
|
974
|
-
info: EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: true, hasCredential: true,
|
|
975
|
-
quotaRefresh: { status: "http_error", httpStatus: resp.status },
|
|
976
|
-
quotaRefreshGeneration,
|
|
977
|
-
};
|
|
978
|
-
}
|
|
979
|
-
quotaPhase = "body";
|
|
980
|
-
const data = (await resp.json()) as WhamUsageResponse;
|
|
981
|
-
quotaPhase = "publish";
|
|
982
|
-
const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease, explicitRefresh);
|
|
983
|
-
if (retried) return retried;
|
|
984
|
-
quotaPhase = "decode";
|
|
985
|
-
if (data === null || typeof data !== "object" || Array.isArray(data)) {
|
|
986
|
-
throw new Error("Invalid WHAM usage object");
|
|
987
|
-
}
|
|
988
|
-
// Check after body/retry awaits and before any cache, credits, policy or
|
|
989
|
-
// Reserve publication. Returning cached state supplies no fresh recovery proof.
|
|
990
|
-
if (dispatchSequence < mainQuotaPublishedSequence) {
|
|
991
|
-
return { info: getMainAccountInfoCache() ?? EMPTY_MAIN_ACCOUNT_INFO,
|
|
992
|
-
credentialChecked: true, hasCredential: true };
|
|
993
|
-
}
|
|
994
|
-
quotaPhase = "publish";
|
|
995
|
-
// A delayed response from a replaced bearer cannot revoke a newer Reserve grant,
|
|
996
|
-
// even in the same workspace or after an A→B→A credential transition.
|
|
997
|
-
if (mainQuotaCredentialGeneration === getMainQuotaCredentialGeneration()
|
|
998
|
-
&& matchesMainQuotaCredential(tokens.access_token, tokens.account_id)) {
|
|
999
|
-
observeMainReserveRevocation(data, mainQuotaWriter);
|
|
1000
|
-
}
|
|
1001
|
-
quotaPhase = "decode";
|
|
1002
|
-
const plan = nonEmptyPlan(data.plan_type) ?? nonEmptyPlan(cached?.plan) ?? nonEmptyPlan(getMainAccountPlan());
|
|
1003
|
-
const usage = { ...data, ...(plan ? { plan_type: plan } : {}) };
|
|
1004
|
-
const quota = parseUsageQuota(usage);
|
|
1005
|
-
const policyQuota = parseMainPolicyUsageQuota(usage);
|
|
1006
|
-
quotaPhase = "publish";
|
|
1007
|
-
const freshResetCredits = quota?.resetCredits;
|
|
1008
|
-
// Tag the count with the identity it was read from, so a later response that omits the
|
|
1009
|
-
// summary can restore the badge without ever crossing an account boundary.
|
|
1010
|
-
rememberMainResetCredits(requestAccountId, freshResetCredits);
|
|
1011
|
-
const result = {
|
|
1012
|
-
email: data.email ?? null,
|
|
1013
|
-
plan,
|
|
1014
|
-
quota,
|
|
1015
|
-
ts: Date.now(),
|
|
1016
|
-
};
|
|
1017
|
-
setMainAccountInfoCache(result);
|
|
1018
|
-
// Only an explicit refresh may retract a reauth quarantine. A 200 from
|
|
1019
|
-
// /wham/usage proves the token authenticates to the usage endpoint; it does not
|
|
1020
|
-
// prove the account can serve Responses traffic, which is a different backend path
|
|
1021
|
-
// and still answers 403 for a workspace the token may no longer select (#327).
|
|
1022
|
-
// Letting the background poll clear the flag put such an account straight back into
|
|
1023
|
-
// rotation: the next request failed the same way and re-marked it, so needsReauth
|
|
1024
|
-
// never settled and the dashboard kept showing nothing — the symptom #327 reported.
|
|
1025
|
-
// An explicit refresh is an operator asking to re-evaluate, normally right after
|
|
1026
|
-
// signing in again, so it stays authoritative.
|
|
1027
|
-
if (explicitRefresh) clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
|
|
1028
|
-
// Mirror main quota + plan into the shared stores so the rotation engine can
|
|
1029
|
-
// score and auto-switch the main account exactly like a pool account (Option A).
|
|
1030
|
-
setMainAccountPlan(result.plan);
|
|
1031
|
-
if (result.quota) {
|
|
1032
|
-
setAccountQuotaFromParsed(MAIN_CODEX_ACCOUNT_ID, result.quota, writerGeneration, mainQuotaWriter, policyQuota);
|
|
1033
|
-
}
|
|
1034
|
-
mainQuotaPublishedSequence = dispatchSequence;
|
|
1035
|
-
return {
|
|
1036
|
-
info: result,
|
|
1037
|
-
quotaRefresh: { status: quota ? "ok" : "not_reported" },
|
|
1038
|
-
quotaRefreshGeneration,
|
|
1039
|
-
credentialChecked: true,
|
|
1040
|
-
hasCredential: true,
|
|
1041
|
-
...(quota ? { freshQuota: quota } : {}),
|
|
1042
|
-
...(quota && mainQuotaWriter && isMainQuotaWriterLive(mainQuotaWriter)
|
|
1043
|
-
&& mainQuotaCredentialGeneration === getMainQuotaCredentialGeneration()
|
|
1044
|
-
&& matchesMainQuotaCredential(tokens.access_token, tokens.account_id)
|
|
1045
|
-
? { resetRecoveryProof: { writer: mainQuotaWriter, credentialGeneration: mainQuotaCredentialGeneration, dispatchSequence } }
|
|
1046
|
-
: {}),
|
|
1047
|
-
...(freshResetCredits !== undefined ? { freshResetCredits } : {}),
|
|
1048
|
-
};
|
|
1049
|
-
} catch (error) {
|
|
1050
|
-
const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease, explicitRefresh);
|
|
1051
|
-
if (retried) return retried;
|
|
1052
|
-
let status: CodexQuotaRefreshOutcome["status"] = "internal_error";
|
|
1053
|
-
if ((quotaPhase === "request" || quotaPhase === "body") && quotaSignal.aborted) status = "timeout";
|
|
1054
|
-
else if (quotaPhase === "request") status = "network_error";
|
|
1055
|
-
else if (quotaPhase === "body") status = error instanceof SyntaxError ? "invalid_response" : "network_error";
|
|
1056
|
-
else if (quotaPhase === "decode") status = "invalid_response";
|
|
1057
|
-
return {
|
|
1058
|
-
info: EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: true, hasCredential: true,
|
|
1059
|
-
quotaRefresh: { status },
|
|
1060
|
-
quotaRefreshGeneration,
|
|
1061
|
-
};
|
|
1062
|
-
}
|
|
1063
|
-
}
|
|
1064
|
-
|
|
1065
|
-
interface PoolQuotaResult {
|
|
1066
|
-
/** Actual refresh result attached only to the successful usage replay. */
|
|
1067
|
-
resetRefreshLineage?: ManualResetRefreshLineage;
|
|
1068
|
-
quota: StoredAccountQuota | null;
|
|
1069
|
-
needsReauth: boolean;
|
|
1070
|
-
/** Credential generation whose cache or network result this DTO state belongs to. */
|
|
1071
|
-
credentialGeneration?: number;
|
|
1072
|
-
/** Present only when this call freshly parsed a WHAM usage response. */
|
|
1073
|
-
freshQuota?: Omit<StoredAccountQuota, "updatedAt">;
|
|
1074
|
-
/** Present only when this call's WHAM response included a non-empty `plan_type`. */
|
|
1075
|
-
freshPlan?: string;
|
|
1076
|
-
/** Credential generation used by this fresh quota request. */
|
|
1077
|
-
freshCredentialGeneration?: number;
|
|
1078
|
-
/** Present only when this call's WHAM response included `rate_limit_reset_credits.available_count`. */
|
|
1079
|
-
freshResetCredits?: number;
|
|
1080
|
-
quotaProbeSkipped?: true;
|
|
1081
|
-
/** Positive evidence captured immediately before an upstream WHAM dispatch. */
|
|
1082
|
-
quotaProbeAttempted?: { at: number; credentialGeneration: number; dispatchSequence: number };
|
|
1083
|
-
}
|
|
1084
|
-
|
|
1085
|
-
// Process-local ordering, never a timestamp or a serialized account identifier.
|
|
1086
|
-
let quotaDispatchSequence = 0;
|
|
1087
|
-
// Shared native-main ownership permits concurrent usage readers. Only a later
|
|
1088
|
-
// successfully published response advances this fence; failed reads do not win.
|
|
1089
|
-
let mainQuotaPublishedSequence = 0;
|
|
1090
|
-
|
|
1091
|
-
interface PoolQuotaProbeEvidence {
|
|
1092
|
-
onDispatch?: (sequence: number) => void;
|
|
1093
|
-
mayPublish?: () => boolean;
|
|
1094
|
-
attempted?: NonNullable<PoolQuotaResult["quotaProbeAttempted"]>;
|
|
1095
|
-
}
|
|
1096
|
-
|
|
1097
|
-
function markQuotaProbeAttempted(evidence: PoolQuotaProbeEvidence, credentialGeneration: number): void {
|
|
1098
|
-
const dispatchSequence = ++quotaDispatchSequence;
|
|
1099
|
-
evidence.attempted = { at: Date.now(), credentialGeneration, dispatchSequence };
|
|
1100
|
-
evidence.onDispatch?.(dispatchSequence);
|
|
1101
|
-
}
|
|
1102
|
-
|
|
1103
|
-
function withQuotaProbeEvidence(
|
|
1104
|
-
result: PoolQuotaResult,
|
|
1105
|
-
evidence: PoolQuotaProbeEvidence,
|
|
1106
|
-
): PoolQuotaResult {
|
|
1107
|
-
return evidence.attempted ? { ...result, quotaProbeAttempted: evidence.attempted } : result;
|
|
1108
|
-
}
|
|
1109
|
-
|
|
1110
|
-
interface PoolQuotaRefreshFlight {
|
|
1111
|
-
state: {
|
|
1112
|
-
dispatchSequence?: number;
|
|
1113
|
-
superseded?: boolean;
|
|
1114
|
-
startCredentialGeneration?: number;
|
|
1115
|
-
resolvedCredentialGeneration?: number;
|
|
1116
|
-
validatePending?: boolean;
|
|
1117
|
-
};
|
|
1118
|
-
promise: Promise<PoolQuotaResult>;
|
|
1119
|
-
}
|
|
1120
|
-
|
|
1121
|
-
const poolQuotaRefreshInFlight = new Map<string, Set<PoolQuotaRefreshFlight>>();
|
|
1122
|
-
const MAX_POOL_QUOTA_FLIGHTS = 16;
|
|
1123
|
-
|
|
1124
|
-
export class PoolQuotaProbeBusyError extends ResourceAdmissionError {
|
|
1125
|
-
constructor() {
|
|
1126
|
-
super("pool_quota_flights", MAX_POOL_QUOTA_FLIGHTS);
|
|
1127
|
-
this.name = "PoolQuotaProbeBusyError";
|
|
1128
|
-
}
|
|
1129
|
-
}
|
|
1130
|
-
|
|
1131
|
-
function poolQuotaFlightCount(): number {
|
|
1132
|
-
let count = 0;
|
|
1133
|
-
for (const flights of poolQuotaRefreshInFlight.values()) count += flights.size;
|
|
1134
|
-
return count;
|
|
1135
|
-
}
|
|
1136
|
-
|
|
1137
|
-
/** Focused admission tests only; returns cleanup for the synthetic owners it inserts. */
|
|
1138
|
-
export function seedCodexAuthAdmissionForTests(options: { loginFlows?: number; quotaFlights?: number }): () => void {
|
|
1139
|
-
const prefix = `admission-test-${crypto.randomUUID()}`;
|
|
1140
|
-
for (let index = 0; index < (options.loginFlows ?? 0); index++) {
|
|
1141
|
-
codexAuthLoginState.set(`${prefix}-login-${index}`, { status: "starting", startedAt: Date.now() });
|
|
1142
|
-
}
|
|
1143
|
-
for (let index = 0; index < (options.quotaFlights ?? 0); index++) {
|
|
1144
|
-
poolQuotaRefreshInFlight.set(`${prefix}-quota-${index}`, new Set([{
|
|
1145
|
-
state: {},
|
|
1146
|
-
promise: new Promise<PoolQuotaResult>(() => {}),
|
|
1147
|
-
}]));
|
|
1148
|
-
}
|
|
1149
|
-
return () => {
|
|
1150
|
-
for (const key of [...codexAuthLoginState.keys()]) if (key.startsWith(prefix)) codexAuthLoginState.delete(key);
|
|
1151
|
-
for (const key of [...poolQuotaRefreshInFlight.keys()]) if (key.startsWith(prefix)) poolQuotaRefreshInFlight.delete(key);
|
|
1152
|
-
};
|
|
1153
|
-
}
|
|
1154
|
-
|
|
1155
|
-
export interface CodexAuthAccountDto {
|
|
1156
|
-
id: string;
|
|
1157
|
-
alias?: string;
|
|
1158
|
-
email: string;
|
|
1159
|
-
plan?: string | null;
|
|
1160
|
-
logLabel?: string;
|
|
1161
|
-
isMain: boolean;
|
|
1162
|
-
paused: boolean;
|
|
1163
|
-
/** Selection order; higher is used earlier. Always present, 0 when unset. */
|
|
1164
|
-
priority: number;
|
|
1165
|
-
quota: (StoredAccountQuota | (Omit<StoredAccountQuota, "updatedAt"> & { updatedAt: number })) | null;
|
|
1166
|
-
needsReauth?: boolean;
|
|
1167
|
-
/**
|
|
1168
|
-
* Which of the independent causes behind `needsReauth` fired. Present only when the account
|
|
1169
|
-
* needs the operator; `/api/oauth/accounts` already carries the same field name.
|
|
1170
|
-
*/
|
|
1171
|
-
reauthReason?: CodexAccountReauthReason;
|
|
1172
|
-
/** Automatic selection policy only; explicit routes retain their usual auth checks. */
|
|
1173
|
-
selectionExcludedReason?: "plan_excluded";
|
|
1174
|
-
selectionExcludedPlan?: string;
|
|
1175
|
-
hasCredential: boolean;
|
|
1176
|
-
health: OAuthAccountHealth;
|
|
1177
|
-
healthLabel: OAuthHealthLabel;
|
|
1178
|
-
healthSummary: string;
|
|
1179
|
-
healthAction?: string;
|
|
1180
|
-
quotaProbeSkipped?: true;
|
|
1181
|
-
quotaRefresh?: CodexQuotaRefreshOutcome;
|
|
1182
|
-
mainAccountHardLock?: MainAccountHardLockStatus;
|
|
1183
|
-
}
|
|
1184
|
-
|
|
1185
|
-
interface FreshPoolPlanUpdate {
|
|
1186
|
-
accountId: string;
|
|
1187
|
-
plan: string;
|
|
1188
|
-
credentialGeneration: number;
|
|
1189
|
-
}
|
|
1190
|
-
|
|
1191
|
-
/**
|
|
1192
|
-
* Persist only validated plan leaves against the latest disk snapshot. A quota GET must not save
|
|
1193
|
-
* the long-lived runtime object wholesale: unrelated manual/provider writes may have landed while
|
|
1194
|
-
* WHAM requests were in flight. Missing or malformed files fail closed: a read path must not
|
|
1195
|
-
* recreate a deleted config from the server's older in-memory snapshot.
|
|
1196
|
-
*/
|
|
1197
|
-
function reconcileFreshPoolAccountPlans(runtimeConfig: OcxConfig, updates: FreshPoolPlanUpdate[]): void {
|
|
1198
|
-
if (updates.length === 0) return;
|
|
1199
|
-
let outcome: ReturnType<typeof mutatePersistedConfig<FreshPoolPlanUpdate[]>>;
|
|
1200
|
-
try {
|
|
1201
|
-
outcome = mutatePersistedConfig(persistedConfig => {
|
|
1202
|
-
const accepted: FreshPoolPlanUpdate[] = [];
|
|
1203
|
-
let changed = false;
|
|
1204
|
-
for (const update of updates) {
|
|
1205
|
-
if (!isCodexAccountGenerationLive(update.accountId, update.credentialGeneration)) continue;
|
|
1206
|
-
const liveAccount = configuredPoolAccount(runtimeConfig, update.accountId);
|
|
1207
|
-
const persistedAccount = configuredPoolAccount(persistedConfig, update.accountId);
|
|
1208
|
-
if (!liveAccount || !persistedAccount) continue;
|
|
1209
|
-
accepted.push(update);
|
|
1210
|
-
if (persistedAccount.plan !== update.plan) {
|
|
1211
|
-
persistedAccount.plan = update.plan;
|
|
1212
|
-
// WHAM is the authoritative plan source: stamp provenance so a later JWT
|
|
1213
|
-
// reconcile cannot overwrite this observation within the same credential
|
|
1214
|
-
// generation (src/codex/plan-from-token.ts jwtMayWritePlan). Stamped only
|
|
1215
|
-
// alongside a real plan change: a steady-state refresh whose plan is
|
|
1216
|
-
// unchanged must stay write-free (no-config-write contract), and an
|
|
1217
|
-
// unchanged value needs no fence — a JWT rewrite to the same text is a
|
|
1218
|
-
// no-op under the caller's own equality check.
|
|
1219
|
-
persistedAccount.planSource = "wham";
|
|
1220
|
-
persistedAccount.planCredentialGeneration = update.credentialGeneration;
|
|
1221
|
-
changed = true;
|
|
1222
|
-
}
|
|
1223
|
-
}
|
|
1224
|
-
return { changed, value: accepted };
|
|
1225
|
-
});
|
|
1226
|
-
} catch (error) {
|
|
1227
|
-
// Plan persistence is derived metadata on a read route. Contention must fail closed without
|
|
1228
|
-
// turning account listing into a 500; a later refresh can retry against the latest files.
|
|
1229
|
-
if (error instanceof ConfigMutationLockError) return;
|
|
1230
|
-
throw error;
|
|
1231
|
-
}
|
|
1232
|
-
if (outcome.status === "unavailable") return;
|
|
1233
|
-
for (const update of outcome.value) {
|
|
1234
|
-
// A replacement immediately after the durable commit is allowed to supersede the result, but
|
|
1235
|
-
// the long-lived object must never be updated from that stale generation.
|
|
1236
|
-
if (!isCodexAccountGenerationLive(update.accountId, update.credentialGeneration)) continue;
|
|
1237
|
-
const liveAccount = configuredPoolAccount(runtimeConfig, update.accountId);
|
|
1238
|
-
if (liveAccount) {
|
|
1239
|
-
liveAccount.plan = update.plan;
|
|
1240
|
-
liveAccount.planSource = "wham";
|
|
1241
|
-
liveAccount.planCredentialGeneration = update.credentialGeneration;
|
|
1242
|
-
}
|
|
1243
|
-
}
|
|
1244
|
-
}
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
/**
|
|
1249
|
-
* One refresh-and-replay for a pool account whose WHAM request came back 401 (#3019).
|
|
1250
|
-
*
|
|
1251
|
-
* The account list used to convert any 401 straight into `needsReauth`, and a bare 401 is
|
|
1252
|
-
* exactly what a stale-but-refreshable bearer produces after a plan change — so a healthy
|
|
1253
|
-
* credential was thrown away and the operator was told to log in again.
|
|
1254
|
-
*
|
|
1255
|
-
* Bounded by the recovery store: one attempt per credential lineage. An unbounded retry
|
|
1256
|
-
* against an upstream 401 is a self-inflicted credential-stuffing loop, which is why the
|
|
1257
|
-
* claim is taken BEFORE the refresh and settled by the flight rather than by this caller.
|
|
1258
|
-
*/
|
|
1259
|
-
async function recoverPoolQuotaFrom401(ctx: {
|
|
1260
|
-
accountId: string;
|
|
1261
|
-
existing: StoredAccountQuota | null;
|
|
1262
|
-
configuredPlan: string | undefined;
|
|
1263
|
-
rejectedAccessToken: string;
|
|
1264
|
-
rejectedGeneration: number;
|
|
1265
|
-
resp: Response;
|
|
1266
|
-
quotaProbeEvidence: PoolQuotaProbeEvidence;
|
|
1267
|
-
onCredentialGeneration?: (generation: number) => void;
|
|
1268
|
-
}): Promise<PoolQuotaResult> {
|
|
1269
|
-
const { accountId, existing, configuredPlan, rejectedAccessToken, rejectedGeneration, resp } = ctx;
|
|
1270
|
-
|
|
1271
|
-
// Structured terminal evidence short-circuits everything: the same allowlist and bounded
|
|
1272
|
-
// parser the main account uses, because it is the same endpoint answering.
|
|
1273
|
-
if (await isTerminalPoolAuthResponse(resp)) {
|
|
1274
|
-
// Durable, not just this response: the account list re-polls, and without a recorded
|
|
1275
|
-
// mark the next bare 401 finds nothing terminal and reports the account healthy.
|
|
1276
|
-
//
|
|
1277
|
-
// Scoped to the generation this evidence is ABOUT. An account-wide mark would outlive
|
|
1278
|
-
// the credential it condemned, so a late terminal response arriving after the operator
|
|
1279
|
-
// re-authenticated would quarantine the replacement.
|
|
1280
|
-
markAccountNeedsReauth(accountId, captureConfigGeneration(), rejectedGeneration);
|
|
1281
|
-
return { quota: existing ?? null, needsReauth: true, credentialGeneration: rejectedGeneration };
|
|
1282
|
-
}
|
|
1283
|
-
|
|
1284
|
-
const claim = claimQuotaRecovery(accountId, rejectedGeneration);
|
|
1285
|
-
if (!claim.granted) {
|
|
1286
|
-
// A lineage fenced by a TERMINAL refresh failure stays terminal. Without this, the
|
|
1287
|
-
// budget being used would make the next bare 401 report a dead credential as healthy.
|
|
1288
|
-
if (quotaRecoveryTerminalFor(accountId, rejectedGeneration)) {
|
|
1289
|
-
return { quota: existing ?? null, needsReauth: true, credentialGeneration: rejectedGeneration };
|
|
1290
|
-
}
|
|
1291
|
-
// Otherwise: this lineage spent its attempt, another caller is mid-refresh, or a
|
|
1292
|
-
// transient failure is backing off. Report transient and let the next poll try —
|
|
1293
|
-
// quarantining here would undo the whole point of the budget.
|
|
1294
|
-
return { quota: existing ?? null, needsReauth: false, credentialGeneration: rejectedGeneration };
|
|
1295
|
-
}
|
|
1296
|
-
|
|
1297
|
-
let refreshed: Awaited<ReturnType<typeof forceRefreshCodexPoolToken>>;
|
|
1298
|
-
try {
|
|
1299
|
-
refreshed = await forceRefreshCodexPoolToken(accountId, {
|
|
1300
|
-
rejectedGeneration,
|
|
1301
|
-
rejectedAccessToken,
|
|
1302
|
-
// Settlement rides the flight, not this await: a cancelled caller would otherwise
|
|
1303
|
-
// leave the claim to expire while the shared refresh commits, and the already
|
|
1304
|
-
// refreshed lineage would get a second attempt.
|
|
1305
|
-
onSettled: outcome => {
|
|
1306
|
-
if (outcome.kind === "resolved") {
|
|
1307
|
-
settleQuotaRecovery(accountId, claim.claimId, outcome);
|
|
1308
|
-
} else if (outcome.error instanceof TokenRefreshError && isTerminalRefreshError(outcome.error)) {
|
|
1309
|
-
// A revoked or expired grant does not become valid on the next poll. Releasing it
|
|
1310
|
-
// into backoff would let the following bare 401 find a non-terminal record and
|
|
1311
|
-
// report a dead credential as healthy.
|
|
1312
|
-
settleQuotaRecoveryTerminal(accountId, claim.claimId);
|
|
1313
|
-
} else {
|
|
1314
|
-
releaseQuotaRecovery(accountId, claim.claimId, QUOTA_RECOVERY_BACKOFF_MS);
|
|
1315
|
-
}
|
|
1316
|
-
},
|
|
1317
|
-
});
|
|
1318
|
-
} catch (e) {
|
|
1319
|
-
// A refresh that failed terminally is the one case where the credential really is gone.
|
|
1320
|
-
// Everything else is unknown, and unknown is not proof.
|
|
1321
|
-
if (e instanceof TokenRefreshError && isTerminalRefreshError(e)) {
|
|
1322
|
-
markAccountNeedsReauth(accountId, captureConfigGeneration(), rejectedGeneration);
|
|
1323
|
-
return { quota: existing ?? null, needsReauth: true, credentialGeneration: rejectedGeneration };
|
|
1324
|
-
}
|
|
1325
|
-
return { quota: existing ?? null, needsReauth: false, credentialGeneration: rejectedGeneration };
|
|
1326
|
-
}
|
|
1327
|
-
|
|
1328
|
-
// A byte-identical access token means replaying earns the same 401. Report transient
|
|
1329
|
-
// rather than burning the replay; the fence already moved to the returned generation.
|
|
1330
|
-
if (!refreshed.rotated) {
|
|
1331
|
-
return { quota: existing ?? null, needsReauth: false, credentialGeneration: refreshed.generation };
|
|
1332
|
-
}
|
|
1333
|
-
|
|
1334
|
-
// The flight may have moved the generation while this request was in the air. Tell the
|
|
1335
|
-
// coalescing layer where the credential actually is, or a late caller joins on a stale
|
|
1336
|
-
// generation and opens a redundant flight.
|
|
1337
|
-
ctx.onCredentialGeneration?.(refreshed.generation);
|
|
1338
|
-
|
|
1339
|
-
const writerGeneration = captureConfigGeneration();
|
|
1340
|
-
markQuotaProbeAttempted(ctx.quotaProbeEvidence, refreshed.generation);
|
|
1341
|
-
const poolWriter = capturePoolQuotaWriter(accountId, refreshed);
|
|
1342
|
-
const replay = await fetch("https://chatgpt.com/backend-api/wham/usage", {
|
|
1343
|
-
headers: {
|
|
1344
|
-
Authorization: `Bearer ${refreshed.accessToken}`,
|
|
1345
|
-
"ChatGPT-Account-Id": refreshed.chatgptAccountId,
|
|
1346
|
-
},
|
|
1347
|
-
signal: AbortSignal.timeout(WHAM_REQUEST_TIMEOUT_MS),
|
|
1348
|
-
});
|
|
1349
|
-
if (!replay.ok) {
|
|
1350
|
-
if (replay.status === 401 && await isTerminalPoolAuthResponse(replay)) {
|
|
1351
|
-
// The refresh already settled this claim non-terminally, so the record alone would
|
|
1352
|
-
// let the next poll call a dead credential healthy. The evidence is about the
|
|
1353
|
-
// REFRESHED credential, which is what the replay used.
|
|
1354
|
-
markAccountNeedsReauth(accountId, writerGeneration, refreshed.generation);
|
|
1355
|
-
return { quota: existing ?? null, needsReauth: true, credentialGeneration: refreshed.generation };
|
|
1356
|
-
}
|
|
1357
|
-
return { quota: existing ?? null, needsReauth: false, credentialGeneration: refreshed.generation };
|
|
1358
|
-
}
|
|
1359
|
-
const result = await commitPoolQuotaResponse(replay, {
|
|
1360
|
-
accountId, existing, configuredPlan, generation: refreshed.generation, writerGeneration, poolWriter,
|
|
1361
|
-
mayPublish: ctx.quotaProbeEvidence.mayPublish,
|
|
1362
|
-
});
|
|
1363
|
-
return result.freshCredentialGeneration === refreshed.generation ? {
|
|
1364
|
-
...result,
|
|
1365
|
-
resetRefreshLineage: {
|
|
1366
|
-
fromGeneration: rejectedGeneration,
|
|
1367
|
-
toGeneration: refreshed.generation,
|
|
1368
|
-
provenance: refreshed.provenance,
|
|
1369
|
-
},
|
|
1370
|
-
} : result;
|
|
1371
|
-
}
|
|
1372
|
-
|
|
1373
|
-
/** Backoff after a refresh failure that proved nothing about the credential. */
|
|
1374
|
-
const QUOTA_RECOVERY_BACKOFF_MS = 60_000;
|
|
1375
|
-
|
|
1376
|
-
/** Same allowlist and bounded parser as the main account: it is the same endpoint. */
|
|
1377
|
-
async function isTerminalPoolAuthResponse(resp: Response): Promise<boolean> {
|
|
1378
|
-
// Consume the original rather than a clone. `resp.clone()` tees the body, and the
|
|
1379
|
-
// bounded parser's timeout cancels only its own reader — the unread original branch
|
|
1380
|
-
// keeps buffering. Nothing needs this response afterwards, so there is nothing to tee.
|
|
1381
|
-
const code = await readMainAuthErrorCode(resp);
|
|
1382
|
-
return typeof code === "string" && MAIN_TERMINAL_AUTH_CODES.has(code);
|
|
1383
|
-
}
|
|
1384
|
-
|
|
1385
|
-
/** A revoked or expired grant is terminal; an unknown or transport failure is not. */
|
|
1386
|
-
function isTerminalRefreshError(error: TokenRefreshError): boolean {
|
|
1387
|
-
// Read the discriminator, not the message. TokenRefreshError carries `reason`, and
|
|
1388
|
-
// matching on human text would let a durable quarantine decision change the next time
|
|
1389
|
-
// somebody rewords an error string.
|
|
1390
|
-
return error.reason === "revoked" || error.reason === "expired";
|
|
1391
|
-
}
|
|
1392
|
-
|
|
1393
|
-
/** Parse and store a successful WHAM response. Shared by the first attempt and the replay. */
|
|
1394
|
-
async function commitPoolQuotaResponse(
|
|
1395
|
-
resp: Response,
|
|
1396
|
-
ctx: {
|
|
1397
|
-
accountId: string;
|
|
1398
|
-
existing: StoredAccountQuota | null;
|
|
1399
|
-
configuredPlan: string | undefined;
|
|
1400
|
-
generation: number;
|
|
1401
|
-
writerGeneration: number;
|
|
1402
|
-
poolWriter?: PoolQuotaWriter;
|
|
1403
|
-
mayPublish?: () => boolean;
|
|
1404
|
-
},
|
|
1405
|
-
): Promise<PoolQuotaResult> {
|
|
1406
|
-
const { accountId, existing, configuredPlan, generation, writerGeneration } = ctx;
|
|
1407
|
-
const data = (await resp.json()) as WhamUsageResponse;
|
|
1408
|
-
const observedAt = Date.now();
|
|
1409
|
-
if (ctx.mayPublish?.() === false) {
|
|
1410
|
-
return { quota: getAccountQuota(accountId), needsReauth: false, credentialGeneration: generation };
|
|
1411
|
-
}
|
|
1412
|
-
const freshPlan = nonEmptyPlan(data.plan_type) ?? undefined;
|
|
1413
|
-
const quota = parseUsageQuota({ ...data, plan_type: freshPlan ?? configuredPlan });
|
|
1414
|
-
const freshResetCredits = quota?.resetCredits;
|
|
1415
|
-
if (!quota) {
|
|
1416
|
-
return {
|
|
1417
|
-
quota: isCodexAccountGenerationLive(accountId, generation) ? existing ?? null : getAccountQuota(accountId),
|
|
1418
|
-
needsReauth: false,
|
|
1419
|
-
credentialGeneration: generation,
|
|
1420
|
-
...(freshPlan !== undefined ? { freshPlan, freshCredentialGeneration: generation } : {}),
|
|
1421
|
-
};
|
|
1422
|
-
}
|
|
1423
|
-
if (!isCodexAccountGenerationLive(accountId, generation)) {
|
|
1424
|
-
return { quota: null, needsReauth: false, credentialGeneration: generation };
|
|
1425
|
-
}
|
|
1426
|
-
setAccountQuotaFromParsed(accountId, quota, writerGeneration, undefined, quota,
|
|
1427
|
-
ctx.poolWriter && isValidWhamHistoryObservation(data) ? { writer: ctx.poolWriter, observedAt, source: "wham", raw: quota } : undefined);
|
|
1428
|
-
return {
|
|
1429
|
-
quota: getAccountQuota(accountId),
|
|
1430
|
-
needsReauth: false,
|
|
1431
|
-
credentialGeneration: generation,
|
|
1432
|
-
freshQuota: quota,
|
|
1433
|
-
freshCredentialGeneration: generation,
|
|
1434
|
-
...(freshPlan !== undefined ? { freshPlan } : {}),
|
|
1435
|
-
...(freshResetCredits !== undefined ? { freshResetCredits } : {}),
|
|
1436
|
-
};
|
|
1437
|
-
}
|
|
1438
|
-
|
|
1439
|
-
async function fetchFreshPoolAccountQuota(
|
|
1440
|
-
accountId: string,
|
|
1441
|
-
existing: StoredAccountQuota | null,
|
|
1442
|
-
configuredPlan?: string,
|
|
1443
|
-
onCredentialGeneration?: (generation: number) => void,
|
|
1444
|
-
getValidToken: typeof getValidCodexToken = getValidCodexToken,
|
|
1445
|
-
quotaProbeEvidence: PoolQuotaProbeEvidence = {},
|
|
1446
|
-
): Promise<PoolQuotaResult> {
|
|
1447
|
-
const writerGeneration = captureConfigGeneration();
|
|
1448
|
-
let requestCredentialGeneration = readCodexAccountRecord(accountId)?.generation;
|
|
1449
|
-
try {
|
|
1450
|
-
const { accessToken, chatgptAccountId, generation } = await getValidToken(accountId);
|
|
1451
|
-
const poolWriter = capturePoolQuotaWriter(accountId, { accessToken, chatgptAccountId, generation });
|
|
1452
|
-
requestCredentialGeneration = generation;
|
|
1453
|
-
onCredentialGeneration?.(generation);
|
|
1454
|
-
markQuotaProbeAttempted(quotaProbeEvidence, generation);
|
|
1455
|
-
const resp = await fetch("https://chatgpt.com/backend-api/wham/usage", {
|
|
1456
|
-
headers: { Authorization: `Bearer ${accessToken}`, "ChatGPT-Account-Id": chatgptAccountId },
|
|
1457
|
-
signal: AbortSignal.timeout(8000),
|
|
1458
|
-
});
|
|
1459
|
-
if (!resp.ok) {
|
|
1460
|
-
if (resp.status !== 401) {
|
|
1461
|
-
return withQuotaProbeEvidence(
|
|
1462
|
-
{ quota: existing ?? null, needsReauth: false, credentialGeneration: generation },
|
|
1463
|
-
quotaProbeEvidence,
|
|
1464
|
-
);
|
|
1465
|
-
}
|
|
1466
|
-
// A bare 401 is what a stale-but-refreshable bearer produces after a plan change, so
|
|
1467
|
-
// quarantining on it tells the operator to re-authenticate an account that was fine
|
|
1468
|
-
// (#3019). Refresh once, replay once, and only then decide.
|
|
1469
|
-
const recovered = await recoverPoolQuotaFrom401({
|
|
1470
|
-
accountId,
|
|
1471
|
-
existing,
|
|
1472
|
-
configuredPlan,
|
|
1473
|
-
rejectedAccessToken: accessToken,
|
|
1474
|
-
rejectedGeneration: generation,
|
|
1475
|
-
resp,
|
|
1476
|
-
quotaProbeEvidence,
|
|
1477
|
-
onCredentialGeneration,
|
|
1478
|
-
});
|
|
1479
|
-
return withQuotaProbeEvidence(recovered, quotaProbeEvidence);
|
|
1480
|
-
}
|
|
1481
|
-
const committed = await commitPoolQuotaResponse(resp, {
|
|
1482
|
-
accountId, existing, configuredPlan, generation, writerGeneration, poolWriter,
|
|
1483
|
-
mayPublish: quotaProbeEvidence.mayPublish,
|
|
1484
|
-
});
|
|
1485
|
-
return withQuotaProbeEvidence(committed, quotaProbeEvidence);
|
|
1486
|
-
} catch (e) {
|
|
1487
|
-
if (e instanceof CodexCredentialGenerationConflictError || e instanceof CodexCredentialRefreshLockTimeoutError
|
|
1488
|
-
|| e instanceof CodexCredentialRefreshBusyError || e instanceof CodexCredentialRefreshStaleError) {
|
|
1489
|
-
return withQuotaProbeEvidence({
|
|
1490
|
-
quota: existing ?? null,
|
|
1491
|
-
needsReauth: false,
|
|
1492
|
-
credentialGeneration: requestCredentialGeneration,
|
|
1493
|
-
quotaProbeSkipped: true,
|
|
1494
|
-
}, quotaProbeEvidence);
|
|
1495
|
-
}
|
|
1496
|
-
if (e instanceof TokenRefreshError) {
|
|
1497
|
-
return withQuotaProbeEvidence(
|
|
1498
|
-
{ quota: existing ?? null, needsReauth: true, credentialGeneration: requestCredentialGeneration },
|
|
1499
|
-
quotaProbeEvidence,
|
|
1500
|
-
);
|
|
1501
|
-
}
|
|
1502
|
-
return withQuotaProbeEvidence(
|
|
1503
|
-
{ quota: existing ?? null, needsReauth: false, credentialGeneration: requestCredentialGeneration },
|
|
1504
|
-
quotaProbeEvidence,
|
|
1505
|
-
);
|
|
1506
|
-
}
|
|
1507
|
-
}
|
|
1508
|
-
|
|
1509
|
-
export async function fetchPoolAccountQuota(
|
|
1510
|
-
accountId: string,
|
|
1511
|
-
forceRefresh = false,
|
|
1512
|
-
configuredPlan?: string,
|
|
1513
|
-
getValidToken: typeof getValidCodexToken = getValidCodexToken,
|
|
1514
|
-
validatePending = false,
|
|
1515
|
-
afterDispatchSequence?: number,
|
|
1516
|
-
): Promise<PoolQuotaResult> {
|
|
1517
|
-
const existing = getAccountQuota(accountId);
|
|
1518
|
-
if (afterDispatchSequence === undefined && !forceRefresh && existing && Date.now() - existing.updatedAt < POOL_CACHE_TTL) {
|
|
1519
|
-
return {
|
|
1520
|
-
quota: existing,
|
|
1521
|
-
needsReauth: false,
|
|
1522
|
-
credentialGeneration: readCodexAccountRecord(accountId)?.generation,
|
|
1523
|
-
};
|
|
1524
|
-
}
|
|
1525
|
-
// A token refresh may increment the generation (and rotate the refresh token) before WHAM
|
|
1526
|
-
// completes. Join a flight whose starting or resolved generation is still current, but let a
|
|
1527
|
-
// replacement credential with the same pool id start its own request.
|
|
1528
|
-
const record = readCodexAccountRecord(accountId);
|
|
1529
|
-
const flights = poolQuotaRefreshInFlight.get(accountId);
|
|
1530
|
-
const current = flights && [...flights].find(flight => {
|
|
1531
|
-
const generation = flight.state.resolvedCredentialGeneration
|
|
1532
|
-
?? flight.state.startCredentialGeneration;
|
|
1533
|
-
return !flight.state.superseded
|
|
1534
|
-
&& (afterDispatchSequence === undefined || (flight.state.dispatchSequence ?? 0) > afterDispatchSequence)
|
|
1535
|
-
&& generation !== undefined && isCodexAccountGenerationLive(accountId, generation);
|
|
1536
|
-
});
|
|
1537
|
-
if (current) {
|
|
1538
|
-
// A manual refresh joining a passive read must not lose its validation intent.
|
|
1539
|
-
current.state.validatePending ||= validatePending;
|
|
1540
|
-
return current.promise;
|
|
1541
|
-
}
|
|
1542
|
-
if (poolQuotaFlightCount() >= MAX_POOL_QUOTA_FLIGHTS) throw new PoolQuotaProbeBusyError();
|
|
1543
|
-
|
|
1544
|
-
// A post-reset request must not let an older same-account response overwrite its evidence.
|
|
1545
|
-
// Flags live only as long as the bounded flights; no retained per-account sequence map.
|
|
1546
|
-
if (afterDispatchSequence !== undefined) {
|
|
1547
|
-
for (const flight of flights ?? []) flight.state.superseded = true;
|
|
1548
|
-
}
|
|
1549
|
-
const state: PoolQuotaRefreshFlight["state"] = {
|
|
1550
|
-
startCredentialGeneration: record?.generation,
|
|
1551
|
-
validatePending,
|
|
1552
|
-
};
|
|
1553
|
-
const refresh = fetchFreshPoolAccountQuota(
|
|
1554
|
-
accountId,
|
|
1555
|
-
existing,
|
|
1556
|
-
configuredPlan,
|
|
1557
|
-
generation => { state.resolvedCredentialGeneration = generation; },
|
|
1558
|
-
getValidToken,
|
|
1559
|
-
{
|
|
1560
|
-
onDispatch: sequence => { state.dispatchSequence = sequence; },
|
|
1561
|
-
mayPublish: () => state.superseded !== true,
|
|
1562
|
-
},
|
|
1563
|
-
).then(async result => {
|
|
1564
|
-
// A passive flight has consumed its validation decision. Remove it before
|
|
1565
|
-
// promise settlement queues other continuations, so a late explicit caller
|
|
1566
|
-
// starts fresh work instead of setting an intent nobody will read again.
|
|
1567
|
-
if (!state.validatePending) {
|
|
1568
|
-
releaseFlight();
|
|
1569
|
-
return result;
|
|
1570
|
-
}
|
|
1571
|
-
// Only an explicit account-list refresh finishes deferred registration. Passive quota
|
|
1572
|
-
// polls and startup priming remain read-only with respect to inference spending.
|
|
1573
|
-
const generation = result.freshCredentialGeneration;
|
|
1574
|
-
const record = state.validatePending ? readCodexAccountRecord(accountId) : null;
|
|
1575
|
-
if (record?.codexValidationPending && record.credential && record.deletedAt == null
|
|
1576
|
-
&& generation !== undefined && record.generation === generation
|
|
1577
|
-
&& isCompleteCodexQuotaRecoverySnapshot(result.freshQuota ?? null, result.freshPlan ?? configuredPlan)) {
|
|
1578
|
-
try {
|
|
1579
|
-
await warmCodexAccount({
|
|
1580
|
-
accessToken: record.credential.accessToken,
|
|
1581
|
-
chatgptAccountId: record.credential.chatgptAccountId,
|
|
1582
|
-
});
|
|
1583
|
-
markCodexAccountValidated(accountId, Date.now(), generation);
|
|
1584
|
-
clearAccountNeedsReauth(accountId, generation);
|
|
1585
|
-
} catch (error) {
|
|
1586
|
-
// Keep the durable restriction on any failed/partial inference response, even
|
|
1587
|
-
// when WHAM just reported headroom. No raw upstream text enters diagnostics.
|
|
1588
|
-
const reason = codexWarmupFailureReason(error);
|
|
1589
|
-
if (reason === "http_status:401" || reason === "http_status:403") {
|
|
1590
|
-
markCodexAccountValidationFailed(accountId, reason, { expectedGeneration: generation });
|
|
1591
|
-
markAccountNeedsReauth(accountId, captureConfigGeneration(), generation);
|
|
1592
|
-
}
|
|
1593
|
-
}
|
|
1594
|
-
}
|
|
1595
|
-
return result;
|
|
1596
|
-
});
|
|
1597
|
-
const flight: PoolQuotaRefreshFlight = { state, promise: refresh };
|
|
1598
|
-
const activeFlights = flights ?? new Set<PoolQuotaRefreshFlight>();
|
|
1599
|
-
activeFlights.add(flight);
|
|
1600
|
-
if (!flights) poolQuotaRefreshInFlight.set(accountId, activeFlights);
|
|
1601
|
-
const releaseFlight = () => {
|
|
1602
|
-
activeFlights.delete(flight);
|
|
1603
|
-
if (activeFlights.size === 0 && poolQuotaRefreshInFlight.get(accountId) === activeFlights) {
|
|
1604
|
-
poolQuotaRefreshInFlight.delete(accountId);
|
|
1605
|
-
}
|
|
1606
|
-
};
|
|
1607
|
-
try {
|
|
1608
|
-
return await refresh;
|
|
1609
|
-
} finally {
|
|
1610
|
-
releaseFlight();
|
|
1611
|
-
}
|
|
1612
|
-
}
|
|
1613
|
-
|
|
1614
|
-
function manualResetAuthStillLive(accountId: string, auth: ResetCreditAuth): boolean {
|
|
1615
|
-
if (!auth.isMain) {
|
|
1616
|
-
const record = readCodexAccountRecord(accountId);
|
|
1617
|
-
return auth.poolGeneration !== undefined
|
|
1618
|
-
&& isCodexAccountGenerationLive(accountId, auth.poolGeneration)
|
|
1619
|
-
&& record?.credential?.chatgptAccountId === auth.chatgptAccountId;
|
|
1620
|
-
}
|
|
1621
|
-
const tokens = readCodexTokens();
|
|
1622
|
-
return !!auth.mainProof && !!tokens
|
|
1623
|
-
&& tokens.access_token === auth.accessToken && tokens.account_id === auth.chatgptAccountId
|
|
1624
|
-
&& isMainQuotaWriterLive(auth.mainProof.writer)
|
|
1625
|
-
&& auth.mainProof.credentialGeneration === getMainQuotaCredentialGeneration()
|
|
1626
|
-
&& matchesMainQuotaCredential(auth.accessToken, auth.chatgptAccountId);
|
|
1627
|
-
}
|
|
1628
|
-
|
|
1629
|
-
/** A confirmed spend remains successful even when its optional usage observation fails. */
|
|
1630
|
-
async function refreshAfterManualReset(
|
|
1631
|
-
config: OcxConfig,
|
|
1632
|
-
accountId: string,
|
|
1633
|
-
auth: ResetCreditAuth,
|
|
1634
|
-
claims: ManualResetCooldownClaim[],
|
|
1635
|
-
didReset: boolean,
|
|
1636
|
-
): Promise<number | undefined> {
|
|
1637
|
-
const afterDispatchSequence = quotaDispatchSequence;
|
|
1638
|
-
try {
|
|
1639
|
-
if (!manualResetAuthStillLive(accountId, auth)) return undefined;
|
|
1640
|
-
if (auth.isMain) {
|
|
1641
|
-
const result = await fetchMainAccountInfoAttempt(true, 1, auth.nativeMainLease,
|
|
1642
|
-
auth.nativeMainSharedClaimHeld === true, false);
|
|
1643
|
-
const proof = result.resetRecoveryProof;
|
|
1644
|
-
const recovered = didReset && manualResetAuthStillLive(accountId, auth)
|
|
1645
|
-
&& !!proof && !!auth.mainProof
|
|
1646
|
-
&& proof.dispatchSequence > afterDispatchSequence
|
|
1647
|
-
&& proof.credentialGeneration === auth.mainProof.credentialGeneration
|
|
1648
|
-
&& proof.writer.identityKey === auth.mainProof.writer.identityKey
|
|
1649
|
-
&& proof.writer.identityGeneration === auth.mainProof.writer.identityGeneration
|
|
1650
|
-
&& isCompleteCodexQuotaRecoverySnapshot(result.freshQuota ?? null, result.info.plan);
|
|
1651
|
-
for (const claim of claims) settleManualResetCooldown(getRuntimeConfig(config), claim, recovered);
|
|
1652
|
-
return manualResetAuthStillLive(accountId, auth) ? result.freshResetCredits : undefined;
|
|
1653
|
-
}
|
|
1654
|
-
const account = configuredPoolAccount(getRuntimeConfig(config), accountId);
|
|
1655
|
-
if (!account) return undefined;
|
|
1656
|
-
// Reuse the just-authenticated consume credential for the first usage request.
|
|
1657
|
-
// getValidCodexToken can silently advance a generation without exposing refresh
|
|
1658
|
-
// provenance. A 401 here instead uses the existing classified refresh/replay path.
|
|
1659
|
-
const resetToken: typeof getValidCodexToken = async () => {
|
|
1660
|
-
if (auth.poolGeneration === undefined || !manualResetAuthStillLive(accountId, auth)) {
|
|
1661
|
-
throw new CodexCredentialGenerationConflictError();
|
|
1662
|
-
}
|
|
1663
|
-
return { accessToken: auth.accessToken, chatgptAccountId: auth.chatgptAccountId, generation: auth.poolGeneration };
|
|
1664
|
-
};
|
|
1665
|
-
// `validatePending` is false here: a manual reset settles cooldown, and finishing deferred
|
|
1666
|
-
// registration stays reserved for an explicit dashboard account-list refresh.
|
|
1667
|
-
const result = await fetchPoolAccountQuota(accountId, true, account.plan, didReset ? resetToken : getValidCodexToken,
|
|
1668
|
-
false, didReset ? afterDispatchSequence : undefined);
|
|
1669
|
-
const record = readCodexAccountRecord(accountId);
|
|
1670
|
-
const recovered = didReset && record?.credential?.chatgptAccountId === auth.chatgptAccountId
|
|
1671
|
-
&& (result.quotaProbeAttempted?.dispatchSequence ?? 0) > afterDispatchSequence
|
|
1672
|
-
&& isCompleteCodexQuotaRecoverySnapshot(result.freshQuota ?? null, result.freshPlan ?? account.plan);
|
|
1673
|
-
for (const claim of claims) settleManualResetCooldown(getRuntimeConfig(config), claim, recovered, {
|
|
1674
|
-
credentialGeneration: result.freshCredentialGeneration,
|
|
1675
|
-
refreshLineage: result.resetRefreshLineage,
|
|
1676
|
-
});
|
|
1677
|
-
return record?.credential?.chatgptAccountId === auth.chatgptAccountId ? result.freshResetCredits : undefined;
|
|
1678
|
-
} catch {
|
|
1679
|
-
// The upstream reset already happened. A failed refresh must not invite another spend.
|
|
1680
|
-
return undefined;
|
|
1681
|
-
}
|
|
1682
|
-
}
|
|
1683
|
-
|
|
1684
|
-
let primeInFlight: Promise<void> | null = null;
|
|
1685
|
-
/**
|
|
1686
|
-
* Last prime attempt per pool account. A failed WHAM lookup stores no quota, so
|
|
1687
|
-
* without this the account stays "unknown" and every later prime trigger re-selects
|
|
1688
|
-
* it as stale and repeats the same failing request. Successful lookups are already
|
|
1689
|
-
* throttled by their stored updatedAt; this gives failures the same TTL backoff.
|
|
1690
|
-
*
|
|
1691
|
-
* Keyed by credential generation so a re-authentication, refresh, or account removal
|
|
1692
|
-
* retries immediately instead of waiting out a backoff earned by the old credential.
|
|
1693
|
-
*/
|
|
1694
|
-
const poolQuotaPrimeAttemptedAt = new Map<string, { generation: number; at: number }>();
|
|
1695
|
-
let cooldownRecoveryInFlight: Promise<void> | null = null;
|
|
1696
|
-
|
|
1697
|
-
export async function runCodexCooldownRecoveryProbes(config: OcxConfig, now = Date.now()): Promise<void> {
|
|
1698
|
-
const openai = config.providers[OPENAI_CODEX_PROVIDER_ID];
|
|
1699
|
-
if (!openai
|
|
1700
|
-
|| openai.disabled === true
|
|
1701
|
-
|| !isCanonicalOpenAiForwardProvider(openai)
|
|
1702
|
-
|| providerCodexAccountMode(OPENAI_CODEX_PROVIDER_ID, openai) !== "pool") return;
|
|
1703
|
-
if (cooldownRecoveryInFlight) return cooldownRecoveryInFlight;
|
|
1704
|
-
cooldownRecoveryInFlight = (async () => {
|
|
1705
|
-
const claims = claimDueCodexQuotaRecoveryProbes(config, POOL_QUOTA_REFRESH_CONCURRENCY, now);
|
|
1706
|
-
await mapWithConcurrency(claims, POOL_QUOTA_REFRESH_CONCURRENCY, async claim => {
|
|
1707
|
-
const account = configuredPoolAccount(config, claim.accountId);
|
|
1708
|
-
if (!account) {
|
|
1709
|
-
settleCodexQuotaRecoveryProbe(claim, false, {}, now);
|
|
1710
|
-
return;
|
|
1711
|
-
}
|
|
1712
|
-
try {
|
|
1713
|
-
const result = await fetchPoolAccountQuota(claim.accountId, true, account.plan);
|
|
1714
|
-
// Defence in depth: independent scopes are already excluded at the claim site.
|
|
1715
|
-
// Generic WHAM must never clear Reserve even if claim selection changes.
|
|
1716
|
-
const recovered = (claim.scope === undefined || claim.scope === "shared")
|
|
1717
|
-
&& isCompleteCodexQuotaRecoverySnapshot(result.freshQuota ?? null, result.freshPlan ?? account.plan);
|
|
1718
|
-
settleCodexQuotaRecoveryProbe(claim, recovered, {
|
|
1719
|
-
credentialGeneration: result.freshCredentialGeneration,
|
|
1720
|
-
}, now);
|
|
1721
|
-
} catch {
|
|
1722
|
-
settleCodexQuotaRecoveryProbe(claim, false, {}, now);
|
|
1723
|
-
}
|
|
1724
|
-
});
|
|
1725
|
-
})().catch(() => {
|
|
1726
|
-
// Background recovery is best-effort; routing keeps the cooldown on failure.
|
|
1727
|
-
}).finally(() => { cooldownRecoveryInFlight = null; });
|
|
1728
|
-
return cooldownRecoveryInFlight;
|
|
1729
|
-
}
|
|
1730
|
-
|
|
1731
|
-
let mainHardLockRecoveryInFlight: Promise<void> | null = null;
|
|
1732
|
-
|
|
1733
|
-
/** Metadata-only recovery on the existing sweep; failures retain the observed policy block. */
|
|
1734
|
-
export async function runMainAccountHardLockRecovery(config: OcxConfig): Promise<void> {
|
|
1735
|
-
if (mainHardLockRecoveryInFlight) return mainHardLockRecoveryInFlight;
|
|
1736
|
-
if (getMainAccountHardLockStatus(config).state !== "blocked"
|
|
1737
|
-
|| isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)) return;
|
|
1738
|
-
const lease = tryAcquireNativeMainProfileClaim();
|
|
1739
|
-
if (!lease) return;
|
|
1740
|
-
mainHardLockRecoveryInFlight = (async () => {
|
|
1741
|
-
reconcileMainCodexAccountRuntimeState();
|
|
1742
|
-
if (getMainAccountHardLockStatus(config).state !== "blocked"
|
|
1743
|
-
|| isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)) return;
|
|
1744
|
-
const identityGeneration = captureMainAccountIdentityGeneration();
|
|
1745
|
-
const writerGeneration = captureConfigGeneration();
|
|
1746
|
-
try {
|
|
1747
|
-
// Refresh can require an exclusive credential claim: never hold WHAM's shared
|
|
1748
|
-
// claim while obtaining a valid token. The runtime lease spans both operations.
|
|
1749
|
-
if (!await getValidMainAccountToken({ preserveReauth: true })) return;
|
|
1750
|
-
} catch (error) {
|
|
1751
|
-
if (error instanceof MainAccountTokenRefreshError && error.reason === "reauth"
|
|
1752
|
-
&& isMainAccountIdentityGenerationLive(identityGeneration)) {
|
|
1753
|
-
markAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID, writerGeneration);
|
|
1754
|
-
}
|
|
1755
|
-
return;
|
|
1756
|
-
}
|
|
1757
|
-
if (isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)) return;
|
|
1758
|
-
await fetchMainAccountInfoAttempt(true, 1, lease, false, false);
|
|
1759
|
-
})().catch(() => {
|
|
1760
|
-
// Best-effort background metadata read; no cooldown/pause or policy clearing on failure.
|
|
1761
|
-
}).finally(() => {
|
|
1762
|
-
lease.release();
|
|
1763
|
-
mainHardLockRecoveryInFlight = null;
|
|
1764
|
-
});
|
|
1765
|
-
return mainHardLockRecoveryInFlight;
|
|
1766
|
-
}
|
|
1767
|
-
|
|
1768
|
-
export function registerCodexCooldownRecoveryProbeWorker(config: OcxConfig): void {
|
|
1769
|
-
registerStateSweepAfterTick({
|
|
1770
|
-
name: "codex-cooldown-recovery",
|
|
1771
|
-
afterTick: () => {
|
|
1772
|
-
void runCodexCooldownRecoveryProbes(config);
|
|
1773
|
-
void runMainAccountHardLockRecovery(config);
|
|
1774
|
-
},
|
|
1775
|
-
});
|
|
1776
|
-
}
|
|
1777
|
-
|
|
1778
|
-
export interface PrimeCodexPoolQuotasOptions {
|
|
1779
|
-
/** Test seams for proving fenced/recovery priming performs no native-main work. */
|
|
1780
|
-
reconcileMainAccount?: typeof reconcileMainCodexAccountRuntimeState;
|
|
1781
|
-
readMainTokens?: typeof readCodexTokens;
|
|
1782
|
-
fetchMainInfo?: typeof fetchMainAccountInfo;
|
|
1783
|
-
}
|
|
1784
|
-
|
|
1785
|
-
let getValidPoolTokenForPrime = getValidCodexToken;
|
|
1786
|
-
|
|
1787
|
-
/** Test-only: inject a deterministic pre-dispatch credential outcome for quota priming. */
|
|
1788
|
-
export function setCodexPoolQuotaTokenResolverForTests(
|
|
1789
|
-
resolver: typeof getValidCodexToken,
|
|
1790
|
-
): () => void {
|
|
1791
|
-
const previous = getValidPoolTokenForPrime;
|
|
1792
|
-
getValidPoolTokenForPrime = resolver;
|
|
1793
|
-
return () => {
|
|
1794
|
-
if (getValidPoolTokenForPrime === resolver) getValidPoolTokenForPrime = previous;
|
|
1795
|
-
};
|
|
1796
|
-
}
|
|
1797
|
-
|
|
1798
|
-
function tryAcquireNativeMainPrimeLease(): AdmissionLease | null {
|
|
1799
|
-
return tryAcquireNativeMainProfileClaim();
|
|
1800
|
-
}
|
|
1801
|
-
|
|
1802
|
-
/**
|
|
1803
|
-
* Best-effort prime of pool-account (and main) quota so the rotation engine has
|
|
1804
|
-
* real usage scores instead of leaving every account at the unknown sentinel.
|
|
1805
|
-
*
|
|
1806
|
-
* Quota is otherwise populated only from live upstream headers (an idle pool
|
|
1807
|
-
* account never serves traffic, so it never gets scored) or from the dashboard
|
|
1808
|
-
* WHAM fetch (a CLI-only user never opens it). Without priming, every account
|
|
1809
|
-
* stays unknown and auto-switch cannot move (see Phase 10). This runs at startup
|
|
1810
|
-
* and lazily before routing when the active account is unknown.
|
|
1811
|
-
*
|
|
1812
|
-
* Single-flight: concurrent callers share one pass instead of stampeding N WHAM
|
|
1813
|
-
* fetches. Per-fetch 8s timeouts and the 5-minute POOL_CACHE_TTL already bound
|
|
1814
|
-
* cost, so the worst case is one WHAM call per account per TTL window. Failures
|
|
1815
|
-
* are swallowed: a blocked WSL network must never crash startup or a request.
|
|
1816
|
-
*/
|
|
1817
|
-
export async function primeCodexPoolQuotas(
|
|
1818
|
-
config: OcxConfig,
|
|
1819
|
-
reason: string,
|
|
1820
|
-
options: PrimeCodexPoolQuotasOptions = {},
|
|
1821
|
-
): Promise<void> {
|
|
1822
|
-
const openai = config.providers[OPENAI_CODEX_PROVIDER_ID];
|
|
1823
|
-
// Prune attempt markers for accounts that no longer exist BEFORE the eligibility
|
|
1824
|
-
// return. A removal that happens while the provider is disabled or out of pool mode
|
|
1825
|
-
// would otherwise leave a stale failure marker behind; restoring the same account id
|
|
1826
|
-
// within POOL_CACHE_TTL would then read that old failure as current and skip the
|
|
1827
|
-
// retry the restored credential is entitled to.
|
|
1828
|
-
const runtimeConfig = getRuntimeConfig(config);
|
|
1829
|
-
const configuredPoolIds = new Set((runtimeConfig.codexAccounts ?? []).map(account => account.id));
|
|
1830
|
-
for (const accountId of poolQuotaPrimeAttemptedAt.keys()) {
|
|
1831
|
-
if (!configuredPoolIds.has(accountId)) poolQuotaPrimeAttemptedAt.delete(accountId);
|
|
1832
|
-
}
|
|
1833
|
-
if (
|
|
1834
|
-
!openai
|
|
1835
|
-
|| openai.disabled === true
|
|
1836
|
-
|| !isCanonicalOpenAiForwardProvider(openai)
|
|
1837
|
-
|| providerCodexAccountMode(OPENAI_CODEX_PROVIDER_ID, openai) !== "pool"
|
|
1838
|
-
) return;
|
|
1839
|
-
if (primeInFlight) return primeInFlight;
|
|
1840
|
-
primeInFlight = (async () => {
|
|
1841
|
-
const pool = (runtimeConfig.codexAccounts ?? []).filter(isSelectableCodexPoolAccount);
|
|
1842
|
-
const stale = pool.filter(a => {
|
|
1843
|
-
const q = getAccountQuota(a.id);
|
|
1844
|
-
if (q) return Date.now() - q.updatedAt >= POOL_CACHE_TTL;
|
|
1845
|
-
// No stored quota: either never primed, or the last attempt failed. Retry only
|
|
1846
|
-
// once per TTL window so an unreachable or rejecting account cannot turn every
|
|
1847
|
-
// prime trigger into another upstream request.
|
|
1848
|
-
const lastAttempt = poolQuotaPrimeAttemptedAt.get(a.id);
|
|
1849
|
-
if (!lastAttempt) return true;
|
|
1850
|
-
// A newer credential invalidates the previous failure: retry without waiting.
|
|
1851
|
-
if (lastAttempt.generation !== readCodexAccountRecord(a.id)?.generation) return true;
|
|
1852
|
-
return Date.now() - lastAttempt.at >= POOL_CACHE_TTL;
|
|
1853
|
-
});
|
|
1854
|
-
const primeMain = async () => {
|
|
1855
|
-
const mainLease = tryAcquireNativeMainPrimeLease();
|
|
1856
|
-
if (!mainLease) return;
|
|
1857
|
-
try {
|
|
1858
|
-
try {
|
|
1859
|
-
await withNativeMainCredentialClaim(async () => {
|
|
1860
|
-
// Keep one local owner and one cross-process reader from physical
|
|
1861
|
-
// identity reconciliation through WHAM and all quota publication.
|
|
1862
|
-
(options.reconcileMainAccount ?? reconcileMainCodexAccountRuntimeState)();
|
|
1863
|
-
if (getAccountQuota(MAIN_CODEX_ACCOUNT_ID)) return;
|
|
1864
|
-
if (!(options.readMainTokens ?? readCodexTokens)()) return;
|
|
1865
|
-
if (options.fetchMainInfo) await options.fetchMainInfo(false);
|
|
1866
|
-
else await fetchMainAccountInfoAttempt(false, 1, mainLease, true);
|
|
1867
|
-
});
|
|
1868
|
-
} catch (error) {
|
|
1869
|
-
if (!isNativeMainClaimUnavailable(error)) throw error;
|
|
1870
|
-
}
|
|
1871
|
-
} finally {
|
|
1872
|
-
mainLease.release();
|
|
1873
|
-
}
|
|
1874
|
-
};
|
|
1875
|
-
try {
|
|
1876
|
-
await Promise.allSettled([
|
|
1877
|
-
primeMain(),
|
|
1878
|
-
mapWithConcurrency(stale, POOL_QUOTA_REFRESH_CONCURRENCY, async a => {
|
|
1879
|
-
if (!getCodexAccountCredential(a.id)) return;
|
|
1880
|
-
let result: PoolQuotaResult;
|
|
1881
|
-
try {
|
|
1882
|
-
result = await fetchPoolAccountQuota(a.id, false, a.plan, getValidPoolTokenForPrime);
|
|
1883
|
-
} catch (error) {
|
|
1884
|
-
// Local quota-flight saturation proves no WHAM request existed for this account.
|
|
1885
|
-
// Consume it per item so sibling workers remain inside the shared prime lifetime.
|
|
1886
|
-
if (error instanceof PoolQuotaProbeBusyError) return;
|
|
1887
|
-
throw error;
|
|
1888
|
-
}
|
|
1889
|
-
// Only the data-plane function knows whether upstream dispatch began. Any
|
|
1890
|
-
// cache hit, credential deferral, or local admission failure remains eligible.
|
|
1891
|
-
const attempted = result.quotaProbeAttempted;
|
|
1892
|
-
if (!attempted) return;
|
|
1893
|
-
if (!configuredPoolAccount(getRuntimeConfig(config), a.id)) {
|
|
1894
|
-
poolQuotaPrimeAttemptedAt.delete(a.id);
|
|
1895
|
-
return;
|
|
1896
|
-
}
|
|
1897
|
-
poolQuotaPrimeAttemptedAt.set(a.id, {
|
|
1898
|
-
// getValidCodexToken may rotate the credential before WHAM is sent.
|
|
1899
|
-
// Bind the backoff to the generation that actually made the request;
|
|
1900
|
-
// otherwise the next prime sees a false generation change and retries
|
|
1901
|
-
// the same failed WHAM call immediately.
|
|
1902
|
-
generation: attempted.credentialGeneration,
|
|
1903
|
-
at: attempted.at,
|
|
1904
|
-
});
|
|
1905
|
-
}),
|
|
1906
|
-
]);
|
|
1907
|
-
} catch {
|
|
1908
|
-
// Priming is best-effort; never propagate.
|
|
1909
|
-
}
|
|
1910
|
-
if (process.env.OPENCODEX_DEBUG_QUOTA === "1") {
|
|
1911
|
-
console.warn(`[codex-quota] prime done (reason=${reason}, pool=${pool.length}, refreshed=${stale.length})`);
|
|
1912
|
-
}
|
|
1913
|
-
})().finally(() => { primeInFlight = null; });
|
|
1914
|
-
return primeInFlight;
|
|
1915
|
-
}
|
|
1916
|
-
|
|
1917
|
-
/** Test-only: drop any in-flight prime pass so a leaked single-flight promise
|
|
1918
|
-
* from another suite cannot coalesce into the next prime. */
|
|
1919
|
-
export function clearCodexQuotaPrimeState(): void {
|
|
1920
|
-
primeInFlight = null;
|
|
1921
|
-
poolQuotaPrimeAttemptedAt.clear();
|
|
1922
|
-
getValidPoolTokenForPrime = getValidCodexToken;
|
|
1923
|
-
}
|
|
1924
|
-
|
|
1925
|
-
/** Test-only: drop the shared single-flight promise while keeping the per-account
|
|
1926
|
-
* failure backoff, so a test can trigger a second real prime pass and still observe
|
|
1927
|
-
* the throttle a production caller would see. */
|
|
1928
|
-
export function clearCodexQuotaPrimeSingleFlightForTests(): void {
|
|
1929
|
-
primeInFlight = null;
|
|
1930
|
-
}
|
|
1931
|
-
|
|
1932
|
-
/** Test-only reset for the worker-level single-flight. */
|
|
1933
|
-
export function clearCodexCooldownRecoveryProbeState(): void {
|
|
1934
|
-
cooldownRecoveryInFlight = null;
|
|
1935
|
-
}
|
|
13
|
+
export { CodexLoginStateBusyError } from "./auth-api/login-state";
|
|
14
|
+
export type {
|
|
15
|
+
CodexAccountReauthReason,
|
|
16
|
+
CodexAuthAccountDto,
|
|
17
|
+
CodexAuthAccountsSnapshot,
|
|
18
|
+
} from "./auth-api/account-list";
|
|
19
|
+
export { listCodexAuthAccountsSnapshot, refreshCodexQuotaForActivation, listCodexAuthAccounts } from "./auth-api/account-list";
|
|
20
|
+
export type { MainAccountInfoSnapshot } from "./auth-api/main-account-probe";
|
|
21
|
+
export { fetchMainAccountInfoSnapshot, fetchMainAccountInfo } from "./auth-api/main-account-probe";
|
|
22
|
+
export { PoolQuotaProbeBusyError, seedCodexAuthAdmissionForTests, fetchPoolAccountQuota } from "./auth-api/pool-quota-probe";
|
|
23
|
+
export type { PrimeCodexPoolQuotasOptions } from "./auth-api/pool-mode-gate";
|
|
24
|
+
export {
|
|
25
|
+
runCodexCooldownRecoveryProbes,
|
|
26
|
+
runMainAccountHardLockRecovery,
|
|
27
|
+
registerCodexCooldownRecoveryProbeWorker,
|
|
28
|
+
setCodexPoolQuotaTokenResolverForTests,
|
|
29
|
+
primeCodexPoolQuotas,
|
|
30
|
+
clearCodexQuotaPrimeState,
|
|
31
|
+
clearCodexQuotaPrimeSingleFlightForTests,
|
|
32
|
+
clearCodexCooldownRecoveryProbeState,
|
|
33
|
+
} from "./auth-api/pool-mode-gate";
|
|
34
|
+
export { createResetCreditWhamClient } from "./auth-api/reset-credit-service";
|
|
35
|
+
export type { CodexAuthCatalogConvergence } from "./auth-api/login-flow";
|
|
36
|
+
export { handleCodexAuthAPI } from "./auth-api/routes";
|
|
37
|
+
import { getEffectiveActiveCodexAccountId } from "./routing";
|
|
38
|
+
import { MAIN_CODEX_ACCOUNT_ID } from "./main-account";
|
|
39
|
+
import type { OcxConfig } from "../types";
|
|
1936
40
|
|
|
1937
41
|
export function effectiveCodexAuthAccountId(config: OcxConfig): string {
|
|
1938
42
|
return getEffectiveActiveCodexAccountId(config) ?? MAIN_CODEX_ACCOUNT_ID;
|
|
1939
43
|
}
|
|
1940
|
-
|
|
1941
|
-
export interface CodexAuthAccountsSnapshot {
|
|
1942
|
-
accounts: CodexAuthAccountDto[];
|
|
1943
|
-
mainIdentityGeneration: number;
|
|
1944
|
-
}
|
|
1945
|
-
|
|
1946
|
-
export async function listCodexAuthAccountsSnapshot(
|
|
1947
|
-
config: OcxConfig,
|
|
1948
|
-
forceRefresh = false,
|
|
1949
|
-
options: { validatePending?: boolean } = {},
|
|
1950
|
-
): Promise<CodexAuthAccountsSnapshot> {
|
|
1951
|
-
const runtimeConfig = getRuntimeConfig(config);
|
|
1952
|
-
const poolAccounts = (runtimeConfig.codexAccounts ?? []).filter(isSelectableCodexPoolAccount);
|
|
1953
|
-
// One redaction decision for the whole snapshot, read once from the operator's config (#3859).
|
|
1954
|
-
const maskEmails = emailMaskingEnabled(runtimeConfig);
|
|
1955
|
-
const mainResult = await fetchMainAccountInfoAttempt(forceRefresh, 1);
|
|
1956
|
-
const refreshedPool = await mapWithConcurrency(poolAccounts, POOL_QUOTA_REFRESH_CONCURRENCY, async account => {
|
|
1957
|
-
const cred = getCodexAccountCredential(account.id);
|
|
1958
|
-
let quotaResult: PoolQuotaResult;
|
|
1959
|
-
if (!cred) {
|
|
1960
|
-
quotaResult = { quota: null, needsReauth: true };
|
|
1961
|
-
} else {
|
|
1962
|
-
try {
|
|
1963
|
-
quotaResult = await fetchPoolAccountQuota(account.id, forceRefresh, account.plan, getValidCodexToken, options.validatePending === true);
|
|
1964
|
-
} catch (error) {
|
|
1965
|
-
if (!(error instanceof PoolQuotaProbeBusyError)) throw error;
|
|
1966
|
-
quotaResult = {
|
|
1967
|
-
quota: getAccountQuota(account.id),
|
|
1968
|
-
needsReauth: false,
|
|
1969
|
-
credentialGeneration: readCodexAccountRecord(account.id)?.generation,
|
|
1970
|
-
quotaProbeSkipped: true,
|
|
1971
|
-
};
|
|
1972
|
-
}
|
|
1973
|
-
}
|
|
1974
|
-
return { accountId: account.id, quotaResult };
|
|
1975
|
-
});
|
|
1976
|
-
|
|
1977
|
-
// WHAM plan_type is authoritative only for the credential generation that fetched it. Collect
|
|
1978
|
-
// changes after every parallel read settles, then apply one narrow disk patch for the batch.
|
|
1979
|
-
const planUpdates = refreshedPool.flatMap(({ accountId, quotaResult }): FreshPoolPlanUpdate[] => {
|
|
1980
|
-
const plan = quotaResult.freshPlan;
|
|
1981
|
-
const credentialGeneration = quotaResult.freshCredentialGeneration;
|
|
1982
|
-
return plan && credentialGeneration !== undefined
|
|
1983
|
-
? [{ accountId, plan, credentialGeneration }]
|
|
1984
|
-
: [];
|
|
1985
|
-
});
|
|
1986
|
-
reconcileFreshPoolAccountPlans(runtimeConfig, planUpdates);
|
|
1987
|
-
|
|
1988
|
-
const withQuota = refreshedPool.flatMap(({ accountId, quotaResult }) => {
|
|
1989
|
-
const currentAccount = configuredPoolAccount(runtimeConfig, accountId);
|
|
1990
|
-
if (!currentAccount) return [];
|
|
1991
|
-
const currentCredential = getCodexAccountCredential(accountId);
|
|
1992
|
-
if (!currentCredential) {
|
|
1993
|
-
return [poolAccountDto(
|
|
1994
|
-
runtimeConfig,
|
|
1995
|
-
currentAccount,
|
|
1996
|
-
{ quota: null, needsReauth: true },
|
|
1997
|
-
false,
|
|
1998
|
-
isCodexAccountPaused(runtimeConfig, accountId),
|
|
1999
|
-
getCodexAccountPriority(runtimeConfig, accountId),
|
|
2000
|
-
maskEmails,
|
|
2001
|
-
)];
|
|
2002
|
-
}
|
|
2003
|
-
const resultGeneration = quotaResult.credentialGeneration ?? quotaResult.freshCredentialGeneration;
|
|
2004
|
-
const generationLive = resultGeneration === undefined
|
|
2005
|
-
|| isCodexAccountGenerationLive(accountId, resultGeneration);
|
|
2006
|
-
const effectiveQuotaResult = !generationLive
|
|
2007
|
-
? { quota: null, needsReauth: false }
|
|
2008
|
-
: quotaResult;
|
|
2009
|
-
// Response DTO can show the WHAM plan even when disk persistence fails closed (lock busy /
|
|
2010
|
-
// missing config). Persistence still remains generation-gated via reconcileFreshPoolAccountPlans.
|
|
2011
|
-
const dtoAccount = generationLive && quotaResult.freshPlan
|
|
2012
|
-
? { ...currentAccount, plan: quotaResult.freshPlan }
|
|
2013
|
-
: currentAccount;
|
|
2014
|
-
return [poolAccountDto(
|
|
2015
|
-
runtimeConfig,
|
|
2016
|
-
dtoAccount,
|
|
2017
|
-
effectiveQuotaResult,
|
|
2018
|
-
true,
|
|
2019
|
-
isCodexAccountPaused(runtimeConfig, accountId),
|
|
2020
|
-
getCodexAccountPriority(runtimeConfig, accountId),
|
|
2021
|
-
maskEmails,
|
|
2022
|
-
)];
|
|
2023
|
-
});
|
|
2024
|
-
const fetchedMainGeneration = mainResult.identityGeneration ?? captureMainAccountIdentityGeneration();
|
|
2025
|
-
const mainSnapshotLive = isMainAccountIdentityGenerationLive(fetchedMainGeneration);
|
|
2026
|
-
const mainInfo = mainSnapshotLive ? mainResult.info : EMPTY_MAIN_ACCOUNT_INFO;
|
|
2027
|
-
const hasMainCredential = mainSnapshotLive && mainResult.credentialChecked
|
|
2028
|
-
? mainResult.hasCredential
|
|
2029
|
-
: getMainAccountCredentialPresence() ?? false;
|
|
2030
|
-
const mainMissingCredential = mainSnapshotLive && mainResult.credentialChecked && !hasMainCredential;
|
|
2031
|
-
const mainNeedsReauth = mainMissingCredential || isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
|
|
2032
|
-
const mainHealth = projectCodexAccountHealth({
|
|
2033
|
-
accountId: MAIN_CODEX_ACCOUNT_ID,
|
|
2034
|
-
needsReauth: mainNeedsReauth,
|
|
2035
|
-
});
|
|
2036
|
-
// The main row carries the same attribution as a pool row. Reaching this point without
|
|
2037
|
-
// `mainMissingCredential` means the runtime reauth flag is what set `mainNeedsReauth`, so the
|
|
2038
|
-
// cause is a refresh that did not complete.
|
|
2039
|
-
const mainReauthReason: CodexAccountReauthReason | undefined = mainMissingCredential
|
|
2040
|
-
? "missing_credential"
|
|
2041
|
-
: mainNeedsReauth
|
|
2042
|
-
? "refresh_failed"
|
|
2043
|
-
: mainHealth.status === "reauth_required" ? mainHealth.reason : undefined;
|
|
2044
|
-
const main: CodexAuthAccountDto = {
|
|
2045
|
-
id: MAIN_CODEX_ACCOUNT_ID,
|
|
2046
|
-
email: projectEmail(mainInfo.email, maskEmails) ?? "Codex App login",
|
|
2047
|
-
plan: mainInfo.plan,
|
|
2048
|
-
...(mainSnapshotLive && mainResult.quotaRefresh && mainResult.quotaRefreshGeneration !== undefined
|
|
2049
|
-
&& isMainAccountIdentityGenerationLive(mainResult.quotaRefreshGeneration)
|
|
2050
|
-
? { quotaRefresh: mainResult.quotaRefresh } : {}),
|
|
2051
|
-
logLabel: "main",
|
|
2052
|
-
isMain: true,
|
|
2053
|
-
paused: isCodexAccountPaused(runtimeConfig, MAIN_CODEX_ACCOUNT_ID),
|
|
2054
|
-
mainAccountHardLock: getMainAccountHardLockStatus(runtimeConfig),
|
|
2055
|
-
priority: getCodexAccountPriority(runtimeConfig, MAIN_CODEX_ACCOUNT_ID),
|
|
2056
|
-
hasCredential: hasMainCredential,
|
|
2057
|
-
needsReauth: mainNeedsReauth,
|
|
2058
|
-
...(mainReauthReason !== undefined ? { reauthReason: mainReauthReason } : {}),
|
|
2059
|
-
quota: mainInfo.quota
|
|
2060
|
-
? quotaForPlan(mainQuotaWithCarriedResetCredits(mainInfo.quota), mainInfo.plan)
|
|
2061
|
-
: null,
|
|
2062
|
-
...oauthAccountHealthFields("codex", MAIN_CODEX_ACCOUNT_ID, mainHealth),
|
|
2063
|
-
};
|
|
2064
|
-
return {
|
|
2065
|
-
accounts: [main, ...withQuota],
|
|
2066
|
-
mainIdentityGeneration: mainSnapshotLive
|
|
2067
|
-
? fetchedMainGeneration
|
|
2068
|
-
: captureMainAccountIdentityGeneration(),
|
|
2069
|
-
};
|
|
2070
|
-
}
|
|
2071
|
-
|
|
2072
|
-
/** One opted-in account's metadata; reuse the bounded WHAM 401 recovery and generation fence. */
|
|
2073
|
-
export async function refreshCodexQuotaForActivation(config: OcxConfig, accountId: string): Promise<void> {
|
|
2074
|
-
if (accountId === MAIN_CODEX_ACCOUNT_ID) {
|
|
2075
|
-
const lease = tryAcquireNativeMainProfileClaim();
|
|
2076
|
-
if (!lease) return;
|
|
2077
|
-
try {
|
|
2078
|
-
reconcileMainCodexAccountRuntimeState();
|
|
2079
|
-
if (isAccountNeedsReauth(accountId)) return;
|
|
2080
|
-
const identityGeneration = captureMainAccountIdentityGeneration();
|
|
2081
|
-
const writerGeneration = captureConfigGeneration();
|
|
2082
|
-
try {
|
|
2083
|
-
// Refresh may need an exclusive claim; prepare before WHAM takes its shared claim.
|
|
2084
|
-
if (!await getValidMainAccountToken({ preserveReauth: true })) return;
|
|
2085
|
-
} catch (error) {
|
|
2086
|
-
if (error instanceof MainAccountTokenRefreshError && error.reason === "reauth"
|
|
2087
|
-
&& isMainAccountIdentityGenerationLive(identityGeneration)) {
|
|
2088
|
-
markAccountNeedsReauth(accountId, writerGeneration);
|
|
2089
|
-
}
|
|
2090
|
-
return;
|
|
2091
|
-
}
|
|
2092
|
-
if (isAccountNeedsReauth(accountId)) return;
|
|
2093
|
-
await fetchMainAccountInfoAttempt(true, 1, lease, false, false);
|
|
2094
|
-
} finally {
|
|
2095
|
-
lease.release();
|
|
2096
|
-
}
|
|
2097
|
-
return;
|
|
2098
|
-
}
|
|
2099
|
-
const account = configuredPoolAccount(config, accountId);
|
|
2100
|
-
if (!account) return;
|
|
2101
|
-
const writerGeneration = captureConfigGeneration();
|
|
2102
|
-
const result = await fetchPoolAccountQuota(accountId, true, account.plan);
|
|
2103
|
-
if (result.needsReauth && result.credentialGeneration !== undefined) {
|
|
2104
|
-
markAccountNeedsReauth(accountId, writerGeneration, result.credentialGeneration);
|
|
2105
|
-
}
|
|
2106
|
-
}
|
|
2107
|
-
|
|
2108
|
-
export async function listCodexAuthAccounts(
|
|
2109
|
-
config: OcxConfig,
|
|
2110
|
-
forceRefresh = false,
|
|
2111
|
-
options: { validatePending?: boolean } = {},
|
|
2112
|
-
): Promise<CodexAuthAccountDto[]> {
|
|
2113
|
-
return (await listCodexAuthAccountsSnapshot(config, forceRefresh, options)).accounts;
|
|
2114
|
-
}
|
|
2115
|
-
|
|
2116
|
-
interface PauseExhaustedResult {
|
|
2117
|
-
pausedAccountIds: string[];
|
|
2118
|
-
checkedAccountCount: number;
|
|
2119
|
-
failedAccountCount: number;
|
|
2120
|
-
}
|
|
2121
|
-
|
|
2122
|
-
function selectFallbackAfterPause(config: OcxConfig, pausedActiveId: string): void {
|
|
2123
|
-
reconcileCodexActiveAfterExclusion(config, pausedActiveId);
|
|
2124
|
-
}
|
|
2125
|
-
|
|
2126
|
-
async function pauseExhaustedCodexAccounts(
|
|
2127
|
-
config: OcxConfig,
|
|
2128
|
-
persistPausedAccounts: () => void,
|
|
2129
|
-
): Promise<PauseExhaustedResult> {
|
|
2130
|
-
const poolAccounts = (config.codexAccounts ?? []).filter(account => !account.isMain);
|
|
2131
|
-
const nativeMainLease = tryAcquireNativeMainProfileClaim();
|
|
2132
|
-
try {
|
|
2133
|
-
const performPause = async (mainLease?: AdmissionLease): Promise<PauseExhaustedResult> => {
|
|
2134
|
-
const mainWork = async (): Promise<{
|
|
2135
|
-
shouldPause: boolean;
|
|
2136
|
-
checkedAccountCount: number;
|
|
2137
|
-
failedAccountCount: number;
|
|
2138
|
-
}> => {
|
|
2139
|
-
if (!mainLease) return { shouldPause: false, checkedAccountCount: 0, failedAccountCount: 1 };
|
|
2140
|
-
const mainResult = await fetchMainAccountInfoAttempt(true, 1, mainLease, true);
|
|
2141
|
-
if (!mainResult.credentialChecked || !mainResult.hasCredential) {
|
|
2142
|
-
return { shouldPause: false, checkedAccountCount: 0, failedAccountCount: 0 };
|
|
2143
|
-
}
|
|
2144
|
-
if (!mainResult.freshQuota || !mainResult.info.plan) {
|
|
2145
|
-
return { shouldPause: false, checkedAccountCount: 0, failedAccountCount: 1 };
|
|
2146
|
-
}
|
|
2147
|
-
return {
|
|
2148
|
-
shouldPause: !isCodexAccountPaused(config, MAIN_CODEX_ACCOUNT_ID)
|
|
2149
|
-
&& isCodexQuotaExhausted(mainResult.freshQuota, mainResult.info.plan),
|
|
2150
|
-
checkedAccountCount: 1,
|
|
2151
|
-
failedAccountCount: 0,
|
|
2152
|
-
};
|
|
2153
|
-
};
|
|
2154
|
-
const [mainResult, poolResults] = await Promise.all([
|
|
2155
|
-
mainWork(),
|
|
2156
|
-
mapWithConcurrency(poolAccounts, POOL_QUOTA_REFRESH_CONCURRENCY, async account => {
|
|
2157
|
-
if (!getCodexAccountCredential(account.id)) return { account, quotaResult: null };
|
|
2158
|
-
try {
|
|
2159
|
-
return {
|
|
2160
|
-
account,
|
|
2161
|
-
quotaResult: await fetchPoolAccountQuota(account.id, true, account.plan),
|
|
2162
|
-
};
|
|
2163
|
-
} catch {
|
|
2164
|
-
// Settle each pool probe independently so a busy/failing account cannot
|
|
2165
|
-
// abandon an already-confirmed main decision before atomic publication.
|
|
2166
|
-
return { account, quotaResult: null };
|
|
2167
|
-
}
|
|
2168
|
-
}),
|
|
2169
|
-
]);
|
|
2170
|
-
|
|
2171
|
-
let checkedAccountCount = mainResult.checkedAccountCount;
|
|
2172
|
-
let failedAccountCount = mainResult.failedAccountCount;
|
|
2173
|
-
const exhaustedIds: string[] = mainResult.shouldPause ? [MAIN_CODEX_ACCOUNT_ID] : [];
|
|
2174
|
-
for (const { account, quotaResult } of poolResults) {
|
|
2175
|
-
const currentAccount = (config.codexAccounts ?? []).find(candidate => candidate.id === account.id && !candidate.isMain);
|
|
2176
|
-
if (!currentAccount) continue;
|
|
2177
|
-
const generation = quotaResult?.freshCredentialGeneration;
|
|
2178
|
-
const plan = quotaResult?.freshPlan ?? currentAccount.plan;
|
|
2179
|
-
if (!quotaResult?.freshQuota || generation === undefined || !isCodexAccountGenerationLive(account.id, generation) || !plan) {
|
|
2180
|
-
failedAccountCount += 1;
|
|
2181
|
-
continue;
|
|
2182
|
-
}
|
|
2183
|
-
checkedAccountCount += 1;
|
|
2184
|
-
if (!isCodexAccountPaused(config, account.id) && isCodexQuotaExhausted(quotaResult.freshQuota, plan)) {
|
|
2185
|
-
exhaustedIds.push(account.id);
|
|
2186
|
-
}
|
|
2187
|
-
}
|
|
2188
|
-
|
|
2189
|
-
for (const id of exhaustedIds) {
|
|
2190
|
-
setCodexAccountPaused(config, id, true);
|
|
2191
|
-
clearThreadAccountMapForAccount(id);
|
|
2192
|
-
}
|
|
2193
|
-
for (const id of exhaustedIds) selectFallbackAfterPause(config, id);
|
|
2194
|
-
const result = {
|
|
2195
|
-
pausedAccountIds: exhaustedIds,
|
|
2196
|
-
checkedAccountCount,
|
|
2197
|
-
failedAccountCount,
|
|
2198
|
-
};
|
|
2199
|
-
// Persist while both the in-process admission and cross-process shared
|
|
2200
|
-
// claim still own the physical-main identity used for the decision.
|
|
2201
|
-
if (result.pausedAccountIds.length > 0) persistPausedAccounts();
|
|
2202
|
-
return result;
|
|
2203
|
-
};
|
|
2204
|
-
|
|
2205
|
-
if (!nativeMainLease) return await performPause();
|
|
2206
|
-
try {
|
|
2207
|
-
return await withNativeMainCredentialClaim(() => performPause(nativeMainLease));
|
|
2208
|
-
} catch (error) {
|
|
2209
|
-
if (isNativeMainClaimUnavailable(error)) return await performPause();
|
|
2210
|
-
throw error;
|
|
2211
|
-
}
|
|
2212
|
-
} finally {
|
|
2213
|
-
nativeMainLease?.release();
|
|
2214
|
-
}
|
|
2215
|
-
}
|
|
2216
|
-
|
|
2217
|
-
export async function handleCodexAuthAPI(
|
|
2218
|
-
req: Request,
|
|
2219
|
-
url: URL,
|
|
2220
|
-
config: OcxConfig,
|
|
2221
|
-
convergeCodexCatalog?: CodexAuthCatalogConvergence,
|
|
2222
|
-
principal?: import("../server/management-auth").ManagementPrincipal,
|
|
2223
|
-
): Promise<Response | null> {
|
|
2224
|
-
|
|
2225
|
-
if (url.pathname === "/api/codex-auth/accounts" && req.method === "GET") {
|
|
2226
|
-
const forceRefresh = url.searchParams.get("refresh") === "1" || url.searchParams.get("refresh") === "true";
|
|
2227
|
-
return jsonResponse({ accounts: await listCodexAuthAccounts(config, forceRefresh) });
|
|
2228
|
-
}
|
|
2229
|
-
|
|
2230
|
-
if (url.pathname === "/api/codex-auth/accounts/refresh" && req.method === "POST") {
|
|
2231
|
-
// Inference spends quota: only a dashboard session carries the consent
|
|
2232
|
-
// required by AGENTS_INSTALL.md. Raw-admin/CLI refreshes remain observational.
|
|
2233
|
-
return jsonResponse({ accounts: await listCodexAuthAccounts(config, true, {
|
|
2234
|
-
validatePending: principal === "gui-session",
|
|
2235
|
-
}) });
|
|
2236
|
-
}
|
|
2237
|
-
|
|
2238
|
-
if (url.pathname === "/api/codex-auth/accounts" && req.method === "POST") {
|
|
2239
|
-
return manualImportDisabledResponse();
|
|
2240
|
-
}
|
|
2241
|
-
|
|
2242
|
-
if (url.pathname === "/api/codex-auth/accounts" && req.method === "DELETE") {
|
|
2243
|
-
const id = url.searchParams.get("id");
|
|
2244
|
-
if (!id) return jsonResponse({ error: "Missing id" }, 400);
|
|
2245
|
-
const runtimeConfig = getRuntimeConfig(config);
|
|
2246
|
-
const isLegacyPoolAccount = CODEX_ACCOUNT_ID_RE.test(id)
|
|
2247
|
-
&& (runtimeConfig.codexAccounts ?? []).some(account => !account.isMain && account.id === id);
|
|
2248
|
-
if (!isValidCodexAccountId(id) && !isLegacyPoolAccount) {
|
|
2249
|
-
return jsonResponse({ error: "Invalid account id format" }, 400);
|
|
2250
|
-
}
|
|
2251
|
-
const pickerVisibilityChanged = deleteCodexAccount(runtimeConfig, id);
|
|
2252
|
-
saveRuntimeConfig(config, runtimeConfig);
|
|
2253
|
-
reconcileLiveStateStores();
|
|
2254
|
-
const catalogRefresh = await convergeAccountNamespaceCatalog(
|
|
2255
|
-
runtimeConfig,
|
|
2256
|
-
pickerVisibilityChanged,
|
|
2257
|
-
convergeCodexCatalog,
|
|
2258
|
-
);
|
|
2259
|
-
return jsonResponse({ ok: true, ...catalogRefresh });
|
|
2260
|
-
}
|
|
2261
|
-
|
|
2262
|
-
if (url.pathname === "/api/codex-auth/accounts/alias" && req.method === "PUT") {
|
|
2263
|
-
const body = await req.json().catch(() => ({})) as { id?: unknown; alias?: unknown };
|
|
2264
|
-
const id = typeof body.id === "string" ? body.id.trim() : "";
|
|
2265
|
-
const alias = typeof body.alias === "string" ? body.alias.trim() : "";
|
|
2266
|
-
if (id === MAIN_CODEX_ACCOUNT_ID) return jsonResponse({ error: "Main Codex account alias is not configurable" }, 400);
|
|
2267
|
-
if (!isValidCodexAccountId(id)) return jsonResponse({ error: "Invalid account id format" }, 400);
|
|
2268
|
-
if (typeof body.alias !== "string" || alias.length > 80 || /[\x00-\x1f\x7f]/.test(alias)) {
|
|
2269
|
-
return jsonResponse({ error: "Alias must be a string of at most 80 printable characters" }, 400);
|
|
2270
|
-
}
|
|
2271
|
-
const runtimeConfig = getRuntimeConfig(config);
|
|
2272
|
-
const account = (runtimeConfig.codexAccounts ?? []).find(candidate => candidate.id === id && !candidate.isMain);
|
|
2273
|
-
if (!account) return jsonResponse({ error: "Account not found" }, 404);
|
|
2274
|
-
if (alias) account.alias = alias;
|
|
2275
|
-
else delete account.alias;
|
|
2276
|
-
saveRuntimeConfig(config, runtimeConfig);
|
|
2277
|
-
return jsonResponse({ ok: true, id, alias: alias || null });
|
|
2278
|
-
}
|
|
2279
|
-
|
|
2280
|
-
if (url.pathname === "/api/codex-auth/accounts/pause" && req.method === "PUT") {
|
|
2281
|
-
const body = await req.json().catch(() => ({})) as { id?: unknown; paused?: unknown };
|
|
2282
|
-
const id = typeof body.id === "string" ? body.id.trim() : "";
|
|
2283
|
-
if (id !== MAIN_CODEX_ACCOUNT_ID && !isValidCodexAccountId(id)) {
|
|
2284
|
-
return jsonResponse({ error: "Invalid account id format" }, 400);
|
|
2285
|
-
}
|
|
2286
|
-
if (typeof body.paused !== "boolean") return jsonResponse({ error: "paused must be a boolean" }, 400);
|
|
2287
|
-
|
|
2288
|
-
const runtimeConfig = getRuntimeConfig(config);
|
|
2289
|
-
const exists = id === MAIN_CODEX_ACCOUNT_ID
|
|
2290
|
-
|| (runtimeConfig.codexAccounts ?? []).some(account => isSelectableCodexPoolAccount(account) && account.id === id);
|
|
2291
|
-
if (!exists) return jsonResponse({ error: "Account not found" }, 404);
|
|
2292
|
-
|
|
2293
|
-
setCodexAccountPaused(runtimeConfig, id, body.paused);
|
|
2294
|
-
if (body.paused) {
|
|
2295
|
-
clearThreadAccountMapForAccount(id);
|
|
2296
|
-
selectFallbackAfterPause(runtimeConfig, id);
|
|
2297
|
-
}
|
|
2298
|
-
saveRuntimeConfig(config, runtimeConfig);
|
|
2299
|
-
return jsonResponse({
|
|
2300
|
-
ok: true,
|
|
2301
|
-
id,
|
|
2302
|
-
paused: body.paused,
|
|
2303
|
-
activeCodexAccountId: getEffectiveActiveCodexAccountId(runtimeConfig) ?? null,
|
|
2304
|
-
appliesImmediately: true,
|
|
2305
|
-
});
|
|
2306
|
-
}
|
|
2307
|
-
|
|
2308
|
-
// Deliberately a route of its own rather than a field on the alias PATCH: aliases
|
|
2309
|
-
// are display-only and reject __main__, while selection order is routing metadata
|
|
2310
|
-
// that the Desktop account must be able to carry. Re-ordering never kicks a live
|
|
2311
|
-
// thread, so there is no affinity clearing and no appliesImmediately here.
|
|
2312
|
-
if (url.pathname === "/api/codex-auth/accounts/priority" && req.method === "PUT") {
|
|
2313
|
-
let parsedBody: unknown;
|
|
2314
|
-
try { parsedBody = await req.json(); } catch { return jsonResponse({ error: "Invalid JSON" }, 400); }
|
|
2315
|
-
if (typeof parsedBody !== "object" || parsedBody === null || Array.isArray(parsedBody)) {
|
|
2316
|
-
return jsonResponse({ error: "body must be an object" }, 400);
|
|
2317
|
-
}
|
|
2318
|
-
const body = parsedBody as { id?: unknown; priority?: unknown };
|
|
2319
|
-
const id = typeof body.id === "string" ? body.id.trim() : "";
|
|
2320
|
-
if (!isCodexAccountPriorityKey(id)) {
|
|
2321
|
-
return jsonResponse({ error: "Invalid account id format" }, 400);
|
|
2322
|
-
}
|
|
2323
|
-
|
|
2324
|
-
let priority = DEFAULT_ACCOUNT_PRIORITY;
|
|
2325
|
-
if (body.priority !== null) {
|
|
2326
|
-
const parsed = parseAccountPriority(body.priority);
|
|
2327
|
-
if (parsed === null) {
|
|
2328
|
-
return jsonResponse({
|
|
2329
|
-
error: `priority must be null or an integer ${MIN_ACCOUNT_PRIORITY}-${MAX_ACCOUNT_PRIORITY}`,
|
|
2330
|
-
}, 400);
|
|
2331
|
-
}
|
|
2332
|
-
priority = parsed;
|
|
2333
|
-
}
|
|
2334
|
-
|
|
2335
|
-
const runtimeConfig = getRuntimeConfig(config);
|
|
2336
|
-
const exists = id === MAIN_CODEX_ACCOUNT_ID
|
|
2337
|
-
|| (runtimeConfig.codexAccounts ?? []).some(account => isSelectableCodexPoolAccount(account) && account.id === id);
|
|
2338
|
-
if (!exists) return jsonResponse({ error: "Account not found" }, 404);
|
|
2339
|
-
|
|
2340
|
-
setCodexAccountPriority(runtimeConfig, id, priority);
|
|
2341
|
-
// Both a pin and an order are the operator saying which account to use, so the newer
|
|
2342
|
-
// statement wins. Without this a pin made before any order existed — an ordinary
|
|
2343
|
-
// account switch — would outrank the order forever: it blocks preemption and caps
|
|
2344
|
-
// every eligibility list at its own tier until that account drains or is paused.
|
|
2345
|
-
clearCodexAccountPin(runtimeConfig);
|
|
2346
|
-
saveRuntimeConfig(config, runtimeConfig);
|
|
2347
|
-
return jsonResponse({
|
|
2348
|
-
ok: true,
|
|
2349
|
-
id,
|
|
2350
|
-
priority,
|
|
2351
|
-
activeCodexAccountId: getEffectiveActiveCodexAccountId(runtimeConfig) ?? null,
|
|
2352
|
-
});
|
|
2353
|
-
}
|
|
2354
|
-
|
|
2355
|
-
if (url.pathname === "/api/codex-auth/accounts/pause-exhausted" && req.method === "PUT") {
|
|
2356
|
-
const runtimeConfig = getRuntimeConfig(config);
|
|
2357
|
-
const result = await pauseExhaustedCodexAccounts(
|
|
2358
|
-
runtimeConfig,
|
|
2359
|
-
() => saveRuntimeConfig(config, runtimeConfig),
|
|
2360
|
-
);
|
|
2361
|
-
const { pausedAccountIds, checkedAccountCount, failedAccountCount } = result;
|
|
2362
|
-
if (checkedAccountCount === 0 && failedAccountCount > 0) {
|
|
2363
|
-
return jsonResponse({
|
|
2364
|
-
ok: false,
|
|
2365
|
-
error: "Failed to refresh any Codex account quota",
|
|
2366
|
-
checkedAccountCount,
|
|
2367
|
-
failedAccountCount,
|
|
2368
|
-
}, 502);
|
|
2369
|
-
}
|
|
2370
|
-
return jsonResponse({
|
|
2371
|
-
ok: true,
|
|
2372
|
-
pausedAccountIds,
|
|
2373
|
-
pausedCount: pausedAccountIds.length,
|
|
2374
|
-
checkedAccountCount,
|
|
2375
|
-
failedAccountCount,
|
|
2376
|
-
complete: failedAccountCount === 0,
|
|
2377
|
-
activeCodexAccountId: getEffectiveActiveCodexAccountId(runtimeConfig) ?? null,
|
|
2378
|
-
appliesImmediately: true,
|
|
2379
|
-
});
|
|
2380
|
-
}
|
|
2381
|
-
|
|
2382
|
-
// Manual escape from a quota cooldown. Injected Codex routing makes this proxy the only
|
|
2383
|
-
// model path for Codex Desktop, so a cooldown that outlives the real upstream limit
|
|
2384
|
-
// otherwise leaves editing config.toml as the user's only recovery.
|
|
2385
|
-
//
|
|
2386
|
-
// Existence is deliberately NOT disclosed: an unknown id returns 200 with cleared:false
|
|
2387
|
-
// exactly like an account that simply had no live cooldown, so this route cannot be used
|
|
2388
|
-
// to enumerate configured accounts. Cooldown state is runtime-only and independent of the
|
|
2389
|
-
// account list, so 404 would carry no useful meaning anyway.
|
|
2390
|
-
if (url.pathname === "/api/codex-auth/accounts/clear-cooldown" && req.method === "POST") {
|
|
2391
|
-
const body = await req.json().catch(() => ({})) as { id?: unknown };
|
|
2392
|
-
const id = typeof body.id === "string" ? body.id.trim() : "";
|
|
2393
|
-
if (id !== MAIN_CODEX_ACCOUNT_ID && !isValidCodexAccountId(id)) {
|
|
2394
|
-
return jsonResponse({ error: "Invalid account id format" }, 400);
|
|
2395
|
-
}
|
|
2396
|
-
return jsonResponse({ ok: true, id, cleared: clearCodexAccountCooldown(id) });
|
|
2397
|
-
}
|
|
2398
|
-
|
|
2399
|
-
if (url.pathname === "/api/codex-auth/active" && req.method === "PUT") {
|
|
2400
|
-
let body: { accountId: string | null };
|
|
2401
|
-
try { body = (await req.json()) as typeof body; } catch { return jsonResponse({ error: "Invalid JSON" }, 400); }
|
|
2402
|
-
const runtimeConfig = getRuntimeConfig(config);
|
|
2403
|
-
const targetAccountId = body.accountId ?? MAIN_CODEX_ACCOUNT_ID;
|
|
2404
|
-
if (body.accountId === MAIN_CODEX_ACCOUNT_ID && hasLegacyMainCodexPoolAccount(runtimeConfig.codexAccounts)) {
|
|
2405
|
-
return jsonResponse({ error: "Remove the legacy __main__ pool row before selecting the Desktop account" }, 409);
|
|
2406
|
-
}
|
|
2407
|
-
if (isCodexAccountPaused(runtimeConfig, targetAccountId)) {
|
|
2408
|
-
return jsonResponse({ error: "Account is paused" }, 409);
|
|
2409
|
-
}
|
|
2410
|
-
if (body.accountId != null && body.accountId !== MAIN_CODEX_ACCOUNT_ID) {
|
|
2411
|
-
if (!isValidCodexAccountId(body.accountId)) return jsonResponse({ error: "Invalid account id format" }, 400);
|
|
2412
|
-
const exists = (runtimeConfig.codexAccounts ?? [])
|
|
2413
|
-
.some(account => isSelectableCodexPoolAccount(account) && account.id === body.accountId);
|
|
2414
|
-
if (!exists) return jsonResponse({ error: "Account not found" }, 400);
|
|
2415
|
-
if (readCodexAccountRecord(body.accountId)?.codexValidationPending) {
|
|
2416
|
-
return jsonResponse({ error: "Account validation is pending. Refresh quota after recovery to validate it." }, 409);
|
|
2417
|
-
}
|
|
2418
|
-
}
|
|
2419
|
-
runtimeConfig.activeCodexAccountId = body.accountId ?? undefined;
|
|
2420
|
-
// "Use this account now" outranks selection order until the account is spent:
|
|
2421
|
-
// persisted here rather than in resetCodexRoutingForManualSelection, which is
|
|
2422
|
-
// runtime state only. A null id clears the selection instead of making one, so it
|
|
2423
|
-
// must release the pin rather than record one: pinning the `targetAccountId`
|
|
2424
|
-
// fallback would leave a pin that no effective active account matches, which
|
|
2425
|
-
// `isEffectiveCodexAccountPinned` reports as unpinned while the tier filter still
|
|
2426
|
-
// honours it as a ceiling — invisibly capping the pool at the main account's tier.
|
|
2427
|
-
if (body.accountId == null) clearCodexAccountPin(runtimeConfig);
|
|
2428
|
-
else setCodexAccountPin(runtimeConfig, targetAccountId);
|
|
2429
|
-
resetCodexRoutingForManualSelection(targetAccountId);
|
|
2430
|
-
saveRuntimeConfig(config, runtimeConfig);
|
|
2431
|
-
return jsonResponse({ ok: true, activeCodexAccountId: body.accountId, appliesImmediately: true });
|
|
2432
|
-
}
|
|
2433
|
-
|
|
2434
|
-
if (url.pathname === "/api/codex-auth/active" && req.method === "GET") {
|
|
2435
|
-
const runtimeConfig = getRuntimeConfig(config);
|
|
2436
|
-
return jsonResponse({
|
|
2437
|
-
activeCodexAccountId: getEffectiveActiveCodexAccountId(runtimeConfig) ?? null,
|
|
2438
|
-
pinned: isEffectiveCodexAccountPinned(runtimeConfig),
|
|
2439
|
-
// Which account carries the pin, not just whether the active one does. Under
|
|
2440
|
-
// round-robin or fill-first the pin caps the tier ceiling at its own tier while the
|
|
2441
|
-
// strategy cursor moves freely inside that tier, so `pinned` alone goes false on a
|
|
2442
|
-
// sibling's turn even though the pin is still suppressing every higher tier. The id
|
|
2443
|
-
// lets a surface mark the account the operator actually chose.
|
|
2444
|
-
pinnedAccountId: pinnedCodexAccountId(runtimeConfig) ?? null,
|
|
2445
|
-
autoSwitchThreshold: runtimeConfig.autoSwitchThreshold ?? 80,
|
|
2446
|
-
upstreamFailoverThreshold: runtimeConfig.upstreamFailoverThreshold ?? 3,
|
|
2447
|
-
accountPoolStrategy: normalizeCodexAccountPoolStrategy(runtimeConfig.accountPoolStrategy),
|
|
2448
|
-
accountPoolStickyLimit: normalizeAccountPoolStickyLimit(runtimeConfig.accountPoolStickyLimit),
|
|
2449
|
-
});
|
|
2450
|
-
}
|
|
2451
|
-
|
|
2452
|
-
if (url.pathname === "/api/codex-auth/auto-switch" && req.method === "PUT") {
|
|
2453
|
-
let body: { threshold: number };
|
|
2454
|
-
try { body = (await req.json()) as typeof body; } catch { return jsonResponse({ error: "Invalid JSON" }, 400); }
|
|
2455
|
-
if (typeof body.threshold !== "number" || !Number.isInteger(body.threshold) || body.threshold < 0 || body.threshold > 100) {
|
|
2456
|
-
return jsonResponse({ error: "Threshold must be an integer 0-100" }, 400);
|
|
2457
|
-
}
|
|
2458
|
-
const runtimeConfig = getRuntimeConfig(config);
|
|
2459
|
-
runtimeConfig.autoSwitchThreshold = body.threshold;
|
|
2460
|
-
saveRuntimeConfig(config, runtimeConfig);
|
|
2461
|
-
return jsonResponse({ ok: true });
|
|
2462
|
-
}
|
|
2463
|
-
|
|
2464
|
-
if (
|
|
2465
|
-
url.pathname === "/api/codex-auth/pool-strategy"
|
|
2466
|
-
&& (req.method === "PUT" || req.method === "PATCH")
|
|
2467
|
-
) {
|
|
2468
|
-
let parsedBody: unknown;
|
|
2469
|
-
try { parsedBody = await req.json(); } catch { return jsonResponse({ error: "Invalid JSON" }, 400); }
|
|
2470
|
-
if (typeof parsedBody !== "object" || parsedBody === null || Array.isArray(parsedBody)) {
|
|
2471
|
-
return jsonResponse({ error: "body must be an object" }, 400);
|
|
2472
|
-
}
|
|
2473
|
-
const body = parsedBody as { strategy?: unknown; stickyLimit?: unknown };
|
|
2474
|
-
if (body.strategy === undefined && body.stickyLimit === undefined) {
|
|
2475
|
-
return jsonResponse({ error: "strategy or stickyLimit required" }, 400);
|
|
2476
|
-
}
|
|
2477
|
-
const runtimeConfig = getRuntimeConfig(config);
|
|
2478
|
-
let nextStrategy: NonNullable<ReturnType<typeof parseCodexAccountPoolStrategy>> | undefined;
|
|
2479
|
-
let nextSticky: NonNullable<ReturnType<typeof parseAccountPoolStickyLimit>> | undefined;
|
|
2480
|
-
if (body.strategy !== undefined) {
|
|
2481
|
-
const parsed = parseCodexAccountPoolStrategy(body.strategy);
|
|
2482
|
-
if (parsed === null) {
|
|
2483
|
-
return jsonResponse({ error: 'strategy must be one of: quota, round-robin, fill-first, reset-first' }, 400);
|
|
2484
|
-
}
|
|
2485
|
-
nextStrategy = parsed;
|
|
2486
|
-
}
|
|
2487
|
-
if (body.stickyLimit !== undefined) {
|
|
2488
|
-
const parsed = parseAccountPoolStickyLimit(body.stickyLimit);
|
|
2489
|
-
if (parsed === null) {
|
|
2490
|
-
return jsonResponse({ error: "stickyLimit must be an integer 1-100" }, 400);
|
|
2491
|
-
}
|
|
2492
|
-
nextSticky = parsed;
|
|
2493
|
-
}
|
|
2494
|
-
if (nextStrategy !== undefined) runtimeConfig.accountPoolStrategy = nextStrategy;
|
|
2495
|
-
if (nextSticky !== undefined) runtimeConfig.accountPoolStickyLimit = nextSticky;
|
|
2496
|
-
saveRuntimeConfig(config, runtimeConfig);
|
|
2497
|
-
return jsonResponse({
|
|
2498
|
-
ok: true,
|
|
2499
|
-
accountPoolStrategy: normalizeCodexAccountPoolStrategy(runtimeConfig.accountPoolStrategy),
|
|
2500
|
-
accountPoolStickyLimit: normalizeAccountPoolStickyLimit(runtimeConfig.accountPoolStickyLimit),
|
|
2501
|
-
});
|
|
2502
|
-
}
|
|
2503
|
-
|
|
2504
|
-
if (url.pathname === "/api/codex-auth/failover" && req.method === "PUT") {
|
|
2505
|
-
let body: { threshold: number };
|
|
2506
|
-
try { body = (await req.json()) as typeof body; } catch { return jsonResponse({ error: "Invalid JSON" }, 400); }
|
|
2507
|
-
if (typeof body.threshold !== "number" || !Number.isInteger(body.threshold) || body.threshold < 0 || body.threshold > 20) {
|
|
2508
|
-
return jsonResponse({ error: "Threshold must be an integer 0-20" }, 400);
|
|
2509
|
-
}
|
|
2510
|
-
const runtimeConfig = getRuntimeConfig(config);
|
|
2511
|
-
runtimeConfig.upstreamFailoverThreshold = body.threshold;
|
|
2512
|
-
saveRuntimeConfig(config, runtimeConfig);
|
|
2513
|
-
return jsonResponse({ ok: true });
|
|
2514
|
-
}
|
|
2515
|
-
|
|
2516
|
-
if (url.pathname === "/api/codex-auth/quota/history" && req.method === "GET") {
|
|
2517
|
-
const accountId = url.searchParams.get("accountId");
|
|
2518
|
-
const rawLimit = url.searchParams.get("limit");
|
|
2519
|
-
if (url.searchParams.getAll("accountId").length !== 1 || !isValidCodexAccountId(accountId)
|
|
2520
|
-
|| url.searchParams.getAll("limit").length > 1
|
|
2521
|
-
|| [...url.searchParams.keys()].some(key => key !== "accountId" && key !== "limit")
|
|
2522
|
-
|| (rawLimit !== null && !/^(?:[1-9]|[1-9][0-9]|1[0-9]{2}|200)$/.test(rawLimit))) {
|
|
2523
|
-
return jsonResponse({ error: "A stored pool accountId and optional limit from 1 to 200 are required" }, 400);
|
|
2524
|
-
}
|
|
2525
|
-
const runtimeConfig = getRuntimeConfig(config);
|
|
2526
|
-
const account = configuredPoolAccount(runtimeConfig, accountId);
|
|
2527
|
-
if (!account) return jsonResponse({ error: "Unknown pool account" }, 404);
|
|
2528
|
-
const identity = poolQuotaHistoryIdentity(accountId);
|
|
2529
|
-
const allHistory = getAccountQuotaHistory(accountId);
|
|
2530
|
-
const limit = rawLimit === null ? 200 : Number(rawLimit);
|
|
2531
|
-
const history = { ...allHistory, observations: allHistory.observations.slice(-limit), truncated: allHistory.observations.length > limit };
|
|
2532
|
-
const label = account.logLabel;
|
|
2533
|
-
const labelStillUnique = () => {
|
|
2534
|
-
const current = getRuntimeConfig(config);
|
|
2535
|
-
return configuredPoolAccount(current, accountId)?.logLabel === label
|
|
2536
|
-
&& current.codexAccounts?.filter(row => codexAccountLogLabel(row) === label).length === 1;
|
|
2537
|
-
};
|
|
2538
|
-
let capacity: CodexCapacityResult = insufficientCodexCapacity("identity_unavailable");
|
|
2539
|
-
if (identity && identity === poolQuotaHistoryIdentity(accountId) && label && CODEX_ACCOUNT_LOG_LABEL_RE.test(label) && labelStillUnique()) {
|
|
2540
|
-
try {
|
|
2541
|
-
const usage = await readUsageSnapshotForManagement();
|
|
2542
|
-
if (poolQuotaHistoryIdentity(accountId) !== identity || !labelStillUnique()) capacity = insufficientCodexCapacity("identity_changed");
|
|
2543
|
-
else if (!usage.revision) capacity = insufficientCodexCapacity("ledger_unavailable");
|
|
2544
|
-
else if (usage.truncatedPrefixBytes > 0 || usage.entriesTruncated || usage.entriesDropped > 0) capacity = insufficientCodexCapacity("ledger_truncated");
|
|
2545
|
-
else capacity = estimateCodexQuotaCapacity(allHistory.observations, usage.entries, label,
|
|
2546
|
-
model => codexQuotaScopeForModel(model) === "shared");
|
|
2547
|
-
} catch { capacity = insufficientCodexCapacity("ledger_unavailable"); }
|
|
2548
|
-
}
|
|
2549
|
-
if (!configuredPoolAccount(getRuntimeConfig(config), accountId)) return jsonResponse({ error: "Unknown pool account" }, 404);
|
|
2550
|
-
if (identity !== poolQuotaHistoryIdentity(accountId) || (identity && label && !labelStillUnique())) {
|
|
2551
|
-
return jsonResponse({ accountId, ...getAccountQuotaHistory(accountId, limit), capacity: insufficientCodexCapacity("identity_changed") });
|
|
2552
|
-
}
|
|
2553
|
-
return jsonResponse({ accountId, ...history, capacity });
|
|
2554
|
-
}
|
|
2555
|
-
|
|
2556
|
-
if (url.pathname === "/api/codex-auth/quota" && req.method === "GET") {
|
|
2557
|
-
const quotas: Record<string, unknown> = {};
|
|
2558
|
-
for (const [id, q] of listAccountQuotas()) quotas[id] = q;
|
|
2559
|
-
return jsonResponse({ quotas });
|
|
2560
|
-
}
|
|
2561
|
-
|
|
2562
|
-
if (url.pathname === "/api/codex-auth/reset-credits" && req.method === "GET") {
|
|
2563
|
-
const accountId = url.searchParams.get("accountId");
|
|
2564
|
-
if (!accountId) return jsonResponse({ error: "accountId required" }, 400);
|
|
2565
|
-
|
|
2566
|
-
try {
|
|
2567
|
-
const result = await withResetCreditAuth(getRuntimeConfig(config), accountId, async auth => {
|
|
2568
|
-
const linkedSignal = signalWithTimeout(8000, req.signal);
|
|
2569
|
-
let detachBodyAbort = () => {};
|
|
2570
|
-
try {
|
|
2571
|
-
let resp: Response;
|
|
2572
|
-
try {
|
|
2573
|
-
resp = await fetch(
|
|
2574
|
-
"https://chatgpt.com/backend-api/wham/rate-limit-reset-credits",
|
|
2575
|
-
{
|
|
2576
|
-
headers: {
|
|
2577
|
-
Authorization: `Bearer ${auth.accessToken}`,
|
|
2578
|
-
"ChatGPT-Account-Id": auth.chatgptAccountId,
|
|
2579
|
-
},
|
|
2580
|
-
signal: linkedSignal.signal,
|
|
2581
|
-
},
|
|
2582
|
-
);
|
|
2583
|
-
} catch (error) {
|
|
2584
|
-
if (linkedSignal.signal.aborted) {
|
|
2585
|
-
return jsonResponse({ error: "Invalid upstream reset-credit response" }, 502);
|
|
2586
|
-
}
|
|
2587
|
-
throw error;
|
|
2588
|
-
}
|
|
2589
|
-
// Own the response body before the bounded reader attaches. If the client
|
|
2590
|
-
// disconnects in that narrow window, Bun otherwise tears down the native
|
|
2591
|
-
// body off the awaited path and can report an unhandled rejection.
|
|
2592
|
-
detachBodyAbort = cancelBodyOnAbort(resp.body, linkedSignal.signal);
|
|
2593
|
-
if (!resp.ok) {
|
|
2594
|
-
await resp.body?.cancel().catch(() => {});
|
|
2595
|
-
return jsonResponse({ error: `Upstream error ${resp.status}` }, resp.status);
|
|
2596
|
-
}
|
|
2597
|
-
const parsed = await readResetCreditJson(resp, linkedSignal.signal);
|
|
2598
|
-
if (!parsed.ok) {
|
|
2599
|
-
return jsonResponse({ error: "Invalid upstream reset-credit response" }, 502);
|
|
2600
|
-
}
|
|
2601
|
-
return jsonResponse(safeResetCreditsDto(parsed.value));
|
|
2602
|
-
} finally {
|
|
2603
|
-
detachBodyAbort();
|
|
2604
|
-
linkedSignal.cleanup();
|
|
2605
|
-
}
|
|
2606
|
-
});
|
|
2607
|
-
return result.ok ? result.value : result.response;
|
|
2608
|
-
} catch (e) {
|
|
2609
|
-
return jsonResponse({ error: e instanceof Error ? e.message : "Reset credit lookup failed" }, 500);
|
|
2610
|
-
}
|
|
2611
|
-
}
|
|
2612
|
-
|
|
2613
|
-
if (url.pathname === "/api/codex-auth/reset-credits/consume" && req.method === "POST") {
|
|
2614
|
-
const body = (await req.json().catch(() => ({}))) as {
|
|
2615
|
-
accountId?: string;
|
|
2616
|
-
operationId?: unknown;
|
|
2617
|
-
};
|
|
2618
|
-
if (!body.accountId) return jsonResponse({ error: "accountId required" }, 400);
|
|
2619
|
-
const accountId = body.accountId;
|
|
2620
|
-
// Optional caller-owned idempotency identity (#3375 axis D). Absent => legacy
|
|
2621
|
-
// behavior: a fresh random redeem_request_id and no durable ledger row.
|
|
2622
|
-
// The ledger throws TypeError on a malformed id, so the format check has to
|
|
2623
|
-
// happen here rather than at the call site, or it surfaces as a 500.
|
|
2624
|
-
const hasOperationId = body.operationId !== undefined;
|
|
2625
|
-
if (hasOperationId && !isCodexResetCreditOperationId(body.operationId)) {
|
|
2626
|
-
return jsonResponse({ error: "Invalid operationId format" }, 400);
|
|
2627
|
-
}
|
|
2628
|
-
const requestedOperationId = hasOperationId ? body.operationId as string : undefined;
|
|
2629
|
-
|
|
2630
|
-
try {
|
|
2631
|
-
const operation = await withResetCreditAuth(getRuntimeConfig(config), accountId, async auth => {
|
|
2632
|
-
// The ledger keys manual operations by the *physical* ChatGPT account, which is
|
|
2633
|
-
// only known after the auth wrapper resolves credentials. Open here, not earlier.
|
|
2634
|
-
let identity = requestedOperationId === undefined
|
|
2635
|
-
? undefined
|
|
2636
|
-
: {
|
|
2637
|
-
accountId,
|
|
2638
|
-
chatgptAccountId: auth.chatgptAccountId,
|
|
2639
|
-
operationId: requestedOperationId,
|
|
2640
|
-
} as const;
|
|
2641
|
-
let idempotencyKey: string;
|
|
2642
|
-
if (identity) {
|
|
2643
|
-
const opened = openManualResetCreditOperation(identity);
|
|
2644
|
-
if (opened.kind === "terminal") {
|
|
2645
|
-
// Durably settled already: replay the recorded outcome instead of
|
|
2646
|
-
// trusting upstream idempotency for an irreversible spend. No
|
|
2647
|
-
// `remaining` — that field is only reported from a freshly parsed
|
|
2648
|
-
// available_count, and a replay has none.
|
|
2649
|
-
return jsonResponse({ code: opened.code, replayed: true });
|
|
2650
|
-
}
|
|
2651
|
-
if (opened.kind === "identity-mismatch") {
|
|
2652
|
-
return jsonResponse({
|
|
2653
|
-
error: "operation_id_owned_by_another_account",
|
|
2654
|
-
code: "identity_mismatch",
|
|
2655
|
-
}, 409);
|
|
2656
|
-
}
|
|
2657
|
-
if (opened.kind !== "execute") {
|
|
2658
|
-
// capacity | unavailable -> fail closed. Falling back to a random id
|
|
2659
|
-
// would silently reintroduce the double-spend this identity prevents.
|
|
2660
|
-
const response = jsonResponse({
|
|
2661
|
-
error: opened.kind === "capacity"
|
|
2662
|
-
? "reset_credit_ledger_capacity"
|
|
2663
|
-
: "reset_credit_ledger_unavailable",
|
|
2664
|
-
code: opened.kind,
|
|
2665
|
-
}, 503);
|
|
2666
|
-
response.headers.set("Retry-After", "1");
|
|
2667
|
-
return response;
|
|
2668
|
-
}
|
|
2669
|
-
// Canonical id, which an alias join may map to an earlier caller id.
|
|
2670
|
-
identity = { ...identity, operationId: opened.operationId };
|
|
2671
|
-
idempotencyKey = opened.operationId;
|
|
2672
|
-
} else {
|
|
2673
|
-
idempotencyKey = crypto.randomUUID();
|
|
2674
|
-
}
|
|
2675
|
-
const claims = manualResetAuthStillLive(accountId, auth)
|
|
2676
|
-
? claimManualResetCooldowns(getRuntimeConfig(config), accountId, Date.now(), auth.poolGeneration) : [];
|
|
2677
|
-
try {
|
|
2678
|
-
let resp: Response;
|
|
2679
|
-
try {
|
|
2680
|
-
resp = await fetch(
|
|
2681
|
-
"https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume",
|
|
2682
|
-
{
|
|
2683
|
-
method: "POST",
|
|
2684
|
-
headers: {
|
|
2685
|
-
Authorization: `Bearer ${auth.accessToken}`,
|
|
2686
|
-
"ChatGPT-Account-Id": auth.chatgptAccountId,
|
|
2687
|
-
"Content-Type": "application/json",
|
|
2688
|
-
},
|
|
2689
|
-
body: JSON.stringify({ redeem_request_id: idempotencyKey }),
|
|
2690
|
-
signal: AbortSignal.timeout(10_000),
|
|
2691
|
-
},
|
|
2692
|
-
);
|
|
2693
|
-
} catch (error) {
|
|
2694
|
-
// Dispatch outcome unknown: the credit may or may not have been spent.
|
|
2695
|
-
// Mark ambiguous so a replay of this same id is never treated as new.
|
|
2696
|
-
if (identity) markManualResetCreditOperationAmbiguous(identity);
|
|
2697
|
-
throw error;
|
|
2698
|
-
}
|
|
2699
|
-
if (!resp.ok) {
|
|
2700
|
-
await resp.body?.cancel().catch(() => {});
|
|
2701
|
-
if (identity) markManualResetCreditOperationAmbiguous(identity);
|
|
2702
|
-
return jsonResponse({ error: `Upstream error ${resp.status}` }, resp.status);
|
|
2703
|
-
}
|
|
2704
|
-
const result = safeResetCreditConsumeDto(await resp.json());
|
|
2705
|
-
if (identity) {
|
|
2706
|
-
// Narrow explicitly rather than casting: `safeResetCreditConsumeDto`
|
|
2707
|
-
// normalizes anything unrecognized to "unknown", and settling that
|
|
2708
|
-
// would come back as a mismatch and leave the row pending anyway.
|
|
2709
|
-
// Settlement failure never downgrades the user-visible outcome: the
|
|
2710
|
-
// spend already happened upstream, and reporting failure would invite
|
|
2711
|
-
// a manual retry -- the exact double-spend this unit removes.
|
|
2712
|
-
if (result.code === "reset" || result.code === "already_redeemed"
|
|
2713
|
-
|| result.code === "nothing_to_reset" || result.code === "no_credit") {
|
|
2714
|
-
settleManualResetCreditOperation(identity, result.code);
|
|
2715
|
-
} else {
|
|
2716
|
-
markManualResetCreditOperationAmbiguous(identity);
|
|
2717
|
-
}
|
|
2718
|
-
}
|
|
2719
|
-
// After a successful redeem (or an idempotent already_redeemed), refresh WHAM usage
|
|
2720
|
-
// and return remaining only when that refresh freshly parsed available_count.
|
|
2721
|
-
// Do not fall back to a preserved cached resetCredits (failed/omitted refresh).
|
|
2722
|
-
if (result.code === "reset" || result.code === "already_redeemed") {
|
|
2723
|
-
const freshResetCredits = await refreshAfterManualReset(
|
|
2724
|
-
config, accountId, auth, claims, result.code === "reset",
|
|
2725
|
-
);
|
|
2726
|
-
return jsonResponse({
|
|
2727
|
-
code: result.code,
|
|
2728
|
-
...(typeof freshResetCredits === "number" && Number.isFinite(freshResetCredits)
|
|
2729
|
-
? { remaining: freshResetCredits }
|
|
2730
|
-
: {}),
|
|
2731
|
-
});
|
|
2732
|
-
}
|
|
2733
|
-
return jsonResponse(result);
|
|
2734
|
-
} finally {
|
|
2735
|
-
// Release only this invocation's leases, including every ambiguous/error outcome.
|
|
2736
|
-
for (const claim of claims) settleManualResetCooldown(getRuntimeConfig(config), claim, false);
|
|
2737
|
-
}
|
|
2738
|
-
});
|
|
2739
|
-
return operation.ok ? operation.value : operation.response;
|
|
2740
|
-
} catch (e) {
|
|
2741
|
-
if (e instanceof PoolQuotaProbeBusyError) {
|
|
2742
|
-
const response = jsonResponse({ error: "server_busy", code: "server_busy" }, 503);
|
|
2743
|
-
response.headers.set("Retry-After", "1");
|
|
2744
|
-
return response;
|
|
2745
|
-
}
|
|
2746
|
-
return jsonResponse({ error: e instanceof Error ? e.message : "Reset credit consume failed" }, 500);
|
|
2747
|
-
}
|
|
2748
|
-
}
|
|
2749
|
-
|
|
2750
|
-
if (url.pathname === "/api/codex-auth/login" && req.method === "POST") {
|
|
2751
|
-
const body = (await req.json().catch(() => ({}))) as {
|
|
2752
|
-
id?: string;
|
|
2753
|
-
reauth?: boolean;
|
|
2754
|
-
openBrowser?: unknown;
|
|
2755
|
-
device?: unknown;
|
|
2756
|
-
};
|
|
2757
|
-
// Device mode: no local browser, no loopback listener. The only way to add
|
|
2758
|
-
// an account to a headless hub (#3366).
|
|
2759
|
-
const useDeviceFlow = body.device === true;
|
|
2760
|
-
const requestedAccountId = body.id?.trim();
|
|
2761
|
-
const reauth = body.reauth === true;
|
|
2762
|
-
if (requestedAccountId && !isValidCodexAccountId(requestedAccountId)) {
|
|
2763
|
-
return jsonResponse({ error: "Invalid account id format" }, 400);
|
|
2764
|
-
}
|
|
2765
|
-
const accountId = requestedAccountId || `chatgpt-${Date.now()}`;
|
|
2766
|
-
const runtimeConfig = getRuntimeConfig(config);
|
|
2767
|
-
const preflightConflict = !reauth
|
|
2768
|
-
? codexAccountPersistenceConflict(runtimeConfig, accountId, "create")
|
|
2769
|
-
: undefined;
|
|
2770
|
-
if (preflightConflict) return jsonResponse({ error: preflightConflict }, 400);
|
|
2771
|
-
if (reauth) {
|
|
2772
|
-
if (!requestedAccountId) return jsonResponse({ error: "id required for reauth" }, 400);
|
|
2773
|
-
if (!configuredPoolAccount(runtimeConfig, accountId)) {
|
|
2774
|
-
return jsonResponse({ error: "Unknown pool account for reauth" }, 404);
|
|
2775
|
-
}
|
|
2776
|
-
}
|
|
2777
|
-
pruneCodexLoginState();
|
|
2778
|
-
if (codexAuthLoginState.size >= MAX_CODEX_LOGIN_STATE_ROWS) {
|
|
2779
|
-
const busy = new CodexLoginStateBusyError();
|
|
2780
|
-
const response = jsonResponse({ error: busy.message, code: busy.code }, 503);
|
|
2781
|
-
response.headers.set("Retry-After", "1");
|
|
2782
|
-
return response;
|
|
2783
|
-
}
|
|
2784
|
-
const flowId = `flow-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
2785
|
-
const loginOwner: CodexLoginStateRow = { status: "starting", startedAt: Date.now() };
|
|
2786
|
-
codexAuthLoginState.set(flowId, loginOwner);
|
|
2787
|
-
try {
|
|
2788
|
-
const { startLoginFlow, getLoginStatus, publicOAuthAuthenticationErrorMessage } = await import("../oauth");
|
|
2789
|
-
const result = await startLoginFlow("chatgpt", {
|
|
2790
|
-
forceLogin: true,
|
|
2791
|
-
...(useDeviceFlow ? { flow: "device" as const } : {}),
|
|
2792
|
-
});
|
|
2793
|
-
|
|
2794
|
-
// Open the browser server-side (same pattern as /api/oauth/login in management-api.ts).
|
|
2795
|
-
// The GUI's window.open is popup-blocked because it runs after an await, not a direct click.
|
|
2796
|
-
// Both login routes share one resolver so this surface cannot drift from the other.
|
|
2797
|
-
const { shouldOpenBrowserForLogin } = await import("../oauth/open-browser-choice");
|
|
2798
|
-
// A device flow's URL is a verification page the user opens on ANOTHER
|
|
2799
|
-
// machine. Opening it on the hub host is useless at best, and on a
|
|
2800
|
-
// headless host it fails. `deviceCode` is the same signal the generic
|
|
2801
|
-
// OAuth login route uses to make this decision.
|
|
2802
|
-
if (result.url && !result.deviceCode && shouldOpenBrowserForLogin(body.openBrowser, runtimeConfig)) {
|
|
2803
|
-
const { openUrl } = await import("../lib/open-url");
|
|
2804
|
-
openUrl(result.url);
|
|
2805
|
-
}
|
|
2806
|
-
|
|
2807
|
-
(async () => {
|
|
2808
|
-
try {
|
|
2809
|
-
let completed = false;
|
|
2810
|
-
// The device grant lives 15 minutes and the whole point is that the
|
|
2811
|
-
// user walks to another device to enter the code. A 5-minute server
|
|
2812
|
-
// budget would kill the flow at minute five while the grant is still
|
|
2813
|
-
// valid. The extra 30 attempts past 450 are settlement margin: a user
|
|
2814
|
-
// who authorizes in the final seconds still needs the token exchange
|
|
2815
|
-
// and credential write to land before this loop gives up.
|
|
2816
|
-
const pollAttempts = useDeviceFlow ? 480 : 150;
|
|
2817
|
-
for (let i = 0; i < pollAttempts; i++) {
|
|
2818
|
-
await new Promise(r => setTimeout(r, 2000));
|
|
2819
|
-
const st = getLoginStatus("chatgpt");
|
|
2820
|
-
if (st.done && st.loggedIn) {
|
|
2821
|
-
const { getCredential } = await import("../oauth/store");
|
|
2822
|
-
const cred = getCredential("chatgpt");
|
|
2823
|
-
if (cred) {
|
|
2824
|
-
const oauthAccountId = cred.accountId;
|
|
2825
|
-
if (!oauthAccountId) {
|
|
2826
|
-
setCodexLoginState(flowId, {
|
|
2827
|
-
status: "error",
|
|
2828
|
-
error: "Could not determine account identity from OAuth tokens. Please retry OAuth login.",
|
|
2829
|
-
doneAt: Date.now(),
|
|
2830
|
-
});
|
|
2831
|
-
completed = true;
|
|
2832
|
-
break;
|
|
2833
|
-
}
|
|
2834
|
-
|
|
2835
|
-
let email = cred.email || accountId;
|
|
2836
|
-
let plan: string | undefined;
|
|
2837
|
-
let quota: Omit<StoredAccountQuota, "updatedAt"> | null = null;
|
|
2838
|
-
try {
|
|
2839
|
-
const tokens = { access_token: cred.access, account_id: oauthAccountId };
|
|
2840
|
-
const resp = await fetch("https://chatgpt.com/backend-api/wham/usage", {
|
|
2841
|
-
headers: { Authorization: `Bearer ${tokens.access_token}`, "ChatGPT-Account-Id": tokens.account_id },
|
|
2842
|
-
signal: AbortSignal.timeout(8000),
|
|
2843
|
-
});
|
|
2844
|
-
if (resp.ok) {
|
|
2845
|
-
const data = (await resp.json()) as WhamUsageResponse;
|
|
2846
|
-
email = data.email ?? email;
|
|
2847
|
-
plan = nonEmptyPlan(data.plan_type) ?? undefined;
|
|
2848
|
-
quota = parseUsageQuota(data);
|
|
2849
|
-
}
|
|
2850
|
-
} catch { /* wham fetch is non-blocking */ }
|
|
2851
|
-
// Reauth must refresh the same ChatGPT identity already bound to this pool slot.
|
|
2852
|
-
// Otherwise a different login would silently overwrite credentials under a trusted id.
|
|
2853
|
-
if (reauth) {
|
|
2854
|
-
const existingCred = getCodexAccountCredential(accountId);
|
|
2855
|
-
const poolAccount = configuredPoolAccount(getRuntimeConfig(config), accountId);
|
|
2856
|
-
const expectedChatgptId = existingCred?.chatgptAccountId?.trim();
|
|
2857
|
-
const expectedEmail = poolAccount?.email?.trim().toLowerCase();
|
|
2858
|
-
const gotEmail = email.trim().toLowerCase();
|
|
2859
|
-
if (expectedChatgptId) {
|
|
2860
|
-
if (expectedChatgptId !== oauthAccountId) {
|
|
2861
|
-
setCodexLoginState(flowId, {
|
|
2862
|
-
status: "error",
|
|
2863
|
-
error: "Signed-in ChatGPT account does not match this pool account. Sign in with the same account, or remove it and add a new one.",
|
|
2864
|
-
doneAt: Date.now(),
|
|
2865
|
-
});
|
|
2866
|
-
completed = true;
|
|
2867
|
-
break;
|
|
2868
|
-
}
|
|
2869
|
-
} else if (expectedEmail) {
|
|
2870
|
-
if (!gotEmail || gotEmail !== expectedEmail) {
|
|
2871
|
-
setCodexLoginState(flowId, {
|
|
2872
|
-
status: "error",
|
|
2873
|
-
error: "Signed-in ChatGPT account does not match this pool account. Sign in with the same account, or remove it and add a new one.",
|
|
2874
|
-
doneAt: Date.now(),
|
|
2875
|
-
});
|
|
2876
|
-
completed = true;
|
|
2877
|
-
break;
|
|
2878
|
-
}
|
|
2879
|
-
} else {
|
|
2880
|
-
// No chatgptAccountId and no pool email — refuse silent identity replacement
|
|
2881
|
-
// (including empty credential slots that still have a pool row).
|
|
2882
|
-
setCodexLoginState(flowId, {
|
|
2883
|
-
status: "error",
|
|
2884
|
-
error: "Cannot verify account identity for reauth. Remove this account and add it again.",
|
|
2885
|
-
doneAt: Date.now(),
|
|
2886
|
-
});
|
|
2887
|
-
completed = true;
|
|
2888
|
-
break;
|
|
2889
|
-
}
|
|
2890
|
-
}
|
|
2891
|
-
|
|
2892
|
-
// 1.2: Duplicate check is scoped by personal vs workspace plan bucket.
|
|
2893
|
-
const collision = checkAccountIdCollision(oauthAccountId, email, plan, reauth ? accountId : undefined);
|
|
2894
|
-
if (collision.collision) {
|
|
2895
|
-
setCodexLoginState(flowId, {
|
|
2896
|
-
status: "error", error: collision.reason, doneAt: Date.now(),
|
|
2897
|
-
});
|
|
2898
|
-
completed = true;
|
|
2899
|
-
break;
|
|
2900
|
-
}
|
|
2901
|
-
|
|
2902
|
-
// A successful authenticated WHAM read can prove quota is exhausted without
|
|
2903
|
-
// spending an inference request. Store the account, but defer inference validation
|
|
2904
|
-
// and keep it unavailable to routing. Unknown/failed usage reads retain the gate.
|
|
2905
|
-
const warmup = isCodexQuotaExhausted(quota, plan)
|
|
2906
|
-
? { ok: true as const, validatedAt: undefined }
|
|
2907
|
-
: await verifyCodexAccountWarmup(accountId, cred.access, oauthAccountId);
|
|
2908
|
-
if (!warmup.ok) {
|
|
2909
|
-
const body = await warmup.response.json().catch(() => ({})) as { error?: string; reason?: string };
|
|
2910
|
-
setCodexLoginState(flowId, {
|
|
2911
|
-
status: "error",
|
|
2912
|
-
error: body.reason ? `${body.error ?? "Codex account warmup failed"} (${body.reason})` : body.error ?? "Codex account warmup failed",
|
|
2913
|
-
doneAt: Date.now(),
|
|
2914
|
-
});
|
|
2915
|
-
completed = true;
|
|
2916
|
-
break;
|
|
2917
|
-
}
|
|
2918
|
-
|
|
2919
|
-
const latestConfig = getRuntimeConfig(config);
|
|
2920
|
-
const accounts = latestConfig.codexAccounts ?? [];
|
|
2921
|
-
const existingIdx = accounts.findIndex(account => account.id === accountId);
|
|
2922
|
-
let pickerVisibilityChanged = false;
|
|
2923
|
-
let newAccountPersistence: PersistNewCodexAccountOutcome | null = null;
|
|
2924
|
-
const commitConflict = codexAccountPersistenceConflict(
|
|
2925
|
-
latestConfig,
|
|
2926
|
-
accountId,
|
|
2927
|
-
reauth ? "reauth" : "create",
|
|
2928
|
-
);
|
|
2929
|
-
if (commitConflict) {
|
|
2930
|
-
setCodexLoginState(flowId, {
|
|
2931
|
-
status: "error",
|
|
2932
|
-
error: commitConflict,
|
|
2933
|
-
doneAt: Date.now(),
|
|
2934
|
-
});
|
|
2935
|
-
completed = true;
|
|
2936
|
-
break;
|
|
2937
|
-
}
|
|
2938
|
-
|
|
2939
|
-
const credential: CodexAccountCredentials = {
|
|
2940
|
-
accessToken: cred.access,
|
|
2941
|
-
refreshToken: cred.refresh,
|
|
2942
|
-
expiresAt: cred.expires,
|
|
2943
|
-
chatgptAccountId: oauthAccountId,
|
|
2944
|
-
};
|
|
2945
|
-
|
|
2946
|
-
if (existingIdx >= 0) {
|
|
2947
|
-
const generation = saveCodexAccountCredential(accountId, credential, {
|
|
2948
|
-
validationPending: warmup.validatedAt === undefined,
|
|
2949
|
-
});
|
|
2950
|
-
// A successful reauthentication replaces the credential generation. Do not let a
|
|
2951
|
-
// failed optional WHAM probe make the replacement inherit quota from the old record.
|
|
2952
|
-
if (reauth) clearAccountQuota(accountId);
|
|
2953
|
-
if (warmup.validatedAt !== undefined) markCodexAccountValidated(accountId, warmup.validatedAt, generation);
|
|
2954
|
-
clearAccountNeedsReauth(accountId);
|
|
2955
|
-
if (quota) setAccountQuotaFromParsed(accountId, quota);
|
|
2956
|
-
// Keep the pool id stable; refresh display metadata after a successful login/reauth.
|
|
2957
|
-
accounts[existingIdx] = withCodexAccountLogLabel({
|
|
2958
|
-
...accounts[existingIdx],
|
|
2959
|
-
email,
|
|
2960
|
-
plan: plan ?? accounts[existingIdx].plan,
|
|
2961
|
-
isMain: false,
|
|
2962
|
-
}, accounts);
|
|
2963
|
-
latestConfig.codexAccounts = accounts;
|
|
2964
|
-
saveRuntimeConfig(config, latestConfig);
|
|
2965
|
-
} else {
|
|
2966
|
-
const addedAccount = withCodexAccountLogLabel({ id: accountId, email, plan, isMain: false }, accounts);
|
|
2967
|
-
newAccountPersistence = persistNewCodexAccount(
|
|
2968
|
-
config,
|
|
2969
|
-
latestConfig,
|
|
2970
|
-
addedAccount,
|
|
2971
|
-
{
|
|
2972
|
-
credential,
|
|
2973
|
-
validatedAt: warmup.validatedAt,
|
|
2974
|
-
},
|
|
2975
|
-
);
|
|
2976
|
-
pickerVisibilityChanged = newAccountPersistence.pickerVisibilityChanged;
|
|
2977
|
-
}
|
|
2978
|
-
reconcileLiveStateStores();
|
|
2979
|
-
if (newAccountPersistence?.status === "publication-failed") {
|
|
2980
|
-
markAccountNeedsReauth(accountId);
|
|
2981
|
-
}
|
|
2982
|
-
// A new quota row is generation-gated by live account ownership. Reconcile the
|
|
2983
|
-
// durable config owner first so a partial prior sweep cannot reject this write.
|
|
2984
|
-
if (newAccountPersistence?.status === "committed" && quota) {
|
|
2985
|
-
setAccountQuotaFromParsed(accountId, quota);
|
|
2986
|
-
}
|
|
2987
|
-
const { catalogRefreshPending } = await convergeAccountNamespaceCatalog(
|
|
2988
|
-
latestConfig,
|
|
2989
|
-
pickerVisibilityChanged,
|
|
2990
|
-
convergeCodexCatalog,
|
|
2991
|
-
);
|
|
2992
|
-
if (newAccountPersistence?.status === "publication-failed") {
|
|
2993
|
-
setCodexLoginState(flowId, {
|
|
2994
|
-
status: "error",
|
|
2995
|
-
...codexCredentialPersistenceFailure(accountId, catalogRefreshPending),
|
|
2996
|
-
doneAt: Date.now(),
|
|
2997
|
-
});
|
|
2998
|
-
completed = true;
|
|
2999
|
-
} else {
|
|
3000
|
-
setCodexLoginState(flowId, {
|
|
3001
|
-
status: "done",
|
|
3002
|
-
accountId,
|
|
3003
|
-
email,
|
|
3004
|
-
...(warmup.validatedAt === undefined ? { validationPending: true } : {}),
|
|
3005
|
-
...(catalogRefreshPending ? { catalogRefreshPending: true } : {}),
|
|
3006
|
-
doneAt: Date.now(),
|
|
3007
|
-
});
|
|
3008
|
-
completed = true;
|
|
3009
|
-
}
|
|
3010
|
-
}
|
|
3011
|
-
break;
|
|
3012
|
-
}
|
|
3013
|
-
if (st.done && st.error) {
|
|
3014
|
-
setCodexLoginState(flowId, {
|
|
3015
|
-
status: "error",
|
|
3016
|
-
// startLoginFlow projects background failures before storing login status, so
|
|
3017
|
-
// fixed actionable OAuth messages retain their type-derived remediation here.
|
|
3018
|
-
error: st.error,
|
|
3019
|
-
doneAt: Date.now(),
|
|
3020
|
-
});
|
|
3021
|
-
completed = true;
|
|
3022
|
-
break;
|
|
3023
|
-
}
|
|
3024
|
-
}
|
|
3025
|
-
if (!completed) {
|
|
3026
|
-
setCodexLoginState(flowId, {
|
|
3027
|
-
status: "error",
|
|
3028
|
-
error: "Login timed out before OAuth completed.",
|
|
3029
|
-
doneAt: Date.now(),
|
|
3030
|
-
});
|
|
3031
|
-
}
|
|
3032
|
-
} catch (error) {
|
|
3033
|
-
const message = error instanceof ConfigMutationLockError
|
|
3034
|
-
|| error instanceof CodexCredentialRefreshLockTimeoutError
|
|
3035
|
-
? "Configuration is busy; retry login shortly."
|
|
3036
|
-
: error instanceof CodexCredentialRefreshBusyError || error instanceof CodexCredentialRefreshStaleError
|
|
3037
|
-
? "Credential refresh is busy; retry login shortly."
|
|
3038
|
-
: publicOAuthAuthenticationErrorMessage(error);
|
|
3039
|
-
setCodexLoginState(flowId, {
|
|
3040
|
-
status: "error",
|
|
3041
|
-
error: message,
|
|
3042
|
-
doneAt: Date.now(),
|
|
3043
|
-
});
|
|
3044
|
-
} finally {
|
|
3045
|
-
// TTL: keep completed flow state available for clients that miss a short polling window.
|
|
3046
|
-
setTimeout(() => { if (codexAuthLoginState.get(flowId) === loginOwner) codexAuthLoginState.delete(flowId); }, CODEX_LOGIN_TERMINAL_TTL_MS);
|
|
3047
|
-
}
|
|
3048
|
-
})();
|
|
3049
|
-
|
|
3050
|
-
setCodexLoginState(flowId, { status: "pending" });
|
|
3051
|
-
return jsonResponse({
|
|
3052
|
-
ok: true,
|
|
3053
|
-
flowId,
|
|
3054
|
-
url: result.url,
|
|
3055
|
-
instructions: result.instructions,
|
|
3056
|
-
// Dropped before #3366: every device-code surface renders this field,
|
|
3057
|
-
// so withholding it left the GUI and CLI with no code to show.
|
|
3058
|
-
...(result.deviceCode ? { deviceCode: result.deviceCode } : {}),
|
|
3059
|
-
});
|
|
3060
|
-
} catch (e) {
|
|
3061
|
-
if (codexAuthLoginState.get(flowId) === loginOwner) codexAuthLoginState.delete(flowId);
|
|
3062
|
-
const msg = e instanceof Error ? e.message : String(e);
|
|
3063
|
-
if (msg === "A login for chatgpt is already in progress") {
|
|
3064
|
-
return jsonResponse({ error: msg, status: "pending" }, 409);
|
|
3065
|
-
}
|
|
3066
|
-
if (e instanceof CodexCredentialRefreshBusyError || e instanceof CodexCredentialRefreshStaleError) {
|
|
3067
|
-
const response = jsonResponse({ error: "server_busy", code: "server_busy" }, 503);
|
|
3068
|
-
response.headers.set("Retry-After", "1");
|
|
3069
|
-
return response;
|
|
3070
|
-
}
|
|
3071
|
-
const { publicOAuthAuthenticationErrorMessage } = await import("../oauth");
|
|
3072
|
-
return jsonResponse({ error: publicOAuthAuthenticationErrorMessage(e) }, 500);
|
|
3073
|
-
}
|
|
3074
|
-
}
|
|
3075
|
-
|
|
3076
|
-
if (url.pathname === "/api/codex-auth/login/code" && req.method === "POST") {
|
|
3077
|
-
const body = (await req.json().catch(() => ({}))) as { flowId?: unknown; input?: unknown };
|
|
3078
|
-
const flowId = typeof body.flowId === "string" ? body.flowId.trim() : "";
|
|
3079
|
-
const input = typeof body.input === "string" ? body.input : "";
|
|
3080
|
-
if (!flowId) return jsonResponse({ error: "flowId required" }, 400);
|
|
3081
|
-
if (input.length > 4096) return jsonResponse({ error: "input too long" }, 400);
|
|
3082
|
-
|
|
3083
|
-
// Import may yield; validate afterwards so cancel/replace cannot race a stale flow through.
|
|
3084
|
-
const { submitManualLoginCode } = await import("../oauth");
|
|
3085
|
-
const flow = codexAuthLoginState.get(flowId);
|
|
3086
|
-
if (!flow) return jsonResponse({ error: "login flow expired or unknown" }, 400);
|
|
3087
|
-
if (flow.status !== "pending") return jsonResponse({ error: "login flow is not pending" }, 400);
|
|
3088
|
-
|
|
3089
|
-
const result = submitManualLoginCode("chatgpt", input);
|
|
3090
|
-
if (!result.ok) return jsonResponse({ error: result.error }, 400);
|
|
3091
|
-
return jsonResponse({ ok: true }, 202);
|
|
3092
|
-
}
|
|
3093
|
-
|
|
3094
|
-
if (url.pathname === "/api/codex-auth/login/cancel" && req.method === "POST") {
|
|
3095
|
-
const body = (await req.json().catch(() => ({}))) as { flowId?: string };
|
|
3096
|
-
const { cancelLoginFlow } = await import("../oauth");
|
|
3097
|
-
const cancelled = cancelLoginFlow("chatgpt");
|
|
3098
|
-
expireCodexAuthFlow(body.flowId ?? null);
|
|
3099
|
-
return jsonResponse({ ok: true, cancelled });
|
|
3100
|
-
}
|
|
3101
|
-
|
|
3102
|
-
if (url.pathname === "/api/codex-auth/login-status" && req.method === "GET") {
|
|
3103
|
-
const flowId = url.searchParams.get("flowId");
|
|
3104
|
-
const accountId = url.searchParams.get("accountId")?.trim();
|
|
3105
|
-
// Transient flow state carries the address of the account being added, so it follows the
|
|
3106
|
-
// same operator policy as the stored accounts it is about to become.
|
|
3107
|
-
const maskFlowEmails = emailMaskingEnabled(config);
|
|
3108
|
-
// Reauth always has a pre-existing credential; never treat "credential exists" as success
|
|
3109
|
-
// when the flow map entry is gone (would false-complete on lost/expired flow state).
|
|
3110
|
-
const reauthStatus = url.searchParams.get("reauth") === "1";
|
|
3111
|
-
if (flowId) {
|
|
3112
|
-
const st = codexAuthLoginState.get(flowId);
|
|
3113
|
-
if (
|
|
3114
|
-
!st
|
|
3115
|
-
&& accountId
|
|
3116
|
-
&& !reauthStatus
|
|
3117
|
-
&& !isAccountNeedsReauth(accountId)
|
|
3118
|
-
&& getCodexAccountCredential(accountId)
|
|
3119
|
-
) {
|
|
3120
|
-
return jsonResponse({ status: "done", accountId,
|
|
3121
|
-
...(readCodexAccountRecord(accountId)?.codexValidationPending ? { validationPending: true } : {}),
|
|
3122
|
-
});
|
|
3123
|
-
}
|
|
3124
|
-
return jsonResponse(st ? { ...st, email: projectEmail(st.email, maskFlowEmails) ?? undefined } : { status: "expired" });
|
|
3125
|
-
}
|
|
3126
|
-
// Legacy fallback: return latest pending flow
|
|
3127
|
-
for (const [, st] of codexAuthLoginState) {
|
|
3128
|
-
if (st.status === "pending") return jsonResponse({ ...st, email: projectEmail(st.email, maskFlowEmails) ?? undefined });
|
|
3129
|
-
}
|
|
3130
|
-
return jsonResponse({ status: "idle" });
|
|
3131
|
-
}
|
|
3132
|
-
|
|
3133
|
-
return null;
|
|
3134
|
-
}
|