@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,194 @@
1
+ import { saveConfigPreservingClaudeCode } from "../../config";
2
+ import { clearCodexAccountPin, pinnedCodexAccountId } from "../account-priority";
3
+ import {
4
+ POOL_KEY_CODEX,
5
+ normalizeCodexAccountPoolStrategy,
6
+ seedPoolRotationAccount,
7
+ } from "../pool-rotation";
8
+ import type { OcxConfig } from "../../types";
9
+ import { clearThreadAccountMap } from "./thread-affinity";
10
+ import {
11
+ NATIVE_MODEL_QUOTA_SCOPES,
12
+ codexPoolKeyForScope,
13
+ deleteAccountHealth,
14
+ deleteScopedHealth,
15
+ getAccountHealth,
16
+ isIndependentCodexQuotaScope,
17
+ listScopedHealthEntries,
18
+ preservedCooldownFields,
19
+ setAccountHealth,
20
+ setScopedHealth,
21
+ type CodexUpstreamHealth,
22
+ } from "./health-store";
23
+
24
+ /**
25
+ * Process-local cursor for automatic RR/fill-first (and quota-429 when not
26
+ * sync-writing) picks. Keeps unrelated `saveConfig` from persisting transient
27
+ * rotation as the operator's `activeCodexAccountId`. Manual selection clears it
28
+ * so disk/`config.activeCodexAccountId` remains authoritative.
29
+ */
30
+ let runtimeActiveCodexAccountId: string | undefined;
31
+
32
+ /** Manual selection resets transient routing evidence without bypassing a real 429 cooldown. */
33
+ export function resetCodexRoutingForManualSelection(accountId: string): void {
34
+ clearThreadAccountMap();
35
+ // Manual selection is the operator source of truth — drop any automatic runtime cursor.
36
+ runtimeActiveCodexAccountId = undefined;
37
+ // Record the pick as an unspent one-shot on the SHARED scope only. An independent scope
38
+ // gets no entry on purpose: every write site the guard protects is already skipped for
39
+ // independent scopes, so an entry there would be state nothing reads — and state nothing
40
+ // reads is what the next reader mistakes for a rule.
41
+ //
42
+ // Seeding happens ONLY here. A pool-driven promote must never create or move a preference,
43
+ // or the pool would manufacture an operator intent nobody expressed.
44
+ manualPreference.set(POOL_KEY_CODEX, accountId);
45
+ // Seed the RR ring so the next unbound new session honors the manually selected account
46
+ // under round-robin (affinity-cleared threads / null threadId). Fill-first already follows
47
+ // config.activeCodexAccountId, which the caller persists before invoking this.
48
+ seedPoolRotationAccount(POOL_KEY_CODEX, accountId);
49
+ for (const scope of new Set(Object.values(NATIVE_MODEL_QUOTA_SCOPES))) {
50
+ if (isIndependentCodexQuotaScope(scope)) {
51
+ seedPoolRotationAccount(codexPoolKeyForScope(scope), accountId);
52
+ }
53
+ }
54
+ // Quota avoidance is a preference, like the soft avoid dropped above, and an operator naming
55
+ // this account has overruled it. The hard cooldown is the part that survives.
56
+ const overrule = (health: CodexUpstreamHealth) => {
57
+ const { quotaAvoidUntil: _avoid, ...retained } = preservedCooldownFields(health);
58
+ return retained;
59
+ };
60
+ const current = getAccountHealth(accountId);
61
+ if (current) {
62
+ const retained = overrule(current);
63
+ if (Object.keys(retained).length === 0) deleteAccountHealth(accountId);
64
+ else setAccountHealth(accountId, { consecutiveFailures: 0, ...retained });
65
+ }
66
+ // A reset-derived refusal records its avoidance on the SCOPED map and returns before the
67
+ // account-wide entry is written, so naming the account has to reach that map too. Stopping
68
+ // at `upstreamHealth` — and returning early when it holds nothing — overruled nothing in
69
+ // the case that produces the avoidance this function exists to overrule.
70
+ for (const [scope, health] of [...(listScopedHealthEntries(accountId))]) {
71
+ const retained = overrule(health);
72
+ if (Object.keys(retained).length === 0) deleteScopedHealth(accountId, scope);
73
+ else setScopedHealth(accountId, scope, { consecutiveFailures: 0, ...retained });
74
+ }
75
+ }
76
+
77
+ /** Effective active: automatic runtime cursor, else operator/persisted selection. */
78
+ /**
79
+ * Unspent operator selections, keyed by pool scope.
80
+ *
81
+ * Codex has no account-side equivalent of the Anthropic `selectionRevision`, so staleness
82
+ * cannot be detected by comparing values: a pool-driven promote legitimately moves the
83
+ * persisted active account, and reading that as staleness would silently spend the
84
+ * operator's one-shot. Invalidation is keyed to the OPERATOR path instead — another manual
85
+ * selection, the account leaving the pool, or a successful dispatch on it.
86
+ */
87
+ const manualPreference = new Map<string, string>();
88
+
89
+ /**
90
+ * Spend the one-shot for a pool scope once a dispatch on that account actually succeeded.
91
+ * This is the Codex analogue of `commitAnthropicSelectionRouting`, which Codex lacks.
92
+ *
93
+ * Wiring this BEFORE the guard below is not a style choice. Measured: with the guard in
94
+ * place and no consume site, the first manual selection freezes the automatic cursor
95
+ * permanently and 15 of 69 rotation tests fail.
96
+ */
97
+ export function consumeManualPreference(accountId: string, poolKey: string): void {
98
+ if (manualPreference.get(poolKey) === accountId) manualPreference.delete(poolKey);
99
+ }
100
+
101
+ /**
102
+ * Drop an account's preference in every scope. Pause and exclusion do not route through
103
+ * `resetCodexRoutingForManualSelection`, so without this a preference could outlive the
104
+ * account it names and keep suppressing the automatic cursor.
105
+ */
106
+ export function forgetManualPreference(accountId: string): void {
107
+ for (const [poolKey, preferred] of manualPreference) {
108
+ if (preferred === accountId) manualPreference.delete(poolKey);
109
+ }
110
+ }
111
+
112
+ /**
113
+ * True while an unspent operator selection for this scope names a DIFFERENT account than
114
+ * the automatic pick about to be recorded.
115
+ *
116
+ * Callers pass their own scope: an independent quota scope keeps its own entry and must
117
+ * never read the shared one. The failover promote does NOT consult this — see its call
118
+ * site for why.
119
+ */
120
+ export function manualPreferenceBlocks(poolKey: string, accountId: string): boolean {
121
+ const preferred = manualPreference.get(poolKey);
122
+ return preferred !== undefined && preferred !== accountId;
123
+ }
124
+
125
+ export function getEffectiveActiveCodexAccountId(config: OcxConfig): string | undefined {
126
+ return runtimeActiveCodexAccountId ?? config.activeCodexAccountId;
127
+ }
128
+
129
+ /**
130
+ * Whether the account routing is currently on is there because an operator asked
131
+ * for it, rather than because a strategy landed on it. Surfaces read this instead
132
+ * of comparing the stored pin themselves, which would report a pin that a later
133
+ * automatic pick has already moved past.
134
+ */
135
+ export function isEffectiveCodexAccountPinned(config: OcxConfig): boolean {
136
+ const pinned = pinnedCodexAccountId(config);
137
+ return pinned !== undefined && pinned === getEffectiveActiveCodexAccountId(config);
138
+ }
139
+
140
+ /**
141
+ * Automatic strategy / failover cursor only — never mutates `config.activeCodexAccountId`
142
+ * so an unrelated `saveConfig` cannot persist transient rotation as operator selection.
143
+ */
144
+ export function rememberActiveCodexAccount(_config: OcxConfig, accountId: string): void {
145
+ runtimeActiveCodexAccountId = accountId;
146
+ }
147
+
148
+ /**
149
+ * End the manual pin when routing moves to a different account. Returns whether
150
+ * the pin changed so the caller can fold it into a write it was already making.
151
+ */
152
+ function releaseCodexAccountPinFor(config: OcxConfig, accountId: string): boolean {
153
+ const pinned = pinnedCodexAccountId(config);
154
+ if (pinned === undefined || pinned === accountId) return false;
155
+ clearCodexAccountPin(config);
156
+ return true;
157
+ }
158
+
159
+ /** Persist operator (or quota-strategy) active selection to config + disk. */
160
+ export function setActiveCodexAccount(config: OcxConfig, accountId: string): void {
161
+ runtimeActiveCodexAccountId = undefined;
162
+ const releasedPin = releaseCodexAccountPinFor(config, accountId);
163
+ if (config.activeCodexAccountId === accountId && !releasedPin) return;
164
+ config.activeCodexAccountId = accountId;
165
+ saveConfigPreservingClaudeCode(config);
166
+ }
167
+
168
+ /** Quota strategy persists; RR/fill-first keep a process-local cursor only. */
169
+ export function promoteActiveCodexAccount(config: OcxConfig, accountId: string): void {
170
+ if (normalizeCodexAccountPoolStrategy(config.accountPoolStrategy) === "quota") {
171
+ setActiveCodexAccount(config, accountId);
172
+ return;
173
+ }
174
+ // Runtime-only, like the cursor itself: a caller that persists (pause, delete)
175
+ // saves this release with its own write; a transient failover does not, so the
176
+ // pin survives a restart that also clears the failure history behind it.
177
+ releaseCodexAccountPinFor(config, accountId);
178
+ rememberActiveCodexAccount(config, accountId);
179
+ }
180
+
181
+ export function clearAllManualPreferences(): void {
182
+ manualPreference.clear();
183
+ }
184
+
185
+ export function forgetRuntimeActiveCodexAccount(): void {
186
+ runtimeActiveCodexAccountId = undefined;
187
+ }
188
+
189
+ export function forgetRoutingPreferencesOutside(codexAccountIds: ReadonlySet<string>): void {
190
+ for (const [poolKey, preferred] of manualPreference) {
191
+ if (codexAccountIds.has(preferred)) continue;
192
+ manualPreference.delete(poolKey);
193
+ }
194
+ }
@@ -0,0 +1,275 @@
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
+
9
+ export const CODEX_DEFAULT_QUOTA_COOLDOWN_MS = 60_000;
10
+ export const CODEX_MAX_QUOTA_COOLDOWN_MS = 24 * 60 * 60_000;
11
+ /**
12
+ * A weekly/monthly quota `resetAt` announces when the window refreshes; it is not
13
+ * a "come back after this" directive like Retry-After. Plan quota routinely frees
14
+ * up long before the advertised reset, so cap reset-derived cooldowns far below
15
+ * the Retry-After ceiling (#433).
16
+ */
17
+ export const CODEX_MAX_RESET_DERIVED_COOLDOWN_MS = 15 * 60_000;
18
+ /**
19
+ * Ceiling on quota-refusal avoidance. Generous enough to cover a full five-hour burst window,
20
+ * tight enough that a weekly or monthly reset four days out cannot take an account out of
21
+ * rotation for the {@link CODEX_MAX_QUOTA_COOLDOWN_MS} day the Retry-After ceiling allows.
22
+ */
23
+ export const CODEX_MAX_QUOTA_AVOID_MS = 6 * 60 * 60_000;
24
+ /** Minimum gap between probe leases for one cooled-down account. */
25
+ export const CODEX_QUOTA_PROBE_INTERVAL_MS = 5 * 60_000;
26
+ export const CODEX_FAILURE_WINDOW_MS = 5 * 60_000;
27
+ /**
28
+ * How recently a 100% burst reading must have been OBSERVED to exclude an account when it
29
+ * carries no reset timestamp (#3425). Deliberately far tighter than the 6h disk-hydration
30
+ * horizon in `quota.ts`: shorter than any plausible five-hour burst window, so a persisted
31
+ * reading can never strand a recovered account, and long enough that a snapshot taken at
32
+ * admission is still fresh when selection reads it.
33
+ */
34
+ export const TERMINAL_SHORT_WINDOW_FRESHNESS_MS = 5 * 60_000;
35
+ /** How long a transient failure keeps the account out of pool selection. */
36
+ export const CODEX_TRANSIENT_SOFT_AVOID_MS = 30_000;
37
+ export const CODEX_TRANSIENT_SOFT_AVOID_ESCALATION_MS = [
38
+ CODEX_TRANSIENT_SOFT_AVOID_MS,
39
+ 2 * 60_000,
40
+ 10 * 60_000,
41
+ 30 * 60_000,
42
+ ] as const;
43
+
44
+ export type CodexUpstreamOutcome = number | "connect_error" | "timeout" | "connect_neutral";
45
+ export type CodexUpstreamOutcomeClass = "success" | "credential"
46
+ | "workspace" | "quota" | "transient" | "caller" | "neutral" | "unknown";
47
+ export type CodexCooldownSource = "retry-after" | "reset-derived" | "default";
48
+
49
+ export type CodexUpstreamOutcomeMeta = {
50
+ retryAfter?: string | null;
51
+ resetAt?: unknown | unknown[];
52
+ now?: number;
53
+ /** (provider, host) ledger key for account-neutral reachability failures (#914). */
54
+ hostKey?: string;
55
+ /**
56
+ * Upstream denial evidence for a 403. A workspace/entitlement denial means the CREDENTIAL
57
+ * is fine and the account simply cannot reach this workspace, so it must not be quarantined
58
+ * for reauthentication (#1789). Absent evidence keeps the historical credential handling.
59
+ */
60
+ denial?: "workspace" | "entitlement";
61
+ /** Stable transport code recorded alongside a neutral host failure. */
62
+ lastFailureCode?: string;
63
+ /** Native model selected for this request; used only for confirmed scoped quotas. */
64
+ modelId?: string;
65
+ /** When set, clears affinity for this thread immediately on transient failure. */
66
+ threadId?: string | null;
67
+ /**
68
+ * Suppress Pool rotation and quota/transient affinity mutations for an account-qualified
69
+ * request. Credential failures still sweep stale affinities because reauthentication is
70
+ * account-wide.
71
+ */
72
+ fixedAccount?: boolean;
73
+ /**
74
+ * Probe lease held by this request, when it was admitted through an active
75
+ * quota cooldown. Only the outcome carrying the current lease may clear the
76
+ * cooldown (#433).
77
+ */
78
+ probeLeaseId?: string;
79
+ /** Scope of `probeLeaseId` when it was granted against a model-scoped cooldown. */
80
+ probeQuotaScope?: CodexQuotaScope;
81
+ /**
82
+ * Already-chosen alternate for same-request 429 retry. When set, promotion
83
+ * reuses this account instead of calling {@link pickAlternateCodexAccount}
84
+ * again (which would advance a round-robin ring twice).
85
+ */
86
+ promoteAccountId?: string;
87
+ /** Generation captured when this routed account was selected. */
88
+ writerGeneration?: number;
89
+ /**
90
+ * Credential generation this request's bearer was read at. Distinct from
91
+ * `writerGeneration`, which tracks the config store.
92
+ *
93
+ * A 401 that arrives after the credential was already replaced is evidence about a
94
+ * token nobody is using any more, so it must not quarantine the replacement. Absent
95
+ * means the caller cannot supply lineage and the historical unfenced handling stands.
96
+ */
97
+ credentialGeneration?: number;
98
+ };
99
+
100
+ export function computeCodexUsageScore(quota: {
101
+ weeklyPercent?: number;
102
+ monthlyPercent?: number;
103
+ shortPercent?: number;
104
+ shortResetAt?: number;
105
+ shortObservedAt?: number;
106
+ } | null, plan?: unknown, now: number = Date.now()): number {
107
+ if (!quota) return CODEX_UNKNOWN_USAGE_SCORE;
108
+ const finite = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value);
109
+ const longWindows = isThirtyDayOnlyCodexPlan(plan)
110
+ ? [quota.monthlyPercent]
111
+ : [quota.weeklyPercent, quota.monthlyPercent];
112
+ const knownLong = longWindows.filter(finite);
113
+ // The short burst window only REFINES a known long-window position; it cannot stand in for
114
+ // one. A snapshot carrying just `shortPercent: 0` would otherwise score a flat 0 and make an
115
+ // account whose weekly/monthly usage is entirely unverified look like the emptiest in the
116
+ // pool, so `pickLowestUsageAmong` would send every request to it. Unknown has to stay
117
+ // unknown until a governing window is actually observed.
118
+ //
119
+ // A FULL burst window is the exception (#3029). It is not an optimistic guess about an
120
+ // unobserved window — it is a direct observation that the account cannot serve a request
121
+ // right now, whatever its monthly position turns out to be. Unknown-means-selectable is
122
+ // correct for uncertainty and wrong for a measured refusal: the account stays selected,
123
+ // `applyQuotaAutoSwitch` never fires, and the pool wedges on an exhausted credential.
124
+ if (knownLong.length === 0) {
125
+ return isTerminalShortWindow(quota, now) ? CODEX_EXHAUSTED_USAGE_PERCENT : CODEX_UNKNOWN_USAGE_SCORE;
126
+ }
127
+ const values = finite(quota.shortPercent) ? [...knownLong, quota.shortPercent] : knownLong;
128
+ return Math.max(...values);
129
+ }
130
+
131
+ /**
132
+ * A short-only reading that proves the account is blocked NOW.
133
+ *
134
+ * Freshness is not optional. `getAccountQuota` performs no expiry check, partial updates
135
+ * carry a still-open short tuple forward, and disk hydration accepts a persisted reading for
136
+ * hours — so scoring 100 from `shortPercent` alone would keep excluding an account whose
137
+ * five-hour window has since reset. Merge no longer carries an elapsed shortResetAt, but an
138
+ * explicit incoming elapsed tuple is still stored, and a missing reset cannot be aged there.
139
+ * That is #3029 pointed the other way: the issue is that
140
+ * an exhausted account stays selected, and "a recovered account stays excluded" trades one
141
+ * unusable pool for another.
142
+ *
143
+ * A reading with no `shortResetAt` cannot be aged, so it stays unknown. The conservative
144
+ * direction here is the one that keeps an account selectable: a wrongly-selected account
145
+ * fails one request, while a wrongly-excluded one is invisible until someone reads the pool
146
+ * by hand.
147
+ *
148
+ * A missing reset can instead be aged by shortObservedAt (#3425). General updatedAt is not
149
+ * sufficient: credit-only updates preserve the old short tuple but advance that timestamp.
150
+ * Old disk snapshots without short-window provenance remain unknown.
151
+ */
152
+ function isTerminalShortWindow(
153
+ quota: { shortPercent?: number; shortResetAt?: number; shortObservedAt?: number },
154
+ now: number,
155
+ ): boolean {
156
+ if (typeof quota.shortPercent !== "number" || !Number.isFinite(quota.shortPercent)) return false;
157
+ if (quota.shortPercent < CODEX_EXHAUSTED_USAGE_PERCENT) return false;
158
+ const resetAt = quota.shortResetAt;
159
+ if (typeof resetAt !== "number" || !Number.isFinite(resetAt) || resetAt <= 0) {
160
+ const observedAt = quota.shortObservedAt;
161
+ if (typeof observedAt !== "number" || !Number.isFinite(observedAt)) return false;
162
+ const age = now - observedAt;
163
+ return age >= 0 && age <= TERMINAL_SHORT_WINDOW_FRESHNESS_MS;
164
+ }
165
+ // Seconds and milliseconds both reach storage, so the split lives in one place next to the
166
+ // merge that also ages a stored reset instant (`resetAtToMs`, src/codex/quota.ts).
167
+ return resetAtToMs(resetAt) > now;
168
+ }
169
+
170
+ export function classifyCodexUpstreamOutcome(
171
+ outcome: CodexUpstreamOutcome,
172
+ denial?: "workspace" | "entitlement",
173
+ ): CodexUpstreamOutcomeClass {
174
+ if (outcome === "connect_neutral") return "neutral";
175
+ if (outcome === "connect_error" || outcome === "timeout") return "transient";
176
+ if (!Number.isFinite(outcome)) return "unknown";
177
+ if (outcome >= 200 && outcome < 300) return "success";
178
+ // Explicit 3xx policy (#914): a redirect response is relayed as-is and is
179
+ // never account or host health evidence — it proves the host is reachable
180
+ // and says nothing about the credential. Relayed as the neutral class so a
181
+ // stray 3xx cannot increment an account's transient streak.
182
+ if (outcome >= 300 && outcome < 400) return "neutral";
183
+ // 401 is always a credential problem. A 403 is only a credential problem when nothing
184
+ // tells us otherwise: a workspace/entitlement denial (#1789) means the credential is valid
185
+ // and the account simply lacks access here, so quarantining it for reauth is wrong advice.
186
+ // Absent denial evidence the historical mapping stands, so the change fails safe.
187
+ if (outcome === 403 && denial !== undefined) return "workspace";
188
+ if (outcome === 401 || outcome === 403) return "credential";
189
+ // 402 Payment Required is treated as quota exhaustion for pool cooldown/failover
190
+ // (same-request alternate retry records this outcome for the depleted account).
191
+ if (outcome === 429 || outcome === 402) return "quota";
192
+ if (outcome >= 400 && outcome < 500) return "caller";
193
+ if (outcome >= 500 && outcome < 600) return "transient";
194
+ return "unknown";
195
+ }
196
+
197
+ function clampCooldownMs(ms: number): number {
198
+ return Math.min(Math.max(ms, 1), CODEX_MAX_QUOTA_COOLDOWN_MS);
199
+ }
200
+
201
+ export function parseRetryAfterMs(value: string | null | undefined, now = Date.now()): number | undefined {
202
+ const text = value?.trim();
203
+ if (!text) return undefined;
204
+ if (/^\d+(?:\.\d+)?$/.test(text)) {
205
+ const seconds = Number(text);
206
+ if (Number.isFinite(seconds) && seconds > 0) return clampCooldownMs(Math.ceil(seconds * 1000));
207
+ }
208
+ const timestamp = Date.parse(text);
209
+ if (!Number.isFinite(timestamp)) return undefined;
210
+ const delay = timestamp - now;
211
+ return delay > 0 ? clampCooldownMs(delay) : undefined;
212
+ }
213
+
214
+ function resetTimestampMs(value: unknown): number | undefined {
215
+ const numeric = typeof value === "number"
216
+ ? value
217
+ : typeof value === "string" && value.trim() !== ""
218
+ ? Number(value)
219
+ : undefined;
220
+ if (typeof numeric !== "number" || !Number.isFinite(numeric) || numeric <= 0) return undefined;
221
+ return numeric < 1_000_000_000_000 ? numeric * 1000 : numeric;
222
+ }
223
+
224
+ export function parseResetCooldownMs(resetAt: unknown | unknown[] | undefined, now = Date.now()): number | undefined {
225
+ const values = Array.isArray(resetAt) ? resetAt : [resetAt];
226
+ let best: number | undefined;
227
+ for (const value of values) {
228
+ const timestamp = resetTimestampMs(value);
229
+ if (timestamp === undefined) continue;
230
+ const delay = timestamp - now;
231
+ if (delay <= 0) continue;
232
+ // A far-future reset must not pin the account for the full Retry-After
233
+ // ceiling: quota usually frees up well before the advertised window (#433).
234
+ const clamped = Math.min(clampCooldownMs(delay), CODEX_MAX_RESET_DERIVED_COOLDOWN_MS);
235
+ if (best === undefined || clamped < best) best = clamped;
236
+ }
237
+ return best;
238
+ }
239
+
240
+ export function computeQuotaCooldown(meta: CodexUpstreamOutcomeMeta = {}): {
241
+ until: number;
242
+ source: CodexCooldownSource;
243
+ } {
244
+ const now = meta.now ?? Date.now();
245
+ const retryAfterMs = parseRetryAfterMs(meta.retryAfter, now);
246
+ if (retryAfterMs !== undefined) return { until: now + retryAfterMs, source: "retry-after" };
247
+ const resetCooldownMs = parseResetCooldownMs(meta.resetAt, now);
248
+ if (resetCooldownMs !== undefined) return { until: now + resetCooldownMs, source: "reset-derived" };
249
+ return { until: now + CODEX_DEFAULT_QUOTA_COOLDOWN_MS, source: "default" };
250
+ }
251
+
252
+ /**
253
+ * When the pool should stop preferring an account after it refused on quota.
254
+ *
255
+ * The earliest window the refusal actually announced, bounded by {@link CODEX_MAX_QUOTA_AVOID_MS},
256
+ * and never shorter than the cooldown the same refusal produced — a Retry-After directive that
257
+ * outlasts every announcement still governs.
258
+ */
259
+ export function quotaAvoidUntilFor(meta: CodexUpstreamOutcomeMeta, now: number, cooldownUntil: number): number {
260
+ const values = Array.isArray(meta.resetAt) ? meta.resetAt : [meta.resetAt];
261
+ let announced: number | undefined;
262
+ for (const value of values) {
263
+ const timestamp = resetTimestampMs(value);
264
+ if (timestamp === undefined) continue;
265
+ const delay = timestamp - now;
266
+ if (delay <= 0) continue;
267
+ const until = now + Math.min(delay, CODEX_MAX_QUOTA_AVOID_MS);
268
+ if (announced === undefined || until < announced) announced = until;
269
+ }
270
+ return Math.max(cooldownUntil, announced ?? 0);
271
+ }
272
+
273
+ export function computeQuotaCooldownUntil(meta: CodexUpstreamOutcomeMeta = {}): number {
274
+ return computeQuotaCooldown(meta).until;
275
+ }