@bitkyc08/opencodex 2.60.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 (249) 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 +5 -1
  13. package/src/adapters/anthropic.ts +16 -0
  14. package/src/adapters/coding-agent/protocol.ts +36 -6
  15. package/src/adapters/coding-agent/turn.ts +10 -2
  16. package/src/adapters/command-code.ts +2 -1
  17. package/src/adapters/cursor/catalog.ts +51 -7
  18. package/src/adapters/cursor/protobuf-request.ts +6 -3
  19. package/src/adapters/cursor/request-builder.ts +13 -3
  20. package/src/adapters/cursor.ts +11 -2
  21. package/src/adapters/declaration-carrier.ts +45 -0
  22. package/src/adapters/devin.ts +75 -23
  23. package/src/adapters/google-antigravity-wire.ts +5 -2
  24. package/src/adapters/google-errors.ts +7 -1
  25. package/src/adapters/google.ts +29 -5
  26. package/src/adapters/image.ts +4 -1
  27. package/src/adapters/input-media-guard.ts +21 -9
  28. package/src/adapters/kiro/usage.ts +3 -2
  29. package/src/adapters/kiro-tool-fallback.ts +1 -1
  30. package/src/adapters/ollama-native.ts +6 -0
  31. package/src/adapters/openai-chat/developer-role.ts +61 -0
  32. package/src/adapters/openai-chat/messages.ts +46 -27
  33. package/src/adapters/openai-chat/parallel-tool-calls.ts +32 -0
  34. package/src/adapters/openai-chat/passthrough.ts +33 -9
  35. package/src/adapters/openai-chat/reasoning-wire.ts +89 -0
  36. package/src/adapters/openai-chat.ts +18 -57
  37. package/src/adapters/openai-responses/passthrough.ts +2 -0
  38. package/src/adapters/registry.ts +3 -2
  39. package/src/adapters/run-turn-queue.ts +178 -29
  40. package/src/adapters/xai-web-search.ts +16 -1
  41. package/src/bridge/errors.ts +8 -2
  42. package/src/bridge/response-json.ts +9 -1
  43. package/src/bridge/sse.ts +10 -0
  44. package/src/chat/inbound.ts +141 -5
  45. package/src/claude/desktop-3p.ts +7 -1
  46. package/src/claude/desktop-first-party.ts +183 -0
  47. package/src/claude/desktop-gateway-state.ts +41 -0
  48. package/src/claude/inbound-content-options.ts +6 -0
  49. package/src/claude/inbound.ts +32 -6
  50. package/src/claude/intercept/connect-proxy.ts +179 -0
  51. package/src/claude/intercept/listener.ts +122 -0
  52. package/src/claude/intercept/local-ca.ts +298 -0
  53. package/src/claude/intercept/runtime.ts +98 -0
  54. package/src/claude/intercept/settings.ts +189 -0
  55. package/src/cli/access.ts +87 -0
  56. package/src/cli/account-auth.ts +19 -0
  57. package/src/cli/capabilities.ts +31 -0
  58. package/src/cli/claude-desktop.ts +206 -16
  59. package/src/cli/codex-shim-autorestore.ts +3 -0
  60. package/src/cli/companion.ts +56 -0
  61. package/src/cli/dispatch.ts +43 -4
  62. package/src/cli/ensure-desired-integrations.ts +43 -5
  63. package/src/cli/help.ts +7 -9
  64. package/src/cli/index.ts +200 -61
  65. package/src/cli/init.ts +8 -0
  66. package/src/cli/integrations.ts +7 -1
  67. package/src/cli/registry.ts +41 -2
  68. package/src/cli/resolve.ts +230 -0
  69. package/src/cli/root.ts +24 -1
  70. package/src/cli/start-ownership-publication.ts +56 -0
  71. package/src/cli/status-probes.ts +2 -18
  72. package/src/cli/status.ts +62 -0
  73. package/src/cli/stop-report.ts +143 -0
  74. package/src/cli/uninstall-plan.ts +9 -0
  75. package/src/client/machine-listener.ts +2 -5
  76. package/src/clients/aside-profiles.ts +4 -0
  77. package/src/clients/config-export/zcode-store.ts +157 -0
  78. package/src/clients/config-export.ts +36 -0
  79. package/src/codex/app-server-processes.ts +72 -40
  80. package/src/codex/auth-api/login-flow.ts +6 -1
  81. package/src/codex/autostart-health.ts +28 -0
  82. package/src/codex/catalog/build-entries.ts +2 -2
  83. package/src/codex/catalog/effort.ts +3 -3
  84. package/src/codex/catalog/provider-models.ts +24 -15
  85. package/src/codex/catalog/retained-sync.ts +2 -2
  86. package/src/codex/convergence.ts +2 -2
  87. package/src/codex/history-provider.ts +12 -1
  88. package/src/codex/inject/config-toml.ts +41 -6
  89. package/src/codex/inject/paginated-openai-compat.ts +90 -0
  90. package/src/codex/inject.ts +18 -15
  91. package/src/codex/injected-marker.ts +18 -0
  92. package/src/codex/main-account.ts +6 -0
  93. package/src/codex/model-cache.ts +52 -6
  94. package/src/codex/model-entitlement-admission.ts +59 -0
  95. package/src/codex/model-entitlements.ts +87 -44
  96. package/src/codex/native-main-admission.ts +83 -0
  97. package/src/codex/routing/health-store.ts +39 -0
  98. package/src/codex/routing/selection.ts +37 -1
  99. package/src/codex/routing.ts +5 -41
  100. package/src/codex/shim-templates.ts +29 -3
  101. package/src/companion/settings.ts +132 -0
  102. package/src/config/atomic-write.ts +117 -5
  103. package/src/config/load-degrade.ts +34 -7
  104. package/src/config/process-state.ts +1 -1
  105. package/src/config/schema/config-schema.ts +27 -1
  106. package/src/config/schema/leaf-validators.ts +47 -0
  107. package/src/config.ts +1 -1
  108. package/src/generated/compatibility-version.json +418 -174
  109. package/src/integrations/config-io.ts +44 -10
  110. package/src/integrations/merge.ts +120 -13
  111. package/src/integrations/mutation-plan.ts +124 -18
  112. package/src/integrations/registry.ts +38 -0
  113. package/src/integrations/state.ts +78 -45
  114. package/src/integrations/target.ts +208 -0
  115. package/src/integrations/writer.ts +49 -11
  116. package/src/lab/conformance/fixture-provider.ts +5 -0
  117. package/src/lib/browser-launch-notice.ts +59 -0
  118. package/src/lib/bun-runtime.ts +6 -2
  119. package/src/lib/debug.ts +40 -0
  120. package/src/lib/open-url.ts +51 -7
  121. package/src/lib/package-tree-integrity.ts +2 -1
  122. package/src/lib/package-version.ts +8 -0
  123. package/src/lib/provider-egress.ts +310 -0
  124. package/src/lib/provider-outbound.ts +59 -14
  125. package/src/lib/proxy-env.ts +82 -7
  126. package/src/lib/request-execution-budget.ts +72 -0
  127. package/src/lib/request-failure-attribution.ts +183 -0
  128. package/src/lib/request-failure-model.ts +236 -0
  129. package/src/lib/request-resend-gate.ts +138 -0
  130. package/src/lib/standalone.ts +16 -0
  131. package/src/lib/upstream-retry.ts +167 -16
  132. package/src/lib/winsw.ts +2 -2
  133. package/src/oauth/index.ts +24 -1
  134. package/src/oauth/login-cli.ts +80 -29
  135. package/src/providers/api-key-resolve.ts +133 -0
  136. package/src/providers/api-key-selection.ts +5 -1
  137. package/src/providers/key-failover.ts +31 -1
  138. package/src/providers/key-store.ts +34 -110
  139. package/src/providers/model-rename-fields.ts +147 -0
  140. package/src/providers/model-rename-migration.ts +124 -37
  141. package/src/providers/quota/vendor-probes-key.ts +37 -22
  142. package/src/providers/reasoning-metadata.ts +43 -18
  143. package/src/providers/registry/entries-core.ts +9 -4
  144. package/src/providers/registry/entries-extended.ts +29 -4
  145. package/src/providers/registry/model-seeds.ts +47 -10
  146. package/src/providers/xai-transport.ts +12 -1
  147. package/src/reasoning-effort.ts +8 -0
  148. package/src/responses/function-call-compat.ts +38 -1
  149. package/src/responses/inline-document.ts +65 -0
  150. package/src/responses/input-media.ts +42 -8
  151. package/src/responses/muse-tool-name-alias.ts +19 -0
  152. package/src/responses/parser-content.ts +8 -2
  153. package/src/responses/parser-tools.ts +3 -0
  154. package/src/responses/parser.ts +3 -1
  155. package/src/responses/schema.ts +3 -0
  156. package/src/router.ts +17 -2
  157. package/src/server/admission-model-scope.ts +219 -0
  158. package/src/server/audio-live.ts +9 -3
  159. package/src/server/audio-upstream.ts +18 -0
  160. package/src/server/auth-cors.ts +26 -0
  161. package/src/server/chat-completions.ts +55 -2
  162. package/src/server/chat-native.ts +19 -4
  163. package/src/server/claude-messages.ts +55 -17
  164. package/src/server/grok-responses-snapshot-repair.ts +113 -11
  165. package/src/server/gui-freshness.ts +103 -0
  166. package/src/server/gui-static.ts +7 -9
  167. package/src/server/images.ts +59 -6
  168. package/src/server/index/claude-intercept-lifecycle.ts +49 -0
  169. package/src/server/index/serve-options.ts +56 -10
  170. package/src/server/index/spend-ledger-lifecycle.ts +34 -8
  171. package/src/server/index/startup-warnings.ts +24 -0
  172. package/src/server/index.ts +21 -28
  173. package/src/server/lifecycle.ts +4 -4
  174. package/src/server/live-call-bindings.ts +6 -0
  175. package/src/server/live.ts +88 -3
  176. package/src/server/management/agent-settings-routes.ts +121 -36
  177. package/src/server/management/companion-routes.ts +77 -0
  178. package/src/server/management/logs-usage-routes.ts +19 -0
  179. package/src/server/management/native-integration-routes.ts +103 -6
  180. package/src/server/management/oauth-account-routes.ts +45 -7
  181. package/src/server/management/route-registry.ts +6 -0
  182. package/src/server/management/shared.ts +18 -1
  183. package/src/server/management/usage-timeline-routes.ts +44 -0
  184. package/src/server/management-api.ts +8 -9
  185. package/src/server/proxy-liveness.ts +75 -0
  186. package/src/server/relay.ts +19 -2
  187. package/src/server/request-log-failure-attribution.ts +99 -0
  188. package/src/server/request-log.ts +114 -0
  189. package/src/server/request-metrics.ts +92 -30
  190. package/src/server/responses/codex-ws-wire.ts +34 -8
  191. package/src/server/responses/combo-stream-preflight.ts +168 -6
  192. package/src/server/responses/compact.ts +11 -0
  193. package/src/server/responses/core-opaque-recovery.ts +90 -0
  194. package/src/server/responses/fetch-helpers.ts +124 -8
  195. package/src/server/responses/input-admission.ts +10 -0
  196. package/src/server/responses/passthrough-delivery.ts +14 -1
  197. package/src/server/responses/passthrough-dispatch.ts +179 -35
  198. package/src/server/responses/passthrough-error.ts +27 -8
  199. package/src/server/responses/request-prepare.ts +42 -1
  200. package/src/server/responses/request-send-budget.ts +12 -0
  201. package/src/server/responses/request-transport.ts +24 -4
  202. package/src/server/responses/reset-replay.ts +108 -0
  203. package/src/server/responses-request-tool-scope.ts +214 -0
  204. package/src/server/responses-undeclared-tool-guard.ts +4 -1
  205. package/src/server/search.ts +25 -1
  206. package/src/server/usage-ledger-retention.ts +73 -0
  207. package/src/service/cli.ts +48 -2
  208. package/src/service/health.ts +3 -2
  209. package/src/service/install-state-contract.d.mts +27 -0
  210. package/src/service/install-state-contract.mjs +34 -0
  211. package/src/service/launchd.ts +1 -1
  212. package/src/service/orchestration.ts +2 -4
  213. package/src/service/ownership-compatibility.ts +164 -0
  214. package/src/service/ownership-mutation-lease.d.mts +32 -0
  215. package/src/service/ownership-mutation-lease.mjs +211 -0
  216. package/src/service/repair.ts +45 -1
  217. package/src/service/state-lock.ts +269 -0
  218. package/src/service/state-record.d.mts +36 -0
  219. package/src/service/state-record.mjs +138 -0
  220. package/src/service/state.ts +582 -68
  221. package/src/service/windows-taskxml.ts +11 -10
  222. package/src/service.ts +7 -3
  223. package/src/tray/windows-tray.ps1 +1 -1
  224. package/src/types/config.ts +37 -0
  225. package/src/types/provider.ts +73 -0
  226. package/src/types/request.ts +28 -2
  227. package/src/types/tools.ts +19 -0
  228. package/src/types.ts +3 -0
  229. package/src/update/index.ts +207 -63
  230. package/src/update/job.ts +9 -5
  231. package/src/update/ownership-transaction.ts +47 -0
  232. package/src/update/restart-ownership.ts +54 -0
  233. package/src/update/runtime-ownership.d.mts +40 -0
  234. package/src/update/runtime-ownership.mjs +122 -0
  235. package/src/usage/attempt-delivery.ts +198 -0
  236. package/src/usage/cache-diagnostic.ts +305 -0
  237. package/src/usage/failure-fingerprint.ts +118 -0
  238. package/src/usage/failure-projection-cache.ts +174 -0
  239. package/src/usage/failure-projection.ts +174 -0
  240. package/src/usage/ledger-retention.ts +165 -0
  241. package/src/usage/log.ts +126 -79
  242. package/src/usage/request-outcome.ts +150 -0
  243. package/src/usage/retention-contract.ts +28 -0
  244. package/src/usage/summary.ts +2 -2
  245. package/src/usage/telemetry-contract.ts +237 -0
  246. package/src/usage/timeline.ts +236 -0
  247. package/src/web-search/alpha-search.ts +21 -1
  248. package/gui/dist/assets/index-BTuCbqQd.css +0 -1
  249. package/gui/dist/assets/index-DoBVdPHP.js +0 -134
@@ -86,12 +86,30 @@ export function outboundProxyConfigured(
86
86
  return OUTBOUND_PROXY_ENV_KEYS.some(key => proxyEnvPresent(key, env));
87
87
  }
88
88
 
89
+ /**
90
+ * The value when `raw` is a proxy URL Bun fetch can actually use, else null.
91
+ * Bun rejects unparseable values and non-http(s) schemes (UnsupportedProxyProtocol),
92
+ * so admitting them as "the proxy that applies" would only downgrade DNS pinning.
93
+ */
94
+ function usableHttpProxyUrl(raw: string | undefined): string | null {
95
+ if (!raw) return null;
96
+ try {
97
+ const scheme = new URL(raw).protocol;
98
+ return scheme === "http:" || scheme === "https:" ? raw : null;
99
+ } catch {
100
+ return null;
101
+ }
102
+ }
103
+
89
104
  /**
90
105
  * The proxy URL selected by configured outbound fetch for `url`, or null when none applies.
91
106
  *
92
107
  * Bun selects by scheme: `HTTPS_PROXY` for `https:` targets, `HTTP_PROXY` for `http:`.
93
- * A SOCKS5 `ALL_PROXY` is selected by the explicit wrapper first; other ALL_PROXY
94
- * schemes remain excluded because the native HTTP fetch does not honor them.
108
+ * A SOCKS5 `ALL_PROXY` is selected by the explicit wrapper first. A non-SOCKS
109
+ * `ALL_PROXY` is still honoured by the native fetch for plain `http:` targets on
110
+ * every platform the CI matrix covers — the provider-outbound e2e drives exactly that
111
+ * request through the proxy on Linux, macOS and Windows. For `https:` targets the
112
+ * SOCKS wrapper remains the only `ALL_PROXY` route this module counts.
95
113
  * Presence of *some* proxy variable (`outboundProxyConfigured`) is not that guarantee.
96
114
  */
97
115
  export function effectiveProxyFor(
@@ -107,8 +125,47 @@ export function effectiveProxyFor(
107
125
  // The installed SOCKS wrapper takes this route before Bun sees scheme proxies.
108
126
  const socksProxy = socks5ProxyFromEnv(env);
109
127
  if (socksProxy) return socksProxy;
128
+ const schemeValue = env[key]?.trim() || env[key.toLowerCase()]?.trim();
129
+ if (schemeValue) {
130
+ // A SOCKS URL in a scheme-matched variable is a usable proxy: admission
131
+ // binds it explicitly and the transport follows, so it applies here too.
132
+ if (isSocks5ProxyUrl(schemeValue)) return schemeValue;
133
+ // A present but unusable scheme-matched variable fails closed: it is not a
134
+ // proxy Bun fetch can use, and it must not fall through to ALL_PROXY either.
135
+ // If Bun would have used ALL_PROXY here, keeping the DNS-pinned transport is
136
+ // the safe direction; if it would not, this is exactly right.
137
+ return usableHttpProxyUrl(schemeValue);
138
+ }
139
+ if (url.protocol !== "http:") return null;
140
+ return usableHttpProxyUrl(env.ALL_PROXY?.trim() || env.all_proxy?.trim());
141
+ }
142
+
143
+ /**
144
+ * The proxy a request can be explicitly bound to for fake-IP admission, or
145
+ * null: a scheme-matched variable or a SOCKS5 `ALL_PROXY`. This is the
146
+ * stricter documented gate for Mihomo IPv6 fake-IP answers — a non-SOCKS
147
+ * `ALL_PROXY` does not count here even when `effectiveProxyFor` reports it,
148
+ * because admission pins the transport to the returned value and the gate's
149
+ * contract is stated in those terms. The scheme-matched value counts only as
150
+ * a usable binding — a SOCKS or http(s) URL; anything else would admit a
151
+ * fake-IP answer nothing can resolve.
152
+ */
153
+ export function schemeMatchedProxyFor(
154
+ url: URL,
155
+ env: ProxyEnvMap = process.env,
156
+ ): string | null {
157
+ const key: ProxyEnvKey | null = url.protocol === "https:"
158
+ ? "HTTPS_PROXY"
159
+ : url.protocol === "http:"
160
+ ? "HTTP_PROXY"
161
+ : null;
162
+ if (!key) return null;
163
+ const socksProxy = socks5ProxyFromEnv(env);
164
+ if (socksProxy) return socksProxy;
110
165
  const value = env[key]?.trim() || env[key.toLowerCase()]?.trim();
111
- return value ? value : null;
166
+ if (!value) return null;
167
+ if (isSocks5ProxyUrl(value)) return value;
168
+ return usableHttpProxyUrl(value);
112
169
  }
113
170
 
114
171
  export function isSocks5ProxyUrl(proxy: string): boolean {
@@ -120,16 +177,34 @@ export function socks5ProxyFromEnv(env: ProxyEnvMap = process.env): string | und
120
177
  return candidates.find(value => typeof value === "string" && isSocks5ProxyUrl(value));
121
178
  }
122
179
 
180
+ /**
181
+ * A request-scoped proxy decision as the outbound transports express it.
182
+ *
183
+ * `false` is Bun's documented "connect directly": it overrides HTTP_PROXY, HTTPS_PROXY and
184
+ * ALL_PROXY, and it overrides NO_PROXY too. Bun treats `undefined`, `null` and `""` alike as
185
+ * "no option given" and falls back to the environment, so none of those can express direct
186
+ * egress. Declared locally because the value travels through `RequestInit`, which does not
187
+ * carry it in the ambient DOM types.
188
+ */
189
+ export type ProxyCapableRequestInit = RequestInit & { proxy?: string | false };
190
+
123
191
  export function configuredOutboundFetch(
124
192
  input: RequestInfo | URL,
125
193
  init?: RequestInit,
126
194
  fallback?: typeof globalThis.fetch,
127
195
  ): Promise<Response> {
128
196
  const base = fallback ?? (globalThis.fetch === installedFetch ? nativeFetch : globalThis.fetch);
129
- const explicitProxy = (init as (RequestInit & { proxy?: string }) | undefined)?.proxy;
130
- const proxy = typeof explicitProxy === "string"
131
- ? (isSocks5ProxyUrl(explicitProxy) ? explicitProxy : undefined)
132
- : socks5ProxyFromEnv();
197
+ const explicitProxy = (init as ProxyCapableRequestInit | undefined)?.proxy;
198
+ // An explicit `false` is a decision, so it also has to win over the installed SOCKS wrapper.
199
+ // Reading it as "no string was supplied" would fall through to ALL_PROXY and send a request
200
+ // the caller pinned to direct egress through the global SOCKS proxy instead — the silent
201
+ // substitution the caller asked this option to prevent. Bun applies the same `false` to its
202
+ // own HTTP(S) proxy environment once the request reaches the base fetch below.
203
+ const proxy = explicitProxy === false
204
+ ? undefined
205
+ : typeof explicitProxy === "string"
206
+ ? (isSocks5ProxyUrl(explicitProxy) ? explicitProxy : undefined)
207
+ : socks5ProxyFromEnv();
133
208
  let url: URL;
134
209
  try {
135
210
  url = new URL(input instanceof Request ? input.url : String(input));
@@ -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
+ }