@bitkyc08/opencodex 2.67.0 → 2.68.0-preview.20260927

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 (257) hide show
  1. package/bin/ocx.mjs +3 -0
  2. package/gui/dist/assets/App-B5-cAEd2.js +51 -0
  3. package/gui/dist/assets/App-DCBismRi.css +1 -0
  4. package/gui/dist/assets/Tray-DFnMiyD0.js +1 -0
  5. package/gui/dist/assets/{index-DtNmX7hW.css → index-BDUBS8PW.css} +1 -1
  6. package/gui/dist/assets/index-BcXblvem.js +86 -0
  7. package/gui/dist/assets/quota-summary-i84eOU3o.js +1 -0
  8. package/gui/dist/index.html +21 -2
  9. package/package.json +1 -1
  10. package/src/adapters/anthropic-image-guard.ts +13 -1
  11. package/src/adapters/anthropic-image-normalize.ts +3 -2
  12. package/src/adapters/anthropic-output-schema.ts +36 -0
  13. package/src/adapters/anthropic.ts +10 -2
  14. package/src/adapters/base.ts +7 -0
  15. package/src/adapters/codebuddy/live-models.ts +180 -0
  16. package/src/adapters/coding-agent/protocol.ts +91 -12
  17. package/src/adapters/coding-agent/turn.ts +32 -27
  18. package/src/adapters/devin/cloud-direct/index.ts +1 -0
  19. package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +18 -4
  20. package/src/adapters/devin.ts +9 -6
  21. package/src/adapters/google-http.ts +4 -4
  22. package/src/adapters/google.ts +93 -3
  23. package/src/adapters/kiro/adapter.ts +6 -2
  24. package/src/adapters/kiro/stream.ts +12 -2
  25. package/src/adapters/kiro/usage.ts +3 -1
  26. package/src/adapters/kiro-errors.ts +11 -1
  27. package/src/adapters/kiro-events.ts +27 -1
  28. package/src/adapters/kiro-refusal.ts +30 -0
  29. package/src/adapters/kiro-retry.ts +71 -31
  30. package/src/adapters/openai-chat/deepseek-artifact-schema.ts +45 -0
  31. package/src/adapters/openai-chat/messages.ts +31 -4
  32. package/src/adapters/openai-chat/serialized-tool-call-content.ts +52 -7
  33. package/src/adapters/openai-chat/tool-call-id-remint.ts +65 -0
  34. package/src/adapters/openai-chat/tool-schema.ts +6 -2
  35. package/src/adapters/openai-chat.ts +2 -2
  36. package/src/adapters/openai-responses/muse-tool-choice.ts +31 -0
  37. package/src/adapters/openai-responses/passthrough.ts +9 -3
  38. package/src/adapters/opencode-go-additional-tools.ts +19 -10
  39. package/src/adapters/physical-send.ts +10 -3
  40. package/src/adapters/registry.ts +2 -1
  41. package/src/adapters/responses-tool-schema.ts +31 -3
  42. package/src/adapters/run-turn-queue.ts +63 -23
  43. package/src/adapters/unique-tool-call-ids.ts +63 -0
  44. package/src/adapters/xai-web-search.ts +32 -2
  45. package/src/bridge/sse.ts +14 -2
  46. package/src/chat/inbound.ts +20 -6
  47. package/src/claude/agents-inject.ts +4 -0
  48. package/src/claude/desktop-3p.ts +53 -13
  49. package/src/claude/desktop-profile.ts +41 -4
  50. package/src/claude/inbound-model-options.ts +14 -2
  51. package/src/claude/inbound.ts +1 -1
  52. package/src/claude/intercept/connect-proxy.ts +17 -1
  53. package/src/claude/intercept/local-ca.ts +7 -1
  54. package/src/claude/message-threads.ts +28 -0
  55. package/src/cli/account-api.ts +13 -0
  56. package/src/cli/account-auth.ts +49 -2
  57. package/src/cli/account-extended.ts +4 -2
  58. package/src/cli/account.ts +4 -2
  59. package/src/cli/capabilities.ts +17 -0
  60. package/src/cli/claude.ts +3 -1
  61. package/src/cli/dispatch.ts +62 -5
  62. package/src/cli/index.ts +84 -79
  63. package/src/cli/minimax.ts +4 -2
  64. package/src/cli/opencode.ts +6 -1
  65. package/src/cli/restart-handoff.ts +110 -0
  66. package/src/cli/status.ts +51 -0
  67. package/src/client/connect.ts +34 -15
  68. package/src/client/link-ingress.ts +102 -0
  69. package/src/client/link-join.ts +37 -16
  70. package/src/client/link-relay.ts +228 -45
  71. package/src/client/link-state.ts +54 -0
  72. package/src/client/link-status.ts +39 -0
  73. package/src/client/link-teardown.ts +2 -2
  74. package/src/client/link-tunnel.ts +615 -86
  75. package/src/client/machine-api.ts +10 -4
  76. package/src/client/machine-listener.ts +57 -10
  77. package/src/client/runtime.ts +210 -35
  78. package/src/clients/config-export/constants.ts +1 -12
  79. package/src/clients/config-export/contracts.ts +2 -0
  80. package/src/clients/config-export/model-metadata.ts +9 -3
  81. package/src/clients/config-export/omp.ts +1 -1
  82. package/src/clients/config-export/zcode-store.ts +2 -2
  83. package/src/clients/config-export.ts +36 -3
  84. package/src/codex/app-server-processes.ts +32 -0
  85. package/src/codex/app-server-restart-service.ts +20 -3
  86. package/src/codex/catalog/access-programs.ts +50 -0
  87. package/src/codex/catalog/build-entries.ts +13 -0
  88. package/src/codex/catalog/derive-entry.ts +1 -0
  89. package/src/codex/catalog/model-hints.ts +11 -0
  90. package/src/codex/catalog/parsing.ts +6 -2
  91. package/src/codex/catalog/provider-models.ts +81 -0
  92. package/src/codex/catalog/retained-sync.ts +3 -0
  93. package/src/codex/codex-write-lock.ts +2 -2
  94. package/src/codex/convergence.ts +2 -0
  95. package/src/codex/desired-state.ts +14 -4
  96. package/src/codex/home.ts +21 -3
  97. package/src/codex/inject/restore.ts +17 -0
  98. package/src/codex/inject/routing-classify.ts +3 -2
  99. package/src/codex/inject-coordination.ts +4 -1
  100. package/src/codex/inject.ts +8 -1
  101. package/src/codex/journal.ts +6 -2
  102. package/src/codex/management-convergence.ts +9 -0
  103. package/src/codex/model-entitlements.ts +28 -5
  104. package/src/codex/routing/idle-window.ts +58 -0
  105. package/src/codex/routing-drift.ts +134 -0
  106. package/src/codex/routing-healer.ts +419 -0
  107. package/src/codex/routing.ts +10 -0
  108. package/src/codex/runtime.ts +188 -57
  109. package/src/codex/sibling-handoff.ts +71 -0
  110. package/src/codex/sibling-start.ts +139 -0
  111. package/src/codex/sync.ts +5 -3
  112. package/src/combos/failover.ts +32 -5
  113. package/src/combos/request.ts +11 -3
  114. package/src/combos/reset-window.ts +10 -5
  115. package/src/combos/resolve.ts +36 -6
  116. package/src/config/diagnostics.ts +2 -1
  117. package/src/config/load-degrade.ts +2 -0
  118. package/src/config/paths.ts +7 -1
  119. package/src/config/process-state.ts +10 -1
  120. package/src/config/schema/compaction-recovery.ts +15 -0
  121. package/src/config/schema/config-schema.ts +2 -0
  122. package/src/config/schema/leaf-validators.ts +9 -3
  123. package/src/generated/compatibility-version.json +360 -204
  124. package/src/images/loop.ts +47 -32
  125. package/src/integrations/catalog-refresh.ts +4 -0
  126. package/src/integrations/omp-yaml-source.ts +1 -0
  127. package/src/lib/claude-request-projection.ts +101 -0
  128. package/src/lib/local-account-switch-capability.ts +48 -0
  129. package/src/lib/local-upstream.ts +146 -0
  130. package/src/lib/package-tree-integrity.ts +25 -3
  131. package/src/lib/package-tree-retarget.ts +130 -0
  132. package/src/lib/process-control.ts +4 -1
  133. package/src/lib/system-restart-contract.ts +106 -0
  134. package/src/lib/upstream-retry.ts +57 -1
  135. package/src/link/ports.ts +9 -0
  136. package/src/link/ssh-argv.ts +16 -0
  137. package/src/link/ssh-runner.ts +70 -2
  138. package/src/link/tunnel-state.ts +58 -9
  139. package/src/oauth/account-quota-rank.ts +29 -18
  140. package/src/oauth/generic-account-failover.ts +179 -22
  141. package/src/oauth/index.ts +17 -4
  142. package/src/oauth/kiro-account-load.ts +107 -0
  143. package/src/oauth/kiro-device-login.ts +312 -0
  144. package/src/oauth/kiro-terminal-failover.ts +28 -0
  145. package/src/oauth/login-flow-state.ts +7 -1
  146. package/src/oauth/pool-settings-capability.ts +23 -14
  147. package/src/oauth/store.ts +56 -3
  148. package/src/oauth/types.ts +4 -0
  149. package/src/plugins/loader.ts +350 -0
  150. package/src/plugins/upstream-hooks.ts +119 -0
  151. package/src/protocols/encoders/adapter-events.ts +9 -2
  152. package/src/providers/account-quota-disk.ts +42 -4
  153. package/src/providers/codebuddy-models.ts +6 -2
  154. package/src/providers/command-code-efforts.ts +33 -10
  155. package/src/providers/kiro-account-state-disk.ts +44 -0
  156. package/src/providers/kiro-model-catalog.ts +162 -0
  157. package/src/providers/kiro-models.ts +5 -4
  158. package/src/providers/kiro-quota-metrics.ts +35 -0
  159. package/src/providers/kiro-usage.ts +118 -14
  160. package/src/providers/quota/account-cache.ts +43 -7
  161. package/src/providers/quota/antigravity.ts +8 -3
  162. package/src/providers/quota/kiro-account-probe.ts +12 -0
  163. package/src/providers/quota/vendor-probes-key.ts +31 -26
  164. package/src/providers/quota/vendor-probes-oauth.ts +16 -7
  165. package/src/providers/quota-types.ts +14 -0
  166. package/src/providers/quota.ts +21 -21
  167. package/src/providers/registry/entries-extended.ts +5 -3
  168. package/src/providers/request-pacing.ts +410 -12
  169. package/src/remote-control/workspace-codex-runtime.ts +4 -3
  170. package/src/responses/citation-markers.ts +132 -65
  171. package/src/responses/hosted-tool-policy.ts +14 -3
  172. package/src/responses/parser-content.ts +3 -2
  173. package/src/responses/parser.ts +4 -1
  174. package/src/responses/schema.ts +4 -0
  175. package/src/responses/visualization-directives.ts +182 -0
  176. package/src/server/chat-native-sse.ts +24 -12
  177. package/src/server/chat-native.ts +137 -68
  178. package/src/server/claude-messages.ts +147 -12
  179. package/src/server/index/link-listener.ts +7 -0
  180. package/src/server/index/optional-listeners.ts +4 -1
  181. package/src/server/index/package-tree-guard.ts +35 -16
  182. package/src/server/index/serve-options.ts +4 -2
  183. package/src/server/index/startup-warnings.ts +6 -0
  184. package/src/server/index.ts +7 -7
  185. package/src/server/inference/client-encoder-delivery.ts +3 -0
  186. package/src/server/inference/context.ts +30 -2
  187. package/src/server/local-account-switch-auth.ts +79 -0
  188. package/src/server/management/agent-settings-routes.ts +11 -3
  189. package/src/server/management/config-routes.ts +28 -11
  190. package/src/server/management/context.ts +2 -0
  191. package/src/server/management/link-routes.ts +123 -39
  192. package/src/server/management/logs-usage-routes.ts +9 -42
  193. package/src/server/management/model-rows.ts +1 -0
  194. package/src/server/management/oauth-account-routes.ts +67 -12
  195. package/src/server/management/route-registry.ts +5 -5
  196. package/src/server/management/sibling-guard.ts +60 -0
  197. package/src/server/management/storage-log-guard-routes.ts +72 -6
  198. package/src/server/management/system-restart.ts +92 -135
  199. package/src/server/management/system-routes.ts +1 -1
  200. package/src/server/management-api.ts +31 -3
  201. package/src/server/management-auth.ts +3 -0
  202. package/src/server/port-reclaim.ts +111 -1
  203. package/src/server/proxy-liveness.ts +16 -0
  204. package/src/server/relay.ts +16 -15
  205. package/src/server/request-log-filter.ts +28 -27
  206. package/src/server/request-log.ts +7 -3
  207. package/src/server/request-metrics.ts +28 -0
  208. package/src/server/responses/adapter-continuation.ts +151 -21
  209. package/src/server/responses/adapter-delivery.ts +21 -7
  210. package/src/server/responses/adapter-dispatch.ts +239 -51
  211. package/src/server/responses/codex-ws-pool.ts +5 -4
  212. package/src/server/responses/codex-ws-request.ts +4 -1
  213. package/src/server/responses/compact.ts +8 -4
  214. package/src/server/responses/compaction-recovery-policy.ts +116 -0
  215. package/src/server/responses/compaction-recovery.ts +330 -0
  216. package/src/server/responses/core-combo-failure.ts +1 -0
  217. package/src/server/responses/core-combo.ts +5 -1
  218. package/src/server/responses/core-lifetime.ts +22 -0
  219. package/src/server/responses/core-options.ts +16 -1
  220. package/src/server/responses/core.ts +14 -4
  221. package/src/server/responses/empty-completion-guard.ts +2 -0
  222. package/src/server/responses/fetch-helpers.ts +82 -31
  223. package/src/server/responses/passthrough-delivery.ts +3 -2
  224. package/src/server/responses/passthrough-dispatch.ts +12 -2
  225. package/src/server/responses/passthrough-execution.ts +2 -2
  226. package/src/server/responses/request-send-budget.ts +21 -5
  227. package/src/server/responses/request-transport.ts +116 -18
  228. package/src/server/responses/reset-replay.ts +32 -0
  229. package/src/server/responses/run-turn-execution.ts +136 -19
  230. package/src/server/responses/sidecar-execution.ts +41 -8
  231. package/src/server/responses/terminal-guard.ts +2 -0
  232. package/src/server/responses/ws-upstream.ts +44 -4
  233. package/src/server/restart-replacement.ts +382 -0
  234. package/src/server/startup-health-cache.ts +23 -4
  235. package/src/server/stop-teardown.ts +35 -1
  236. package/src/server/system-env.ts +3 -0
  237. package/src/service/windows-taskxml.ts +11 -7
  238. package/src/service/windows-wrapper-exit.ts +8 -0
  239. package/src/stall-timeout.ts +35 -10
  240. package/src/storage/policy-job.ts +4 -0
  241. package/src/storage/scanner.ts +177 -29
  242. package/src/storage/storage-mutation-coordinator.ts +17 -0
  243. package/src/tray/windows-tray.ps1 +50 -23
  244. package/src/types/config.ts +4 -0
  245. package/src/types/provider.ts +5 -1
  246. package/src/types/request.ts +13 -2
  247. package/src/update/index.ts +3 -1
  248. package/src/update/job.ts +6 -2
  249. package/src/update/mise-launcher-target.ts +100 -0
  250. package/src/update/worker-launch.ts +46 -0
  251. package/src/usage/log.ts +2 -0
  252. package/src/web-search/loop.ts +5 -2
  253. package/gui/dist/assets/App-BJsT8Icc.css +0 -1
  254. package/gui/dist/assets/App-D3pNGiN4.js +0 -51
  255. package/gui/dist/assets/Tray-yb05wCjb.js +0 -1
  256. package/gui/dist/assets/index-CouvvtMV.js +0 -86
  257. package/gui/dist/assets/tray-data-CYGdjCJ7.js +0 -1
@@ -78,6 +78,38 @@ export function selfContainedResponsesBody(body: unknown): boolean {
78
78
  });
79
79
  }
80
80
 
81
+ /**
82
+ * Chat Completions tools are executed by the client by spec -- the origin only emits the call.
83
+ * Anything else (`web_search`, `code_interpreter`, a vendor hosted tool) runs on the origin during
84
+ * the turn, so an unknown or hosted entry fails the whole catalog rather than being skipped.
85
+ */
86
+ function clientExecutedChatTools(tools: unknown, budget: { remaining: number }): boolean {
87
+ if (!Array.isArray(tools) || tools.length > budget.remaining) return false;
88
+ budget.remaining -= tools.length;
89
+ return tools.every(tool => record(tool) && tool.type === "function");
90
+ }
91
+
92
+ /**
93
+ * A Chat Completions body whose second send can only repeat the inference.
94
+ *
95
+ * The lane is stateless by construction -- the proxy rebuilds the whole `messages` array on every
96
+ * turn, and `previous_response_id` is not part of the Chat wire -- so the only hazards left are
97
+ * server-side storage, which would record a second completion, and a hosted tool the origin would
98
+ * run a second time.
99
+ */
100
+ export function selfContainedChatBody(body: unknown): boolean {
101
+ if (!record(body)) return false;
102
+ if (body.store === true) return false;
103
+ if (body.previous_response_id != null) return false;
104
+ // Hosted execution requested outside the `tools` catalog. Judged on the inbound body like every
105
+ // other hazard here, which is conservative for the outbound request by design: a body that asks
106
+ // for a hosted search is refused rather than assumed harmless because a later stage might drop
107
+ // the field.
108
+ if (body.web_search_options !== undefined) return false;
109
+ if (!Array.isArray(body.messages)) return false;
110
+ return body.tools === undefined || clientExecutedChatTools(body.tools, { remaining: MAX_TOOL_ENTRIES });
111
+ }
112
+
81
113
  /**
82
114
  * The allowance for ONE logical request, or nothing when the provider did not opt in.
83
115
  *
@@ -6,14 +6,14 @@ import type { ResponsesEffects } from "./response-effects";
6
6
  import type { ResponsesSendBudget } from "./request-send-budget";
7
7
  import type { ResponsesCompletionPolicy } from "./completion-policy";
8
8
  import { linkAbortSignal, runTurnAdapterSseResponses } from "./core-lifetime";
9
- import { createAdapterEventQueue, preflightAdapterEvents } from "../../adapters/run-turn-queue";
9
+ import { createAdapterEventQueue, preflightAdapterEvents, type AdapterEventPreflight } from "../../adapters/run-turn-queue";
10
10
  import {
11
11
  bindRouteReasoningReplayScope,
12
12
  adapterNeedsForcedContinuation,
13
13
  adapterResponseReachedServingTerminal,
14
14
  } from "./core-replay";
15
15
  import { noteAttemptRecoveryWithheld, sealRequestAttemptIdentity, recordAttemptCredentialSource } from "../request-log";
16
- import { waitForProviderRequestSlot, RequestPacingQueueOverloadError } from "../../providers/request-pacing";
16
+ import { releaseProviderRequestSlot, waitForProviderRequestSlot, RequestPacingQueueOverloadError, type ProviderRequestSlot } from "../../providers/request-pacing";
17
17
  import type { AdapterEventQueue } from "../../adapters/run-turn-queue";
18
18
  import type { AttemptRecoveryKind } from "../../usage/log";
19
19
  import { providerFetch } from "./fetch-helpers";
@@ -22,7 +22,6 @@ import { normalizeDeclaredToolName, type AdapterEvent, type OcxProviderContinuat
22
22
  import { adapterFailureFromMessage, SEND_BUDGET_EXHAUSTED_CODE } from "../../lib/errors";
23
23
  import { SendBudgetExhaustedError, markResponseNonReplayable } from "../../lib/upstream-retry";
24
24
  import {
25
- GENERIC_OAUTH_MAX_FAILOVERS_PER_REQUEST,
26
25
  hasEligibleGenericOAuthFailoverTarget,
27
26
  isGenericOAuthFailoverEnabled,
28
27
  rotateGenericOAuthAccountOn429,
@@ -31,6 +30,9 @@ import {
31
30
  import { resolveWireProtocolOverride } from "../adapter-resolve";
32
31
  import { formatErrorResponse, bridgeToResponsesSSE, buildResponseJSON } from "../../bridge";
33
32
  import { redactSecretString } from "../../lib/redact";
33
+ import { adapterFailureFromEvent } from "../../bridge/internal";
34
+ import { resolveClientRetryAfter } from "../../lib/retry-after";
35
+ import { DEFAULT_STALL_TIMEOUT_SEC, resolveStallTimeoutSec } from "../../stall-timeout";
34
36
  import { jsonUtf8Bytes } from "../../lib/json-byte-size";
35
37
  import { isTranslatorBudgetExceededError } from "../../lib/translator-budget";
36
38
  import {
@@ -89,6 +91,7 @@ export async function executeResponsesRunTurn(
89
91
  | "replayOAuthCredentialSnapshot"
90
92
  | "genericFailoverAccountId"
91
93
  | "genericFailovers"
94
+ | "genericFailoverLimit"
92
95
  | "applyFailoverSnapshot"
93
96
  | "resolveSelectionAdapter"
94
97
  | "adapter"
@@ -184,8 +187,9 @@ export async function executeResponsesRunTurn(
184
187
  };
185
188
  // Initial admission must settle before the streaming Response commits HTTP 200.
186
189
  // Let the outer Responses facade preserve the local retryable-429 contract.
190
+ let initialPacingSlot: ProviderRequestSlot;
187
191
  try {
188
- await waitForProviderRequestSlot(route.providerName, route.provider, route.modelId, runTurnAbort.signal);
192
+ initialPacingSlot = await waitForProviderRequestSlot(route.providerName, route.provider, route.modelId, runTurnAbort.signal);
189
193
  } catch (error) {
190
194
  cleanupRunTurnAbort();
191
195
  queue.close();
@@ -211,11 +215,17 @@ export async function executeResponsesRunTurn(
211
215
  // rounds carry the grown message history and adjusted tool list while
212
216
  // selection/replay binding stays on the request's own parsed object.
213
217
  turnParsed: PreparedResponsesRequest["parsed"] = parsed,
218
+ preacquiredSlot?: ProviderRequestSlot,
214
219
  ): Promise<void> => {
215
220
  const attemptSeq = ++runTurnAttemptSeq;
221
+ let pacingSlot = preacquiredSlot;
222
+ const emit = (event: AdapterEvent) => {
223
+ options.onCompactionRecoveryAdapterEvent?.(event);
224
+ targetQueue.push(event);
225
+ };
216
226
  try {
217
227
  if (!pacingSlotAcquired) {
218
- await waitForProviderRequestSlot(route.providerName, route.provider, route.modelId, runTurnAbort.signal);
228
+ pacingSlot = await waitForProviderRequestSlot(route.providerName, route.provider, route.modelId, runTurnAbort.signal);
219
229
  }
220
230
  await refreshRunTurnSelection();
221
231
  // LOCAL PATCH (runturn-websearch): refreshRunTurnSelection binds route
@@ -241,6 +251,8 @@ export async function executeResponsesRunTurn(
241
251
  // Cursor HTTP/1.1 consumes it for RunSSE; every BidiAppend and redial then waits on
242
252
  // the same provider queue through this stateful wrapper.
243
253
  pacingSlotAcquired: true,
254
+ pacingSlot,
255
+ turnScopedPacing: true,
244
256
  },
245
257
  );
246
258
  await transportState.runTurnAdapter.runTurn?.(
@@ -248,8 +260,10 @@ export async function executeResponsesRunTurn(
248
260
  {
249
261
  headers: requestState.selectedForwardHeaders,
250
262
  abortSignal: runTurnAbort.signal,
263
+ comboAttempt: options.comboAttempt === true,
251
264
  translatorBudget,
252
265
  providerFetch: runTurnProviderFetch,
266
+ pacingSlot,
253
267
  // The only way the request budget reaches a transport the adapter owns. Without it
254
268
  // a Cursor turn's inner ladder was three physical sends the cap read as one.
255
269
  ...(adapterDispatchBudget ? { sendBudget: adapterDispatchBudget } : {}),
@@ -262,7 +276,7 @@ export async function executeResponsesRunTurn(
262
276
  ),
263
277
  onRecoveryWithheld: noteAdapterRecoveryWithheld,
264
278
  },
265
- targetQueue.push,
279
+ emit,
266
280
  );
267
281
  // LOCAL PATCH (runturn-websearch): adapters may write conversation/
268
282
  // continuation state onto the object they received; merge it back so
@@ -276,7 +290,7 @@ export async function executeResponsesRunTurn(
276
290
  Object.assign(parsed, routeState);
277
291
  }
278
292
  } catch (err) {
279
- targetQueue.push(err instanceof RequestPacingQueueOverloadError
293
+ emit(err instanceof RequestPacingQueueOverloadError
280
294
  ? {
281
295
  type: "error",
282
296
  status: 429,
@@ -301,6 +315,7 @@ export async function executeResponsesRunTurn(
301
315
  message: err instanceof Error ? err.message : String(err),
302
316
  });
303
317
  } finally {
318
+ releaseProviderRequestSlot(pacingSlot);
304
319
  // Cursor assigns a stable conversation id inside runTurn on the first headerless
305
320
  // turn; backfill so Logs can filter/total that opening request (#330 / #522).
306
321
  if (!logCtx.conversationId && parsed._cursorConversationId) {
@@ -313,7 +328,7 @@ export async function executeResponsesRunTurn(
313
328
  // synthetic web_search tool; later iterations get their own queue so the
314
329
  // search loop can buffer each turn's events before deciding to intercept.
315
330
  const wsFirstParsed = wsPlan ? runTurnWebSearchInitialParsed(parsed) : parsed;
316
- const runTurn = async (): Promise<void> => runTurnAttempt(queue, undefined, true, wsFirstParsed);
331
+ const runTurn = async (): Promise<void> => runTurnAttempt(queue, undefined, true, wsFirstParsed, initialPacingSlot);
317
332
  const runTurnFailoverArmed = () =>
318
333
  route.provider.authMode === "oauth"
319
334
  || !!(transportState.genericFailoverAccountId
@@ -345,7 +360,7 @@ export async function executeResponsesRunTurn(
345
360
  if (
346
361
  status !== 429
347
362
  || !transportState.genericFailoverAccountId
348
- || transportState.genericFailovers >= GENERIC_OAUTH_MAX_FAILOVERS_PER_REQUEST
363
+ || transportState.genericFailovers >= transportState.genericFailoverLimit
349
364
  || !isGenericOAuthFailoverEnabled(config, route.providerName)
350
365
  ) return false;
351
366
  // Intersection with the request's shared budget: the roster bound above answers "may this
@@ -432,17 +447,81 @@ export async function executeResponsesRunTurn(
432
447
  return false;
433
448
  }
434
449
  };
450
+ const streamAfterPreflight = (
451
+ initialSource: AsyncIterable<AdapterEvent>,
452
+ replayParsed: PreparedResponsesRequest["parsed"],
453
+ initiallyReplayUnsafe: boolean,
454
+ ): AsyncIterable<AdapterEvent> => (async function* () {
455
+ let source = initialSource;
456
+ let replayUnsafe = initiallyReplayUnsafe;
457
+ let firstMeaningfulSeen = false;
458
+ while (true) {
459
+ let rotated = false;
460
+ for await (const event of source) {
461
+ if (!firstMeaningfulSeen && event.type === "heartbeat") {
462
+ replayUnsafe ||= event.replayUnsafe === true;
463
+ yield event;
464
+ continue;
465
+ }
466
+ if (!firstMeaningfulSeen && !replayUnsafe && event.type === "error"
467
+ && await rotateRunTurnAdapterOnPreflight429(event)) {
468
+ const retryQueue = createAdapterEventQueue({
469
+ onBacklogExceeded: () => runTurnAbort.abort(),
470
+ });
471
+ const pendingPermit = sendBudgetState.pendingHopPermit;
472
+ const retryAttempt = runTurnAttempt(retryQueue, "oauth-account-429", false, replayParsed);
473
+ if (pendingPermit) {
474
+ const releaseIfUnclaimed = () => {
475
+ if (sendBudgetState.pendingHopPermit !== pendingPermit) return;
476
+ sendBudgetState.pendingHopPermit = undefined;
477
+ pendingPermit.release();
478
+ };
479
+ void retryAttempt.then(releaseIfUnclaimed, releaseIfUnclaimed);
480
+ }
481
+ source = retryQueue.stream();
482
+ rotated = true;
483
+ break;
484
+ }
485
+ firstMeaningfulSeen = true;
486
+ yield event;
487
+ }
488
+ if (!rotated) return;
489
+ }
490
+ })();
435
491
  const preflightRunTurnFailover = async (
436
492
  firstSource: AsyncIterable<AdapterEvent>,
437
493
  // LOCAL PATCH (runturn-websearch): replayed attempts re-dispatch this
438
494
  // request — the grown-history iteration for post-search legs, the
439
495
  // tool-injected first request elsewhere.
440
496
  replayParsed: PreparedResponsesRequest["parsed"] = wsFirstParsed,
497
+ deadlineAt?: number,
441
498
  ): Promise<AsyncIterable<AdapterEvent>> => {
442
499
  let source = firstSource;
500
+ let latestRetryAttempt: Promise<void> | undefined;
501
+ let deferPendingPermitCleanup = false;
443
502
  try {
444
503
  while (true) {
445
- const preflight = await preflightAdapterEvents(source);
504
+ const preflight = await preflightAdapterEvents(source, undefined, {
505
+ ...(deadlineAt === undefined ? {} : { maxWaitMs: deadlineAt - Date.now() }),
506
+ honorReady: replayParsed.stream,
507
+ });
508
+ if (preflight.timedOut) {
509
+ const pendingPermit = sendBudgetState.pendingHopPermit;
510
+ if (pendingPermit && latestRetryAttempt) {
511
+ // The timed-out replay may still be waiting for its first physical dispatch.
512
+ // Keep its reservation available until the adapter claims it; if the attempt ends
513
+ // before claiming, refund it then rather than charging a later send twice.
514
+ deferPendingPermitCleanup = true;
515
+ const releaseIfUnclaimed = () => {
516
+ if (sendBudgetState.pendingHopPermit !== pendingPermit) return;
517
+ sendBudgetState.pendingHopPermit = undefined;
518
+ pendingPermit.release();
519
+ };
520
+ void latestRetryAttempt.then(releaseIfUnclaimed, releaseIfUnclaimed);
521
+ }
522
+ return streamAfterPreflight(preflight.stream, replayParsed, preflight.replayUnsafe);
523
+ }
524
+ if (preflight.ready) return streamAfterPreflight(preflight.stream, replayParsed, preflight.replayUnsafe);
446
525
  if (preflight.replayUnsafe
447
526
  || !preflight.error
448
527
  || !(await rotateRunTurnAdapterOnPreflight429(preflight.error))) {
@@ -451,15 +530,14 @@ export async function executeResponsesRunTurn(
451
530
  const retryQueue = createAdapterEventQueue({
452
531
  onBacklogExceeded: () => runTurnAbort.abort(),
453
532
  });
454
- void runTurnAttempt(retryQueue, "oauth-account-429", false, replayParsed);
533
+ latestRetryAttempt = runTurnAttempt(retryQueue, "oauth-account-429", false, replayParsed);
534
+ void latestRetryAttempt;
455
535
  source = retryQueue.stream();
456
536
  }
457
537
  } finally {
458
- // A handed-down hop reservation belongs to the replay this loop dispatched, and the
459
- // loop only leaves after that replay's first event has arrived -- so the adapter has
460
- // already reserved if it was ever going to. Dropping the reference here keeps an
461
- // adapter that reserves nothing from leaving a free send for an unrelated later leg.
462
- sendBudgetState.pendingHopPermit = undefined;
538
+ // On ordinary exit the replay's first event proves its first-send reservation was reached.
539
+ // A timed-out replay may not have dispatched yet, so that path defers cleanup above.
540
+ if (!deferPendingPermitCleanup) sendBudgetState.pendingHopPermit = undefined;
463
541
  }
464
542
  };
465
543
  // The empty-completion retry re-runs the turn against a fresh queue: the
@@ -490,17 +568,49 @@ export async function executeResponsesRunTurn(
490
568
  message: undeclaredToolCallMessage(effectiveName),
491
569
  };
492
570
  };
571
+ const grokDevinPreflight = !options.comboAttempt && logCtx.surface === "grok" && inboundWire === "responses"
572
+ && transportState.runTurnAdapter.name === "devin";
573
+ const grokRateLimitResponse = (preflight: AdapterEventPreflight): Response | undefined => {
574
+ if (preflight.replayUnsafe || preflight.error?.status !== 429
575
+ || preflight.error.code === SEND_BUDGET_EXHAUSTED_CODE) return;
576
+ const { httpStatus, error } = adapterFailureFromEvent(preflight.error);
577
+ cancelResponseCompletion();
578
+ runTurnAbort.abort();
579
+ queue.close();
580
+ cleanupRunTurnAbort();
581
+ releaseSearchProbeLease();
582
+ return formatErrorResponse(httpStatus, error.type, error.message, {
583
+ code: error.code,
584
+ retryAfter: resolveClientRetryAfter({ status: httpStatus, message: error.message }),
585
+ });
586
+ };
493
587
  if (parsed.stream) {
494
588
  try {
495
589
  void runTurn();
496
590
  let eventSource: AsyncIterable<AdapterEvent> = queue.stream();
591
+ const stallTimeoutSec = wsPlan?.stallTimeoutSec ?? config.stallTimeoutSec;
592
+ // A disabled watchdog (0) is not a zero preflight: it would commit SSE before Devin's first
593
+ // event and deliver a pre-output 429 in-stream. Fall back to the default finite bound.
594
+ const preflightDeadlineAt = grokDevinPreflight
595
+ ? Date.now() + (resolveStallTimeoutSec(stallTimeoutSec) || DEFAULT_STALL_TIMEOUT_SEC) * 1_000
596
+ : undefined;
497
597
  if (runTurnFailoverArmed()) {
498
598
  // Preflight holds only heartbeats and the first meaningful event. A first-event 429 can be
499
599
  // replayed transparently; after any output reaches the bridge, a later error stays terminal.
500
- eventSource = await preflightRunTurnFailover(eventSource);
600
+ eventSource = await preflightRunTurnFailover(eventSource, wsFirstParsed, preflightDeadlineAt);
601
+ }
602
+ if (grokDevinPreflight) {
603
+ const preflight = await preflightAdapterEvents(eventSource, undefined, {
604
+ maxWaitMs: (preflightDeadlineAt ?? Date.now()) - Date.now(),
605
+ });
606
+ eventSource = preflight.stream;
607
+ const refusal = grokRateLimitResponse(preflight);
608
+ if (refusal) return refusal;
501
609
  }
502
610
  if (options.comboAttempt) {
503
- const preflight = await preflightAdapterEvents(eventSource, classifyUndeclaredFirstTool);
611
+ const preflight = await preflightAdapterEvents(
612
+ eventSource, classifyUndeclaredFirstTool, { honorReady: false },
613
+ );
504
614
  if (preflight.error || preflight.empty) {
505
615
  runTurnAbort.abort();
506
616
  queue.close();
@@ -559,7 +669,7 @@ export async function executeResponsesRunTurn(
559
669
  translatorBudget,
560
670
  replayCacheScope: parsed._reasoningReplayScope,
561
671
  ...(options.forceEmptyResponseId ? { responseId: "" } : {}),
562
- stallTimeoutSec: wsPlan?.stallTimeoutSec ?? config.stallTimeoutSec,
672
+ stallTimeoutSec,
563
673
  hideThinkingSummary: parsed.options.hideThinkingSummary,
564
674
  declaredToolNames,
565
675
  enforceDeclaredToolNames,
@@ -618,6 +728,13 @@ export async function executeResponsesRunTurn(
618
728
  (async function* () { yield* firstAttemptEvents; })(),
619
729
  )) runTurnEvents.push(event);
620
730
  }
731
+ if (grokDevinPreflight) {
732
+ const preflight = await preflightAdapterEvents(
733
+ (async function* () { yield* runTurnEvents; })(), undefined, { honorReady: false },
734
+ );
735
+ const refusal = grokRateLimitResponse(preflight);
736
+ if (refusal) return refusal;
737
+ }
621
738
  let events: AdapterEvent[];
622
739
  // LOCAL PATCH (runturn-websearch): same exclusion as the streaming branch —
623
740
  // a search-aware retry runs inside runTurnWebSearchLoop, not the raw guard.
@@ -20,11 +20,16 @@ import type { ProviderAdapter } from "../../adapters/base";
20
20
  import type { OcxParsedRequest } from "../../types";
21
21
  import { rotateProviderTransportOn429, rateLimitRetryPolicyFor } from "../../providers/key-failover";
22
22
  import {
23
- GENERIC_OAUTH_MAX_FAILOVERS_PER_REQUEST,
24
23
  isGenericOAuthFailoverEnabled,
25
24
  rotateGenericOAuthAccountOn429,
25
+ rotateGenericOAuthAccountOnRefusal,
26
+ quarantineKiroSuspendedAccount,
26
27
  failoverAccountSnapshot,
27
28
  } from "../../oauth/generic-account-failover";
29
+ import { classifyKiroRefusal } from "../../adapters/kiro-refusal";
30
+ import { noteKiroMonthlyRefusal, noteKiroServedSuccess } from "../../providers/kiro-usage";
31
+ import { persistKiroAccountState } from "../../providers/kiro-account-state-disk";
32
+ import { readDisplaySafeErrorText } from "./core-errors";
28
33
  import {
29
34
  ANTHROPIC_POOL_MAX_FAILOVERS_PER_REQUEST,
30
35
  rotateAnthropicAccountOn429,
@@ -59,6 +64,8 @@ export async function executeResponsesSidecars(
59
64
  | "adapter"
60
65
  | "genericFailoverAccountId"
61
66
  | "genericFailovers"
67
+ | "genericFailoverLimit"
68
+ | "replayOAuthCredentialSnapshot"
62
69
  | "applyFailoverSnapshot"
63
70
  | "anthropicPoolAccountId"
64
71
  | "anthropicPoolFailovers"
@@ -160,18 +167,32 @@ export async function executeResponsesSidecars(
160
167
  retryAfter: string | null,
161
168
  responseHeaders?: Headers,
162
169
  retryParsed?: OcxParsedRequest,
170
+ originalResponse?: Response,
163
171
  ): Promise<{ adapter: ProviderAdapter; recoveryKind: AttemptRecoveryKind } | null> => {
172
+ if (route.providerName !== "kiro" && originalResponse && originalResponse.status !== 429) return null;
173
+ const refusal = route.providerName === "kiro" && originalResponse
174
+ ? classifyKiroRefusal(originalResponse.status,
175
+ await readDisplaySafeErrorText(originalResponse.clone(), options.abortSignal ?? new AbortController().signal, "")).kind
176
+ : "rate";
177
+ if (refusal === "other") return null;
178
+ const sent = transportState.replayOAuthCredentialSnapshot;
179
+ const monthlyCooldownMs = route.providerName === "kiro" && refusal === "monthly_quota" && sent
180
+ ? noteKiroMonthlyRefusal(sent.accountId, sent.generation, Date.now()) : undefined;
181
+ if (monthlyCooldownMs !== undefined) persistKiroAccountState();
182
+ if (route.providerName === "kiro" && refusal === "suspended" && transportState.genericFailoverAccountId)
183
+ quarantineKiroSuspendedAccount(transportState.genericFailoverAccountId, sent?.generation);
164
184
  // Which credential axis actually moved. The main routed path already reports these three
165
185
  // separately (`adapter-dispatch`: key-429 / anthropic-oauth-429 / oauth-account-429); the
166
186
  // sidecar loops used to flatten all three to `key-429`, so an account rotation read as a key
167
187
  // rotation in the attempt row and in the Logs UI.
168
188
  let recoveryKind: AttemptRecoveryKind = "key-429";
169
- const rotated = rotateProviderTransportOn429(config, route.providerName, route.provider, {
189
+ const rotated = route.providerName !== "kiro" || originalResponse?.status === 429
190
+ ? rotateProviderTransportOn429(config, route.providerName, route.provider, {
170
191
  retryAfter,
171
192
  now: Date.now(),
172
193
  attemptedKey: route.provider.apiKey,
173
194
  promptCacheKey: parsed.options.promptCacheKey,
174
- });
195
+ }) : null;
175
196
  if (rotated) {
176
197
  route.provider = rotated;
177
198
  } else if (
@@ -180,7 +201,7 @@ export async function executeResponsesSidecars(
180
201
  // excludes it), so its sidecar 429s died on this guard before the Anthropic arm below
181
202
  // could ever be considered.
182
203
  transportState.genericFailoverAccountId
183
- && transportState.genericFailovers < GENERIC_OAUTH_MAX_FAILOVERS_PER_REQUEST
204
+ && transportState.genericFailovers < transportState.genericFailoverLimit
184
205
  && isGenericOAuthFailoverEnabled(config, route.providerName)
185
206
  ) {
186
207
  // Intersection with the request's shared budget. The sidecar replay is dispatched by the
@@ -191,7 +212,10 @@ export async function executeResponsesSidecars(
191
212
  `${route.providerName}|${route.modelId}|sidecar-oauth-429`,
192
213
  );
193
214
  if (!hop.allowed) return null;
194
- const nextAccountId = rotateGenericOAuthAccountOn429(
215
+ const nextAccountId = route.providerName === "kiro" ? rotateGenericOAuthAccountOnRefusal(
216
+ config, route.providerName, transportState.genericFailoverAccountId,
217
+ refusal, retryAfter, Date.now(), route.modelId, monthlyCooldownMs,
218
+ ) : rotateGenericOAuthAccountOn429(
195
219
  config,
196
220
  route.providerName,
197
221
  transportState.genericFailoverAccountId,
@@ -205,11 +229,12 @@ export async function executeResponsesSidecars(
205
229
  }
206
230
  try {
207
231
  const snapshot = await failoverAccountSnapshot(route.providerName, nextAccountId);
208
- transportState.genericFailovers += 1;
232
+ if (route.providerName !== "kiro") transportState.genericFailovers += 1;
209
233
  if (!await applyFailoverSnapshot(snapshot, retryParsed)) {
210
234
  hop.permit?.release();
211
235
  return null;
212
236
  }
237
+ if (route.providerName === "kiro") transportState.genericFailovers += 1;
213
238
  } catch {
214
239
  hop.permit?.release();
215
240
  return null;
@@ -384,10 +409,14 @@ export async function executeResponsesSidecars(
384
409
  transportState.bindKeyUsageFromBridge(usage);
385
410
  },
386
411
  on429: rotateSidecarProviderOn429,
387
- retryOn429Policy: rateLimitRetryPolicyFor(route.provider),
412
+ retryOn429Policy: route.providerName === "kiro" && isGenericOAuthFailoverEnabled(config, "kiro")
413
+ ? null : rateLimitRetryPolicyFor(route.provider),
388
414
  ...(options.onFirstOutput ? { onFirstOutput: options.onFirstOutput } : {}),
389
415
  ...(options.forceEmptyResponseId ? { forceEmptyResponseId: true } : {}),
390
416
  onCompletedResponse: (response, providerState) => {
417
+ const served = transportState.replayOAuthCredentialSnapshot;
418
+ if (route.providerName === "kiro" && response.status === "completed" && served
419
+ && noteKiroServedSuccess(served.accountId, served.generation)) persistKiroAccountState();
391
420
  commitReasoningReplayServingRoute();
392
421
  rememberKiroDeliveredFinalAnswer(transportState.adapter.name, response);
393
422
  rememberResponseState(
@@ -467,8 +496,12 @@ export async function executeResponsesSidecars(
467
496
  stallTimeoutSec: wsPlan.stallTimeoutSec,
468
497
  streamRoutedModelOutput: wsPlan.streamRoutedModelOutput,
469
498
  on429: rotateSidecarProviderOn429,
470
- retryOn429Policy: rateLimitRetryPolicyFor(route.provider),
499
+ retryOn429Policy: route.providerName === "kiro" && isGenericOAuthFailoverEnabled(config, "kiro")
500
+ ? null : rateLimitRetryPolicyFor(route.provider),
471
501
  onCompletedResponse: response => {
502
+ const served = transportState.replayOAuthCredentialSnapshot;
503
+ if (route.providerName === "kiro" && response.status === "completed" && served
504
+ && noteKiroServedSuccess(served.accountId, served.generation)) persistKiroAccountState();
472
505
  commitReasoningReplayServingRoute();
473
506
  notifyResponseComplete(response);
474
507
  },
@@ -184,6 +184,7 @@ function mergeUsage(first: OcxUsage | undefined, second: OcxUsage | undefined):
184
184
  const cacheReadInputTokens = sumOptional("cacheReadInputTokens");
185
185
  const cacheCreationInputTokens = sumOptional("cacheCreationInputTokens");
186
186
  const reasoningOutputTokens = sumOptional("reasoningOutputTokens");
187
+ const providerCredits = sumOptional("providerCredits");
187
188
  const inputTokens = first.inputTokens + second.inputTokens;
188
189
  const outputTokens = first.outputTokens + second.outputTokens;
189
190
  return {
@@ -194,6 +195,7 @@ function mergeUsage(first: OcxUsage | undefined, second: OcxUsage | undefined):
194
195
  ...(cacheReadInputTokens !== undefined ? { cacheReadInputTokens } : {}),
195
196
  ...(cacheCreationInputTokens !== undefined ? { cacheCreationInputTokens } : {}),
196
197
  ...(reasoningOutputTokens !== undefined ? { reasoningOutputTokens } : {}),
198
+ ...(providerCredits !== undefined ? { providerCredits } : {}),
197
199
  ...(first.estimated || second.estimated ? { estimated: true } : {}),
198
200
  };
199
201
  }
@@ -18,11 +18,12 @@ import type { NativeResponseControl } from "./native-response-control";
18
18
  import { compareBunVersions } from "../../lib/bun-stream-caps";
19
19
  import { resolveProxyRoute, socks5ProxyFromEnv } from "../../lib/proxy-env";
20
20
  import type { CodexWsQuotaObserver } from "./codex-ws-metadata";
21
- import { CODEX_RESPONSES_HTTP_URL, CODEX_RESPONSES_WS_URL, prepareCodexHttpInit, prepareCodexWsRequest } from "./codex-ws-request";
21
+ import { CODEX_RESPONSES_HTTP_URL, CODEX_RESPONSES_WS_URL, CODEX_WS_FRAME_HEADERS, prepareCodexHttpInit, prepareCodexWsRequest } from "./codex-ws-request";
22
22
  import { codexWsExchange } from "./codex-ws-exchange";
23
23
  import { CodexWsSession } from "./codex-ws-session";
24
24
  import { codexWsPool, codexWsReuseIdentity } from "./codex-ws-pool";
25
25
  import { codexWsCreateFrameExceedsLimit } from "./codex-ws-wire";
26
+ import { isLoopbackUrl, rewriteWebSocketDial } from "../../plugins/upstream-hooks";
26
27
  export { CODEX_WS_LIVENESS_PING_INTERVAL_MS, CODEX_WS_RESPONSE_PRELUDE_TIMEOUT_MS, MAX_CODEX_WS_FRAME_BYTES, MAX_CODEX_WS_QUEUE_BYTES,
27
28
  MAX_CODEX_WS_CREATE_FRAME_BYTES, CODEX_WS_CREATE_FRAME_LIMIT_BYTES, codexWsCreateFrameExceedsLimit,
28
29
  isCodexWsQuotaObservedResponse, isCodexWsUpstreamResponse } from "./codex-ws-wire";
@@ -81,6 +82,40 @@ export function bunSupportsBoundedCodexWsRelay(
81
82
  return comparison !== null && comparison >= 0;
82
83
  }
83
84
 
85
+ /**
86
+ * Apply plugin rewrites to one Codex WebSocket dial and settle its proxy. `proxy` was resolved
87
+ * for the canonical `wsUrl`. A loopback rewrite dials directly; any other rewrite gets its own
88
+ * route (scheme and `NO_PROXY` may differ). Null means the rewritten destination needs the SSE
89
+ * fallback, exactly as an unusable route for the canonical URL does.
90
+ */
91
+ export function planCodexWsDial(
92
+ wsUrl: string,
93
+ headers: Record<string, string>,
94
+ proxy: string | undefined,
95
+ env: Parameters<typeof resolveProxyRoute>[1] = process.env,
96
+ ): { url: string; headers: Record<string, string>; proxy: string | undefined } | null {
97
+ const rewritten = rewriteWebSocketDial(wsUrl, headers, proxy);
98
+ // Per-turn headers ride in each frame's client_metadata, which was prepared before the rewrite
99
+ // and is authoritative; a pooled socket's upgrade copy is intentionally ignored. A rewriter
100
+ // therefore cannot change them here, and the upgrade keeps the values the frame carries.
101
+ const dialHeaders = { ...rewritten.headers };
102
+ for (const name of CODEX_WS_FRAME_HEADERS) {
103
+ if (Object.hasOwn(headers, name)) dialHeaders[name] = headers[name]!;
104
+ else delete dialHeaders[name];
105
+ }
106
+ const dial = { ...rewritten, headers: dialHeaders };
107
+ if (dial.url === wsUrl || isLoopbackUrl(dial.url)) return dial;
108
+ let destination: URL;
109
+ try {
110
+ destination = new URL(dial.url);
111
+ } catch {
112
+ return null;
113
+ }
114
+ const route = resolveProxyRoute(destination, env);
115
+ if (route.kind === "fallback") return null;
116
+ return { ...dial, proxy: route.kind === "proxy" ? route.proxy : undefined };
117
+ }
118
+
84
119
  export function shouldUseCodexWsUpstream(
85
120
  url: string,
86
121
  init?: RequestInit,
@@ -176,9 +211,14 @@ export function codexWsUpstreamFetch(
176
211
  try {
177
212
  // Steering keeps a private physical connection across successor responses; it
178
213
  // must never enter the idle-socket pool or move to a different credential.
179
- const identity = control ? null : codexWsReuseIdentity(url, headers, frameText, proxy);
180
- session = (identity ? codexWsPool.acquire(identity, wsUrl, headers, proxy) : null)
181
- ?? new CodexWsSession(wsUrl, headers, false, undefined, proxy);
214
+ // Plugin rewrite runs per exchange, before the pool lookup. The dialled destination, its
215
+ // headers and its proxy are all part of the reuse identity, so a pooled socket is never
216
+ // reused for a different destination or with stale plugin headers.
217
+ const dial = planCodexWsDial(wsUrl, headers, proxy);
218
+ if (!dial) return sseFallback(url, init);
219
+ const identity = control ? null : codexWsReuseIdentity(url, dial.headers, frameText, dial.proxy, dial.url);
220
+ session = (identity ? codexWsPool.acquire(identity, dial.url, dial.headers, dial.proxy) : null)
221
+ ?? new CodexWsSession(dial.url, dial.headers, false, undefined, dial.proxy);
182
222
  if (!session.busy && !session.reserve()) {
183
223
  session.dispose();
184
224
  return sseFallback(url, init);