@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,402 @@
1
+ import { isCodexAccountGenerationLive } from "../account-store";
2
+ import { NATIVE_RESERVE_MODEL } from "../catalog/native-models";
3
+ import { MAIN_CODEX_ACCOUNT_ID } from "../main-account";
4
+ import { POOL_KEY_CODEX } from "../pool-rotation";
5
+ import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
6
+ import type { OcxConfig } from "../../types";
7
+ import type { CodexCooldownSource } from "./cooldown-math";
8
+
9
+ export type CodexUpstreamHealth = {
10
+ consecutiveFailures: number;
11
+ /** Consecutive healthy terminals observed while recovering from escalation level 2+. */
12
+ consecutiveSuccesses?: number;
13
+ lastFailureStatus?: number;
14
+ lastFailureAt?: number;
15
+ /** Hard cooldown (quota 429). Survives a later 2xx; blocks auth + selection. */
16
+ cooldownUntil?: number;
17
+ /**
18
+ * How long a quota refusal keeps selection away from this account (or this native quota
19
+ * group), as opposed to how long it is hard-blocked.
20
+ *
21
+ * The two are deliberately different lengths. {@link CODEX_MAX_RESET_DERIVED_COOLDOWN_MS}
22
+ * caps the hard cooldown at 15 minutes because a reset announcement is advisory and plan
23
+ * quota usually frees up before it — an account must stay reachable so the pool can find
24
+ * that out (#433). The window the refusal announced is not 15 minutes, though, so once the
25
+ * cooldown lapses the account is selectable again while its burst window is still spent,
26
+ * and the strategy picks it straight back: this proxy reads a weekly bar a burst limit never
27
+ * touches, so a refused account still scores as the coolest in the pool. Every request then
28
+ * earns the same 429 until the process restarts, which is the only thing that drops this map.
29
+ *
30
+ * So the announcement governs avoidance and the cap still governs blocking. Avoidance is soft
31
+ * in the {@link softAvoidUntil} sense: it reorders the pool and releases a bound thread, and
32
+ * the last-resort paths still reach the account when nothing else can serve, so one pessimistic
33
+ * announcement cannot stall routing.
34
+ */
35
+ quotaAvoidUntil?: number;
36
+ /** When the current cooldown was recorded; origin of the probe interval clock. */
37
+ cooldownSince?: number;
38
+ /**
39
+ * What produced the cooldown. An explicit Retry-After is a literal retry
40
+ * directive and is never probed; a quota resetAt only announces a window
41
+ * refresh, so it may be probed early (#433).
42
+ */
43
+ cooldownSource?: CodexCooldownSource;
44
+ /**
45
+ * Bumped on every cooldown write. A probe lease records the generation it was
46
+ * issued for so a lease cannot clear a cooldown that a later 429 replaced.
47
+ */
48
+ cooldownGeneration?: number;
49
+ /**
50
+ * Identity of the in-flight probe. A cooled-down account sends no traffic, so
51
+ * no organic 2xx can prove recovery; only the outcome carrying this id may
52
+ * clear the cooldown.
53
+ */
54
+ probeLeaseId?: string;
55
+ /** Cooldown generation at the moment the lease was granted. */
56
+ probeLeaseGeneration?: number;
57
+ /** Last probe grant or conclusion; paces the probe interval. */
58
+ lastProbeAt?: number;
59
+ /**
60
+ * Soft avoid after connect_error / timeout / transient 5xx. Cleared on 2xx.
61
+ * Blocks pool selection + thread affinity reuse so a sticky session can leave a
62
+ * flaky account without throwing CodexAccountCooldownError (hard-only).
63
+ */
64
+ softAvoidUntil?: number;
65
+ /**
66
+ * Credential generation a 401/403 quarantine was derived from (#2892 gap 4).
67
+ *
68
+ * Provenance lives ON the entry rather than in a side map keyed by account id. A side map spends
69
+ * "whatever health is current when the old credential is found dead", which deletes a later
70
+ * unrelated entry: a G1 401, then a G2 save, then a genuine G2 503 would lose the 503. Only the
71
+ * entry that carries this field can be spent, and any later write simply replaces it.
72
+ */
73
+ credentialFailureGeneration?: number;
74
+ };
75
+
76
+ const upstreamHealth = new Map<string, CodexUpstreamHealth>();
77
+ /**
78
+ * Reset-derived 429s can describe a quota owned by one native model family,
79
+ * rather than the whole ChatGPT account. Keep those advisory cooldowns apart
80
+ * from account-wide Retry-After/default throttles and transient health.
81
+ */
82
+ const quotaScopedHealth = new Map<string, Map<CodexQuotaScope, CodexUpstreamHealth>>();
83
+ /**
84
+ * Spend a credential-failure health entry whose credential no longer exists (#2892 gap 4).
85
+ *
86
+ * A 401/403 describes one CREDENTIAL, not an account, and a replacement can land at any point after
87
+ * the outcome is recorded — so re-reading the store inside `recordCodexUpstreamOutcome` narrows the
88
+ * window without closing it. The reader decides instead, and it may only spend an entry that
89
+ * actually carries credential provenance: a later transient or quota write replaces the entry and
90
+ * with it the tag, so this can never delete evidence that belongs to a different failure.
91
+ */
92
+ export function dropSpentCredentialFailure(accountId: string): void {
93
+ const health = upstreamHealth.get(accountId);
94
+ const generation = health?.credentialFailureGeneration;
95
+ if (health === undefined || generation === undefined) return;
96
+ if (isCodexAccountGenerationLive(accountId, generation)) return;
97
+ upstreamHealth.delete(accountId);
98
+ }
99
+ let lastReconciledGeneration = 0;
100
+ let liveHealthAccountIds = new Set<string>();
101
+
102
+ /**
103
+ * Native Codex quota groups known to be independent upstream. Keep the mapping
104
+ * deliberately conservative: unlisted models share the normal native group.
105
+ * Add a new explicit group here only when its independent upstream quota is
106
+ * confirmed, so shared limits never receive cross-model bypasses.
107
+ */
108
+ export type CodexQuotaScope = "shared" | "reserve";
109
+
110
+
111
+ export const NATIVE_MODEL_QUOTA_SCOPES: Readonly<Record<string, CodexQuotaScope>> = {
112
+ [NATIVE_RESERVE_MODEL]: "reserve",
113
+ };
114
+
115
+ export function codexQuotaScopeForModel(modelId: string | undefined): CodexQuotaScope | undefined {
116
+ if (!modelId?.trim()) return undefined;
117
+ return NATIVE_MODEL_QUOTA_SCOPES[modelId.trim().toLowerCase()] ?? "shared";
118
+ }
119
+
120
+ /** Independent quota groups must not mutate the shared active-account cursor. */
121
+ export function isIndependentCodexQuotaScope(quotaScope?: CodexQuotaScope): boolean {
122
+ return quotaScope !== undefined && quotaScope !== "shared";
123
+ }
124
+
125
+ export function codexPoolKeyForScope(quotaScope?: CodexQuotaScope): string {
126
+ return isIndependentCodexQuotaScope(quotaScope) ? `${POOL_KEY_CODEX}:${quotaScope}` : POOL_KEY_CODEX;
127
+ }
128
+
129
+ export function listLiveCodexAccountIds(config: OcxConfig): ReadonlySet<string> {
130
+ const ids = new Set((config.codexAccounts ?? []).map(account => account.id));
131
+ const openai = config.providers.openai;
132
+ if (openai && openai.disabled !== true && isCanonicalOpenAiForwardProvider(openai)) {
133
+ ids.add(MAIN_CODEX_ACCOUNT_ID);
134
+ }
135
+ return ids;
136
+ }
137
+
138
+ export function getCodexUpstreamHealth(
139
+ accountId: string,
140
+ ): CodexUpstreamHealth | null {
141
+ dropSpentCredentialFailure(accountId);
142
+ return upstreamHealth.get(accountId) ?? null;
143
+ }
144
+
145
+ export function scopedHealthFor(accountId: string, scope: CodexQuotaScope): CodexUpstreamHealth | undefined {
146
+ return quotaScopedHealth.get(accountId)?.get(scope);
147
+ }
148
+
149
+ export function setScopedHealth(accountId: string, scope: CodexQuotaScope, health: CodexUpstreamHealth): void {
150
+ let scopes = quotaScopedHealth.get(accountId);
151
+ if (!scopes) {
152
+ scopes = new Map();
153
+ quotaScopedHealth.set(accountId, scopes);
154
+ }
155
+ scopes.set(scope, health);
156
+ }
157
+
158
+ export function deleteScopedHealth(accountId: string, scope: CodexQuotaScope): void {
159
+ const scopes = quotaScopedHealth.get(accountId);
160
+ if (!scopes) return;
161
+ scopes.delete(scope);
162
+ if (scopes.size === 0) quotaScopedHealth.delete(accountId);
163
+ }
164
+
165
+ /** Live quota-refusal avoidance for an account, including the lane the request belongs to. */
166
+ function codexQuotaAvoidUntil(
167
+ accountId: string,
168
+ quotaScope: CodexQuotaScope | undefined,
169
+ now: number,
170
+ ): number | null {
171
+ const live = (value: number | undefined): number | null =>
172
+ typeof value === "number" && Number.isFinite(value) && value > now ? value : null;
173
+ const account = live(upstreamHealth.get(accountId)?.quotaAvoidUntil);
174
+ const scoped = quotaScope === undefined
175
+ ? null
176
+ : live(scopedHealthFor(accountId, quotaScope)?.quotaAvoidUntil);
177
+ if (account === null) return scoped;
178
+ return scoped === null ? account : Math.max(account, scoped);
179
+ }
180
+
181
+ export function isCodexQuotaAvoided(
182
+ accountId: string,
183
+ quotaScope: CodexQuotaScope | undefined,
184
+ now: number,
185
+ ): boolean {
186
+ return codexQuotaAvoidUntil(accountId, quotaScope, now) !== null;
187
+ }
188
+
189
+ /**
190
+ * Hard-cooldown bookkeeping that ordinary success/transient transitions rebuild
191
+ * their health object from. Dropping these would let one late unrelated response
192
+ * erase a Retry-After source, a cooldown generation, or someone else's live probe.
193
+ */
194
+ export function preservedCooldownFields(health: CodexUpstreamHealth | undefined): Partial<CodexUpstreamHealth> {
195
+ if (!health) return {};
196
+ // `credentialFailureGeneration` is provenance for ONE credential failure, so it must not survive
197
+ // into a later transient or quota entry — otherwise that entry inherits the tag and gets spent
198
+ // when the old credential dies, deleting evidence that was never about it (#2892 gap 4 review).
199
+ const {
200
+ consecutiveFailures: _f, consecutiveSuccesses: _s, lastFailureStatus: _st, lastFailureAt: _at,
201
+ softAvoidUntil: _sa, credentialFailureGeneration: _cg, ...cooldownFields
202
+ } = health;
203
+ return cooldownFields;
204
+ }
205
+
206
+ export function getCodexAccountCooldownUntil(accountId: string, now = Date.now()): number | null {
207
+ const cooldownUntil = upstreamHealth.get(accountId)?.cooldownUntil;
208
+ return typeof cooldownUntil === "number" && Number.isFinite(cooldownUntil) && cooldownUntil > now ? cooldownUntil : null;
209
+ }
210
+
211
+ /** Read-only cooldown snapshot for shared OAuth health projection (no write side effects). */
212
+ export function getCodexAccountHealthSnapshot(accountId: string, now = Date.now()): {
213
+ cooldownUntil?: number;
214
+ cooldownSource?: CodexCooldownSource;
215
+ } | null {
216
+ const cooldownUntil = getCodexAccountCooldownUntil(accountId, now);
217
+ if (cooldownUntil === null) return null;
218
+ const source = upstreamHealth.get(accountId)?.cooldownSource;
219
+ return {
220
+ cooldownUntil,
221
+ ...(source ? { cooldownSource: source } : {}),
222
+ };
223
+ }
224
+
225
+ /**
226
+ * Read the cooldown relevant to a routed native model. Account-wide cooldowns
227
+ * (Retry-After/default) always win; reset-derived scoped state applies only to
228
+ * its confirmed quota group.
229
+ */
230
+ export function getCodexQuotaHealthSnapshot(
231
+ accountId: string,
232
+ quotaScope: CodexQuotaScope | undefined,
233
+ now = Date.now(),
234
+ ): {
235
+ cooldownUntil?: number;
236
+ cooldownSource?: CodexCooldownSource;
237
+ quotaScope?: CodexQuotaScope;
238
+ } | null {
239
+ const account = getCodexAccountHealthSnapshot(accountId, now);
240
+ if (account) return account;
241
+ if (!quotaScope) return null;
242
+ const scoped = scopedHealthFor(accountId, quotaScope);
243
+ const cooldownUntil = scoped?.cooldownUntil;
244
+ if (typeof cooldownUntil !== "number" || !Number.isFinite(cooldownUntil) || cooldownUntil <= now) return null;
245
+ return {
246
+ cooldownUntil,
247
+ ...(scoped?.cooldownSource ? { cooldownSource: scoped.cooldownSource } : {}),
248
+ quotaScope,
249
+ };
250
+ }
251
+
252
+ export function isCodexAccountInCooldown(accountId: string, now = Date.now()): boolean {
253
+ return getCodexAccountCooldownUntil(accountId, now) !== null;
254
+ }
255
+
256
+ /**
257
+ * Manually lift a hard quota cooldown without touching failure history.
258
+ *
259
+ * Injected Codex routing makes this proxy the ONLY model path for Codex Desktop, so a
260
+ * cooldown that outlives the real upstream limit reads to the user as "the whole app is
261
+ * broken" with no escape but editing config.toml. This is that escape hatch.
262
+ *
263
+ * Deliberately narrow:
264
+ * - Failure counters and softAvoid survive. Clearing a cooldown says "the quota window
265
+ * moved", not "this account is healthy"; failover must keep its knowledge.
266
+ * - Dropping `probeLeaseId` is what stops a stale in-flight probe from later "proving"
267
+ * recovery against a NEWER cooldown: {@link ownsProbeLease} needs the id to match.
268
+ * `cooldownGeneration` is preserved and bumped as redundancy only — a fresh 429 already
269
+ * bumps it in {@link recordCodexUpstreamOutcome}, so the bump here is not load-bearing
270
+ * today and is kept so the invariant survives a future change that retains the lease.
271
+ *
272
+ * Returns false when the account carried neither a live cooldown nor a live avoidance window.
273
+ * The window outlives the cooldown by design — the cooldown caps at fifteen minutes and the
274
+ * window runs up to six hours — so the moment an operator actually reaches for this escape
275
+ * hatch is usually after the cooldown lapsed and only the window is still keeping the account
276
+ * out of rotation. Refusing to look at the window then would leave the hatch shut in the one
277
+ * case it exists for.
278
+ */
279
+ export function clearCodexAccountCooldown(accountId: string, now = Date.now()): boolean {
280
+ const clear = (health: CodexUpstreamHealth): CodexUpstreamHealth | null => {
281
+ const cooldownUntil = health.cooldownUntil;
282
+ const liveCooldown = typeof cooldownUntil === "number" && Number.isFinite(cooldownUntil) && cooldownUntil > now;
283
+ const avoidUntil = health.quotaAvoidUntil;
284
+ const liveAvoidance = typeof avoidUntil === "number" && Number.isFinite(avoidUntil) && avoidUntil > now;
285
+ if (!liveCooldown && !liveAvoidance) return null;
286
+ const {
287
+ cooldownUntil: _until,
288
+ cooldownSince: _since,
289
+ cooldownSource: _source,
290
+ probeLeaseId: _leaseId,
291
+ probeLeaseGeneration: _leaseGeneration,
292
+ // Same reasoning as the probe recovery above: "the quota window moved" is a statement
293
+ // about the whole refusal, so the avoidance it announced goes with the block it
294
+ // produced. Keeping it would leave this escape hatch not escaping, because selection
295
+ // would still pass over the account for as long as the announced window runs.
296
+ quotaAvoidUntil: _avoid,
297
+ ...rest
298
+ } = health;
299
+ return {
300
+ ...rest,
301
+ cooldownGeneration: (health.cooldownGeneration ?? 0) + 1,
302
+ lastProbeAt: now,
303
+ };
304
+ };
305
+
306
+ let cleared = false;
307
+ const accountHealth = upstreamHealth.get(accountId);
308
+ if (accountHealth) {
309
+ const next = clear(accountHealth);
310
+ if (next) {
311
+ upstreamHealth.set(accountId, next);
312
+ cleared = true;
313
+ }
314
+ }
315
+ for (const [scope, health] of quotaScopedHealth.get(accountId) ?? []) {
316
+ const next = clear(health);
317
+ if (next) {
318
+ setScopedHealth(accountId, scope, next);
319
+ cleared = true;
320
+ }
321
+ }
322
+ return cleared;
323
+ }
324
+
325
+ export function getCodexAccountSoftAvoidUntil(accountId: string, now = Date.now()): number | null {
326
+ const softAvoidUntil = upstreamHealth.get(accountId)?.softAvoidUntil;
327
+ return typeof softAvoidUntil === "number" && Number.isFinite(softAvoidUntil) && softAvoidUntil > now
328
+ ? softAvoidUntil
329
+ : null;
330
+ }
331
+
332
+ export function isCodexAccountSoftAvoided(accountId: string, now = Date.now()): boolean {
333
+ return getCodexAccountSoftAvoidUntil(accountId, now) !== null;
334
+ }
335
+
336
+ /**
337
+ * Closed package-internal accessors for the account-wide health maps. Selection,
338
+ * the probe lease, and the active cursor mutate health only through these; the
339
+ * Map bindings themselves never leave this module.
340
+ */
341
+ export function getAccountHealth(accountId: string): CodexUpstreamHealth | undefined {
342
+ return upstreamHealth.get(accountId);
343
+ }
344
+
345
+ export function setAccountHealth(accountId: string, health: CodexUpstreamHealth): void {
346
+ upstreamHealth.set(accountId, health);
347
+ }
348
+
349
+ export function deleteAccountHealth(accountId: string): void {
350
+ upstreamHealth.delete(accountId);
351
+ }
352
+
353
+ export function listScopedHealthEntries(accountId: string): Array<[CodexQuotaScope, CodexUpstreamHealth]> {
354
+ return [...(quotaScopedHealth.get(accountId) ?? [])];
355
+ }
356
+
357
+ export function deleteAllScopedHealth(accountId: string): void {
358
+ quotaScopedHealth.delete(accountId);
359
+ }
360
+
361
+ export function isHealthAccountAdmissible(accountId: string, writerGeneration: number): boolean {
362
+ return writerGeneration >= lastReconciledGeneration || liveHealthAccountIds.has(accountId);
363
+ }
364
+
365
+ export function isHealthGenerationReconciled(generation: number): boolean {
366
+ return generation <= lastReconciledGeneration;
367
+ }
368
+
369
+ export function pruneHealthAccountsForContext(codexAccountIds: ReadonlySet<string>): number {
370
+ let removed = 0;
371
+ for (const accountId of upstreamHealth.keys()) {
372
+ if (codexAccountIds.has(accountId)) continue;
373
+ upstreamHealth.delete(accountId);
374
+ removed += 1;
375
+ }
376
+ for (const accountId of quotaScopedHealth.keys()) {
377
+ if (codexAccountIds.has(accountId)) continue;
378
+ quotaScopedHealth.delete(accountId);
379
+ removed += 1;
380
+ }
381
+ return removed;
382
+ }
383
+
384
+ export function commitHealthReconcile(generation: number, codexAccountIds: ReadonlySet<string>): void {
385
+ liveHealthAccountIds = new Set(codexAccountIds);
386
+ lastReconciledGeneration = generation;
387
+ }
388
+
389
+ export function clearUpstreamHealthState(): void {
390
+ upstreamHealth.clear();
391
+ quotaScopedHealth.clear();
392
+ }
393
+
394
+ export function resetHealthReconcileState(): void {
395
+ lastReconciledGeneration = 0;
396
+ liveHealthAccountIds = new Set();
397
+ }
398
+
399
+ export function deleteAllHealthForAccount(accountId: string): void {
400
+ upstreamHealth.delete(accountId);
401
+ quotaScopedHealth.delete(accountId);
402
+ }