@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
@@ -19,8 +19,10 @@ import {
19
19
  readResponseStreamWithInactivity,
20
20
  ResponseBodyInactivityError,
21
21
  } from "../../lib/response-body-inactivity";
22
- import { resolveStallTimeoutSec } from "../../stall-timeout";
22
+ import { resolveStallTimeoutMs } from "../../stall-timeout";
23
23
  import { clientEncoderForDelivery, deliverClientEncodedResponse } from "../inference/client-encoder-delivery";
24
+ import { noteKiroServedSuccess } from "../../providers/kiro-usage";
25
+ import { persistKiroAccountState } from "../../providers/kiro-account-state-disk";
24
26
 
25
27
  /** One responsibility of the Responses request pipeline; state owners are explicit. */
26
28
  export async function deliverAdapterResponse(
@@ -33,7 +35,7 @@ export async function deliverAdapterResponse(
33
35
  | "rememberKiroDeliveredFinalAnswer"
34
36
  | "responseStateOptions"
35
37
  >,
36
- transportState: Pick<ResponsesTransport, "activeAdapter" | "bindKeyUsageFromBridge">,
38
+ transportState: Pick<ResponsesTransport, "activeAdapter" | "bindKeyUsageFromBridge" | "sentOAuthSnapshot">,
37
39
  sidecarState: Pick<ResponsesSidecarAuth, "routedCompaction">,
38
40
  responseEffects: Pick<
39
41
  ResponsesEffects,
@@ -43,7 +45,7 @@ export async function deliverAdapterResponse(
43
45
  | "notifyResponseComplete"
44
46
  >,
45
47
  completionPolicy: Pick<ResponsesCompletionPolicy, "emptyCompletionGuardEnabled">,
46
- adapterExchange: Pick<AdapterExchange, "upstreamResponse" | "upstream" | "cleanupUpstreamAbort">,
48
+ adapterExchange: Pick<AdapterExchange, "upstreamResponse" | "upstream" | "cleanupUpstreamAbort" | "localUpstream">,
47
49
  continuationState: Pick<AdapterContinuations, "terminalGuardEnabled" | "fetchTerminalGuardContinuation" | "fetchGuardedEmptyCompletionRetry">,
48
50
  ): Promise<Response> {
49
51
  const { logCtx, options, config } = requestContext;
@@ -54,7 +56,7 @@ export async function deliverAdapterResponse(
54
56
  rememberKiroDeliveredFinalAnswer,
55
57
  responseStateOptions,
56
58
  } = requestState;
57
- const { upstreamResponse, upstream, cleanupUpstreamAbort } = adapterExchange;
59
+ const { upstreamResponse, upstream, cleanupUpstreamAbort, localUpstream } = adapterExchange;
58
60
  const {
59
61
  terminalGuardEnabled,
60
62
  fetchTerminalGuardContinuation,
@@ -68,7 +70,7 @@ export async function deliverAdapterResponse(
68
70
  notifyResponseComplete,
69
71
  } = responseEffects;
70
72
  const { routedCompaction } = sidecarState;
71
- const bodyInactivityMs = resolveStallTimeoutSec(config.stallTimeoutSec) * 1000;
73
+ const bodyInactivityMs = resolveStallTimeoutMs(config.stallTimeoutSec, { localUpstream });
72
74
 
73
75
 
74
76
  if (parsed.stream) {
@@ -76,12 +78,15 @@ export async function deliverAdapterResponse(
76
78
  // same mapping or the bridge catch reports this upstream timeout as a 500 proxy_error.
77
79
  const initialEventStream = (async function* (): AsyncGenerator<AdapterEvent> {
78
80
  try {
79
- yield* readResponseStreamWithInactivity(
81
+ for await (const event of readResponseStreamWithInactivity(
80
82
  upstreamResponse,
81
83
  upstream.signal,
82
84
  bodyInactivityMs,
83
85
  response => transportState.activeAdapter.parseStream(response, translatorBudget, logCtx.activeTierMetadata),
84
- );
86
+ )) {
87
+ options.onCompactionRecoveryAdapterEvent?.(event);
88
+ yield event;
89
+ }
85
90
  } catch (error) {
86
91
  if (error instanceof ResponseBodyInactivityError) {
87
92
  yield {
@@ -118,6 +123,9 @@ export async function deliverAdapterResponse(
118
123
  // One completion owner for both deliveries: the bridge calls it from its terminal, the
119
124
  // direct client encoder from the fold of the same events.
120
125
  const onCompletedResponse = (response: Record<string, unknown>, providerState?: OcxProviderContinuationState) => {
126
+ const served = transportState.sentOAuthSnapshot;
127
+ if (transportState.activeAdapter.name === "kiro" && response.status === "completed" && served
128
+ && noteKiroServedSuccess(served.accountId, served.generation)) persistKiroAccountState();
121
129
  commitReasoningReplayServingRoute();
122
130
  rememberKiroDeliveredFinalAnswer(transportState.activeAdapter.name, response);
123
131
  // Compaction turns must NOT enter the continuation cache: _rawBody still holds the full
@@ -148,6 +156,7 @@ export async function deliverAdapterResponse(
148
156
  toolNsMap, declaredToolNames, toolParameterSchemas, freeformToolNames, toolSearchToolNames,
149
157
  },
150
158
  stallTimeoutSec: config.stallTimeoutSec,
159
+ localUpstream,
151
160
  turnAdmissionLease: options.turnAdmissionLease,
152
161
  ...(options.onFirstOutput ? { onFirstOutput: options.onFirstOutput } : {}),
153
162
  stopUpstream: () => { cancelResponseCompletion(); upstream.abort(); },
@@ -164,6 +173,7 @@ export async function deliverAdapterResponse(
164
173
  replayCacheScope: parsed._reasoningReplayScope,
165
174
  ...(options.forceEmptyResponseId ? { responseId: "" } : {}),
166
175
  stallTimeoutSec: config.stallTimeoutSec,
176
+ localUpstream,
167
177
  hideThinkingSummary: parsed.options.hideThinkingSummary,
168
178
  declaredToolNames,
169
179
  enforceDeclaredToolNames: options.inboundWire !== "chat" && options.inboundWire !== "anthropic",
@@ -195,6 +205,7 @@ export async function deliverAdapterResponse(
195
205
  bodyInactivityMs,
196
206
  response => transportState.activeAdapter.parseResponse!(response, translatorBudget, logCtx.activeTierMetadata),
197
207
  );
208
+ for (const event of initialEvents) options.onCompactionRecoveryAdapterEvent?.(event);
198
209
  let guardedEvents: AdapterEvent[];
199
210
  if (terminalGuardEnabled) {
200
211
  guardedEvents = [];
@@ -256,6 +267,9 @@ export async function deliverAdapterResponse(
256
267
  // #1926 gap 2: same buffered-path durability bound as the primary branch.
257
268
  await awaitThoughtSignatureDurability();
258
269
  if (adapterResponseReachedServingTerminal(events, json)) {
270
+ const served = transportState.sentOAuthSnapshot;
271
+ if (transportState.activeAdapter.name === "kiro" && json.status === "completed" && served
272
+ && noteKiroServedSuccess(served.accountId, served.generation)) persistKiroAccountState();
259
273
  commitReasoningReplayServingRoute();
260
274
  }
261
275
  notifyResponseComplete(json);
@@ -1,4 +1,5 @@
1
1
  import { isNonReplayableResponse } from "../../lib/upstream-retry";
2
+ import { isLocalUpstream } from "../../lib/local-upstream";
2
3
  import type { ResponsesRequestContext, ResponsesAdmissionState } from "./core-options";
3
4
  import type { PreparedResponsesRequest } from "./request-prepare";
4
5
  import type { ResponsesTransport } from "./request-transport";
@@ -18,7 +19,7 @@ import {
18
19
  import { clientCancelledResponse, readDisplaySafeErrorText, normalizeUpstreamErrorText } from "./core-errors";
19
20
  import { redactSecretString } from "../../lib/redact";
20
21
  import { rewriteUpstreamPolicyRefusal } from "./policy-refusal";
21
- import { waitForProviderRequestSlot } from "../../providers/request-pacing";
22
+ import { withProviderRequestSlot } from "../../providers/request-pacing";
22
23
  import { providerFetch, fetchWithHeaderTimeout, safeHostLabel } from "./fetch-helpers";
23
24
  import {
24
25
  transientRetryPolicyFor,
@@ -41,7 +42,12 @@ import { describeUpstreamConnectFailure } from "./upstream-error";
41
42
  import type { OpaqueBlobRecoveryGuard } from "./core-opaque-recovery";
42
43
  import type { AttemptRecoveryKind } from "../../usage/log";
43
44
  import type { OAuthAccessSnapshot } from "../../oauth";
44
- import { publicOAuthAuthenticationErrorMessage } from "../../oauth";
45
+ import { OAuthLoginRequiredError, publicOAuthAuthenticationErrorMessage } from "../../oauth";
46
+ import { tryKiroAlternateAfterTerminalRefresh } from "../../oauth/kiro-terminal-failover";
47
+ import { classifyKiroRefusal } from "../../adapters/kiro-refusal";
48
+ import { normalizeFinalKiroHttpError } from "../../adapters/kiro-retry";
49
+ import { noteKiroMonthlyRefusal } from "../../providers/kiro-usage";
50
+ import { persistKiroAccountState } from "../../providers/kiro-account-state-disk";
45
51
  import { isXaiResponsesDestination, resolveProviderTransport } from "../../providers/xai-transport";
46
52
  import { resolveCopilotApiBaseUrl } from "../../oauth/github-copilot";
47
53
  import { resolveWireProtocolOverride } from "../adapter-resolve";
@@ -53,9 +59,10 @@ import {
53
59
  formatAnthropicProviderForLog,
54
60
  } from "../../oauth/anthropic-routing";
55
61
  import {
56
- GENERIC_OAUTH_MAX_FAILOVERS_PER_REQUEST,
57
62
  isGenericOAuthFailoverEnabled,
58
63
  rotateGenericOAuthAccountOn429,
64
+ rotateGenericOAuthAccountOnRefusal,
65
+ quarantineKiroSuspendedAccount,
59
66
  failoverAccountSnapshot,
60
67
  } from "../../oauth/generic-account-failover";
61
68
  import {
@@ -123,6 +130,7 @@ export async function prepareAdapterExchange(
123
130
  | "commitResolvedOAuthSelection"
124
131
  | "genericFailoverAccountId"
125
132
  | "genericFailovers"
133
+ | "genericFailoverLimit"
126
134
  | "applyFailoverSnapshot"
127
135
  | "noteRoutedAttemptSend"
128
136
  >,
@@ -184,6 +192,10 @@ export async function prepareAdapterExchange(
184
192
  const stallTimeoutMs = typeof config.stallTimeoutSec === "number" && Number.isFinite(config.stallTimeoutSec) && config.stallTimeoutSec > 0
185
193
  ? Math.floor(config.stallTimeoutSec * 1000)
186
194
  : 300_000;
195
+ // Where this exchange dials. Local upstreams (loopback / private / `.local` / `.lan`) are
196
+ // operator-trusted and often CPU-bound, so an unset stall budget resolves to disabled for them.
197
+ // Key/OAuth rotation re-resolves credentials, never the origin, so the routed baseUrl is stable.
198
+ const localUpstream = isLocalUpstream(route.provider.baseUrl);
187
199
  transportState.activeAdapter = transportState.adapter;
188
200
 
189
201
  // One immutable, body-safe outbound request per same-target sequence (URL, serialized body,
@@ -291,21 +303,23 @@ export async function prepareAdapterExchange(
291
303
  try {
292
304
  if (transportState.activeAdapter.fetchResponse) {
293
305
  transportState.noteRoutedAttemptSend(inputTokenEstimate);
294
- await waitForProviderRequestSlot(route.providerName, route.provider, route.modelId, upstream.signal);
295
- upstreamResponse = await transportState.activeAdapter.fetchResponse(builtInitialRequest, {
296
- abortSignal: upstream.signal,
297
- timeoutMs: connectMs,
298
- sendBudget: adapterDispatchBudget,
299
- onPhysicalSend: send => noteAdapterPhysicalSend(inputTokenEstimate, send),
300
- onRecoveryWithheld: noteAdapterRecoveryWithheld,
301
- stream: parsed.stream,
302
- executor: providerFetch(route.provider, options.codexWsRuntimeIdentity, {
303
- pacingSlotAcquired: true,
304
- dispatchOverride: oauthDispatch(builtInitialRequest),
305
- providerName: route.providerName,
306
- modelId: route.modelId,
307
- }),
308
- });
306
+ upstreamResponse = await withProviderRequestSlot(route.providerName, route.provider, route.modelId, upstream.signal, pacingSlot =>
307
+ transportState.activeAdapter.fetchResponse!(builtInitialRequest, {
308
+ kiroPreferAccountFailover: route.providerName === "kiro" && isGenericOAuthFailoverEnabled(config, "kiro"),
309
+ abortSignal: upstream.signal,
310
+ timeoutMs: connectMs,
311
+ sendBudget: adapterDispatchBudget,
312
+ onPhysicalSend: send => noteAdapterPhysicalSend(inputTokenEstimate, send),
313
+ onRecoveryWithheld: noteAdapterRecoveryWithheld,
314
+ stream: parsed.stream,
315
+ executor: providerFetch(route.provider, options.codexWsRuntimeIdentity, {
316
+ pacingSlotAcquired: true,
317
+ pacingSlot,
318
+ dispatchOverride: oauthDispatch(builtInitialRequest),
319
+ providerName: route.providerName,
320
+ modelId: route.modelId,
321
+ }),
322
+ }));
309
323
  } else {
310
324
  // #1851 scope guard: transient-5xx retry on this generic adapter path is opt-in for
311
325
  // direct Google AI Studio only (Vertex/Antigravity use fetchResponse above). Other
@@ -315,11 +329,18 @@ export async function prepareAdapterExchange(
315
329
  // legacy direct-Google exception is preserved exactly; every other adapter still keeps
316
330
  // reset-only semantics so combo failover hops on the first 5xx.
317
331
  const transientPolicy = transientRetryPolicyFor(route.provider);
332
+ const compactPrepaid = options.compactionRecoveryAttempted ? sendBudgetState.pendingHopPermit : undefined;
333
+ if (compactPrepaid) sendBudgetState.pendingHopPermit = undefined;
334
+ let compactPrepaidUsed = false;
318
335
  const fetchWithRetryPolicy = (route.provider.adapter === "google" || transientPolicy)
319
336
  ? fetchWithTransientRetry
320
337
  : fetchWithResetRetry;
321
338
  upstreamResponse = await fetchWithRetryPolicy(
322
339
  recovery => {
340
+ if (compactPrepaid && !compactPrepaidUsed) {
341
+ if (!compactPrepaid.use()) throw new SendBudgetExhaustedError(safeHostLabel(builtInitialRequest.url));
342
+ compactPrepaidUsed = true;
343
+ }
323
344
  transportState.noteRoutedAttemptSend(inputTokenEstimate, recovery);
324
345
  return fetchWithHeaderTimeout(builtInitialRequest.url, applyUpstreamRecoveryInit({
325
346
  method: builtInitialRequest.method,
@@ -335,12 +356,15 @@ export async function prepareAdapterExchange(
335
356
  {
336
357
  abortSignal: upstream.signal,
337
358
  label: safeHostLabel(builtInitialRequest.url),
338
- ...(transientPolicy
359
+ ...(transientPolicy || compactPrepaid
339
360
  // Draws the remainder, not the raw policy. A combo child inherits the parent's
340
361
  // holder but used to take a fresh full allowance on its own first send, so the
341
362
  // shared counter was inherited without ever being read as a limit.
342
363
  ? {
343
- attempts: remainingTransientSendBudget(transientPolicy.attempts),
364
+ // The first emergency send is already paid for. Only retries consume the
365
+ // remaining allowance; treating the booking as unavailable blocks a cap of two.
366
+ attempts: Math.min(transientPolicy?.attempts ?? 1,
367
+ remainingTransientSendBudget(transientPolicy?.attempts ?? 1) + (compactPrepaid ? 1 : 0)),
344
368
  onSendsConsumed: noteTransientSends,
345
369
  }
346
370
  : {}),
@@ -405,7 +429,9 @@ export async function prepareAdapterExchange(
405
429
  * a permit confirmed earlier would keep the charge for a send that never happened.
406
430
  */
407
431
  onDispatch?: () => void,
432
+ preserveFailureResponse?: Response,
408
433
  ): Promise<Response | { failed: Response }> => {
434
+ let replacementAdmitted = false;
409
435
  // The repair permit books the request ledger, but only the transient helper reports
410
436
  // that send to the root workflow. The other two dispatch paths confirm it here, at
411
437
  // their physical-send boundary, without booking the request ledger again.
@@ -430,6 +456,7 @@ export async function prepareAdapterExchange(
430
456
  recordAdapterReasoning(logCtx, retryRequest);
431
457
  recordAdapterTier(logCtx, retryRequest);
432
458
  } catch (err) {
459
+ if (preserveFailureResponse && !options.abortSignal?.aborted) return { failed: preserveFailureResponse };
433
460
  // A rotated/rebuilt adapter build failure is a request-shaping error, not an
434
461
  // upstream connect failure: tear the abort link down and map it as 400 (no 413
435
462
  // translator-budget mapping here — that stays with parseRequest/buildToolBridgeMaps).
@@ -455,28 +482,32 @@ export async function prepareAdapterExchange(
455
482
  try {
456
483
  if (transportState.activeAdapter.fetchResponse) {
457
484
  transportState.noteRoutedAttemptSend(retryEstimate, recovery);
458
- await waitForProviderRequestSlot(route.providerName, route.provider, route.modelId, upstream.signal);
459
- // The dispatch boundary is HERE, not before the pacing wait: that wait can reject for
460
- // an abort, a saturated queue, an expired slot or a removed provider, and none of
461
- // those reach the wire. Confirming earlier would hold the charge for a send that the
462
- // pacer refused.
463
- onDispatch?.();
464
- return await transportState.activeAdapter.fetchResponse(retryRequest, {
465
- abortSignal: upstream.signal,
466
- timeoutMs: connectMs,
467
- sendBudget: adapterDispatchBudget,
468
- onPhysicalSend: send => {
469
- noteAdapterPhysicalSend(retryEstimate, send);
470
- chargeFastDowngradeWorkflowSend();
471
- },
472
- onRecoveryWithheld: noteAdapterRecoveryWithheld,
473
- stream: parsed.stream,
474
- executor: providerFetch(route.provider, options.codexWsRuntimeIdentity, {
475
- pacingSlotAcquired: true,
476
- dispatchOverride: oauthDispatch(retryRequest),
477
- providerName: route.providerName,
478
- modelId: route.modelId,
479
- }),
485
+ return await withProviderRequestSlot(route.providerName, route.provider, route.modelId, upstream.signal, pacingSlot => {
486
+ // The dispatch boundary is HERE, not before the pacing wait: that wait can reject for
487
+ // an abort, a saturated queue, an expired slot or a removed provider, and none of
488
+ // those reach the wire. Confirming earlier would hold the charge for a send that the
489
+ // pacer refused.
490
+ onDispatch?.();
491
+ return transportState.activeAdapter.fetchResponse!(retryRequest, {
492
+ kiroPreferAccountFailover: route.providerName === "kiro" && isGenericOAuthFailoverEnabled(config, "kiro"),
493
+ abortSignal: upstream.signal,
494
+ timeoutMs: connectMs,
495
+ sendBudget: adapterDispatchBudget,
496
+ onPhysicalSend: send => {
497
+ if (preserveFailureResponse) replacementAdmitted = true;
498
+ noteAdapterPhysicalSend(retryEstimate, send);
499
+ chargeFastDowngradeWorkflowSend();
500
+ },
501
+ onRecoveryWithheld: noteAdapterRecoveryWithheld,
502
+ stream: parsed.stream,
503
+ executor: providerFetch(route.provider, options.codexWsRuntimeIdentity, {
504
+ pacingSlotAcquired: true,
505
+ pacingSlot,
506
+ dispatchOverride: oauthDispatch(retryRequest),
507
+ providerName: route.providerName,
508
+ modelId: route.modelId,
509
+ }),
510
+ });
480
511
  });
481
512
  }
482
513
  // #2643 review: this leg used to call fetchWithHeaderTimeout directly, so an
@@ -509,6 +540,7 @@ export async function prepareAdapterExchange(
509
540
  // Same boundary on the helper path: the thunk is what reaches the wire, and it
510
541
  // can be refused above before it does. use() past the first attempt is a no-op.
511
542
  onDispatch?.();
543
+ if (preserveFailureResponse) replacementAdmitted = true;
512
544
  if (!refetchTransientPolicy) chargeFastDowngradeWorkflowSend();
513
545
  return fetchWithHeaderTimeout(retryRequest.url,
514
546
  applyUpstreamRecoveryInit({
@@ -540,6 +572,8 @@ export async function prepareAdapterExchange(
540
572
  retryRequest.releaseBodyObservation?.();
541
573
  }
542
574
  } catch (err) {
575
+ if (preserveFailureResponse && !replacementAdmitted && !options.abortSignal?.aborted)
576
+ return { failed: preserveFailureResponse };
543
577
  cleanupUpstreamAbort();
544
578
  upstream.abort();
545
579
  if (options.abortSignal?.aborted) {
@@ -575,6 +609,46 @@ export async function prepareAdapterExchange(
575
609
  try {
576
610
  refreshed = await refreshResolvedOAuthSelection(transportState.sentOAuthSnapshot);
577
611
  } catch (err) {
612
+ const failed = transportState.sentOAuthSnapshot;
613
+ if (route.providerName === "kiro" && err instanceof OAuthLoginRequiredError && failed
614
+ && transportState.genericFailovers < transportState.genericFailoverLimit) {
615
+ const alternate = await tryKiroAlternateAfterTerminalRefresh(config, failed.accountId, failed.generation);
616
+ if (alternate) {
617
+ const adapterOwnsDispatch = transportState.activeAdapter.fetchResponse !== undefined;
618
+ const hop = reserveCredentialHop("auth-recovery",
619
+ `${route.providerName}|${route.modelId}|terminal-refresh-account`,
620
+ !adapterOwnsDispatch && transientRetryPolicyFor(route.provider) !== null);
621
+ if (hop.allowed) {
622
+ try {
623
+ const admitted = await applyFailoverSnapshot(alternate);
624
+ if (admitted?.accountId === alternate.accountId) {
625
+ transportState.genericFailovers += 1;
626
+ invalidateSameTargetRequest();
627
+ transportState.activeAdapter = resolveSelectionAdapter(
628
+ resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire, route.staticPolicy),
629
+ config.cacheRetention);
630
+ bindRouteReasoningReplayScope({
631
+ parsed, providerName: route.providerName, provider: route.provider,
632
+ adapterName: transportState.activeAdapter.name,
633
+ oauthCredentialSnapshot: transportState.replayOAuthCredentialSnapshot,
634
+ });
635
+ sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider,
636
+ transportState.activeAdapter.name, logCtx.accountLogLabel);
637
+ recordAttemptCredentialSource(logCtx.activeAttempt, route.providerName,
638
+ route.provider, transportState.activeAdapter.name);
639
+ sendBudgetState.pendingHopPermit = hop.permit;
640
+ const result = await rebuildAndRefetch("oauth-account-429", () => {
641
+ if (!adapterOwnsDispatch) hop.permit?.use();
642
+ });
643
+ if ("failed" in result) return result.failed;
644
+ upstreamResponse = result;
645
+ continue recovery;
646
+ }
647
+ } catch { /* Keep the original public authentication error. */ }
648
+ finally { sendBudgetState.pendingHopPermit = undefined; hop.permit?.release(); }
649
+ }
650
+ }
651
+ }
578
652
  cleanupUpstreamAbort();
579
653
  return formatErrorResponse(401, "authentication_error", publicOAuthAuthenticationErrorMessage(err));
580
654
  }
@@ -840,18 +914,123 @@ export async function prepareAdapterExchange(
840
914
  break;
841
915
  }
842
916
  }
843
- // Generic OAuth account failover (#2568) for providers with no pool of their own.
844
- // Presence is consent since #2568d: rotation is ON by default once two or more eligible
845
- // accounts are stored for the provider, because a second deliberate login is read as the
846
- // operator asking for it. A single-account install is still a strict no-op, and an
847
- // explicit `oauthAccountFailover.enabled: false` (global or per provider) still wins --
848
- // see isGenericOAuthFailoverEnabled in src/oauth/generic-account-failover.ts. Codex and
849
- // Anthropic are excluded by isGenericFailoverProvider: their pools own quota scopes,
850
- // probe leases and affinity that this must not reimplement.
917
+ // Generic OAuth account failover (#2568) rotates reactively after a refusal when
918
+ // two accounts are stored. Kiro additionally classifies bounded 400/403 refusals;
919
+ // all other providers retain the original 429 loop below.
920
+ if (route.providerName === "kiro") {
921
+ while (
922
+ (upstreamResponse.status === 429 || upstreamResponse.status === 400 || upstreamResponse.status === 403)
923
+ && transportState.genericFailoverAccountId
924
+ ) {
925
+ const refusal = classifyKiroRefusal(upstreamResponse.status,
926
+ await readDisplaySafeErrorText(upstreamResponse.clone(), upstream.signal, ""));
927
+ if (refusal.kind === "other") break;
928
+ const sent = transportState.sentOAuthSnapshot;
929
+ const monthlyCooldownMs = refusal.kind === "monthly_quota" && sent
930
+ ? noteKiroMonthlyRefusal(sent.accountId, sent.generation, Date.now()) : undefined;
931
+ if (monthlyCooldownMs !== undefined) persistKiroAccountState();
932
+ if (refusal.kind === "suspended")
933
+ quarantineKiroSuspendedAccount(transportState.genericFailoverAccountId, sent?.generation);
934
+ if (transportState.genericFailovers >= transportState.genericFailoverLimit
935
+ || !isGenericOAuthFailoverEnabled(config, "kiro")) break;
936
+ // Intersection with the shared request budget. This arm re-sends through
937
+ // rebuildAndRefetch, so the roster cap alone would let one request walk the roster on
938
+ // an allowance the rest of the request cannot see. A refusal ends the ladder with the
939
+ // original HTTP response already in hand.
940
+ //
941
+ // Who settles this reservation depends on who dispatches the replay (#4709). An
942
+ // adapter that owns its ladder -- Kiro's reset loop, Cursor's transport loop --
943
+ // reserves once per physical send and would charge the same replay again; the helper
944
+ // path reports it again through `onSendsConsumed`. Both turned one physical send into
945
+ // two charges, and once the allowance was spent, into a synthetic error in place of
946
+ // the refusal this hop was recovering from. The wire protocol is resolved from the
947
+ // provider and model, not from the account, so an account rotation cannot move the
948
+ // replay between these two shapes.
949
+ const adapterOwnsDispatch = transportState.activeAdapter.fetchResponse !== undefined;
950
+ const hop = reserveCredentialHop(
951
+ "auth-recovery",
952
+ `${route.providerName}|${route.modelId}|adapter-recovery-oauth-429`,
953
+ // Only a helper-routed replay reports this send back. A reset-only refetch reports
954
+ // nothing and an adapter ladder settles the booking itself, so promising an external
955
+ // report on either would leave a booking pending until it swallowed a later charge.
956
+ !adapterOwnsDispatch && transientRetryPolicyFor(route.provider) !== null,
957
+ );
958
+ if (!hop.allowed) break;
959
+ const nextAccountId = rotateGenericOAuthAccountOnRefusal(
960
+ config,
961
+ route.providerName,
962
+ transportState.genericFailoverAccountId,
963
+ refusal.kind,
964
+ upstreamResponse.headers.get("retry-after"),
965
+ Date.now(),
966
+ route.modelId,
967
+ monthlyCooldownMs,
968
+ );
969
+ if (!nextAccountId) {
970
+ hop.permit?.release();
971
+ break;
972
+ }
973
+ try {
974
+ // The FULL snapshot, not just the bearer: Antigravity pairs an account-matched
975
+ // projectId with its token and Kiro carries routing metadata, so a token-only swap
976
+ // would mix one account's credential with another's routing data.
977
+ const snapshot = await failoverAccountSnapshot(route.providerName, nextAccountId);
978
+ if (!await applyFailoverSnapshot(snapshot)) {
979
+ hop.permit?.release();
980
+ break;
981
+ }
982
+ transportState.genericFailovers += 1;
983
+ invalidateSameTargetRequest();
984
+ transportState.activeAdapter = resolveSelectionAdapter(
985
+ resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire, route.staticPolicy),
986
+ config.cacheRetention,
987
+ );
988
+ sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, transportState.activeAdapter.name, logCtx.accountLogLabel);
989
+ recordAttemptCredentialSource(logCtx.activeAttempt, route.providerName, route.provider, transportState.activeAdapter.name);
990
+ // The replay IS this hop's send, so hand the reservation down and let the layer that
991
+ // dispatches settle it: `adapterDispatchBudget` spends it on the adapter's first
992
+ // reservation, and the retry helper's reporter settles the external booking.
993
+ sendBudgetState.pendingHopPermit = hop.permit;
994
+ let result: Response | { failed: Response };
995
+ try {
996
+ // Confirm at the dispatch boundary, not here: a rebuild can fail while shaping the
997
+ // request and return `{ failed }` without reaching the wire, and a permit confirmed
998
+ // before that would hold the charge for a send that never happened. An
999
+ // adapter-owned ladder is the exception -- its own reservation is the confirmation,
1000
+ // and settling here first would hand it a dead permit, which it reads as an
1001
+ // exhausted request and stops sending on.
1002
+ result = await rebuildAndRefetch("oauth-account-429", () => {
1003
+ if (!adapterOwnsDispatch) hop.permit?.use();
1004
+ }, upstreamResponse);
1005
+ } finally {
1006
+ sendBudgetState.pendingHopPermit = undefined;
1007
+ }
1008
+ if ("failed" in result) {
1009
+ // A no-op if the boundary was reached; a refund if the rebuild died before it.
1010
+ hop.permit?.release();
1011
+ if (result.failed !== upstreamResponse) return result.failed;
1012
+ break;
1013
+ }
1014
+ try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ }
1015
+ upstreamResponse = result;
1016
+ // The hop's permit is already settled by the dispatch boundary above; continuing
1017
+ // only skips the remaining arms, it does not abandon a reservation.
1018
+ if (isNonReplayableResponse(upstreamResponse)) continue recovery;
1019
+ } catch {
1020
+ // A throw before the send — snapshot fetch, credential application, adapter
1021
+ // resolution — must hand the reservation back. Without this the ladder charges the
1022
+ // request for a send it never made, and a later recovery in the same request is
1023
+ // refused on an allowance nothing spent. release() is idempotent and a no-op once
1024
+ // used, so a throw from the rebuild keeps its charge.
1025
+ hop.permit?.release();
1026
+ break;
1027
+ }
1028
+ }
1029
+ } else {
851
1030
  while (
852
1031
  upstreamResponse.status === 429
853
1032
  && transportState.genericFailoverAccountId
854
- && transportState.genericFailovers < GENERIC_OAUTH_MAX_FAILOVERS_PER_REQUEST
1033
+ && transportState.genericFailovers < transportState.genericFailoverLimit
855
1034
  && isGenericOAuthFailoverEnabled(config, route.providerName)
856
1035
  ) {
857
1036
  // Intersection with the shared request budget. This arm re-sends through
@@ -944,6 +1123,7 @@ export async function prepareAdapterExchange(
944
1123
  break;
945
1124
  }
946
1125
  }
1126
+ }
947
1127
  // Unknown provenance is deliberately fail-soft in pre-flight: after a restart, TTL expiry,
948
1128
  // or LRU eviction, a valid same-backend blob must survive. A decoder's own 4xx identity is
949
1129
  // the missing authoritative signal. Rebuild once through the same sanitation path used by a
@@ -1042,6 +1222,8 @@ export async function prepareAdapterExchange(
1042
1222
  break;
1043
1223
  }
1044
1224
  if (!upstreamResponse.ok) {
1225
+ if (route.providerName === "kiro")
1226
+ upstreamResponse = await normalizeFinalKiroHttpError(upstreamResponse, upstream.signal);
1045
1227
  if (options.comboAttempt) {
1046
1228
  // No pre-read guard: `consumeComboFailure` -> `readBoundedResponseBody` reads
1047
1229
  // `response.body` itself with the abort signal threaded through, and the combo
@@ -1097,6 +1279,11 @@ export async function prepareAdapterExchange(
1097
1279
  // material before it reaches the client-facing error surface.
1098
1280
  const upstreamRetryAfter = upstreamResponse.headers.get("retry-after");
1099
1281
  const normalized = normalizeUpstreamErrorText(errorText, "unknown error");
1282
+ options.onCompactionRecoveryAdapterEvent?.({
1283
+ type: "error", status: upstreamResponse.status,
1284
+ errorType: normalized.type, code: normalized.code,
1285
+ message: "Structured upstream failure observed before client formatting",
1286
+ });
1100
1287
  const message = normalized.cyberPolicy
1101
1288
  ? normalized.message
1102
1289
  ?? (isCyberPolicyCode(normalized.code) ? CYBER_POLICY_FALLBACK_MESSAGE : normalized.safeText)
@@ -1141,6 +1328,7 @@ export async function prepareAdapterExchange(
1141
1328
  cleanupUpstreamAbort,
1142
1329
  connectMs,
1143
1330
  stallTimeoutMs,
1331
+ localUpstream,
1144
1332
  upstreamResponse,
1145
1333
  rateLimitPolicy,
1146
1334
  get rateLimitRetries(): typeof rateLimitRetries {
@@ -1,13 +1,13 @@
1
1
  import { createHmac, randomBytes } from "node:crypto";
2
2
  import { registerOptionalShutdownHook } from "../../lib/optional-shutdown-hooks";
3
- import { CODEX_RESPONSES_HTTP_URL } from "./codex-ws-request";
3
+ import { CODEX_RESPONSES_HTTP_URL, CODEX_WS_FRAME_HEADERS } from "./codex-ws-request";
4
4
  import { CODEX_WS_ID_MAX_BYTES } from "./codex-ws-correlation";
5
5
  import { CodexWsSession } from "./codex-ws-session";
6
6
 
7
7
  export const CODEX_WS_POOL_MAX_SESSIONS = 32;
8
8
  export const CODEX_WS_POOL_IDLE_MS = 30_000;
9
9
  export const CODEX_WS_POOL_MAX_AGE_MS = 5 * 60_000;
10
- const MUTABLE_HEADERS = new Set(["x-codex-turn-state", "x-codex-turn-metadata"]);
10
+ const MUTABLE_HEADERS = new Set<string>(CODEX_WS_FRAME_HEADERS);
11
11
  let processKey: Buffer | undefined;
12
12
  let poolSequence = 0;
13
13
 
@@ -25,7 +25,7 @@ function digest(input: unknown): string {
25
25
  }
26
26
 
27
27
  /** Identity comes from the selected outgoing request, never a model label or caller hint. */
28
- export function codexWsReuseIdentity(url: string, headers: Record<string, string>, frameText: string, proxy?: string): CodexWsReuseIdentity | null {
28
+ export function codexWsReuseIdentity(url: string, headers: Record<string, string>, frameText: string, proxy?: string, dialUrl?: string): CodexWsReuseIdentity | null {
29
29
  if (url !== CODEX_RESPONSES_HTTP_URL) return null;
30
30
  let body: unknown;
31
31
  try { body = JSON.parse(frameText); } catch { return null; }
@@ -52,7 +52,8 @@ export function codexWsReuseIdentity(url: string, headers: Record<string, string
52
52
  const scope = digest([url, account, thread, turn]);
53
53
  const lite = metadata.ws_request_header_x_openai_internal_codex_responses_lite;
54
54
  if (lite !== undefined && lite !== "true" && lite !== "false") return null;
55
- return { scope, key: digest([scope, authorization, body.model, body.service_tier ?? null, lite ?? null, immutable, proxy ?? null]) };
55
+ // `dialUrl` is the socket destination after plugin rewrites; absent means the canonical one.
56
+ return { scope, key: digest([scope, authorization, body.model, body.service_tier ?? null, lite ?? null, immutable, proxy ?? null, dialUrl ?? null]) };
56
57
  }
57
58
 
58
59
  interface Entry { identity: CodexWsReuseIdentity; session: CodexWsSession; createdAt: number; idleAt: number; retired: boolean }
@@ -4,6 +4,9 @@ import {
4
4
  CODEX_RESPONSES_LITE_METADATA_KEY,
5
5
  } from "../../codex/forward-transport-headers";
6
6
 
7
+ /** Per-turn headers the WebSocket carries in `client_metadata` of each frame, not in the upgrade. */
8
+ export const CODEX_WS_FRAME_HEADERS = ["x-codex-turn-state", "x-codex-turn-metadata"] as const;
9
+
7
10
  export const CODEX_RESPONSES_HTTP_URL = "https://chatgpt.com/backend-api/codex/responses";
8
11
  export const CODEX_RESPONSES_WS_URL = "wss://chatgpt.com/backend-api/codex/responses";
9
12
  export const WS_BETA = "responses_websockets=2026-02-06";
@@ -32,7 +35,7 @@ function applyLiteMetadata(body: Record<string, unknown>, headers: Headers): boo
32
35
  body.client_metadata = { ...(metadata as Record<string, string> | undefined),
33
36
  [CODEX_RESPONSES_LITE_METADATA_KEY]: lite };
34
37
  }
35
- for (const name of ["x-codex-turn-state", "x-codex-turn-metadata"]) {
38
+ for (const name of CODEX_WS_FRAME_HEADERS) {
36
39
  const value = headers.get(name);
37
40
  const current = body.client_metadata as Record<string, string> | undefined;
38
41
  if (value !== null && !Object.hasOwn(current ?? {}, name)) {
@@ -154,7 +154,8 @@ import { codexAccountSelectionForTurn, registerTurn, trackStreamLifetime, unregi
154
154
  import type { AdmissionLease } from "../../lib/admission";
155
155
  import { redactSecretString } from "../../lib/redact";
156
156
  import { readBoundedResponseBytes } from "../../lib/bounded-body";
157
- import { resolveStallTimeoutSec } from "../../stall-timeout";
157
+ import { resolveStallTimeoutMs } from "../../stall-timeout";
158
+ import { isLocalUpstream } from "../../lib/local-upstream";
158
159
  import { isRateLimitOrQuotaFailureMessage } from "../../lib/errors";
159
160
  import { supportedLadderFor } from "../effort-policy";
160
161
  import {
@@ -562,6 +563,7 @@ export async function bufferCompactResponse(
562
563
  upstream: Response,
563
564
  signal: AbortSignal,
564
565
  stallTimeoutSec?: number,
566
+ localUpstream?: boolean,
565
567
  ): Promise<Response> {
566
568
  const headers = compactResponseHeaders(upstream);
567
569
  try {
@@ -581,7 +583,9 @@ export async function bufferCompactResponse(
581
583
  const result = await readBoundedResponseBytes(upstream, {
582
584
  signal,
583
585
  maxBytes: COMPACT_RESPONSE_MAX_BYTES,
584
- inactivityTimeoutMs: resolveStallTimeoutSec(stallTimeoutSec) * 1_000,
586
+ // Compaction buffers the complete body while holding an active-turn lease. Keep its
587
+ // default bounded even for local destinations so silent bodies cannot exhaust that gate.
588
+ inactivityTimeoutMs: resolveStallTimeoutMs(stallTimeoutSec),
585
589
  });
586
590
  if (signal.aborted) return formatErrorResponse(499, "client_cancelled", "Client cancelled compact request");
587
591
  if (result.oversized) return compactResponseTooLargeError();
@@ -1332,7 +1336,7 @@ export async function handleResponsesCompact(
1332
1336
  upstream.headers.get("x-codex-secondary-reset-at"),
1333
1337
  upstream.headers.get("x-codex-tertiary-reset-at"),
1334
1338
  ].filter(Boolean);
1335
- const buffered = await bufferCompactResponse(upstream, req.signal, config.stallTimeoutSec);
1339
+ const buffered = await bufferCompactResponse(upstream, req.signal, config.stallTimeoutSec, isLocalUpstream(compactUrl));
1336
1340
  const bufferedErrorText = buffered.ok
1337
1341
  ? ""
1338
1342
  : await buffered.clone().text().catch(() => "");
@@ -1431,7 +1435,7 @@ export async function handleResponsesCompact(
1431
1435
  // The routed compaction turn is a handoff inside the same logical request, so it draws the
1432
1436
  // REMAINDER. Minting here is what let a native attempt spend three sends and the routed
1433
1437
  // fallback spend four more.
1434
- const response = await handleResponses(internalReq, config, logCtx, { abortSignal: req.signal, turnAdmissionLease, sendBudget, compactionRoutingOverride: options.compactionRoutingOverride, ...(admission ? { admission } : {}) });
1438
+ const response = await handleResponses(internalReq, config, logCtx, { abortSignal: req.signal, turnAdmissionLease, sendBudget, compactionRecoveryKind: "compaction-v1", compactionRoutingOverride: options.compactionRoutingOverride, ...(admission ? { admission } : {}) });
1435
1439
  if (!response.ok) return response;
1436
1440
  let json: { output?: unknown[]; status?: unknown; error?: unknown };
1437
1441
  if (response.headers.get("content-type")?.includes("text/event-stream")) {