@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
@@ -0,0 +1,298 @@
1
+ import type { ResponsesTerminalStatus } from "../bridge";
2
+ import type { AttemptRecoveryKind } from "../usage/log";
3
+ import {
4
+ REQUEST_FAILURE_CAUSES,
5
+ type RequestFailureCause,
6
+ causeForRecoveryKind,
7
+ } from "../lib/request-failure-model";
8
+ import {
9
+ REQUEST_OUTCOME_CLASSES,
10
+ classifyRequestOutcome,
11
+ type RequestOutcomeClass,
12
+ } from "../usage/request-outcome";
13
+
14
+ export const REQUEST_METRICS_PROTOCOLS = Object.freeze(["responses", "chat", "messages", "unknown"] as const);
15
+ /**
16
+ * The exporter's result label set IS the shared outcome vocabulary, not a copy of it. Restating
17
+ * these four strings here is what let the exporter and the dashboard drift into disagreeing about
18
+ * the same request.
19
+ */
20
+ export const REQUEST_METRICS_RESULTS = REQUEST_OUTCOME_CLASSES;
21
+ /**
22
+ * Closed recovery classes exported as Prometheus label values.
23
+ *
24
+ * Bounded by construction: the label can only ever take one of these strings, so no user, model,
25
+ * account or request identifier can reach a series name. `quota`, `policy` and `ciphertext` are
26
+ * separate members because an operator seeing a spike needs to know which one it is -- waiting
27
+ * out a rate limit, changing accounts, changing the prompt and dropping stale ciphertext are
28
+ * four different responses, and collapsing them is what made the existing counter unactionable.
29
+ */
30
+ export const REQUEST_METRICS_RECOVERY_CLASSES = Object.freeze([
31
+ "transient",
32
+ "connection",
33
+ "credential",
34
+ "rate_limit",
35
+ "quota",
36
+ "policy",
37
+ "ciphertext",
38
+ "payload",
39
+ "empty_completion",
40
+ "effort_downgrade",
41
+ "other",
42
+ ] as const);
43
+
44
+ export const REQUEST_DURATION_BUCKETS_SECONDS = Object.freeze([0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60] as const);
45
+ export const REQUEST_TTFT_BUCKETS_SECONDS = Object.freeze([0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30] as const);
46
+
47
+ /**
48
+ * The failure-cause label set IS the shared dictionary, for the same reason the result label set
49
+ * is the shared outcome vocabulary: a restated copy is what let two surfaces drift into
50
+ * disagreeing about the same request.
51
+ *
52
+ * It labels a COUNTER and never a histogram. Fifteen causes across four protocols is sixty
53
+ * series, fixed for the lifetime of the roster, and every value comes from a frozen list, so no
54
+ * user, model, account or request identifier can reach a series name. A histogram labelled by
55
+ * cause would multiply that by its bucket count for no question anyone asks.
56
+ */
57
+ export const REQUEST_METRICS_FAILURE_CAUSES = REQUEST_FAILURE_CAUSES;
58
+
59
+ export type RequestMetricsProtocol = typeof REQUEST_METRICS_PROTOCOLS[number];
60
+ export type RequestMetricsResult = RequestOutcomeClass;
61
+ export type RequestMetricsRecoveryClass = typeof REQUEST_METRICS_RECOVERY_CLASSES[number];
62
+
63
+ export interface RequestMetricFinalFact {
64
+ protocol?: "responses" | "chat" | "messages";
65
+ status: number;
66
+ durationMs: number;
67
+ firstOutputMs?: number;
68
+ terminalStatus?: ResponsesTerminalStatus;
69
+ closeReason?: "terminal" | "client_cancel" | "non_stream" | "body_stall" | "body_overflow";
70
+ attempts?: ReadonlyArray<{
71
+ sendCount: number;
72
+ recoveryKinds: readonly AttemptRecoveryKind[];
73
+ }>;
74
+ spendSends?: number;
75
+ /**
76
+ * Why this request failed, as the recorder derived it. Absent when it did not fail, which is
77
+ * why the counter below cannot be reconstructed by subtracting completions from totals.
78
+ */
79
+ failureCause?: RequestFailureCause;
80
+ }
81
+
82
+ export interface RequestMetricsRecorder {
83
+ recordFinalRequest(fact: RequestMetricFinalFact): void;
84
+ }
85
+
86
+ export interface RequestMetricsSnapshotter {
87
+ snapshot(): string;
88
+ }
89
+
90
+ export interface RequestMetricsOwner extends RequestMetricsRecorder, RequestMetricsSnapshotter {
91
+ resetForTests(): void;
92
+ }
93
+
94
+ interface HistogramCell {
95
+ buckets: number[];
96
+ count: number;
97
+ sum: number;
98
+ }
99
+
100
+ const protocolCell = (value: RequestMetricsProtocol): number => REQUEST_METRICS_PROTOCOLS.indexOf(value);
101
+ const resultCell = (value: RequestMetricsResult): number => REQUEST_METRICS_RESULTS.indexOf(value);
102
+ const recoveryCell = (value: RequestMetricsRecoveryClass): number => REQUEST_METRICS_RECOVERY_CLASSES.indexOf(value);
103
+ const failureCauseCell = (value: RequestFailureCause): number => REQUEST_METRICS_FAILURE_CAUSES.indexOf(value);
104
+
105
+ function matrix(rows: number, columns: number): number[][] {
106
+ return Array.from({ length: rows }, () => Array.from({ length: columns }, () => 0));
107
+ }
108
+
109
+ function histograms(bounds: readonly number[]): HistogramCell[][] {
110
+ return Array.from({ length: REQUEST_METRICS_PROTOCOLS.length }, () => (
111
+ Array.from({ length: REQUEST_METRICS_RESULTS.length }, () => ({
112
+ buckets: Array.from({ length: bounds.length + 1 }, () => 0),
113
+ count: 0,
114
+ sum: 0,
115
+ }))
116
+ ));
117
+ }
118
+
119
+ /**
120
+ * Metrics class for each shared failure cause.
121
+ *
122
+ * Keyed on the cause rather than on the recovery kind so this projection and the durable log
123
+ * speak one vocabulary. Total by construction: the previous switch ended in `default: "other"`,
124
+ * which meant a recovery kind added later compiled cleanly and then disappeared into an
125
+ * unactionable bucket. A missing member is now a typecheck failure.
126
+ */
127
+ const CAUSE_METRICS_CLASS = {
128
+ "transport-unsent": "connection",
129
+ "transport-ambiguous": "connection",
130
+ "upstream-declined": "transient",
131
+ "rate-limit": "rate_limit",
132
+ "quota-exhausted": "quota",
133
+ "credential-rejected": "credential",
134
+ "policy-refusal": "policy",
135
+ "parameter-rejected": "effort_downgrade",
136
+ "ciphertext-refusal": "ciphertext",
137
+ "payload-too-large": "payload",
138
+ "payload-rejected": "payload",
139
+ "upstream-fault": "transient",
140
+ "empty-output": "empty_completion",
141
+ "client-cancelled": "other",
142
+ "local-refusal": "other",
143
+ } as const satisfies Record<RequestFailureCause, RequestMetricsRecoveryClass>;
144
+
145
+ function recoveryClass(kind: AttemptRecoveryKind): RequestMetricsRecoveryClass {
146
+ return CAUSE_METRICS_CLASS[causeForRecoveryKind(kind)];
147
+ }
148
+
149
+ function observeHistogram(cell: HistogramCell, bounds: readonly number[], value: number): void {
150
+ if (!Number.isFinite(value) || value < 0) return;
151
+ cell.count += 1;
152
+ cell.sum += value;
153
+ for (let index = 0; index < bounds.length; index += 1) {
154
+ if (value <= bounds[index]!) cell.buckets[index]! += 1;
155
+ }
156
+ cell.buckets[bounds.length]! += 1;
157
+ }
158
+
159
+ function sampleLabels(protocol: RequestMetricsProtocol, result?: RequestMetricsResult): string {
160
+ return result === undefined
161
+ ? `{protocol="${protocol}"}`
162
+ : `{protocol="${protocol}",result="${result}"}`;
163
+ }
164
+
165
+ function appendHistogram(
166
+ lines: string[],
167
+ name: string,
168
+ help: string,
169
+ values: HistogramCell[][],
170
+ bounds: readonly number[],
171
+ ): void {
172
+ lines.push(`# HELP ${name} ${help}`, `# TYPE ${name} histogram`);
173
+ for (const protocol of REQUEST_METRICS_PROTOCOLS) {
174
+ for (const result of REQUEST_METRICS_RESULTS) {
175
+ const cell = values[protocolCell(protocol)]![resultCell(result)]!;
176
+ for (let index = 0; index < bounds.length; index += 1) {
177
+ lines.push(`${name}_bucket{protocol="${protocol}",result="${result}",le="${bounds[index]}"} ${cell.buckets[index]}`);
178
+ }
179
+ lines.push(`${name}_bucket{protocol="${protocol}",result="${result}",le="+Inf"} ${cell.buckets[bounds.length]}`);
180
+ lines.push(`${name}_sum${sampleLabels(protocol, result)} ${cell.sum}`);
181
+ lines.push(`${name}_count${sampleLabels(protocol, result)} ${cell.count}`);
182
+ }
183
+ }
184
+ }
185
+
186
+ export function createRequestMetricsOwner(
187
+ processStartTimeSeconds = Date.now() / 1000,
188
+ ): RequestMetricsOwner {
189
+ let logicalRequests = matrix(REQUEST_METRICS_PROTOCOLS.length, REQUEST_METRICS_RESULTS.length);
190
+ let physicalSends = Array.from({ length: REQUEST_METRICS_PROTOCOLS.length }, () => 0);
191
+ let recoveries = matrix(REQUEST_METRICS_PROTOCOLS.length, REQUEST_METRICS_RECOVERY_CLASSES.length);
192
+ let failureCauses = matrix(REQUEST_METRICS_PROTOCOLS.length, REQUEST_METRICS_FAILURE_CAUSES.length);
193
+ let durations = histograms(REQUEST_DURATION_BUCKETS_SECONDS);
194
+ let ttft = histograms(REQUEST_TTFT_BUCKETS_SECONDS);
195
+ let missingTtft = matrix(REQUEST_METRICS_PROTOCOLS.length, REQUEST_METRICS_RESULTS.length);
196
+
197
+ return {
198
+ recordFinalRequest(fact): void {
199
+ const protocol: RequestMetricsProtocol = fact.protocol ?? "unknown";
200
+ const result = classifyRequestOutcome(fact);
201
+ const protocolIndex = protocolCell(protocol);
202
+ const resultIndex = resultCell(result);
203
+ logicalRequests[protocolIndex]![resultIndex]! += 1;
204
+
205
+ const attempts = fact.attempts;
206
+ const sends = attempts === undefined
207
+ ? (Number.isInteger(fact.spendSends) && fact.spendSends! >= 0 ? fact.spendSends! : 0)
208
+ : attempts.reduce((total, attempt) => (
209
+ Number.isInteger(attempt.sendCount) && attempt.sendCount >= 0 ? total + attempt.sendCount : total
210
+ ), 0);
211
+ physicalSends[protocolIndex]! += sends;
212
+
213
+ // Counted from the cause the recorder derived, not re-derived here. Two derivations of one
214
+ // answer is the disagreement this batch exists to remove, and the recorder is the only
215
+ // place that sees the transport facts a cause needs.
216
+ if (fact.failureCause !== undefined) {
217
+ failureCauses[protocolIndex]![failureCauseCell(fact.failureCause)]! += 1;
218
+ }
219
+
220
+ for (const attempt of attempts ?? []) {
221
+ for (const kind of new Set(attempt.recoveryKinds)) {
222
+ recoveries[protocolIndex]![recoveryCell(recoveryClass(kind))]! += 1;
223
+ }
224
+ }
225
+
226
+ observeHistogram(durations[protocolIndex]![resultIndex]!, REQUEST_DURATION_BUCKETS_SECONDS, fact.durationMs / 1000);
227
+ if (typeof fact.firstOutputMs === "number" && Number.isFinite(fact.firstOutputMs) && fact.firstOutputMs >= 0) {
228
+ observeHistogram(ttft[protocolIndex]![resultIndex]!, REQUEST_TTFT_BUCKETS_SECONDS, fact.firstOutputMs / 1000);
229
+ } else {
230
+ missingTtft[protocolIndex]![resultIndex]! += 1;
231
+ }
232
+ },
233
+
234
+ snapshot(): string {
235
+ const lines: string[] = [
236
+ "# HELP opencodex_logical_requests_total Finalized logical requests in this process.",
237
+ "# TYPE opencodex_logical_requests_total counter",
238
+ ];
239
+ for (const protocol of REQUEST_METRICS_PROTOCOLS) {
240
+ for (const result of REQUEST_METRICS_RESULTS) {
241
+ lines.push(`opencodex_logical_requests_total${sampleLabels(protocol, result)} ${logicalRequests[protocolCell(protocol)]![resultCell(result)]}`);
242
+ }
243
+ }
244
+ lines.push(
245
+ "# HELP opencodex_physical_sends_total Upstream sends made by finalized logical requests in this process.",
246
+ "# TYPE opencodex_physical_sends_total counter",
247
+ );
248
+ for (const protocol of REQUEST_METRICS_PROTOCOLS) {
249
+ lines.push(`opencodex_physical_sends_total${sampleLabels(protocol)} ${physicalSends[protocolCell(protocol)]}`);
250
+ }
251
+ lines.push(
252
+ "# HELP opencodex_recoveries_total Distinct recovery kinds observed per physical attempt in this process.",
253
+ "# TYPE opencodex_recoveries_total counter",
254
+ );
255
+ for (const protocol of REQUEST_METRICS_PROTOCOLS) {
256
+ for (const recovery of REQUEST_METRICS_RECOVERY_CLASSES) {
257
+ lines.push(`opencodex_recoveries_total{protocol="${protocol}",recovery="${recovery}"} ${recoveries[protocolCell(protocol)]![recoveryCell(recovery)]}`);
258
+ }
259
+ }
260
+ lines.push(
261
+ "# HELP opencodex_request_failures_total Finalized logical requests that did not deliver an answer, by derived cause.",
262
+ "# TYPE opencodex_request_failures_total counter",
263
+ );
264
+ for (const protocol of REQUEST_METRICS_PROTOCOLS) {
265
+ for (const cause of REQUEST_METRICS_FAILURE_CAUSES) {
266
+ lines.push(`opencodex_request_failures_total{protocol="${protocol}",cause="${cause}"} ${failureCauses[protocolCell(protocol)]![failureCauseCell(cause)]}`);
267
+ }
268
+ }
269
+ appendHistogram(lines, "opencodex_request_duration_seconds", "Finalized logical request duration in seconds.", durations, REQUEST_DURATION_BUCKETS_SECONDS);
270
+ appendHistogram(lines, "opencodex_ttft_seconds", "Observed time to first output in seconds.", ttft, REQUEST_TTFT_BUCKETS_SECONDS);
271
+ lines.push(
272
+ "# HELP opencodex_ttft_missing_total Finalized logical requests without an observed time to first output.",
273
+ "# TYPE opencodex_ttft_missing_total counter",
274
+ );
275
+ for (const protocol of REQUEST_METRICS_PROTOCOLS) {
276
+ for (const result of REQUEST_METRICS_RESULTS) {
277
+ lines.push(`opencodex_ttft_missing_total${sampleLabels(protocol, result)} ${missingTtft[protocolCell(protocol)]![resultCell(result)]}`);
278
+ }
279
+ }
280
+ lines.push(
281
+ "# HELP opencodex_metrics_process_start_time_seconds Unix time when this process metrics owner started.",
282
+ "# TYPE opencodex_metrics_process_start_time_seconds gauge",
283
+ `opencodex_metrics_process_start_time_seconds ${processStartTimeSeconds}`,
284
+ );
285
+ return `${lines.join("\n")}\n`;
286
+ },
287
+
288
+ resetForTests(): void {
289
+ logicalRequests = matrix(REQUEST_METRICS_PROTOCOLS.length, REQUEST_METRICS_RESULTS.length);
290
+ physicalSends = Array.from({ length: REQUEST_METRICS_PROTOCOLS.length }, () => 0);
291
+ recoveries = matrix(REQUEST_METRICS_PROTOCOLS.length, REQUEST_METRICS_RECOVERY_CLASSES.length);
292
+ failureCauses = matrix(REQUEST_METRICS_PROTOCOLS.length, REQUEST_METRICS_FAILURE_CAUSES.length);
293
+ durations = histograms(REQUEST_DURATION_BUCKETS_SECONDS);
294
+ ttft = histograms(REQUEST_TTFT_BUCKETS_SECONDS);
295
+ missingTtft = matrix(REQUEST_METRICS_PROTOCOLS.length, REQUEST_METRICS_RESULTS.length);
296
+ },
297
+ };
298
+ }
@@ -338,7 +338,7 @@ export function createAdapterContinuations(
338
338
  route.provider = rotated;
339
339
  invalidateSameTargetRequest();
340
340
  transportState.activeAdapter = resolveSelectionAdapter(
341
- resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire),
341
+ resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire, route.staticPolicy),
342
342
  config.cacheRetention,
343
343
  );
344
344
  bindRouteReasoningReplayScope({
@@ -384,7 +384,7 @@ export function createAdapterContinuations(
384
384
  invalidateSameTargetRequest();
385
385
  logCtx.provider = formatAnthropicProviderForLog("anthropic", admitted.accountId, config);
386
386
  transportState.activeAdapter = resolveSelectionAdapter(
387
- resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire),
387
+ resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire, route.staticPolicy),
388
388
  config.cacheRetention,
389
389
  );
390
390
  sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, transportState.activeAdapter.name, logCtx.accountLogLabel);
@@ -449,7 +449,7 @@ export function createAdapterContinuations(
449
449
  if (applied) {
450
450
  invalidateSameTargetRequest();
451
451
  transportState.activeAdapter = resolveSelectionAdapter(
452
- resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire),
452
+ resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire, route.staticPolicy),
453
453
  config.cacheRetention,
454
454
  );
455
455
  bindRouteReasoningReplayScope({
@@ -246,7 +246,11 @@ export async function prepareAdapterExchange(
246
246
  return new Response(JSON.stringify(json), { headers: { "Content-Type": "application/json" } });
247
247
  }
248
248
  try {
249
- 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
+ });
250
254
  refreshRequestToolAliases(initialRequest);
251
255
  recordAdapterReasoning(logCtx, initialRequest);
252
256
  recordAdapterTier(logCtx, initialRequest);
@@ -405,6 +409,7 @@ export async function prepareAdapterExchange(
405
409
  retryRequest = await transportState.activeAdapter.buildRequest(parsed, {
406
410
  headers: requestState.selectedForwardHeaders,
407
411
  translatorBudget,
412
+ abortSignal: upstream.signal,
408
413
  ...(transportState.imageTierBias > 0 ? { imageTierBias: transportState.imageTierBias } : {}),
409
414
  });
410
415
  recordAdapterReasoning(logCtx, retryRequest);
@@ -581,7 +586,7 @@ export async function prepareAdapterExchange(
581
586
  route.provider = refreshedProvider;
582
587
  invalidateSameTargetRequest();
583
588
  transportState.activeAdapter = resolveSelectionAdapter(
584
- resolveWireProtocolOverride(route.providerName, route.modelId, refreshedProvider, inboundWire),
589
+ resolveWireProtocolOverride(route.providerName, route.modelId, refreshedProvider, inboundWire, route.staticPolicy),
585
590
  config.cacheRetention,
586
591
  );
587
592
  bindRouteReasoningReplayScope({
@@ -614,7 +619,7 @@ export async function prepareAdapterExchange(
614
619
  route.provider = rotated;
615
620
  invalidateSameTargetRequest();
616
621
  transportState.activeAdapter = resolveSelectionAdapter(
617
- resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire),
622
+ resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire, route.staticPolicy),
618
623
  config.cacheRetention,
619
624
  );
620
625
  bindRouteReasoningReplayScope({
@@ -714,7 +719,7 @@ export async function prepareAdapterExchange(
714
719
  route.provider = rotated;
715
720
  invalidateSameTargetRequest();
716
721
  transportState.activeAdapter = resolveSelectionAdapter(
717
- resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire),
722
+ resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire, route.staticPolicy),
718
723
  config.cacheRetention,
719
724
  );
720
725
  bindRouteReasoningReplayScope({
@@ -757,7 +762,7 @@ export async function prepareAdapterExchange(
757
762
  invalidateSameTargetRequest();
758
763
  logCtx.provider = formatAnthropicProviderForLog("anthropic", admitted.accountId, config);
759
764
  transportState.activeAdapter = resolveSelectionAdapter(
760
- resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire),
765
+ resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire, route.staticPolicy),
761
766
  config.cacheRetention,
762
767
  );
763
768
  sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, transportState.activeAdapter.name, logCtx.accountLogLabel);
@@ -832,7 +837,7 @@ export async function prepareAdapterExchange(
832
837
  }
833
838
  invalidateSameTargetRequest();
834
839
  transportState.activeAdapter = resolveSelectionAdapter(
835
- resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire),
840
+ resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire, route.staticPolicy),
836
841
  config.cacheRetention,
837
842
  );
838
843
  sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, transportState.activeAdapter.name, logCtx.accountLogLabel);
@@ -4,7 +4,8 @@ import {
4
4
  UPSTREAM_CLOSED_BEFORE_RESPONSE_CODE,
5
5
  UPSTREAM_NO_RESPONSE_CODE,
6
6
  } from "../../lib/upstream-retry";
7
- import { readFileSync } from "node:fs";
7
+ import type { RequestFailureCause, RequestFailureStage } from "../../lib/request-failure-model";
8
+ import { packageVersion } from "../../lib/package-version";
8
9
  // If the 101 never arrives (network black hole), give SSE a chance well before
9
10
  // the caller's connect timeout (default 200s) would fire.
10
11
  export const UPGRADE_DEADLINE_MS = 10_000;
@@ -72,13 +73,7 @@ export function markCodexWsResponse(response: Response, observed: boolean): void
72
73
  * importing management-api from the transport layer would invert the
73
74
  * layering and pull the management surface into every WS exchange.
74
75
  */
75
- const OCX_VERSION = (() => {
76
- try {
77
- return JSON.parse(readFileSync(new URL("../../../package.json", import.meta.url), "utf8")).version as string;
78
- } catch {
79
- return "0.0.0";
80
- }
81
- })();
76
+ const OCX_VERSION = packageVersion("0.0.0");
82
77
 
83
78
  /**
84
79
  * The durable form of the stage counters, carried out of the exchange on the
@@ -210,6 +205,37 @@ export function classifyCodexWsFailure(stage: CodexWsFailureStage): CodexWsFailu
210
205
  return "no-response-event";
211
206
  }
212
207
 
208
+ /**
209
+ * The same four outcomes said in the shared stage-and-cause vocabulary (#4191).
210
+ *
211
+ * A projection, not a second classifier: {@link classifyCodexWsFailure} stays the one place that
212
+ * reads the counters, and this only restates its answer in the words the durable log, the metrics
213
+ * projection and the HTTP path already use. Without it the WebSocket transport is the one surface
214
+ * whose failures cannot be compared with anything else, which is the reported symptom -- every
215
+ * such failure reached the user as one of two bare sentences.
216
+ *
217
+ * It does not relax the transport's own rule. The no-replay-after-send contract in
218
+ * `codex-ws-exchange.ts` holds regardless of what this returns, and the stage below is
219
+ * deliberately not consulted as a fallback-eligibility signal; it reports where the exchange got
220
+ * to, and `resendPermission` happens to agree that everything past `before-send` is refused.
221
+ */
222
+ export const CODEX_WS_FAILURE_PROJECTION = {
223
+ /** The create frame never left, so the origin provably never saw this turn. */
224
+ "before-send": { stage: "pre-header", cause: "transport-unsent" },
225
+ /** The frame left and the socket said nothing at all. The turn may be running upstream. */
226
+ "no-upstream-frame": { stage: "pre-header", cause: "transport-ambiguous" },
227
+ /** Control frames only: the peer is alive and answered, but no Responses event arrived. */
228
+ "no-response-event": { stage: "protocol-prelude", cause: "transport-ambiguous" },
229
+ /** Events already reached the caller, so a resend would duplicate output they have seen. */
230
+ "after-response-started": { stage: "semantic-output", cause: "transport-ambiguous" },
231
+ } as const satisfies Record<CodexWsFailureCause, { stage: RequestFailureStage; cause: RequestFailureCause }>;
232
+
233
+ export function projectCodexWsFailure(
234
+ stage: CodexWsFailureStage,
235
+ ): { stage: RequestFailureStage; cause: RequestFailureCause } {
236
+ return CODEX_WS_FAILURE_PROJECTION[classifyCodexWsFailure(stage)];
237
+ }
238
+
213
239
  /**
214
240
  * Render the stage as a suffix appended to an existing failure message.
215
241
  *
@@ -1,6 +1,7 @@
1
1
  import type { ResponsesTerminalStatus } from "../../bridge";
2
2
  import { comboFailureDecision } from "../../combos";
3
3
  import { httpStatusFromTerminalError } from "../../lib/errors";
4
+ import type { RequestFailureStage } from "../../lib/request-failure-model";
4
5
  import type { RequestLogContext } from "../request-log";
5
6
  import { createSseInspector } from "../relay";
6
7
  import { MAX_CLIENT_SSE_FRAME_BYTES } from "../sse-frame-buffer";
@@ -108,9 +109,48 @@ export function comboStreamPayloadCommitsOutput(payload: unknown): boolean {
108
109
  if (!payload || typeof payload !== "object" || Array.isArray(payload)) return true;
109
110
  const type = (payload as { type?: unknown }).type;
110
111
  if (typeof type !== "string") return true;
112
+ if (type === "response.created") {
113
+ // A created event is a control frame only while its snapshot is empty. An origin that
114
+ // resumes a turn can put completed items in it, and treating that as a prelude would let
115
+ // a replacement re-emit output the caller already received.
116
+ const response = (payload as { response?: unknown }).response;
117
+ if (response && typeof response === "object" && !Array.isArray(response)) {
118
+ const output = (response as { output?: unknown }).output;
119
+ if (Array.isArray(output) && output.length > 0) return true;
120
+ }
121
+ }
111
122
  return !PRE_OUTPUT_CONTROL_EVENTS.has(type) && !TERMINAL_EVENTS.has(type);
112
123
  }
113
124
 
125
+ /**
126
+ * How far this SSE body got, in the vocabulary of src/lib/request-failure-model.ts.
127
+ *
128
+ * The preflight cannot separate `semantic-output` from `side-effect`: it classifies any event
129
+ * that is not a lifecycle control frame as committing, without reading item types. Both stages
130
+ * refuse a resend, so the distinction would change no decision -- it is named here so a later
131
+ * reader does not mistake the collapse for an omission.
132
+ *
133
+ * A terminal that settled carrying no output is `protocol-prelude`, not `terminal`. That is
134
+ * the failure model's own rule: `terminal` means the answer was delivered, and an empty
135
+ * completion delivered none.
136
+ *
137
+ * Only the two nothing-observed stages actually reach a read error today: the loop below hands
138
+ * the body back as `accepted` the moment output commits or a terminal arrives, so a stream
139
+ * that committed anything never reports a stage at all. The committed branches stay because
140
+ * this has to be total for any other caller, and because a later change to that loop must not
141
+ * be able to promote a committed stream into a replaceable one by omission.
142
+ */
143
+ function observedResponsesStage(state: {
144
+ readonly outputCommitted: boolean;
145
+ readonly terminalStatus: ResponsesTerminalStatus | undefined;
146
+ readonly responseCreated: boolean;
147
+ }): RequestFailureStage {
148
+ if (state.outputCommitted) return "semantic-output";
149
+ if (state.terminalStatus === "completed") return "terminal";
150
+ if (state.responseCreated || state.terminalStatus !== undefined) return "protocol-prelude";
151
+ return "headers-only";
152
+ }
153
+
114
154
  function replayBufferedResponse(
115
155
  response: Response,
116
156
  reader: ReadableStreamDefaultReader<Uint8Array>,
@@ -186,13 +226,22 @@ function failedTerminalResponse(
186
226
 
187
227
  export type ComboStreamPreflightResult =
188
228
  | { kind: "accepted"; response: Response }
189
- | { kind: "failed"; response: Response };
229
+ | { kind: "failed"; response: Response }
230
+ /**
231
+ * The body errored mid-stream and `replayReadErrors` asked for the prefix back rather than
232
+ * a rethrow. `stage` is how far the inspection actually got; whether that permits a
233
+ * replacement is the resend gate's decision, not this function's. Callers that only act on
234
+ * a projected terminal can treat this exactly as `accepted`, which is what it was before
235
+ * the stage became observable.
236
+ */
237
+ | { kind: "read-error"; response: Response; error: unknown; stage: RequestFailureStage };
190
238
 
191
239
  /**
192
- * Buffer a combo child's downstream SSE only until the request becomes unsafe to
193
- * replay or reaches a terminal. This owns exactly one body reader. The aggregate
194
- * buffer is capped by bytes and retained chunks; hitting either cap commits the
195
- * current target instead of growing memory or guessing that replay is safe.
240
+ * Buffer a Responses SSE only until the request becomes unsafe to replay or reaches a
241
+ * terminal. Combo failover and native post-header reset recovery share this protocol
242
+ * boundary, because they are asking the same question about the same bytes. This owns exactly
243
+ * one body reader. The aggregate buffer is capped by bytes and retained chunks; hitting either
244
+ * cap commits the current target instead of growing memory or guessing that replay is safe.
196
245
  */
197
246
  export async function preflightComboStreamResponse(
198
247
  response: Response,
@@ -211,12 +260,18 @@ export async function preflightComboStreamResponse(
211
260
  const buffered: Uint8Array[] = [];
212
261
  let bufferedBytes = 0;
213
262
  let outputCommitted = false;
263
+ let responseCreated = false;
214
264
  let terminalStatus: ResponsesTerminalStatus | undefined;
215
265
  let retryableTerminalPayload: Record<string, unknown> | undefined;
216
266
  const inspector = createSseInspector({
217
267
  logCtx,
268
+ // A payload the inspector could not parse still reached this proxy, and it may be output.
269
+ // Committing on it is what keeps an unreadable frame from reading as an empty prelude.
270
+ onOpaquePayload: () => { outputCommitted = true; },
218
271
  onParsedPayload: payload => {
219
272
  if (terminalStatus !== undefined || outputCommitted || retryableTerminalPayload) return;
273
+ if (payload !== null && typeof payload === "object" && !Array.isArray(payload)
274
+ && (payload as { type?: unknown }).type === "response.created") responseCreated = true;
220
275
  const retryable = retryableTerminal(payload);
221
276
  const matchedBareError = retryable && payload !== null && typeof payload === "object"
222
277
  && !Array.isArray(payload) && (payload as { type?: unknown }).type === "error";
@@ -239,7 +294,9 @@ export async function preflightComboStreamResponse(
239
294
  // The native relay still owns post-header transport failures. Preserve
240
295
  // the bounded prefix and the errored reader; cancelling it here would
241
296
  // erase the failure before either client relay or inspection sees it.
242
- return { kind: "accepted", response: replayBufferedResponse(response, reader, buffered) };
297
+ const replay = replayBufferedResponse(response, reader, buffered);
298
+ const stage = observedResponsesStage({ outputCommitted, terminalStatus, responseCreated });
299
+ return { kind: "read-error", response: replay, error, stage };
243
300
  }
244
301
  if (next.done) {
245
302
  inspector.finish();
@@ -277,3 +334,108 @@ export async function preflightComboStreamResponse(
277
334
  inspector.dispose();
278
335
  }
279
336
  }
337
+
338
+ /** Produce a replacement body for a mid-stream failure at `stage`, or null to keep the error. */
339
+ export type ProtocolSafeResetRecovery = (
340
+ error: unknown,
341
+ stage: RequestFailureStage,
342
+ ) => Promise<Response | null>;
343
+
344
+ /**
345
+ * Defer protocol inspection until the downstream actually pulls the body.
346
+ *
347
+ * Direct passthrough must return response headers before the first SSE event arrives, so the
348
+ * inspection cannot be awaited at the dispatch site the way combo routing awaits it. Wrapping
349
+ * the body moves it to the first pull, which is the earliest moment the client is willing to
350
+ * wait anyway.
351
+ */
352
+ export function deferProtocolSafeResetRecovery(
353
+ response: Response,
354
+ logCtx: RequestLogContext,
355
+ recover: ProtocolSafeResetRecovery,
356
+ options?: { allowMissingContentType?: boolean },
357
+ ): Response {
358
+ if (!response.body) return response;
359
+
360
+ let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
361
+ let initialization: Promise<void> | undefined;
362
+ let closed = false;
363
+
364
+ const cancelBody = (body: ReadableStream<Uint8Array> | null, reason?: unknown): void => {
365
+ try { void body?.cancel(reason).catch(() => {}); } catch { /* already locked or closed */ }
366
+ };
367
+ const initialize = async (): Promise<void> => {
368
+ const preflight = await preflightComboStreamResponse(
369
+ response,
370
+ logCtx,
371
+ () => false,
372
+ { allowMissingContentType: options?.allowMissingContentType === true, replayReadErrors: true },
373
+ );
374
+ let selected = preflight.response;
375
+ if (preflight.kind === "read-error") {
376
+ const replacement = await recover(preflight.error, preflight.stage);
377
+ if (replacement) {
378
+ cancelBody(selected.body, "using protocol-safe replacement stream");
379
+ selected = replacement;
380
+ }
381
+ }
382
+ if (closed) {
383
+ cancelBody(selected.body, "downstream cancelled before protocol preflight completed");
384
+ return;
385
+ }
386
+ reader = selected.body?.getReader();
387
+ };
388
+
389
+ const body = new ReadableStream<Uint8Array>({
390
+ async pull(controller) {
391
+ try {
392
+ initialization ??= initialize();
393
+ await initialization;
394
+ if (closed) return;
395
+ if (!reader) {
396
+ closed = true;
397
+ controller.close();
398
+ return;
399
+ }
400
+ const next = await reader.read();
401
+ if (closed) return;
402
+ if (next.done) {
403
+ closed = true;
404
+ try { reader.releaseLock(); } catch { /* already released */ }
405
+ reader = undefined;
406
+ controller.close();
407
+ return;
408
+ }
409
+ controller.enqueue(next.value);
410
+ } catch (error) {
411
+ if (closed) return;
412
+ closed = true;
413
+ try { reader?.releaseLock(); } catch { /* errored reader */ }
414
+ reader = undefined;
415
+ controller.error(error);
416
+ }
417
+ },
418
+ cancel(reason) {
419
+ if (closed) return;
420
+ closed = true;
421
+ if (reader) {
422
+ try { void reader.cancel(reason).catch(() => {}); } catch { /* already closed */ }
423
+ try { reader.releaseLock(); } catch { /* already released */ }
424
+ reader = undefined;
425
+ } else if (initialization === undefined) {
426
+ // Nothing has read the upstream yet, so this body is still ours to cancel.
427
+ cancelBody(response.body, reason);
428
+ }
429
+ // A cancel while the preflight is mid-flight falls through deliberately. That body is
430
+ // locked by the preflight's own reader, so cancelling it here would reject and be
431
+ // swallowed; `initialize` sees `closed` when it settles and releases whichever body it
432
+ // ended up selecting, which is the one that actually has to be let go.
433
+ },
434
+ }, { highWaterMark: 0 });
435
+
436
+ return new Response(body, {
437
+ status: response.status,
438
+ statusText: response.statusText,
439
+ headers: response.headers,
440
+ });
441
+ }