@bitkyc08/opencodex 2.55.0 → 2.56.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) hide show
  1. package/gui/dist/assets/{index-VuoiWj9J.js → index-D4zuyIxQ.js} +1 -1
  2. package/gui/dist/index.html +1 -1
  3. package/package.json +2 -1
  4. package/src/adapters/base.ts +21 -0
  5. package/src/adapters/cursor/transport-retry.ts +46 -1
  6. package/src/adapters/cursor.ts +4 -0
  7. package/src/adapters/kiro/adapter.ts +42 -1
  8. package/src/adapters/kiro-retry.ts +23 -4
  9. package/src/adapters/openai-chat/errors.ts +116 -0
  10. package/src/adapters/openai-chat/messages.ts +346 -0
  11. package/src/adapters/openai-chat/passthrough.ts +146 -0
  12. package/src/adapters/openai-chat/response-events.ts +117 -0
  13. package/src/adapters/openai-chat/tool-call-validation.ts +200 -0
  14. package/src/adapters/openai-chat/tool-schema.ts +477 -0
  15. package/src/adapters/openai-chat/wire.ts +50 -0
  16. package/src/adapters/openai-chat.ts +33 -1445
  17. package/src/adapters/openai-responses/canonical-forward.ts +202 -0
  18. package/src/adapters/openai-responses/image-gen.ts +406 -0
  19. package/src/adapters/openai-responses/internal.ts +3 -0
  20. package/src/adapters/openai-responses/passthrough.ts +611 -0
  21. package/src/adapters/openai-responses/prompt-cache.ts +83 -0
  22. package/src/adapters/openai-responses/reasoning.ts +220 -0
  23. package/src/adapters/openai-responses/request-strips.ts +185 -0
  24. package/src/adapters/openai-responses/tool-output-recovery.ts +509 -0
  25. package/src/adapters/openai-responses/tool-schema.ts +293 -0
  26. package/src/adapters/openai-responses/web-search.ts +156 -0
  27. package/src/adapters/openai-responses.ts +4 -2625
  28. package/src/bridge/errors.ts +34 -0
  29. package/src/bridge/internal.ts +174 -0
  30. package/src/bridge/response-json.ts +624 -0
  31. package/src/bridge/sse.ts +1444 -0
  32. package/src/bridge.ts +5 -2204
  33. package/src/chat/inbound.ts +12 -1
  34. package/src/codex/account-lifecycle.ts +3 -0
  35. package/src/codex/account-store.ts +71 -9
  36. package/src/codex/auth-api/account-list.ts +507 -0
  37. package/src/codex/auth-api/http.ts +32 -0
  38. package/src/codex/auth-api/login-flow.ts +554 -0
  39. package/src/codex/auth-api/login-state.ts +64 -0
  40. package/src/codex/auth-api/main-account-probe.ts +331 -0
  41. package/src/codex/auth-api/pool-mode-gate.ts +274 -0
  42. package/src/codex/auth-api/pool-quota-probe.ts +512 -0
  43. package/src/codex/auth-api/reset-credit-service.ts +422 -0
  44. package/src/codex/auth-api/routes.ts +425 -0
  45. package/src/codex/auth-api/runtime-config.ts +48 -0
  46. package/src/codex/auth-api.ts +27 -3118
  47. package/src/codex/auth-context.ts +95 -28
  48. package/src/codex/catalog/auto-review.ts +507 -0
  49. package/src/codex/catalog/build-entries.ts +981 -0
  50. package/src/codex/catalog/combo-member.ts +375 -0
  51. package/src/codex/catalog/derive-entry.ts +229 -0
  52. package/src/codex/catalog/effort.ts +0 -1
  53. package/src/codex/catalog/gated-native-warn.ts +63 -0
  54. package/src/codex/catalog/gather-capture.ts +533 -0
  55. package/src/codex/catalog/model-hints.ts +691 -0
  56. package/src/codex/catalog/model-visibility.ts +304 -0
  57. package/src/codex/catalog/provider-fetch.ts +52 -2942
  58. package/src/codex/catalog/provider-models.ts +685 -0
  59. package/src/codex/catalog/restore.ts +132 -0
  60. package/src/codex/catalog/retained-sync.ts +706 -0
  61. package/src/codex/catalog/routed-gather.ts +858 -0
  62. package/src/codex/catalog/subagent-roster.ts +176 -0
  63. package/src/codex/catalog/sync.ts +52 -2698
  64. package/src/codex/inject/config-toml.ts +563 -0
  65. package/src/codex/inject/remove.ts +192 -0
  66. package/src/codex/inject/restore.ts +540 -0
  67. package/src/codex/inject/routing-classify.ts +109 -0
  68. package/src/codex/inject/routing-target.ts +125 -0
  69. package/src/codex/inject.ts +81 -1436
  70. package/src/codex/lineage.ts +458 -0
  71. package/src/codex/pool-refresh-backoff.ts +152 -0
  72. package/src/codex/routing/active-account.ts +194 -0
  73. package/src/codex/routing/cooldown-math.ts +275 -0
  74. package/src/codex/routing/health-store.ts +402 -0
  75. package/src/codex/routing/probe-lease.ts +358 -0
  76. package/src/codex/routing/selection.ts +703 -0
  77. package/src/codex/routing/thread-affinity.ts +538 -0
  78. package/src/codex/routing.ts +353 -2234
  79. package/src/codex/shim-fingerprint.ts +223 -0
  80. package/src/codex/shim-inspect.ts +175 -0
  81. package/src/codex/shim-probe.ts +367 -0
  82. package/src/codex/shim-restore-lock.ts +169 -0
  83. package/src/codex/shim-state-file.ts +151 -0
  84. package/src/codex/shim-templates.ts +265 -0
  85. package/src/codex/shim.ts +48 -1268
  86. package/src/config/diagnostics.ts +705 -0
  87. package/src/config/feature-flags.ts +55 -0
  88. package/src/config/live-reconcile.ts +403 -0
  89. package/src/config/load-degrade.ts +880 -0
  90. package/src/config/mutation-lock.ts +244 -0
  91. package/src/config/openai-tier-backup.ts +268 -0
  92. package/src/config/persist-unlocked.ts +92 -0
  93. package/src/config/proxy-env.ts +188 -0
  94. package/src/config/salvage.ts +244 -0
  95. package/src/config/schema/config-schema.ts +640 -0
  96. package/src/config/schema/leaf-validators.ts +855 -0
  97. package/src/config/warn-memo.ts +28 -0
  98. package/src/config.ts +234 -4481
  99. package/src/generated/compatibility-version.json +539 -39
  100. package/src/lib/request-execution-budget.ts +69 -20
  101. package/src/lib/spend-reservation-ledger.ts +940 -0
  102. package/src/lib/upstream-retry.ts +55 -11
  103. package/src/lib/workflow-budget.ts +553 -30
  104. package/src/providers/quota/account-cache.ts +441 -0
  105. package/src/providers/quota/antigravity.ts +295 -0
  106. package/src/providers/quota/report-cache.ts +320 -0
  107. package/src/providers/quota/vendor-probes-key.ts +1243 -0
  108. package/src/providers/quota/vendor-probes-oauth.ts +590 -0
  109. package/src/providers/quota.ts +324 -3079
  110. package/src/providers/registry/entries-core.ts +1221 -0
  111. package/src/providers/registry/entries-extended.ts +1204 -0
  112. package/src/providers/registry/model-seeds.ts +908 -0
  113. package/src/providers/registry/types.ts +352 -0
  114. package/src/providers/registry.ts +24 -3536
  115. package/src/responses/continuation-ownership.ts +29 -0
  116. package/src/responses/state/replay-fingerprint.ts +80 -0
  117. package/src/responses/state/snapshot-codec.ts +104 -0
  118. package/src/responses/state/spill-failure.ts +118 -0
  119. package/src/responses/state/spill-queue.ts +665 -0
  120. package/src/responses/state/temp-recovery.ts +257 -0
  121. package/src/responses/state.ts +82 -1143
  122. package/src/routing/identity-domains.ts +449 -0
  123. package/src/routing/probe-lease.ts +511 -0
  124. package/src/server/index/bounded-request.ts +88 -0
  125. package/src/server/index/live-sideband.ts +565 -0
  126. package/src/server/index/serve-options.ts +1766 -0
  127. package/src/server/index/startup-warnings.ts +213 -0
  128. package/src/server/index/websocket-handler.ts +335 -0
  129. package/src/server/index.ts +40 -2547
  130. package/src/server/management/route-registry.ts +26 -23
  131. package/src/server/management/shared.ts +8 -5
  132. package/src/server/management/workflow-budget-routes.ts +133 -0
  133. package/src/server/management-api.ts +12 -0
  134. package/src/server/request-log-conversation.ts +9 -7
  135. package/src/server/request-log.ts +245 -1
  136. package/src/server/responses/account-change-state.ts +233 -0
  137. package/src/server/responses/adapter-continuation.ts +514 -0
  138. package/src/server/responses/adapter-delivery.ts +214 -0
  139. package/src/server/responses/adapter-dispatch.ts +971 -0
  140. package/src/server/responses/compact.ts +59 -4
  141. package/src/server/responses/completion-policy.ts +33 -0
  142. package/src/server/responses/core-auth.ts +527 -0
  143. package/src/server/responses/core-codex-account.ts +859 -0
  144. package/src/server/responses/core-combo-failure.ts +210 -0
  145. package/src/server/responses/core-combo.ts +707 -0
  146. package/src/server/responses/core-errors.ts +152 -0
  147. package/src/server/responses/core-lifetime.ts +95 -0
  148. package/src/server/responses/core-normalize.ts +350 -0
  149. package/src/server/responses/core-opaque-recovery.ts +380 -0
  150. package/src/server/responses/core-options.ts +159 -0
  151. package/src/server/responses/core-replay.ts +225 -0
  152. package/src/server/responses/core.ts +192 -8893
  153. package/src/server/responses/passthrough-delivery.ts +856 -0
  154. package/src/server/responses/passthrough-dispatch.ts +1476 -0
  155. package/src/server/responses/passthrough-execution.ts +54 -0
  156. package/src/server/responses/request-prepare.ts +970 -0
  157. package/src/server/responses/request-send-budget.ts +164 -0
  158. package/src/server/responses/request-sidecar-auth.ts +149 -0
  159. package/src/server/responses/request-transport.ts +744 -0
  160. package/src/server/responses/response-effects.ts +157 -0
  161. package/src/server/responses/run-turn-execution.ts +448 -0
  162. package/src/server/responses/sidecar-execution.ts +469 -0
  163. package/src/server/responses-image-gen-repair.ts +1 -1
  164. package/src/server/workflow-refusal.ts +84 -0
  165. package/src/types/config.ts +30 -0
  166. package/src/usage/log.ts +146 -0
  167. package/src/usage/summary.ts +171 -21
@@ -0,0 +1,331 @@
1
+ import { parseMainPolicyUsageQuota, parseUsageQuota, setAccountQuotaFromParsed } from "../quota";
2
+ import type { StoredAccountQuota, WhamUsageResponse } from "../quota";
3
+ import { reconcileMainCodexAccountRuntimeState } from "../account-lifecycle";
4
+ import { getMainChatgptAccountId, readCodexTokensResult } from "../auth-collision";
5
+ import { clearAccountNeedsReauth, markAccountNeedsReauth } from "../account-runtime-state";
6
+ import { extractAccountId } from "../../oauth/chatgpt";
7
+ import { getMainAccountPlan, isMainAccountTokenVerifiablyLive, MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan } from "../main-account";
8
+ import { captureConfigGeneration } from "../../lib/state-store-sweeper";
9
+ import { captureMainAccountIdentityGeneration, clearMainAccountInfoCache, getMainAccountInfoCache, getMainQuotaCredentialGeneration, isMainAccountIdentityGenerationLive, isMainQuotaWriterLive, matchesMainQuotaCredential, observeMainQuotaCredential, setMainAccountCredentialPresence, setMainAccountInfoCache } from "../main-account-cache";
10
+ import type { MainQuotaWriter, MainAccountInfo } from "../main-account-cache";
11
+ import type { CodexQuotaRefreshOutcome } from "../quota-refresh-outcome";
12
+ import { observeMainReserveRevocation } from "../reserve-availability";
13
+ import type { AdmissionLease } from "../../lib/admission";
14
+ import { nonEmptyPlan } from "./runtime-config";
15
+ import { tryAcquireNativeMainProfileClaim } from "../native-main-admission";
16
+ import { WHAM_REQUEST_TIMEOUT_MS } from "../quota-recovery-timing";
17
+ import { withNativeMainCredentialClaim, isNativeMainClaimUnavailable } from "./http";
18
+ import { MAIN_TERMINAL_AUTH_CODES, readMainAuthErrorCode, nextQuotaDispatchSequence, isQuotaDispatchCurrent, publishQuotaDispatch } from "./pool-quota-probe";
19
+
20
+ /**
21
+ * Last reset-credit count this process parsed for the main account, tagged with the
22
+ * physical ChatGPT account it was read from.
23
+ *
24
+ * It is deliberately memory-only. The quota store is keyed by the stable `__main__`
25
+ * ALIAS, and `~/.codex/auth.json` can be swapped for another account while the proxy is
26
+ * not running — `reconcileMainCodexAccountRuntimeState` only purges alias-keyed state
27
+ * when it observes the id CHANGE, and its first observation after a restart has nothing
28
+ * to compare against. A disk-hydrated `__main__` entry can therefore belong to the
29
+ * previous login, so filling the DTO from it would show one account's tickets on
30
+ * another's card. Pool accounts have no such hole because their store key IS the account
31
+ * id. Binding the value to `requestAccountId` keeps the fill honest: after a restart the
32
+ * badge simply waits for the first usage response that carries the summary.
33
+ */
34
+ let mainResetCreditsProvenance: { accountId: string; credits: number } | null = null;
35
+
36
+ export function rememberMainResetCredits(accountId: string | null, credits: number | undefined): void {
37
+ if (accountId === null || credits === undefined) return;
38
+ mainResetCreditsProvenance = { accountId, credits };
39
+ }
40
+
41
+ /** Forget the remembered count when the physical main identity is no longer the same. */
42
+ export function mainResetCreditsForCurrentIdentity(): number | undefined {
43
+ if (!mainResetCreditsProvenance) return undefined;
44
+ const currentAccountId = getMainChatgptAccountId();
45
+ if (currentAccountId === null) return undefined;
46
+ if (currentAccountId !== mainResetCreditsProvenance.accountId) {
47
+ mainResetCreditsProvenance = null;
48
+ return undefined;
49
+ }
50
+ return mainResetCreditsProvenance.credits;
51
+ }
52
+
53
+ export const MAIN_CACHE_TTL = 5 * 60_000;
54
+
55
+ /**
56
+ * A WHAM 401 is not itself proof the local credential died. Upstream edges can
57
+ * transiently reject a still-valid access token (region/anti-abuse/rotation
58
+ * races), and fail-closing on every bare 401 makes a healthy main account flip
59
+ * needs-reauth on the next GUI quota poll. Only treat the response as terminal
60
+ * when the body carries a known terminal code or the local access token is not
61
+ * verifiably live (`accessTokenLive`). Liveness must be strict: a JWT whose
62
+ * `exp` cannot be decoded is NOT live — an undecodable token that vouched for
63
+ * itself would make a real 401 permanently transient.
64
+ */
65
+ export async function isTerminalMainAuthResponse(resp: Response, accessTokenLive: boolean): Promise<boolean> {
66
+ if (resp.status === 401) {
67
+ if (!accessTokenLive) return true;
68
+ const code = await readMainAuthErrorCode(resp);
69
+ return typeof code === "string" && MAIN_TERMINAL_AUTH_CODES.has(code);
70
+ }
71
+ if (resp.status !== 403) return false;
72
+ const code = await readMainAuthErrorCode(resp);
73
+ return typeof code === "string" && MAIN_TERMINAL_AUTH_CODES.has(code);
74
+ }
75
+
76
+ export interface MainResetQuotaProof {
77
+ writer: MainQuotaWriter;
78
+ credentialGeneration: number;
79
+ }
80
+
81
+ export interface MainAccountInfoFetchResult {
82
+ info: MainAccountInfo;
83
+ resetRecoveryProof?: MainResetQuotaProof & { dispatchSequence: number };
84
+ /** Ephemeral result of this attempt, omitted when no WHAM request was made. */
85
+ quotaRefresh?: CodexQuotaRefreshOutcome;
86
+ /** Internal dispatch fence for diagnostics only; never copied into a public DTO or cache. */
87
+ quotaRefreshGeneration?: number;
88
+ /** Whether this attempt safely inspected the physical native-main credential. */
89
+ credentialChecked: boolean;
90
+ /** Meaningful only when credentialChecked is true. */
91
+ hasCredential: boolean;
92
+ /** Main identity generation captured while the native-main claim was held. */
93
+ identityGeneration?: number;
94
+ /** Present only when this call freshly parsed a WHAM usage response. */
95
+ freshQuota?: Omit<StoredAccountQuota, "updatedAt">;
96
+ /** Present only when this call's WHAM response included `rate_limit_reset_credits.available_count`. */
97
+ freshResetCredits?: number;
98
+ }
99
+
100
+ export interface MainAccountInfoSnapshot {
101
+ info: MainAccountInfo;
102
+ mainIdentityGeneration: number;
103
+ quotaRefresh?: CodexQuotaRefreshOutcome;
104
+ }
105
+
106
+ export async function fetchMainAccountInfoSnapshot(forceRefresh = false): Promise<MainAccountInfoSnapshot> {
107
+ const result = await fetchMainAccountInfoAttempt(forceRefresh, 1);
108
+ return {
109
+ info: result.info,
110
+ ...(result.quotaRefresh && result.quotaRefreshGeneration !== undefined
111
+ && isMainAccountIdentityGenerationLive(result.quotaRefreshGeneration)
112
+ ? { quotaRefresh: result.quotaRefresh } : {}),
113
+ mainIdentityGeneration: result.identityGeneration ?? captureMainAccountIdentityGeneration(),
114
+ };
115
+ }
116
+
117
+ export async function fetchMainAccountInfo(forceRefresh = false): Promise<MainAccountInfo> {
118
+ return (await fetchMainAccountInfoSnapshot(forceRefresh)).info;
119
+ }
120
+
121
+ export const EMPTY_MAIN_ACCOUNT_INFO: MainAccountInfo = { email: null, plan: null, quota: null };
122
+
123
+ export async function retryMainAccountInfoIfIdentityChanged(
124
+ requestAccountId: string | null,
125
+ retriesRemaining: number,
126
+ nativeMainLease: AdmissionLease,
127
+ explicitRefresh: boolean,
128
+ ): Promise<MainAccountInfoFetchResult | null> {
129
+ const currentAccountId = getMainChatgptAccountId();
130
+ if (currentAccountId === null || currentAccountId === requestAccountId) return null;
131
+ reconcileMainCodexAccountRuntimeState();
132
+ return retriesRemaining > 0
133
+ ? fetchMainAccountInfoWhileOwned(true, retriesRemaining - 1, nativeMainLease, explicitRefresh)
134
+ : { info: EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: true, hasCredential: true };
135
+ }
136
+
137
+ export async function fetchMainAccountInfoAttempt(
138
+ forceRefresh: boolean,
139
+ retriesRemaining: number,
140
+ existingNativeMainLease?: AdmissionLease,
141
+ nativeMainSharedClaimHeld = false,
142
+ explicitRefresh: boolean = forceRefresh,
143
+ ): Promise<MainAccountInfoFetchResult> {
144
+ const nativeMainLease = existingNativeMainLease ?? tryAcquireNativeMainProfileClaim();
145
+ if (!nativeMainLease) {
146
+ return {
147
+ info: EMPTY_MAIN_ACCOUNT_INFO,
148
+ credentialChecked: false,
149
+ hasCredential: false,
150
+ identityGeneration: captureMainAccountIdentityGeneration(),
151
+ };
152
+ }
153
+ try {
154
+ const operation = async () => ({
155
+ ...await fetchMainAccountInfoWhileOwned(forceRefresh, retriesRemaining, nativeMainLease, explicitRefresh),
156
+ identityGeneration: captureMainAccountIdentityGeneration(),
157
+ });
158
+ if (nativeMainSharedClaimHeld) return await operation();
159
+ try {
160
+ return await withNativeMainCredentialClaim(operation);
161
+ } catch (error) {
162
+ if (isNativeMainClaimUnavailable(error)) {
163
+ return {
164
+ info: EMPTY_MAIN_ACCOUNT_INFO,
165
+ credentialChecked: false,
166
+ hasCredential: false,
167
+ identityGeneration: captureMainAccountIdentityGeneration(),
168
+ };
169
+ }
170
+ throw error;
171
+ }
172
+ } finally {
173
+ if (!existingNativeMainLease) nativeMainLease.release();
174
+ }
175
+ }
176
+
177
+ export async function fetchMainAccountInfoWhileOwned(
178
+ forceRefresh: boolean,
179
+ retriesRemaining: number,
180
+ nativeMainLease: AdmissionLease,
181
+ /**
182
+ * Whether the *caller* asked for this refresh. `forceRefresh` also means "bypass the
183
+ * cache", and `retryMainAccountInfoIfIdentityChanged` re-enters with it set purely to
184
+ * re-read after the identity changed. Keeping the two apart stops that retry from
185
+ * promoting a background poll into operator intent below.
186
+ */
187
+ explicitRefresh: boolean = forceRefresh,
188
+ ): Promise<MainAccountInfoFetchResult> {
189
+ const writerGeneration = captureConfigGeneration();
190
+ reconcileMainCodexAccountRuntimeState();
191
+ const tokenRead = readCodexTokensResult();
192
+ setMainAccountCredentialPresence(tokenRead.status === "ok");
193
+ if (tokenRead.status !== "ok") {
194
+ // A local read failure is NOT proof of sign-out: a missing file can be a non-atomic rewrite
195
+ // gap, and malformed JSON can be a half-written file. Clearing the cache and marking the
196
+ // account for reauth here destroyed healthy email/plan/quota state and pinned a working
197
+ // account as unusable. Preserve what we already know and let the caller retry; request
198
+ // routing stays fail-closed because getMainAccountToken() re-reads the file itself, and the
199
+ // account DTO still reports hasCredential=false while the file is unreadable.
200
+ const preserved = getMainAccountInfoCache();
201
+ return { info: preserved ?? EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: true, hasCredential: false };
202
+ }
203
+ const tokens = tokenRead.tokens;
204
+ const requestAccountId = extractAccountId(tokens.id_token, tokens.access_token) ?? (tokens.account_id || null);
205
+ const cached = getMainAccountInfoCache();
206
+ if (!forceRefresh && cached && Date.now() - cached.ts < MAIN_CACHE_TTL) {
207
+ return { info: cached, credentialChecked: true, hasCredential: true };
208
+ }
209
+ // Bind quota to the owned credential and the account actually selected by WHAM's header.
210
+ // A conflicting legacy token/account tuple is not evidence for the new policy.
211
+ const mainQuotaWriter = requestAccountId === tokens.account_id
212
+ ? observeMainQuotaCredential(tokens.access_token, tokens.account_id)
213
+ : undefined;
214
+ const mainQuotaCredentialGeneration = getMainQuotaCredentialGeneration();
215
+ // Keep diagnostics separate from authentication and freshness policy. Never serialize errors.
216
+ const quotaSignal = AbortSignal.timeout(WHAM_REQUEST_TIMEOUT_MS);
217
+ let quotaPhase: "request" | "body" | "decode" | "publish" = "request";
218
+ let quotaRefreshGeneration = captureMainAccountIdentityGeneration();
219
+ try {
220
+ const dispatchSequence = nextQuotaDispatchSequence();
221
+ const resp = await fetch("https://chatgpt.com/backend-api/wham/usage", {
222
+ headers: { Authorization: `Bearer ${tokens.access_token}`, "ChatGPT-Account-Id": tokens.account_id },
223
+ signal: quotaSignal,
224
+ });
225
+ quotaPhase = "publish";
226
+ if (!resp.ok) {
227
+ const terminalAuthFailure = await isTerminalMainAuthResponse(resp, isMainAccountTokenVerifiablyLive());
228
+ const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease, explicitRefresh);
229
+ if (retried) return retried;
230
+ if (!isQuotaDispatchCurrent(dispatchSequence)) {
231
+ return { info: getMainAccountInfoCache() ?? EMPTY_MAIN_ACCOUNT_INFO,
232
+ credentialChecked: true, hasCredential: true };
233
+ }
234
+ if (terminalAuthFailure) {
235
+ // Account for this attempt's own synchronous invalidation, never prior external drift.
236
+ const diagnosticStillLive = isMainAccountIdentityGenerationLive(quotaRefreshGeneration);
237
+ clearMainAccountInfoCache();
238
+ if (diagnosticStillLive) quotaRefreshGeneration = captureMainAccountIdentityGeneration();
239
+ markAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID, writerGeneration);
240
+ }
241
+ return {
242
+ info: EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: true, hasCredential: true,
243
+ quotaRefresh: { status: "http_error", httpStatus: resp.status },
244
+ quotaRefreshGeneration,
245
+ };
246
+ }
247
+ quotaPhase = "body";
248
+ const data = (await resp.json()) as WhamUsageResponse;
249
+ quotaPhase = "publish";
250
+ const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease, explicitRefresh);
251
+ if (retried) return retried;
252
+ quotaPhase = "decode";
253
+ if (data === null || typeof data !== "object" || Array.isArray(data)) {
254
+ throw new Error("Invalid WHAM usage object");
255
+ }
256
+ // Check after body/retry awaits and before any cache, credits, policy or
257
+ // Reserve publication. Returning cached state supplies no fresh recovery proof.
258
+ if (!isQuotaDispatchCurrent(dispatchSequence)) {
259
+ return { info: getMainAccountInfoCache() ?? EMPTY_MAIN_ACCOUNT_INFO,
260
+ credentialChecked: true, hasCredential: true };
261
+ }
262
+ quotaPhase = "publish";
263
+ // A delayed response from a replaced bearer cannot revoke a newer Reserve grant,
264
+ // even in the same workspace or after an A→B→A credential transition.
265
+ if (mainQuotaCredentialGeneration === getMainQuotaCredentialGeneration()
266
+ && matchesMainQuotaCredential(tokens.access_token, tokens.account_id)) {
267
+ observeMainReserveRevocation(data, mainQuotaWriter);
268
+ }
269
+ quotaPhase = "decode";
270
+ const plan = nonEmptyPlan(data.plan_type) ?? nonEmptyPlan(cached?.plan) ?? nonEmptyPlan(getMainAccountPlan());
271
+ const usage = { ...data, ...(plan ? { plan_type: plan } : {}) };
272
+ const quota = parseUsageQuota(usage);
273
+ const policyQuota = parseMainPolicyUsageQuota(usage);
274
+ quotaPhase = "publish";
275
+ const freshResetCredits = quota?.resetCredits;
276
+ // Tag the count with the identity it was read from, so a later response that omits the
277
+ // summary can restore the badge without ever crossing an account boundary.
278
+ rememberMainResetCredits(requestAccountId, freshResetCredits);
279
+ const result = {
280
+ email: data.email ?? null,
281
+ plan,
282
+ quota,
283
+ ts: Date.now(),
284
+ };
285
+ setMainAccountInfoCache(result);
286
+ // Only an explicit refresh may retract a reauth quarantine. A 200 from
287
+ // /wham/usage proves the token authenticates to the usage endpoint; it does not
288
+ // prove the account can serve Responses traffic, which is a different backend path
289
+ // and still answers 403 for a workspace the token may no longer select (#327).
290
+ // Letting the background poll clear the flag put such an account straight back into
291
+ // rotation: the next request failed the same way and re-marked it, so needsReauth
292
+ // never settled and the dashboard kept showing nothing — the symptom #327 reported.
293
+ // An explicit refresh is an operator asking to re-evaluate, normally right after
294
+ // signing in again, so it stays authoritative.
295
+ if (explicitRefresh) clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
296
+ // Mirror main quota + plan into the shared stores so the rotation engine can
297
+ // score and auto-switch the main account exactly like a pool account (Option A).
298
+ setMainAccountPlan(result.plan);
299
+ if (result.quota) {
300
+ setAccountQuotaFromParsed(MAIN_CODEX_ACCOUNT_ID, result.quota, writerGeneration, mainQuotaWriter, policyQuota);
301
+ }
302
+ publishQuotaDispatch(dispatchSequence);
303
+ return {
304
+ info: result,
305
+ quotaRefresh: { status: quota ? "ok" : "not_reported" },
306
+ quotaRefreshGeneration,
307
+ credentialChecked: true,
308
+ hasCredential: true,
309
+ ...(quota ? { freshQuota: quota } : {}),
310
+ ...(quota && mainQuotaWriter && isMainQuotaWriterLive(mainQuotaWriter)
311
+ && mainQuotaCredentialGeneration === getMainQuotaCredentialGeneration()
312
+ && matchesMainQuotaCredential(tokens.access_token, tokens.account_id)
313
+ ? { resetRecoveryProof: { writer: mainQuotaWriter, credentialGeneration: mainQuotaCredentialGeneration, dispatchSequence } }
314
+ : {}),
315
+ ...(freshResetCredits !== undefined ? { freshResetCredits } : {}),
316
+ };
317
+ } catch (error) {
318
+ const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease, explicitRefresh);
319
+ if (retried) return retried;
320
+ let status: CodexQuotaRefreshOutcome["status"] = "internal_error";
321
+ if ((quotaPhase === "request" || quotaPhase === "body") && quotaSignal.aborted) status = "timeout";
322
+ else if (quotaPhase === "request") status = "network_error";
323
+ else if (quotaPhase === "body") status = error instanceof SyntaxError ? "invalid_response" : "network_error";
324
+ else if (quotaPhase === "decode") status = "invalid_response";
325
+ return {
326
+ info: EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: true, hasCredential: true,
327
+ quotaRefresh: { status },
328
+ quotaRefreshGeneration,
329
+ };
330
+ }
331
+ }
@@ -0,0 +1,274 @@
1
+ import { getCodexAccountCredential, getValidCodexToken, readCodexAccountRecord } from "../account-store";
2
+ import { getAccountQuota, isCompleteCodexQuotaRecoverySnapshot } from "../quota";
3
+ import { reconcileMainCodexAccountRuntimeState } from "../account-lifecycle";
4
+ import { claimDueCodexQuotaRecoveryProbes, settleCodexQuotaRecoveryProbe } from "../routing";
5
+ import { readCodexTokens } from "../auth-collision";
6
+ import { isAccountNeedsReauth, markAccountNeedsReauth } from "../account-runtime-state";
7
+ import { getValidMainAccountToken, MainAccountTokenRefreshError, MAIN_CODEX_ACCOUNT_ID } from "../main-account";
8
+ import { captureConfigGeneration, registerStateSweepAfterTick } from "../../lib/state-store-sweeper";
9
+ import { captureMainAccountIdentityGeneration, isMainAccountIdentityGenerationLive } from "../main-account-cache";
10
+ import { getMainAccountHardLockStatus } from "../main-account-hard-lock";
11
+ import type { OcxConfig } from "../../types";
12
+ import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers";
13
+ import { providerCodexAccountMode } from "../../providers/registry";
14
+ import { isSelectableCodexPoolAccount } from "../account-id";
15
+ import type { AdmissionLease } from "../../lib/admission";
16
+ import { tryAcquireNativeMainProfileClaim } from "../native-main-admission";
17
+ import { withNativeMainCredentialClaim, isNativeMainClaimUnavailable } from "./http";
18
+ import type { PoolQuotaResult } from "./pool-quota-probe";
19
+ import { fetchMainAccountInfoAttempt, fetchMainAccountInfo } from "./main-account-probe";
20
+ import { fetchPoolAccountQuota, PoolQuotaProbeBusyError, POOL_CACHE_TTL, POOL_QUOTA_REFRESH_CONCURRENCY } from "./pool-quota-probe";
21
+ import { getRuntimeConfig, configuredPoolAccount, mapWithConcurrency } from "./runtime-config";
22
+
23
+ let primeInFlight: Promise<void> | null = null;
24
+ /**
25
+ * Last prime attempt per pool account. A failed WHAM lookup stores no quota, so
26
+ * without this the account stays "unknown" and every later prime trigger re-selects
27
+ * it as stale and repeats the same failing request. Successful lookups are already
28
+ * throttled by their stored updatedAt; this gives failures the same TTL backoff.
29
+ *
30
+ * Keyed by credential generation so a re-authentication, refresh, or account removal
31
+ * retries immediately instead of waiting out a backoff earned by the old credential.
32
+ */
33
+ const poolQuotaPrimeAttemptedAt = new Map<string, { generation: number; at: number }>();
34
+ let cooldownRecoveryInFlight: Promise<void> | null = null;
35
+
36
+ export async function runCodexCooldownRecoveryProbes(config: OcxConfig, now = Date.now()): Promise<void> {
37
+ const openai = config.providers[OPENAI_CODEX_PROVIDER_ID];
38
+ if (!openai
39
+ || openai.disabled === true
40
+ || !isCanonicalOpenAiForwardProvider(openai)
41
+ || providerCodexAccountMode(OPENAI_CODEX_PROVIDER_ID, openai) !== "pool") return;
42
+ if (cooldownRecoveryInFlight) return cooldownRecoveryInFlight;
43
+ cooldownRecoveryInFlight = (async () => {
44
+ const claims = claimDueCodexQuotaRecoveryProbes(config, POOL_QUOTA_REFRESH_CONCURRENCY, now);
45
+ await mapWithConcurrency(claims, POOL_QUOTA_REFRESH_CONCURRENCY, async claim => {
46
+ const account = configuredPoolAccount(config, claim.accountId);
47
+ if (!account) {
48
+ settleCodexQuotaRecoveryProbe(claim, false, {}, now);
49
+ return;
50
+ }
51
+ try {
52
+ const result = await fetchPoolAccountQuota(claim.accountId, true, account.plan);
53
+ // Defence in depth: independent scopes are already excluded at the claim site.
54
+ // Generic WHAM must never clear Reserve even if claim selection changes.
55
+ const recovered = (claim.scope === undefined || claim.scope === "shared")
56
+ && isCompleteCodexQuotaRecoverySnapshot(result.freshQuota ?? null, result.freshPlan ?? account.plan);
57
+ settleCodexQuotaRecoveryProbe(claim, recovered, {
58
+ credentialGeneration: result.freshCredentialGeneration,
59
+ }, now);
60
+ } catch {
61
+ settleCodexQuotaRecoveryProbe(claim, false, {}, now);
62
+ }
63
+ });
64
+ })().catch(() => {
65
+ // Background recovery is best-effort; routing keeps the cooldown on failure.
66
+ }).finally(() => { cooldownRecoveryInFlight = null; });
67
+ return cooldownRecoveryInFlight;
68
+ }
69
+
70
+ let mainHardLockRecoveryInFlight: Promise<void> | null = null;
71
+
72
+ /** Metadata-only recovery on the existing sweep; failures retain the observed policy block. */
73
+ export async function runMainAccountHardLockRecovery(config: OcxConfig): Promise<void> {
74
+ if (mainHardLockRecoveryInFlight) return mainHardLockRecoveryInFlight;
75
+ if (getMainAccountHardLockStatus(config).state !== "blocked"
76
+ || isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)) return;
77
+ const lease = tryAcquireNativeMainProfileClaim();
78
+ if (!lease) return;
79
+ mainHardLockRecoveryInFlight = (async () => {
80
+ reconcileMainCodexAccountRuntimeState();
81
+ if (getMainAccountHardLockStatus(config).state !== "blocked"
82
+ || isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)) return;
83
+ const identityGeneration = captureMainAccountIdentityGeneration();
84
+ const writerGeneration = captureConfigGeneration();
85
+ try {
86
+ // Refresh can require an exclusive credential claim: never hold WHAM's shared
87
+ // claim while obtaining a valid token. The runtime lease spans both operations.
88
+ if (!await getValidMainAccountToken({ preserveReauth: true })) return;
89
+ } catch (error) {
90
+ if (error instanceof MainAccountTokenRefreshError && error.reason === "reauth"
91
+ && isMainAccountIdentityGenerationLive(identityGeneration)) {
92
+ markAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID, writerGeneration);
93
+ }
94
+ return;
95
+ }
96
+ if (isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)) return;
97
+ await fetchMainAccountInfoAttempt(true, 1, lease, false, false);
98
+ })().catch(() => {
99
+ // Best-effort background metadata read; no cooldown/pause or policy clearing on failure.
100
+ }).finally(() => {
101
+ lease.release();
102
+ mainHardLockRecoveryInFlight = null;
103
+ });
104
+ return mainHardLockRecoveryInFlight;
105
+ }
106
+
107
+ export function registerCodexCooldownRecoveryProbeWorker(config: OcxConfig): void {
108
+ registerStateSweepAfterTick({
109
+ name: "codex-cooldown-recovery",
110
+ afterTick: () => {
111
+ void runCodexCooldownRecoveryProbes(config);
112
+ void runMainAccountHardLockRecovery(config);
113
+ },
114
+ });
115
+ }
116
+
117
+ export interface PrimeCodexPoolQuotasOptions {
118
+ /** Test seams for proving fenced/recovery priming performs no native-main work. */
119
+ reconcileMainAccount?: typeof reconcileMainCodexAccountRuntimeState;
120
+ readMainTokens?: typeof readCodexTokens;
121
+ fetchMainInfo?: typeof fetchMainAccountInfo;
122
+ }
123
+
124
+ let getValidPoolTokenForPrime = getValidCodexToken;
125
+
126
+ /** Test-only: inject a deterministic pre-dispatch credential outcome for quota priming. */
127
+ export function setCodexPoolQuotaTokenResolverForTests(
128
+ resolver: typeof getValidCodexToken,
129
+ ): () => void {
130
+ const previous = getValidPoolTokenForPrime;
131
+ getValidPoolTokenForPrime = resolver;
132
+ return () => {
133
+ if (getValidPoolTokenForPrime === resolver) getValidPoolTokenForPrime = previous;
134
+ };
135
+ }
136
+
137
+ export function tryAcquireNativeMainPrimeLease(): AdmissionLease | null {
138
+ return tryAcquireNativeMainProfileClaim();
139
+ }
140
+
141
+ /**
142
+ * Best-effort prime of pool-account (and main) quota so the rotation engine has
143
+ * real usage scores instead of leaving every account at the unknown sentinel.
144
+ *
145
+ * Quota is otherwise populated only from live upstream headers (an idle pool
146
+ * account never serves traffic, so it never gets scored) or from the dashboard
147
+ * WHAM fetch (a CLI-only user never opens it). Without priming, every account
148
+ * stays unknown and auto-switch cannot move (see Phase 10). This runs at startup
149
+ * and lazily before routing when the active account is unknown.
150
+ *
151
+ * Single-flight: concurrent callers share one pass instead of stampeding N WHAM
152
+ * fetches. Per-fetch 8s timeouts and the 5-minute POOL_CACHE_TTL already bound
153
+ * cost, so the worst case is one WHAM call per account per TTL window. Failures
154
+ * are swallowed: a blocked WSL network must never crash startup or a request.
155
+ */
156
+ export async function primeCodexPoolQuotas(
157
+ config: OcxConfig,
158
+ reason: string,
159
+ options: PrimeCodexPoolQuotasOptions = {},
160
+ ): Promise<void> {
161
+ const openai = config.providers[OPENAI_CODEX_PROVIDER_ID];
162
+ // Prune attempt markers for accounts that no longer exist BEFORE the eligibility
163
+ // return. A removal that happens while the provider is disabled or out of pool mode
164
+ // would otherwise leave a stale failure marker behind; restoring the same account id
165
+ // within POOL_CACHE_TTL would then read that old failure as current and skip the
166
+ // retry the restored credential is entitled to.
167
+ const runtimeConfig = getRuntimeConfig(config);
168
+ const configuredPoolIds = new Set((runtimeConfig.codexAccounts ?? []).map(account => account.id));
169
+ for (const accountId of poolQuotaPrimeAttemptedAt.keys()) {
170
+ if (!configuredPoolIds.has(accountId)) poolQuotaPrimeAttemptedAt.delete(accountId);
171
+ }
172
+ if (
173
+ !openai
174
+ || openai.disabled === true
175
+ || !isCanonicalOpenAiForwardProvider(openai)
176
+ || providerCodexAccountMode(OPENAI_CODEX_PROVIDER_ID, openai) !== "pool"
177
+ ) return;
178
+ if (primeInFlight) return primeInFlight;
179
+ primeInFlight = (async () => {
180
+ const pool = (runtimeConfig.codexAccounts ?? []).filter(isSelectableCodexPoolAccount);
181
+ const stale = pool.filter(a => {
182
+ const q = getAccountQuota(a.id);
183
+ if (q) return Date.now() - q.updatedAt >= POOL_CACHE_TTL;
184
+ // No stored quota: either never primed, or the last attempt failed. Retry only
185
+ // once per TTL window so an unreachable or rejecting account cannot turn every
186
+ // prime trigger into another upstream request.
187
+ const lastAttempt = poolQuotaPrimeAttemptedAt.get(a.id);
188
+ if (!lastAttempt) return true;
189
+ // A newer credential invalidates the previous failure: retry without waiting.
190
+ if (lastAttempt.generation !== readCodexAccountRecord(a.id)?.generation) return true;
191
+ return Date.now() - lastAttempt.at >= POOL_CACHE_TTL;
192
+ });
193
+ const primeMain = async () => {
194
+ const mainLease = tryAcquireNativeMainPrimeLease();
195
+ if (!mainLease) return;
196
+ try {
197
+ try {
198
+ await withNativeMainCredentialClaim(async () => {
199
+ // Keep one local owner and one cross-process reader from physical
200
+ // identity reconciliation through WHAM and all quota publication.
201
+ (options.reconcileMainAccount ?? reconcileMainCodexAccountRuntimeState)();
202
+ if (getAccountQuota(MAIN_CODEX_ACCOUNT_ID)) return;
203
+ if (!(options.readMainTokens ?? readCodexTokens)()) return;
204
+ if (options.fetchMainInfo) await options.fetchMainInfo(false);
205
+ else await fetchMainAccountInfoAttempt(false, 1, mainLease, true);
206
+ });
207
+ } catch (error) {
208
+ if (!isNativeMainClaimUnavailable(error)) throw error;
209
+ }
210
+ } finally {
211
+ mainLease.release();
212
+ }
213
+ };
214
+ try {
215
+ await Promise.allSettled([
216
+ primeMain(),
217
+ mapWithConcurrency(stale, POOL_QUOTA_REFRESH_CONCURRENCY, async a => {
218
+ if (!getCodexAccountCredential(a.id)) return;
219
+ let result: PoolQuotaResult;
220
+ try {
221
+ result = await fetchPoolAccountQuota(a.id, false, a.plan, getValidPoolTokenForPrime);
222
+ } catch (error) {
223
+ // Local quota-flight saturation proves no WHAM request existed for this account.
224
+ // Consume it per item so sibling workers remain inside the shared prime lifetime.
225
+ if (error instanceof PoolQuotaProbeBusyError) return;
226
+ throw error;
227
+ }
228
+ // Only the data-plane function knows whether upstream dispatch began. Any
229
+ // cache hit, credential deferral, or local admission failure remains eligible.
230
+ const attempted = result.quotaProbeAttempted;
231
+ if (!attempted) return;
232
+ if (!configuredPoolAccount(getRuntimeConfig(config), a.id)) {
233
+ poolQuotaPrimeAttemptedAt.delete(a.id);
234
+ return;
235
+ }
236
+ poolQuotaPrimeAttemptedAt.set(a.id, {
237
+ // getValidCodexToken may rotate the credential before WHAM is sent.
238
+ // Bind the backoff to the generation that actually made the request;
239
+ // otherwise the next prime sees a false generation change and retries
240
+ // the same failed WHAM call immediately.
241
+ generation: attempted.credentialGeneration,
242
+ at: attempted.at,
243
+ });
244
+ }),
245
+ ]);
246
+ } catch {
247
+ // Priming is best-effort; never propagate.
248
+ }
249
+ if (process.env.OPENCODEX_DEBUG_QUOTA === "1") {
250
+ console.warn(`[codex-quota] prime done (reason=${reason}, pool=${pool.length}, refreshed=${stale.length})`);
251
+ }
252
+ })().finally(() => { primeInFlight = null; });
253
+ return primeInFlight;
254
+ }
255
+
256
+ /** Test-only: drop any in-flight prime pass so a leaked single-flight promise
257
+ * from another suite cannot coalesce into the next prime. */
258
+ export function clearCodexQuotaPrimeState(): void {
259
+ primeInFlight = null;
260
+ poolQuotaPrimeAttemptedAt.clear();
261
+ getValidPoolTokenForPrime = getValidCodexToken;
262
+ }
263
+
264
+ /** Test-only: drop the shared single-flight promise while keeping the per-account
265
+ * failure backoff, so a test can trigger a second real prime pass and still observe
266
+ * the throttle a production caller would see. */
267
+ export function clearCodexQuotaPrimeSingleFlightForTests(): void {
268
+ primeInFlight = null;
269
+ }
270
+
271
+ /** Test-only reset for the worker-level single-flight. */
272
+ export function clearCodexCooldownRecoveryProbeState(): void {
273
+ cooldownRecoveryInFlight = null;
274
+ }