@bitkyc08/opencodex 2.55.0 → 2.57.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (256) hide show
  1. package/bin/ocx.mjs +10 -0
  2. package/gui/dist/assets/{index-BBOZWGB6.css → index-C5-RdDmD.css} +1 -1
  3. package/gui/dist/assets/{index-VuoiWj9J.js → index-Cz7CLdif.js} +21 -21
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +4 -3
  6. package/src/adapters/base.ts +21 -0
  7. package/src/adapters/codebuddy/adapter.ts +2 -1
  8. package/src/adapters/codebuddy/scaffold-guard.ts +248 -0
  9. package/src/adapters/command-code.ts +1 -1
  10. package/src/adapters/cursor/envelope-echo.ts +8 -2
  11. package/src/adapters/cursor/transport-retry.ts +46 -1
  12. package/src/adapters/cursor.ts +4 -0
  13. package/src/adapters/google.ts +7 -7
  14. package/src/adapters/kiro/adapter.ts +42 -1
  15. package/src/adapters/kiro/payload.ts +17 -3
  16. package/src/adapters/kiro/reasoning.ts +70 -7
  17. package/src/adapters/kiro/stream.ts +8 -2
  18. package/src/adapters/kiro/wire.ts +2 -1
  19. package/src/adapters/kiro-events.ts +21 -13
  20. package/src/adapters/kiro-retry.ts +23 -4
  21. package/src/adapters/openai-chat/errors.ts +116 -0
  22. package/src/adapters/openai-chat/messages.ts +346 -0
  23. package/src/adapters/openai-chat/passthrough.ts +146 -0
  24. package/src/adapters/openai-chat/response-events.ts +117 -0
  25. package/src/adapters/openai-chat/tool-call-validation.ts +200 -0
  26. package/src/adapters/openai-chat/tool-name-registry.ts +166 -0
  27. package/src/adapters/openai-chat/tool-schema.ts +495 -0
  28. package/src/adapters/openai-chat/wire.ts +50 -0
  29. package/src/adapters/openai-chat.ts +40 -1452
  30. package/src/adapters/openai-responses/canonical-forward.ts +202 -0
  31. package/src/adapters/openai-responses/image-gen.ts +406 -0
  32. package/src/adapters/openai-responses/internal.ts +3 -0
  33. package/src/adapters/openai-responses/passthrough.ts +642 -0
  34. package/src/adapters/openai-responses/prompt-cache.ts +83 -0
  35. package/src/adapters/openai-responses/reasoning.ts +220 -0
  36. package/src/adapters/openai-responses/request-strips.ts +185 -0
  37. package/src/adapters/openai-responses/tool-output-recovery.ts +509 -0
  38. package/src/adapters/openai-responses/tool-schema.ts +293 -0
  39. package/src/adapters/openai-responses/web-search.ts +156 -0
  40. package/src/adapters/openai-responses.ts +4 -2625
  41. package/src/bridge/errors.ts +58 -0
  42. package/src/bridge/internal.ts +174 -0
  43. package/src/bridge/response-json.ts +630 -0
  44. package/src/bridge/sse.ts +1462 -0
  45. package/src/bridge.ts +5 -2204
  46. package/src/chat/inbound.ts +12 -1
  47. package/src/claude/desktop-profile.ts +66 -9
  48. package/src/claude/outbound.ts +18 -0
  49. package/src/cli/account-main.ts +1 -1
  50. package/src/cli/capabilities.ts +2 -2
  51. package/src/cli/combo.ts +10 -1
  52. package/src/cli/index.ts +48 -5
  53. package/src/cli/registry.ts +2 -1
  54. package/src/cli/system-command.ts +4 -4
  55. package/src/clients/config-export.ts +7 -3
  56. package/src/codex/account-label.ts +14 -3
  57. package/src/codex/account-lifecycle.ts +3 -0
  58. package/src/codex/account-store.ts +184 -35
  59. package/src/codex/account-usability.ts +21 -0
  60. package/src/codex/auth-api/account-list.ts +507 -0
  61. package/src/codex/auth-api/http.ts +32 -0
  62. package/src/codex/auth-api/login-flow.ts +566 -0
  63. package/src/codex/auth-api/login-state.ts +64 -0
  64. package/src/codex/auth-api/main-account-probe.ts +331 -0
  65. package/src/codex/auth-api/pool-mode-gate.ts +274 -0
  66. package/src/codex/auth-api/pool-quota-probe.ts +512 -0
  67. package/src/codex/auth-api/reset-credit-service.ts +431 -0
  68. package/src/codex/auth-api/routes.ts +425 -0
  69. package/src/codex/auth-api/runtime-config.ts +48 -0
  70. package/src/codex/auth-api.ts +27 -3118
  71. package/src/codex/auth-context.ts +252 -35
  72. package/src/codex/catalog/aggregation.ts +80 -1
  73. package/src/codex/catalog/auto-review.ts +507 -0
  74. package/src/codex/catalog/build-entries.ts +981 -0
  75. package/src/codex/catalog/combo-member.ts +375 -0
  76. package/src/codex/catalog/derive-entry.ts +229 -0
  77. package/src/codex/catalog/effort.ts +0 -1
  78. package/src/codex/catalog/gated-native-warn.ts +63 -0
  79. package/src/codex/catalog/gather-capture.ts +533 -0
  80. package/src/codex/catalog/model-hints.ts +691 -0
  81. package/src/codex/catalog/model-visibility.ts +305 -0
  82. package/src/codex/catalog/provider-fetch.ts +52 -2942
  83. package/src/codex/catalog/provider-models.ts +685 -0
  84. package/src/codex/catalog/remote.ts +30 -0
  85. package/src/codex/catalog/restore.ts +132 -0
  86. package/src/codex/catalog/retained-sync.ts +714 -0
  87. package/src/codex/catalog/routed-gather.ts +895 -0
  88. package/src/codex/catalog/subagent-roster.ts +176 -0
  89. package/src/codex/catalog/sync.ts +52 -2698
  90. package/src/codex/cli-install-provenance.ts +7 -1
  91. package/src/codex/convergence.ts +7 -2
  92. package/src/codex/desktop-app/types.ts +11 -2
  93. package/src/codex/desktop-app/windows.ts +5 -5
  94. package/src/codex/inject/config-toml.ts +563 -0
  95. package/src/codex/inject/remove.ts +192 -0
  96. package/src/codex/inject/restore.ts +567 -0
  97. package/src/codex/inject/routing-classify.ts +109 -0
  98. package/src/codex/inject/routing-target.ts +125 -0
  99. package/src/codex/inject.ts +89 -1444
  100. package/src/codex/lineage.ts +458 -0
  101. package/src/codex/model-entitlements.ts +152 -15
  102. package/src/codex/pool-refresh-backoff.ts +161 -0
  103. package/src/codex/quota-rejection.ts +104 -15
  104. package/src/codex/routing/active-account.ts +194 -0
  105. package/src/codex/routing/cache-affinity.ts +70 -0
  106. package/src/codex/routing/cooldown-math.ts +285 -0
  107. package/src/codex/routing/health-store.ts +402 -0
  108. package/src/codex/routing/probe-lease.ts +358 -0
  109. package/src/codex/routing/selection.ts +780 -0
  110. package/src/codex/routing/thread-affinity.ts +586 -0
  111. package/src/codex/routing/transient-hold-dispatch.ts +141 -0
  112. package/src/codex/routing.ts +370 -2271
  113. package/src/codex/shim-fingerprint.ts +223 -0
  114. package/src/codex/shim-inspect.ts +175 -0
  115. package/src/codex/shim-probe.ts +367 -0
  116. package/src/codex/shim-restore-lock.ts +169 -0
  117. package/src/codex/shim-state-file.ts +151 -0
  118. package/src/codex/shim-templates.ts +265 -0
  119. package/src/codex/shim.ts +48 -1268
  120. package/src/codex/warmup.ts +1 -1
  121. package/src/combos/failover.ts +85 -0
  122. package/src/combos/request.ts +17 -10
  123. package/src/combos/types.ts +23 -2
  124. package/src/config/diagnostics.ts +705 -0
  125. package/src/config/feature-flags.ts +55 -0
  126. package/src/config/live-reconcile.ts +403 -0
  127. package/src/config/load-degrade.ts +880 -0
  128. package/src/config/mutation-lock.ts +244 -0
  129. package/src/config/openai-tier-backup.ts +268 -0
  130. package/src/config/pending-teardown.ts +31 -0
  131. package/src/config/persist-unlocked.ts +92 -0
  132. package/src/config/proxy-env.ts +188 -0
  133. package/src/config/salvage.ts +244 -0
  134. package/src/config/schema/config-schema.ts +640 -0
  135. package/src/config/schema/leaf-validators.ts +855 -0
  136. package/src/config/warn-memo.ts +28 -0
  137. package/src/config.ts +234 -4481
  138. package/src/generated/compatibility-version.json +649 -121
  139. package/src/images/loop.ts +1 -1
  140. package/src/lib/errors.ts +17 -0
  141. package/src/lib/request-execution-budget.ts +198 -23
  142. package/src/lib/spend-reservation-ledger.ts +958 -0
  143. package/src/lib/state-store-registrations.ts +6 -2
  144. package/src/lib/test-home-guard.ts +85 -1
  145. package/src/lib/upstream-retry.ts +132 -21
  146. package/src/lib/windows-elevation.ts +76 -14
  147. package/src/lib/workflow-budget.ts +553 -30
  148. package/src/oauth/index.ts +2 -2
  149. package/src/oauth/key-providers.ts +2 -2
  150. package/src/providers/kiro-models.ts +4 -3
  151. package/src/providers/label.ts +19 -1
  152. package/src/providers/model-discovery.ts +16 -0
  153. package/src/providers/quota/account-cache.ts +441 -0
  154. package/src/providers/quota/antigravity.ts +295 -0
  155. package/src/providers/quota/report-cache.ts +320 -0
  156. package/src/providers/quota/vendor-probes-key.ts +1243 -0
  157. package/src/providers/quota/vendor-probes-oauth.ts +590 -0
  158. package/src/providers/quota.ts +324 -3079
  159. package/src/providers/registry/entries-core.ts +1228 -0
  160. package/src/providers/registry/entries-extended.ts +1213 -0
  161. package/src/providers/registry/model-seeds.ts +912 -0
  162. package/src/providers/registry/types.ts +352 -0
  163. package/src/providers/registry.ts +24 -3536
  164. package/src/responses/continuation-ownership.ts +29 -0
  165. package/src/responses/reasoning-envelope.ts +6 -3
  166. package/src/responses/state/replay-fingerprint.ts +80 -0
  167. package/src/responses/state/snapshot-codec.ts +104 -0
  168. package/src/responses/state/spill-failure.ts +118 -0
  169. package/src/responses/state/spill-queue.ts +665 -0
  170. package/src/responses/state/temp-recovery.ts +257 -0
  171. package/src/responses/state.ts +82 -1143
  172. package/src/routing/identity-domains.ts +456 -0
  173. package/src/routing/probe-lease.ts +613 -0
  174. package/src/server/chat-completions.ts +3 -1
  175. package/src/server/chat-native.ts +37 -9
  176. package/src/server/index/bounded-request.ts +88 -0
  177. package/src/server/index/live-sideband.ts +601 -0
  178. package/src/server/index/serve-options.ts +1766 -0
  179. package/src/server/index/startup-warnings.ts +213 -0
  180. package/src/server/index/websocket-handler.ts +339 -0
  181. package/src/server/index.ts +45 -2552
  182. package/src/server/inspection-tee.ts +107 -0
  183. package/src/server/live.ts +46 -1
  184. package/src/server/management/combo-routes.ts +10 -1
  185. package/src/server/management/route-registry.ts +26 -23
  186. package/src/server/management/shared.ts +8 -5
  187. package/src/server/management/workflow-budget-routes.ts +133 -0
  188. package/src/server/management-api.ts +12 -0
  189. package/src/server/relay-eager.ts +2 -0
  190. package/src/server/relay.ts +14 -19
  191. package/src/server/request-log-conversation.ts +9 -7
  192. package/src/server/request-log.ts +372 -4
  193. package/src/server/response-log-body.ts +153 -0
  194. package/src/server/responses/account-change-state.ts +307 -0
  195. package/src/server/responses/adapter-continuation.ts +540 -0
  196. package/src/server/responses/adapter-delivery.ts +208 -0
  197. package/src/server/responses/adapter-dispatch.ts +1042 -0
  198. package/src/server/responses/codex-ws-wire.ts +5 -0
  199. package/src/server/responses/collaboration.ts +74 -4
  200. package/src/server/responses/combo-session-recall.ts +68 -8
  201. package/src/server/responses/compact.ts +113 -17
  202. package/src/server/responses/completion-policy.ts +33 -0
  203. package/src/server/responses/core-auth.ts +529 -0
  204. package/src/server/responses/core-codex-account.ts +907 -0
  205. package/src/server/responses/core-combo-failure.ts +210 -0
  206. package/src/server/responses/core-combo.ts +787 -0
  207. package/src/server/responses/core-errors.ts +170 -0
  208. package/src/server/responses/core-lifetime.ts +95 -0
  209. package/src/server/responses/core-normalize.ts +350 -0
  210. package/src/server/responses/core-opaque-recovery.ts +380 -0
  211. package/src/server/responses/core-options.ts +159 -0
  212. package/src/server/responses/core-replay.ts +298 -0
  213. package/src/server/responses/core.ts +192 -8893
  214. package/src/server/responses/encrypted-payload.ts +0 -1
  215. package/src/server/responses/input-admission.ts +126 -6
  216. package/src/server/responses/passthrough-delivery.ts +869 -0
  217. package/src/server/responses/passthrough-dispatch.ts +1494 -0
  218. package/src/server/responses/passthrough-error.ts +38 -2
  219. package/src/server/responses/passthrough-execution.ts +54 -0
  220. package/src/server/responses/request-prepare.ts +1080 -0
  221. package/src/server/responses/request-send-budget.ts +259 -0
  222. package/src/server/responses/request-sidecar-auth.ts +149 -0
  223. package/src/server/responses/request-spend.ts +147 -0
  224. package/src/server/responses/request-transport.ts +803 -0
  225. package/src/server/responses/response-effects.ts +157 -0
  226. package/src/server/responses/run-turn-execution.ts +476 -0
  227. package/src/server/responses/sidecar-execution.ts +463 -0
  228. package/src/server/responses/terminal-guard.ts +65 -4
  229. package/src/server/responses-image-gen-repair.ts +1 -1
  230. package/src/server/responses-undeclared-tool-guard.ts +9 -5
  231. package/src/server/workflow-refusal.ts +84 -0
  232. package/src/service/windows-ops.ts +210 -16
  233. package/src/service/windows-scheduler.ts +28 -21
  234. package/src/service.ts +1 -1
  235. package/src/types/config.ts +34 -1
  236. package/src/types/request.ts +8 -5
  237. package/src/types/tools.ts +24 -0
  238. package/src/types.ts +2 -0
  239. package/src/update/index.ts +10 -0
  240. package/src/update/stop-contract.d.mts +1 -0
  241. package/src/update/stop-contract.mjs +19 -0
  242. package/src/update/stop-decision.d.mts +1 -1
  243. package/src/update/stop-decision.mjs +12 -3
  244. package/src/usage/log.ts +147 -1
  245. package/src/usage/summary.ts +171 -21
  246. package/src/vision/anthropic-describe.ts +1 -1
  247. package/src/vision/describe.ts +5 -5
  248. package/src/web-search/anthropic-executor.ts +1 -1
  249. package/src/web-search/exa-executor.ts +1 -1
  250. package/src/web-search/executor.ts +1 -1
  251. package/src/web-search/gemini-executor.ts +1 -1
  252. package/src/web-search/loop.ts +1 -1
  253. package/src/web-search/ollama-executor.ts +1 -1
  254. package/src/web-search/parse.ts +67 -14
  255. package/src/web-search/passthrough-bridge.ts +64 -31
  256. package/src/web-search/xai-executor.ts +1 -1
@@ -0,0 +1,285 @@
1
+ import {
2
+ CODEX_EXHAUSTED_USAGE_PERCENT,
3
+ CODEX_UNKNOWN_USAGE_SCORE,
4
+ resetAtToMs,
5
+ } from "../quota";
6
+ import { isThirtyDayOnlyCodexPlan } from "../plan";
7
+ import type { CodexQuotaScope } from "./health-store";
8
+ import type { TransientProbeGrant } from "./thread-affinity";
9
+
10
+ export const CODEX_DEFAULT_QUOTA_COOLDOWN_MS = 60_000;
11
+ export const CODEX_MAX_QUOTA_COOLDOWN_MS = 24 * 60 * 60_000;
12
+ /**
13
+ * A weekly/monthly quota `resetAt` announces when the window refreshes; it is not
14
+ * a "come back after this" directive like Retry-After. Plan quota routinely frees
15
+ * up long before the advertised reset, so cap reset-derived cooldowns far below
16
+ * the Retry-After ceiling (#433).
17
+ */
18
+ export const CODEX_MAX_RESET_DERIVED_COOLDOWN_MS = 15 * 60_000;
19
+ /**
20
+ * Ceiling on quota-refusal avoidance. Generous enough to cover a full five-hour burst window,
21
+ * tight enough that a weekly or monthly reset four days out cannot take an account out of
22
+ * rotation for the {@link CODEX_MAX_QUOTA_COOLDOWN_MS} day the Retry-After ceiling allows.
23
+ */
24
+ export const CODEX_MAX_QUOTA_AVOID_MS = 6 * 60 * 60_000;
25
+ /** Minimum gap between probe leases for one cooled-down account. */
26
+ export const CODEX_QUOTA_PROBE_INTERVAL_MS = 5 * 60_000;
27
+ export const CODEX_FAILURE_WINDOW_MS = 5 * 60_000;
28
+ /**
29
+ * How recently a 100% burst reading must have been OBSERVED to exclude an account when it
30
+ * carries no reset timestamp (#3425). Deliberately far tighter than the 6h disk-hydration
31
+ * horizon in `quota.ts`: shorter than any plausible five-hour burst window, so a persisted
32
+ * reading can never strand a recovered account, and long enough that a snapshot taken at
33
+ * admission is still fresh when selection reads it.
34
+ */
35
+ export const TERMINAL_SHORT_WINDOW_FRESHNESS_MS = 5 * 60_000;
36
+ /** How long a transient failure keeps the account out of pool selection. */
37
+ export const CODEX_TRANSIENT_SOFT_AVOID_MS = 30_000;
38
+ export const CODEX_TRANSIENT_SOFT_AVOID_ESCALATION_MS = [
39
+ CODEX_TRANSIENT_SOFT_AVOID_MS,
40
+ 2 * 60_000,
41
+ 10 * 60_000,
42
+ 30 * 60_000,
43
+ ] as const;
44
+
45
+ export type CodexUpstreamOutcome = number | "connect_error" | "timeout" | "connect_neutral";
46
+ export type CodexUpstreamOutcomeClass = "success" | "credential"
47
+ | "workspace" | "quota" | "transient" | "caller" | "neutral" | "unknown";
48
+ export type CodexCooldownSource = "retry-after" | "reset-derived" | "default";
49
+
50
+ export type CodexUpstreamOutcomeMeta = {
51
+ retryAfter?: string | null;
52
+ resetAt?: unknown | unknown[];
53
+ now?: number;
54
+ /** (provider, host) ledger key for account-neutral reachability failures (#914). */
55
+ hostKey?: string;
56
+ /**
57
+ * Upstream denial evidence for a 403. A workspace/entitlement denial means the CREDENTIAL
58
+ * is fine and the account simply cannot reach this workspace, so it must not be quarantined
59
+ * for reauthentication (#1789). Absent evidence keeps the historical credential handling.
60
+ */
61
+ denial?: "workspace" | "entitlement";
62
+ /** Stable transport code recorded alongside a neutral host failure. */
63
+ lastFailureCode?: string;
64
+ /** Native model selected for this request; used only for confirmed scoped quotas. */
65
+ modelId?: string;
66
+ /** When set, clears affinity for this thread immediately on transient failure. */
67
+ threadId?: string | null;
68
+ /**
69
+ * Suppress Pool rotation and quota/transient affinity mutations for an account-qualified
70
+ * request. Credential failures still sweep stale affinities because reauthentication is
71
+ * account-wide.
72
+ */
73
+ fixedAccount?: boolean;
74
+ /**
75
+ * Probe lease held by this request, when it was admitted through an active
76
+ * quota cooldown. Only the outcome carrying the current lease may clear the
77
+ * cooldown (#433).
78
+ */
79
+ probeLeaseId?: string;
80
+ /** Scope of `probeLeaseId` when it was granted against a model-scoped cooldown. */
81
+ probeQuotaScope?: CodexQuotaScope;
82
+ /**
83
+ * The half-open TRANSIENT-HOLD probe this request was granted, when it was the one request
84
+ * admitted to test a held account (#4701). A different lease to `probeLeaseId` above, in a
85
+ * different domain: that one governs a quota cooldown, this one governs a 5xx hold. The two
86
+ * are mutually exclusive by construction -- `isTransientOnlyAffinityBlock` refuses to
87
+ * recognise a transient hold on an account that carries quota health -- so a request never
88
+ * holds both and never pays two recovery permits for one send.
89
+ */
90
+ transientProbe?: TransientProbeGrant;
91
+ /**
92
+ * Already-chosen alternate for same-request 429 retry. When set, promotion
93
+ * reuses this account instead of calling {@link pickAlternateCodexAccount}
94
+ * again (which would advance a round-robin ring twice).
95
+ */
96
+ promoteAccountId?: string;
97
+ /** Generation captured when this routed account was selected. */
98
+ writerGeneration?: number;
99
+ /**
100
+ * Credential generation this request's bearer was read at. Distinct from
101
+ * `writerGeneration`, which tracks the config store.
102
+ *
103
+ * A 401 that arrives after the credential was already replaced is evidence about a
104
+ * token nobody is using any more, so it must not quarantine the replacement. Absent
105
+ * means the caller cannot supply lineage and the historical unfenced handling stands.
106
+ */
107
+ credentialGeneration?: number;
108
+ };
109
+
110
+ export function computeCodexUsageScore(quota: {
111
+ weeklyPercent?: number;
112
+ monthlyPercent?: number;
113
+ shortPercent?: number;
114
+ shortResetAt?: number;
115
+ shortObservedAt?: number;
116
+ } | null, plan?: unknown, now: number = Date.now()): number {
117
+ if (!quota) return CODEX_UNKNOWN_USAGE_SCORE;
118
+ const finite = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value);
119
+ const longWindows = isThirtyDayOnlyCodexPlan(plan)
120
+ ? [quota.monthlyPercent]
121
+ : [quota.weeklyPercent, quota.monthlyPercent];
122
+ const knownLong = longWindows.filter(finite);
123
+ // The short burst window only REFINES a known long-window position; it cannot stand in for
124
+ // one. A snapshot carrying just `shortPercent: 0` would otherwise score a flat 0 and make an
125
+ // account whose weekly/monthly usage is entirely unverified look like the emptiest in the
126
+ // pool, so `pickLowestUsageAmong` would send every request to it. Unknown has to stay
127
+ // unknown until a governing window is actually observed.
128
+ //
129
+ // A FULL burst window is the exception (#3029). It is not an optimistic guess about an
130
+ // unobserved window — it is a direct observation that the account cannot serve a request
131
+ // right now, whatever its monthly position turns out to be. Unknown-means-selectable is
132
+ // correct for uncertainty and wrong for a measured refusal: the account stays selected,
133
+ // `applyQuotaAutoSwitch` never fires, and the pool wedges on an exhausted credential.
134
+ if (knownLong.length === 0) {
135
+ return isTerminalShortWindow(quota, now) ? CODEX_EXHAUSTED_USAGE_PERCENT : CODEX_UNKNOWN_USAGE_SCORE;
136
+ }
137
+ const values = finite(quota.shortPercent) ? [...knownLong, quota.shortPercent] : knownLong;
138
+ return Math.max(...values);
139
+ }
140
+
141
+ /**
142
+ * A short-only reading that proves the account is blocked NOW.
143
+ *
144
+ * Freshness is not optional. `getAccountQuota` performs no expiry check, partial updates
145
+ * carry a still-open short tuple forward, and disk hydration accepts a persisted reading for
146
+ * hours — so scoring 100 from `shortPercent` alone would keep excluding an account whose
147
+ * five-hour window has since reset. Merge no longer carries an elapsed shortResetAt, but an
148
+ * explicit incoming elapsed tuple is still stored, and a missing reset cannot be aged there.
149
+ * That is #3029 pointed the other way: the issue is that
150
+ * an exhausted account stays selected, and "a recovered account stays excluded" trades one
151
+ * unusable pool for another.
152
+ *
153
+ * A reading with no `shortResetAt` cannot be aged, so it stays unknown. The conservative
154
+ * direction here is the one that keeps an account selectable: a wrongly-selected account
155
+ * fails one request, while a wrongly-excluded one is invisible until someone reads the pool
156
+ * by hand.
157
+ *
158
+ * A missing reset can instead be aged by shortObservedAt (#3425). General updatedAt is not
159
+ * sufficient: credit-only updates preserve the old short tuple but advance that timestamp.
160
+ * Old disk snapshots without short-window provenance remain unknown.
161
+ */
162
+ function isTerminalShortWindow(
163
+ quota: { shortPercent?: number; shortResetAt?: number; shortObservedAt?: number },
164
+ now: number,
165
+ ): boolean {
166
+ if (typeof quota.shortPercent !== "number" || !Number.isFinite(quota.shortPercent)) return false;
167
+ if (quota.shortPercent < CODEX_EXHAUSTED_USAGE_PERCENT) return false;
168
+ const resetAt = quota.shortResetAt;
169
+ if (typeof resetAt !== "number" || !Number.isFinite(resetAt) || resetAt <= 0) {
170
+ const observedAt = quota.shortObservedAt;
171
+ if (typeof observedAt !== "number" || !Number.isFinite(observedAt)) return false;
172
+ const age = now - observedAt;
173
+ return age >= 0 && age <= TERMINAL_SHORT_WINDOW_FRESHNESS_MS;
174
+ }
175
+ // Seconds and milliseconds both reach storage, so the split lives in one place next to the
176
+ // merge that also ages a stored reset instant (`resetAtToMs`, src/codex/quota.ts).
177
+ return resetAtToMs(resetAt) > now;
178
+ }
179
+
180
+ export function classifyCodexUpstreamOutcome(
181
+ outcome: CodexUpstreamOutcome,
182
+ denial?: "workspace" | "entitlement",
183
+ ): CodexUpstreamOutcomeClass {
184
+ if (outcome === "connect_neutral") return "neutral";
185
+ if (outcome === "connect_error" || outcome === "timeout") return "transient";
186
+ if (!Number.isFinite(outcome)) return "unknown";
187
+ if (outcome >= 200 && outcome < 300) return "success";
188
+ // Explicit 3xx policy (#914): a redirect response is relayed as-is and is
189
+ // never account or host health evidence — it proves the host is reachable
190
+ // and says nothing about the credential. Relayed as the neutral class so a
191
+ // stray 3xx cannot increment an account's transient streak.
192
+ if (outcome >= 300 && outcome < 400) return "neutral";
193
+ // 401 is always a credential problem. A 403 is only a credential problem when nothing
194
+ // tells us otherwise: a workspace/entitlement denial (#1789) means the credential is valid
195
+ // and the account simply lacks access here, so quarantining it for reauth is wrong advice.
196
+ // Absent denial evidence the historical mapping stands, so the change fails safe.
197
+ if (outcome === 403 && denial !== undefined) return "workspace";
198
+ if (outcome === 401 || outcome === 403) return "credential";
199
+ // 402 Payment Required is treated as quota exhaustion for pool cooldown/failover
200
+ // (same-request alternate retry records this outcome for the depleted account).
201
+ if (outcome === 429 || outcome === 402) return "quota";
202
+ if (outcome >= 400 && outcome < 500) return "caller";
203
+ if (outcome >= 500 && outcome < 600) return "transient";
204
+ return "unknown";
205
+ }
206
+
207
+ function clampCooldownMs(ms: number): number {
208
+ return Math.min(Math.max(ms, 1), CODEX_MAX_QUOTA_COOLDOWN_MS);
209
+ }
210
+
211
+ export function parseRetryAfterMs(value: string | null | undefined, now = Date.now()): number | undefined {
212
+ const text = value?.trim();
213
+ if (!text) return undefined;
214
+ if (/^\d+(?:\.\d+)?$/.test(text)) {
215
+ const seconds = Number(text);
216
+ if (Number.isFinite(seconds) && seconds > 0) return clampCooldownMs(Math.ceil(seconds * 1000));
217
+ }
218
+ const timestamp = Date.parse(text);
219
+ if (!Number.isFinite(timestamp)) return undefined;
220
+ const delay = timestamp - now;
221
+ return delay > 0 ? clampCooldownMs(delay) : undefined;
222
+ }
223
+
224
+ function resetTimestampMs(value: unknown): number | undefined {
225
+ const numeric = typeof value === "number"
226
+ ? value
227
+ : typeof value === "string" && value.trim() !== ""
228
+ ? Number(value)
229
+ : undefined;
230
+ if (typeof numeric !== "number" || !Number.isFinite(numeric) || numeric <= 0) return undefined;
231
+ return numeric < 1_000_000_000_000 ? numeric * 1000 : numeric;
232
+ }
233
+
234
+ export function parseResetCooldownMs(resetAt: unknown | unknown[] | undefined, now = Date.now()): number | undefined {
235
+ const values = Array.isArray(resetAt) ? resetAt : [resetAt];
236
+ let best: number | undefined;
237
+ for (const value of values) {
238
+ const timestamp = resetTimestampMs(value);
239
+ if (timestamp === undefined) continue;
240
+ const delay = timestamp - now;
241
+ if (delay <= 0) continue;
242
+ // A far-future reset must not pin the account for the full Retry-After
243
+ // ceiling: quota usually frees up well before the advertised window (#433).
244
+ const clamped = Math.min(clampCooldownMs(delay), CODEX_MAX_RESET_DERIVED_COOLDOWN_MS);
245
+ if (best === undefined || clamped < best) best = clamped;
246
+ }
247
+ return best;
248
+ }
249
+
250
+ export function computeQuotaCooldown(meta: CodexUpstreamOutcomeMeta = {}): {
251
+ until: number;
252
+ source: CodexCooldownSource;
253
+ } {
254
+ const now = meta.now ?? Date.now();
255
+ const retryAfterMs = parseRetryAfterMs(meta.retryAfter, now);
256
+ if (retryAfterMs !== undefined) return { until: now + retryAfterMs, source: "retry-after" };
257
+ const resetCooldownMs = parseResetCooldownMs(meta.resetAt, now);
258
+ if (resetCooldownMs !== undefined) return { until: now + resetCooldownMs, source: "reset-derived" };
259
+ return { until: now + CODEX_DEFAULT_QUOTA_COOLDOWN_MS, source: "default" };
260
+ }
261
+
262
+ /**
263
+ * When the pool should stop preferring an account after it refused on quota.
264
+ *
265
+ * The earliest window the refusal actually announced, bounded by {@link CODEX_MAX_QUOTA_AVOID_MS},
266
+ * and never shorter than the cooldown the same refusal produced — a Retry-After directive that
267
+ * outlasts every announcement still governs.
268
+ */
269
+ export function quotaAvoidUntilFor(meta: CodexUpstreamOutcomeMeta, now: number, cooldownUntil: number): number {
270
+ const values = Array.isArray(meta.resetAt) ? meta.resetAt : [meta.resetAt];
271
+ let announced: number | undefined;
272
+ for (const value of values) {
273
+ const timestamp = resetTimestampMs(value);
274
+ if (timestamp === undefined) continue;
275
+ const delay = timestamp - now;
276
+ if (delay <= 0) continue;
277
+ const until = now + Math.min(delay, CODEX_MAX_QUOTA_AVOID_MS);
278
+ if (announced === undefined || until < announced) announced = until;
279
+ }
280
+ return Math.max(cooldownUntil, announced ?? 0);
281
+ }
282
+
283
+ export function computeQuotaCooldownUntil(meta: CodexUpstreamOutcomeMeta = {}): number {
284
+ return computeQuotaCooldown(meta).until;
285
+ }
@@ -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
+ }