@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
@@ -12,9 +12,57 @@ import { waitForProviderRequestSlot } from "../../providers/request-pacing";
12
12
  import { withUpstreamHttpVersion } from "../../lib/upstream-http-version";
13
13
  import type { CodexWsQuotaObserver } from "./codex-ws-metadata";
14
14
  import { configuredOutboundFetch } from "../../lib/proxy-env";
15
+ import {
16
+ describeProviderEgressForLog,
17
+ markEgressTransparentExecutor,
18
+ providerEgressSendInit,
19
+ providerEgressIsExplicit,
20
+ resolveProviderEgress,
21
+ type ProviderEgressBinding,
22
+ } from "../../lib/provider-egress";
23
+ import { redactSecretString } from "../../lib/redact";
15
24
 
16
25
  export { withUpstreamHttpVersion };
17
26
 
27
+ const egressWebsocketDowngradeWarned = new Set<string>();
28
+ /** A provider name is configuration-controlled, so the notice set is bounded like any cache. */
29
+ const EGRESS_DOWNGRADE_NOTICE_LIMIT = 64;
30
+ /**
31
+ * Marks an init whose provider egress route an outer physical-send boundary already decided.
32
+ *
33
+ * Own symbol keys survive object spread, so the mark travels through the rebuild a
34
+ * `dispatchOverride` performs, and an unknown symbol on a `RequestInit` is inert at the wire.
35
+ */
36
+ const EGRESS_DECIDED = Symbol.for("opencodex.provider-egress.decided");
37
+
38
+ /**
39
+ * Announce once, per provider, that an explicit egress route moved this provider off the
40
+ * WebSocket fast lane.
41
+ *
42
+ * The WebSocket upstream selects its proxy from the process environment when it dials, so it
43
+ * cannot carry a per-provider route. Serving the turn over HTTP/SSE honours the operator's
44
+ * egress choice, which is the one that has to win — but a transport change the operator did
45
+ * not ask for is exactly the kind of substitution this batch refuses to make silently, so it
46
+ * is stated rather than merely done.
47
+ */
48
+ function warnEgressWebsocketDowngradeOnce(providerName: string, egress: string): void {
49
+ if (egressWebsocketDowngradeWarned.has(providerName)) return;
50
+ if (egressWebsocketDowngradeWarned.size >= EGRESS_DOWNGRADE_NOTICE_LIMIT) return;
51
+ egressWebsocketDowngradeWarned.add(providerName);
52
+ console.warn(
53
+ // The name is caller-controlled and can be token-shaped, so it is redacted and JSON-escaped
54
+ // before it reaches a log, exactly as at the management error boundary.
55
+ `[opencodex] provider ${JSON.stringify(redactSecretString(providerName))} declares egress ${egress}; the WebSocket upstream `
56
+ + "selects its proxy from the process environment and cannot carry a per-provider route, "
57
+ + "so these turns are served over HTTP/SSE.",
58
+ );
59
+ }
60
+
61
+ /** Test seam: the downgrade notice is once per provider per process, not once per request. */
62
+ export function __resetEgressWebsocketDowngradeNotices(): void {
63
+ egressWebsocketDowngradeWarned.clear();
64
+ }
65
+
18
66
  export function disableResponsesRequestTimeout(req: Request, server: Pick<Server<WsData>, "timeout"> | undefined): boolean {
19
67
  if (!server) return false;
20
68
  try {
@@ -97,21 +145,43 @@ export function sendWithConnectionPolicy(
97
145
  physicalFetch: typeof globalThis.fetch,
98
146
  input: Parameters<typeof globalThis.fetch>[0],
99
147
  init?: RequestInit,
148
+ egress?: ProviderEgressBinding,
100
149
  ): Promise<Response> {
101
150
  const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined));
102
151
  const fresh = wantsFreshConnection(input);
103
152
  if (fresh) {
104
153
  headers.set("Connection", "close");
105
154
  }
155
+ // Decided here, against the destination this send is actually going to, and around whichever
156
+ // executor was just selected. A `dispatchOverride` that rebuilds a queued request can change
157
+ // both the upstream host and the provider transport after the wrapper was constructed, so a
158
+ // route resolved at construction could be applied to a different host than it was decided for.
159
+ // These calls nest: an override decides with its own binding and then hands the send to the
160
+ // executor `providerFetch` supplied, which is another one of these. The outermost caller holds
161
+ // the reselected provider and the rebuilt destination, so it decides and marks the init; the
162
+ // inner pass honours that mark rather than recomputing from a stale closure.
163
+ const alreadyDecided = (init as Record<symbol, unknown> | undefined)?.[EGRESS_DECIDED] === true;
164
+ const decide = egress !== undefined && !alreadyDecided;
165
+ const egressInit = decide ? providerEgressSendInit(egress, physicalFetch, input) : {};
106
166
  return physicalFetch(input, {
107
167
  ...init,
108
168
  headers,
109
169
  redirect: "manual",
110
170
  ...(fresh ? { keepalive: false } : {}),
171
+ ...egressInit,
172
+ ...(decide ? { [EGRESS_DECIDED]: true } : {}),
111
173
  });
112
174
  }
113
175
 
114
176
  export interface ProviderFetchOptions {
177
+ /**
178
+ * Keep this send on HTTP even where the WebSocket upstream would normally be selected.
179
+ *
180
+ * Set by a caller replacing an HTTP stream that already failed: a WS create frame is a
181
+ * different send on a different transport, and the replacement has to be the same kind of
182
+ * exchange the client is already reading.
183
+ */
184
+ httpOnly?: boolean;
115
185
  nativeControl?: NativeResponseControl;
116
186
  providerName?: string;
117
187
  modelId?: string;
@@ -130,28 +200,63 @@ export function providerFetch(
130
200
  runtime: BunRuntimeGateInput = currentBunRuntimeIdentity(),
131
201
  options: ProviderFetchOptions = {},
132
202
  ): ProviderFetch {
133
- const configuredFetch = Object.assign(
203
+ const providerName = options.providerName ?? "<unnamed provider>";
204
+ const customExecutor = (provider as OcxProviderConfig & { fetch?: typeof globalThis.fetch }).fetch;
205
+ // The route is applied at the physical send (see `sendWithConnectionPolicy`). This binding is
206
+ // only what that boundary needs to decide it.
207
+ const egressBinding: ProviderEgressBinding = { providerName, provider };
208
+ // Resolved per request, not once per wrapper: `providers.<name>.noProxy` is evaluated against
209
+ // the destination, so two requests through the same executor can legitimately take different
210
+ // routes. A malformed value throws and rejects the request rather than degrading to the
211
+ // global proxy or to direct, either of which would read as success at the call site.
212
+ const egressFor = (input: Parameters<typeof globalThis.fetch>[0]) => resolveProviderEgress({
213
+ providerName,
214
+ provider,
215
+ url: typeof input === "string" ? input : input instanceof URL ? input : input.url,
216
+ });
217
+ // The built-in executor forwards its init to a transport that honours the proxy option.
218
+ const configuredFetch = markEgressTransparentExecutor(Object.assign(
134
219
  (input: Parameters<typeof globalThis.fetch>[0], init?: RequestInit) => configuredOutboundFetch(input, init),
135
220
  { preconnect: globalThis.fetch.preconnect?.bind(globalThis.fetch) },
136
- ) as typeof globalThis.fetch;
137
- const base = (provider as OcxProviderConfig & { fetch?: typeof globalThis.fetch }).fetch ?? configuredFetch;
221
+ ) as typeof globalThis.fetch);
222
+ const base = customExecutor ?? configuredFetch;
138
223
  const preconnect = (...args: Parameters<typeof globalThis.fetch.preconnect>): void => {
139
224
  base.preconnect?.(...args);
140
225
  };
141
226
  // Rebuilt dispatches must use the same physical-send boundary as ordinary HTTP sends.
142
227
  // Return the original 3xx so the response owner retains its retry/health/relay contract.
143
- const dispatch = Object.assign(
228
+ //
229
+ // Marked transparent because it forwards its init to a transport that honours the proxy
230
+ // option. Leaving it unmarked would make an ordinary configured provider refuse its own route
231
+ // on every overridden path, after the attempt had already been recorded — an override selects
232
+ // `provider.fetch ?? execute`, and `execute` is this wrapper. It still carries the binding, so
233
+ // an override that simply calls it gets the route decided rather than dropped; an override
234
+ // that decided for itself has already marked the init and this pass defers to that decision.
235
+ const dispatch = markEgressTransparentExecutor(Object.assign(
144
236
  (input: Parameters<typeof globalThis.fetch>[0], init?: RequestInit) =>
145
- sendWithConnectionPolicy(base, input, init),
237
+ sendWithConnectionPolicy(base, input, init, egressBinding),
146
238
  { preconnect },
147
- ) as typeof globalThis.fetch;
239
+ ) as typeof globalThis.fetch);
148
240
  const httpFetch = Object.assign(
149
241
  async (input: Parameters<typeof globalThis.fetch>[0], init?: RequestInit) => {
242
+ // Refuse before any dispatch side effect where that is sound. `beforeDispatch` commits
243
+ // attempt accounting and consumes admission state, so a refusal firing after it would
244
+ // charge an attempt for a send that never happens, and a throwing hook would mask the
245
+ // egress error with an unrelated one.
246
+ //
247
+ // With no override, this input and `base` ARE the final destination and executor, so the
248
+ // full decision can be made now. With an override, only the configured value is checked:
249
+ // the override may rebuild against a different host and select a different transport, and
250
+ // refusing on this destination would reject a request whose real route is fine.
251
+ if (options.dispatchOverride) egressFor(input);
252
+ else providerEgressSendInit(egressBinding, base, input);
150
253
  // The hook inspects the outgoing headers and refuses the send by throwing; it is not a
151
254
  // mutator, and the copy it receives is deliberately not threaded onward. `Connection`
152
255
  // is decided inside `dispatch`, which runs after this, so the fresh-connection policy
153
256
  // wins regardless of what any caller or hook put in the header.
154
257
  options.beforeDispatch?.(new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined)));
258
+ // No proxy option is attached here: a `dispatchOverride` may rebuild this request against
259
+ // a different destination, so the route is decided at the physical send instead.
155
260
  const dispatchInit = { ...withUpstreamHttpVersion(input, init, provider), timeout: 0 };
156
261
  return options.dispatchOverride
157
262
  ? options.dispatchOverride(input, dispatchInit, dispatch)
@@ -164,7 +269,13 @@ export function providerFetch(
164
269
  // else keeps the provider's HTTP fetch. See ws-upstream.ts for the details.
165
270
  const unpaced = async (input: Parameters<typeof globalThis.fetch>[0], init?: RequestInit) => {
166
271
  const upstreamWebsocket = provider.upstreamWebsocket === true;
167
- if (typeof input === "string" && init && shouldUseCodexWsUpstream(input, init, runtime, upstreamWebsocket)) {
272
+ if (!options.httpOnly && typeof input === "string" && init
273
+ && shouldUseCodexWsUpstream(input, init, runtime, upstreamWebsocket)) {
274
+ const egress = egressFor(input);
275
+ if (providerEgressIsExplicit(egress)) {
276
+ warnEgressWebsocketDowngradeOnce(providerName, describeProviderEgressForLog(egress));
277
+ return httpFetch(input, init);
278
+ }
168
279
  // The fallback has to be the same HTTP fetch the non-WS branch would have
169
280
  // used, protocol pin included: a WS turn that falls back is serving the
170
281
  // request over HTTP, and dropping the provider's `upstreamHttpVersion`
@@ -188,11 +299,16 @@ export function providerFetch(
188
299
  await waitForPacing(init?.signal ?? undefined);
189
300
  return unpaced(input, init);
190
301
  };
191
- return Object.assign(wrapped, {
302
+ // The returned wrapper forwards its init down to `dispatch`, which applies the route at the
303
+ // physical send. Adapters that hand this executor back as `provider.fetch` (Cursor does)
304
+ // therefore still carry a per-provider route instead of being refused as opaque.
305
+ const paceAware = Object.assign(wrapped, {
192
306
  preconnect,
193
307
  waitForPacing,
194
308
  unpacedFetch: Object.assign(unpaced, { preconnect }),
195
309
  });
310
+ markEgressTransparentExecutor(paceAware as unknown as typeof globalThis.fetch);
311
+ return paceAware;
196
312
  }
197
313
 
198
314
 
@@ -85,9 +85,19 @@ function imageTokens(imageUrl: string): number {
85
85
  function contentPartTokens(part: OcxContentPart, modelId: string): number {
86
86
  if (part.type === "image") return imageTokens(part.imageUrl);
87
87
  if (part.type === "video") return imageTokens(part.videoUrl);
88
+ // An inline document is a base64 payload, not a sentence: estimating it from its marker would
89
+ // admit a request whose real input is orders of magnitude larger. Counted arithmetically —
90
+ // rebuilding the data URL here would materialize a second request-sized string just to measure it.
91
+ if (part.type === "document") return base64PayloadTokens(part.data);
88
92
  return estimateTokens(part.text, modelId);
89
93
  }
90
94
 
95
+ function base64PayloadTokens(base64: string): number {
96
+ if (base64.length === 0) return 0;
97
+ const decoded = Math.floor((base64.length * 3) / 4);
98
+ return Math.max(1, Math.ceil(decoded / IMAGE_BYTES_PER_TOKEN));
99
+ }
100
+
91
101
  function contentTokens(content: string | readonly OcxContentPart[], modelId: string): number {
92
102
  if (typeof content === "string") return estimateTokens(content, modelId);
93
103
  let total = 0;
@@ -93,6 +93,7 @@ import { createResponsesFieldBackfillBlockRewrite } from "./responses-field-back
93
93
  import { createResponsesFunctionToolRepairBlockRewrite } from "../responses-function-tool-repair";
94
94
  import {
95
95
  createUndeclaredToolCallGuardBlockRewrite,
96
+ currentTurnWireToolCatalogBody,
96
97
  undeclaredToolCallNameInResponse,
97
98
  undeclaredToolCallMessage,
98
99
  normalizeDefaultNamespaceInJson,
@@ -515,7 +516,19 @@ export async function deliverPassthroughResponse(
515
516
  ? createGrokResponsesTimestampBlockRewrite()
516
517
  : undefined,
517
518
  grokClientCompatibilityEnabled
518
- ? createGrokResponsesSparseTerminalBlockRewrite(translatorBudget)
519
+ ? createGrokResponsesSparseTerminalBlockRewrite(
520
+ translatorBudget,
521
+ nativeExchange.outboundRequestBody,
522
+ {
523
+ clientToolAuthorizationBody: currentTurnWireToolCatalogBody(
524
+ parsed._rawBody,
525
+ parsed._replayPrefixLen ?? 0,
526
+ ),
527
+ routedNamespaceToolAliases: responseEffects.routedNamespaceToolAliases,
528
+ routedMuseToolNameAliases: responseEffects.routedMuseToolNameAliases,
529
+ convertedRoutedCustomToolNames: routedCustomToolNames,
530
+ },
531
+ )
519
532
  : undefined,
520
533
  snapshotRepairEnabled
521
534
  ? createResponsesSnapshotBlockRewrite(nativeExchange.outboundRequestBody, translatorBudget)
@@ -102,7 +102,7 @@ import {
102
102
  clearCodexModelDenialEvidence,
103
103
  recordCodexModelDenialEvidence,
104
104
  } from "../../codex/model-entitlements";
105
- import { readCodexWsStage } from "./codex-ws-wire";
105
+ import { isCodexWsUpstreamResponse, readCodexWsStage } from "./codex-ws-wire";
106
106
  import { linkAbortSignal } from "./core-lifetime";
107
107
  import type { CodexAuthContext } from "../../codex/auth-context";
108
108
  import { checkOutboundBodySize, describeOutboundBodyRefusal } from "./outbound-body-guard";
@@ -112,7 +112,8 @@ import {
112
112
  fetchWithTransientRetry,
113
113
  applyUpstreamRecoveryInit,
114
114
  isNonReplayableResponse,
115
- prepareSameTarget429Wait,
115
+ refetchAfterProtocolSafeReset,
116
+ prepareSameTarget429Wait,
116
117
  sleepWithAbort,
117
118
  } from "../../lib/upstream-retry";
118
119
  import { mapCodexAuthContextErrorToResponse } from "./codex-auth-error";
@@ -151,7 +152,9 @@ import {
151
152
  reasoningEffortRejectionText,
152
153
  } from "./core-opaque-recovery";
153
154
  import type { RequestLogContext } from "../request-log";
154
- import { preflightComboStreamResponse } from "./combo-stream-preflight";
155
+ import { deferProtocolSafeResetRecovery, preflightComboStreamResponse } from "./combo-stream-preflight";
156
+ import { authorizeResendForRecovery } from "../../lib/request-resend-gate";
157
+ import { ambiguousResendAllowanceFor, selfContainedResponsesBody } from "./reset-replay";
155
158
  import { upstreamErrorMessageFromPayload, ENCRYPTED_FUNCTION_OUTPUT_REJECTION } from "../../lib/errors";
156
159
  import { isTransientConsoleGoUploadRejection } from "../../providers/opencode-zen-rate-limit";
157
160
  import { planReasoningEffortDowngrade } from "../../providers/reasoning-metadata";
@@ -189,6 +192,8 @@ export async function preparePassthroughExchange(
189
192
  | "genericFailovers"
190
193
  | "applyFailoverSnapshot"
191
194
  | "noteRoutedAttemptSend"
195
+ | "selectionIsCurrent"
196
+ | "requestBindings"
192
197
  >,
193
198
  responseEffects: Pick<
194
199
  ResponsesEffects,
@@ -206,6 +211,7 @@ export async function preparePassthroughExchange(
206
211
  | "recoverySendAllowance"
207
212
  | "recoveryClassFor"
208
213
  | "sendBudgetExhausted"
214
+ | "claimAmbiguousResend"
209
215
  | "reserveCredentialHop"
210
216
  | "pendingHopPermit"
211
217
  | "workflowRootId"
@@ -239,6 +245,7 @@ export async function preparePassthroughExchange(
239
245
  recoverySendAllowance,
240
246
  recoveryClassFor,
241
247
  sendBudgetExhausted,
248
+ claimAmbiguousResend,
242
249
  reserveCredentialHop,
243
250
  workflowRootId,
244
251
  } = sendBudgetState;
@@ -704,6 +711,35 @@ export async function preparePassthroughExchange(
704
711
  );
705
712
  const configuredTransientSendBudgetExhausted = (): boolean =>
706
713
  transientSendPolicy() !== null && transientSendAttempts() === 0;
714
+ /**
715
+ * Judged once. The inbound body does not change between legs, and every rebuild this lane
716
+ * performs only ever REMOVES a hazard -- `previous_response_id` is expanded, hosted tools
717
+ * are lowered into client execution -- so a body that was replaceable stays replaceable.
718
+ * Memoized rather than recomputed because it walks the input array, and a provider that
719
+ * never opted in must not pay for it at all.
720
+ */
721
+ let selfContainedJudgment: boolean | undefined;
722
+ const requestIsSelfContained = (): boolean =>
723
+ selfContainedJudgment ??= selfContainedResponsesBody(parsed._rawBody);
724
+ /**
725
+ * The operator's replacement grant for THIS logical request.
726
+ *
727
+ * Read per leg because `route.provider` is reassigned by credential rotation and transport
728
+ * resolution inside the recovery loop, exactly like `transientSendPolicy`. The counter it
729
+ * claims from is not per leg: it lives on the request's execution budget, which a combo
730
+ * child shares, so every ambiguous stage of this request draws on the same grant.
731
+ */
732
+ const ambiguousResend = () =>
733
+ ambiguousResendAllowanceFor(route.provider, requestIsSelfContained, claimAmbiguousResend);
734
+ /**
735
+ * The pre-header row of the stage table, asked through the one gate.
736
+ *
737
+ * `fetchWithResetRetry` takes a plain callback because it is a leaf that must not import
738
+ * the server tree; routing the answer through `authorizeResendForRecovery` here is what
739
+ * keeps the decision derived from the table rather than restated as a boolean.
740
+ */
741
+ const claimPreHeaderResend = (): boolean =>
742
+ authorizeResendForRecovery("pre-header", "connection-reset", ambiguousResend()).allowed;
707
743
  /**
708
744
  * Refuse a built body that exceeds the operator's configured ceiling, before it is sent.
709
745
  *
@@ -845,6 +881,7 @@ export async function preparePassthroughExchange(
845
881
  },
846
882
  { abortSignal: upstream.signal, label: safeHostLabel(request.url),
847
883
  attempts: remainingTransientSendBudget(transientSendAttempts()), onSendsConsumed: noteTransientSends,
884
+ claimAmbiguousResend: claimPreHeaderResend,
848
885
  // The OpenCode Go destination stalls-then-drops inference sends (ambiguous
849
886
  // pre-header resets surfacing as refused 429s); its subscription traffic is
850
887
  // inference-only, so a bounded reset replay here absorbs the blip instead of
@@ -949,7 +986,8 @@ export async function preparePassthroughExchange(
949
986
  route.provider.authMode === "forward")
950
987
  .then(adoptObservedResponse);
951
988
  },
952
- { abortSignal: upstream.signal, label: safeHostLabel(request.url), attempts: allowance.attempts, onSendsConsumed: noteTransientSends },
989
+ { abortSignal: upstream.signal, label: safeHostLabel(request.url), attempts: allowance.attempts,
990
+ onSendsConsumed: noteTransientSends, claimAmbiguousResend: claimPreHeaderResend },
953
991
  );
954
992
  } catch (err) {
955
993
  return { failed: transportFailureResponse(err) };
@@ -1030,35 +1068,54 @@ export async function preparePassthroughExchange(
1030
1068
  // every other build site; a replay is exactly when a grown payload reappears.
1031
1069
  const replayBodyRefusal = refuseOversizedOutboundBody(request);
1032
1070
  if (replayBodyRefusal) return replayBodyRefusal;
1033
- transportState.noteRoutedAttemptSend(passthroughEstimate, "oauth-401");
1034
- upstreamResponse = await fetchWithHeaderTimeout(
1035
- request.url,
1036
- { method: request.method, headers: request.headers, body: request.body },
1037
- upstream.signal,
1038
- connectMs,
1039
- parsed.stream,
1040
- // The replay-dispatched signal is what bounds the rest of this logical request, so it
1041
- // has to describe a send that actually happened. fetchWithHeaderTimeout awaits pacing
1042
- // admission BEFORE calling the executor, so signalling at the call site would spend the
1043
- // budget even when a rejected pacing wait means nothing reaches the network. Wrapping
1044
- // the executor moves the signal to the last moment before the send, where a throw from
1045
- // here on is a genuine transport attempt.
1046
- storedPoolReplayDispatchNotifier(
1047
- providerFetch(route.provider, options.codexWsRuntimeIdentity, {
1048
- nativeControl: nativeResponseControlEligible(route.provider, options.nativeControl) && options.inboundTransport === "websocket" && !options.comboAttempt
1049
- && responseEffects.plaintextV2AgentMessageToolNames.size === 0
1050
- ? options.nativeControl : undefined,
1051
- dispatchOverride: oauthDispatch(request),
1052
- providerName: route.providerName,
1053
- modelId: route.modelId,
1054
- onCodexWsQuota: codexWsQuotaObserver(admissionState.authCtx, route.provider, route.modelId),
1055
- beforeDispatch: isCanonicalOpenAiForwardProvider(route.provider)
1056
- ? createCodexReserveDispatchGuard(admissionState.authCtx, options.codexAuthPolicy ?? config, route.modelId, options.admission, options.visionDescribeTerminal === true) : undefined,
1057
- }),
1058
- codex401ReplayKind === "stored" ? options.onStoredPool401ReplayDispatched : undefined,
1059
- ),
1060
- route.provider.authMode === "forward",
1061
- ).then(adoptObservedResponse);
1071
+ // The replay-dispatched signal is what bounds the rest of this logical request, so it
1072
+ // has to describe a send that actually happened. fetchWithHeaderTimeout awaits pacing
1073
+ // admission BEFORE calling the executor, so signalling at the call site would spend the
1074
+ // budget even when a rejected pacing wait means nothing reaches the network. Wrapping
1075
+ // the executor moves the signal to the last moment before the send, where a throw from
1076
+ // here on is a genuine transport attempt. The notifier is built once for the whole leg:
1077
+ // it fires on the first dispatch, and a replacement is another send of the same replay
1078
+ // rather than a second one to announce.
1079
+ const oauthReplayExecutor = storedPoolReplayDispatchNotifier(
1080
+ providerFetch(route.provider, options.codexWsRuntimeIdentity, {
1081
+ nativeControl: nativeResponseControlEligible(route.provider, options.nativeControl) && options.inboundTransport === "websocket" && !options.comboAttempt
1082
+ && responseEffects.plaintextV2AgentMessageToolNames.size === 0
1083
+ ? options.nativeControl : undefined,
1084
+ dispatchOverride: oauthDispatch(request),
1085
+ providerName: route.providerName,
1086
+ modelId: route.modelId,
1087
+ onCodexWsQuota: codexWsQuotaObserver(admissionState.authCtx, route.provider, route.modelId),
1088
+ beforeDispatch: isCanonicalOpenAiForwardProvider(route.provider)
1089
+ ? createCodexReserveDispatchGuard(admissionState.authCtx, options.codexAuthPolicy ?? config, route.modelId, options.admission, options.visionDescribeTerminal === true) : undefined,
1090
+ }),
1091
+ codex401ReplayKind === "stored" ? options.onStoredPool401ReplayDispatched : undefined,
1092
+ );
1093
+ // Routed through the shared helper so an ambiguous reset on THIS leg answers with the
1094
+ // same refusal every other leg gives. A bare fetch here rejected instead, and the
1095
+ // caller's transport-failure path turns a rejection into a client-retryable 502 --
1096
+ // which invites the whole turn to be sent again, on a leg whose first send may already
1097
+ // have run it.
1098
+ upstreamResponse = await fetchWithTransientRetry(
1099
+ recovery => {
1100
+ transportState.noteRoutedAttemptSend(passthroughEstimate, recovery ?? "oauth-401");
1101
+ return fetchWithHeaderTimeout(
1102
+ request.url,
1103
+ applyUpstreamRecoveryInit({
1104
+ method: request.method,
1105
+ headers: request.headers,
1106
+ body: request.body,
1107
+ }, recovery),
1108
+ upstream.signal,
1109
+ connectMs,
1110
+ parsed.stream,
1111
+ oauthReplayExecutor,
1112
+ route.provider.authMode === "forward",
1113
+ ).then(adoptObservedResponse);
1114
+ },
1115
+ { abortSignal: upstream.signal, label: safeHostLabel(request.url),
1116
+ attempts: remainingTransientSendBudget(transientSendAttempts()),
1117
+ onSendsConsumed: noteTransientSends, claimAmbiguousResend: claimPreHeaderResend },
1118
+ );
1062
1119
  } catch (err) {
1063
1120
  return transportFailureResponse(err);
1064
1121
  } finally {
@@ -1182,7 +1239,8 @@ export async function preparePassthroughExchange(
1182
1239
  route.provider.authMode === "forward")
1183
1240
  .then(adoptObservedResponse);
1184
1241
  },
1185
- { abortSignal: upstream.signal, label: safeHostLabel(request.url), attempts: remainingTransientSendBudget(transientSendAttempts()), onSendsConsumed: noteTransientSends },
1242
+ { abortSignal: upstream.signal, label: safeHostLabel(request.url), attempts: remainingTransientSendBudget(transientSendAttempts()),
1243
+ onSendsConsumed: noteTransientSends, claimAmbiguousResend: claimPreHeaderResend },
1186
1244
  );
1187
1245
  } catch (err) {
1188
1246
  return transportFailureResponse(err);
@@ -1314,7 +1372,8 @@ export async function preparePassthroughExchange(
1314
1372
  route.provider.authMode === "forward")
1315
1373
  .then(adoptObservedResponse);
1316
1374
  },
1317
- { abortSignal: upstream.signal, label: safeHostLabel(request.url), attempts: remainingTransientSendBudget(transientSendAttempts()), onSendsConsumed: noteTransientSends },
1375
+ { abortSignal: upstream.signal, label: safeHostLabel(request.url), attempts: remainingTransientSendBudget(transientSendAttempts()),
1376
+ onSendsConsumed: noteTransientSends, claimAmbiguousResend: claimPreHeaderResend },
1318
1377
  );
1319
1378
  } catch (err) {
1320
1379
  return transportFailureResponse(err);
@@ -1571,6 +1630,91 @@ export async function preparePassthroughExchange(
1571
1630
  continue passthroughRecovery;
1572
1631
  }
1573
1632
  }
1633
+
1634
+ // The post-header row of the same table. A native SSE body can die after the head with the
1635
+ // caller having observed nothing, which is the identical question the pre-header helper
1636
+ // answers -- and the identical grant, because both claim from this request's one allowance.
1637
+ const streamRecoveryContentType = upstreamResponse.headers.get("content-type")?.toLowerCase() ?? "";
1638
+ const protocolRecoveryCandidate = upstreamResponse.ok
1639
+ && !!upstreamResponse.body
1640
+ && !isNonReplayableResponse(upstreamResponse)
1641
+ // The WebSocket transport settles its own ambiguous failures and marks them
1642
+ // non-replayable; re-reading its body here would be a second owner of one exchange.
1643
+ && !isCodexWsUpstreamResponse(upstreamResponse)
1644
+ // A downstream WebSocket turn that fell back to HTTP must relay response.created
1645
+ // immediately so the client can address the turn and receive explicit control refusal.
1646
+ // This preflight retains that event until output commits, so the two contracts cannot
1647
+ // share one body owner.
1648
+ && !(options.nativeControl && options.inboundTransport === "websocket")
1649
+ && ambiguousResend() !== undefined
1650
+ && remainingTransientSendBudget(transientSendAttempts()) > 0
1651
+ && (streamRecoveryContentType.includes("text/event-stream") || (!streamRecoveryContentType && parsed.stream));
1652
+ if (protocolRecoveryCandidate) {
1653
+ upstreamResponse = deferProtocolSafeResetRecovery(
1654
+ upstreamResponse,
1655
+ { model: logCtx.model, provider: logCtx.provider },
1656
+ (error, stage) => refetchAfterProtocolSafeReset(
1657
+ (signal = upstream.signal) => fetchWithHeaderTimeout(
1658
+ request.url,
1659
+ applyUpstreamRecoveryInit({
1660
+ method: request.method,
1661
+ headers: request.headers,
1662
+ body: request.body,
1663
+ }, "connection-reset"),
1664
+ signal,
1665
+ connectMs,
1666
+ true,
1667
+ providerFetch(route.provider, options.codexWsRuntimeIdentity, {
1668
+ // A replacement HTTP body must not open a fresh WebSocket exchange: the turn it
1669
+ // replaces was an HTTP stream, and a WS create frame is a different send.
1670
+ httpOnly: true,
1671
+ providerName: route.providerName,
1672
+ modelId: route.modelId,
1673
+ dispatchOverride: oauthDispatch(request),
1674
+ beforeDispatch: headers => {
1675
+ if (signal.aborted) throw signal.reason;
1676
+ if (!transportState.selectionIsCurrent(transportState.requestBindings.get(request))) {
1677
+ throw new Error("Credential selection changed before pre-output stream recovery");
1678
+ }
1679
+ if (isCanonicalOpenAiForwardProvider(route.provider)) {
1680
+ createCodexReserveDispatchGuard(
1681
+ admissionState.authCtx,
1682
+ options.codexAuthPolicy ?? config,
1683
+ route.modelId,
1684
+ options.admission,
1685
+ options.visionDescribeTerminal === true,
1686
+ )?.(headers);
1687
+ }
1688
+ // Recorded with the kind the gate derived its cause from, at the moment the
1689
+ // send actually leaves. One authorisation, one recorded reason, one send.
1690
+ transportState.noteRoutedAttemptSend(passthroughEstimate, "connection-reset");
1691
+ // Charged to the SAME request counter every other send goes through. The
1692
+ // replacement is bought here rather than by a nested retry helper, so there is
1693
+ // one charge for one send and no per-layer counter to reconcile.
1694
+ noteTransientSends(1);
1695
+ },
1696
+ }),
1697
+ route.provider.authMode === "forward",
1698
+ ).then(adoptObservedResponse),
1699
+ error,
1700
+ {
1701
+ abortSignal: upstream.signal,
1702
+ label: safeHostLabel(request.url),
1703
+ attempts: remainingTransientSendBudget(transientSendAttempts()),
1704
+ // The whole decision, including the stage the preflight observed and the grant the
1705
+ // pre-header helper shares. A committed stage refuses here without touching the
1706
+ // allowance, which is what keeps a turn that already emitted output from draining
1707
+ // the replacement a later ambiguous reset would have been entitled to.
1708
+ authorize: () => authorizeResendForRecovery(stage, "connection-reset", ambiguousResend()).allowed,
1709
+ acceptResponse: candidate => {
1710
+ const type = candidate.headers.get("content-type")?.toLowerCase() ?? "";
1711
+ return type.includes("text/event-stream") || (!type && parsed.stream);
1712
+ },
1713
+ },
1714
+ ),
1715
+ { allowMissingContentType: !streamRecoveryContentType && parsed.stream },
1716
+ );
1717
+ }
1574
1718
  break;
1575
1719
  }
1576
1720
 
@@ -1,6 +1,11 @@
1
1
  import { formatErrorResponse } from "../../bridge";
2
2
  import { isCyberPolicyCode, isCyberPolicyMessage } from "../../lib/errors";
3
- import { isReplayRefusalCode, UPSTREAM_RESET_REPLAY_REFUSED_CODE } from "../../lib/upstream-retry";
3
+ import {
4
+ applyReplayRefusalClientHeaders,
5
+ isReplayRefusalCode,
6
+ retainReplayRefusal,
7
+ UPSTREAM_RESET_REPLAY_REFUSED_CODE,
8
+ } from "../../lib/upstream-retry";
4
9
  import {
5
10
  resolveClientRetryAfter,
6
11
  validateClientRetryAfterHeader,
@@ -62,6 +67,10 @@ function isReplayRefusalBody(body: string): boolean {
62
67
  * - a replay refusal this proxy wrote gets none and keeps none: the whole point of the
63
68
  * refusal is that the turn may already be running, and the synthetic default for a
64
69
  * retryable 429 is a direct instruction to the client to send it a second time
70
+ *
71
+ * A refusal also leaves with the shared no-retry header and the in-process marker, so the
72
+ * verdict survives this re-wrap as a property of the response rather than as a status a later
73
+ * reader would have to guess from.
65
74
  */
66
75
  export function formatPassthroughUpstreamError(
67
76
  status: number,
@@ -84,11 +93,10 @@ export function formatPassthroughUpstreamError(
84
93
  const upstreamRetryAfter = options?.headers?.get("retry-after")?.trim() || undefined;
85
94
  const originalValid = validateClientRetryAfterHeader(upstreamRetryAfter, now);
86
95
  const cyberPolicyFailure = isCyberPolicyBody(trimmed);
96
+ const replayRefusal = options?.replayRefusal === true || isReplayRefusalBody(trimmed);
87
97
  // Two different reasons to answer with no wait at all, handled the same way: a hard policy
88
98
  // block will not become servable, and a refusal we made was never a rate limit.
89
- const suppressRetryAfter = cyberPolicyFailure
90
- || options?.replayRefusal === true
91
- || isReplayRefusalBody(trimmed);
99
+ const suppressRetryAfter = cyberPolicyFailure || replayRefusal;
92
100
  const resolved = suppressRetryAfter
93
101
  ? undefined
94
102
  : resolveClientRetryAfter({
@@ -105,7 +113,9 @@ export function formatPassthroughUpstreamError(
105
113
  && upstreamRetryAfter !== undefined
106
114
  && originalValid === undefined);
107
115
 
108
- if (!needsSet && !needsDelete) {
116
+ // A refusal always takes the rewriting path: it has a header to add even when the
117
+ // upstream named no wait for it to remove.
118
+ if (!needsSet && !needsDelete && !replayRefusal) {
109
119
  return new Response(bodyText, {
110
120
  status,
111
121
  ...(options?.statusText ? { statusText: options.statusText } : {}),
@@ -118,21 +128,30 @@ export function formatPassthroughUpstreamError(
118
128
  : new Headers({ "Content-Type": "application/json" });
119
129
  if (needsSet) headers.set("Retry-After", resolved!);
120
130
  else headers.delete("Retry-After");
121
- return new Response(bodyText, {
131
+ if (replayRefusal) applyReplayRefusalClientHeaders(headers);
132
+ const rewritten = new Response(bodyText, {
122
133
  status,
123
134
  ...(options?.statusText ? { statusText: options.statusText } : {}),
124
135
  headers,
125
136
  });
137
+ return replayRefusal ? retainReplayRefusal(rewritten) : rewritten;
126
138
  }
127
139
 
128
140
  const response = formatErrorResponse(
129
141
  status,
130
142
  "upstream_error",
131
143
  `Provider error ${status}: (empty body)`,
132
- resolved !== undefined ? { retryAfter: resolved } : undefined,
144
+ // Provenance is all that is left when the bounded read returned nothing display-safe, and
145
+ // it is enough: the formatter allowlists this code, restates the refusal status and marks
146
+ // the response, so an unreadable refusal reaches the client as the same refusal.
147
+ replayRefusal
148
+ ? { code: UPSTREAM_RESET_REPLAY_REFUSED_CODE }
149
+ : resolved !== undefined ? { retryAfter: resolved } : undefined,
133
150
  );
134
151
  const headers = new Headers(response.headers);
135
152
  headers.set("Content-Type", "application/json");
136
153
  if (resolved !== undefined) headers.set("Retry-After", resolved);
137
- return new Response(response.body, { status: response.status, headers });
154
+ if (replayRefusal) applyReplayRefusalClientHeaders(headers);
155
+ const wrapped = new Response(response.body, { status: response.status, headers });
156
+ return replayRefusal ? retainReplayRefusal(wrapped) : wrapped;
138
157
  }