@bitkyc08/opencodex 2.58.0 → 2.60.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 (303) hide show
  1. package/README.md +28 -10
  2. package/gui/dist/assets/index-BTuCbqQd.css +1 -0
  3. package/gui/dist/assets/index-DoBVdPHP.js +134 -0
  4. package/gui/dist/index.html +2 -2
  5. package/gui/dist/provider-icons/crusoe.svg +1 -0
  6. package/gui/dist/provider-icons/opper.svg +3 -0
  7. package/package.json +4 -1
  8. package/src/adapters/anthropic-image-codec.ts +16 -2
  9. package/src/adapters/anthropic-image-normalize.ts +49 -2
  10. package/src/adapters/anthropic.ts +4 -1
  11. package/src/adapters/base.ts +34 -1
  12. package/src/adapters/coding-agent/turn.ts +22 -2
  13. package/src/adapters/command-code.ts +50 -3
  14. package/src/adapters/cursor/catalog.ts +11 -0
  15. package/src/adapters/cursor/checkpoint-store.ts +3 -0
  16. package/src/adapters/cursor/discovery.ts +11 -8
  17. package/src/adapters/cursor/effort-map.ts +16 -2
  18. package/src/adapters/cursor/envelope-echo.ts +55 -2
  19. package/src/adapters/cursor/live-transport.ts +26 -9
  20. package/src/adapters/cursor/message-mapper.ts +3 -2
  21. package/src/adapters/cursor/protobuf-request.ts +8 -5
  22. package/src/adapters/cursor/request-builder.ts +21 -4
  23. package/src/adapters/cursor/thread-continuity.ts +105 -31
  24. package/src/adapters/cursor/tool-guidance.ts +5 -4
  25. package/src/adapters/cursor/transport.ts +19 -0
  26. package/src/adapters/cursor.ts +45 -2
  27. package/src/adapters/devin/cloud-direct/chat.ts +14 -3
  28. package/src/adapters/devin/cloud-direct/index.ts +7 -0
  29. package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +140 -0
  30. package/src/adapters/devin.ts +125 -26
  31. package/src/adapters/google-antigravity-replay.ts +1 -1
  32. package/src/adapters/google-antigravity-wire.ts +55 -4
  33. package/src/adapters/google-http.ts +57 -11
  34. package/src/adapters/google-tool-schema.ts +595 -31
  35. package/src/adapters/google-wire-compiler.ts +93 -10
  36. package/src/adapters/google-wire-shape.ts +461 -0
  37. package/src/adapters/google.ts +60 -10
  38. package/src/adapters/openai-chat/response-events.ts +61 -0
  39. package/src/adapters/openai-chat-images.ts +3 -1
  40. package/src/adapters/openai-chat.ts +10 -11
  41. package/src/adapters/openai-responses/image-gen.ts +8 -6
  42. package/src/adapters/openai-responses/passthrough.ts +25 -4
  43. package/src/adapters/openai-responses/reasoning.ts +7 -0
  44. package/src/adapters/openai-responses/tool-output-recovery.ts +75 -0
  45. package/src/adapters/openai-responses/tool-schema.ts +19 -7
  46. package/src/adapters/opencode-go-additional-tools.ts +12 -2
  47. package/src/adapters/responses-tool-schema.ts +76 -46
  48. package/src/adapters/run-turn-queue.ts +17 -4
  49. package/src/bridge/response-json.ts +1 -1
  50. package/src/bridge/sse.ts +28 -35
  51. package/src/claude/context-windows.ts +22 -0
  52. package/src/claude/outbound.ts +35 -4
  53. package/src/cli/account-api.ts +4 -3
  54. package/src/cli/account-extended.ts +26 -6
  55. package/src/cli/account-orca-import.ts +63 -0
  56. package/src/cli/account.ts +32 -4
  57. package/src/cli/capabilities.ts +40 -0
  58. package/src/cli/claude.ts +29 -1
  59. package/src/cli/codex-cli-update.ts +97 -2
  60. package/src/cli/dispatch.ts +57 -3
  61. package/src/cli/doctor.ts +218 -4
  62. package/src/cli/help.ts +4 -1
  63. package/src/cli/hub.ts +3 -2
  64. package/src/cli/index.ts +95 -22
  65. package/src/cli/models-runtime.ts +33 -4
  66. package/src/cli/opencode.ts +2 -2
  67. package/src/cli/provider.ts +13 -1
  68. package/src/cli/registry.ts +11 -1
  69. package/src/cli/runtime-api.ts +44 -0
  70. package/src/cli/start-args.ts +94 -0
  71. package/src/cli/system-command.ts +2 -0
  72. package/src/client/machine-api.ts +6 -5
  73. package/src/client/machine-listener.ts +16 -3
  74. package/src/client/runtime.ts +26 -2
  75. package/src/clients/config-export/constants.ts +2 -3
  76. package/src/clients/config-export.ts +5 -5
  77. package/src/codex/account-store.ts +146 -5
  78. package/src/codex/auth-api/account-list.ts +19 -11
  79. package/src/codex/auth-api/pool-quota-probe.ts +44 -10
  80. package/src/codex/auth-api/routes.ts +17 -2
  81. package/src/codex/auth-context.ts +16 -12
  82. package/src/codex/catalog/build-entries.ts +25 -4
  83. package/src/codex/catalog/derive-entry.ts +8 -1
  84. package/src/codex/catalog/effort.ts +10 -6
  85. package/src/codex/catalog/gather-capture.ts +22 -2
  86. package/src/codex/catalog/model-hints.ts +66 -33
  87. package/src/codex/catalog/parsing.ts +90 -5
  88. package/src/codex/catalog/provider-models.ts +19 -2
  89. package/src/codex/catalog/reserve-warn.ts +96 -0
  90. package/src/codex/catalog/retained-sync.ts +41 -26
  91. package/src/codex/catalog/routed-gather.ts +61 -3
  92. package/src/codex/cli-installation-identity.ts +210 -0
  93. package/src/codex/cli-installation-targets.ts +158 -0
  94. package/src/codex/context-compat.ts +5 -2
  95. package/src/codex/convergence.ts +5 -0
  96. package/src/codex/desired-state.ts +4 -1
  97. package/src/codex/history-job.ts +6 -6
  98. package/src/codex/history-provider.ts +24 -167
  99. package/src/codex/history-rollout-read.ts +174 -0
  100. package/src/codex/history-state-open.ts +105 -0
  101. package/src/codex/inject/config-toml.ts +44 -2
  102. package/src/codex/inject.ts +3 -2
  103. package/src/codex/internal/catalog-writer.ts +33 -1
  104. package/src/codex/lineage.ts +83 -32
  105. package/src/codex/loopback-target.ts +31 -0
  106. package/src/codex/main-account-hard-lock.ts +2 -1
  107. package/src/codex/main-account.ts +10 -3
  108. package/src/codex/main-device-reauth.ts +17 -9
  109. package/src/codex/model-cache.ts +47 -0
  110. package/src/codex/model-entitlements.ts +80 -2
  111. package/src/codex/observed-model-denials.ts +230 -0
  112. package/src/codex/orca-auth-source.ts +94 -0
  113. package/src/codex/orca-import.ts +219 -0
  114. package/src/codex/prompt-text-probe.ts +289 -16
  115. package/src/codex/quota-401-recovery.ts +12 -0
  116. package/src/codex/quota-types.ts +65 -0
  117. package/src/codex/quota.ts +24 -19
  118. package/src/codex/routing/cooldown-math.ts +8 -47
  119. package/src/codex/routing/pin-drain.ts +57 -0
  120. package/src/codex/routing.ts +20 -16
  121. package/src/codex/shim.ts +1 -1
  122. package/src/codex/subagent-model-fallback.ts +114 -2
  123. package/src/codex/windows-installation-files.ts +224 -0
  124. package/src/combos/failover.ts +125 -5
  125. package/src/config/admitted-identity.ts +222 -0
  126. package/src/config/diagnostics.ts +43 -1
  127. package/src/config/feature-flags.ts +5 -0
  128. package/src/config/load-degrade.ts +33 -0
  129. package/src/config/pending-teardown.ts +8 -0
  130. package/src/config/process-state.ts +36 -3
  131. package/src/config/provider-relative-send-path.ts +16 -0
  132. package/src/config/proxy-env.ts +31 -7
  133. package/src/config/schema/compaction-triggers.ts +11 -0
  134. package/src/config/schema/config-schema.ts +25 -0
  135. package/src/config/schema/leaf-validators.ts +76 -17
  136. package/src/config.ts +2 -2
  137. package/src/generated/compatibility-version.json +410 -258
  138. package/src/generated/model-metadata.ts +1 -1
  139. package/src/grok/reset-coupons.ts +38 -19
  140. package/src/images/loop.ts +6 -1
  141. package/src/integrations/aside-profile-context.ts +37 -3
  142. package/src/integrations/aside-profile-journal.ts +68 -3
  143. package/src/integrations/aside-profiles.ts +128 -3
  144. package/src/integrations/mutation-plan.ts +815 -0
  145. package/src/integrations/writer.ts +85 -99
  146. package/src/lab/live/transport.ts +4 -0
  147. package/src/lab/live/types.ts +5 -0
  148. package/src/lab/subject/behavior-fingerprint.ts +1 -1
  149. package/src/lib/admin-secrets.ts +9 -1
  150. package/src/lib/bounded-body.ts +4 -2
  151. package/src/lib/debug-log-buffer.ts +6 -1
  152. package/src/lib/debug.ts +23 -0
  153. package/src/lib/destination-policy.ts +48 -6
  154. package/src/lib/errors.ts +82 -15
  155. package/src/lib/http-response-semantics.ts +57 -0
  156. package/src/lib/lab-live-pinned-sender.ts +26 -12
  157. package/src/lib/local-destinations.ts +32 -5
  158. package/src/lib/pinned-http.ts +142 -2
  159. package/src/lib/plain-data.ts +103 -0
  160. package/src/lib/process-control.ts +13 -5
  161. package/src/lib/provider-outbound.ts +53 -5
  162. package/src/lib/proxy-env.ts +70 -3
  163. package/src/lib/request-execution-budget.ts +11 -3
  164. package/src/lib/response-body-inactivity.ts +193 -0
  165. package/src/lib/retry-delay.ts +69 -0
  166. package/src/lib/socks5-fetch.ts +741 -0
  167. package/src/lib/spend-ledger-owner.ts +364 -0
  168. package/src/lib/spend-reservation-ledger.ts +332 -35
  169. package/src/lib/windows-system-proxy.ts +16 -11
  170. package/src/lib/workflow-budget.ts +145 -8
  171. package/src/oauth/account-quota-rank.ts +72 -15
  172. package/src/oauth/callback-server.ts +4 -3
  173. package/src/oauth/generic-account-failover.ts +41 -27
  174. package/src/oauth/health.ts +12 -1
  175. package/src/oauth/index.ts +3 -107
  176. package/src/oauth/login-flow-state.ts +127 -0
  177. package/src/oauth/orcarouter.ts +15 -2
  178. package/src/oauth/store.ts +8 -0
  179. package/src/providers/codex-capacity.ts +9 -0
  180. package/src/providers/derive.ts +34 -17
  181. package/src/providers/devin-cli-authmode-migration.ts +14 -10
  182. package/src/providers/devin-provider-merge-migration.ts +33 -12
  183. package/src/providers/free-directory.ts +20 -2
  184. package/src/providers/key-failover.ts +327 -25
  185. package/src/providers/model-rename-migration.ts +56 -1
  186. package/src/providers/model-rename-startup.ts +7 -5
  187. package/src/providers/openai-sidecar.ts +4 -0
  188. package/src/providers/openai-virtual-models.ts +42 -2
  189. package/src/providers/opencode-go-transport.ts +14 -5
  190. package/src/providers/quota/antigravity.ts +22 -2
  191. package/src/providers/quota/report-cache.ts +3 -0
  192. package/src/providers/quota/vendor-probes-key.ts +1 -1
  193. package/src/providers/registry/entries-core.ts +39 -17
  194. package/src/providers/registry/entries-extended.ts +131 -1
  195. package/src/providers/registry/model-ids.ts +168 -0
  196. package/src/providers/registry/model-seeds.ts +87 -21
  197. package/src/providers/registry/types.ts +2 -0
  198. package/src/providers/resolved-model-policy-merge.ts +167 -0
  199. package/src/providers/resolved-model-policy.ts +406 -0
  200. package/src/providers/stale-vision-classification-migration.ts +137 -0
  201. package/src/responses/apply-patch-envelope.ts +32 -11
  202. package/src/responses/bridge-search-replay-cache.ts +152 -0
  203. package/src/responses/code-mode-helper-compat.ts +26 -16
  204. package/src/responses/custom-tool-compat.ts +1 -1
  205. package/src/responses/freeform-wrapper-scan.ts +279 -0
  206. package/src/responses/hosted-tool-policy.ts +85 -2
  207. package/src/responses/legacy-dotted-tool-name-repair.ts +134 -0
  208. package/src/responses/progressive-freeform-input.ts +130 -0
  209. package/src/responses/reasoning-envelope.ts +30 -0
  210. package/src/responses/schema.ts +9 -2
  211. package/src/responses/state.ts +5 -12
  212. package/src/responses/tool-name-aliases.ts +15 -1
  213. package/src/router.ts +91 -115
  214. package/src/routing/compatibility/behavior.ts +9 -0
  215. package/src/routing/compatibility/subject.ts +16 -1
  216. package/src/server/adapter-resolve.ts +9 -0
  217. package/src/server/auth-cors.ts +29 -0
  218. package/src/server/chat-completions.ts +13 -5
  219. package/src/server/chat-native-sse.ts +26 -9
  220. package/src/server/chat-native.ts +10 -4
  221. package/src/server/claude-messages.ts +30 -5
  222. package/src/server/effort-row.ts +11 -3
  223. package/src/server/grok-responses-control-frame.ts +160 -1
  224. package/src/server/gui-static.ts +36 -2
  225. package/src/server/inbound-body-admission.ts +187 -0
  226. package/src/server/index/serve-options.ts +86 -29
  227. package/src/server/index/spend-ledger-lifecycle.ts +66 -0
  228. package/src/server/index/websocket-handler.ts +6 -1
  229. package/src/server/index.ts +24 -25
  230. package/src/server/management/api-access.ts +3 -4
  231. package/src/server/management/aside-profile-routes.ts +266 -7
  232. package/src/server/management/config-routes.ts +48 -7
  233. package/src/server/management/context.ts +3 -0
  234. package/src/server/management/integration-routes.ts +287 -5
  235. package/src/server/management/metrics-routes.ts +20 -0
  236. package/src/server/management/model-rows.ts +224 -12
  237. package/src/server/management/provider-capability-config.ts +35 -7
  238. package/src/server/management/provider-routes.ts +70 -18
  239. package/src/server/management/route-registry.ts +14 -0
  240. package/src/server/management/shared.ts +10 -3
  241. package/src/server/management/system-restart.ts +7 -2
  242. package/src/server/management/system-routes.ts +2 -0
  243. package/src/server/management/usage-aggregate-cache.ts +4 -0
  244. package/src/server/management-api.ts +2 -0
  245. package/src/server/management-auth.ts +15 -1
  246. package/src/server/proxy-liveness.ts +97 -2
  247. package/src/server/readiness.ts +29 -10
  248. package/src/server/relay-eager.ts +24 -2
  249. package/src/server/relay.ts +136 -34
  250. package/src/server/request-log.ts +80 -3
  251. package/src/server/request-metrics.ts +236 -0
  252. package/src/server/responses/adapter-continuation.ts +74 -30
  253. package/src/server/responses/adapter-delivery.ts +39 -8
  254. package/src/server/responses/adapter-dispatch.ts +63 -30
  255. package/src/server/responses/compact.ts +89 -18
  256. package/src/server/responses/compaction-routing.ts +111 -0
  257. package/src/server/responses/core-codex-account.ts +90 -24
  258. package/src/server/responses/core-combo.ts +7 -7
  259. package/src/server/responses/core-normalize.ts +16 -15
  260. package/src/server/responses/core-opaque-recovery.ts +1 -0
  261. package/src/server/responses/core-options.ts +4 -0
  262. package/src/server/responses/encrypted-payload.ts +20 -2
  263. package/src/server/responses/fetch-helpers.ts +68 -2
  264. package/src/server/responses/passthrough-delivery.ts +41 -15
  265. package/src/server/responses/passthrough-dispatch.ts +145 -53
  266. package/src/server/responses/passthrough-execution.ts +11 -1
  267. package/src/server/responses/policy-fallback.ts +5 -13
  268. package/src/server/responses/request-prepare.ts +96 -17
  269. package/src/server/responses/request-send-budget.ts +89 -8
  270. package/src/server/responses/request-sidecar-auth.ts +17 -9
  271. package/src/server/responses/request-spend.ts +38 -9
  272. package/src/server/responses/request-transport.ts +15 -12
  273. package/src/server/responses/run-turn-execution.ts +45 -8
  274. package/src/server/responses/sidecar-execution.ts +19 -2
  275. package/src/server/responses/ws-upstream.ts +16 -28
  276. package/src/server/responses-custom-tool-repair.ts +29 -56
  277. package/src/server/responses-undeclared-tool-guard.ts +31 -1
  278. package/src/server/sse-frame-buffer.ts +12 -10
  279. package/src/server/sse-payload-rewrite.ts +37 -10
  280. package/src/server/system-env-shell.ts +5 -1
  281. package/src/server/system-env.ts +7 -1
  282. package/src/server/workflow-refusal.ts +56 -2
  283. package/src/service/cli.ts +16 -6
  284. package/src/service/guards.ts +10 -0
  285. package/src/service/health.ts +43 -0
  286. package/src/service/state.ts +7 -2
  287. package/src/tray/windows-tray.ps1 +155 -3
  288. package/src/types/accounts.ts +4 -0
  289. package/src/types/config.ts +111 -6
  290. package/src/types/provider.ts +37 -0
  291. package/src/types/request.ts +9 -1
  292. package/src/types/tools.ts +14 -0
  293. package/src/types/wire.ts +9 -1
  294. package/src/types.ts +1 -0
  295. package/src/usage/expected-prices.ts +28 -0
  296. package/src/usage/log.ts +87 -4
  297. package/src/vision/eligibility.ts +88 -9
  298. package/src/vision/plan.ts +34 -10
  299. package/src/web-search/executor.ts +41 -2
  300. package/src/web-search/loop.ts +6 -1
  301. package/src/web-search/passthrough-bridge.ts +39 -5
  302. package/gui/dist/assets/index-BbrHOIY0.js +0 -128
  303. package/gui/dist/assets/index-C5-RdDmD.css +0 -1
@@ -25,6 +25,7 @@ import {
25
25
  hasKeyPoolFailover,
26
26
  rotateProviderTransportOn401,
27
27
  rateLimitRetryDelayMs,
28
+ readQuotaResetAt,
28
29
  rotateProviderTransportOn429,
29
30
  } from "../../providers/key-failover";
30
31
  import {
@@ -127,6 +128,7 @@ export async function prepareAdapterExchange(
127
128
  ResponsesSendBudget,
128
129
  | "adapterDispatchBudget"
129
130
  | "noteAdapterPhysicalSend"
131
+ | "noteAdapterRecoveryWithheld"
130
132
  | "remainingTransientSendBudget"
131
133
  | "noteTransientSends"
132
134
  | "recoverySendAllowance"
@@ -160,6 +162,7 @@ export async function prepareAdapterExchange(
160
162
  const {
161
163
  adapterDispatchBudget,
162
164
  noteAdapterPhysicalSend,
165
+ noteAdapterRecoveryWithheld,
163
166
  remainingTransientSendBudget,
164
167
  noteTransientSends,
165
168
  recoverySendAllowance,
@@ -243,7 +246,11 @@ export async function prepareAdapterExchange(
243
246
  return new Response(JSON.stringify(json), { headers: { "Content-Type": "application/json" } });
244
247
  }
245
248
  try {
246
- initialRequest = await transportState.activeAdapter.buildRequest(parsed, { headers: requestState.selectedForwardHeaders, translatorBudget });
249
+ initialRequest = await transportState.activeAdapter.buildRequest(parsed, {
250
+ headers: requestState.selectedForwardHeaders,
251
+ translatorBudget,
252
+ abortSignal: upstream.signal,
253
+ });
247
254
  refreshRequestToolAliases(initialRequest);
248
255
  recordAdapterReasoning(logCtx, initialRequest);
249
256
  recordAdapterTier(logCtx, initialRequest);
@@ -286,6 +293,7 @@ export async function prepareAdapterExchange(
286
293
  timeoutMs: connectMs,
287
294
  sendBudget: adapterDispatchBudget,
288
295
  onPhysicalSend: send => noteAdapterPhysicalSend(inputTokenEstimate, send),
296
+ onRecoveryWithheld: noteAdapterRecoveryWithheld,
289
297
  stream: parsed.stream,
290
298
  executor: providerFetch(route.provider, options.codexWsRuntimeIdentity, {
291
299
  pacingSlotAcquired: true,
@@ -401,6 +409,7 @@ export async function prepareAdapterExchange(
401
409
  retryRequest = await transportState.activeAdapter.buildRequest(parsed, {
402
410
  headers: requestState.selectedForwardHeaders,
403
411
  translatorBudget,
412
+ abortSignal: upstream.signal,
404
413
  ...(transportState.imageTierBias > 0 ? { imageTierBias: transportState.imageTierBias } : {}),
405
414
  });
406
415
  recordAdapterReasoning(logCtx, retryRequest);
@@ -442,6 +451,7 @@ export async function prepareAdapterExchange(
442
451
  timeoutMs: connectMs,
443
452
  sendBudget: adapterDispatchBudget,
444
453
  onPhysicalSend: send => noteAdapterPhysicalSend(retryEstimate, send),
454
+ onRecoveryWithheld: noteAdapterRecoveryWithheld,
445
455
  stream: parsed.stream,
446
456
  executor: providerFetch(route.provider, options.codexWsRuntimeIdentity, {
447
457
  pacingSlotAcquired: true,
@@ -525,17 +535,17 @@ export async function prepareAdapterExchange(
525
535
  return { failed: formatErrorResponse(502, "upstream_error", msg) };
526
536
  }
527
537
  };
528
- // Keep recovery kinds in sync with the native Responses `passthroughRecovery:` loop above.
529
- recovery: for (;;) {
538
+ // Keep recovery kinds in sync with the native Responses `passthroughRecovery:` loop above.
539
+ recovery: for (;;) {
530
540
  // Preserve the terminal verdict through adapter and combo error formatting.
531
541
  // This also covers a reset reached by a 401/429/413 recovery refetch.
532
542
  if (isNonReplayableResponse(upstreamResponse)) {
533
543
  cleanupUpstreamAbort();
534
544
  return upstreamResponse;
535
545
  }
536
- if (
537
- upstreamResponse.status === 401
538
- && isOAuth401ReplayProvider
546
+ if (
547
+ upstreamResponse.status === 401
548
+ && isOAuth401ReplayProvider
539
549
  && transportState.sentOAuthSnapshot
540
550
  && !oauth401ReplayAttempted
541
551
  && !sendBudgetExhausted()
@@ -576,7 +586,7 @@ export async function prepareAdapterExchange(
576
586
  route.provider = refreshedProvider;
577
587
  invalidateSameTargetRequest();
578
588
  transportState.activeAdapter = resolveSelectionAdapter(
579
- resolveWireProtocolOverride(route.providerName, route.modelId, refreshedProvider, inboundWire),
589
+ resolveWireProtocolOverride(route.providerName, route.modelId, refreshedProvider, inboundWire, route.staticPolicy),
580
590
  config.cacheRetention,
581
591
  );
582
592
  bindRouteReasoningReplayScope({
@@ -609,7 +619,7 @@ export async function prepareAdapterExchange(
609
619
  route.provider = rotated;
610
620
  invalidateSameTargetRequest();
611
621
  transportState.activeAdapter = resolveSelectionAdapter(
612
- resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire),
622
+ resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire, route.staticPolicy),
613
623
  config.cacheRetention,
614
624
  );
615
625
  bindRouteReasoningReplayScope({
@@ -618,15 +628,15 @@ export async function prepareAdapterExchange(
618
628
  provider: route.provider,
619
629
  adapterName: transportState.activeAdapter.name,
620
630
  });
621
- const result = await rebuildAndRefetch("key-401");
622
- if ("failed" in result) return result.failed;
623
- upstreamResponse = result;
631
+ const result = await rebuildAndRefetch("key-401");
632
+ if ("failed" in result) return result.failed;
633
+ upstreamResponse = result;
624
634
  // A recovery refetch can itself die on an ambiguous pre-header reset, and the refusal
625
635
  // that answers it is a 429. Every arm below keys on 429, so letting it fall through
626
636
  // hands the marked refusal to the next waiting arm and replays the send it exists to
627
637
  // stop. Re-enter the loop guard instead, which returns it unchanged.
628
638
  if (isNonReplayableResponse(upstreamResponse)) continue recovery;
629
- }
639
+ }
630
640
 
631
641
  // Same-target 429 wait-and-retry (opt-in `retryOn429`, issue #487). Codex never retries
632
642
  // 429 itself (it retries 5xx only), and single-key pools cannot use the failover below,
@@ -663,23 +673,44 @@ export async function prepareAdapterExchange(
663
673
  upstream.abort();
664
674
  return clientCancelledResponse();
665
675
  }
666
- const result = await rebuildAndRefetch("rate-limit-429");
667
- if ("failed" in result) return result.failed;
668
- upstreamResponse = result;
676
+ const result = await rebuildAndRefetch("rate-limit-429");
677
+ if ("failed" in result) return result.failed;
678
+ upstreamResponse = result;
669
679
  // The refusal is a 429 too: without this the while condition is still true and the
670
680
  // next configured attempt replays it on the same target.
671
681
  if (isNonReplayableResponse(upstreamResponse)) continue recovery;
672
- }
682
+ }
673
683
 
674
684
  // Multi-key 429 failover: rotate to the next pool key (cooldown-aware) and retry the
675
685
  // SAME request once per remaining key. OAuth/forward providers and single-key pools
676
686
  // return null immediately, so this stays a no-op for them (src/providers/key-failover.ts).
677
687
  while (upstreamResponse.status === 429 && hasKeyPoolFailover(route.provider)) {
688
+ // A quota exhaustion is dated in the BODY, not in `Retry-After` — OpenRouter
689
+ // sends no header for it (#4024). Read a bounded prefix before the socket is
690
+ // released below; a failed or slow read just leaves the header path in charge.
691
+ // Peeks a bounded prefix and hands back a Response still carrying the whole
692
+ // body, so the cancel below still releases the socket.
693
+ let peeked: Awaited<ReturnType<typeof readQuotaResetAt>>;
694
+ try {
695
+ peeked = await readQuotaResetAt(upstreamResponse, { signal: options.abortSignal });
696
+ } catch {
697
+ cleanupUpstreamAbort();
698
+ upstream.abort();
699
+ return clientCancelledResponse();
700
+ }
701
+ if (options.abortSignal?.aborted) {
702
+ cleanupUpstreamAbort();
703
+ upstream.abort();
704
+ return clientCancelledResponse();
705
+ }
706
+ upstreamResponse = peeked.response;
707
+ const quotaResetAt = peeked.at;
678
708
  const rotated = rotateProviderTransportOn429(config, route.providerName, route.provider, {
679
709
  retryAfter: upstreamResponse.headers.get("retry-after"),
680
710
  now: Date.now(),
681
711
  attemptedKey: route.provider.apiKey,
682
712
  promptCacheKey: parsed.options.promptCacheKey,
713
+ quotaResetAt,
683
714
  });
684
715
  if (!rotated) break;
685
716
  // Release the failed response's socket before retrying; unread bodies otherwise linger
@@ -688,7 +719,7 @@ export async function prepareAdapterExchange(
688
719
  route.provider = rotated;
689
720
  invalidateSameTargetRequest();
690
721
  transportState.activeAdapter = resolveSelectionAdapter(
691
- resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire),
722
+ resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire, route.staticPolicy),
692
723
  config.cacheRetention,
693
724
  );
694
725
  bindRouteReasoningReplayScope({
@@ -697,13 +728,13 @@ export async function prepareAdapterExchange(
697
728
  provider: route.provider,
698
729
  adapterName: transportState.activeAdapter.name,
699
730
  });
700
- const result = await rebuildAndRefetch("key-429");
701
- if ("failed" in result) return result.failed;
702
- upstreamResponse = result;
731
+ const result = await rebuildAndRefetch("key-429");
732
+ if ("failed" in result) return result.failed;
733
+ upstreamResponse = result;
703
734
  // Rotating on the refusal would also write a cooldown against a key that rate-limited
704
735
  // nothing, which outlives the request.
705
736
  if (isNonReplayableResponse(upstreamResponse)) continue recovery;
706
- }
737
+ }
707
738
 
708
739
  // Opt-in Anthropic OAuth account pool (#294): cool the failed account and retry
709
740
  // with another eligible OAuth account (bounded per request). Disabled by default.
@@ -731,16 +762,16 @@ export async function prepareAdapterExchange(
731
762
  invalidateSameTargetRequest();
732
763
  logCtx.provider = formatAnthropicProviderForLog("anthropic", admitted.accountId, config);
733
764
  transportState.activeAdapter = resolveSelectionAdapter(
734
- resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire),
765
+ resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire, route.staticPolicy),
735
766
  config.cacheRetention,
736
767
  );
737
768
  sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, transportState.activeAdapter.name, logCtx.accountLogLabel);
738
769
  recordAttemptCredentialSource(logCtx.activeAttempt, route.providerName, route.provider, transportState.activeAdapter.name);
739
- const result = await rebuildAndRefetch("anthropic-oauth-429");
740
- if ("failed" in result) return result.failed;
741
- upstreamResponse = result;
770
+ const result = await rebuildAndRefetch("anthropic-oauth-429");
771
+ if ("failed" in result) return result.failed;
772
+ upstreamResponse = result;
742
773
  if (isNonReplayableResponse(upstreamResponse)) continue recovery;
743
- } catch {
774
+ } catch {
744
775
  break;
745
776
  }
746
777
  }
@@ -786,6 +817,8 @@ export async function prepareAdapterExchange(
786
817
  route.providerName,
787
818
  transportState.genericFailoverAccountId,
788
819
  upstreamResponse.headers.get("retry-after"),
820
+ Date.now(),
821
+ route.modelId,
789
822
  );
790
823
  if (!nextAccountId) {
791
824
  hop.permit?.release();
@@ -804,7 +837,7 @@ export async function prepareAdapterExchange(
804
837
  }
805
838
  invalidateSameTargetRequest();
806
839
  transportState.activeAdapter = resolveSelectionAdapter(
807
- resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire),
840
+ resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire, route.staticPolicy),
808
841
  config.cacheRetention,
809
842
  );
810
843
  sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, transportState.activeAdapter.name, logCtx.accountLogLabel);
@@ -832,12 +865,12 @@ export async function prepareAdapterExchange(
832
865
  hop.permit?.release();
833
866
  return result.failed;
834
867
  }
835
- upstreamResponse = result;
868
+ upstreamResponse = result;
836
869
  // The hop's permit is already settled by the dispatch boundary above; continuing
837
870
  // only skips the remaining arms, it does not abandon a reservation.
838
871
  if (isNonReplayableResponse(upstreamResponse)) continue recovery;
839
- } catch {
840
- // A throw before the send — snapshot fetch, credential application, adapter
872
+ } catch {
873
+ // A throw before the send — snapshot fetch, credential application, adapter
841
874
  // resolution — must hand the reservation back. Without this the ladder charges the
842
875
  // request for a send it never made, and a later recovery in the same request is
843
876
  // refused on an allowance nothing spent. release() is idempotent and a no-op once
@@ -10,6 +10,7 @@ import { parseRequest } from "../../responses/parser";
10
10
  import { buildCompactV1Output, COMPACT_PROMPT, decodeCompactionSummary, extractCompactUserMessages } from "../../responses/compaction";
11
11
  import { FORWARD_HEADERS, sanitizeReasoningInputContent } from "../../adapters/openai-responses";
12
12
  import { expandPreviousResponseInput, previousResponseProviderState, rememberResponseState } from "../../responses/state";
13
+ import { repairLegacyDottedToolCallNames } from "../../responses/legacy-dotted-tool-name-repair";
13
14
  import { NoEligiblePolicyCandidateError, routeCompactionModel } from "../../router";
14
15
  import { evidenceFromBody } from "../../routing/request-evidence";
15
16
  import {
@@ -107,6 +108,7 @@ import {
107
108
  } from "../../codex/upstream-host-health";
108
109
  import {
109
110
  ForwardAdmissionCredentialError,
111
+ contextPrincipalIdOf,
110
112
  hasForwardableCodexBearer,
111
113
  validateForwardAdmissionCredential,
112
114
  } from "../auth-cors";
@@ -114,12 +116,21 @@ import type { DataPlaneAdmission } from "../auth-cors";
114
116
  import { listOpenAiForwardSidecarCandidates, resolveFirstUsableOpenAiSidecar, type ResolvedOpenAiForwardSidecar } from "../../providers/openai-sidecar";
115
117
  import { CODEX_FORWARD_BASE_URL, isCanonicalOpenAiForwardProvider, supportsNativeResponsesCompactEndpoint } from "../../providers/openai-tiers";
116
118
  import { NATIVE_RESERVE_MODEL } from "../../codex/catalog/native-models";
117
- import { isCodexReserveRequestEligible } from "../../codex/loopback-target";
119
+ import {
120
+ isCodexReserveOptInMissing,
121
+ isCodexReserveRequestEligible,
122
+ CODEX_RESERVE_OPT_IN_REQUIRED_MESSAGE,
123
+ } from "../../codex/loopback-target";
118
124
  import { slugsEquivalent } from "../../providers/slug-codec";
119
125
  import { decideTier, tierValueAfterDecision } from "../../providers/fastwire";
120
126
  import { fastPolicyForModel } from "../../providers/service-tier";
121
127
  import { parseFastOnlyRowId } from "../fast-row";
122
- import { applyOpenAiVirtualModel, resolveOpenAiCompactModel } from "../../providers/openai-virtual-models";
128
+ import { captureOpenAiVirtualWirePolicy, resolveOpenAiCompactModel } from "../../providers/openai-virtual-models";
129
+ import {
130
+ applyCompactionRoutingOverride,
131
+ compactionRoutingKeepsProviderIdentity,
132
+ type CompactionRoutingOverride,
133
+ } from "./compaction-routing";
123
134
  import { isUsageDebugEnabled } from "../../usage/debug";
124
135
  import {
125
136
  readJsonRequestBody,
@@ -210,8 +221,33 @@ function pruneCompactHandoffRoutes(now: number): void {
210
221
  }
211
222
  }
212
223
 
213
- function rememberCompactHandoffRoute(req: Request, model: string, now = Date.now()): void {
214
- const key = sessionLaneIdFromRequest(req.headers);
224
+ /**
225
+ * The handoff map is process-global, so a caller-controlled lane header alone
226
+ * cannot be the key: two authenticated clients sending the same lane header
227
+ * would share one fallback route. Namespace the lane by the admitted principal.
228
+ * A loopback or missing admission has no authenticated identity to bind this
229
+ * cross-request state to, so it is ineligible rather than trusted.
230
+ */
231
+ function compactHandoffRouteKey(req: Request, admission: DataPlaneAdmission | undefined): string | null {
232
+ const lane = sessionLaneIdFromRequest(req.headers);
233
+ if (!lane || !admission || admission.kind === "loopback") return null;
234
+ // Fail closed rather than substituting a weaker identity. `keyId` survives a
235
+ // rotation and every identity-less environment admission would collapse into
236
+ // one bucket, which is the collision this key exists to prevent. Production
237
+ // admission always mints `contextPrincipalId` for configured and environment
238
+ // holders, so no real authenticated caller loses the route.
239
+ const principal = contextPrincipalIdOf(admission);
240
+ if (!principal) return null;
241
+ return `${principal}\u0000${lane}`;
242
+ }
243
+
244
+ function rememberCompactHandoffRoute(
245
+ req: Request,
246
+ admission: DataPlaneAdmission | undefined,
247
+ model: string,
248
+ now = Date.now(),
249
+ ): void {
250
+ const key = compactHandoffRouteKey(req, admission);
215
251
  if (!key || model.length > COMPACT_HANDOFF_MODEL_MAX_LENGTH) return;
216
252
  pruneCompactHandoffRoutes(now);
217
253
  compactHandoffRoutes.delete(key);
@@ -219,13 +255,18 @@ function rememberCompactHandoffRoute(req: Request, model: string, now = Date.now
219
255
  pruneCompactHandoffRoutes(now);
220
256
  }
221
257
 
222
- function forgetCompactHandoffRoute(req: Request): void {
223
- const key = sessionLaneIdFromRequest(req.headers);
258
+ function forgetCompactHandoffRoute(req: Request, admission?: DataPlaneAdmission): void {
259
+ const key = compactHandoffRouteKey(req, admission);
224
260
  if (key) compactHandoffRoutes.delete(key);
225
261
  }
226
262
 
227
- function compactHandoffRoute(req: Request, previousModel: string, now = Date.now()): string | null {
228
- const key = sessionLaneIdFromRequest(req.headers);
263
+ function compactHandoffRoute(
264
+ req: Request,
265
+ admission: DataPlaneAdmission | undefined,
266
+ previousModel: string,
267
+ now = Date.now(),
268
+ ): string | null {
269
+ const key = compactHandoffRouteKey(req, admission);
229
270
  if (!key) return null;
230
271
  pruneCompactHandoffRoutes(now);
231
272
  const entry = compactHandoffRoutes.get(key);
@@ -236,6 +277,7 @@ function compactHandoffRoute(req: Request, previousModel: string, now = Date.now
236
277
  }
237
278
 
238
279
  export interface HandleResponsesCompactOptions {
280
+ compactionRoutingOverride?: CompactionRoutingOverride | null;
239
281
  nativeMainRefreshDependencies?: NativeMainRefreshDependencies;
240
282
  /** Release the listener's idle guard only after the complete request body is accepted. */
241
283
  onRequestBodyRead?: () => void;
@@ -568,6 +610,9 @@ export async function handleResponsesCompact(
568
610
  if (!body || typeof body !== "object" || Array.isArray(body)) {
569
611
  return formatErrorResponse(400, "invalid_request_error", "Invalid compaction request body");
570
612
  }
613
+ if (!options.compactionRoutingOverride) {
614
+ options = { ...options, compactionRoutingOverride: applyCompactionRoutingOverride(body, req.headers, config, { endpoint: "compact" }) };
615
+ }
571
616
  const raw = body as { model?: unknown; input?: unknown };
572
617
  if (typeof raw.model !== "string" || raw.model.length === 0) {
573
618
  return formatErrorResponse(400, "invalid_request_error", "compaction request requires a model");
@@ -582,11 +627,12 @@ export async function handleResponsesCompact(
582
627
  // The client's own selector, kept for the request log: `raw.model` is rewritten to the
583
628
  // base id above, and logCtx.requestedModel is assigned from it further down, so without
584
629
  // this the log would lose which id the client actually asked for.
585
- const compactRequestedModel = compactFastRow ? compactFastRow.baseId + "--fast" : raw.model;
630
+ const compactRequestedModel = options.compactionRoutingOverride?.sourceModel
631
+ ?? (compactFastRow ? compactFastRow.baseId + "--fast" : raw.model);
586
632
 
587
633
  // Recall the last completed client-visible bare model after a combo switch (#3891).
588
634
  // Configured selectors take precedence over this implicit session hint.
589
- if (typeof compactModel === "string" && !compactModel.includes("/") && !compactFastRow
635
+ if (!options.compactionRoutingOverride && typeof compactModel === "string" && !compactModel.includes("/") && !compactFastRow
590
636
  && !resolveComboId(config, compactModel)) {
591
637
  const recalledComboId = recallComboForLane(config, sessionLaneIdFromRequest(req.headers), compactModel);
592
638
  if (recalledComboId) {
@@ -632,9 +678,24 @@ export async function handleResponsesCompact(
632
678
  ? `${route.providerName}-${route.codexAccountNamespace}`
633
679
  : route.providerName;
634
680
  logCtx.providerAdapter = route.provider.adapter;
681
+ // #4940, and the same refusal the ordinary Responses path makes in request-prepare.ts. Compact has
682
+ // to repeat it rather than inherit it: the native branch below dispatches straight to
683
+ // `/responses/compact` and only the routed fallback replays through handleResponses, so relying on
684
+ // that one seam would leave the native compact turn forwarding a Reserve model the opt-in has made
685
+ // unservable. Composed from the same three facts, in the same order, as `customReserveForward`
686
+ // further down, and placed before the virtual-model rewrite so it reads the model the caller
687
+ // actually selected -- and before auth, host-circuit admission, or any upstream byte.
688
+ //
689
+ // No terminal-helper or inbound-wire qualifier is needed here the way it is on the ordinary path:
690
+ // this endpoint is the native Codex compaction wire, and a vision/search helper never reaches it.
691
+ if (isCodexReserveOptInMissing(config, selectedModelId, admission)
692
+ && isCanonicalOpenAiForwardProvider(route.provider)) {
693
+ return formatErrorResponse(400, "invalid_request_error", CODEX_RESERVE_OPT_IN_REQUIRED_MESSAGE);
694
+ }
635
695
  const virtual = resolveOpenAiCompactModel(route.providerName, selectedModelId);
636
696
  if (virtual) {
637
697
  route.modelId = virtual.wireModelId;
698
+ captureOpenAiVirtualWirePolicy(route, virtual);
638
699
  logCtx.model = virtual.selectedModelId;
639
700
  logCtx.resolvedModel = virtual.wireModelId;
640
701
  } else {
@@ -698,7 +759,12 @@ export async function handleResponsesCompact(
698
759
  // no budget at all, so `handleResponsesInner` minted a fresh four after the native attempt
699
760
  // had already spent some of the first one.
700
761
  const sendBudget: RequestExecutionBudget = options.sendBudget ?? createRequestExecutionBudget();
701
- if (supportsNativeResponsesCompactEndpoint(route.providerName, route.provider) && !accountGatedCompactWireModel && !route.combo) {
762
+ // A manual override onto another backend must not mint ciphertext the conversation model cannot replay.
763
+ const manualOverrideCrossesProvider = options.compactionRoutingOverride
764
+ ? !compactionRoutingKeepsProviderIdentity(config, options.compactionRoutingOverride, route)
765
+ : false;
766
+ if (supportsNativeResponsesCompactEndpoint(route.providerName, route.provider) && !accountGatedCompactWireModel && !route.combo
767
+ && !manualOverrideCrossesProvider) {
702
768
  if (req.signal.aborted) {
703
769
  return formatErrorResponse(499, "client_cancelled", "Client cancelled compact request");
704
770
  }
@@ -796,7 +862,12 @@ export async function handleResponsesCompact(
796
862
  // The regular /v1/responses path applies sanitizeReasoningInputContent via the adapter's
797
863
  // buildRequest, but the compact endpoint forwards directly. Apply the same sanitizer here
798
864
  // so routed-model reasoning items (reasoning_text content) don't 400 the ChatGPT backend.
799
- const compactBody = sanitizeReasoningInputContent(compactBodyRaw) as typeof compactBodyRaw;
865
+ // #5095: the compact endpoint forwards `raw` directly, so it needs the same legacy dotted
866
+ // call-name repair the adapter applies. A damaged item here refuses the compaction itself,
867
+ // which is the request a long task depends on to keep going.
868
+ const compactBody = repairLegacyDottedToolCallNames(
869
+ sanitizeReasoningInputContent(compactBodyRaw),
870
+ ) as typeof compactBodyRaw;
800
871
  {
801
872
  const binding = conversationStateBindingFromAuth(authCtx, codexPoolAffinityKey(req.headers));
802
873
  if (binding) {
@@ -1254,10 +1325,10 @@ export async function handleResponsesCompact(
1254
1325
  // synthetic buffer errors are not upstream bodies and stay uninspected.
1255
1326
  if (buffered.ok) {
1256
1327
  inspectResponseLogJson(logCtx, await buffered.clone().text());
1257
- forgetCompactHandoffRoute(req);
1328
+ if (!options.compactionRoutingOverride) forgetCompactHandoffRoute(req, admission);
1258
1329
  rememberServingConversationStateIssuer(outcomeCtx, codexPoolAffinityKey(req.headers));
1259
- } else if (quotaFailure && !storedPool401ReplayAttempted) {
1260
- const fallbackModel = compactHandoffRoute(req, raw.model);
1330
+ } else if (!options.compactionRoutingOverride && quotaFailure && !storedPool401ReplayAttempted) {
1331
+ const fallbackModel = compactHandoffRoute(req, admission, raw.model);
1261
1332
  if (fallbackModel && !req.signal.aborted) {
1262
1333
  const fallbackReq = new Request(req.url, {
1263
1334
  method: "POST",
@@ -1319,7 +1390,7 @@ export async function handleResponsesCompact(
1319
1390
  // The routed compaction turn is a handoff inside the same logical request, so it draws the
1320
1391
  // REMAINDER. Minting here is what let a native attempt spend three sends and the routed
1321
1392
  // fallback spend four more.
1322
- const response = await handleResponses(internalReq, config, logCtx, { abortSignal: req.signal, turnAdmissionLease, sendBudget, ...(admission ? { admission } : {}) });
1393
+ const response = await handleResponses(internalReq, config, logCtx, { abortSignal: req.signal, turnAdmissionLease, sendBudget, compactionRoutingOverride: options.compactionRoutingOverride, ...(admission ? { admission } : {}) });
1323
1394
  if (!response.ok) return response;
1324
1395
  let json: { output?: unknown[]; status?: unknown; error?: unknown };
1325
1396
  if (response.headers.get("content-type")?.includes("text/event-stream")) {
@@ -1389,7 +1460,7 @@ export async function handleResponsesCompact(
1389
1460
  const result = new Response(JSON.stringify({ output: compactionItems }), {
1390
1461
  headers: { "Content-Type": "application/json" },
1391
1462
  });
1392
- rememberCompactHandoffRoute(req, raw.model);
1463
+ if (!options.compactionRoutingOverride) rememberCompactHandoffRoute(req, admission, raw.model);
1393
1464
  return result;
1394
1465
  }
1395
1466
  const encrypted = compactionItems[0]!.encrypted_content;
@@ -1400,6 +1471,6 @@ export async function handleResponsesCompact(
1400
1471
  }
1401
1472
  const summary = decoded;
1402
1473
  const output = buildCompactV1Output(extractCompactUserMessages(inputItems), summary);
1403
- rememberCompactHandoffRoute(req, raw.model);
1474
+ if (!options.compactionRoutingOverride) rememberCompactHandoffRoute(req, admission, raw.model);
1404
1475
  return new Response(JSON.stringify({ output }), { headers: { "Content-Type": "application/json" } });
1405
1476
  }
@@ -0,0 +1,111 @@
1
+ import type { OcxConfig } from "../../types";
2
+ import { isDeclaredReasoningEffort } from "../../reasoning-effort";
3
+ import { COMPACTION_TRIGGERS } from "../../config/schema/compaction-triggers";
4
+ import { routeConcreteModel, type RouteResult } from "../../router";
5
+ import { resolveComboId } from "../../combos/identifiers";
6
+ import { recallComboForLane } from "./combo-session-recall";
7
+ import { sessionLaneIdFromRequest } from "../request-log-conversation";
8
+
9
+ /** `sourceModel` is the conversation's own selector before the rewrite. */
10
+ export interface CompactionRoutingOverride {
11
+ sourceModel: string;
12
+ /** Combo the lane remembers for a bare `sourceModel` (#3891); the conversation resumes there, not on the bare route. */
13
+ sourceCombo?: string;
14
+ /** Combo the configured override resolves to; its children route concretely but stay portable. */
15
+ targetCombo?: string;
16
+ }
17
+
18
+ function record(value: unknown): Record<string, unknown> | undefined {
19
+ return value !== null && typeof value === "object" && !Array.isArray(value)
20
+ ? value as Record<string, unknown>
21
+ : undefined;
22
+ }
23
+
24
+ /**
25
+ * Triggers this override covers. Absent means manual only, which is the narrowest
26
+ * reading of the setting and leaves automatic compaction exactly as it routes today.
27
+ * A hand-edited value the schema would have rejected disables the override rather
28
+ * than widening it, so a malformed edit can never route more than it names.
29
+ */
30
+ function configuredTriggers(value: unknown): readonly string[] | null {
31
+ if (value === undefined) return ["manual"];
32
+ if (!Array.isArray(value) || value.length === 0) return null;
33
+ if (!value.every(entry => typeof entry === "string" && (COMPACTION_TRIGGERS as readonly string[]).includes(entry))) return null;
34
+ if (new Set(value).size !== value.length) return null;
35
+ return value as readonly string[];
36
+ }
37
+
38
+ export interface CompactionRoutingOverrideOptions {
39
+ /** `responses` requires a `compaction_trigger` input item; the native compact endpoint carries none. */
40
+ endpoint?: "responses" | "compact";
41
+ transport?: "websocket";
42
+ }
43
+
44
+ export function applyCompactionRoutingOverride(
45
+ body: unknown,
46
+ headers: Headers,
47
+ config: OcxConfig,
48
+ options: CompactionRoutingOverrideOptions = {},
49
+ ): CompactionRoutingOverride | null {
50
+ const override = config.compactionRouting;
51
+ const raw = record(body);
52
+ if (!raw || typeof raw.model !== "string" || !raw.model.trim()
53
+ || typeof override?.model !== "string" || !override.model.trim()) return null;
54
+ if (override.reasoningEffort !== undefined
55
+ && (typeof override.reasoningEffort !== "string" || !isDeclaredReasoningEffort(override.reasoningEffort))) return null;
56
+ const triggers = configuredTriggers(override.triggers);
57
+ if (!triggers) return null;
58
+ if (options.endpoint !== "compact"
59
+ && !(Array.isArray(raw.input) && raw.input.some(item => record(item)?.type === "compaction_trigger"))) return null;
60
+
61
+ const metadata: unknown[] = [];
62
+ const header = headers.get("x-codex-turn-metadata");
63
+ if (options.transport !== "websocket" && header !== null) metadata.push(header);
64
+ const client = record(raw.client_metadata);
65
+ if (client && Object.hasOwn(client, "x-codex-turn-metadata")) metadata.push(client["x-codex-turn-metadata"]);
66
+ if (metadata.length === 0) return null;
67
+ let trigger: string | undefined;
68
+ for (const value of metadata) {
69
+ if (typeof value !== "string") return null;
70
+ try {
71
+ const parsed = record(JSON.parse(value));
72
+ if (parsed?.request_kind !== "compaction") return null;
73
+ const carried = record(parsed.compaction)?.trigger;
74
+ if (typeof carried !== "string" || !triggers.includes(carried)) return null;
75
+ // Copies that name different triggers are not agreement, and picking either one would
76
+ // let a caller widen an override by disagreeing with itself.
77
+ if (trigger !== undefined && trigger !== carried) return null;
78
+ trigger = carried;
79
+ } catch {
80
+ return null;
81
+ }
82
+ }
83
+ if (trigger === undefined) return null;
84
+
85
+ const sourceModel = raw.model;
86
+ const sourceCombo = recallComboForLane(config, sessionLaneIdFromRequest(headers), sourceModel);
87
+ const targetCombo = resolveComboId(config, override.model.trim()) ?? undefined;
88
+ raw.model = override.model.trim();
89
+ if (override.reasoningEffort !== undefined) {
90
+ raw.reasoning = { ...record(raw.reasoning), effort: override.reasoningEffort };
91
+ }
92
+ return { sourceModel, ...(sourceCombo ? { sourceCombo } : {}), ...(targetCombo ? { targetCombo } : {}) };
93
+ }
94
+
95
+ /** Same provider identity keeps caller auth and may use native compact; its ciphertext replays only there. */
96
+ export function compactionRoutingKeepsProviderIdentity(
97
+ config: OcxConfig,
98
+ override: CompactionRoutingOverride,
99
+ route: RouteResult,
100
+ ): boolean {
101
+ if (route.combo || override.sourceCombo || override.targetCombo || resolveComboId(config, override.sourceModel)) return false;
102
+ let source: RouteResult;
103
+ try {
104
+ source = routeConcreteModel(config, override.sourceModel);
105
+ } catch {
106
+ return false;
107
+ }
108
+ return source.providerName === route.providerName
109
+ && source.codexAccountMode === route.codexAccountMode
110
+ && source.codexAccountNamespace === route.codexAccountNamespace;
111
+ }