@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
@@ -1,5 +1,5 @@
1
1
  import type { PoolQuotaWriter } from "./quota-types";
2
- import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
2
+ import { createHash, timingSafeEqual } from "node:crypto";
3
3
  import {
4
4
  CodexCredentialGenerationConflictError,
5
5
  CodexCredentialRefreshLockTimeoutError,
@@ -39,8 +39,20 @@ import {
39
39
  pickAlternateCodexAccount,
40
40
  resolveCodexAccountForThreadDetailed,
41
41
  type CodexAffinityDecision,
42
+ type CodexThreadResolution,
43
+ type TransientProbeGrant,
42
44
  } from "./routing";
45
+ // The half-open TRANSIENT-HOLD lease (#4701). Not the quota-cooldown probe lease imported from
46
+ // ./routing above -- different module, different domain, and a request never holds both.
47
+ import { releaseTransientProbe } from "../routing/probe-lease";
43
48
  import {
49
+ codexConversationIdentity,
50
+ recordCodexThreadLineage,
51
+ resolveCodexThreadLineage,
52
+ type CodexThreadLineage,
53
+ } from "./lineage";
54
+ import {
55
+ cachedDeniedCodexAccountIdsForModel,
44
56
  entitledCodexAccountIdsForModel,
45
57
  isDirectCallerEntitledToCodexModel,
46
58
  resolveCodexModelEntitlements,
@@ -53,7 +65,6 @@ import { CODEX_UNKNOWN_USAGE_SCORE, getAccountQuota, parseUsageQuota, parseMainP
53
65
  import type { CodexAccountMode, OcxConfig, OcxProviderConfig } from "../types";
54
66
  import { FORWARD_HEADERS } from "../adapters/openai-responses";
55
67
  import { captureConfigGeneration } from "../lib/state-store-sweeper";
56
- import { retainedUtf8Bytes } from "../lib/admission";
57
68
  import { extractAccountId, extractEmail } from "../oauth/chatgpt";
58
69
  import { getMainAccountHardLockStatus, isMainAccountHardLocked } from "./main-account-hard-lock";
59
70
  import {
@@ -70,9 +81,6 @@ import type { DataPlaneAdmission } from "../server/auth-cors";
70
81
  import { getMainReserveAuthorization, isMainReserveAuthorizationLive, nativeUserIdClaims, type MainReserveAuthorization } from "./reserve-availability";
71
82
  import { UpstreamRetryEvidenceError } from "../lib/upstream-retry";
72
83
 
73
- const CODEX_AFFINITY_COMPONENT_MAX_BYTES = 512;
74
- const CODEX_APP_AFFINITY_KEY = randomBytes(32);
75
-
76
84
  /**
77
85
  * A request-owned bearer cannot inspect the physical main credential for its plan, but cached
78
86
  * WHAM usage is still valid routing evidence for the same logical main account. Score it with
@@ -88,32 +96,90 @@ function requestOwnedMainPinHasQuotaHeadroom(config: OcxConfig): boolean {
88
96
  return usage >= CODEX_UNKNOWN_USAGE_SCORE || usage < threshold;
89
97
  }
90
98
 
91
- function boundedCodexAffinityComponent(value: string | null): string | undefined {
92
- const normalized = value?.trim();
93
- if (!normalized) return undefined;
94
- if (retainedUtf8Bytes(normalized) > CODEX_AFFINITY_COMPONENT_MAX_BYTES) return undefined;
95
- return normalized;
99
+ /**
100
+ * Every thread keys as ITSELF, never as its parent (#4546, wp8).
101
+ *
102
+ * The old rule preferred `x-codex-parent-thread-id`, so every child of one parent bound under
103
+ * the RAW parent id -- one shared entry, unrelated to the root's own `app:HMAC(session, thread)`
104
+ * binding -- and a grandchild keyed on its own parent landed on a key nobody had ever bound.
105
+ * A child therefore started cold while its parent was being served warm somewhere, and no
106
+ * child could hold a binding of its own.
107
+ *
108
+ * Now a request with a `thread-id` keys as HMAC(session ?? parent, thread). A root is
109
+ * unchanged, a child gets an independent key, and a request naming only a parent rides the
110
+ * parent's lane under HMAC(parent, parent) -- the same one-to-one lane it always had, minus
111
+ * the caller-supplied identifier that used to sit in Pool state. Which requests produce no
112
+ * key at all is unchanged. First placement for a child is what consults the family, through
113
+ * `recordCodexThreadLineage` below and the placement hook in ./routing.
114
+ *
115
+ * The derivation itself lives in ./lineage so a lineage record's conversation key and the key
116
+ * the thread actually binds under can never drift apart.
117
+ */
118
+ export function codexPoolAffinityKey(headers: Headers, now = Date.now()): string | undefined {
119
+ // `now` is threaded rather than read inside because a parent-only turn resolves its key
120
+ // through the recorded lineage, and that record is TTL-bounded: a caller working against a
121
+ // fixed clock would otherwise see a live record as expired and fall back to a key the parent
122
+ // never bound under.
123
+ return codexConversationIdentity(headers, now)?.conversationKey;
124
+ }
125
+
126
+ /** What a caller needs to know to answer the Pool-state question below before auth has run. */
127
+ export interface CodexPoolStateEligibility {
128
+ /** An exact account selector from the route, i.e. `options.accountId` here. */
129
+ readonly accountId?: string;
130
+ readonly modelId?: string;
131
+ readonly admission?: Pick<DataPlaneAdmission, "source">;
132
+ /** The caller presented its own forwardable ChatGPT credential for this route. */
133
+ readonly requestScopedMainCredential?: boolean;
134
+ }
135
+
136
+ /** The one expression both the resolution below and any preview must agree on. */
137
+ function poolStateEligible(
138
+ fixedAccountId: string | undefined,
139
+ requestScopedMainCredential: boolean,
140
+ ): boolean {
141
+ return fixedAccountId === undefined && !requestScopedMainCredential;
96
142
  }
97
143
 
98
144
  /**
99
- * Preserve Codex's parent-thread affinity when present. Desktop App requests can omit that
100
- * header while retaining a stable session/thread pair, so derive an opaque process-local key
101
- * only from the complete bounded pair. Raw identifiers and durable hashes never enter Pool state.
145
+ * May this request own Pool affinity state at all?
146
+ *
147
+ * Two credentials authenticate outside the Pool: an exact account selector (including the
148
+ * Reserve pin) and a request-owned main bearer, which exists for one request and must never
149
+ * fold into durable account state. Neither may read or write a binding, so neither may read
150
+ * or write LINEAGE either.
151
+ *
152
+ * Exported so that a preview asks the question with the code that answers it, instead of a
153
+ * restatement that can drift. It drifted once already: preview read a family relation from raw
154
+ * request headers before this function had decided anything, so it could follow a Pool family
155
+ * binding while the resolution below deliberately created no affinity -- and model fallback then
156
+ * evaluated eligibility against an account the request would never be authenticated as.
157
+ */
158
+ export function codexPoolStateEligible(
159
+ headers: Headers,
160
+ policy: CodexAuthPolicyConfig | undefined,
161
+ options: CodexPoolStateEligibility = {},
162
+ ): boolean {
163
+ const reserve = requiresReserveAuthorization(policy, options.modelId, options.admission);
164
+ return poolStateEligible(
165
+ reserve ? MAIN_CODEX_ACCOUNT_ID : options.accountId,
166
+ options.requestScopedMainCredential === true && hasCallerCodexBearer(headers),
167
+ );
168
+ }
169
+
170
+ /**
171
+ * The lineage a PREVIEW is allowed to see: read-only, and only for a request that may hold Pool
172
+ * state. Recording is left to the resolution that actually binds, so a preview can never leave a
173
+ * record behind for a request that turned out to own no Pool state at all.
102
174
  */
103
- export function codexPoolAffinityKey(headers: Headers): string | undefined {
104
- const parentThreadId = boundedCodexAffinityComponent(headers.get("x-codex-parent-thread-id"));
105
- if (parentThreadId) return parentThreadId;
106
-
107
- const sessionId = boundedCodexAffinityComponent(headers.get("session-id"));
108
- const threadId = boundedCodexAffinityComponent(headers.get("thread-id"));
109
- if (!sessionId || !threadId) return undefined;
110
-
111
- return `app:${createHmac("sha256", CODEX_APP_AFFINITY_KEY)
112
- .update("opencodex-app-pool-affinity-v1\0")
113
- .update(sessionId)
114
- .update("\0")
115
- .update(threadId)
116
- .digest("base64url")}`;
175
+ export function previewCodexPoolLineage(
176
+ headers: Headers,
177
+ policy: CodexAuthPolicyConfig | undefined,
178
+ options: CodexPoolStateEligibility = {},
179
+ ): CodexThreadLineage | undefined {
180
+ return codexPoolStateEligible(headers, policy, options)
181
+ ? resolveCodexThreadLineage(headers)
182
+ : undefined;
117
183
  }
118
184
 
119
185
  export type CodexAuthContext =
@@ -142,6 +208,12 @@ export type CodexAuthContext =
142
208
  affinityDecision?: CodexAffinityDecision;
143
209
  /** Scope that owns `probeLeaseId`, when it is a scoped recovery probe. */
144
210
  probeQuotaScope?: CodexQuotaScope;
211
+ /**
212
+ * Set when this request is the ONE dispatch admitted to test an account held under a
213
+ * transient 5xx hold (#4701). Echo it into the upstream outcome so the trial is settled
214
+ * by the request that ran it, and release it on any path that never reaches upstream.
215
+ */
216
+ transientProbe?: TransientProbeGrant;
145
217
  }
146
218
  | {
147
219
  // Main Codex account participating in rotation: token injected from ~/.codex/auth.json
@@ -162,6 +234,8 @@ export type CodexAuthContext =
162
234
  probeLeaseId?: string;
163
235
  quotaScope?: CodexQuotaScope;
164
236
  probeQuotaScope?: CodexQuotaScope;
237
+ /** See `pool.transientProbe`. */
238
+ transientProbe?: TransientProbeGrant;
165
239
  };
166
240
 
167
241
  /** Probe lease carried by this context, when it holds one. */
@@ -174,11 +248,24 @@ export function codexProbeQuotaScope(ctx: CodexAuthContext | undefined): CodexQu
174
248
  return ctx?.kind === "pool" || ctx?.kind === "main-pool" ? ctx.probeQuotaScope : undefined;
175
249
  }
176
250
 
251
+ /** The transient-hold recovery probe carried by this context, when it holds one (#4701). */
252
+ export function codexTransientProbeGrant(ctx: CodexAuthContext | undefined): TransientProbeGrant | undefined {
253
+ return ctx?.kind === "pool" || ctx?.kind === "main-pool" ? ctx.transientProbe : undefined;
254
+ }
255
+
177
256
  /**
178
257
  * Hand back a probe lease for a request that will not reach upstream. Safe to
179
258
  * call with a context that holds no lease.
259
+ *
260
+ * BOTH leases, deliberately. A context can carry the quota-cooldown probe or the transient-hold
261
+ * probe, and every one of the ~30 call sites that already hands back the first is a path where
262
+ * the second would leak too. Releasing them together is what makes those sites correct for the
263
+ * new lease without re-deriving the discard set by hand -- the failure mode being avoided is a
264
+ * held account nobody may probe because the request that held the trial went away quietly.
180
265
  */
181
266
  export function releaseCodexAuthContextProbeLease(ctx: CodexAuthContext | undefined): void {
267
+ const transientProbe = codexTransientProbeGrant(ctx);
268
+ if (transientProbe) releaseTransientProbe(transientProbe.lease);
182
269
  const leaseId = codexProbeLeaseId(ctx);
183
270
  if (!ctx || ctx.kind === "main" || !leaseId) return;
184
271
  if (ctx.probeQuotaScope) releaseCodexQuotaScopeProbeLease(ctx.accountId!, ctx.probeQuotaScope, leaseId);
@@ -364,6 +451,44 @@ export class CodexReserveHelperUnsupportedError extends CodexReserveUnavailableE
364
451
  }
365
452
  }
366
453
 
454
+ /**
455
+ * Every account bound to this conversation is held after upstream failures, the recovery
456
+ * budget for this window is spent, and there is no detour left -- so this request is refused
457
+ * BEFORE any upstream I/O (#4701).
458
+ *
459
+ * This is not a quota cooldown, and the message below says so. It subclasses
460
+ * {@link CodexAccountCooldownError} for one reason: the deadline-carrying refusal has exactly
461
+ * one representation in this codebase, and roughly a dozen transports already map it to a 429
462
+ * with `Retry-After` and treat it as an expected terminal answer rather than a credential
463
+ * fault. Introducing a parallel type would mean either re-deriving that handling in every one
464
+ * of them or silently falling through to a 500 in the ones that were missed.
465
+ *
466
+ * What must NOT be inherited is the quota wording -- "cooling down", `ocx account
467
+ * clear-cooldown` -- because none of it describes a 5xx hold and following it would do
468
+ * nothing. {@link cooldownErrorMessage} therefore returns this class's own message verbatim,
469
+ * the same escape hatch {@link CodexMainAccountHardLockError} and
470
+ * {@link CodexReserveUnavailableError} already use.
471
+ *
472
+ * `cooldownUntil` carries the limiter's own change point, which is strictly in the future:
473
+ * either the moment the held account may next be probed or the moment the recovery window
474
+ * moves, whichever is later. A refusal that answered `now` would busy-loop the caller into
475
+ * the same load it just declined.
476
+ */
477
+ export class CodexRecoveryWithheldError extends CodexAccountCooldownError {
478
+ /** The sibling still remembered for this thread, when one exists but is itself unusable. */
479
+ readonly detourAccountId?: string;
480
+
481
+ constructor(accountId: string, retryAt: number, detourAccountId?: string) {
482
+ super(accountId, retryAt);
483
+ this.name = "CodexRecoveryWithheldError";
484
+ this.detourAccountId = detourAccountId;
485
+ this.message = `Codex account (${cooldownAccountLabel(accountId)}) is held after repeated upstream`
486
+ + ` failures and the pool's recovery budget for this window is spent, so nothing was sent`
487
+ + ` upstream. Retry after ${new Date(retryAt).toISOString()}.`
488
+ + " This clears on its own as the account recovers; no cooldown to lift and no account to switch.";
489
+ }
490
+ }
491
+
367
492
  export type CodexAuthPolicyConfig = Readonly<Pick<OcxConfig,
368
493
  "codexMainAccountHardLock" | "codexDesktopAuthless" | "runtimeRole" | "pausedCodexAccountIds"
369
494
  >>;
@@ -574,7 +699,12 @@ export function cooldownAccountLabel(accountId: string): string {
574
699
  * injected `openai_base_url` in config.toml.
575
700
  */
576
701
  export function cooldownErrorMessage(err: CodexAccountCooldownError, accountSelector?: string): string {
577
- if (err instanceof CodexMainAccountHardLockError || err instanceof CodexReserveUnavailableError) return err.message;
702
+ if (err instanceof CodexMainAccountHardLockError
703
+ || err instanceof CodexReserveUnavailableError
704
+ // A transient-hold refusal is not a quota cooldown. Its own wording is the only accurate
705
+ // one, and the quota recovery advice below would send the operator after a cooldown that
706
+ // does not exist (#4701).
707
+ || err instanceof CodexRecoveryWithheldError) return err.message;
578
708
  const until = new Date(err.cooldownUntil).toISOString();
579
709
  const scopeLabels: Record<CodexQuotaScope, string> = {
580
710
  shared: "shared native quota", reserve: "Reserve quota",
@@ -657,6 +787,11 @@ export interface ResolveCodexAuthContextOptions {
657
787
  requestScopedMainCredential?: boolean;
658
788
  /** Test seam for a Direct request's own forwarded ChatGPT credential. */
659
789
  isDirectCallerEntitledToCodexModel?: (headers: Headers, modelId: string) => Promise<boolean>;
790
+ /**
791
+ * This request's conversation carries live uploaded-file references (#4778). Retains the bound
792
+ * account across a VOLUNTARY quota move; involuntary release is untouched.
793
+ */
794
+ retainAccountForUploadedFiles?: boolean;
660
795
  }
661
796
 
662
797
  export interface CodexAccountSelectionAdmission {
@@ -798,12 +933,27 @@ export async function resolveCodexAuthContext(
798
933
  // A caller bearer can still accompany a request that selects a configured Pool account. Do not
799
934
  // let that request read, delete, or create a file-main affinity binding while deciding whether a
800
935
  // stored account is available; only the stored credential selected below may own Pool state.
801
- const affinityKey = fixedAccountId === undefined && !requestScopedMainCredential
936
+ const affinityKey = poolStateEligible(fixedAccountId, requestScopedMainCredential)
802
937
  ? codexPoolAffinityKey(headers)
803
938
  : undefined;
939
+ // The thread's family relation, recorded under the same condition as the key itself. A
940
+ // first-placing child consults it; a request-owned or fixed credential never enters Pool
941
+ // state, so it never enters lineage either.
942
+ const lineage = affinityKey !== undefined
943
+ ? recordCodexThreadLineage(headers)
944
+ : undefined;
804
945
  // Why this request is on this account, carried to the request log so a move reads as an event
805
946
  // instead of something inferred from account labels across lines (#4546).
806
947
  let affinityDecision: CodexAffinityDecision | undefined;
948
+ // The half-open trial this request was granted, if it is the one allowed to test a held
949
+ // account. Declared out here because the release paths below and the returned context are on
950
+ // opposite sides of several throws (#4701).
951
+ let transientProbe: TransientProbeGrant | undefined;
952
+ const releaseTransientProbeGrant = (): void => {
953
+ if (!transientProbe) return;
954
+ releaseTransientProbe(transientProbe.lease);
955
+ transientProbe = undefined;
956
+ };
807
957
  // Retained startup recovery makes the physical main identity ineligible. Routing
808
958
  // can still preserve service by selecting a healthy configured pool account. A
809
959
  // request-owned bearer likewise cannot inspect or reconcile file-main state.
@@ -834,6 +984,23 @@ export async function resolveCodexAuthContext(
834
984
  const modelEligibleAccountIds = entitledAccountIds
835
985
  ? new Set([...entitledAccountIds].filter(candidate => !excludeAccountIds?.has(candidate)))
836
986
  : undefined;
987
+ // #4768: the flagships stay visible and never fail closed, so this is evidence routing may
988
+ // ORDER by, not evidence it may refuse on. Read synchronously from rosters discovery has
989
+ // already gathered -- no upstream fetch joins the request path for the most commonly
990
+ // requested models in the product -- and passed to selection as a preference that is dropped
991
+ // whenever honouring it would leave no candidate.
992
+ //
993
+ // Under the SAME exclusion the entitlement snapshot above uses. The reader validates each
994
+ // cached roster against the account's current credential, and for native main that is a
995
+ // synchronous read of the physical stored token -- exactly what this request is forbidden to
996
+ // touch while a profile switch drains it or while it is served by a request-owned credential.
997
+ // Excluding main here costs nothing: the preference is an ordering hint, so main becomes
998
+ // unknown rather than denied, and unknown leaves selection exactly as it was.
999
+ const deniedModelAccountIds = cachedDeniedCodexAccountIdsForModel(
1000
+ options.modelId,
1001
+ undefined,
1002
+ { excludeAccountIds },
1003
+ );
837
1004
  const selectionOptions = {
838
1005
  // Temporary switch drain keeps the candidate until the atomic claim rejects
839
1006
  // it. Retained recovery makes main wholly ineligible so pool routing continues.
@@ -845,13 +1012,21 @@ export async function resolveCodexAuthContext(
845
1012
  ? () => preserveRequestOwnedMainPin
846
1013
  : options.isMainAccountTokenLive,
847
1014
  modelEligibleAccountIds,
1015
+ deniedModelAccountIds,
1016
+ // Request-scoped and deliberately absent from `sharedStateSelectionOptions`: one
1017
+ // conversation's attachments say nothing about where unrelated threads should be served.
1018
+ retainAccountForUploadedFiles: options.retainAccountForUploadedFiles === true,
848
1019
  };
849
1020
  // A pre-drain selector reserves the native identity while reconciliation and
850
1021
  // routing inspect it. Selectors arriving after the fence skip reconciliation
851
1022
  // and may still route to non-main pool accounts without touching switch state.
852
1023
  if (reserve && !nativeMainReadsForbidden && !selectionAdmission) throw new CodexMainProfileDrainingError();
853
1024
  if (!nativeMainReadsForbidden) reconcileMainCodexAccountRuntimeState();
854
- const resolution = fixedAccountId !== undefined
1025
+ // Annotated, not inferred. The two literals below carry neither `affinity` nor
1026
+ // `transientProbe`, so an inferred union makes `"k" in resolution` widen those reads to
1027
+ // `unknown` and a discriminant narrowing fail outright. Contextually typing every branch to
1028
+ // the resolver's own union is what lets the reads below stay total.
1029
+ const resolution: CodexThreadResolution = fixedAccountId !== undefined
855
1030
  ? { status: "selected" as const, accountId: fixedAccountId }
856
1031
  : options.excludeAccountId
857
1032
  ? (() => {
@@ -873,10 +1048,20 @@ export async function resolveCodexAuthContext(
873
1048
  quotaScope,
874
1049
  selectionOptions,
875
1050
  options.modelId,
1051
+ lineage,
876
1052
  );
877
1053
  if (resolution.status === "expired") throw new CodexThreadAffinityExpiredError(resolution.accountId);
1054
+ // THE REFUSAL. Every candidate is held, the recovery budget is spent, and no detour is
1055
+ // left -- so this request must not reach upstream at all. Returning the held account here
1056
+ // is what #4701 is about: under a provider-wide 503 that is every bound request piling
1057
+ // onto an account already known to be failing. Thrown before any credential is read, so
1058
+ // nothing is sent and nothing is spent.
1059
+ if (resolution.status === "withheld") {
1060
+ throw new CodexRecoveryWithheldError(resolution.accountId, resolution.retryAt, resolution.detourAccountId);
1061
+ }
878
1062
  const selected = resolution.status === "selected" ? resolution.accountId : null;
879
- affinityDecision = "affinity" in resolution ? resolution.affinity : undefined;
1063
+ affinityDecision = resolution.affinity;
1064
+ transientProbe = resolution.status === "selected" ? resolution.transientProbe : undefined;
880
1065
  if (!selected) {
881
1066
  // A retry that excluded a failed Pool account may still use the validated caller-owned
882
1067
  // main credential. Treating every exclusion as if main itself had failed strands a healthy
@@ -958,12 +1143,25 @@ export async function resolveCodexAuthContext(
958
1143
  throw new CodexPoolAuthenticationError("Selected Codex account is unavailable");
959
1144
  }
960
1145
  }
1146
+ } catch (cause) {
1147
+ // Selection granted a trial and then a later policy check refused the account. The trial
1148
+ // never runs, so hand it back instead of leaving the held account unprobeable until its
1149
+ // deadline lapses (#4701).
1150
+ releaseTransientProbeGrant();
1151
+ throw cause;
961
1152
  } finally {
962
1153
  selectionAdmission?.release();
963
1154
  }
964
1155
  // Legacy selectors may retain an unusable account for actionable errors. A
965
1156
  // deferred credential must never become request auth through that fallback.
966
- assertCodexAccountValidationReady(accountId);
1157
+ try {
1158
+ assertCodexAccountValidationReady(accountId);
1159
+ } catch (cause) {
1160
+ // Nothing will reach upstream, so give the trial back instead of leaving the held account
1161
+ // unprobeable until the lease deadline lapses (#4701).
1162
+ releaseTransientProbeGrant();
1163
+ throw cause;
1164
+ }
967
1165
  // Lazy prime: if the selected account has no quota yet, the pool is likely
968
1166
  // unprimed (dashboard never opened, or startup prime was blocked). Kick a
969
1167
  // best-effort prime so the NEXT routing decision has real scores. This never
@@ -982,6 +1180,13 @@ export async function resolveCodexAuthContext(
982
1180
  // a literal Retry-After reads very differently to a user than a reset-derived guess.
983
1181
  const cooldown = getCodexQuotaHealthSnapshot(accountId, quotaScope);
984
1182
  const cooldownUntil = cooldown?.cooldownUntil;
1183
+ // A transient-hold trial and a quota cooldown cannot both describe this account:
1184
+ // `isTransientOnlyAffinityBlock` refuses to recognise a transient hold on an account carrying
1185
+ // quota health, so the cooldown branch below is unreachable while a trial is held. That is
1186
+ // also why no request pays two recovery permits for one send. The release is defensive --
1187
+ // should that invariant ever move, the trial is handed back rather than stranded behind a
1188
+ // refusal that belongs to the other domain.
1189
+ if (cooldownUntil && transientProbe) releaseTransientProbeGrant();
985
1190
  // A cooled-down account never sends traffic, so upstream recovery can never be
986
1191
  // observed and the cooldown outlives the real limit. Admit one probe per
987
1192
  // interval; its outcome decides whether the cooldown ends (#433).
@@ -1022,6 +1227,7 @@ export async function resolveCodexAuthContext(
1022
1227
  if (token) mainQuotaWriter = observeSelectedMainCredential(token, mainQuotaWriter);
1023
1228
  assertMainAccountPolicy(policy);
1024
1229
  } catch (cause) {
1230
+ releaseTransientProbeGrant();
1025
1231
  if (probeLeaseId && probeQuotaScope) releaseCodexQuotaScopeProbeLease(accountId, probeQuotaScope, probeLeaseId);
1026
1232
  else if (probeLeaseId) releaseCodexQuotaProbeLease(accountId, probeLeaseId);
1027
1233
  if (cause instanceof CodexMainAccountHardLockError) throw cause;
@@ -1032,15 +1238,23 @@ export async function resolveCodexAuthContext(
1032
1238
  }
1033
1239
  if (!token) {
1034
1240
  // Nothing will reach upstream, so give the probe back instead of burning it.
1241
+ releaseTransientProbeGrant();
1035
1242
  if (probeLeaseId && probeQuotaScope) releaseCodexQuotaScopeProbeLease(accountId, probeQuotaScope, probeLeaseId);
1036
1243
  else if (probeLeaseId) releaseCodexQuotaProbeLease(accountId, probeLeaseId);
1037
1244
  throw new CodexPoolAuthenticationError(
1038
1245
  fixedAccountId !== undefined ? "Selected Codex account is unavailable" : undefined,
1039
1246
  );
1040
1247
  }
1041
- const reserveAuthorization = reserve
1042
- ? await authorizeReserveCredential(token, mainQuotaWriter, policy, options.signal, undefined, writerGeneration)
1043
- : undefined;
1248
+ let reserveAuthorization: MainReserveAuthorization | undefined;
1249
+ try {
1250
+ reserveAuthorization = reserve
1251
+ ? await authorizeReserveCredential(token, mainQuotaWriter, policy, options.signal, undefined, writerGeneration)
1252
+ : undefined;
1253
+ } catch (cause) {
1254
+ // A Reserve refusal ends the request here, so the trial it was holding never runs.
1255
+ releaseTransientProbeGrant();
1256
+ throw cause;
1257
+ }
1044
1258
  return {
1045
1259
  kind: "main-pool",
1046
1260
  accountId,
@@ -1054,6 +1268,7 @@ export async function resolveCodexAuthContext(
1054
1268
  ...(quotaScope ? { quotaScope } : {}),
1055
1269
  ...(probeLeaseId ? { probeLeaseId } : {}),
1056
1270
  ...(probeQuotaScope ? { probeQuotaScope } : {}),
1271
+ ...(transientProbe ? { transientProbe } : {}),
1057
1272
  };
1058
1273
  }
1059
1274
 
@@ -1074,8 +1289,10 @@ export async function resolveCodexAuthContext(
1074
1289
  ...(probeLeaseId ? { probeLeaseId } : {}),
1075
1290
  ...(probeQuotaScope ? { probeQuotaScope } : {}),
1076
1291
  ...(affinityDecision ? { affinityDecision } : {}),
1292
+ ...(transientProbe ? { transientProbe } : {}),
1077
1293
  };
1078
1294
  } catch (cause) {
1295
+ releaseTransientProbeGrant();
1079
1296
  if (probeLeaseId && probeQuotaScope) releaseCodexQuotaScopeProbeLease(accountId, probeQuotaScope, probeLeaseId);
1080
1297
  else if (probeLeaseId) releaseCodexQuotaProbeLease(accountId, probeLeaseId);
1081
1298
  if (!options.signal?.aborted && shouldMarkAccountNeedsReauthForCodexAuthFailure(cause)) {
@@ -34,7 +34,7 @@ import upstreamModelsSnapshot from "../data/upstream-models.json";
34
34
 
35
35
 
36
36
  import { catalogModelSlug } from "./parsing";
37
- import type { CatalogModel } from "./parsing";
37
+ import type { CatalogModel, RawEntry } from "./parsing";
38
38
 
39
39
  export const openAiApiCollisionWarnings = new Set<string>();
40
40
 
@@ -222,6 +222,85 @@ export function safeCatalogWarningLabel(value: string): string {
222
222
  .slice(0, 200);
223
223
  }
224
224
 
225
+ /**
226
+ * Keep the first row of each slug and drop the rest (#4730).
227
+ *
228
+ * First-win is the only answer that agrees with the ordering already decided upstream: the merge
229
+ * ranks rows, so its first occurrence is the row it chose. Distinct slugs are never touched — an
230
+ * alias row and the canonical routed row of the same provider model are two different public names
231
+ * and both survive — and a row without a string slug passes through untouched.
232
+ */
233
+ export function dedupeCatalogEntriesBySlug(models: RawEntry[]): RawEntry[] {
234
+ const seen = new Set<string>();
235
+ const out: RawEntry[] = [];
236
+ for (const entry of models) {
237
+ if (typeof entry.slug !== "string") {
238
+ out.push(entry);
239
+ continue;
240
+ }
241
+ if (seen.has(entry.slug)) continue;
242
+ seen.add(entry.slug);
243
+ out.push(entry);
244
+ }
245
+ return out;
246
+ }
247
+
248
+ /**
249
+ * Every slug this proxy writes into the Codex catalog must appear exactly once (#4730).
250
+ *
251
+ * The cost of breaking it is the whole file: a slug-unique validating consumer refuses the catalog
252
+ * outright, so one duplicated row takes every model with it. A 2.56.0 report carried 507 rows for
253
+ * 72 unique slugs, every duplicate byte-identical, and Codex rejected the file as `source-invalid`.
254
+ *
255
+ * This is a write-boundary invariant rather than a repair of one producer, and that distinction is
256
+ * deliberate: the reported catalog is evidence that some emit path can double a row, but nothing in
257
+ * this tree has been shown to be that path, and a guard that only covered the producer someone
258
+ * guessed at would leave the file corruptible by the next one. Both writers that serialize a merged
259
+ * catalog call this as their LAST mutation — `writeRetainedCatalogSync` and the management
260
+ * convergence commit — so uniqueness holds for the exact bytes that land on disk.
261
+ *
262
+ * Ordering is load-bearing. Running the guard before the effort clamp would be unsound:
263
+ * `clampCatalogModelsToObservedCodexSupport` splices whole rows out when an exact-reserve ladder
264
+ * clamps empty, so dropping a later same-slug row first can leave the slug with no row at all once
265
+ * the surviving one is spliced.
266
+ *
267
+ * @param models - The finished row list, already clamped and finalized.
268
+ * @param warn - Whether to report on `console.warn`. The convergence path merges under
269
+ * `warningPolicy: "suppress"` and stays silent for the same reason.
270
+ * @returns The original array when it was already unique, so an unchanged catalog stays a no-op
271
+ * write; otherwise a first-win copy.
272
+ */
273
+ export function enforceCatalogSlugUniqueness(models: RawEntry[], warn: boolean): RawEntry[] {
274
+ const deduped = dedupeCatalogEntriesBySlug(models);
275
+ if (deduped.length === models.length) return models;
276
+ if (warn) {
277
+ // A dropped row that differs from the kept one means two emit paths disagree about the same
278
+ // slug's content. First-win still stands, but the operator needs to see WHICH slugs diverged
279
+ // instead of silently losing data. The baseline is the row the dedupe actually keeps — the
280
+ // FIRST occurrence — so the reported divergence is measured against what lands on disk.
281
+ const keptBySlug = new Map<string, RawEntry>();
282
+ for (const entry of models) {
283
+ if (typeof entry.slug !== "string" || keptBySlug.has(entry.slug)) continue;
284
+ keptBySlug.set(entry.slug, entry);
285
+ }
286
+ const divergentSlugs = new Set<string>();
287
+ for (const entry of models) {
288
+ if (typeof entry.slug !== "string") continue;
289
+ const kept = keptBySlug.get(entry.slug);
290
+ if (kept && kept !== entry && JSON.stringify(kept) !== JSON.stringify(entry)) {
291
+ divergentSlugs.add(entry.slug);
292
+ }
293
+ }
294
+ const divergentNote = divergentSlugs.size > 0
295
+ ? `; divergent content on: ${[...divergentSlugs].slice(0, 5).map(safeCatalogWarningLabel).join(", ")}${divergentSlugs.size > 5 ? ", …" : ""}`
296
+ : "";
297
+ console.warn(
298
+ `[opencodex] catalog sync dropped ${models.length - deduped.length} duplicate slug row(s), keeping the first occurrence of each slug (#4730)${divergentNote}.`,
299
+ );
300
+ }
301
+ return deduped;
302
+ }
303
+
225
304
  export function comboCatalogWarningSignature(
226
305
  combo: NormalizedComboConfig,
227
306
  members: readonly CatalogModel[],