@bitkyc08/opencodex 2.59.0 → 2.61.0-preview.20260922

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 (374) hide show
  1. package/AGENTS_INSTALL.md +64 -0
  2. package/README.md +28 -1
  3. package/bin/ocx.mjs +382 -209
  4. package/gui/dist/assets/App-E64Rzjap.js +50 -0
  5. package/gui/dist/assets/Tray-_nfzD8k4.js +1 -0
  6. package/gui/dist/assets/index-DpdfZWMK.js +86 -0
  7. package/gui/dist/assets/index-_bpvxJu0.css +1 -0
  8. package/gui/dist/assets/usage-companion-chart-DtoK7T6h.js +1 -0
  9. package/gui/dist/favicon.png +0 -0
  10. package/gui/dist/index.html +2 -2
  11. package/gui/dist/provider-icons/stepfun-color.svg +1 -0
  12. package/package.json +8 -1
  13. package/src/adapters/anthropic-image-codec.ts +16 -2
  14. package/src/adapters/anthropic-image-normalize.ts +49 -2
  15. package/src/adapters/anthropic.ts +20 -1
  16. package/src/adapters/base.ts +23 -0
  17. package/src/adapters/coding-agent/protocol.ts +36 -6
  18. package/src/adapters/coding-agent/turn.ts +32 -4
  19. package/src/adapters/command-code.ts +52 -4
  20. package/src/adapters/cursor/catalog.ts +51 -7
  21. package/src/adapters/cursor/checkpoint-store.ts +3 -0
  22. package/src/adapters/cursor/discovery.ts +11 -8
  23. package/src/adapters/cursor/live-transport.ts +26 -9
  24. package/src/adapters/cursor/protobuf-request.ts +6 -3
  25. package/src/adapters/cursor/request-builder.ts +20 -4
  26. package/src/adapters/cursor/transport.ts +19 -0
  27. package/src/adapters/cursor.ts +14 -3
  28. package/src/adapters/declaration-carrier.ts +45 -0
  29. package/src/adapters/devin/cloud-direct/chat.ts +3 -1
  30. package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +42 -5
  31. package/src/adapters/devin.ts +125 -36
  32. package/src/adapters/google-antigravity-replay.ts +1 -1
  33. package/src/adapters/google-antigravity-wire.ts +34 -7
  34. package/src/adapters/google-errors.ts +7 -1
  35. package/src/adapters/google-http.ts +49 -10
  36. package/src/adapters/google-tool-schema.ts +595 -31
  37. package/src/adapters/google-wire-compiler.ts +93 -10
  38. package/src/adapters/google-wire-shape.ts +461 -0
  39. package/src/adapters/google.ts +66 -11
  40. package/src/adapters/image.ts +4 -1
  41. package/src/adapters/input-media-guard.ts +21 -9
  42. package/src/adapters/kiro/usage.ts +3 -2
  43. package/src/adapters/kiro-tool-fallback.ts +1 -1
  44. package/src/adapters/ollama-native.ts +6 -0
  45. package/src/adapters/openai-chat/developer-role.ts +61 -0
  46. package/src/adapters/openai-chat/messages.ts +46 -27
  47. package/src/adapters/openai-chat/parallel-tool-calls.ts +32 -0
  48. package/src/adapters/openai-chat/passthrough.ts +33 -9
  49. package/src/adapters/openai-chat/reasoning-wire.ts +89 -0
  50. package/src/adapters/openai-chat-images.ts +3 -1
  51. package/src/adapters/openai-chat.ts +23 -58
  52. package/src/adapters/openai-responses/image-gen.ts +8 -6
  53. package/src/adapters/openai-responses/passthrough.ts +17 -3
  54. package/src/adapters/openai-responses/reasoning.ts +7 -0
  55. package/src/adapters/opencode-go-additional-tools.ts +12 -2
  56. package/src/adapters/registry.ts +3 -2
  57. package/src/adapters/run-turn-queue.ts +178 -29
  58. package/src/adapters/xai-web-search.ts +16 -1
  59. package/src/bridge/errors.ts +8 -2
  60. package/src/bridge/response-json.ts +9 -1
  61. package/src/bridge/sse.ts +13 -151
  62. package/src/chat/inbound.ts +141 -5
  63. package/src/claude/desktop-3p.ts +7 -1
  64. package/src/claude/desktop-first-party.ts +183 -0
  65. package/src/claude/desktop-gateway-state.ts +41 -0
  66. package/src/claude/inbound-content-options.ts +6 -0
  67. package/src/claude/inbound.ts +32 -6
  68. package/src/claude/intercept/connect-proxy.ts +179 -0
  69. package/src/claude/intercept/listener.ts +122 -0
  70. package/src/claude/intercept/local-ca.ts +298 -0
  71. package/src/claude/intercept/runtime.ts +98 -0
  72. package/src/claude/intercept/settings.ts +189 -0
  73. package/src/cli/access.ts +87 -0
  74. package/src/cli/account-auth.ts +19 -0
  75. package/src/cli/account-extended.ts +4 -4
  76. package/src/cli/capabilities.ts +31 -0
  77. package/src/cli/claude-desktop.ts +206 -16
  78. package/src/cli/codex-shim-autorestore.ts +3 -0
  79. package/src/cli/companion.ts +56 -0
  80. package/src/cli/dispatch.ts +46 -7
  81. package/src/cli/doctor.ts +28 -9
  82. package/src/cli/ensure-desired-integrations.ts +43 -5
  83. package/src/cli/help.ts +7 -9
  84. package/src/cli/hub.ts +3 -2
  85. package/src/cli/index.ts +203 -59
  86. package/src/cli/init.ts +8 -0
  87. package/src/cli/integrations.ts +7 -1
  88. package/src/cli/opencode.ts +2 -2
  89. package/src/cli/provider.ts +13 -1
  90. package/src/cli/registry.ts +41 -2
  91. package/src/cli/resolve.ts +230 -0
  92. package/src/cli/root.ts +24 -1
  93. package/src/cli/start-ownership-publication.ts +56 -0
  94. package/src/cli/status-probes.ts +2 -18
  95. package/src/cli/status.ts +62 -0
  96. package/src/cli/stop-report.ts +143 -0
  97. package/src/cli/uninstall-plan.ts +9 -0
  98. package/src/client/machine-api.ts +2 -2
  99. package/src/client/machine-listener.ts +4 -7
  100. package/src/client/runtime.ts +26 -2
  101. package/src/clients/aside-profiles.ts +4 -0
  102. package/src/clients/config-export/zcode-store.ts +157 -0
  103. package/src/clients/config-export.ts +36 -0
  104. package/src/codex/account-store.ts +65 -0
  105. package/src/codex/app-server-processes.ts +72 -40
  106. package/src/codex/auth-api/account-list.ts +19 -11
  107. package/src/codex/auth-api/login-flow.ts +6 -1
  108. package/src/codex/auth-api/pool-quota-probe.ts +30 -7
  109. package/src/codex/autostart-health.ts +28 -0
  110. package/src/codex/catalog/build-entries.ts +2 -2
  111. package/src/codex/catalog/effort.ts +3 -3
  112. package/src/codex/catalog/gather-capture.ts +21 -2
  113. package/src/codex/catalog/model-hints.ts +29 -28
  114. package/src/codex/catalog/parsing.ts +7 -0
  115. package/src/codex/catalog/provider-models.ts +43 -17
  116. package/src/codex/catalog/retained-sync.ts +24 -28
  117. package/src/codex/catalog/routed-gather.ts +19 -0
  118. package/src/codex/context-compat.ts +5 -2
  119. package/src/codex/convergence.ts +2 -2
  120. package/src/codex/desired-state.ts +4 -1
  121. package/src/codex/history-job.ts +6 -6
  122. package/src/codex/history-provider.ts +31 -166
  123. package/src/codex/history-rollout-read.ts +174 -0
  124. package/src/codex/inject/config-toml.ts +41 -6
  125. package/src/codex/inject/paginated-openai-compat.ts +90 -0
  126. package/src/codex/inject.ts +18 -15
  127. package/src/codex/injected-marker.ts +18 -0
  128. package/src/codex/internal/catalog-writer.ts +33 -1
  129. package/src/codex/main-account.ts +6 -0
  130. package/src/codex/model-cache.ts +99 -6
  131. package/src/codex/model-entitlement-admission.ts +59 -0
  132. package/src/codex/model-entitlements.ts +116 -54
  133. package/src/codex/native-main-admission.ts +83 -0
  134. package/src/codex/observed-model-denials.ts +101 -8
  135. package/src/codex/prompt-text-probe.ts +9 -6
  136. package/src/codex/routing/health-store.ts +39 -0
  137. package/src/codex/routing/selection.ts +37 -1
  138. package/src/codex/routing.ts +12 -42
  139. package/src/codex/shim-templates.ts +29 -3
  140. package/src/codex/shim.ts +1 -1
  141. package/src/codex/subagent-model-fallback.ts +22 -4
  142. package/src/combos/failover.ts +3 -0
  143. package/src/companion/settings.ts +132 -0
  144. package/src/config/admitted-identity.ts +222 -0
  145. package/src/config/atomic-write.ts +117 -5
  146. package/src/config/diagnostics.ts +22 -1
  147. package/src/config/feature-flags.ts +5 -0
  148. package/src/config/load-degrade.ts +52 -7
  149. package/src/config/process-state.ts +1 -1
  150. package/src/config/proxy-env.ts +8 -2
  151. package/src/config/schema/compaction-triggers.ts +11 -0
  152. package/src/config/schema/config-schema.ts +31 -1
  153. package/src/config/schema/leaf-validators.ts +59 -0
  154. package/src/config.ts +3 -3
  155. package/src/generated/compatibility-version.json +604 -280
  156. package/src/grok/reset-coupons.ts +38 -19
  157. package/src/images/loop.ts +6 -1
  158. package/src/integrations/aside-profile-context.ts +37 -3
  159. package/src/integrations/aside-profile-journal.ts +68 -3
  160. package/src/integrations/aside-profiles.ts +128 -3
  161. package/src/integrations/config-io.ts +44 -10
  162. package/src/integrations/merge.ts +120 -13
  163. package/src/integrations/mutation-plan.ts +921 -0
  164. package/src/integrations/registry.ts +38 -0
  165. package/src/integrations/state.ts +78 -45
  166. package/src/integrations/target.ts +208 -0
  167. package/src/integrations/writer.ts +134 -110
  168. package/src/lab/conformance/fixture-provider.ts +5 -0
  169. package/src/lab/live/transport.ts +4 -0
  170. package/src/lab/live/types.ts +5 -0
  171. package/src/lab/subject/behavior-fingerprint.ts +1 -1
  172. package/src/lib/admin-secrets.ts +9 -1
  173. package/src/lib/browser-launch-notice.ts +59 -0
  174. package/src/lib/bun-runtime.ts +6 -2
  175. package/src/lib/debug-log-buffer.ts +6 -1
  176. package/src/lib/debug.ts +63 -0
  177. package/src/lib/errors.ts +79 -0
  178. package/src/lib/http-response-semantics.ts +57 -0
  179. package/src/lib/lab-live-pinned-sender.ts +26 -12
  180. package/src/lib/open-url.ts +51 -7
  181. package/src/lib/package-tree-integrity.ts +2 -1
  182. package/src/lib/package-version.ts +8 -0
  183. package/src/lib/pinned-http.ts +142 -2
  184. package/src/lib/plain-data.ts +103 -0
  185. package/src/lib/process-control.ts +13 -5
  186. package/src/lib/provider-egress.ts +310 -0
  187. package/src/lib/provider-outbound.ts +109 -16
  188. package/src/lib/proxy-env.ts +82 -7
  189. package/src/lib/request-execution-budget.ts +72 -0
  190. package/src/lib/request-failure-attribution.ts +183 -0
  191. package/src/lib/request-failure-model.ts +236 -0
  192. package/src/lib/request-resend-gate.ts +138 -0
  193. package/src/lib/socks5-fetch.ts +136 -26
  194. package/src/lib/spend-ledger-owner.ts +364 -0
  195. package/src/lib/spend-reservation-ledger.ts +218 -27
  196. package/src/lib/standalone.ts +16 -0
  197. package/src/lib/upstream-retry.ts +167 -16
  198. package/src/lib/windows-system-proxy.ts +16 -11
  199. package/src/lib/winsw.ts +2 -2
  200. package/src/oauth/callback-server.ts +4 -3
  201. package/src/oauth/generic-account-failover.ts +1 -0
  202. package/src/oauth/health.ts +12 -1
  203. package/src/oauth/index.ts +27 -108
  204. package/src/oauth/login-cli.ts +80 -29
  205. package/src/oauth/login-flow-state.ts +127 -0
  206. package/src/providers/api-key-resolve.ts +133 -0
  207. package/src/providers/api-key-selection.ts +5 -1
  208. package/src/providers/derive.ts +34 -17
  209. package/src/providers/devin-cli-authmode-migration.ts +14 -10
  210. package/src/providers/key-failover.ts +97 -19
  211. package/src/providers/key-store.ts +34 -110
  212. package/src/providers/model-rename-fields.ts +147 -0
  213. package/src/providers/model-rename-migration.ts +179 -38
  214. package/src/providers/model-rename-startup.ts +7 -5
  215. package/src/providers/openai-virtual-models.ts +42 -2
  216. package/src/providers/quota/antigravity.ts +22 -2
  217. package/src/providers/quota/vendor-probes-key.ts +38 -23
  218. package/src/providers/reasoning-metadata.ts +43 -18
  219. package/src/providers/registry/entries-core.ts +47 -20
  220. package/src/providers/registry/entries-extended.ts +63 -4
  221. package/src/providers/registry/model-ids.ts +168 -0
  222. package/src/providers/registry/model-seeds.ts +56 -10
  223. package/src/providers/registry/types.ts +2 -0
  224. package/src/providers/resolved-model-policy-merge.ts +167 -0
  225. package/src/providers/resolved-model-policy.ts +406 -0
  226. package/src/providers/stale-vision-classification-migration.ts +137 -0
  227. package/src/providers/xai-transport.ts +12 -1
  228. package/src/reasoning-effort.ts +8 -0
  229. package/src/responses/apply-patch-envelope.ts +0 -12
  230. package/src/responses/freeform-wrapper-scan.ts +279 -0
  231. package/src/responses/function-call-compat.ts +38 -1
  232. package/src/responses/inline-document.ts +65 -0
  233. package/src/responses/input-media.ts +42 -8
  234. package/src/responses/legacy-dotted-tool-name-repair.ts +134 -0
  235. package/src/responses/muse-tool-name-alias.ts +19 -0
  236. package/src/responses/parser-content.ts +8 -2
  237. package/src/responses/parser-tools.ts +3 -0
  238. package/src/responses/parser.ts +3 -1
  239. package/src/responses/progressive-freeform-input.ts +130 -0
  240. package/src/responses/reasoning-envelope.ts +30 -0
  241. package/src/responses/schema.ts +3 -0
  242. package/src/responses/state.ts +5 -12
  243. package/src/responses/tool-name-aliases.ts +15 -1
  244. package/src/router.ts +108 -117
  245. package/src/routing/compatibility/behavior.ts +9 -0
  246. package/src/routing/compatibility/subject.ts +16 -1
  247. package/src/server/adapter-resolve.ts +9 -0
  248. package/src/server/admission-model-scope.ts +219 -0
  249. package/src/server/audio-live.ts +9 -3
  250. package/src/server/audio-upstream.ts +18 -0
  251. package/src/server/auth-cors.ts +29 -0
  252. package/src/server/chat-completions.ts +60 -4
  253. package/src/server/chat-native.ts +19 -4
  254. package/src/server/claude-messages.ts +61 -20
  255. package/src/server/effort-row.ts +11 -3
  256. package/src/server/grok-responses-control-frame.ts +160 -1
  257. package/src/server/grok-responses-snapshot-repair.ts +113 -11
  258. package/src/server/gui-freshness.ts +103 -0
  259. package/src/server/gui-static.ts +7 -9
  260. package/src/server/images.ts +59 -6
  261. package/src/server/index/claude-intercept-lifecycle.ts +49 -0
  262. package/src/server/index/serve-options.ts +142 -39
  263. package/src/server/index/spend-ledger-lifecycle.ts +92 -0
  264. package/src/server/index/startup-warnings.ts +24 -0
  265. package/src/server/index/websocket-handler.ts +6 -1
  266. package/src/server/index.ts +34 -38
  267. package/src/server/lifecycle.ts +4 -4
  268. package/src/server/live-call-bindings.ts +6 -0
  269. package/src/server/live.ts +88 -3
  270. package/src/server/management/agent-settings-routes.ts +121 -36
  271. package/src/server/management/aside-profile-routes.ts +266 -7
  272. package/src/server/management/companion-routes.ts +77 -0
  273. package/src/server/management/config-routes.ts +18 -2
  274. package/src/server/management/context.ts +3 -0
  275. package/src/server/management/integration-routes.ts +287 -5
  276. package/src/server/management/logs-usage-routes.ts +19 -0
  277. package/src/server/management/metrics-routes.ts +20 -0
  278. package/src/server/management/model-rows.ts +224 -12
  279. package/src/server/management/native-integration-routes.ts +103 -6
  280. package/src/server/management/oauth-account-routes.ts +45 -7
  281. package/src/server/management/route-registry.ts +20 -0
  282. package/src/server/management/shared.ts +28 -4
  283. package/src/server/management/system-restart.ts +7 -2
  284. package/src/server/management/system-routes.ts +2 -0
  285. package/src/server/management/usage-aggregate-cache.ts +4 -0
  286. package/src/server/management/usage-timeline-routes.ts +44 -0
  287. package/src/server/management-api.ts +10 -9
  288. package/src/server/management-auth.ts +15 -1
  289. package/src/server/proxy-liveness.ts +75 -0
  290. package/src/server/readiness.ts +29 -10
  291. package/src/server/relay-eager.ts +24 -2
  292. package/src/server/relay.ts +138 -12
  293. package/src/server/request-log-failure-attribution.ts +99 -0
  294. package/src/server/request-log.ts +169 -2
  295. package/src/server/request-metrics.ts +298 -0
  296. package/src/server/responses/adapter-continuation.ts +3 -3
  297. package/src/server/responses/adapter-dispatch.ts +11 -6
  298. package/src/server/responses/codex-ws-wire.ts +34 -8
  299. package/src/server/responses/combo-stream-preflight.ts +168 -6
  300. package/src/server/responses/compact.ts +43 -10
  301. package/src/server/responses/compaction-routing.ts +111 -0
  302. package/src/server/responses/core-codex-account.ts +8 -3
  303. package/src/server/responses/core-combo.ts +7 -7
  304. package/src/server/responses/core-normalize.ts +6 -12
  305. package/src/server/responses/core-opaque-recovery.ts +91 -0
  306. package/src/server/responses/core-options.ts +4 -0
  307. package/src/server/responses/encrypted-payload.ts +20 -2
  308. package/src/server/responses/fetch-helpers.ts +124 -8
  309. package/src/server/responses/input-admission.ts +10 -0
  310. package/src/server/responses/passthrough-delivery.ts +45 -15
  311. package/src/server/responses/passthrough-dispatch.ts +215 -44
  312. package/src/server/responses/passthrough-error.ts +27 -8
  313. package/src/server/responses/policy-fallback.ts +5 -13
  314. package/src/server/responses/request-prepare.ts +109 -18
  315. package/src/server/responses/request-send-budget.ts +17 -1
  316. package/src/server/responses/request-sidecar-auth.ts +1 -1
  317. package/src/server/responses/request-transport.ts +26 -6
  318. package/src/server/responses/reset-replay.ts +108 -0
  319. package/src/server/responses/run-turn-execution.ts +25 -3
  320. package/src/server/responses/sidecar-execution.ts +17 -2
  321. package/src/server/responses/ws-upstream.ts +14 -27
  322. package/src/server/responses-custom-tool-repair.ts +27 -54
  323. package/src/server/responses-request-tool-scope.ts +214 -0
  324. package/src/server/responses-undeclared-tool-guard.ts +35 -2
  325. package/src/server/search.ts +25 -1
  326. package/src/server/sse-payload-rewrite.ts +1 -1
  327. package/src/server/usage-ledger-retention.ts +73 -0
  328. package/src/service/cli.ts +48 -2
  329. package/src/service/health.ts +3 -2
  330. package/src/service/install-state-contract.d.mts +27 -0
  331. package/src/service/install-state-contract.mjs +34 -0
  332. package/src/service/launchd.ts +1 -1
  333. package/src/service/orchestration.ts +2 -4
  334. package/src/service/ownership-compatibility.ts +164 -0
  335. package/src/service/ownership-mutation-lease.d.mts +32 -0
  336. package/src/service/ownership-mutation-lease.mjs +211 -0
  337. package/src/service/repair.ts +45 -1
  338. package/src/service/state-lock.ts +269 -0
  339. package/src/service/state-record.d.mts +36 -0
  340. package/src/service/state-record.mjs +138 -0
  341. package/src/service/state.ts +582 -68
  342. package/src/service/windows-taskxml.ts +11 -10
  343. package/src/service.ts +7 -3
  344. package/src/tray/windows-tray.ps1 +156 -4
  345. package/src/types/config.ts +48 -3
  346. package/src/types/provider.ts +91 -0
  347. package/src/types/request.ts +30 -2
  348. package/src/types/tools.ts +33 -0
  349. package/src/types.ts +4 -0
  350. package/src/update/index.ts +207 -63
  351. package/src/update/job.ts +9 -5
  352. package/src/update/ownership-transaction.ts +47 -0
  353. package/src/update/restart-ownership.ts +54 -0
  354. package/src/update/runtime-ownership.d.mts +40 -0
  355. package/src/update/runtime-ownership.mjs +122 -0
  356. package/src/usage/attempt-delivery.ts +198 -0
  357. package/src/usage/cache-diagnostic.ts +305 -0
  358. package/src/usage/failure-fingerprint.ts +118 -0
  359. package/src/usage/failure-projection-cache.ts +174 -0
  360. package/src/usage/failure-projection.ts +174 -0
  361. package/src/usage/ledger-retention.ts +165 -0
  362. package/src/usage/log.ts +126 -79
  363. package/src/usage/request-outcome.ts +150 -0
  364. package/src/usage/retention-contract.ts +28 -0
  365. package/src/usage/summary.ts +2 -2
  366. package/src/usage/telemetry-contract.ts +237 -0
  367. package/src/usage/timeline.ts +236 -0
  368. package/src/vision/eligibility.ts +88 -9
  369. package/src/vision/plan.ts +34 -10
  370. package/src/web-search/alpha-search.ts +21 -1
  371. package/src/web-search/executor.ts +41 -2
  372. package/src/web-search/loop.ts +6 -1
  373. package/gui/dist/assets/index-C5IebErG.js +0 -136
  374. package/gui/dist/assets/index-OESInAjC.css +0 -1
@@ -102,7 +102,7 @@ import {
102
102
  clearCodexModelDenialEvidence,
103
103
  recordCodexModelDenialEvidence,
104
104
  } from "../../codex/model-entitlements";
105
- import { readCodexWsStage } from "./codex-ws-wire";
105
+ import { isCodexWsUpstreamResponse, readCodexWsStage } from "./codex-ws-wire";
106
106
  import { linkAbortSignal } from "./core-lifetime";
107
107
  import type { CodexAuthContext } from "../../codex/auth-context";
108
108
  import { checkOutboundBodySize, describeOutboundBodyRefusal } from "./outbound-body-guard";
@@ -112,7 +112,8 @@ import {
112
112
  fetchWithTransientRetry,
113
113
  applyUpstreamRecoveryInit,
114
114
  isNonReplayableResponse,
115
- prepareSameTarget429Wait,
115
+ refetchAfterProtocolSafeReset,
116
+ prepareSameTarget429Wait,
116
117
  sleepWithAbort,
117
118
  } from "../../lib/upstream-retry";
118
119
  import { mapCodexAuthContextErrorToResponse } from "./codex-auth-error";
@@ -121,6 +122,7 @@ import { recordCodexUpstreamOutcome } from "../../codex/routing";
121
122
  import { describeUpstreamConnectFailure } from "./upstream-error";
122
123
  import type { OpaqueBlobRecoveryGuard } from "./core-opaque-recovery";
123
124
  import {
125
+ isOpenCodeGoDestination,
124
126
  rateLimitRetryPolicyFor,
125
127
  rateLimitRetryDelayMs,
126
128
  transientRetryPolicyFor,
@@ -142,6 +144,7 @@ import { captureCodexAffinityDiagnostic } from "../../codex/affinity-debug";
142
144
  import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS } from "../../codex/catalog/native-models";
143
145
  import {
144
146
  attemptOpaqueBlobRecovery,
147
+ isEncryptedFunctionOutputRejection,
145
148
  outboundResponsesBodyCarriesEncryptedFunctionOutput,
146
149
  resetStreamedOpaqueBlobLogContext,
147
150
  consoleGoUploadRejectionBody,
@@ -149,7 +152,9 @@ import {
149
152
  reasoningEffortRejectionText,
150
153
  } from "./core-opaque-recovery";
151
154
  import type { RequestLogContext } from "../request-log";
152
- import { preflightComboStreamResponse } from "./combo-stream-preflight";
155
+ import { deferProtocolSafeResetRecovery, preflightComboStreamResponse } from "./combo-stream-preflight";
156
+ import { authorizeResendForRecovery } from "../../lib/request-resend-gate";
157
+ import { ambiguousResendAllowanceFor, selfContainedResponsesBody } from "./reset-replay";
153
158
  import { upstreamErrorMessageFromPayload, ENCRYPTED_FUNCTION_OUTPUT_REJECTION } from "../../lib/errors";
154
159
  import { isTransientConsoleGoUploadRejection } from "../../providers/opencode-zen-rate-limit";
155
160
  import { planReasoningEffortDowngrade } from "../../providers/reasoning-metadata";
@@ -187,6 +192,8 @@ export async function preparePassthroughExchange(
187
192
  | "genericFailovers"
188
193
  | "applyFailoverSnapshot"
189
194
  | "noteRoutedAttemptSend"
195
+ | "selectionIsCurrent"
196
+ | "requestBindings"
190
197
  >,
191
198
  responseEffects: Pick<
192
199
  ResponsesEffects,
@@ -204,6 +211,7 @@ export async function preparePassthroughExchange(
204
211
  | "recoverySendAllowance"
205
212
  | "recoveryClassFor"
206
213
  | "sendBudgetExhausted"
214
+ | "claimAmbiguousResend"
207
215
  | "reserveCredentialHop"
208
216
  | "pendingHopPermit"
209
217
  | "workflowRootId"
@@ -237,6 +245,7 @@ export async function preparePassthroughExchange(
237
245
  recoverySendAllowance,
238
246
  recoveryClassFor,
239
247
  sendBudgetExhausted,
248
+ claimAmbiguousResend,
240
249
  reserveCredentialHop,
241
250
  workflowRootId,
242
251
  } = sendBudgetState;
@@ -702,6 +711,35 @@ export async function preparePassthroughExchange(
702
711
  );
703
712
  const configuredTransientSendBudgetExhausted = (): boolean =>
704
713
  transientSendPolicy() !== null && transientSendAttempts() === 0;
714
+ /**
715
+ * Judged once. The inbound body does not change between legs, and every rebuild this lane
716
+ * performs only ever REMOVES a hazard -- `previous_response_id` is expanded, hosted tools
717
+ * are lowered into client execution -- so a body that was replaceable stays replaceable.
718
+ * Memoized rather than recomputed because it walks the input array, and a provider that
719
+ * never opted in must not pay for it at all.
720
+ */
721
+ let selfContainedJudgment: boolean | undefined;
722
+ const requestIsSelfContained = (): boolean =>
723
+ selfContainedJudgment ??= selfContainedResponsesBody(parsed._rawBody);
724
+ /**
725
+ * The operator's replacement grant for THIS logical request.
726
+ *
727
+ * Read per leg because `route.provider` is reassigned by credential rotation and transport
728
+ * resolution inside the recovery loop, exactly like `transientSendPolicy`. The counter it
729
+ * claims from is not per leg: it lives on the request's execution budget, which a combo
730
+ * child shares, so every ambiguous stage of this request draws on the same grant.
731
+ */
732
+ const ambiguousResend = () =>
733
+ ambiguousResendAllowanceFor(route.provider, requestIsSelfContained, claimAmbiguousResend);
734
+ /**
735
+ * The pre-header row of the stage table, asked through the one gate.
736
+ *
737
+ * `fetchWithResetRetry` takes a plain callback because it is a leaf that must not import
738
+ * the server tree; routing the answer through `authorizeResendForRecovery` here is what
739
+ * keeps the decision derived from the table rather than restated as a boolean.
740
+ */
741
+ const claimPreHeaderResend = (): boolean =>
742
+ authorizeResendForRecovery("pre-header", "connection-reset", ambiguousResend()).allowed;
705
743
  /**
706
744
  * Refuse a built body that exceeds the operator's configured ceiling, before it is sent.
707
745
  *
@@ -841,7 +879,16 @@ export async function preparePassthroughExchange(
841
879
  // retry wrapper replaces — proves the host was reached (#914 review).
842
880
  .then(adoptObservedResponse);
843
881
  },
844
- { abortSignal: upstream.signal, label: safeHostLabel(request.url), attempts: remainingTransientSendBudget(transientSendAttempts()), onSendsConsumed: noteTransientSends },
882
+ { abortSignal: upstream.signal, label: safeHostLabel(request.url),
883
+ attempts: remainingTransientSendBudget(transientSendAttempts()), onSendsConsumed: noteTransientSends,
884
+ claimAmbiguousResend: claimPreHeaderResend,
885
+ // The OpenCode Go destination stalls-then-drops inference sends (ambiguous
886
+ // pre-header resets surfacing as refused 429s); its subscription traffic is
887
+ // inference-only, so a bounded reset replay here absorbs the blip instead of
888
+ // failing the turn. Recovery legs keep the fail-closed refusal; only this
889
+ // initial send is replay-eligible. Attempts stay budget-bounded via attempts.
890
+ replaySafe: isOpenCodeGoDestination(route.provider),
891
+ },
845
892
  );
846
893
  } catch (err) {
847
894
  return transportFailureResponse(err);
@@ -863,7 +910,7 @@ export async function preparePassthroughExchange(
863
910
  recovery: AttemptRecoveryKind,
864
911
  ): Promise<Response | { failed: Response }> => {
865
912
  const retryAdapter = resolveSelectionAdapter(
866
- resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire),
913
+ resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire, route.staticPolicy),
867
914
  config.cacheRetention,
868
915
  );
869
916
  if (!("passthrough" in retryAdapter) || !retryAdapter.passthrough) {
@@ -939,7 +986,8 @@ export async function preparePassthroughExchange(
939
986
  route.provider.authMode === "forward")
940
987
  .then(adoptObservedResponse);
941
988
  },
942
- { abortSignal: upstream.signal, label: safeHostLabel(request.url), attempts: allowance.attempts, onSendsConsumed: noteTransientSends },
989
+ { abortSignal: upstream.signal, label: safeHostLabel(request.url), attempts: allowance.attempts,
990
+ onSendsConsumed: noteTransientSends, claimAmbiguousResend: claimPreHeaderResend },
943
991
  );
944
992
  } catch (err) {
945
993
  return { failed: transportFailureResponse(err) };
@@ -989,7 +1037,7 @@ export async function preparePassthroughExchange(
989
1037
  route.provider = replay.provider;
990
1038
  requestState.selectedForwardHeaders = withClaudeNativeSession(replay.headers, replay.provider, options.claudeNativeSessionId);
991
1039
  const replayAdapter = resolveSelectionAdapter(
992
- resolveWireProtocolOverride(route.providerName, route.modelId, replay.provider, inboundWire),
1040
+ resolveWireProtocolOverride(route.providerName, route.modelId, replay.provider, inboundWire, route.staticPolicy),
993
1041
  config.cacheRetention,
994
1042
  );
995
1043
  if (!("passthrough" in replayAdapter) || !replayAdapter.passthrough) {
@@ -1020,35 +1068,54 @@ export async function preparePassthroughExchange(
1020
1068
  // every other build site; a replay is exactly when a grown payload reappears.
1021
1069
  const replayBodyRefusal = refuseOversizedOutboundBody(request);
1022
1070
  if (replayBodyRefusal) return replayBodyRefusal;
1023
- transportState.noteRoutedAttemptSend(passthroughEstimate, "oauth-401");
1024
- upstreamResponse = await fetchWithHeaderTimeout(
1025
- request.url,
1026
- { method: request.method, headers: request.headers, body: request.body },
1027
- upstream.signal,
1028
- connectMs,
1029
- parsed.stream,
1030
- // The replay-dispatched signal is what bounds the rest of this logical request, so it
1031
- // has to describe a send that actually happened. fetchWithHeaderTimeout awaits pacing
1032
- // admission BEFORE calling the executor, so signalling at the call site would spend the
1033
- // budget even when a rejected pacing wait means nothing reaches the network. Wrapping
1034
- // the executor moves the signal to the last moment before the send, where a throw from
1035
- // here on is a genuine transport attempt.
1036
- storedPoolReplayDispatchNotifier(
1037
- providerFetch(route.provider, options.codexWsRuntimeIdentity, {
1038
- nativeControl: nativeResponseControlEligible(route.provider, options.nativeControl) && options.inboundTransport === "websocket" && !options.comboAttempt
1039
- && responseEffects.plaintextV2AgentMessageToolNames.size === 0
1040
- ? options.nativeControl : undefined,
1041
- dispatchOverride: oauthDispatch(request),
1042
- providerName: route.providerName,
1043
- modelId: route.modelId,
1044
- onCodexWsQuota: codexWsQuotaObserver(admissionState.authCtx, route.provider, route.modelId),
1045
- beforeDispatch: isCanonicalOpenAiForwardProvider(route.provider)
1046
- ? createCodexReserveDispatchGuard(admissionState.authCtx, options.codexAuthPolicy ?? config, route.modelId, options.admission, options.visionDescribeTerminal === true) : undefined,
1047
- }),
1048
- codex401ReplayKind === "stored" ? options.onStoredPool401ReplayDispatched : undefined,
1049
- ),
1050
- route.provider.authMode === "forward",
1051
- ).then(adoptObservedResponse);
1071
+ // The replay-dispatched signal is what bounds the rest of this logical request, so it
1072
+ // has to describe a send that actually happened. fetchWithHeaderTimeout awaits pacing
1073
+ // admission BEFORE calling the executor, so signalling at the call site would spend the
1074
+ // budget even when a rejected pacing wait means nothing reaches the network. Wrapping
1075
+ // the executor moves the signal to the last moment before the send, where a throw from
1076
+ // here on is a genuine transport attempt. The notifier is built once for the whole leg:
1077
+ // it fires on the first dispatch, and a replacement is another send of the same replay
1078
+ // rather than a second one to announce.
1079
+ const oauthReplayExecutor = storedPoolReplayDispatchNotifier(
1080
+ providerFetch(route.provider, options.codexWsRuntimeIdentity, {
1081
+ nativeControl: nativeResponseControlEligible(route.provider, options.nativeControl) && options.inboundTransport === "websocket" && !options.comboAttempt
1082
+ && responseEffects.plaintextV2AgentMessageToolNames.size === 0
1083
+ ? options.nativeControl : undefined,
1084
+ dispatchOverride: oauthDispatch(request),
1085
+ providerName: route.providerName,
1086
+ modelId: route.modelId,
1087
+ onCodexWsQuota: codexWsQuotaObserver(admissionState.authCtx, route.provider, route.modelId),
1088
+ beforeDispatch: isCanonicalOpenAiForwardProvider(route.provider)
1089
+ ? createCodexReserveDispatchGuard(admissionState.authCtx, options.codexAuthPolicy ?? config, route.modelId, options.admission, options.visionDescribeTerminal === true) : undefined,
1090
+ }),
1091
+ codex401ReplayKind === "stored" ? options.onStoredPool401ReplayDispatched : undefined,
1092
+ );
1093
+ // Routed through the shared helper so an ambiguous reset on THIS leg answers with the
1094
+ // same refusal every other leg gives. A bare fetch here rejected instead, and the
1095
+ // caller's transport-failure path turns a rejection into a client-retryable 502 --
1096
+ // which invites the whole turn to be sent again, on a leg whose first send may already
1097
+ // have run it.
1098
+ upstreamResponse = await fetchWithTransientRetry(
1099
+ recovery => {
1100
+ transportState.noteRoutedAttemptSend(passthroughEstimate, recovery ?? "oauth-401");
1101
+ return fetchWithHeaderTimeout(
1102
+ request.url,
1103
+ applyUpstreamRecoveryInit({
1104
+ method: request.method,
1105
+ headers: request.headers,
1106
+ body: request.body,
1107
+ }, recovery),
1108
+ upstream.signal,
1109
+ connectMs,
1110
+ parsed.stream,
1111
+ oauthReplayExecutor,
1112
+ route.provider.authMode === "forward",
1113
+ ).then(adoptObservedResponse);
1114
+ },
1115
+ { abortSignal: upstream.signal, label: safeHostLabel(request.url),
1116
+ attempts: remainingTransientSendBudget(transientSendAttempts()),
1117
+ onSendsConsumed: noteTransientSends, claimAmbiguousResend: claimPreHeaderResend },
1118
+ );
1052
1119
  } catch (err) {
1053
1120
  return transportFailureResponse(err);
1054
1121
  } finally {
@@ -1110,7 +1177,7 @@ export async function preparePassthroughExchange(
1110
1177
  );
1111
1178
  route.provider = refreshedProvider;
1112
1179
  const refreshedAdapter = resolveSelectionAdapter(
1113
- resolveWireProtocolOverride(route.providerName, route.modelId, refreshedProvider, inboundWire),
1180
+ resolveWireProtocolOverride(route.providerName, route.modelId, refreshedProvider, inboundWire, route.staticPolicy),
1114
1181
  config.cacheRetention,
1115
1182
  );
1116
1183
  if (!("passthrough" in refreshedAdapter) || !refreshedAdapter.passthrough) {
@@ -1172,7 +1239,8 @@ export async function preparePassthroughExchange(
1172
1239
  route.provider.authMode === "forward")
1173
1240
  .then(adoptObservedResponse);
1174
1241
  },
1175
- { abortSignal: upstream.signal, label: safeHostLabel(request.url), attempts: remainingTransientSendBudget(transientSendAttempts()), onSendsConsumed: noteTransientSends },
1242
+ { abortSignal: upstream.signal, label: safeHostLabel(request.url), attempts: remainingTransientSendBudget(transientSendAttempts()),
1243
+ onSendsConsumed: noteTransientSends, claimAmbiguousResend: claimPreHeaderResend },
1176
1244
  );
1177
1245
  } catch (err) {
1178
1246
  return transportFailureResponse(err);
@@ -1304,7 +1372,8 @@ export async function preparePassthroughExchange(
1304
1372
  route.provider.authMode === "forward")
1305
1373
  .then(adoptObservedResponse);
1306
1374
  },
1307
- { abortSignal: upstream.signal, label: safeHostLabel(request.url), attempts: remainingTransientSendBudget(transientSendAttempts()), onSendsConsumed: noteTransientSends },
1375
+ { abortSignal: upstream.signal, label: safeHostLabel(request.url), attempts: remainingTransientSendBudget(transientSendAttempts()),
1376
+ onSendsConsumed: noteTransientSends, claimAmbiguousResend: claimPreHeaderResend },
1308
1377
  );
1309
1378
  } catch (err) {
1310
1379
  return transportFailureResponse(err);
@@ -1340,8 +1409,16 @@ export async function preparePassthroughExchange(
1340
1409
  // refusal: whatever the entitlement was when upstream declined, it is not that now. Both
1341
1410
  // ids are cleared because the wire model can differ from the routed one.
1342
1411
  if (upstreamResponse.ok) {
1343
- clearCodexModelDenialEvidence(admissionState.authCtx.accountId, route.modelId);
1344
- clearCodexModelDenialEvidence(admissionState.authCtx.accountId, parsed.modelId);
1412
+ clearCodexModelDenialEvidence(
1413
+ admissionState.authCtx.accountId,
1414
+ route.modelId,
1415
+ admissionState.authCtx.kind === "pool" ? admissionState.authCtx.generation : undefined,
1416
+ );
1417
+ clearCodexModelDenialEvidence(
1418
+ admissionState.authCtx.accountId,
1419
+ parsed.modelId,
1420
+ admissionState.authCtx.kind === "pool" ? admissionState.authCtx.generation : undefined,
1421
+ );
1345
1422
  }
1346
1423
  const model400Denial = await codexPoolAccountModel400Denial(
1347
1424
  upstreamResponse,
@@ -1354,7 +1431,11 @@ export async function preparePassthroughExchange(
1354
1431
  // answer about this model, and the roster cache that selection otherwise reads expires
1355
1432
  // five minutes after a catalog sync fills it -- so without remembering this, the next
1356
1433
  // request selects the same account on quota alone and takes the same 400 (#4906).
1357
- recordCodexModelDenialEvidence(admissionState.authCtx.accountId, model400Denial);
1434
+ recordCodexModelDenialEvidence(
1435
+ admissionState.authCtx.accountId,
1436
+ model400Denial,
1437
+ admissionState.authCtx.kind === "pool" ? admissionState.authCtx.generation : undefined,
1438
+ );
1358
1439
  poolRetryOutcome = 400;
1359
1440
  } else if (!admissionState.authCtx.fixedAccount && await shouldRetryCodexPoolAccountQuota(
1360
1441
  upstreamResponse,
@@ -1453,8 +1534,13 @@ export async function preparePassthroughExchange(
1453
1534
  payload => {
1454
1535
  if (!payload || typeof payload !== "object" || Array.isArray(payload)) return false;
1455
1536
  const type = (payload as { type?: unknown }).type;
1456
- return (type === "error" || type === "response.failed" || type === "response.incomplete")
1457
- && upstreamErrorMessageFromPayload(payload) === ENCRYPTED_FUNCTION_OUTPUT_REJECTION;
1537
+ const decryptRejection = (type === "error" || type === "response.failed" || type === "response.incomplete")
1538
+ && isEncryptedFunctionOutputRejection(JSON.stringify(payload));
1539
+ // `detail` is a real WebSocket error shape but the generic preflight failure projector
1540
+ // intentionally understands only Responses error/message fields. Preserve only this
1541
+ // exact identity so the projected 502 can enter the existing single-shot recovery.
1542
+ if (decryptRejection) preflightLog.upstreamError = ENCRYPTED_FUNCTION_OUTPUT_REJECTION;
1543
+ return decryptRejection;
1458
1544
  }, {
1459
1545
  allowMissingContentType: !recoveryContentType && parsed.stream,
1460
1546
  replayReadErrors: true,
@@ -1544,6 +1630,91 @@ export async function preparePassthroughExchange(
1544
1630
  continue passthroughRecovery;
1545
1631
  }
1546
1632
  }
1633
+
1634
+ // The post-header row of the same table. A native SSE body can die after the head with the
1635
+ // caller having observed nothing, which is the identical question the pre-header helper
1636
+ // answers -- and the identical grant, because both claim from this request's one allowance.
1637
+ const streamRecoveryContentType = upstreamResponse.headers.get("content-type")?.toLowerCase() ?? "";
1638
+ const protocolRecoveryCandidate = upstreamResponse.ok
1639
+ && !!upstreamResponse.body
1640
+ && !isNonReplayableResponse(upstreamResponse)
1641
+ // The WebSocket transport settles its own ambiguous failures and marks them
1642
+ // non-replayable; re-reading its body here would be a second owner of one exchange.
1643
+ && !isCodexWsUpstreamResponse(upstreamResponse)
1644
+ // A downstream WebSocket turn that fell back to HTTP must relay response.created
1645
+ // immediately so the client can address the turn and receive explicit control refusal.
1646
+ // This preflight retains that event until output commits, so the two contracts cannot
1647
+ // share one body owner.
1648
+ && !(options.nativeControl && options.inboundTransport === "websocket")
1649
+ && ambiguousResend() !== undefined
1650
+ && remainingTransientSendBudget(transientSendAttempts()) > 0
1651
+ && (streamRecoveryContentType.includes("text/event-stream") || (!streamRecoveryContentType && parsed.stream));
1652
+ if (protocolRecoveryCandidate) {
1653
+ upstreamResponse = deferProtocolSafeResetRecovery(
1654
+ upstreamResponse,
1655
+ { model: logCtx.model, provider: logCtx.provider },
1656
+ (error, stage) => refetchAfterProtocolSafeReset(
1657
+ (signal = upstream.signal) => fetchWithHeaderTimeout(
1658
+ request.url,
1659
+ applyUpstreamRecoveryInit({
1660
+ method: request.method,
1661
+ headers: request.headers,
1662
+ body: request.body,
1663
+ }, "connection-reset"),
1664
+ signal,
1665
+ connectMs,
1666
+ true,
1667
+ providerFetch(route.provider, options.codexWsRuntimeIdentity, {
1668
+ // A replacement HTTP body must not open a fresh WebSocket exchange: the turn it
1669
+ // replaces was an HTTP stream, and a WS create frame is a different send.
1670
+ httpOnly: true,
1671
+ providerName: route.providerName,
1672
+ modelId: route.modelId,
1673
+ dispatchOverride: oauthDispatch(request),
1674
+ beforeDispatch: headers => {
1675
+ if (signal.aborted) throw signal.reason;
1676
+ if (!transportState.selectionIsCurrent(transportState.requestBindings.get(request))) {
1677
+ throw new Error("Credential selection changed before pre-output stream recovery");
1678
+ }
1679
+ if (isCanonicalOpenAiForwardProvider(route.provider)) {
1680
+ createCodexReserveDispatchGuard(
1681
+ admissionState.authCtx,
1682
+ options.codexAuthPolicy ?? config,
1683
+ route.modelId,
1684
+ options.admission,
1685
+ options.visionDescribeTerminal === true,
1686
+ )?.(headers);
1687
+ }
1688
+ // Recorded with the kind the gate derived its cause from, at the moment the
1689
+ // send actually leaves. One authorisation, one recorded reason, one send.
1690
+ transportState.noteRoutedAttemptSend(passthroughEstimate, "connection-reset");
1691
+ // Charged to the SAME request counter every other send goes through. The
1692
+ // replacement is bought here rather than by a nested retry helper, so there is
1693
+ // one charge for one send and no per-layer counter to reconcile.
1694
+ noteTransientSends(1);
1695
+ },
1696
+ }),
1697
+ route.provider.authMode === "forward",
1698
+ ).then(adoptObservedResponse),
1699
+ error,
1700
+ {
1701
+ abortSignal: upstream.signal,
1702
+ label: safeHostLabel(request.url),
1703
+ attempts: remainingTransientSendBudget(transientSendAttempts()),
1704
+ // The whole decision, including the stage the preflight observed and the grant the
1705
+ // pre-header helper shares. A committed stage refuses here without touching the
1706
+ // allowance, which is what keeps a turn that already emitted output from draining
1707
+ // the replacement a later ambiguous reset would have been entitled to.
1708
+ authorize: () => authorizeResendForRecovery(stage, "connection-reset", ambiguousResend()).allowed,
1709
+ acceptResponse: candidate => {
1710
+ const type = candidate.headers.get("content-type")?.toLowerCase() ?? "";
1711
+ return type.includes("text/event-stream") || (!type && parsed.stream);
1712
+ },
1713
+ },
1714
+ ),
1715
+ { allowMissingContentType: !streamRecoveryContentType && parsed.stream },
1716
+ );
1717
+ }
1547
1718
  break;
1548
1719
  }
1549
1720
 
@@ -1,6 +1,11 @@
1
1
  import { formatErrorResponse } from "../../bridge";
2
2
  import { isCyberPolicyCode, isCyberPolicyMessage } from "../../lib/errors";
3
- import { isReplayRefusalCode, UPSTREAM_RESET_REPLAY_REFUSED_CODE } from "../../lib/upstream-retry";
3
+ import {
4
+ applyReplayRefusalClientHeaders,
5
+ isReplayRefusalCode,
6
+ retainReplayRefusal,
7
+ UPSTREAM_RESET_REPLAY_REFUSED_CODE,
8
+ } from "../../lib/upstream-retry";
4
9
  import {
5
10
  resolveClientRetryAfter,
6
11
  validateClientRetryAfterHeader,
@@ -62,6 +67,10 @@ function isReplayRefusalBody(body: string): boolean {
62
67
  * - a replay refusal this proxy wrote gets none and keeps none: the whole point of the
63
68
  * refusal is that the turn may already be running, and the synthetic default for a
64
69
  * retryable 429 is a direct instruction to the client to send it a second time
70
+ *
71
+ * A refusal also leaves with the shared no-retry header and the in-process marker, so the
72
+ * verdict survives this re-wrap as a property of the response rather than as a status a later
73
+ * reader would have to guess from.
65
74
  */
66
75
  export function formatPassthroughUpstreamError(
67
76
  status: number,
@@ -84,11 +93,10 @@ export function formatPassthroughUpstreamError(
84
93
  const upstreamRetryAfter = options?.headers?.get("retry-after")?.trim() || undefined;
85
94
  const originalValid = validateClientRetryAfterHeader(upstreamRetryAfter, now);
86
95
  const cyberPolicyFailure = isCyberPolicyBody(trimmed);
96
+ const replayRefusal = options?.replayRefusal === true || isReplayRefusalBody(trimmed);
87
97
  // Two different reasons to answer with no wait at all, handled the same way: a hard policy
88
98
  // block will not become servable, and a refusal we made was never a rate limit.
89
- const suppressRetryAfter = cyberPolicyFailure
90
- || options?.replayRefusal === true
91
- || isReplayRefusalBody(trimmed);
99
+ const suppressRetryAfter = cyberPolicyFailure || replayRefusal;
92
100
  const resolved = suppressRetryAfter
93
101
  ? undefined
94
102
  : resolveClientRetryAfter({
@@ -105,7 +113,9 @@ export function formatPassthroughUpstreamError(
105
113
  && upstreamRetryAfter !== undefined
106
114
  && originalValid === undefined);
107
115
 
108
- if (!needsSet && !needsDelete) {
116
+ // A refusal always takes the rewriting path: it has a header to add even when the
117
+ // upstream named no wait for it to remove.
118
+ if (!needsSet && !needsDelete && !replayRefusal) {
109
119
  return new Response(bodyText, {
110
120
  status,
111
121
  ...(options?.statusText ? { statusText: options.statusText } : {}),
@@ -118,21 +128,30 @@ export function formatPassthroughUpstreamError(
118
128
  : new Headers({ "Content-Type": "application/json" });
119
129
  if (needsSet) headers.set("Retry-After", resolved!);
120
130
  else headers.delete("Retry-After");
121
- return new Response(bodyText, {
131
+ if (replayRefusal) applyReplayRefusalClientHeaders(headers);
132
+ const rewritten = new Response(bodyText, {
122
133
  status,
123
134
  ...(options?.statusText ? { statusText: options.statusText } : {}),
124
135
  headers,
125
136
  });
137
+ return replayRefusal ? retainReplayRefusal(rewritten) : rewritten;
126
138
  }
127
139
 
128
140
  const response = formatErrorResponse(
129
141
  status,
130
142
  "upstream_error",
131
143
  `Provider error ${status}: (empty body)`,
132
- resolved !== undefined ? { retryAfter: resolved } : undefined,
144
+ // Provenance is all that is left when the bounded read returned nothing display-safe, and
145
+ // it is enough: the formatter allowlists this code, restates the refusal status and marks
146
+ // the response, so an unreadable refusal reaches the client as the same refusal.
147
+ replayRefusal
148
+ ? { code: UPSTREAM_RESET_REPLAY_REFUSED_CODE }
149
+ : resolved !== undefined ? { retryAfter: resolved } : undefined,
133
150
  );
134
151
  const headers = new Headers(response.headers);
135
152
  headers.set("Content-Type", "application/json");
136
153
  if (resolved !== undefined) headers.set("Retry-After", resolved);
137
- return new Response(response.body, { status: response.status, headers });
154
+ if (replayRefusal) applyReplayRefusalClientHeaders(headers);
155
+ const wrapped = new Response(response.body, { status: response.status, headers });
156
+ return replayRefusal ? retainReplayRefusal(wrapped) : wrapped;
138
157
  }
@@ -1,6 +1,5 @@
1
1
  import { comboFailureDecision } from "../../combos/failover";
2
2
  import { readBoundedResponseBody } from "../../lib/bounded-body";
3
- import { readJsonRequestBody, resolveInboundBodyLimitBytes } from "../request-decompress";
4
3
  import { finishRequestAttempt, type RequestLogContext } from "../request-log";
5
4
  import { linkRequestSessionLane } from "../request-log-conversation";
6
5
  import type { OcxConfig } from "../../types";
@@ -129,6 +128,7 @@ export async function handleResponsesWithPolicyFallback(
129
128
  const runCore = deps.runCore ?? handleResponsesCore;
130
129
  let requestBodyReadNotified = false;
131
130
  let storedPool401ReplayDispatched = false;
131
+ let rawBody: Record<string, unknown> | null = null;
132
132
  const coreOptions: CoreOptions = {
133
133
  ...options,
134
134
  openAiSidecarAuth: options.openAiSidecarAuth === undefined
@@ -144,23 +144,15 @@ export async function handleResponsesWithPolicyFallback(
144
144
  options.onRequestBodyRead?.();
145
145
  },
146
146
  } : {}),
147
+ onRequestBodyParsed: body => {
148
+ options.onRequestBodyParsed?.(body);
149
+ if (body && typeof body === "object" && !Array.isArray(body)) rawBody = body as Record<string, unknown>;
150
+ },
147
151
  onStoredPool401ReplayDispatched: () => {
148
152
  storedPool401ReplayDispatched = true;
149
153
  options.onStoredPool401ReplayDispatched?.();
150
154
  },
151
155
  };
152
- let rawBody: Record<string, unknown> | null = null;
153
- try {
154
- const parsed = await readJsonRequestBody(
155
- req.clone(),
156
- undefined,
157
- resolveInboundBodyLimitBytes(config.maxInboundBodyBytes),
158
- );
159
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) rawBody = parsed as Record<string, unknown>;
160
- } catch {
161
- // Core owns the client-facing parse/decompression error.
162
- }
163
-
164
156
  let response: Response;
165
157
  try {
166
158
  response = await runCore(req, config, logCtx, coreOptions);