@bitkyc08/opencodex 2.42.0 → 2.43.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 (246) hide show
  1. package/AGENTS_INSTALL.md +2 -2
  2. package/README.md +31 -0
  3. package/bin/ocx.mjs +10 -1
  4. package/gui/dist/assets/index-DS1NE4Jn.css +1 -0
  5. package/gui/dist/assets/index-Djowl68T.js +112 -0
  6. package/gui/dist/index.html +2 -2
  7. package/package.json +1 -1
  8. package/src/adapters/anthropic-image-codec.ts +304 -0
  9. package/src/adapters/anthropic-image-normalize.ts +8 -298
  10. package/src/adapters/anthropic.ts +30 -7
  11. package/src/adapters/command-code.ts +7 -5
  12. package/src/adapters/cursor/desktop-executor-contract.ts +15 -0
  13. package/src/adapters/cursor/images.ts +36 -6
  14. package/src/adapters/cursor/live-transport.ts +7 -2
  15. package/src/adapters/cursor/native-exec-desktop.ts +2 -15
  16. package/src/adapters/cursor/protobuf-request.ts +13 -3
  17. package/src/adapters/cursor/tool-definitions.ts +5 -670
  18. package/src/adapters/cursor/tool-guidance.ts +236 -0
  19. package/src/adapters/cursor/tool-naming.ts +252 -0
  20. package/src/adapters/cursor/tool-schemas.ts +195 -0
  21. package/src/adapters/cursor/types.ts +6 -3
  22. package/src/adapters/exec-tool-result-normalize.ts +1 -1
  23. package/src/adapters/google-errors.ts +9 -1
  24. package/src/adapters/google.ts +1 -0
  25. package/src/adapters/kiro-calibration.ts +181 -0
  26. package/src/adapters/kiro.ts +135 -3
  27. package/src/adapters/openai-responses.ts +216 -26
  28. package/src/adapters/responses-code-mode.ts +59 -0
  29. package/src/adapters/tool-catalog-nudge.ts +1 -1
  30. package/src/adapters/xai-schema-analysis.ts +86 -0
  31. package/src/adapters/xai-tool-schema.ts +2 -87
  32. package/src/adapters/xai-web-search.ts +1 -1
  33. package/src/bridge.ts +22 -10
  34. package/src/chat/inbound.ts +11 -3
  35. package/src/claude/inbound-content-options.ts +60 -0
  36. package/src/claude/inbound-model-options.ts +142 -0
  37. package/src/claude/inbound-records.ts +7 -0
  38. package/src/claude/inbound.ts +10 -202
  39. package/src/claude/model-info.ts +45 -0
  40. package/src/cli/account-auth.ts +21 -6
  41. package/src/cli/capabilities.ts +13 -2
  42. package/src/cli/claude.ts +232 -39
  43. package/src/cli/config-command.ts +9 -1
  44. package/src/cli/dispatch.ts +5 -1
  45. package/src/cli/doctor.ts +10 -0
  46. package/src/cli/effort.ts +372 -0
  47. package/src/cli/export-command.ts +3 -9
  48. package/src/cli/help.ts +1 -0
  49. package/src/cli/index.ts +13 -0
  50. package/src/cli/init.ts +4 -0
  51. package/src/cli/model-selection-guidance.ts +30 -0
  52. package/src/cli/models-runtime.ts +3 -2
  53. package/src/cli/models.ts +8 -3
  54. package/src/cli/opencode.ts +4 -1
  55. package/src/cli/provider-runtime.ts +65 -0
  56. package/src/cli/provider.ts +8 -0
  57. package/src/cli/registry.ts +16 -2
  58. package/src/cli/runtime-api.ts +3 -1
  59. package/src/cli/star-prompt.ts +22 -6
  60. package/src/cli/status-probes.ts +168 -0
  61. package/src/cli/status.ts +5 -168
  62. package/src/clients/config-export/constants.ts +69 -0
  63. package/src/clients/config-export/contracts.ts +154 -0
  64. package/src/clients/config-export/dsh.ts +132 -0
  65. package/src/clients/config-export/fast-models.ts +29 -0
  66. package/src/clients/config-export/mcode.ts +83 -0
  67. package/src/clients/config-export/model-metadata.ts +108 -0
  68. package/src/clients/config-export/omp.ts +104 -0
  69. package/src/clients/config-export/zcode.ts +92 -0
  70. package/src/clients/config-export.ts +18 -710
  71. package/src/codex/account-lifecycle.ts +20 -3
  72. package/src/codex/account-usability.ts +2 -0
  73. package/src/codex/auth-api.ts +170 -23
  74. package/src/codex/auth-context.ts +328 -24
  75. package/src/codex/catalog/effort.ts +30 -4
  76. package/src/codex/catalog/metadata.ts +39 -9
  77. package/src/codex/catalog/native-models.ts +53 -12
  78. package/src/codex/catalog/parsing.ts +119 -5
  79. package/src/codex/catalog/provider-fetch.ts +150 -20
  80. package/src/codex/catalog/reserve.ts +52 -0
  81. package/src/codex/catalog/sync.ts +89 -16
  82. package/src/codex/convergence-types.ts +1 -0
  83. package/src/codex/convergence.ts +2 -0
  84. package/src/codex/data/upstream-models.json +1 -1
  85. package/src/codex/forward-transport-headers.ts +25 -0
  86. package/src/codex/inject.ts +3 -28
  87. package/src/codex/legacy-config-keys.ts +68 -0
  88. package/src/codex/log-guard/inspect-schema.ts +137 -0
  89. package/src/codex/log-guard/inspect.ts +2 -134
  90. package/src/codex/loopback-target.ts +54 -0
  91. package/src/codex/main-account-cache.ts +63 -1
  92. package/src/codex/main-account-hard-lock.ts +52 -0
  93. package/src/codex/main-account.ts +3 -1
  94. package/src/codex/management-convergence.ts +3 -0
  95. package/src/codex/model-entitlements.ts +54 -4
  96. package/src/codex/project-config-warnings.ts +92 -2
  97. package/src/codex/prompt-layers/encoding.ts +80 -0
  98. package/src/codex/prompt-layers/paths.ts +54 -0
  99. package/src/codex/prompt-layers/revision.ts +55 -0
  100. package/src/codex/prompt-layers/toml-edit.ts +163 -0
  101. package/src/codex/prompt-layers/toml-read.ts +181 -0
  102. package/src/codex/prompt-layers.ts +14 -520
  103. package/src/codex/quota-auto-refresh-state.ts +16 -0
  104. package/src/codex/quota-auto-refresh.ts +219 -0
  105. package/src/codex/quota-types.ts +51 -0
  106. package/src/codex/quota.ts +252 -93
  107. package/src/codex/reserve-availability.ts +177 -0
  108. package/src/codex/routing.ts +28 -9
  109. package/src/codex/shim.ts +53 -11
  110. package/src/codex/subagent-model-fallback.ts +23 -3
  111. package/src/combos/failover.ts +125 -7
  112. package/src/combos/identifiers.ts +89 -0
  113. package/src/combos/index.ts +4 -0
  114. package/src/combos/resolve.ts +80 -9
  115. package/src/combos/types.ts +20 -93
  116. package/src/config/subagent-models.ts +24 -0
  117. package/src/config.ts +156 -13
  118. package/src/generated/compatibility-version.json +454 -166
  119. package/src/generated/model-metadata.ts +1 -1
  120. package/src/integrations/journal.ts +65 -4
  121. package/src/integrations/store.ts +5 -0
  122. package/src/lab/events/limits.ts +4 -0
  123. package/src/lib/destination-policy.ts +31 -2
  124. package/src/lib/errors.ts +39 -0
  125. package/src/lib/provider-outbound.ts +69 -3
  126. package/src/lib/proxy-env.ts +22 -0
  127. package/src/lib/redact-folding.ts +176 -0
  128. package/src/lib/redact.ts +2 -175
  129. package/src/lib/state-store-sweeper.ts +20 -6
  130. package/src/lib/token-estimate.ts +94 -27
  131. package/src/lib/windows-user-principal.ts +53 -5
  132. package/src/oauth/anthropic-routing.ts +99 -3
  133. package/src/oauth/generic-account-failover.ts +36 -13
  134. package/src/oauth/index.ts +127 -26
  135. package/src/oauth/login-cli.ts +5 -0
  136. package/src/oauth/meta-muse.ts +117 -15
  137. package/src/oauth/pool-settings-capability.ts +15 -4
  138. package/src/providers/api-keys.ts +8 -10
  139. package/src/providers/default-aliases.ts +39 -0
  140. package/src/providers/derive.ts +10 -2
  141. package/src/providers/fastwire.ts +36 -7
  142. package/src/providers/initial-model-selection-runtime.ts +90 -0
  143. package/src/providers/initial-model-selection.ts +120 -0
  144. package/src/providers/key-failover.ts +134 -54
  145. package/src/providers/key-store.ts +11 -1
  146. package/src/providers/label.ts +1 -1
  147. package/src/providers/model-discovery.ts +76 -0
  148. package/src/providers/model-rename-startup.ts +72 -8
  149. package/src/providers/openai-sidecar.ts +17 -5
  150. package/src/providers/openai-tiers-destination.ts +102 -0
  151. package/src/providers/openai-tiers.ts +2 -99
  152. package/src/providers/opencode-go-transport.ts +41 -0
  153. package/src/providers/quota-key-accounts.ts +141 -0
  154. package/src/providers/quota-types.ts +9 -0
  155. package/src/providers/quota.ts +535 -104
  156. package/src/providers/registry.ts +60 -17
  157. package/src/providers/xai-responses-opt-in.ts +31 -5
  158. package/src/quota/reset-activation.ts +81 -0
  159. package/src/quota/reset-detector.ts +305 -0
  160. package/src/quota/reset-notify-config.ts +162 -0
  161. package/src/quota/reset-observer.ts +125 -0
  162. package/src/quota/reset-poller.ts +160 -0
  163. package/src/quota/reset-seen-store.ts +385 -0
  164. package/src/quota/reset-sinks.ts +199 -0
  165. package/src/quota/window-mapping.ts +106 -0
  166. package/src/responses/apply-patch-envelope.ts +46 -0
  167. package/src/responses/code-mode-helper-compat.ts +39 -1
  168. package/src/responses/custom-tool-compat.ts +10 -4
  169. package/src/responses/hosted-tool-policy.ts +12 -4
  170. package/src/responses/parser-content.ts +133 -0
  171. package/src/responses/parser-text-format.ts +24 -0
  172. package/src/responses/parser-tools.ts +188 -0
  173. package/src/responses/parser.ts +3 -326
  174. package/src/responses/state.ts +124 -28
  175. package/src/router.ts +48 -13
  176. package/src/routing/analytics.ts +1 -0
  177. package/src/routing/capability.ts +17 -4
  178. package/src/server/auth-cors.ts +7 -1
  179. package/src/server/background-lifecycle.ts +23 -1
  180. package/src/server/chat-completions.ts +25 -3
  181. package/src/server/claude-messages.ts +62 -5
  182. package/src/server/effort-row.ts +1 -1
  183. package/src/server/fast-row.ts +295 -0
  184. package/src/server/gui-static.ts +30 -4
  185. package/src/server/index.ts +107 -21
  186. package/src/server/management/agent-settings-routes.ts +2 -2
  187. package/src/server/management/combo-routes.ts +37 -9
  188. package/src/server/management/config-routes.ts +93 -2
  189. package/src/server/management/integration-routes.ts +108 -0
  190. package/src/server/management/model-routes.ts +13 -3
  191. package/src/server/management/model-rows.ts +20 -1
  192. package/src/server/management/native-integration-routes.ts +4 -1
  193. package/src/server/management/oauth-account-routes.ts +38 -10
  194. package/src/server/management/provider-routes.ts +34 -3
  195. package/src/server/management/quota-reset-routes.ts +57 -0
  196. package/src/server/management/route-registry.ts +7 -4
  197. package/src/server/management/shared.ts +19 -5
  198. package/src/server/management/system-routes.ts +3 -2
  199. package/src/server/management-api.ts +14 -2
  200. package/src/server/ports.ts +12 -2
  201. package/src/server/relay-eager.ts +38 -23
  202. package/src/server/relay.ts +4 -0
  203. package/src/server/request-log.ts +6 -0
  204. package/src/server/responses/codex-ws-correlation.ts +65 -0
  205. package/src/server/responses/codex-ws-exchange.ts +261 -0
  206. package/src/server/responses/codex-ws-metadata.ts +134 -0
  207. package/src/server/responses/codex-ws-pool.ts +162 -0
  208. package/src/server/responses/codex-ws-request.ts +87 -0
  209. package/src/server/responses/codex-ws-session.ts +93 -0
  210. package/src/server/responses/codex-ws-wire.ts +144 -0
  211. package/src/server/responses/collaboration.ts +41 -1
  212. package/src/server/responses/compact.ts +105 -12
  213. package/src/server/responses/core.ts +476 -57
  214. package/src/server/responses/fetch-helpers.ts +10 -3
  215. package/src/server/responses/input-admission.ts +16 -9
  216. package/src/server/responses/responses-field-backfill.ts +1 -1
  217. package/src/server/responses/ws-upstream.ts +34 -318
  218. package/src/server/responses-custom-tool-repair.ts +20 -4
  219. package/src/server/responses-undeclared-tool-guard.ts +100 -8
  220. package/src/server/safe-response-headers.ts +23 -0
  221. package/src/server/search.ts +9 -0
  222. package/src/server/subagent-models-startup.ts +27 -0
  223. package/src/server/system-env-shell.ts +238 -0
  224. package/src/server/system-env.ts +7 -234
  225. package/src/server/ws-bridge.ts +3 -25
  226. package/src/server/xai-responses-startup.ts +21 -0
  227. package/src/service-manager-probe.ts +1 -1
  228. package/src/service.ts +55 -16
  229. package/src/types/config.ts +108 -12
  230. package/src/types/provider.ts +36 -7
  231. package/src/types/tools.ts +26 -1
  232. package/src/types.ts +2 -0
  233. package/src/update/notify.ts +8 -2
  234. package/src/usage/cost.ts +38 -28
  235. package/src/usage/expected-prices.ts +34 -15
  236. package/src/usage/log.ts +2 -0
  237. package/src/usage/model-identity.ts +26 -0
  238. package/src/usage/summary.ts +15 -1
  239. package/src/vision/describe.ts +6 -0
  240. package/src/vision/image-rewrite.ts +108 -0
  241. package/src/vision/index.ts +19 -306
  242. package/src/vision/plan.ts +205 -0
  243. package/src/web-search/executor.ts +6 -0
  244. package/src/web-search/index.ts +8 -1
  245. package/gui/dist/assets/index-BU1tE0sr.js +0 -112
  246. package/gui/dist/assets/index-DL9-iS6J.css +0 -1
@@ -8,7 +8,6 @@ import {
8
8
  isCodexAccountGenerationLive,
9
9
  } from "./account-store";
10
10
  import { isAccountNeedsReauth, markAccountNeedsReauth } from "./account-runtime-state";
11
- import { isCodexAccountPaused } from "./account-pause";
12
11
  import { ConfigMutationLockError } from "../config";
13
12
  import { NativeProfileError } from "./native-profile-types";
14
13
  import { isCodexAccountUsable } from "./account-usability";
@@ -41,16 +40,30 @@ import {
41
40
  isDirectCallerEntitledToCodexModel,
42
41
  resolveCodexModelEntitlements,
43
42
  } from "./model-entitlements";
44
- import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS } from "./catalog/native-models";
43
+ import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS, NATIVE_RESERVE_MODEL } from "./catalog/native-models";
45
44
  import type { CodexCooldownSource, CodexQuotaScope } from "./routing";
46
45
  import { maskAccountId } from "../lib/privacy";
47
46
  import { formatErrorResponse } from "../bridge";
48
- import { CODEX_UNKNOWN_USAGE_SCORE, getAccountQuota } from "./quota";
47
+ import { CODEX_UNKNOWN_USAGE_SCORE, getAccountQuota, parseUsageQuota, parseMainPolicyUsageQuota, setAccountQuotaFromParsed } from "./quota";
49
48
  import type { CodexAccountMode, OcxConfig, OcxProviderConfig } from "../types";
50
49
  import { FORWARD_HEADERS } from "../adapters/openai-responses";
51
50
  import { captureConfigGeneration } from "../lib/state-store-sweeper";
52
51
  import { retainedUtf8Bytes } from "../lib/admission";
53
52
  import { extractAccountId } from "../oauth/chatgpt";
53
+ import { getMainAccountHardLockStatus, isMainAccountHardLocked } from "./main-account-hard-lock";
54
+ import {
55
+ captureMainAccountIdentityGeneration,
56
+ captureMainQuotaWriter,
57
+ getObservedMainQuotaIdentityKey,
58
+ isMainQuotaWriterLive,
59
+ matchesMainQuotaCredential,
60
+ observeMainQuotaCredential,
61
+ type MainQuotaWriter,
62
+ } from "./main-account-cache";
63
+ import { CODEX_RESERVE_HELPER_UNSUPPORTED_MESSAGE, isCodexReserveHelperUnsupported, isCodexReserveRequestEligible } from "./loopback-target";
64
+ import type { DataPlaneAdmission } from "../server/auth-cors";
65
+ import { getMainReserveAuthorization, isMainReserveAuthorizationLive, type MainReserveAuthorization } from "./reserve-availability";
66
+ import { UpstreamRetryEvidenceError } from "../lib/upstream-retry";
54
67
 
55
68
  const CODEX_AFFINITY_COMPONENT_MAX_BYTES = 512;
56
69
  const CODEX_APP_AFFINITY_KEY = randomBytes(32);
@@ -99,7 +112,7 @@ export function codexPoolAffinityKey(headers: Headers): string | undefined {
99
112
  }
100
113
 
101
114
  export type CodexAuthContext =
102
- | { kind: "main"; accountId: null }
115
+ | { kind: "main"; accountId: null; reserveAuthorization?: MainReserveAuthorization }
103
116
  | {
104
117
  kind: "pool";
105
118
  accountId: string;
@@ -128,6 +141,9 @@ export type CodexAuthContext =
128
141
  kind: "main-pool";
129
142
  accountId: string;
130
143
  writerGeneration: number;
144
+ /** Captured before async credential work; never reconstructed after the upstream response. */
145
+ mainQuotaWriter?: MainQuotaWriter;
146
+ reserveAuthorization?: MainReserveAuthorization;
131
147
  accessToken: string;
132
148
  chatgptAccountId: string;
133
149
  /** Bypass Pool selection and suppress quota/transient failover for an exact selector. */
@@ -283,6 +299,182 @@ export class CodexAccountCooldownError extends Error {
283
299
  }
284
300
  }
285
301
 
302
+ export class CodexMainAccountHardLockError extends CodexAccountCooldownError {
303
+ readonly resetAt?: number;
304
+
305
+ constructor(resetAt?: number) {
306
+ super(MAIN_CODEX_ACCOUNT_ID, resetAt ?? 0);
307
+ this.name = "CodexMainAccountHardLockError";
308
+ this.resetAt = resetAt;
309
+ this.message = "Codex main account is blocked by the 99% main-account quota policy."
310
+ + " Choose another account, wait for quota to reset, or disable codexMainAccountHardLock in Settings.";
311
+ }
312
+ }
313
+
314
+ export class CodexReserveUnavailableError extends CodexAccountCooldownError {
315
+ constructor() {
316
+ super(MAIN_CODEX_ACCOUNT_ID, 0);
317
+ this.name = "CodexReserveUnavailableError";
318
+ this.message = "Codex Reserve is unavailable for this main credential."
319
+ + " Use the stored main login or its matching caller credential, and retry when OpenAI grants Reserve access."
320
+ + " Reserve compatibility requires the effective local Desktop authless opt-in; it cannot switch accounts automatically.";
321
+ }
322
+ }
323
+
324
+ /** A local unsupported-helper refusal; retain Reserve policy error mapping on delayed sends. */
325
+ export class CodexReserveHelperUnsupportedError extends CodexReserveUnavailableError {
326
+ constructor() {
327
+ super();
328
+ this.name = "CodexReserveHelperUnsupportedError";
329
+ this.message = CODEX_RESERVE_HELPER_UNSUPPORTED_MESSAGE;
330
+ }
331
+ }
332
+
333
+ export type CodexAuthPolicyConfig = Readonly<Pick<OcxConfig,
334
+ "codexMainAccountHardLock" | "codexDesktopAuthless" | "runtimeRole" | "pausedCodexAccountIds"
335
+ >>;
336
+
337
+ interface CodexAuthMaterializationOptions {
338
+ substituteMainCredential?: boolean;
339
+ config?: CodexAuthPolicyConfig;
340
+ modelId?: string;
341
+ /** Trusted receiving-listener admission; never inferred from request headers or config. */
342
+ admission?: Pick<DataPlaneAdmission, "source">;
343
+ signal?: AbortSignal;
344
+ nativeMainRefreshDependencies?: NativeMainRefreshDependencies;
345
+ beginCodexAccountSelection?: () => CodexAccountSelectionAdmission | undefined;
346
+ }
347
+
348
+ function requiresReserveAuthorization(
349
+ config: CodexAuthPolicyConfig | undefined,
350
+ modelId: string | undefined,
351
+ admission: Pick<DataPlaneAdmission, "source"> | undefined,
352
+ ): boolean {
353
+ return modelId === NATIVE_RESERVE_MODEL && !!config && isCodexReserveRequestEligible(config, admission);
354
+ }
355
+
356
+ function assertReserveAdmission(config: CodexAuthPolicyConfig): void {
357
+ if (config.pausedCodexAccountIds?.includes(MAIN_CODEX_ACCOUNT_ID) || isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)) {
358
+ throw new CodexReserveUnavailableError();
359
+ }
360
+ assertMainAccountPolicy(config);
361
+ const cooldown = getCodexQuotaHealthSnapshot(MAIN_CODEX_ACCOUNT_ID, "reserve");
362
+ if (cooldown?.cooldownUntil) {
363
+ throw new CodexAccountCooldownError(MAIN_CODEX_ACCOUNT_ID, cooldown.cooldownUntil, cooldown.cooldownSource, cooldown.quotaScope);
364
+ }
365
+ }
366
+
367
+ async function authorizeReserveCredential(
368
+ token: { accessToken: string; chatgptAccountId: string },
369
+ writer: MainQuotaWriter | undefined,
370
+ config: CodexAuthPolicyConfig,
371
+ signal?: AbortSignal,
372
+ existing?: MainReserveAuthorization,
373
+ writerGeneration = captureConfigGeneration(),
374
+ ): Promise<MainReserveAuthorization> {
375
+ assertReserveAdmission(config);
376
+ if (!writer || !isMainQuotaWriterLive(writer)
377
+ || !matchesMainQuotaCredential(token.accessToken, token.chatgptAccountId)) {
378
+ throw new CodexReserveUnavailableError();
379
+ }
380
+ const authorization = isMainReserveAuthorizationLive(existing, token) ? existing
381
+ : await getMainReserveAuthorization({
382
+ token, writer, signal,
383
+ observeOrdinaryQuota(data, capturedWriter) {
384
+ setAccountQuotaFromParsed(MAIN_CODEX_ACCOUNT_ID, parseUsageQuota(data), writerGeneration,
385
+ capturedWriter, parseMainPolicyUsageQuota(data));
386
+ },
387
+ });
388
+ // The capability read also publishes ordinary quota. A new 99% reading or cooldown wins.
389
+ assertReserveAdmission(config);
390
+ if (signal?.aborted) throw signal.reason;
391
+ if (!authorization || !isMainReserveAuthorizationLive(authorization, token)) throw new CodexReserveUnavailableError();
392
+ return authorization;
393
+ }
394
+
395
+ function selectedCodexToken(headers: Headers): { accessToken: string; chatgptAccountId: string } {
396
+ return {
397
+ accessToken: headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim() ?? "",
398
+ chatgptAccountId: headers.get("chatgpt-account-id") ?? "",
399
+ };
400
+ }
401
+
402
+ function assertMaterializedReserve(headers: Headers, ctx: CodexAuthContext, options: CodexAuthMaterializationOptions): void {
403
+ if (!requiresReserveAuthorization(options.config, options.modelId, options.admission)) return;
404
+ assertReserveAdmission(options.config!);
405
+ if (ctx.kind === "pool" || !isMainReserveAuthorizationLive(ctx.reserveAuthorization, selectedCodexToken(headers))) {
406
+ throw new CodexReserveUnavailableError();
407
+ }
408
+ }
409
+
410
+ /** A dispatch never renews permission: the next request may obtain a fresh bounded proof. */
411
+ export function createCodexReserveDispatchGuard(
412
+ ctx: CodexAuthContext,
413
+ config: CodexAuthPolicyConfig,
414
+ modelId: string,
415
+ admission?: Pick<DataPlaneAdmission, "source">,
416
+ terminalHelper = false,
417
+ ): ((headers: Headers) => void) | undefined {
418
+ // Snapshot the resolved source value, not the caller's mutable admission object. Config stays
419
+ // live so policy changes remain visible after pacing and retry backoff.
420
+ const source = admission?.source;
421
+ if (modelId !== NATIVE_RESERVE_MODEL || source !== "loopback") return undefined;
422
+ // Only immutable request facts decide whether to install the callback. Flag/role eligibility
423
+ // is checked inside it, including an opt-in enabled while a send waits for pacing or WS open.
424
+ const ingress = Object.freeze({ source });
425
+ return headers => {
426
+ if (isCodexReserveHelperUnsupported(config, modelId, ingress, terminalHelper)) {
427
+ throw new CodexReserveHelperUnsupportedError();
428
+ }
429
+ assertMaterializedReserve(headers, ctx, { config, modelId, admission: ingress });
430
+ };
431
+ }
432
+
433
+ /** Retry history must not turn a later local admission refusal into a network failure. */
434
+ export function unwrapUpstreamRetryEvidenceError(error: unknown): unknown {
435
+ const seen = new Set<unknown>();
436
+ while (error instanceof UpstreamRetryEvidenceError && !seen.has(error)) {
437
+ seen.add(error);
438
+ error = error.cause;
439
+ }
440
+ return error;
441
+ }
442
+
443
+ function assertMainAccountPolicy(config: Pick<OcxConfig, "codexMainAccountHardLock"> | undefined): void {
444
+ if (!config) return;
445
+ const status = getMainAccountHardLockStatus(config);
446
+ if (status.state === "blocked") throw new CodexMainAccountHardLockError(status.resetAt);
447
+ }
448
+
449
+ /** No auth-file I/O: an unsigned claim alone never identifies a caller as stored main. */
450
+ function callerMatchesObservedMain(headers: Headers): boolean {
451
+ const bearer = headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim();
452
+ if (!bearer) return false;
453
+ const effectiveAccountId = headers.get("chatgpt-account-id")
454
+ ?? extractAccountId(undefined, bearer);
455
+ return matchesMainQuotaCredential(bearer, effectiveAccountId);
456
+ }
457
+
458
+ function captureObservedMainWriter(): MainQuotaWriter | undefined {
459
+ const identityKey = getObservedMainQuotaIdentityKey();
460
+ return identityKey === undefined ? undefined : {
461
+ identityKey,
462
+ identityGeneration: captureMainAccountIdentityGeneration(),
463
+ };
464
+ }
465
+
466
+ function observeSelectedMainCredential(
467
+ token: { accessToken: string; chatgptAccountId: string },
468
+ writer: MainQuotaWriter | undefined,
469
+ ): MainQuotaWriter | undefined {
470
+ if (!writer) return undefined;
471
+ // Carry an explicitly stale writer through to quota's rejection fence; turning it into an
472
+ // untagged write would instead invalidate the replacement account's trusted observation.
473
+ if (!isMainQuotaWriterLive(writer)) return writer;
474
+ const observed = observeMainQuotaCredential(token.accessToken, token.chatgptAccountId);
475
+ return observed?.identityKey === writer.identityKey ? writer : undefined;
476
+ }
477
+
286
478
  /**
287
479
  * Human-readable account label for a client-visible error. NEVER the raw id: the proxy
288
480
  * supports non-loopback binds (auth-cors.ts `isApiAuthRequired` requires a token there
@@ -300,12 +492,12 @@ export function cooldownAccountLabel(accountId: string): string {
300
492
  * injected `openai_base_url` in config.toml.
301
493
  */
302
494
  export function cooldownErrorMessage(err: CodexAccountCooldownError, accountSelector?: string): string {
495
+ if (err instanceof CodexMainAccountHardLockError || err instanceof CodexReserveUnavailableError) return err.message;
303
496
  const until = new Date(err.cooldownUntil).toISOString();
304
- const scope = err.quotaScope === "spark"
305
- ? "Spark quota"
306
- : err.quotaScope === "shared"
307
- ? "shared native quota"
308
- : null;
497
+ const scopeLabels: Record<CodexQuotaScope, string> = {
498
+ spark: "Spark quota", shared: "shared native quota", reserve: "Reserve quota",
499
+ };
500
+ const scope = err.quotaScope ? scopeLabels[err.quotaScope] : null;
309
501
  const selected = accountSelector
310
502
  ? `Selected Codex account selector (${accountSelector})`
311
503
  : `Selected Codex account (${cooldownAccountLabel(err.accountId)})`;
@@ -325,7 +517,10 @@ export function cooldownErrorResponse(
325
517
  ): Response {
326
518
  const res = formatErrorResponse(429, "rate_limit_error", cooldownErrorMessage(err, accountSelector));
327
519
  const headers = new Headers(res.headers);
328
- headers.set("Retry-After", String(Math.max(1, Math.ceil((err.cooldownUntil - now) / 1000))));
520
+ if (!(err instanceof CodexReserveUnavailableError)
521
+ && (!(err instanceof CodexMainAccountHardLockError) || err.resetAt !== undefined)) {
522
+ headers.set("Retry-After", String(Math.max(1, Math.ceil((err.cooldownUntil - now) / 1000))));
523
+ }
329
524
  return new Response(res.body, { status: res.status, headers });
330
525
  }
331
526
 
@@ -340,7 +535,9 @@ export class CodexThreadAffinityExpiredError extends Error {
340
535
  }
341
536
 
342
537
  export function shouldMarkAccountNeedsReauthForCodexAuthFailure(cause: unknown): boolean {
343
- return !(cause instanceof CodexCredentialGenerationConflictError)
538
+ return !(cause instanceof CodexMainAccountHardLockError)
539
+ && !(cause instanceof CodexReserveUnavailableError)
540
+ && !(cause instanceof CodexCredentialGenerationConflictError)
344
541
  && !(cause instanceof CodexCredentialRefreshLockTimeoutError)
345
542
  && !(cause instanceof CodexCredentialRefreshBusyError)
346
543
  && !(cause instanceof CodexCredentialRefreshStaleError)
@@ -352,6 +549,9 @@ export function shouldMarkAccountNeedsReauthForCodexAuthFailure(cause: unknown):
352
549
  }
353
550
 
354
551
  export interface ResolveCodexAuthContextOptions {
552
+ admission?: Pick<DataPlaneAdmission, "source">;
553
+ /** Live policy owner when the routing config is a caller-specific replay snapshot. */
554
+ codexAuthPolicy?: CodexAuthPolicyConfig;
355
555
  excludeAccountId?: string;
356
556
  /** Resolve exactly this account without consulting or mutating Pool selection. */
357
557
  accountId?: string;
@@ -389,14 +589,21 @@ export async function resolveCodexAuthContext(
389
589
  options: ResolveCodexAuthContextOptions = {},
390
590
  ): Promise<CodexAuthContext> {
391
591
  const writerGeneration = captureConfigGeneration();
592
+ const policy = options.codexAuthPolicy ?? config;
392
593
  const requestScopedMainCredential = options.requestScopedMainCredential === true
393
594
  && hasCallerCodexBearer(headers);
394
- const fixedAccountId = options.accountId;
595
+ const reserve = requiresReserveAuthorization(policy, options.modelId, options.admission);
596
+ if (reserve && (options.excludeAccountId !== undefined
597
+ || (options.accountId !== undefined && options.accountId !== MAIN_CODEX_ACCOUNT_ID))) {
598
+ throw new CodexReserveUnavailableError();
599
+ }
600
+ const fixedAccountId = reserve ? MAIN_CODEX_ACCOUNT_ID : options.accountId;
395
601
  const preserveRequestOwnedMainPin = requestScopedMainCredential
396
602
  && fixedAccountId === undefined
397
603
  && config.activeCodexAccountPinned === MAIN_CODEX_ACCOUNT_ID
398
604
  && isEffectiveCodexAccountPinned(config)
399
- && !isCodexAccountPaused(config, MAIN_CODEX_ACCOUNT_ID)
605
+ && !policy.pausedCodexAccountIds?.includes(MAIN_CODEX_ACCOUNT_ID)
606
+ && !(callerMatchesObservedMain(headers) && isMainAccountHardLocked(policy))
400
607
  && requestOwnedMainPinHasQuotaHeadroom(config);
401
608
  if (fixedAccountId !== undefined && options.excludeAccountId !== undefined) {
402
609
  throw new Error("Codex auth context cannot select and exclude an account simultaneously");
@@ -405,6 +612,14 @@ export async function resolveCodexAuthContext(
405
612
  if (!hasCallerCodexBearer(headers)) throw new CodexDirectAuthenticationError();
406
613
  const substituteStoredMain = options.substituteMainCredentialForDirect === true;
407
614
  if (!substituteStoredMain) {
615
+ if (callerMatchesObservedMain(headers)) assertMainAccountPolicy(policy);
616
+ if (reserve) {
617
+ const selected = materializeCodexUpstreamAuth(headers, { kind: "main", accountId: null }, { config: policy });
618
+ const token = selectedCodexToken(selected);
619
+ const reserveAuthorization = await authorizeReserveCredential(token, captureMainQuotaWriter(token.chatgptAccountId),
620
+ policy, options.signal, undefined, writerGeneration);
621
+ return { kind: "main", accountId: null, reserveAuthorization };
622
+ }
408
623
  if (options.modelId && ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(options.modelId)) {
409
624
  const entitled = await (
410
625
  options.isDirectCallerEntitledToCodexModel ?? isDirectCallerEntitledToCodexModel
@@ -413,6 +628,7 @@ export async function resolveCodexAuthContext(
413
628
  throw new CodexPoolAuthenticationError("The selected ChatGPT account does not support this model");
414
629
  }
415
630
  }
631
+ if (callerMatchesObservedMain(headers)) assertMainAccountPolicy(policy);
416
632
  return { kind: "main", accountId: null };
417
633
  }
418
634
 
@@ -432,6 +648,8 @@ export async function resolveCodexAuthContext(
432
648
  ) {
433
649
  throw new CodexMainProfileDrainingError();
434
650
  }
651
+ if (policy.codexMainAccountHardLock === true) reconcileMainCodexAccountRuntimeState();
652
+ assertMainAccountPolicy(policy);
435
653
  if (options.modelId && ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(options.modelId)) {
436
654
  const entitled = entitledCodexAccountIdsForModel(
437
655
  await (options.resolveCodexModelEntitlements ?? resolveCodexModelEntitlements)(config, {
@@ -444,6 +662,7 @@ export async function resolveCodexAuthContext(
444
662
  throw new CodexPoolAuthenticationError("The selected ChatGPT account does not support this model");
445
663
  }
446
664
  }
665
+ assertMainAccountPolicy(policy);
447
666
  return { kind: "main", accountId: null };
448
667
  } finally {
449
668
  // The short selector reservation ends here. A successful claim remains owned by
@@ -462,13 +681,17 @@ export async function resolveCodexAuthContext(
462
681
  || await (
463
682
  options.isDirectCallerEntitledToCodexModel ?? isDirectCallerEntitledToCodexModel
464
683
  )(headers, options.modelId);
465
- if (callerEntitled) return { kind: "main", accountId: null };
684
+ if (callerEntitled && !(callerMatchesObservedMain(headers) && isMainAccountHardLocked(policy))) {
685
+ return { kind: "main", accountId: null };
686
+ }
466
687
  }
467
688
  // An explicit namespace binding is stronger than the provider's default mode. It must use the
468
689
  // selected stored credential even while the canonical OpenAI provider is globally Direct.
469
690
  // A request-owned bearer is deliberately not represented as `main-pool`: Pool account ids own
470
691
  // durable health, quota, and affinity state, while this credential exists for one request only.
471
- if ((mode === "direct" && fixedAccountId === undefined)
692
+ if ((reserve && hasCallerCodexBearer(headers) && !options.substituteMainCredentialForDirect
693
+ && (requestScopedMainCredential || mode === "direct"))
694
+ || (mode === "direct" && fixedAccountId === undefined)
472
695
  || (requestScopedMainCredential && fixedAccountId === MAIN_CODEX_ACCOUNT_ID)) {
473
696
  return resolveCallerOwnedMainContext();
474
697
  }
@@ -523,6 +746,7 @@ export async function resolveCodexAuthContext(
523
746
  // A pre-drain selector reserves the native identity while reconciliation and
524
747
  // routing inspect it. Selectors arriving after the fence skip reconciliation
525
748
  // and may still route to non-main pool accounts without touching switch state.
749
+ if (reserve && !nativeMainReadsForbidden && !selectionAdmission) throw new CodexMainProfileDrainingError();
526
750
  if (!nativeMainReadsForbidden) reconcileMainCodexAccountRuntimeState();
527
751
  const resolution = fixedAccountId !== undefined
528
752
  ? { status: "selected" as const, accountId: fixedAccountId }
@@ -577,6 +801,11 @@ export async function resolveCodexAuthContext(
577
801
  if (nativeMainReadsForbidden && !options.excludeAccountId) {
578
802
  throw new CodexMainProfileDrainingError();
579
803
  }
804
+ if (!nativeMainReadsForbidden && options.excludeAccountId !== MAIN_CODEX_ACCOUNT_ID
805
+ && !policy.pausedCodexAccountIds?.includes(MAIN_CODEX_ACCOUNT_ID)
806
+ && (!modelEligibleAccountIds || modelEligibleAccountIds.has(MAIN_CODEX_ACCOUNT_ID))) {
807
+ assertMainAccountPolicy(policy);
808
+ }
580
809
  throw new CodexPoolAuthenticationError(
581
810
  modelEligibleAccountIds === undefined
582
811
  ? undefined
@@ -586,6 +815,7 @@ export async function resolveCodexAuthContext(
586
815
  );
587
816
  }
588
817
  accountId = selected;
818
+ if (accountId === MAIN_CODEX_ACCOUNT_ID) assertMainAccountPolicy(policy);
589
819
  if (accountId === MAIN_CODEX_ACCOUNT_ID && nativeMainTrafficBlocked) {
590
820
  throw new CodexMainProfileDrainingError();
591
821
  }
@@ -608,7 +838,7 @@ export async function resolveCodexAuthContext(
608
838
  );
609
839
  }
610
840
  if (fixedAccountId !== undefined) {
611
- if (isCodexAccountPaused(config, accountId)) {
841
+ if (policy.pausedCodexAccountIds?.includes(accountId)) {
612
842
  throw new CodexPoolAuthenticationError("Selected Codex account is unavailable");
613
843
  }
614
844
  if (isAccountNeedsReauth(accountId)) {
@@ -662,14 +892,18 @@ export async function resolveCodexAuthContext(
662
892
  if (accountId === MAIN_CODEX_ACCOUNT_ID) {
663
893
  // Main account in rotation: refresh auth.json before upstream I/O and fail closed if it vanished.
664
894
  let token: { accessToken: string; chatgptAccountId: string } | null;
895
+ let mainQuotaWriter = captureObservedMainWriter();
665
896
  try {
666
897
  token = await (options.getValidMainAccountToken ?? getValidMainAccountToken)({
667
898
  signal: options.signal,
668
899
  ...(options.nativeMainRefreshDependencies ?? {}),
669
900
  });
901
+ if (token) mainQuotaWriter = observeSelectedMainCredential(token, mainQuotaWriter);
902
+ assertMainAccountPolicy(policy);
670
903
  } catch (cause) {
671
904
  if (probeLeaseId && probeQuotaScope) releaseCodexQuotaScopeProbeLease(accountId, probeQuotaScope, probeLeaseId);
672
905
  else if (probeLeaseId) releaseCodexQuotaProbeLease(accountId, probeLeaseId);
906
+ if (cause instanceof CodexMainAccountHardLockError) throw cause;
673
907
  if (!options.signal?.aborted && shouldMarkAccountNeedsReauthForCodexAuthFailure(cause)) {
674
908
  markAccountNeedsReauth(accountId, writerGeneration);
675
909
  }
@@ -683,10 +917,15 @@ export async function resolveCodexAuthContext(
683
917
  fixedAccountId !== undefined ? "Selected Codex account is unavailable" : undefined,
684
918
  );
685
919
  }
920
+ const reserveAuthorization = reserve
921
+ ? await authorizeReserveCredential(token, mainQuotaWriter, policy, options.signal, undefined, writerGeneration)
922
+ : undefined;
686
923
  return {
687
924
  kind: "main-pool",
688
925
  accountId,
689
926
  writerGeneration,
927
+ mainQuotaWriter,
928
+ ...(reserveAuthorization ? { reserveAuthorization } : {}),
690
929
  accessToken: token.accessToken,
691
930
  chatgptAccountId: token.chatgptAccountId,
692
931
  ...(fixedAccountId !== undefined ? { fixedAccount: true } : {}),
@@ -772,7 +1011,7 @@ export class CodexMainSubstitutionUnavailableError extends Error {
772
1011
  export function materializeCodexUpstreamAuth(
773
1012
  headers: Headers,
774
1013
  ctx: CodexAuthContext,
775
- options: { substituteMainCredential?: boolean } = {},
1014
+ options: CodexAuthMaterializationOptions = {},
776
1015
  ): Headers {
777
1016
  const selected = new Headers();
778
1017
  for (const name of FORWARD_HEADERS) {
@@ -782,6 +1021,11 @@ export function materializeCodexUpstreamAuth(
782
1021
  if (ctx.kind === "pool" || ctx.kind === "main-pool") {
783
1022
  selected.set("authorization", `Bearer ${ctx.accessToken}`);
784
1023
  selected.set("chatgpt-account-id", ctx.chatgptAccountId);
1024
+ if (ctx.kind === "main-pool") {
1025
+ ctx.mainQuotaWriter = observeSelectedMainCredential(ctx, ctx.mainQuotaWriter);
1026
+ assertMainAccountPolicy(options.config);
1027
+ }
1028
+ assertMaterializedReserve(selected, ctx, options);
785
1029
  return selected;
786
1030
  }
787
1031
  if (ctx.kind === "main" && options.substituteMainCredential !== true
@@ -791,6 +1035,8 @@ export function materializeCodexUpstreamAuth(
791
1035
  if (accountId) selected.set("chatgpt-account-id", accountId);
792
1036
  }
793
1037
  if (ctx.kind === "main" && options.substituteMainCredential === true) {
1038
+ if (options.config?.codexMainAccountHardLock === true) reconcileMainCodexAccountRuntimeState();
1039
+ const writer = captureObservedMainWriter();
794
1040
  const stored = getMainAccountToken();
795
1041
  // Fail BEFORE any upstream I/O. Falling through here would send the admission secret.
796
1042
  if (!stored?.accessToken || !isMainAccountTokenLive()) {
@@ -798,20 +1044,66 @@ export function materializeCodexUpstreamAuth(
798
1044
  }
799
1045
  selected.set("authorization", `Bearer ${stored.accessToken}`);
800
1046
  if (stored.chatgptAccountId) selected.set("chatgpt-account-id", stored.chatgptAccountId);
1047
+ observeSelectedMainCredential(stored, writer);
1048
+ assertMainAccountPolicy(options.config);
1049
+ assertMaterializedReserve(selected, ctx, options);
801
1050
  return selected;
802
1051
  }
1052
+ if (callerMatchesObservedMain(selected)) assertMainAccountPolicy(options.config);
1053
+ assertMaterializedReserve(selected, ctx, options);
803
1054
  return selected;
804
1055
  }
805
1056
 
1057
+ /** The model producer, not an optional context marker, decides whether a grant is required. */
1058
+ async function materializeReserveUpstreamAuth(
1059
+ headers: Headers,
1060
+ ctx: CodexAuthContext,
1061
+ options: CodexAuthMaterializationOptions,
1062
+ ): Promise<Headers> {
1063
+ if (ctx.kind === "pool") throw new CodexReserveUnavailableError();
1064
+ const config = options.config!;
1065
+ assertReserveAdmission(config);
1066
+ const writerGeneration = ctx.kind === "main-pool" ? ctx.writerGeneration : captureConfigGeneration();
1067
+ const storedMain = ctx.kind === "main"
1068
+ && (options.substituteMainCredential === true || !hasCallerCodexBearer(headers));
1069
+ let admission: CodexAccountSelectionAdmission | undefined;
1070
+ let writer = ctx.kind === "main-pool" ? ctx.mainQuotaWriter : undefined;
1071
+ try {
1072
+ if (storedMain) {
1073
+ if (isNativeMainTrafficBlocked()) throw new CodexMainProfileDrainingError();
1074
+ admission = options.beginCodexAccountSelection?.();
1075
+ if (!admission || admission.mainProfileDraining || !admission.claimMainProfile() || isNativeMainTrafficBlocked()) {
1076
+ throw new CodexMainProfileDrainingError();
1077
+ }
1078
+ reconcileMainCodexAccountRuntimeState();
1079
+ writer = captureObservedMainWriter();
1080
+ assertReserveAdmission(config);
1081
+ }
1082
+ // Build the real credential first, without recursively requiring a not-yet-fetched proof.
1083
+ // The ordinary hard lock remains enabled, including the post-refresh check.
1084
+ const selected = await materializeCodexUpstreamAuthAsync(headers, ctx, {
1085
+ ...options, modelId: undefined, substituteMainCredential: storedMain,
1086
+ });
1087
+ const token = selectedCodexToken(selected);
1088
+ if (ctx.kind === "main-pool") writer = ctx.mainQuotaWriter;
1089
+ else if (!storedMain) writer = captureMainQuotaWriter(token.chatgptAccountId);
1090
+ ctx.reserveAuthorization = await authorizeReserveCredential(token, writer, config, options.signal,
1091
+ ctx.reserveAuthorization, writerGeneration);
1092
+ assertMaterializedReserve(selected, ctx, options);
1093
+ return selected;
1094
+ } finally {
1095
+ admission?.release();
1096
+ }
1097
+ }
1098
+
806
1099
  export async function materializeCodexUpstreamAuthAsync(
807
1100
  headers: Headers,
808
1101
  ctx: CodexAuthContext,
809
- options: {
810
- substituteMainCredential?: boolean;
811
- signal?: AbortSignal;
812
- nativeMainRefreshDependencies?: NativeMainRefreshDependencies;
813
- } = {},
1102
+ options: CodexAuthMaterializationOptions = {},
814
1103
  ): Promise<Headers> {
1104
+ if (requiresReserveAuthorization(options.config, options.modelId, options.admission)) {
1105
+ return materializeReserveUpstreamAuth(headers, ctx, options);
1106
+ }
815
1107
  if (ctx.kind !== "main" || options.substituteMainCredential !== true) {
816
1108
  return materializeCodexUpstreamAuth(headers, ctx, options);
817
1109
  }
@@ -820,6 +1112,8 @@ export async function materializeCodexUpstreamAuthAsync(
820
1112
  const value = headers.get(name);
821
1113
  if (value) selected.set(name, value);
822
1114
  }
1115
+ if (options.config?.codexMainAccountHardLock === true) reconcileMainCodexAccountRuntimeState();
1116
+ const writer = captureObservedMainWriter();
823
1117
  const stored = await getValidMainAccountToken({
824
1118
  signal: options.signal,
825
1119
  ...(options.nativeMainRefreshDependencies ?? {}),
@@ -827,12 +1121,22 @@ export async function materializeCodexUpstreamAuthAsync(
827
1121
  if (!stored?.accessToken) throw new CodexMainSubstitutionUnavailableError();
828
1122
  selected.set("authorization", `Bearer ${stored.accessToken}`);
829
1123
  if (stored.chatgptAccountId) selected.set("chatgpt-account-id", stored.chatgptAccountId);
1124
+ observeSelectedMainCredential(stored, writer);
1125
+ assertMainAccountPolicy(options.config);
1126
+ // An opt-in enabled during token refresh must not turn a proof-less context into Reserve.
1127
+ assertMaterializedReserve(selected, ctx, options);
830
1128
  return selected;
831
1129
  }
832
1130
 
833
1131
  /** @deprecated Prefer materializeCodexUpstreamAuth; kept for call sites without admission context. */
834
- export function headersForCodexAuthContext(headers: Headers, ctx: CodexAuthContext): Headers {
835
- return materializeCodexUpstreamAuth(headers, ctx);
1132
+ export function headersForCodexAuthContext(
1133
+ headers: Headers,
1134
+ ctx: CodexAuthContext,
1135
+ config?: CodexAuthPolicyConfig,
1136
+ modelId?: string,
1137
+ admission?: Pick<DataPlaneAdmission, "source">,
1138
+ ): Headers {
1139
+ return materializeCodexUpstreamAuth(headers, ctx, { config, modelId, admission });
836
1140
  }
837
1141
 
838
1142
  export function isCodexAuthContextUsable(ctx: CodexAuthContext, config: OcxConfig): boolean {
@@ -35,7 +35,8 @@ import upstreamModelsSnapshot from "../data/upstream-models.json";
35
35
  import { generatedModelMetadata, readCatalog, readCodexCatalogPath } from "./parsing";
36
36
  import type { CatalogModel, RawEntry } from "./parsing";
37
37
  import { UPSTREAM_NATIVE_ENTRIES } from "./metadata";
38
- import { nativeOpenAiCapabilitySourceSlug, SELF_DESCRIBED_NATIVE_OPENAI_MODELS } from "./native-models";
38
+ import { nativeOpenAiCapabilitySourceSlug, SELF_DESCRIBED_NATIVE_OPENAI_MODELS, NATIVE_RESERVE_MODEL } from "./native-models";
39
+ import { isReserveCatalogProjection } from "./reserve";
39
40
  import { loadBundledCodexCatalog } from "./bundled";
40
41
  import type { BundledCatalogDeps, ReadonlyRawCatalog } from "./bundled";
41
42
  import { deriveEntry } from "./sync";
@@ -158,11 +159,14 @@ export function applyCatalogModelMetadata(entry: RawEntry, model?: CatalogModel)
158
159
  entry.supports_reasoning_summaries = model.supportsReasoningSummaries;
159
160
  }
160
161
  if (model.supportsServiceTier === true) {
162
+ const nativeFast = model.codexForwardNativeCapabilityAlias && Array.isArray(entry.service_tiers)
163
+ ? entry.service_tiers.find(tier => tier?.id === "priority")
164
+ : undefined;
161
165
  entry.default_service_tier = null;
162
166
  entry.service_tiers = [{
163
167
  id: "priority",
164
168
  name: "Fast",
165
- description: model.fastTierDescription ?? "1.5x speed, increased usage",
169
+ description: model.fastTierDescription ?? nativeFast?.description ?? "1.5x speed, increased usage",
166
170
  }];
167
171
  entry.additional_speed_tiers = ["fast"];
168
172
  }
@@ -337,6 +341,10 @@ export function clampedDefaultEffort(original: string, surviving: readonly strin
337
341
  return (atOrBelow.at(-1) ?? ranked[0]!).effort;
338
342
  }
339
343
 
344
+ function requiresExactReserveEfforts(entry: RawEntry): boolean {
345
+ return entry.slug === NATIVE_RESERVE_MODEL || isReserveCatalogProjection(entry);
346
+ }
347
+
340
348
  export function clampEntryToCodexSupportedEfforts(
341
349
  entry: RawEntry,
342
350
  supported: ReadonlySet<string> | null,
@@ -347,6 +355,19 @@ export function clampEntryToCodexSupportedEfforts(
347
355
  : null;
348
356
  if (levels && levels.length > 0) {
349
357
  const kept = levels.filter(level => typeof level?.effort === "string" && supported.has(level.effort));
358
+ if (requiresExactReserveEfforts(entry)) {
359
+ entry.supported_reasoning_levels = kept;
360
+ if (kept.length === 0) {
361
+ // The list-level clamp removes this incompatible row; never invent another ladder.
362
+ delete entry.default_reasoning_level;
363
+ } else if (!kept.some(level => level.effort === entry.default_reasoning_level)) {
364
+ entry.default_reasoning_level = clampedDefaultEffort(
365
+ typeof entry.default_reasoning_level === "string" ? entry.default_reasoning_level : "",
366
+ kept.map(level => level.effort!),
367
+ );
368
+ }
369
+ return;
370
+ }
350
371
  entry.supported_reasoning_levels = kept.length > 0
351
372
  ? kept
352
373
  : CODEX_REASONING_LEVELS
@@ -377,7 +398,9 @@ export function clampCatalogModelsToObservedCodexSupport(
377
398
 
378
399
  const removed = new Set<string>();
379
400
  const affected: string[] = [];
380
- for (const entry of models) {
401
+ for (let index = 0; index < models.length;) {
402
+ const entry = models[index]!;
403
+ const hadLadder = Array.isArray(entry.supported_reasoning_levels) && entry.supported_reasoning_levels.length > 0;
381
404
  const before = new Set(
382
405
  (Array.isArray(entry.supported_reasoning_levels) ? entry.supported_reasoning_levels : [])
383
406
  .flatMap(level => typeof (level as { effort?: string })?.effort === "string"
@@ -399,11 +422,14 @@ export function clampCatalogModelsToObservedCodexSupport(
399
422
  : null;
400
423
  const lost = [...before].filter(effort => !after.has(effort));
401
424
  const defaultClamped = Boolean(beforeDefault && beforeDefault !== afterDefault);
402
- if (lost.length > 0 || defaultClamped) {
425
+ const omitted = requiresExactReserveEfforts(entry) && hadLadder && after.size === 0;
426
+ if (lost.length > 0 || defaultClamped || omitted) {
403
427
  for (const effort of lost) removed.add(effort);
404
428
  if (defaultClamped && beforeDefault) removed.add(beforeDefault);
405
429
  if (typeof entry.slug === "string") affected.push(entry.slug);
406
430
  }
431
+ if (omitted) models.splice(index, 1);
432
+ else index += 1;
407
433
  }
408
434
 
409
435
  return {