@bitkyc08/opencodex 2.55.0-preview.20260914 → 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-DH2PUHqr.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,512 @@
1
+ import { capturePoolQuotaWriter, getValidCodexToken, isCodexAccountGenerationLive, forceRefreshCodexPoolToken, markCodexAccountValidated, markCodexAccountValidationFailed, readCodexAccountRecord, CodexCredentialGenerationConflictError, CodexCredentialRefreshLockTimeoutError, CodexCredentialRefreshBusyError, CodexCredentialRefreshStaleError, TokenRefreshError } from "../account-store";
2
+ import type { PoolQuotaWriter } from "../quota-types";
3
+ import { isValidWhamHistoryObservation, getAccountQuota, isCompleteCodexQuotaRecoverySnapshot, parseUsageQuota, setAccountQuotaFromParsed } from "../quota";
4
+ import type { StoredAccountQuota, WhamUsageResponse } from "../quota";
5
+ import type { ManualResetRefreshLineage } from "../routing";
6
+ import { clearAccountNeedsReauth, markAccountNeedsReauth } from "../account-runtime-state";
7
+ import { captureConfigGeneration } from "../../lib/state-store-sweeper";
8
+ import { codexWarmupFailureReason, warmCodexAccount } from "../warmup";
9
+ import { readBoundedResponseBody } from "../../lib/bounded-body";
10
+ import { ResourceAdmissionError } from "../../lib/admission";
11
+ import { WHAM_REQUEST_TIMEOUT_MS } from "../quota-recovery-timing";
12
+ import { claimQuotaRecovery, quotaRecoveryTerminalFor, releaseQuotaRecovery, settleQuotaRecovery, settleQuotaRecoveryTerminal } from "../quota-401-recovery";
13
+ import { seedLoginRowsForTests } from "./login-state";
14
+ import { nonEmptyPlan } from "./runtime-config";
15
+
16
+ export const POOL_CACHE_TTL = 5 * 60_000;
17
+ export const POOL_QUOTA_REFRESH_CONCURRENCY = 4;
18
+
19
+ export const MAIN_TERMINAL_AUTH_CODES = new Set([
20
+ "invalid_workspace_selected",
21
+ "invalid_refresh_token",
22
+ ]);
23
+
24
+ export async function readMainAuthErrorCode(resp: Response): Promise<unknown> {
25
+ try {
26
+ const body = await readBoundedResponseBody(resp, { totalTimeoutMs: 1_000, inactivityTimeoutMs: 1_000 });
27
+ if (!body.displaySafe) return undefined;
28
+ const parsed = JSON.parse(body.text) as {
29
+ detail?: { code?: unknown } | string;
30
+ error?: { code?: unknown } | string;
31
+ code?: unknown;
32
+ };
33
+ const code = typeof parsed.detail === "object" && parsed.detail !== null
34
+ ? parsed.detail.code
35
+ : typeof parsed.error === "object" && parsed.error !== null
36
+ ? parsed.error.code
37
+ : parsed.code;
38
+ return code;
39
+ } catch {
40
+ return undefined;
41
+ }
42
+ }
43
+
44
+ export interface PoolQuotaResult {
45
+ /** Actual refresh result attached only to the successful usage replay. */
46
+ resetRefreshLineage?: ManualResetRefreshLineage;
47
+ quota: StoredAccountQuota | null;
48
+ needsReauth: boolean;
49
+ /** Credential generation whose cache or network result this DTO state belongs to. */
50
+ credentialGeneration?: number;
51
+ /** Present only when this call freshly parsed a WHAM usage response. */
52
+ freshQuota?: Omit<StoredAccountQuota, "updatedAt">;
53
+ /** Present only when this call's WHAM response included a non-empty `plan_type`. */
54
+ freshPlan?: string;
55
+ /** Credential generation used by this fresh quota request. */
56
+ freshCredentialGeneration?: number;
57
+ /** Present only when this call's WHAM response included `rate_limit_reset_credits.available_count`. */
58
+ freshResetCredits?: number;
59
+ quotaProbeSkipped?: true;
60
+ /** Positive evidence captured immediately before an upstream WHAM dispatch. */
61
+ quotaProbeAttempted?: { at: number; credentialGeneration: number; dispatchSequence: number };
62
+ }
63
+
64
+ // Process-local ordering, never a timestamp or a serialized account identifier.
65
+ let quotaDispatchSequence = 0;
66
+ // Shared native-main ownership permits concurrent usage readers. Only a later
67
+ // successfully published response advances this fence; failed reads do not win.
68
+ let mainQuotaPublishedSequence = 0;
69
+
70
+ export function nextQuotaDispatchSequence(): number {
71
+ return ++quotaDispatchSequence;
72
+ }
73
+
74
+ export function currentQuotaDispatchSequence(): number {
75
+ return quotaDispatchSequence;
76
+ }
77
+
78
+ export function isQuotaDispatchCurrent(sequence: number): boolean {
79
+ return sequence >= mainQuotaPublishedSequence;
80
+ }
81
+
82
+ export function publishQuotaDispatch(sequence: number): void {
83
+ mainQuotaPublishedSequence = sequence;
84
+ }
85
+
86
+ export interface PoolQuotaProbeEvidence {
87
+ onDispatch?: (sequence: number) => void;
88
+ mayPublish?: () => boolean;
89
+ attempted?: NonNullable<PoolQuotaResult["quotaProbeAttempted"]>;
90
+ }
91
+
92
+ export function markQuotaProbeAttempted(evidence: PoolQuotaProbeEvidence, credentialGeneration: number): void {
93
+ const dispatchSequence = nextQuotaDispatchSequence();
94
+ evidence.attempted = { at: Date.now(), credentialGeneration, dispatchSequence };
95
+ evidence.onDispatch?.(dispatchSequence);
96
+ }
97
+
98
+ export function withQuotaProbeEvidence(
99
+ result: PoolQuotaResult,
100
+ evidence: PoolQuotaProbeEvidence,
101
+ ): PoolQuotaResult {
102
+ return evidence.attempted ? { ...result, quotaProbeAttempted: evidence.attempted } : result;
103
+ }
104
+
105
+ export interface PoolQuotaRefreshFlight {
106
+ state: {
107
+ dispatchSequence?: number;
108
+ superseded?: boolean;
109
+ startCredentialGeneration?: number;
110
+ resolvedCredentialGeneration?: number;
111
+ validatePending?: boolean;
112
+ };
113
+ promise: Promise<PoolQuotaResult>;
114
+ }
115
+
116
+ export const poolQuotaRefreshInFlight = new Map<string, Set<PoolQuotaRefreshFlight>>();
117
+ export const MAX_POOL_QUOTA_FLIGHTS = 16;
118
+
119
+ export class PoolQuotaProbeBusyError extends ResourceAdmissionError {
120
+ constructor() {
121
+ super("pool_quota_flights", MAX_POOL_QUOTA_FLIGHTS);
122
+ this.name = "PoolQuotaProbeBusyError";
123
+ }
124
+ }
125
+
126
+ export function poolQuotaFlightCount(): number {
127
+ let count = 0;
128
+ for (const flights of poolQuotaRefreshInFlight.values()) count += flights.size;
129
+ return count;
130
+ }
131
+
132
+ /** Focused admission tests only; returns cleanup for the synthetic owners it inserts. */
133
+ export function seedCodexAuthAdmissionForTests(options: { loginFlows?: number; quotaFlights?: number }): () => void {
134
+ const prefix = `admission-test-${crypto.randomUUID()}`;
135
+ const cleanupLoginRows = seedLoginRowsForTests(prefix, options.loginFlows ?? 0);
136
+ for (let index = 0; index < (options.quotaFlights ?? 0); index++) {
137
+ poolQuotaRefreshInFlight.set(`${prefix}-quota-${index}`, new Set([{
138
+ state: {},
139
+ promise: new Promise<PoolQuotaResult>(() => {}),
140
+ }]));
141
+ }
142
+ return () => {
143
+ cleanupLoginRows();
144
+ for (const key of [...poolQuotaRefreshInFlight.keys()]) if (key.startsWith(prefix)) poolQuotaRefreshInFlight.delete(key);
145
+ };
146
+ }
147
+
148
+ /**
149
+ * One refresh-and-replay for a pool account whose WHAM request came back 401 (#3019).
150
+ *
151
+ * The account list used to convert any 401 straight into `needsReauth`, and a bare 401 is
152
+ * exactly what a stale-but-refreshable bearer produces after a plan change — so a healthy
153
+ * credential was thrown away and the operator was told to log in again.
154
+ *
155
+ * Bounded by the recovery store: one attempt per credential lineage. An unbounded retry
156
+ * against an upstream 401 is a self-inflicted credential-stuffing loop, which is why the
157
+ * claim is taken BEFORE the refresh and settled by the flight rather than by this caller.
158
+ */
159
+ export async function recoverPoolQuotaFrom401(ctx: {
160
+ accountId: string;
161
+ existing: StoredAccountQuota | null;
162
+ configuredPlan: string | undefined;
163
+ rejectedAccessToken: string;
164
+ rejectedGeneration: number;
165
+ resp: Response;
166
+ quotaProbeEvidence: PoolQuotaProbeEvidence;
167
+ onCredentialGeneration?: (generation: number) => void;
168
+ }): Promise<PoolQuotaResult> {
169
+ const { accountId, existing, configuredPlan, rejectedAccessToken, rejectedGeneration, resp } = ctx;
170
+
171
+ // Structured terminal evidence short-circuits everything: the same allowlist and bounded
172
+ // parser the main account uses, because it is the same endpoint answering.
173
+ if (await isTerminalPoolAuthResponse(resp)) {
174
+ // Durable, not just this response: the account list re-polls, and without a recorded
175
+ // mark the next bare 401 finds nothing terminal and reports the account healthy.
176
+ //
177
+ // Scoped to the generation this evidence is ABOUT. An account-wide mark would outlive
178
+ // the credential it condemned, so a late terminal response arriving after the operator
179
+ // re-authenticated would quarantine the replacement.
180
+ markAccountNeedsReauth(accountId, captureConfigGeneration(), rejectedGeneration);
181
+ return { quota: existing ?? null, needsReauth: true, credentialGeneration: rejectedGeneration };
182
+ }
183
+
184
+ const claim = claimQuotaRecovery(accountId, rejectedGeneration);
185
+ if (!claim.granted) {
186
+ // A lineage fenced by a TERMINAL refresh failure stays terminal. Without this, the
187
+ // budget being used would make the next bare 401 report a dead credential as healthy.
188
+ if (quotaRecoveryTerminalFor(accountId, rejectedGeneration)) {
189
+ return { quota: existing ?? null, needsReauth: true, credentialGeneration: rejectedGeneration };
190
+ }
191
+ // Otherwise: this lineage spent its attempt, another caller is mid-refresh, or a
192
+ // transient failure is backing off. Report transient and let the next poll try —
193
+ // quarantining here would undo the whole point of the budget.
194
+ return { quota: existing ?? null, needsReauth: false, credentialGeneration: rejectedGeneration };
195
+ }
196
+
197
+ let refreshed: Awaited<ReturnType<typeof forceRefreshCodexPoolToken>>;
198
+ try {
199
+ refreshed = await forceRefreshCodexPoolToken(accountId, {
200
+ rejectedGeneration,
201
+ rejectedAccessToken,
202
+ // Settlement rides the flight, not this await: a cancelled caller would otherwise
203
+ // leave the claim to expire while the shared refresh commits, and the already
204
+ // refreshed lineage would get a second attempt.
205
+ onSettled: outcome => {
206
+ if (outcome.kind === "resolved") {
207
+ settleQuotaRecovery(accountId, claim.claimId, outcome);
208
+ } else if (outcome.error instanceof TokenRefreshError && isTerminalRefreshError(outcome.error)) {
209
+ // A revoked or expired grant does not become valid on the next poll. Releasing it
210
+ // into backoff would let the following bare 401 find a non-terminal record and
211
+ // report a dead credential as healthy.
212
+ settleQuotaRecoveryTerminal(accountId, claim.claimId);
213
+ } else {
214
+ releaseQuotaRecovery(accountId, claim.claimId, QUOTA_RECOVERY_BACKOFF_MS);
215
+ }
216
+ },
217
+ });
218
+ } catch (e) {
219
+ // A refresh that failed terminally is the one case where the credential really is gone.
220
+ // Everything else is unknown, and unknown is not proof.
221
+ if (e instanceof TokenRefreshError && isTerminalRefreshError(e)) {
222
+ markAccountNeedsReauth(accountId, captureConfigGeneration(), rejectedGeneration);
223
+ return { quota: existing ?? null, needsReauth: true, credentialGeneration: rejectedGeneration };
224
+ }
225
+ return { quota: existing ?? null, needsReauth: false, credentialGeneration: rejectedGeneration };
226
+ }
227
+
228
+ // A byte-identical access token means replaying earns the same 401. Report transient
229
+ // rather than burning the replay; the fence already moved to the returned generation.
230
+ if (!refreshed.rotated) {
231
+ return { quota: existing ?? null, needsReauth: false, credentialGeneration: refreshed.generation };
232
+ }
233
+
234
+ // The flight may have moved the generation while this request was in the air. Tell the
235
+ // coalescing layer where the credential actually is, or a late caller joins on a stale
236
+ // generation and opens a redundant flight.
237
+ ctx.onCredentialGeneration?.(refreshed.generation);
238
+
239
+ const writerGeneration = captureConfigGeneration();
240
+ markQuotaProbeAttempted(ctx.quotaProbeEvidence, refreshed.generation);
241
+ const poolWriter = capturePoolQuotaWriter(accountId, refreshed);
242
+ const replay = await fetch("https://chatgpt.com/backend-api/wham/usage", {
243
+ headers: {
244
+ Authorization: `Bearer ${refreshed.accessToken}`,
245
+ "ChatGPT-Account-Id": refreshed.chatgptAccountId,
246
+ },
247
+ signal: AbortSignal.timeout(WHAM_REQUEST_TIMEOUT_MS),
248
+ });
249
+ if (!replay.ok) {
250
+ if (replay.status === 401 && await isTerminalPoolAuthResponse(replay)) {
251
+ // The refresh already settled this claim non-terminally, so the record alone would
252
+ // let the next poll call a dead credential healthy. The evidence is about the
253
+ // REFRESHED credential, which is what the replay used.
254
+ markAccountNeedsReauth(accountId, writerGeneration, refreshed.generation);
255
+ return { quota: existing ?? null, needsReauth: true, credentialGeneration: refreshed.generation };
256
+ }
257
+ return { quota: existing ?? null, needsReauth: false, credentialGeneration: refreshed.generation };
258
+ }
259
+ const result = await commitPoolQuotaResponse(replay, {
260
+ accountId, existing, configuredPlan, generation: refreshed.generation, writerGeneration, poolWriter,
261
+ mayPublish: ctx.quotaProbeEvidence.mayPublish,
262
+ });
263
+ return result.freshCredentialGeneration === refreshed.generation ? {
264
+ ...result,
265
+ resetRefreshLineage: {
266
+ fromGeneration: rejectedGeneration,
267
+ toGeneration: refreshed.generation,
268
+ provenance: refreshed.provenance,
269
+ },
270
+ } : result;
271
+ }
272
+
273
+ /** Backoff after a refresh failure that proved nothing about the credential. */
274
+ export const QUOTA_RECOVERY_BACKOFF_MS = 60_000;
275
+
276
+ /** Same allowlist and bounded parser as the main account: it is the same endpoint. */
277
+ export async function isTerminalPoolAuthResponse(resp: Response): Promise<boolean> {
278
+ // Consume the original rather than a clone. `resp.clone()` tees the body, and the
279
+ // bounded parser's timeout cancels only its own reader — the unread original branch
280
+ // keeps buffering. Nothing needs this response afterwards, so there is nothing to tee.
281
+ const code = await readMainAuthErrorCode(resp);
282
+ return typeof code === "string" && MAIN_TERMINAL_AUTH_CODES.has(code);
283
+ }
284
+
285
+ /** A revoked or expired grant is terminal; an unknown or transport failure is not. */
286
+ export function isTerminalRefreshError(error: TokenRefreshError): boolean {
287
+ // Read the discriminator, not the message. TokenRefreshError carries `reason`, and
288
+ // matching on human text would let a durable quarantine decision change the next time
289
+ // somebody rewords an error string.
290
+ return error.reason === "revoked" || error.reason === "expired";
291
+ }
292
+
293
+ /** Parse and store a successful WHAM response. Shared by the first attempt and the replay. */
294
+ export async function commitPoolQuotaResponse(
295
+ resp: Response,
296
+ ctx: {
297
+ accountId: string;
298
+ existing: StoredAccountQuota | null;
299
+ configuredPlan: string | undefined;
300
+ generation: number;
301
+ writerGeneration: number;
302
+ poolWriter?: PoolQuotaWriter;
303
+ mayPublish?: () => boolean;
304
+ },
305
+ ): Promise<PoolQuotaResult> {
306
+ const { accountId, existing, configuredPlan, generation, writerGeneration } = ctx;
307
+ const data = (await resp.json()) as WhamUsageResponse;
308
+ const observedAt = Date.now();
309
+ if (ctx.mayPublish?.() === false) {
310
+ return { quota: getAccountQuota(accountId), needsReauth: false, credentialGeneration: generation };
311
+ }
312
+ const freshPlan = nonEmptyPlan(data.plan_type) ?? undefined;
313
+ const quota = parseUsageQuota({ ...data, plan_type: freshPlan ?? configuredPlan });
314
+ const freshResetCredits = quota?.resetCredits;
315
+ if (!quota) {
316
+ return {
317
+ quota: isCodexAccountGenerationLive(accountId, generation) ? existing ?? null : getAccountQuota(accountId),
318
+ needsReauth: false,
319
+ credentialGeneration: generation,
320
+ ...(freshPlan !== undefined ? { freshPlan, freshCredentialGeneration: generation } : {}),
321
+ };
322
+ }
323
+ if (!isCodexAccountGenerationLive(accountId, generation)) {
324
+ return { quota: null, needsReauth: false, credentialGeneration: generation };
325
+ }
326
+ setAccountQuotaFromParsed(accountId, quota, writerGeneration, undefined, quota,
327
+ ctx.poolWriter && isValidWhamHistoryObservation(data) ? { writer: ctx.poolWriter, observedAt, source: "wham", raw: quota } : undefined);
328
+ return {
329
+ quota: getAccountQuota(accountId),
330
+ needsReauth: false,
331
+ credentialGeneration: generation,
332
+ freshQuota: quota,
333
+ freshCredentialGeneration: generation,
334
+ ...(freshPlan !== undefined ? { freshPlan } : {}),
335
+ ...(freshResetCredits !== undefined ? { freshResetCredits } : {}),
336
+ };
337
+ }
338
+
339
+ export async function fetchFreshPoolAccountQuota(
340
+ accountId: string,
341
+ existing: StoredAccountQuota | null,
342
+ configuredPlan?: string,
343
+ onCredentialGeneration?: (generation: number) => void,
344
+ getValidToken: typeof getValidCodexToken = getValidCodexToken,
345
+ quotaProbeEvidence: PoolQuotaProbeEvidence = {},
346
+ ): Promise<PoolQuotaResult> {
347
+ const writerGeneration = captureConfigGeneration();
348
+ let requestCredentialGeneration = readCodexAccountRecord(accountId)?.generation;
349
+ try {
350
+ const { accessToken, chatgptAccountId, generation } = await getValidToken(accountId);
351
+ const poolWriter = capturePoolQuotaWriter(accountId, { accessToken, chatgptAccountId, generation });
352
+ requestCredentialGeneration = generation;
353
+ onCredentialGeneration?.(generation);
354
+ markQuotaProbeAttempted(quotaProbeEvidence, generation);
355
+ const resp = await fetch("https://chatgpt.com/backend-api/wham/usage", {
356
+ headers: { Authorization: `Bearer ${accessToken}`, "ChatGPT-Account-Id": chatgptAccountId },
357
+ signal: AbortSignal.timeout(8000),
358
+ });
359
+ if (!resp.ok) {
360
+ if (resp.status !== 401) {
361
+ return withQuotaProbeEvidence(
362
+ { quota: existing ?? null, needsReauth: false, credentialGeneration: generation },
363
+ quotaProbeEvidence,
364
+ );
365
+ }
366
+ // A bare 401 is what a stale-but-refreshable bearer produces after a plan change, so
367
+ // quarantining on it tells the operator to re-authenticate an account that was fine
368
+ // (#3019). Refresh once, replay once, and only then decide.
369
+ const recovered = await recoverPoolQuotaFrom401({
370
+ accountId,
371
+ existing,
372
+ configuredPlan,
373
+ rejectedAccessToken: accessToken,
374
+ rejectedGeneration: generation,
375
+ resp,
376
+ quotaProbeEvidence,
377
+ onCredentialGeneration,
378
+ });
379
+ return withQuotaProbeEvidence(recovered, quotaProbeEvidence);
380
+ }
381
+ const committed = await commitPoolQuotaResponse(resp, {
382
+ accountId, existing, configuredPlan, generation, writerGeneration, poolWriter,
383
+ mayPublish: quotaProbeEvidence.mayPublish,
384
+ });
385
+ return withQuotaProbeEvidence(committed, quotaProbeEvidence);
386
+ } catch (e) {
387
+ if (e instanceof CodexCredentialGenerationConflictError || e instanceof CodexCredentialRefreshLockTimeoutError
388
+ || e instanceof CodexCredentialRefreshBusyError || e instanceof CodexCredentialRefreshStaleError) {
389
+ return withQuotaProbeEvidence({
390
+ quota: existing ?? null,
391
+ needsReauth: false,
392
+ credentialGeneration: requestCredentialGeneration,
393
+ quotaProbeSkipped: true,
394
+ }, quotaProbeEvidence);
395
+ }
396
+ if (e instanceof TokenRefreshError) {
397
+ return withQuotaProbeEvidence(
398
+ { quota: existing ?? null, needsReauth: true, credentialGeneration: requestCredentialGeneration },
399
+ quotaProbeEvidence,
400
+ );
401
+ }
402
+ return withQuotaProbeEvidence(
403
+ { quota: existing ?? null, needsReauth: false, credentialGeneration: requestCredentialGeneration },
404
+ quotaProbeEvidence,
405
+ );
406
+ }
407
+ }
408
+
409
+ export async function fetchPoolAccountQuota(
410
+ accountId: string,
411
+ forceRefresh = false,
412
+ configuredPlan?: string,
413
+ getValidToken: typeof getValidCodexToken = getValidCodexToken,
414
+ validatePending = false,
415
+ afterDispatchSequence?: number,
416
+ ): Promise<PoolQuotaResult> {
417
+ const existing = getAccountQuota(accountId);
418
+ if (afterDispatchSequence === undefined && !forceRefresh && existing && Date.now() - existing.updatedAt < POOL_CACHE_TTL) {
419
+ return {
420
+ quota: existing,
421
+ needsReauth: false,
422
+ credentialGeneration: readCodexAccountRecord(accountId)?.generation,
423
+ };
424
+ }
425
+ // A token refresh may increment the generation (and rotate the refresh token) before WHAM
426
+ // completes. Join a flight whose starting or resolved generation is still current, but let a
427
+ // replacement credential with the same pool id start its own request.
428
+ const record = readCodexAccountRecord(accountId);
429
+ const flights = poolQuotaRefreshInFlight.get(accountId);
430
+ const current = flights && [...flights].find(flight => {
431
+ const generation = flight.state.resolvedCredentialGeneration
432
+ ?? flight.state.startCredentialGeneration;
433
+ return !flight.state.superseded
434
+ && (afterDispatchSequence === undefined || (flight.state.dispatchSequence ?? 0) > afterDispatchSequence)
435
+ && generation !== undefined && isCodexAccountGenerationLive(accountId, generation);
436
+ });
437
+ if (current) {
438
+ // A manual refresh joining a passive read must not lose its validation intent.
439
+ current.state.validatePending ||= validatePending;
440
+ return current.promise;
441
+ }
442
+ if (poolQuotaFlightCount() >= MAX_POOL_QUOTA_FLIGHTS) throw new PoolQuotaProbeBusyError();
443
+
444
+ // A post-reset request must not let an older same-account response overwrite its evidence.
445
+ // Flags live only as long as the bounded flights; no retained per-account sequence map.
446
+ if (afterDispatchSequence !== undefined) {
447
+ for (const flight of flights ?? []) flight.state.superseded = true;
448
+ }
449
+ const state: PoolQuotaRefreshFlight["state"] = {
450
+ startCredentialGeneration: record?.generation,
451
+ validatePending,
452
+ };
453
+ const refresh = fetchFreshPoolAccountQuota(
454
+ accountId,
455
+ existing,
456
+ configuredPlan,
457
+ generation => { state.resolvedCredentialGeneration = generation; },
458
+ getValidToken,
459
+ {
460
+ onDispatch: sequence => { state.dispatchSequence = sequence; },
461
+ mayPublish: () => state.superseded !== true,
462
+ },
463
+ ).then(async result => {
464
+ // A passive flight has consumed its validation decision. Remove it before
465
+ // promise settlement queues other continuations, so a late explicit caller
466
+ // starts fresh work instead of setting an intent nobody will read again.
467
+ if (!state.validatePending) {
468
+ releaseFlight();
469
+ return result;
470
+ }
471
+ // Only an explicit account-list refresh finishes deferred registration. Passive quota
472
+ // polls and startup priming remain read-only with respect to inference spending.
473
+ const generation = result.freshCredentialGeneration;
474
+ const record = state.validatePending ? readCodexAccountRecord(accountId) : null;
475
+ if (record?.codexValidationPending && record.credential && record.deletedAt == null
476
+ && generation !== undefined && record.generation === generation
477
+ && isCompleteCodexQuotaRecoverySnapshot(result.freshQuota ?? null, result.freshPlan ?? configuredPlan)) {
478
+ try {
479
+ await warmCodexAccount({
480
+ accessToken: record.credential.accessToken,
481
+ chatgptAccountId: record.credential.chatgptAccountId,
482
+ });
483
+ markCodexAccountValidated(accountId, Date.now(), generation);
484
+ clearAccountNeedsReauth(accountId, generation);
485
+ } catch (error) {
486
+ // Keep the durable restriction on any failed/partial inference response, even
487
+ // when WHAM just reported headroom. No raw upstream text enters diagnostics.
488
+ const reason = codexWarmupFailureReason(error);
489
+ if (reason === "http_status:401" || reason === "http_status:403") {
490
+ markCodexAccountValidationFailed(accountId, reason, { expectedGeneration: generation });
491
+ markAccountNeedsReauth(accountId, captureConfigGeneration(), generation);
492
+ }
493
+ }
494
+ }
495
+ return result;
496
+ });
497
+ const flight: PoolQuotaRefreshFlight = { state, promise: refresh };
498
+ const activeFlights = flights ?? new Set<PoolQuotaRefreshFlight>();
499
+ activeFlights.add(flight);
500
+ if (!flights) poolQuotaRefreshInFlight.set(accountId, activeFlights);
501
+ const releaseFlight = () => {
502
+ activeFlights.delete(flight);
503
+ if (activeFlights.size === 0 && poolQuotaRefreshInFlight.get(accountId) === activeFlights) {
504
+ poolQuotaRefreshInFlight.delete(accountId);
505
+ }
506
+ };
507
+ try {
508
+ return await refresh;
509
+ } finally {
510
+ releaseFlight();
511
+ }
512
+ }