@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
@@ -5,10 +5,14 @@ import {
5
5
  CYBER_POLICY_FALLBACK_MESSAGE,
6
6
  isCyberPolicyCode,
7
7
  isCyberPolicyMessage,
8
+ isTerminalRefusalCode,
9
+ safetyRefusalCodeFromMessage,
10
+ terminalRefusalFallbackMessage,
8
11
  upstreamErrorMessageFromPayload,
9
12
  } from "../lib/errors";
10
13
  import { redactSecretString } from "../lib/redact";
11
14
  import { isTranslatorBudgetExceededError } from "../lib/translator-budget";
15
+ import { carryReplayRefusal } from "../lib/upstream-retry";
12
16
  import { isUsageDebugEnabled } from "../usage/debug";
13
17
  import {
14
18
  addRequestLog,
@@ -150,16 +154,61 @@ export function failedTailFrame(encoder: TextEncoder, err: unknown): Uint8Array
150
154
  return encoder.encode(`\n\nevent: response.failed\ndata: ${payload}\n\n${DONE_SSE_FRAME_TEXT}`);
151
155
  }
152
156
 
153
- export function upstreamErrorTailFrame(encoder: TextEncoder, message: string): Uint8Array {
157
+ /**
158
+ * Close a turn the upstream ended without a Responses terminal.
159
+ *
160
+ * `refusalCode` carries the upstream's own verdict when it gave one. Codex
161
+ * classifies this terminal by `error.code` alone and retries everything outside
162
+ * its fatal set (codex-rs/codex-api/src/sse/responses.rs:423-450), so stamping
163
+ * `upstream_server_error` on a refusal delivered it as a retryable disconnect
164
+ * and drove the reconnect loop in #5176. Without a refusal code the terminal is
165
+ * unchanged: a genuine transport failure stays retryable, which is what it is.
166
+ */
167
+ export function upstreamErrorTailFrame(
168
+ encoder: TextEncoder,
169
+ message: string,
170
+ refusalCode?: string,
171
+ ): Uint8Array {
172
+ return encoder.encode(
173
+ `event: response.failed\ndata: ${upstreamErrorFailedPayload(message, refusalCode)}\n\n`,
174
+ );
175
+ }
176
+
177
+ function upstreamErrorFailedPayload(message: string, refusalCode?: string): string {
154
178
  const error = {
155
- type: "upstream_error",
156
- code: "upstream_server_error",
179
+ type: refusalCode === undefined ? "upstream_error" : "invalid_request_error",
180
+ code: refusalCode ?? "upstream_server_error",
157
181
  message: redactSecretString(message).slice(0, MAX_TAIL_ERROR_MESSAGE_CHARS),
158
182
  };
159
- return encoder.encode(`event: response.failed\ndata: ${JSON.stringify({
183
+ return JSON.stringify({
160
184
  type: "response.failed",
161
- response: { status: "failed", error, last_error: error },
162
- })}\n\n`);
185
+ response: {
186
+ status: "failed",
187
+ error,
188
+ last_error: error,
189
+ ...(refusalCode === undefined ? {} : { retryable: false }),
190
+ },
191
+ });
192
+ }
193
+
194
+ /**
195
+ * Terminal for a read that failed after the upstream had already refused.
196
+ *
197
+ * Framed exactly like {@link failedTailFrame} — leading blank line to close a
198
+ * partial block, then the sentinel — but carrying the refusal instead of the
199
+ * generic reset. The refusal is the real outcome of the turn and the socket
200
+ * teardown that followed it is not, so reporting `upstream_reset` here would
201
+ * restart a turn the upstream has already ended (#5176).
202
+ */
203
+ export function refusalFailedTailFrame(
204
+ encoder: TextEncoder,
205
+ message: string,
206
+ refusalCode: string,
207
+ ): Uint8Array {
208
+ const payload = upstreamErrorFailedPayload(message, refusalCode);
209
+ return encoder.encode(
210
+ `\n\nevent: response.failed\ndata: ${payload}\n\n${DONE_SSE_FRAME_TEXT}`,
211
+ );
163
212
  }
164
213
 
165
214
  function boundedBareUpstreamErrorMessage(payload: unknown): string | undefined {
@@ -169,12 +218,57 @@ function boundedBareUpstreamErrorMessage(payload: unknown): string | undefined {
169
218
  return message ? redactSecretString(message).slice(0, MAX_TAIL_ERROR_MESSAGE_CHARS) : undefined;
170
219
  }
171
220
 
221
+ /**
222
+ * A bare upstream `error` event reduced to what the synthesized terminal needs.
223
+ *
224
+ * The structured code is authoritative whenever the upstream sent one: a code
225
+ * that is not a refusal means the upstream did not refuse, whatever its
226
+ * diagnostic text happens to quote. Recognized refusal copy is read only when
227
+ * no code was carried anywhere on the event, which is the shape #5176 reports.
228
+ * A refusal code with no message still yields a terminal, because Codex accepts
229
+ * that shape and supplies its own copy for it.
230
+ *
231
+ * The candidate topology mirrors {@link upstreamErrorMessageFromPayload}: code
232
+ * and message must be read from the same places, or an event whose message is
233
+ * nested under `response.error` would contribute text while its verdict went
234
+ * unseen.
235
+ */
236
+ function boundedBareUpstreamError(payload: unknown): {
237
+ message: string;
238
+ refusalCode: string | undefined;
239
+ } | undefined {
240
+ const root = asJsonRecord(payload);
241
+ if (!root || root.type !== "error") return undefined;
242
+ const message = boundedBareUpstreamErrorMessage(payload);
243
+ const response = asJsonRecord(root.response);
244
+ // Precedence is {@link upstreamErrorMessageFromPayload}'s, so the envelope
245
+ // that supplied the message also supplies the verdict. Taking the FIRST code
246
+ // rather than searching for a refusal is what stops a refusal nested below a
247
+ // transient one from overruling it.
248
+ const code = [
249
+ asJsonRecord(root.error),
250
+ asJsonRecord(root.last_error),
251
+ asJsonRecord(response?.error),
252
+ asJsonRecord(response?.incomplete_details),
253
+ root,
254
+ ]
255
+ .map(candidate => stringField(candidate, "code"))
256
+ .find(candidate => candidate !== undefined);
257
+ const refusalCode = code !== undefined
258
+ ? (isTerminalRefusalCode(code) ? code : undefined)
259
+ : message === undefined ? undefined : safetyRefusalCodeFromMessage(message);
260
+ if (message !== undefined) return { message, refusalCode };
261
+ if (refusalCode === undefined) return undefined;
262
+ return { message: terminalRefusalFallbackMessage(refusalCode), refusalCode };
263
+ }
264
+
172
265
  export type SseTerminalOutputBoundary = {
173
266
  feed(chunk: Uint8Array): Uint8Array;
174
267
  finish(): Uint8Array;
175
268
  terminalSeen(): boolean;
176
269
  doneSeen(): boolean;
177
270
  upstreamError(): string | undefined;
271
+ upstreamRefusalCode(): string | undefined;
178
272
  dispose(): void;
179
273
  };
180
274
 
@@ -197,6 +291,7 @@ export function createSseTerminalOutputBoundary(
197
291
  let pendingDone: { block: Uint8Array; delimiter: Uint8Array } | null = null;
198
292
  let disposed = false;
199
293
  let upstreamError: string | undefined;
294
+ let upstreamRefusalCode: string | undefined;
200
295
 
201
296
  const processFrames = (
202
297
  frames: ReturnType<BoundedSseFrameBuffer["feed"]>,
@@ -210,8 +305,11 @@ export function createSseTerminalOutputBoundary(
210
305
  const parsed = payload === null ? undefined : parseSsePayload(payload);
211
306
  // Observe on the client reader itself: a tee inspection branch may lag
212
307
  // behind EOF, so its log context cannot determine the outgoing terminal.
213
- const message = boundedBareUpstreamErrorMessage(parsed);
214
- if (message !== undefined) upstreamError = message;
308
+ const bare = boundedBareUpstreamError(parsed);
309
+ if (bare !== undefined) {
310
+ upstreamError = bare.message;
311
+ upstreamRefusalCode = bare.refusalCode;
312
+ }
215
313
  const safetyBuffering = dropSafetyBuffering && parsed !== undefined
216
314
  ? codexSafetyBufferingBlockAction(parsed) : "keep";
217
315
  if (safetyBuffering === "drop") continue;
@@ -278,6 +376,7 @@ export function createSseTerminalOutputBoundary(
278
376
  terminalSeen: () => terminal,
279
377
  doneSeen: () => done,
280
378
  upstreamError: () => upstreamError,
379
+ upstreamRefusalCode: () => upstreamRefusalCode,
281
380
  dispose() {
282
381
  if (disposed) return;
283
382
  disposed = true;
@@ -349,7 +448,11 @@ export function relaySseWithFailedTail(
349
448
  const upstreamError = terminalBoundary.upstreamError() ?? opts?.upstreamError;
350
449
  controller.enqueue(upstreamError === undefined
351
450
  ? adapterEofIncompleteFrame(encoder)
352
- : upstreamErrorTailFrame(encoder, upstreamError));
451
+ : upstreamErrorTailFrame(
452
+ encoder,
453
+ upstreamError,
454
+ terminalBoundary.upstreamRefusalCode(),
455
+ ));
353
456
  controller.enqueue(doneFrame(encoder));
354
457
  }
355
458
  terminalBoundary.dispose();
@@ -378,7 +481,11 @@ export function relaySseWithFailedTail(
378
481
  if (!terminalBoundary.doneSeen()) controller.enqueue(doneFrame(encoder));
379
482
  } else {
380
483
  // Leading blank line terminates a partial SSE block so the failed frame parses cleanly.
381
- controller.enqueue(failedTailFrame(encoder, err));
484
+ const refusalCode = terminalBoundary.upstreamRefusalCode();
485
+ const refusalMessage = terminalBoundary.upstreamError();
486
+ controller.enqueue(refusalCode !== undefined && refusalMessage !== undefined
487
+ ? refusalFailedTailFrame(encoder, refusalMessage, refusalCode)
488
+ : failedTailFrame(encoder, err));
382
489
  }
383
490
  controller.close();
384
491
  } catch { /* client already torn down */ }
@@ -720,15 +827,21 @@ export function responseWithDeferredRequestLog(
720
827
  // convention for a client cancellation or upstream read failure.
721
828
  const status = reason === "cancel" ? 499 : reason === "read_error" ? 502 : response.status;
722
829
  addFinalRequestLog(requestId, start, logCtx, status, {
830
+ ...(reason === "eof" && logCtx.observedTerminalStatus
831
+ ? { terminalStatus: logCtx.observedTerminalStatus }
832
+ : {}),
723
833
  closeReason: reason === "cancel" ? "client_cancel" : "non_stream",
724
834
  }, addLog);
725
835
  },
726
836
  });
727
- return new Response(body, {
837
+ // Logging re-wraps the response, and an in-process verdict does not survive a re-wrap on
838
+ // its own. A replay refusal that lost it here would read to a later quota recorder or
839
+ // Retry-After synthesizer as a 429 some upstream produced.
840
+ return carryReplayRefusal(response, new Response(body, {
728
841
  status: response.status,
729
842
  statusText: response.statusText,
730
843
  headers: response.headers,
731
- });
844
+ }));
732
845
  }
733
846
  if (isUsageDebugEnabled() && logCtx.usageDebugBodyKind === undefined) {
734
847
  logCtx.usageDebugBodyKind = response.body ? "other" : "none";
@@ -880,6 +993,14 @@ export type SseInspectorHandlers = {
880
993
  * with an empty `output`.
881
994
  */
882
995
  onParsedPayload?: (payload: unknown) => void;
996
+ /**
997
+ * A complete data payload that did not parse as a JSON event, `[DONE]` included.
998
+ *
999
+ * An inspector that only hears about parsed events cannot tell "nothing has been emitted"
1000
+ * from "something was emitted that I could not read", and a replay decision needs that
1001
+ * difference: an unreadable payload is a payload the caller may already have seen.
1002
+ */
1003
+ onOpaquePayload?: () => void;
883
1004
  onFirstOutput?: () => void;
884
1005
  /**
885
1006
  * Provider-scoped compatibility: persist the completed snapshot under the
@@ -1068,6 +1189,11 @@ export function createSseInspector(handlers: SseInspectorHandlers): SseInspector
1068
1189
  if (handlers.onParsedPayload && parsed !== undefined) {
1069
1190
  try { handlers.onParsedPayload(parsed); } catch { /* inspection must never throw into the pump */ }
1070
1191
  }
1192
+ // The other half of the same observation. A payload that did not parse still reached the
1193
+ // caller, so a consumer deciding whether anything has been emitted has to hear about it.
1194
+ if (handlers.onOpaquePayload && parsed === undefined) {
1195
+ try { handlers.onOpaquePayload(); } catch { /* inspection must never throw into the pump */ }
1196
+ }
1071
1197
  reportFirstOutput.parsed(parsed);
1072
1198
  const status = terminalStatusFromParsed(parsed);
1073
1199
  const policyTerminal = status === "failed"
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Where a request's failure attribution is derived and stamped.
3
+ *
4
+ * A sibling of `request-log.ts` rather than a section inside it. That file carries the whole
5
+ * request-logging surface and sits against the 2,000-line repository ceiling, which only ever
6
+ * moves down; this is the extraction that rule asks for, not a cap that was negotiated.
7
+ *
8
+ * It holds the two places a stage and cause are decided and written. Both read the recorder's
9
+ * own closed facts and neither reads `errorCode` or `upstreamError`, which are assembled partly
10
+ * from upstream text.
11
+ */
12
+ import { deriveRequestFailureAttribution } from "../lib/request-failure-attribution";
13
+ import { causeForRecoveryKind } from "../lib/request-failure-model";
14
+ import type { AttemptRecoveryKind, PersistedUsageAttempt, RequestFailureCause, RequestFailureStage } from "../usage/log";
15
+ import type { ResponsesTerminalStatus } from "../bridge";
16
+
17
+ /** The subset of the log context this derivation may see. Narrow on purpose. */
18
+ export interface FinalRequestAttributionFacts {
19
+ readonly status: number;
20
+ readonly terminalStatus?: ResponsesTerminalStatus | undefined;
21
+ readonly closeReason?: "terminal" | "client_cancel" | "non_stream" | "body_stall" | "body_overflow" | undefined;
22
+ readonly transportPhase?: "pre_headers" | "mid_stream" | "terminal_sse" | undefined;
23
+ readonly terminalSource?: "upstream" | "synthetic" | undefined;
24
+ /** The REQUEST's first-output observation, not the final attempt's. See below. */
25
+ readonly outputObserved: boolean;
26
+ readonly locallyAnswered: boolean;
27
+ readonly attempt?: PersistedUsageAttempt | undefined;
28
+ }
29
+
30
+ export interface StampedAttribution {
31
+ failureStage?: RequestFailureStage;
32
+ failureCause?: RequestFailureCause;
33
+ }
34
+
35
+ /**
36
+ * Derive the attribution for a finished logical request and stamp the attempt that ended it.
37
+ *
38
+ * Called from the one seam every request passes exactly once, and BEFORE the attempt snapshot,
39
+ * so the row that reaches disk and the live attempt object carry the same pair. Deriving it at
40
+ * each transport's own exit would give the same request a different attribution per transport,
41
+ * which is the disagreement the shared terminal classifier already removed once.
42
+ *
43
+ * `outputObserved` is the request's observation rather than the final attempt's. A request that
44
+ * relayed output on its first attempt and then failed over has committed that output to the
45
+ * caller whatever the last attempt saw, so the logical row and the attempt that ended it carry
46
+ * the same answer. Reading the attempt-local value would produce a MORE permissive resend verdict
47
+ * for exactly that case, and a permission decision has to fail in the safe direction.
48
+ */
49
+ export function attributeFinalRequest(facts: FinalRequestAttributionFacts): StampedAttribution {
50
+ const attribution = deriveRequestFailureAttribution({
51
+ status: facts.status,
52
+ ...(facts.terminalStatus ? { terminalStatus: facts.terminalStatus } : {}),
53
+ ...(facts.closeReason ? { closeReason: facts.closeReason } : {}),
54
+ ...(facts.transportPhase ? { transportPhase: facts.transportPhase } : {}),
55
+ ...(facts.terminalSource ? { terminalSource: facts.terminalSource } : {}),
56
+ ...(facts.attempt?.streamAborted === true ? { streamAborted: true } : {}),
57
+ outputObserved: facts.outputObserved,
58
+ // The one fact that can raise a stage above `semantic-output`, and the reason it is counted
59
+ // at the transport rather than at the adapter: an emitted tool call the client never
60
+ // received has committed nothing, and a resend for it is still safe (#3983).
61
+ sideEffectObserved: (facts.attempt?.deliverySummary?.sideEffectEvents ?? 0) > 0,
62
+ locallyAnswered: facts.locallyAnswered,
63
+ recoveryKinds: facts.attempt?.recoveryKinds ?? [],
64
+ });
65
+ // The final row and the attempt that ended it describe the same exchange, so they carry the
66
+ // same pair rather than each deriving one from a different slice of the facts.
67
+ if (facts.attempt) {
68
+ if (attribution?.stage) facts.attempt.failureStage = attribution.stage;
69
+ else delete facts.attempt.failureStage;
70
+ if (attribution?.cause) facts.attempt.failureCause = attribution.cause;
71
+ else delete facts.attempt.failureCause;
72
+ }
73
+ return {
74
+ ...(attribution?.stage ? { failureStage: attribution.stage } : {}),
75
+ ...(attribution?.cause ? { failureCause: attribution.cause } : {}),
76
+ };
77
+ }
78
+
79
+ /**
80
+ * Attribute an attempt being sealed because a named recovery rejected it.
81
+ *
82
+ * The recovery kind is direct evidence here rather than an inference from history: this attempt
83
+ * is ending precisely because that recovery was needed. Without it the sealed attempt would reach
84
+ * the ledger with no attribution at all, because the finalization seam only ever sees the last
85
+ * attempt of a request.
86
+ */
87
+ export function attributeSealedAttempt(
88
+ attempt: PersistedUsageAttempt,
89
+ recovery: AttemptRecoveryKind | undefined,
90
+ ): void {
91
+ const attribution = deriveRequestFailureAttribution({
92
+ status: attempt.status,
93
+ outputObserved: attempt.firstOutputMs !== undefined,
94
+ sideEffectObserved: (attempt.deliverySummary?.sideEffectEvents ?? 0) > 0,
95
+ ...(recovery ? { causeHint: causeForRecoveryKind(recovery) } : {}),
96
+ });
97
+ if (attribution?.stage) attempt.failureStage = attribution.stage;
98
+ if (attribution?.cause) attempt.failureCause = attribution.cause;
99
+ }
@@ -12,6 +12,7 @@ import {
12
12
  isCyberPolicyCode,
13
13
  isCyberPolicyMessage,
14
14
  isRateLimitOrQuotaFailureMessage,
15
+ isUpstreamResetReplayRefusedMessage,
15
16
  upstreamErrorMessageFromPayload,
16
17
  } from "../lib/errors";
17
18
  import { CODEX_CONFIG_PATH, readRootTomlString } from "../codex/paths";
@@ -22,6 +23,7 @@ import { normalizeRouteDecisionTrace, type RouteDecisionTraceV1 } from "../routi
22
23
  import type { AdapterRequest } from "../adapters/base";
23
24
  import type { RequestSpendSettlement } from "./responses/request-spend";
24
25
  import type { AdapterTierMetadata } from "../providers/fastwire";
26
+ import { UPSTREAM_RESET_REPLAY_REFUSED_CODE } from "../lib/upstream-retry";
25
27
  import { redactSecretString, sanitizeLogMetadataString } from "../lib/redact";
26
28
  import {
27
29
  appendUsageEntry,
@@ -38,6 +40,7 @@ import {
38
40
  isLogicalRequestId,
39
41
  isValidReasoningWireValue,
40
42
  normalizeClaudeCompatibilityUsageLog,
43
+ normalizeRequestFailureAttribution,
41
44
  normalizeRequestSpend,
42
45
  readRecentUsageEntries,
43
46
  usageForFinalLog,
@@ -50,9 +53,13 @@ import {
50
53
  type PersistedUsageAttempt,
51
54
  type PersistedUsageEntry,
52
55
  type PersistedClaudeCompatibilityLog,
56
+ type RequestFailureCause,
57
+ type RequestFailureStage,
53
58
  type UsageStatus,
54
59
  } from "../usage/log";
55
60
  import type { RequestExecutionBudget } from "../lib/request-execution-budget";
61
+ import { attributeFinalRequest, attributeSealedAttempt } from "./request-log-failure-attribution";
62
+ import { debugAttemptDeliverySummary } from "../lib/debug";
56
63
  import {
57
64
  appendUsageDebug,
58
65
  isUsageDebugEnabled,
@@ -67,15 +74,37 @@ import { inferCursorContextWindow } from "../adapters/cursor/discovery";
67
74
  import { KIRO_MODEL_CONTEXT_WINDOWS, normalizeKiroModelId } from "../providers/kiro-models";
68
75
  import { DEVIN_MODEL_CONTEXT_WINDOWS } from "../adapters/devin/live-models";
69
76
  import { modelRecordValue } from "../reasoning-effort";
77
+ import type { RequestMetricsRecorder } from "./request-metrics";
78
+ import type {
79
+ CacheDiagnosticDraft,
80
+ CacheDiagnosticFinalFacts,
81
+ PromptCacheKeySource,
82
+ } from "../usage/cache-diagnostic";
83
+
84
+ const CACHE_DIAGNOSTIC_HOOK = Symbol.for("opencodex.cache-diagnostic.v1");
85
+ interface CacheDiagnosticHooks {
86
+ observeInbound(body: unknown, headers: Headers, source: PromptCacheKeySource): CacheDiagnosticDraft;
87
+ rebind(body: unknown, draft: CacheDiagnosticDraft | undefined): void;
88
+ finalize(facts: CacheDiagnosticFinalFacts): void;
89
+ }
90
+ function cacheDiagnosticHooks(): CacheDiagnosticHooks | undefined {
91
+ return (globalThis as Record<symbol, CacheDiagnosticHooks | undefined>)[CACHE_DIAGNOSTIC_HOOK];
92
+ }
70
93
 
71
94
  export interface RequestLogContext {
72
95
  model: string;
73
96
  provider: string;
97
+ /** Optional process-lifetime aggregate sink, injected by the server composition owner. */
98
+ requestMetricsRecorder?: RequestMetricsRecorder;
99
+ /** Bounded terminal enum observed while inspecting a buffered response body. */
100
+ observedTerminalStatus?: ResponsesTerminalStatus;
74
101
  /**
75
102
  * Identity of the ONE logical request this context serves (#4546). Set from the execution
76
103
  * budget minted at ingress; a retry leg, a repair refetch and a combo child share it.
77
104
  */
78
105
  logicalRequestId?: string;
106
+ /** Process-local privacy-bounded cache diagnostic; never persisted with request logs. */
107
+ cacheDiagnosticDraft?: CacheDiagnosticDraft;
79
108
  /**
80
109
  * Internal live reference to this request's execution budget; omitted from RequestLogEntry and
81
110
  * JSONL. Read at final-log time so the row reports the budget's FINAL state rather than a
@@ -202,6 +231,24 @@ export interface RequestLogContext {
202
231
  claudeCompatibility?: PersistedClaudeCompatibilityLog;
203
232
  }
204
233
 
234
+ export function observeCacheDiagnosticInbound(
235
+ logCtx: RequestLogContext,
236
+ body: unknown,
237
+ headers: Headers,
238
+ source: PromptCacheKeySource,
239
+ ): void {
240
+ const draft = cacheDiagnosticHooks()?.observeInbound(body, headers, source);
241
+ if (draft) logCtx.cacheDiagnosticDraft = draft;
242
+ }
243
+
244
+ /** Alias a rebuilt form of the request body to the request's diagnostic draft. */
245
+ export function rebindCacheDiagnosticBody(
246
+ body: unknown,
247
+ draft: CacheDiagnosticDraft | undefined,
248
+ ): void {
249
+ cacheDiagnosticHooks()?.rebind(body, draft);
250
+ }
251
+
205
252
  export interface RequestLogEntry {
206
253
  requestId: string;
207
254
  /** The logical request this row belongs to (#4546); absent on rows written without a budget. */
@@ -288,9 +335,17 @@ export interface RequestLogEntry {
288
335
  routeDecision?: RouteDecisionTraceV1;
289
336
  /** Closed Claude protocol codes; no request or header values. */
290
337
  claudeCompatibility?: PersistedClaudeCompatibilityLog;
338
+ /**
339
+ * How far this request got and why it failed, in the shared stage and cause vocabulary
340
+ * (#2366). Derived once at the single finalization seam and carried on the row so the
341
+ * dashboard, the durable ledger and the exporter read one answer instead of three.
342
+ */
343
+ failureStage?: RequestFailureStage;
344
+ failureCause?: RequestFailureCause;
291
345
  }
292
346
 
293
347
  const requestLog: RequestLogEntry[] = [];
348
+ const requestLogObserversForTests = new Set<(entry: RequestLogEntry) => void>();
294
349
  const MAX_LOG_SIZE = 2000;
295
350
  const requestLogEntryBytes = new WeakMap<RequestLogEntry, number>();
296
351
  let requestLogBytes = 0;
@@ -412,6 +467,7 @@ export function requestLogEntryFromPersistedUsage(entry: PersistedUsageEntry): R
412
467
  ...(entry.conversationStateScrub === "account-change"
413
468
  ? { conversationStateScrub: "account-change" }
414
469
  : {}),
470
+ ...normalizeRequestFailureAttribution(entry),
415
471
  };
416
472
  }
417
473
 
@@ -472,6 +528,24 @@ export function hydrateRequestLogsFromDisk(
472
528
  }
473
529
  }
474
530
 
531
+ /**
532
+ * Rebuild the Logs ring after retention deleted rows from the ledger.
533
+ *
534
+ * Without this a compaction is invisible where an operator actually looks: the ring holds up to
535
+ * 2,000 entries independently of the file, so rows deleted from disk keep serving through
536
+ * /api/logs until eviction or a restart -- the dashboard showing history the ledger no longer
537
+ * has. Observers are deliberately not replayed; they exist to watch NEW rows arrive, and
538
+ * replaying a rehydration through them would announce two thousand arrivals that did not happen.
539
+ */
540
+ export function rehydrateRequestLogsAfterLedgerReplacement(
541
+ reader: () => PersistedUsageEntry[] = () => readRecentUsageEntries(MAX_LOG_SIZE),
542
+ ): number {
543
+ requestLog.length = 0;
544
+ requestLogBytes = 0;
545
+ requestLogsHydratedFromDisk = false;
546
+ return hydrateRequestLogsFromDisk(reader);
547
+ }
548
+
475
549
  export function addRequestLog(entry: RequestLogEntry) {
476
550
  // Sanitize ONCE, at the ingress, and use that one value for both destinations.
477
551
  //
@@ -491,6 +565,9 @@ export function addRequestLog(entry: RequestLogEntry) {
491
565
  else if (retained !== entry) delete retained.claudeCompatibility;
492
566
  entry = retained;
493
567
  retainRequestLogEntry(entry);
568
+ for (const observer of requestLogObserversForTests) {
569
+ try { observer(entry); } catch { /* test observation must never fail request logging */ }
570
+ }
494
571
  try {
495
572
  // Failure diagnostics survive the 200-entry ring buffer by riding the persisted
496
573
  // usage entry (devlog/_plan/260716_claudecode_hardening/030). Success rows stay
@@ -555,6 +632,10 @@ export function addRequestLog(entry: RequestLogEntry) {
555
632
  ...(isKnownTransportPhase(entry.transportPhase) ? { transportPhase: entry.transportPhase } : {}),
556
633
  ...(isKnownTerminalSource(entry.terminalSource) ? { terminalSource: entry.terminalSource } : {}),
557
634
  ...failureDiagnostics,
635
+ // Rebuilt explicitly, like every other field here: this function does not spread the
636
+ // entry, so a pair omitted at this line would reach /api/logs and never reach
637
+ // usage.jsonl, which is the surface the derived failure projection reads.
638
+ ...normalizeRequestFailureAttribution(entry),
558
639
  ...(entry.routeDecision ? { routeDecision: entry.routeDecision } : {}),
559
640
  ...(entry.claudeCompatibility ? { claudeCompatibility: entry.claudeCompatibility } : {}),
560
641
  ...(entry.conversationStateScrub === "account-change"
@@ -566,6 +647,12 @@ export function addRequestLog(entry: RequestLogEntry) {
566
647
  }
567
648
  }
568
649
 
650
+ /** Test-only finalized-row observation without polling the management projection. */
651
+ export function observeRequestLogsForTests(observer: (entry: RequestLogEntry) => void): () => void {
652
+ requestLogObserversForTests.add(observer);
653
+ return () => { requestLogObserversForTests.delete(observer); };
654
+ }
655
+
569
656
  export function nextRequestLogId(_timestamp = Date.now()): string {
570
657
  return `ocx-${randomBytes(16).toString("hex")}`;
571
658
  }
@@ -714,7 +801,15 @@ export function requestLogErrorCode(
714
801
  }
715
802
  return "permission_denied";
716
803
  }
717
- if (status === 429) return "rate_limit_exceeded";
804
+ if (status === 429) {
805
+ // A refused ambiguous reset answers 429 by design (it must not invite a client
806
+ // retry that could duplicate inference); classify it by its message so the log
807
+ // distinguishes a proxy refusal from provider throttling.
808
+ if (upstreamError?.trim() && isUpstreamResetReplayRefusedMessage(upstreamError)) {
809
+ return UPSTREAM_RESET_REPLAY_REFUSED_CODE;
810
+ }
811
+ return "rate_limit_exceeded";
812
+ }
718
813
  if (status === 503) return "server_is_overloaded";
719
814
  if (status >= 500) return "upstream_server_error";
720
815
  return `http_${status}`;
@@ -766,6 +861,22 @@ export function catalogModelSupportsServiceTier(modelId: string, serviceTier: st
766
861
 
767
862
  export function applyResponseLogMetadata(logCtx: RequestLogContext, payload: unknown): void {
768
863
  if (!payload || typeof payload !== "object") return;
864
+ if (logCtx.observedTerminalStatus === undefined) {
865
+ const eventType = (payload as { type?: unknown }).type;
866
+ const response = (payload as { response?: unknown }).response;
867
+ const responseStatus = response && typeof response === "object"
868
+ ? (response as { status?: unknown }).status
869
+ : (payload as { status?: unknown }).status;
870
+ if (eventType === "response.completed") {
871
+ logCtx.observedTerminalStatus = "completed";
872
+ } else if (eventType === "response.failed") {
873
+ logCtx.observedTerminalStatus = "failed";
874
+ } else if (eventType === "response.incomplete") {
875
+ logCtx.observedTerminalStatus = "incomplete";
876
+ } else if (responseStatus === "completed" || responseStatus === "failed" || responseStatus === "incomplete") {
877
+ logCtx.observedTerminalStatus = responseStatus;
878
+ }
879
+ }
769
880
  const source = "response" in payload && typeof (payload as { response?: unknown }).response === "object"
770
881
  ? (payload as { response?: unknown }).response
771
882
  : payload;
@@ -1293,6 +1404,18 @@ export function addFinalRequestLog(
1293
1404
  if (errorCode) logCtx.activeAttempt.errorCode = errorCode;
1294
1405
  else delete logCtx.activeAttempt.errorCode;
1295
1406
  }
1407
+ // Derived and stamped in a sibling module, before the attempt snapshot below. Every input is
1408
+ // a closed value; the open error strings are deliberately not among them.
1409
+ const attribution = attributeFinalRequest({
1410
+ status: effectiveStatus,
1411
+ ...(meta?.terminalStatus ? { terminalStatus: meta.terminalStatus } : {}),
1412
+ ...(closeReason ? { closeReason } : {}),
1413
+ ...(logCtx.transportPhase ? { transportPhase: logCtx.transportPhase } : {}),
1414
+ ...(logCtx.terminalSource ? { terminalSource: logCtx.terminalSource } : {}),
1415
+ outputObserved: logCtx.firstOutputMs !== undefined,
1416
+ locallyAnswered: logCtx.localTerminalReason !== undefined,
1417
+ ...(logCtx.activeAttempt ? { attempt: logCtx.activeAttempt } : {}),
1418
+ });
1296
1419
  // The one seam every request passes exactly once, whatever transport served it and however
1297
1420
  // it ended. The terminal usage belongs to the last send that left; the ledger resolves every
1298
1421
  // earlier send of this request as unresolved spend rather than handing its tokens back.
@@ -1310,6 +1433,10 @@ export function addFinalRequestLog(
1310
1433
  ...(attempt.recoveryWithheld?.length ? { recoveryWithheld: [...attempt.recoveryWithheld] } : {}),
1311
1434
  ...(attempt.usage ? { usage: { ...attempt.usage } } : {}),
1312
1435
  ...(attempt.tierOutcome ? { tierOutcome: { ...attempt.tierOutcome } } : {}),
1436
+ // Detached, like every mutable field beside it: the live summary keeps counting if the
1437
+ // stream is still draining, and a shared reference would let a finalized row change after
1438
+ // it was written.
1439
+ ...(attempt.deliverySummary ? { deliverySummary: { ...attempt.deliverySummary } } : {}),
1313
1440
  }));
1314
1441
  const isCombo = logCtx.comboId !== undefined && (attempts?.length ?? 0) > 0;
1315
1442
  const aggregate = isCombo ? aggregateAttemptUsage(attempts ?? []) : null;
@@ -1317,10 +1444,40 @@ export function addFinalRequestLog(
1317
1444
  const usageStatus = aggregate?.status ?? existing.status;
1318
1445
  const totalTokens = aggregate?.totalTokens ?? existing.totalTokens;
1319
1446
  const spend = requestSpendRecord(logCtx, attempts);
1447
+ const durationMs = Date.now() - start;
1448
+ logCtx.requestMetricsRecorder?.recordFinalRequest({
1449
+ ...(logCtx.inboundProtocol ? { protocol: logCtx.inboundProtocol } : {}),
1450
+ status: effectiveStatus,
1451
+ durationMs,
1452
+ ...(logCtx.firstOutputMs !== undefined ? { firstOutputMs: logCtx.firstOutputMs } : {}),
1453
+ ...(meta?.terminalStatus ? { terminalStatus: meta.terminalStatus } : {}),
1454
+ ...(closeReason ? { closeReason } : {}),
1455
+ ...(attempts !== undefined ? { attempts } : {}),
1456
+ ...(spend ? { spendSends: spend.sends } : {}),
1457
+ ...(attribution.failureCause ? { failureCause: attribution.failureCause } : {}),
1458
+ });
1320
1459
  const cacheProvenance = classifyCacheTelemetryProvenance(loggedUsage, {
1321
1460
  wireParsed: logCtx.usageWireParsed === true,
1322
1461
  });
1323
1462
  const logicalRequestId = logCtx.logicalRequestId ?? logCtx.executionBudget?.logicalRequestId;
1463
+ const normalizedCacheValue = loggedUsage?.cacheReadInputTokens ?? loggedUsage?.cachedInputTokens;
1464
+ cacheDiagnosticHooks()?.finalize({
1465
+ requestId,
1466
+ ...(isLogicalRequestId(logicalRequestId) ? { logicalRequestId } : {}),
1467
+ protocol: logCtx.inboundProtocol ?? "responses",
1468
+ provider: logCtx.provider,
1469
+ model: logCtx.model,
1470
+ ...(isCodexUsageAccountLogLabel(logCtx.accountLogLabel) ? { accountLogLabel: logCtx.accountLogLabel } : {}),
1471
+ ...(logCtx.affinity ? { affinityMove: logCtx.affinity } : {}),
1472
+ ...(logCtx.affinityReason ? { affinityReason: logCtx.affinityReason } : {}),
1473
+ // loggedUsage carries the upstream cache counter by reference all the way from the
1474
+ // adapter extraction for the native Responses route, so an undefined read here is a
1475
+ // genuinely absent counter rather than a defaulted one.
1476
+ ...(normalizedCacheValue !== undefined ? { rawCacheCounterValue: normalizedCacheValue } : {}),
1477
+ ...(normalizedCacheValue !== undefined ? { normalizedCacheValue } : {}),
1478
+ cacheProvenance,
1479
+ ...(logCtx.cacheDiagnosticDraft ? { draft: logCtx.cacheDiagnosticDraft } : {}),
1480
+ });
1324
1481
  // Sanitize at the logging layer, not only at the one call site that populates this today.
1325
1482
  // The value originates in an upstream-supplied model id, so an unsanitized newline would
1326
1483
  // let a single field forge a record boundary in any line-oriented log viewer. Doing it here
@@ -1364,7 +1521,7 @@ export function addFinalRequestLog(
1364
1521
  : {}),
1365
1522
  ...(logCtx.resolvedModel ? { resolvedModel: logCtx.resolvedModel } : {}),
1366
1523
  status: effectiveStatus,
1367
- durationMs: Date.now() - start,
1524
+ durationMs,
1368
1525
  ...(logCtx.firstOutputMs !== undefined ? { firstOutputMs: logCtx.firstOutputMs } : {}),
1369
1526
  ...(errorCode ? { errorCode } : {}),
1370
1527
  ...(meta?.terminalStatus ? { terminalStatus: meta.terminalStatus } : {}),
@@ -1388,7 +1545,10 @@ export function addFinalRequestLog(
1388
1545
  ...(logCtx.terminalSource ? { terminalSource: logCtx.terminalSource } : {}),
1389
1546
  ...(logCtx.routeDecision ? { routeDecision: logCtx.routeDecision } : {}),
1390
1547
  ...(claudeCompatibility ? { claudeCompatibility } : {}),
1548
+ ...attribution,
1391
1549
  });
1550
+ // Formatted from the finalized snapshot, so the ring shows exactly what the ledger holds.
1551
+ for (const attempt of attempts ?? []) debugAttemptDeliverySummary(requestId, attempt);
1392
1552
  if (isUsageDebugEnabled()) {
1393
1553
  appendUsageDebug({
1394
1554
  ts: Date.now(),
@@ -1635,8 +1795,14 @@ export function noteProviderAttemptSend(
1635
1795
  finishRequestAttempt(attempt, attempt.status >= 100 ? attempt.status
1636
1796
  : recovery === "key-401" ? 401 : recovery?.includes("429") ? 429 : 502,
1637
1797
  Date.now() - (logCtx.activeAttemptStartedAt ?? Date.now()), attempt.usage);
1798
+ // This attempt is being sealed because a NAMED recovery rejected it, so the recovery kind
1799
+ // is direct evidence here rather than an inference from history. Without this the sealed
1800
+ // attempt would reach the ledger with no attribution at all: the finalization seam below
1801
+ // only ever sees the last attempt of the request.
1802
+ attributeSealedAttempt(attempt, recovery);
1638
1803
  const completed = { ...attempt, recoveryKinds: [...attempt.recoveryKinds],
1639
1804
  ...(attempt.usage ? { usage: { ...attempt.usage } } : {}),
1805
+ ...(attempt.deliverySummary ? { deliverySummary: { ...attempt.deliverySummary } } : {}),
1640
1806
  ...(attempt.tierOutcome ? { tierOutcome: { ...attempt.tierOutcome } } : {}) };
1641
1807
  const attempts = logCtx.attempts ??= [attempt];
1642
1808
  const index = attempts.indexOf(attempt);
@@ -1809,4 +1975,5 @@ export function clearRequestLogsForTests(): void {
1809
1975
  requestLog.length = 0;
1810
1976
  requestLogBytes = 0;
1811
1977
  requestLogsHydratedFromDisk = false;
1978
+ requestLogObserversForTests.clear();
1812
1979
  }