@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
@@ -165,6 +165,28 @@ export interface RequestExecutionBudget extends TransientSendBudget {
165
165
  readonly alternateTargetSends: number;
166
166
  readonly targetTransitions: number;
167
167
  readonly lastTargetKey: string | undefined;
168
+ /**
169
+ * Spend one operator-granted replacement for an AMBIGUOUS failure of this logical request,
170
+ * up to `limit`. False once the request has none left.
171
+ *
172
+ * It lives on the budget rather than beside the policy that grants it because it has to be
173
+ * shared exactly where the physical-send ledger is shared. A combo child derives its own
174
+ * budget from the parent's ledger, and two counters would let a request whose parent leg
175
+ * reset before the head and whose child leg reset after it replace an unknown-state send
176
+ * twice. It is NOT a send budget: an authorised replacement still has to fit inside
177
+ * `remainingBaseSends` like every other send.
178
+ *
179
+ * `limit` is the ceiling the ASKING leg is authorised to present, and the request keeps the
180
+ * smallest one any leg has presented. A leg reads it from `route.provider`, which credential
181
+ * rotation, OAuth refresh, transport resolution and a combo target all reassign mid-request,
182
+ * so a per-call ceiling meant the number of duplicate inferences a request could make
183
+ * depended on which row happened to ask last: a row granting one, then a row granting two,
184
+ * bought a second replacement of a turn that may already have run.
185
+ *
186
+ * Optional so a hand-written stub that satisfies the shape test keeps typechecking; a caller
187
+ * that cannot reach it has no operator override, which is the fail-closed answer.
188
+ */
189
+ claimAmbiguousResend?(limit: number): boolean;
168
190
  }
169
191
 
170
192
  const RESERVE_FUNDED_CLASSES: ReadonlySet<SendClass> = new Set<SendClass>([
@@ -192,11 +214,49 @@ let logicalRequestSeq = 0;
192
214
  interface SharedSendLedger {
193
215
  spent: number;
194
216
  pendingExternalSends: number;
217
+ /**
218
+ * Spend one of this logical request's replacements for an ambiguous failure. Beside `spent`
219
+ * for the same reason `pendingExternalSends` is: a derived scope that shared one without the
220
+ * other would hand the request a second grant.
221
+ *
222
+ * A function rather than the raw count, because the count is not the whole state. The
223
+ * ceiling belongs to the request too, and a bridged scope has no counter of its own to keep
224
+ * it in -- it has to ask whoever holds the request's grant.
225
+ */
226
+ claimAmbiguousResend(limit: number): boolean;
195
227
  readonly observer?: RequestSendObserver;
196
228
  }
197
229
 
198
230
  const sharedSendLedgers = new WeakMap<RequestExecutionBudget, SharedSendLedger>();
199
231
 
232
+ /**
233
+ * One logical request's replacement grant: how many it has spent, and the ceiling it is held
234
+ * to.
235
+ *
236
+ * The ceiling is the SMALLEST any leg has presented rather than whatever the current leg
237
+ * presents. Each leg reads its number from the provider row it is running against, and that
238
+ * row changes inside one request -- credential rotation, OAuth refresh, transport resolution
239
+ * and each combo target reassign it. Taking the asking leg's number let a request that had
240
+ * already spent the one replacement a strict row granted buy another as soon as a more
241
+ * permissive row asked, which is a second duplicate inference of one turn.
242
+ */
243
+ function createAmbiguousResendGrant(): (limit: number) => boolean {
244
+ let claimed = 0;
245
+ let ceiling: number | undefined;
246
+ return (limit: number): boolean => {
247
+ const presented = Number.isFinite(limit) ? Math.trunc(limit) : 0;
248
+ // A zero or nonsense ceiling refuses on its own and leaves the request's alone. It is a
249
+ // caller that cannot state a grant, not an operator narrowing this request: a leg with no
250
+ // policy is refused before it ever claims, so binding the request to a malformed number
251
+ // would only let such a caller cancel a grant an opted-in row really made.
252
+ if (presented <= 0) return false;
253
+ ceiling = ceiling === undefined ? presented : Math.min(ceiling, presented);
254
+ if (claimed >= ceiling) return false;
255
+ claimed += 1;
256
+ return true;
257
+ };
258
+ }
259
+
200
260
  function createRequestExecutionBudgetWithLedger(
201
261
  policy: RequestExecutionBudgetPolicy,
202
262
  logicalRequestId: string | undefined,
@@ -239,6 +299,9 @@ function createRequestExecutionBudgetWithLedger(
239
299
  const capped = Number.isFinite(cap) ? Math.trunc(cap) : 0;
240
300
  return Math.max(0, Math.min(capped, policy.baseSendAllowance - counter.spent));
241
301
  },
302
+ claimAmbiguousResend(limit: number): boolean {
303
+ return counter.claimAmbiguousResend(limit);
304
+ },
242
305
  reserveDispatch(intent: DispatchIntent): DispatchDecision {
243
306
  if (intent.replaySafe === false) return { allowed: false, reason: "not-replay-safe" };
244
307
  if (counter.spent >= policy.maxTotalModelSends) return { allowed: false, reason: "total-exhausted" };
@@ -336,6 +399,7 @@ export function createRequestExecutionBudget(
336
399
  return createRequestExecutionBudgetWithLedger(policy, logicalRequestId, {
337
400
  spent: 0,
338
401
  pendingExternalSends: 0,
402
+ claimAmbiguousResend: createAmbiguousResendGrant(),
339
403
  ...(observer ? { observer } : {}),
340
404
  });
341
405
  }
@@ -375,6 +439,14 @@ function ledgerFor(parent: RequestExecutionBudget): SharedSendLedger {
375
439
  set spent(next: number) { parent.used = next; },
376
440
  get pendingExternalSends(): number { return pendingExternalSends; },
377
441
  set pendingExternalSends(next: number) { pendingExternalSends = next; },
442
+ // Asked of the parent rather than counted here. A local counter is a SECOND grant: two
443
+ // scopes derived from one bridged parent, or one scope beside the parent it was derived
444
+ // from, each replaced an unknown-state send once. Pending bookings and the durable-spend
445
+ // observer genuinely cannot cross this boundary because they are private to the factory,
446
+ // but the grant can -- `claimAmbiguousResend` is public on the parent. A parent that does
447
+ // not implement it grants nothing, which is the fail-closed answer for a send whose
448
+ // upstream state is unknown.
449
+ claimAmbiguousResend: (limit: number): boolean => parent.claimAmbiguousResend?.(limit) === true,
378
450
  };
379
451
  }
380
452
 
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Derive how far a failed request got and why, from the closed facts the recorder already holds.
3
+ *
4
+ * #2366 asked for durable failure attribution and shipped its own `FailureSide` and seven-member
5
+ * `FailureStage` to carry it. Those are a second attribution vocabulary beside the one that
6
+ * landed in {@link ../lib/request-failure-model}, and two vocabularies for one question is the
7
+ * class of defect that blocked 2.60.0. This module is the same answer expressed in the landed
8
+ * vocabulary: no new stage names, no new cause names, and no new record store.
9
+ *
10
+ * Everything it reads is a CLOSED value the row already carries -- an HTTP status, a terminal
11
+ * status, a close reason, a transport phase, a recovery kind. It never reads `errorCode` or
12
+ * `upstreamError`, which are open strings assembled partly from upstream text: a classification
13
+ * keyed on those is a different answer per provider and per locale, and a grouping key built from
14
+ * them cannot promise it carries no content.
15
+ *
16
+ * MUST stay a leaf. Its only imports are types and the two tables it decides with, so nothing
17
+ * here can pull the usage or budget subsystems into a request path that lacked them.
18
+ */
19
+ import type { AttemptRecoveryKind, RequestFailureCause, RequestFailureStage } from "../usage/telemetry-contract";
20
+ import { causeForRecoveryKind } from "./request-failure-model";
21
+ import { classifyRequestOutcome, type RequestOutcomeFacts } from "../usage/request-outcome";
22
+
23
+ /**
24
+ * What the recorder knows about one finished exchange at the single seam every request passes.
25
+ *
26
+ * Deliberately the same narrow set {@link RequestOutcomeFacts} reads, plus the four observation
27
+ * facts a stage needs. A field that could carry a provider name, a model, an account or upstream
28
+ * text is absent by construction rather than by review.
29
+ */
30
+ export interface RequestFailureFacts extends RequestOutcomeFacts {
31
+ readonly transportPhase?: "pre_headers" | "mid_stream" | "terminal_sse" | undefined;
32
+ /** Where the status and message came from: an origin response, or a tail this proxy wrote. */
33
+ readonly terminalSource?: "upstream" | "synthetic" | undefined;
34
+ /** True when the upstream stream died after its head was committed. */
35
+ readonly streamAborted?: boolean | undefined;
36
+ /** True once any output-bearing event reached the caller; `firstOutputMs` is the usual source. */
37
+ readonly outputObserved?: boolean | undefined;
38
+ /** True once a tool call or other externally visible effect was relayed to the caller. */
39
+ readonly sideEffectObserved?: boolean | undefined;
40
+ /** True when this proxy answered the turn itself and issued no upstream request. */
41
+ readonly locallyAnswered?: boolean | undefined;
42
+ /**
43
+ * Recovery kinds recorded on the attempt that ended the request, in the order they happened.
44
+ * Only the LAST one is ever consulted, and only under the narrow rule below.
45
+ */
46
+ readonly recoveryKinds?: readonly AttemptRecoveryKind[] | undefined;
47
+ /**
48
+ * A cause the CALLER proved, which the status alone cannot reconstruct.
49
+ *
50
+ * Set only by a finalizer that is sealing an attempt it knows the rejection for -- the
51
+ * key-account rotation seals the previous attempt because a named recovery rejected it, and
52
+ * that argument is direct evidence rather than an inference from history. It outranks the
53
+ * status table and is outranked by a client cancel, which is a fact about the caller and not
54
+ * about the origin.
55
+ */
56
+ readonly causeHint?: RequestFailureCause | undefined;
57
+ }
58
+
59
+ /**
60
+ * How far the caller's view of the exchange got.
61
+ *
62
+ * Total by construction and ordered downward from the most committed observation, so a fact that
63
+ * proves a later stage wins over one that only proves an earlier one. The boundary between
64
+ * `headers-only` and `protocol-prelude` is the one genuinely debatable step -- a non-streaming
65
+ * 4xx error body is a body, but not a protocol body event -- and it is safe to argue about
66
+ * because both stages carry the same `nothing-observed` commitment, so no resend decision turns
67
+ * on which side of it a row lands.
68
+ */
69
+ export function deriveRequestFailureStage(facts: RequestFailureFacts): RequestFailureStage {
70
+ if (facts.terminalStatus === "completed" && facts.outputObserved === true) return "terminal";
71
+ if (facts.sideEffectObserved === true) return "side-effect";
72
+ if (facts.outputObserved === true) return "semantic-output";
73
+ if (facts.terminalStatus !== undefined
74
+ || facts.closeReason === "terminal"
75
+ || facts.transportPhase === "mid_stream"
76
+ || facts.transportPhase === "terminal_sse") return "protocol-prelude";
77
+ if (facts.status >= 100) return "headers-only";
78
+ return "pre-header";
79
+ }
80
+
81
+ /**
82
+ * The two recovery kinds that name a 4xx the status alone cannot tell apart.
83
+ *
84
+ * `causeForRecoveryKind` answers why a recovery was ATTEMPTED, which is usually a different
85
+ * question from why the request finally failed -- one that recovered from a 401 and then died on
86
+ * a 500 failed for the 500. So the rule here is deliberately narrow on three axes at once: only
87
+ * these two kinds, only when they are the LAST recovery this attempt recorded, and only when the
88
+ * attempt then ended on the very status that recovery was made for. Everything else falls
89
+ * through to the status table.
90
+ *
91
+ * The residual: a ciphertext recovery that SUCCEEDED, followed by an unrelated 400 on the same
92
+ * attempt, still reads as `ciphertext-refusal`, because a successful recovery does not currently
93
+ * clear its own evidence. Closing that belongs in the recovery path rather than here -- it is
94
+ * recorded in this lane's devlog as the next step -- and the rule is kept meanwhile because
95
+ * without it a rejected ciphertext, a rejected reasoning parameter and a rejected payload are one
96
+ * undifferentiated answer, which is three different remedies collapsed into one.
97
+ */
98
+ const STATUS_CONFIRMED_RECOVERY_KINDS: Readonly<Partial<Record<AttemptRecoveryKind, number>>> = Object.freeze({
99
+ "opaque-blob-rejection": 400,
100
+ "reasoning-effort-downgrade": 400,
101
+ });
102
+
103
+ function refinedFourHundredCause(
104
+ facts: RequestFailureFacts,
105
+ ): RequestFailureCause | undefined {
106
+ const last = facts.recoveryKinds?.at(-1);
107
+ if (last === undefined) return undefined;
108
+ const confirmedStatus = STATUS_CONFIRMED_RECOVERY_KINDS[last];
109
+ return confirmedStatus === facts.status ? causeForRecoveryKind(last) : undefined;
110
+ }
111
+
112
+ /**
113
+ * Why the request failed.
114
+ *
115
+ * Returns `undefined` for an outcome that is not a failure. An incomplete turn is a real
116
+ * shortfall and gets a stage, but this dictionary answers "why did it fail", and a turn cut short
117
+ * by `max_output_tokens` did not fail for any of these reasons; inventing one would put a
118
+ * fabricated cause into a metric label and a grouping key.
119
+ *
120
+ * The status is the primary evidence because it is the one fact every transport produces. Two
121
+ * refinements sit above it, both from closed values: a client cancel is known from the close
122
+ * reason before any status is consulted, and a 400 that a recovery kind identified as a rejected
123
+ * ciphertext or a rejected reasoning parameter is not the same answer as a rejected payload.
124
+ */
125
+ export function deriveRequestFailureCause(facts: RequestFailureFacts): RequestFailureCause | undefined {
126
+ const outcome = classifyRequestOutcome(facts);
127
+ if (outcome === "completed" || outcome === "incomplete") return undefined;
128
+ if (outcome === "aborted") return "client-cancelled";
129
+ if (facts.locallyAnswered === true) return "local-refusal";
130
+ // A cause the finalizer proved outranks anything reconstructed from the status.
131
+ if (facts.causeHint !== undefined) return facts.causeHint;
132
+
133
+ const status = facts.status;
134
+ // Transport evidence outranks the numeric status, because a stream that died mid-flight is
135
+ // reported as a SYNTHETIC 502 -- a tail this proxy wrote, not an answer the origin gave. Read
136
+ // in status order that 502 becomes `upstream-fault`, which claims the origin answered when it
137
+ // did not. Both causes refuse an automatic resend, so this is an accuracy fix rather than a
138
+ // safety one, but a label an operator cannot trust is a label they stop reading.
139
+ if (facts.streamAborted === true
140
+ || (facts.terminalSource === "synthetic"
141
+ && (facts.transportPhase === "mid_stream" || facts.transportPhase === "terminal_sse"))) {
142
+ return "transport-ambiguous";
143
+ }
144
+ // A 2xx head that carried a failed terminal: the origin ran the turn and said it failed. With
145
+ // no output relayed the useful distinction is that nothing usable came back at all.
146
+ if (status >= 100 && status < 400) {
147
+ return facts.outputObserved === true ? "upstream-fault" : "empty-output";
148
+ }
149
+ if (status === 401) return "credential-rejected";
150
+ if (status === 403) return "credential-rejected";
151
+ // Payment required. Waiting out a retry window does not help; the account has to change.
152
+ if (status === 402) return "quota-exhausted";
153
+ if (status === 413) return "payload-too-large";
154
+ if (status === 429) return "rate-limit";
155
+ if (status === 451) return "policy-refusal";
156
+ if (status === 503) return "upstream-declined";
157
+ if (status >= 500) return "upstream-fault";
158
+ if (status >= 400) return refinedFourHundredCause(facts) ?? "payload-rejected";
159
+ // No response head at all, and nothing proved the bytes never left. `transport-ambiguous` is
160
+ // the honest answer for an unknown execution state, and it is the safe one: it refuses an
161
+ // automatic resend where `transport-unsent` would permit one. `transport-unsent` is reachable
162
+ // only through `causeHint`, from a site that classified a pre-connect failure and can prove it.
163
+ return "transport-ambiguous";
164
+ }
165
+
166
+ export interface RequestFailureAttribution {
167
+ stage: RequestFailureStage;
168
+ cause?: RequestFailureCause;
169
+ }
170
+
171
+ /**
172
+ * The attribution to persist, or `undefined` when the request completed.
173
+ *
174
+ * A completed request has no failure to attribute, and recording a stage for one would put a
175
+ * `terminal` row into every grouping that exists to find failures.
176
+ */
177
+ export function deriveRequestFailureAttribution(
178
+ facts: RequestFailureFacts,
179
+ ): RequestFailureAttribution | undefined {
180
+ if (classifyRequestOutcome(facts) === "completed") return undefined;
181
+ const cause = deriveRequestFailureCause(facts);
182
+ return { stage: deriveRequestFailureStage(facts), ...(cause ? { cause } : {}) };
183
+ }
@@ -0,0 +1,236 @@
1
+ /**
2
+ * One vocabulary for how far a failed request got, why it failed, and whether this proxy may
3
+ * send it again (roadmap items 7 and 14).
4
+ *
5
+ * These two items are one module on purpose. Item 7 wants a resend decision per failure stage;
6
+ * item 14 wants one cause dictionary spanning logical request, attempt, physical send and
7
+ * terminal. Defined apart they typecheck on each branch and contradict each other in the merge,
8
+ * which is the class that blocked 2.60.0.
9
+ *
10
+ * What lives here is the vocabulary and the decision derived from it. What does NOT live here is
11
+ * a second record store: the durable shapes stay `PersistedUsageAttempt` and
12
+ * `PersistedUsageEntry` in src/usage/log.ts, and every projection below reads those structurally
13
+ * rather than growing a parallel history.
14
+ *
15
+ * MUST stay a leaf. Its one runtime import is `src/usage/telemetry-contract.ts`, which has no
16
+ * imports at all; everything else it names is a type, erased at runtime. So nothing here can
17
+ * pull the usage or budget subsystems into a request path that did not already have them.
18
+ */
19
+ import type { SendClass } from "./request-execution-budget";
20
+ import {
21
+ REQUEST_FAILURE_STAGES,
22
+ type AttemptRecoveryKind,
23
+ type RequestFailureCause,
24
+ type RequestFailureStage,
25
+ type ResendPermission,
26
+ } from "../usage/telemetry-contract";
27
+
28
+ /**
29
+ * The vocabulary this module decides over is DECLARED in `src/usage/telemetry-contract.ts` and
30
+ * re-exported here, so every importer of this module keeps its path while the dashboard can
31
+ * reach the same rosters without pulling this file's import graph into the browser project.
32
+ *
33
+ * What stays here is the decision: the per-stage commitment, the per-cause evidence and
34
+ * disposition, and the resend permission derived from them.
35
+ *
36
+ * A stage is how far the OBSERVABLE progression got, not which events happened to arrive. A turn
37
+ * that settled carrying no output -- an empty completion, a 4xx error body -- did not reach
38
+ * `terminal`; it stalled below `semantic-output`, because the caller saw no answer. `terminal`
39
+ * means the answer was delivered, which is why it is both last and refused.
40
+ */
41
+ export {
42
+ REQUEST_FAILURE_CAUSES,
43
+ REQUEST_FAILURE_STAGES,
44
+ RESEND_PERMISSIONS,
45
+ } from "../usage/telemetry-contract";
46
+ export type {
47
+ RequestFailureCause,
48
+ RequestFailureStage,
49
+ ResendPermission,
50
+ } from "../usage/telemetry-contract";
51
+
52
+ /** Position in {@link REQUEST_FAILURE_STAGES}. Derived, so the order is stated exactly once. */
53
+ export function stageRank(stage: RequestFailureStage): number {
54
+ return REQUEST_FAILURE_STAGES.indexOf(stage);
55
+ }
56
+
57
+ /**
58
+ * What the caller has irreversibly observed at a stage.
59
+ *
60
+ * Named separately from the rank so a reader can see WHY a stage refuses rather than inferring it
61
+ * from a position, and so the three committed stages stay distinguishable in a record.
62
+ */
63
+ export type StageCommitment = "nothing-observed" | "output-observed" | "effect-observed" | "answer-delivered";
64
+
65
+ const STAGE_COMMITMENT = {
66
+ "pre-header": "nothing-observed",
67
+ "headers-only": "nothing-observed",
68
+ "protocol-prelude": "nothing-observed",
69
+ "semantic-output": "output-observed",
70
+ "side-effect": "effect-observed",
71
+ "terminal": "answer-delivered",
72
+ } as const satisfies Record<RequestFailureStage, StageCommitment>;
73
+
74
+ export function stageCommitment(stage: RequestFailureStage): StageCommitment {
75
+ return STAGE_COMMITMENT[stage];
76
+ }
77
+
78
+ /**
79
+ * What the cause proves about whether the origin ran the turn.
80
+ *
81
+ * This is the safety axis. `unknown` is the RFC 9110 9.2.2 case and is never upgraded by having
82
+ * budget left: a request whose upstream execution state is unknown is not replayable merely
83
+ * because a counter allows another send.
84
+ */
85
+ export type UpstreamProcessingEvidence = "not-processed" | "declined" | "processed" | "unknown";
86
+
87
+ const CAUSE_EVIDENCE = {
88
+ "transport-unsent": "not-processed",
89
+ "transport-ambiguous": "unknown",
90
+ "upstream-declined": "declined",
91
+ "rate-limit": "declined",
92
+ "quota-exhausted": "declined",
93
+ "credential-rejected": "declined",
94
+ "policy-refusal": "processed",
95
+ "parameter-rejected": "declined",
96
+ "ciphertext-refusal": "declined",
97
+ "payload-too-large": "declined",
98
+ "payload-rejected": "declined",
99
+ "upstream-fault": "unknown",
100
+ "empty-output": "processed",
101
+ "client-cancelled": "unknown",
102
+ "local-refusal": "not-processed",
103
+ } as const satisfies Record<RequestFailureCause, UpstreamProcessingEvidence>;
104
+
105
+ export function causeEvidence(cause: RequestFailureCause): UpstreamProcessingEvidence {
106
+ return CAUSE_EVIDENCE[cause];
107
+ }
108
+
109
+ /**
110
+ * What a resend would have to change to have any chance.
111
+ *
112
+ * The usefulness axis, orthogonal to safety. A policy refusal is perfectly safe to repeat and
113
+ * completely pointless; an ambiguous reset is the reverse.
114
+ */
115
+ export type ResendDisposition = "resend-may-help" | "resend-after-repair" | "resend-is-futile";
116
+
117
+ const CAUSE_DISPOSITION = {
118
+ "transport-unsent": "resend-may-help",
119
+ "transport-ambiguous": "resend-may-help",
120
+ "upstream-declined": "resend-may-help",
121
+ "rate-limit": "resend-may-help",
122
+ "quota-exhausted": "resend-is-futile",
123
+ "credential-rejected": "resend-after-repair",
124
+ "policy-refusal": "resend-is-futile",
125
+ "parameter-rejected": "resend-after-repair",
126
+ "ciphertext-refusal": "resend-after-repair",
127
+ "payload-too-large": "resend-after-repair",
128
+ "payload-rejected": "resend-is-futile",
129
+ "upstream-fault": "resend-may-help",
130
+ "empty-output": "resend-may-help",
131
+ "client-cancelled": "resend-is-futile",
132
+ "local-refusal": "resend-is-futile",
133
+ } as const satisfies Record<RequestFailureCause, ResendDisposition>;
134
+
135
+ export function causeDisposition(cause: RequestFailureCause): ResendDisposition {
136
+ return CAUSE_DISPOSITION[cause];
137
+ }
138
+
139
+ /**
140
+ * Whether this proxy may send the request again, from the stage it failed at and the cause.
141
+ *
142
+ * Derived from the two per-cause facts above and the per-stage commitment, rather than written
143
+ * out as a stage-by-cause matrix. A matrix of that size is a restatement: it would have to be
144
+ * re-derived by hand every time a member is added, and the cell nobody revisited is exactly how
145
+ * two correct branches merge into a wrong table.
146
+ *
147
+ * `refused-ambiguous` forbids an AUTOMATIC resend. It does not forbid a narrowly scoped,
148
+ * explicitly opted-in recovery that a maintainer reasoned about and bounded -- the reset replay
149
+ * behind a default-off provider flag, the single-shot empty-completion rebuild. Those are
150
+ * separate recorded decisions with their own acceptance, which is precisely what distinguishes
151
+ * them from a retry loop that fires because a counter had room.
152
+ */
153
+ export function resendPermission(
154
+ stage: RequestFailureStage,
155
+ cause: RequestFailureCause,
156
+ ): ResendPermission {
157
+ // Any stage at which the caller observed something refuses, whatever the cause says. Testing
158
+ // the commitment rather than listing the committed stages is what keeps a stage added later
159
+ // from defaulting into permission.
160
+ if (STAGE_COMMITMENT[stage] !== "nothing-observed") return "refused-committed";
161
+ if (CAUSE_DISPOSITION[cause] === "resend-is-futile") return "refused-futile";
162
+ const evidence = CAUSE_EVIDENCE[cause];
163
+ if (evidence === "unknown" || evidence === "processed") return "refused-ambiguous";
164
+ return CAUSE_DISPOSITION[cause] === "resend-after-repair" ? "permitted-after-repair" : "permitted";
165
+ }
166
+
167
+ /** True for the two permissions that allow a further send. */
168
+ export function permitsResend(permission: ResendPermission): boolean {
169
+ return permission === "permitted" || permission === "permitted-after-repair";
170
+ }
171
+
172
+ /**
173
+ * Which request-wide send budget class a resend for this cause draws on, or null when no resend
174
+ * of any kind makes sense.
175
+ *
176
+ * Funding is keyed on the DISPOSITION, not on the permission. A cause the table refuses to resend
177
+ * automatically may still be resent by a narrowly scoped recovery a maintainer opted into, and
178
+ * that send has to be bought from the same budget every other send comes from -- the opt-in reset
179
+ * replay and the bounded empty-completion rebuild both draw on the transient allowance. Keying on
180
+ * permission instead would leave exactly those paths unfunded, which is how a per-layer counter
181
+ * reappears.
182
+ *
183
+ * Only a futile cause is null. `quota-exhausted` is null rather than `account-failover` because
184
+ * moving accounts is a route decision this table does not make.
185
+ */
186
+ const CAUSE_SEND_CLASS = {
187
+ "transport-unsent": "transient",
188
+ "transport-ambiguous": "transient",
189
+ "upstream-declined": "transient",
190
+ "rate-limit": "transient",
191
+ "quota-exhausted": null,
192
+ "credential-rejected": "auth-recovery",
193
+ "policy-refusal": null,
194
+ "parameter-rejected": "repair",
195
+ "ciphertext-refusal": "repair",
196
+ "payload-too-large": "repair",
197
+ "payload-rejected": null,
198
+ "upstream-fault": "transient",
199
+ "empty-output": "transient",
200
+ "client-cancelled": null,
201
+ "local-refusal": null,
202
+ } as const satisfies Record<RequestFailureCause, SendClass | null>;
203
+
204
+ export function resendSendClass(cause: RequestFailureCause): SendClass | null {
205
+ return CAUSE_SEND_CLASS[cause];
206
+ }
207
+
208
+ /**
209
+ * The cause behind each recovery this proxy already records.
210
+ *
211
+ * Total over `AttemptRecoveryKind` by construction, so a new recovery kind is a typecheck
212
+ * failure here rather than a row that quietly classifies as "other" in three projections.
213
+ */
214
+ const RECOVERY_KIND_CAUSE = {
215
+ // The retried status set mixes 503, which declined, with 500, which may already have run the
216
+ // turn. One kind cannot say both, so it says the weaker thing.
217
+ "transient-5xx": "upstream-fault",
218
+ "connection-reset": "transport-ambiguous",
219
+ "oauth-401": "credential-rejected",
220
+ "key-401": "credential-rejected",
221
+ "key-429": "rate-limit",
222
+ "rate-limit-429": "rate-limit",
223
+ "anthropic-oauth-429": "rate-limit",
224
+ "oauth-account-429": "rate-limit",
225
+ "image-413": "payload-too-large",
226
+ // The gateway rejects a body it accepts seconds later and the replay is byte-identical, so
227
+ // nothing about the payload was wrong; the origin declined to take it at that moment.
228
+ "console-go-upload-retry": "upstream-declined",
229
+ "opaque-blob-rejection": "ciphertext-refusal",
230
+ "empty-completion": "empty-output",
231
+ "reasoning-effort-downgrade": "parameter-rejected",
232
+ } as const satisfies Record<AttemptRecoveryKind, RequestFailureCause>;
233
+
234
+ export function causeForRecoveryKind(kind: AttemptRecoveryKind): RequestFailureCause {
235
+ return RECOVERY_KIND_CAUSE[kind];
236
+ }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Whether one leg of a logical request may send it again, asked in the #5266 vocabulary.
3
+ *
4
+ * Two pull requests arrived at this question from opposite sides of the response head. #4942
5
+ * asked it for a connection that died before any head; #4989 asked it for an SSE body that
6
+ * died after the head while carrying only control events. Both are the same row of the stage
7
+ * table: a stage the caller observed nothing at, with a cause that cannot prove the origin did
8
+ * not run the turn. `resendPermission` answers `refused-ambiguous` for both, and
9
+ * request-failure-model.ts already names the only thing that may override that answer -- a
10
+ * narrowly scoped recovery a maintainer opted into and bounded.
11
+ *
12
+ * One override, not two. The reason this module exists rather than a boolean in each caller is
13
+ * that a request which resets before the head and again after it would otherwise buy a
14
+ * replacement send on each side, and the second one is exactly the duplicated inference the
15
+ * refusal exists to prevent. The allowance is claimed HERE, at the moment of authorisation, so
16
+ * a caller cannot ask without paying.
17
+ *
18
+ * MUST stay a leaf. It imports the vocabulary as values and everything else as types, so it
19
+ * reaches no request path that did not already have it.
20
+ */
21
+ import {
22
+ causeForRecoveryKind,
23
+ permitsResend,
24
+ resendPermission,
25
+ resendSendClass,
26
+ type RequestFailureCause,
27
+ type RequestFailureStage,
28
+ type ResendPermission,
29
+ } from "./request-failure-model";
30
+ import type { SendClass } from "./request-execution-budget";
31
+ import type { AttemptRecoveryKind } from "../usage/telemetry-contract";
32
+
33
+ /**
34
+ * Why an authorisation was refused.
35
+ *
36
+ * The three ambiguous members are separate because they need different operator responses: no
37
+ * policy is a configuration choice, a request the proxy cannot judge is a property of the turn,
38
+ * and a spent allowance means the replacement already went somewhere else in this request.
39
+ */
40
+ export const RESEND_REFUSALS = Object.freeze([
41
+ /** The caller already observed output, an effect, or the delivered answer. */
42
+ "committed",
43
+ /** Identical bytes would get the identical answer. */
44
+ "futile",
45
+ /** The origin's execution state is unknown and no operator policy overrides that. */
46
+ "ambiguous-no-policy",
47
+ /** An operator policy exists, but this request's second send could do more than re-infer. */
48
+ "ambiguous-request-not-replayable",
49
+ /** The operator policy exists and its replacement was already spent by this request. */
50
+ "ambiguous-allowance-spent",
51
+ ] as const);
52
+
53
+ export type ResendRefusal = typeof RESEND_REFUSALS[number];
54
+
55
+ /**
56
+ * The operator-granted replacement for ONE logical request.
57
+ *
58
+ * `claim` is the single counter both stages draw on. It is a method rather than a number
59
+ * because the holder is the request's send ledger, which a combo child shares with its parent;
60
+ * a number passed down per leg is what let each leg hold its own.
61
+ */
62
+ export interface AmbiguousResendAllowance {
63
+ /** True when a second send of this request's body can only repeat the inference. */
64
+ readonly selfContained: boolean;
65
+ /** Spend one replacement. False once the request has none left. */
66
+ claim(): boolean;
67
+ }
68
+
69
+ interface ResendDecisionBase {
70
+ readonly stage: RequestFailureStage;
71
+ readonly cause: RequestFailureCause;
72
+ readonly permission: ResendPermission;
73
+ /**
74
+ * The recovery this send will be recorded as, when the caller asked in those terms. Carried
75
+ * back rather than re-chosen at the call site: the cause was derived from it, so recording a
76
+ * different kind would describe the send by a reason the gate never evaluated.
77
+ */
78
+ readonly recoveryKind?: AttemptRecoveryKind;
79
+ }
80
+
81
+ export type ResendDecision =
82
+ | ResendDecisionBase & {
83
+ readonly allowed: true;
84
+ /** Which request-wide send class funds it, or null when the cause funds no resend. */
85
+ readonly sendClass: SendClass | null;
86
+ /** True when the table refused and an operator allowance was spent to proceed. */
87
+ readonly spentOperatorAllowance: boolean;
88
+ }
89
+ | ResendDecisionBase & { readonly allowed: false; readonly refusal: ResendRefusal };
90
+
91
+ /**
92
+ * Decide whether this proxy may send the request again after a failure at `stage` caused by
93
+ * `cause`, spending `allowance` when the table refuses only because the upstream state is
94
+ * unknown.
95
+ *
96
+ * The allowance is touched on exactly one path: a decision the table would otherwise refuse as
97
+ * ambiguous, for a request whose body the caller has judged replayable. A committed or futile
98
+ * failure never reaches it, so a turn that already produced output cannot quietly drain the
99
+ * replacement a later ambiguous reset would have been entitled to.
100
+ */
101
+ export function authorizeResend(
102
+ stage: RequestFailureStage,
103
+ cause: RequestFailureCause,
104
+ allowance?: AmbiguousResendAllowance,
105
+ recoveryKind?: AttemptRecoveryKind,
106
+ ): ResendDecision {
107
+ const permission = resendPermission(stage, cause);
108
+ const base = { stage, cause, permission, ...(recoveryKind ? { recoveryKind } : {}) };
109
+ if (permitsResend(permission)) {
110
+ return { ...base, allowed: true, sendClass: resendSendClass(cause), spentOperatorAllowance: false };
111
+ }
112
+ if (permission === "refused-committed") return { ...base, allowed: false, refusal: "committed" };
113
+ if (permission === "refused-futile") return { ...base, allowed: false, refusal: "futile" };
114
+ if (!allowance) return { ...base, allowed: false, refusal: "ambiguous-no-policy" };
115
+ if (!allowance.selfContained) {
116
+ return { ...base, allowed: false, refusal: "ambiguous-request-not-replayable" };
117
+ }
118
+ // Claimed last, and only here. Asking earlier would spend the request's one replacement on a
119
+ // question whose answer was already no.
120
+ if (!allowance.claim()) return { ...base, allowed: false, refusal: "ambiguous-allowance-spent" };
121
+ return { ...base, allowed: true, sendClass: resendSendClass(cause), spentOperatorAllowance: true };
122
+ }
123
+
124
+ /**
125
+ * The same decision, asked in terms of the recovery this proxy will RECORD for the send.
126
+ *
127
+ * Deriving the cause from the recorded kind is what keeps the log honest: the reason an
128
+ * operator reads beside a send count is the reason the gate weighed, because it is the same
129
+ * value. A call site that recorded one kind and reasoned about another is how a send count
130
+ * stops meaning anything.
131
+ */
132
+ export function authorizeResendForRecovery(
133
+ stage: RequestFailureStage,
134
+ kind: AttemptRecoveryKind,
135
+ allowance?: AmbiguousResendAllowance,
136
+ ): ResendDecision {
137
+ return authorizeResend(stage, causeForRecoveryKind(kind), allowance, kind);
138
+ }