@bitkyc08/opencodex 2.84.0 → 2.86.0

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 (246) hide show
  1. package/README.md +0 -4
  2. package/bin/ocx.mjs +61 -3
  3. package/gui/dist/assets/App-BjlttVLy.js +58 -0
  4. package/gui/dist/assets/{Tray-C9TGtwad.js → Tray-DyR9yBee.js} +1 -1
  5. package/gui/dist/assets/index-BMssP4k3.js +94 -0
  6. package/gui/dist/assets/quota-summary-D8M1bc_R.js +1 -0
  7. package/gui/dist/index.html +1 -1
  8. package/package.json +1 -2
  9. package/src/adapters/anthropic/beta-allowlist.ts +87 -8
  10. package/src/adapters/anthropic/client-identity.ts +1 -1
  11. package/src/adapters/anthropic/passthrough.ts +33 -4
  12. package/src/adapters/azure.ts +19 -1
  13. package/src/adapters/client-fingerprint.ts +24 -0
  14. package/src/adapters/coding-agent/turn.ts +22 -9
  15. package/src/adapters/cursor/arg-codec.ts +17 -5
  16. package/src/adapters/cursor/catalog.ts +8 -0
  17. package/src/adapters/cursor/effort-map.ts +3 -0
  18. package/src/adapters/cursor/freeform-args.ts +75 -0
  19. package/src/adapters/cursor/protobuf-events.ts +36 -23
  20. package/src/adapters/google-antigravity-wire.ts +22 -1
  21. package/src/adapters/google.ts +2 -2
  22. package/src/adapters/openai-chat/response-events.ts +59 -1
  23. package/src/adapters/openai-chat.ts +8 -5
  24. package/src/adapters/qoder/adapter.ts +40 -1
  25. package/src/advisor/consult.ts +220 -0
  26. package/src/advisor/context.ts +226 -0
  27. package/src/advisor/disclosure.ts +20 -0
  28. package/src/advisor/runtime.ts +305 -0
  29. package/src/advisor/settings.ts +135 -0
  30. package/src/advisor/state.ts +369 -0
  31. package/src/advisor/synthetic-tool.ts +33 -0
  32. package/src/bridge/errors.ts +5 -0
  33. package/src/chat/inbound.ts +30 -6
  34. package/src/chat/outbound.ts +5 -0
  35. package/src/claude/desktop-3p-library.ts +43 -3
  36. package/src/claude/desktop-3p.ts +22 -8
  37. package/src/claude/desktop-picker-profile.ts +11 -4
  38. package/src/claude/desktop-profile.ts +10 -1
  39. package/src/claude/desktop-remote-store-io.ts +5 -3
  40. package/src/claude/inbound.ts +121 -50
  41. package/src/claude/intercept/local-ca.ts +8 -1
  42. package/src/claude/model-default-settings.ts +154 -0
  43. package/src/claude/model-info.ts +7 -7
  44. package/src/claude/outbound.ts +13 -6
  45. package/src/cli/advisor.ts +151 -0
  46. package/src/cli/auxiliary-listener-preflight.ts +37 -0
  47. package/src/cli/capabilities-agents-routing.ts +18 -0
  48. package/src/cli/capabilities-base.ts +1 -1
  49. package/src/cli/chatgpt-command.ts +35 -14
  50. package/src/cli/claude.ts +46 -6
  51. package/src/cli/dispatch.ts +5 -1
  52. package/src/cli/doctor.ts +9 -0
  53. package/src/cli/ensure-desktop-cold-start.ts +329 -0
  54. package/src/cli/export-command.ts +1 -1
  55. package/src/cli/help.ts +2 -1
  56. package/src/cli/index.ts +13 -4
  57. package/src/cli/integration-plan-dto.ts +2 -0
  58. package/src/cli/registry.ts +15 -3
  59. package/src/cli/restart-scope.ts +3 -1
  60. package/src/clients/config-export/contracts.ts +1 -0
  61. package/src/clients/config-export/model-metadata.ts +3 -2
  62. package/src/clients/config-export/qoder.ts +93 -0
  63. package/src/clients/config-export.ts +17 -4
  64. package/src/codex/auth-api/pool-mode-gate.ts +5 -2
  65. package/src/codex/auth-context.ts +79 -5
  66. package/src/codex/autostart-health.ts +4 -0
  67. package/src/codex/catalog/build-entries.ts +11 -2
  68. package/src/codex/catalog/configured-tiers.ts +50 -0
  69. package/src/codex/catalog/effort.ts +4 -1
  70. package/src/codex/catalog/model-hints.ts +21 -3
  71. package/src/codex/catalog/native-ultrafast.ts +106 -0
  72. package/src/codex/catalog/parsing.ts +5 -0
  73. package/src/codex/catalog/provider-models.ts +4 -0
  74. package/src/codex/catalog/retained-sync.ts +2 -0
  75. package/src/codex/catalog/routed-gather.ts +22 -4
  76. package/src/codex/catalog/vendor-context.ts +82 -0
  77. package/src/codex/config-write-lock.ts +40 -2
  78. package/src/codex/convergence.ts +2 -0
  79. package/src/codex/desktop-app/types.ts +2 -0
  80. package/src/codex/desktop-app/windows.ts +29 -16
  81. package/src/codex/desktop-app-restart.ts +4 -2
  82. package/src/codex/desktop-compatibility/certificate-service.ts +99 -0
  83. package/src/codex/desktop-compatibility/certificate-store.ts +193 -0
  84. package/src/codex/desktop-compatibility/connection-store.ts +87 -0
  85. package/src/codex/desktop-compatibility/installed-build.ts +55 -0
  86. package/src/codex/desktop-compatibility/json-body.ts +29 -0
  87. package/src/codex/desktop-compatibility/native-identity.ts +62 -0
  88. package/src/codex/desktop-compatibility/relay-listener.ts +93 -0
  89. package/src/codex/desktop-compatibility/routing-binding.ts +10 -0
  90. package/src/codex/desktop-compatibility/routing-preflight.ts +69 -0
  91. package/src/codex/desktop-compatibility/runtime-ownership.ts +28 -0
  92. package/src/codex/desktop-compatibility/runtime.ts +173 -0
  93. package/src/codex/desktop-compatibility/service.ts +30 -0
  94. package/src/codex/desktop-compatibility/startup-settings.ts +50 -0
  95. package/src/codex/desktop-compatibility/usage-activation.ts +86 -0
  96. package/src/codex/desktop-compatibility/usage-controlled-fetch.ts +59 -0
  97. package/src/codex/desktop-compatibility/usage-controller.ts +107 -0
  98. package/src/codex/desktop-compatibility/usage-policy.ts +59 -0
  99. package/src/codex/desktop-compatibility/usage-refresh.ts +33 -0
  100. package/src/codex/desktop-compatibility/usage-sse-controller.ts +86 -0
  101. package/src/codex/desktop-compatibility/windows-activation-source.ts +44 -0
  102. package/src/codex/desktop-compatibility/windows-certificate-trust.ts +81 -0
  103. package/src/codex/desktop-compatibility/windows-key-protection.ts +59 -0
  104. package/src/codex/desktop-compatibility/windows-package-command.ts +86 -0
  105. package/src/codex/desktop-compatibility/windows-package-launch.ts +28 -0
  106. package/src/codex/history-provider.ts +1 -1
  107. package/src/codex/inject/config-write-section.ts +4 -1
  108. package/src/codex/inject/remove.ts +18 -6
  109. package/src/codex/journal.ts +16 -1
  110. package/src/codex/main-account.ts +99 -15
  111. package/src/codex/native-main-refresh-mode.ts +217 -0
  112. package/src/codex/routing/health-store.ts +1 -1
  113. package/src/codex/routing/pool-recovery.ts +79 -0
  114. package/src/codex/shim-diagnostics.ts +6 -0
  115. package/src/codex/shim-overlay.ts +7 -2
  116. package/src/config/diagnostics.ts +2 -1
  117. package/src/config/live-reconcile.ts +13 -0
  118. package/src/config/load-degrade.ts +6 -1
  119. package/src/config/paths.ts +10 -3
  120. package/src/config/schema/config-schema.ts +17 -0
  121. package/src/config/schema/desktop-compatibility.ts +9 -0
  122. package/src/config/schema/leaf-validators.ts +1 -0
  123. package/src/generated/compatibility-version.json +451 -163
  124. package/src/generated/model-metadata.ts +1 -1
  125. package/src/integrations/catalog-refresh.ts +1 -1
  126. package/src/integrations/config-io.ts +20 -2
  127. package/src/integrations/journal.ts +15 -1
  128. package/src/integrations/mutation-plan.ts +1 -0
  129. package/src/integrations/registry.ts +7 -0
  130. package/src/integrations/store.ts +3 -0
  131. package/src/integrations/writer.ts +10 -1
  132. package/src/lib/advisor-activation.ts +16 -0
  133. package/src/lib/background-work.ts +65 -0
  134. package/src/lib/bounded-file-read.ts +58 -0
  135. package/src/lib/bun-path-runtime.d.mts +10 -1
  136. package/src/lib/bun-path-runtime.mjs +62 -3
  137. package/src/lib/desktop-proxy-route.ts +30 -0
  138. package/src/lib/desktop-upstream-tunnel.ts +107 -0
  139. package/src/lib/errors.ts +7 -0
  140. package/src/lib/local-internal-call-capability.ts +55 -0
  141. package/src/lib/package-tree-integrity.ts +2 -2
  142. package/src/lib/socks5-fetch.ts +48 -71
  143. package/src/lib/socks5-handshake.ts +112 -0
  144. package/src/lib/translator-budget.ts +7 -1
  145. package/src/oauth/generic-account-failover.ts +29 -5
  146. package/src/oauth/index.ts +4 -2
  147. package/src/oauth/kiro-device-login.ts +2 -1
  148. package/src/oauth/kiro.ts +7 -1
  149. package/src/protocols/codecs/chat-ir.ts +467 -0
  150. package/src/protocols/codecs/chat.ts +10 -1
  151. package/src/protocols/codecs/messages-ir.ts +500 -0
  152. package/src/protocols/features.ts +10 -2
  153. package/src/protocols/path.ts +11 -1
  154. package/src/protocols/plan-snapshot.ts +39 -16
  155. package/src/protocols/plan.ts +9 -1
  156. package/src/protocols/settings.ts +8 -0
  157. package/src/providers/antigravity-account-roster.ts +102 -0
  158. package/src/providers/antigravity-models.ts +85 -7
  159. package/src/providers/model-rename-fields.ts +2 -0
  160. package/src/providers/model-rename-migration.ts +5 -3
  161. package/src/providers/registry/entries-core.ts +7 -6
  162. package/src/providers/registry/entries-extended.ts +19 -0
  163. package/src/responses/parser.ts +30 -19
  164. package/src/responses/state.ts +32 -29
  165. package/src/routing/history/build-file.ts +92 -0
  166. package/src/routing/history/indexer.ts +248 -216
  167. package/src/routing/history/ingest.ts +238 -0
  168. package/src/routing/history/rebuild-job.ts +111 -0
  169. package/src/routing/history/rebuild-protocol.ts +15 -0
  170. package/src/routing/history/rebuild-worker.ts +34 -0
  171. package/src/server/admission-model-scope.ts +4 -1
  172. package/src/server/antigravity-direct-eligibility.ts +208 -0
  173. package/src/server/auth-cors.ts +4 -0
  174. package/src/server/chat-completions.ts +75 -9
  175. package/src/server/claude-messages.ts +110 -20
  176. package/src/server/index/desktop-compatibility-startup.ts +56 -0
  177. package/src/server/index/serve-options.ts +2 -2
  178. package/src/server/index/startup-warnings.ts +9 -0
  179. package/src/server/index/websocket-handler.ts +9 -0
  180. package/src/server/index.ts +13 -10
  181. package/src/server/inference/client-encoder-delivery.ts +6 -5
  182. package/src/server/login-shell-path.ts +146 -0
  183. package/src/server/management/advisor-routes.ts +191 -0
  184. package/src/server/management/agent-settings-routes.ts +22 -4
  185. package/src/server/management/companion-routes.ts +4 -0
  186. package/src/server/management/config-routes.ts +2 -2
  187. package/src/server/management/context.ts +4 -0
  188. package/src/server/management/desktop-compatibility-routes.ts +46 -0
  189. package/src/server/management/desktop-compatibility-runtime-routes.ts +46 -0
  190. package/src/server/management/desktop-compatibility-settings-routes.ts +27 -0
  191. package/src/server/management/model-routes.ts +2 -2
  192. package/src/server/management/protocol-settings-patch.ts +1 -0
  193. package/src/server/management/route-registry.ts +11 -0
  194. package/src/server/management/sibling-guard.ts +1 -0
  195. package/src/server/management-api.ts +6 -0
  196. package/src/server/messages-antigravity-direct.ts +195 -0
  197. package/src/server/messages-native.ts +5 -1
  198. package/src/server/request-log.ts +7 -0
  199. package/src/server/responses/adapter-continuation.ts +35 -18
  200. package/src/server/responses/adapter-delivery.ts +24 -2
  201. package/src/server/responses/adapter-dispatch.ts +29 -2
  202. package/src/server/responses/advisor-plan-slot.ts +27 -0
  203. package/src/server/responses/advisor-slot.ts +336 -0
  204. package/src/server/responses/code-mode-collab-surface.ts +116 -8
  205. package/src/server/responses/core-combo-failure.ts +10 -1
  206. package/src/server/responses/core-combo.ts +21 -0
  207. package/src/server/responses/core-lifetime.ts +2 -0
  208. package/src/server/responses/core-normalize.ts +11 -0
  209. package/src/server/responses/core-options.ts +46 -0
  210. package/src/server/responses/core-replay.ts +7 -4
  211. package/src/server/responses/core.ts +1 -1
  212. package/src/server/responses/passthrough-delivery.ts +16 -10
  213. package/src/server/responses/passthrough-dispatch.ts +14 -3
  214. package/src/server/responses/request-prepare.ts +68 -5
  215. package/src/server/responses/request-transport.ts +16 -0
  216. package/src/server/responses/run-turn-execution.ts +70 -8
  217. package/src/server/responses/sidecar-execution.ts +78 -3
  218. package/src/server/responses/terminal-guard.ts +2 -1
  219. package/src/server/responses-undeclared-tool-guard.ts +30 -9
  220. package/src/server/startup-action-control.ts +16 -2
  221. package/src/server/startup-health-cache.ts +1 -0
  222. package/src/service/desktop-startup.ts +14 -0
  223. package/src/service/health-sentinel-main.ts +23 -4
  224. package/src/service/health-sentinel-probe.ts +24 -7
  225. package/src/service/logging.ts +72 -0
  226. package/src/tray/windows-tray.ps1 +43 -24
  227. package/src/types/config.ts +63 -6
  228. package/src/types/provider.ts +7 -0
  229. package/src/types/request.ts +15 -0
  230. package/src/types/tools.ts +2 -0
  231. package/src/update/async-check.ts +1 -1
  232. package/src/update/badge.ts +2 -2
  233. package/src/update/index.ts +88 -15
  234. package/src/update/job-supervised-restart.ts +107 -0
  235. package/src/update/job.ts +32 -32
  236. package/src/update/notify.ts +1 -1
  237. package/src/update/refresh-scheduler.ts +2 -2
  238. package/src/update/supervised-recovery.d.mts +27 -0
  239. package/src/update/supervised-recovery.mjs +157 -0
  240. package/src/usage/cache-diagnostic.ts +2 -0
  241. package/src/usage/expected-prices.ts +12 -0
  242. package/src/web-search/gemini-executor.ts +2 -2
  243. package/assets/sponsors/packycode.png +0 -0
  244. package/gui/dist/assets/App-CZtqiGcw.js +0 -56
  245. package/gui/dist/assets/index-CTm4l-HQ.js +0 -94
  246. package/gui/dist/assets/quota-summary-BbEeRfPY.js +0 -1
@@ -22,6 +22,8 @@ import type { RequestLogContext } from "../request-log";
22
22
  import type { UpstreamHostAdmissionLease } from "../../codex/upstream-host-health";
23
23
  import type { AccountLease } from "../../oauth/kiro-account-load";
24
24
  import type { PolicyRequestScope } from "./policy-request-scope";
25
+ import type { OcxParsedRequest } from "../../types";
26
+ import type { PolicyRequestEvidence } from "../../routing/evaluator";
25
27
 
26
28
  export interface ConsumedComboFailure {
27
29
  response: Response;
@@ -61,6 +63,32 @@ export interface ClientEncoderOption {
61
63
  stream: boolean;
62
64
  model: string;
63
65
  inputTokenFloor?: number;
66
+ /**
67
+ * `direct` when the request side also skipped the internal Responses body (the Antigravity
68
+ * direct lane), so the attempt's request path is traced as `[inbound, ir, upstream]`.
69
+ */
70
+ requestLane?: "bridge" | "direct";
71
+ }
72
+
73
+ /**
74
+ * The Antigravity direct lane (`protocols.rollout.antigravityDirect`). The ingress sends the core
75
+ * a Responses body that carries only the request settings; the conversation is decoded straight
76
+ * from the client body. Request preparation applies `decode` at the parse seam, reads routing
77
+ * evidence from `evidence`, and calls `restoreBridgeConversation` if the final route is not the
78
+ * one the lane was decided for, so every `_rawBody` reader of another adapter sees the full
79
+ * bridge body.
80
+ */
81
+ export interface DirectRequestOption {
82
+ protocol: "chat" | "messages";
83
+ /** The concrete route the ingress decided the lane for. */
84
+ providerName: string;
85
+ modelId: string;
86
+ /** Policy-routing evidence from the client body; the settings-only body has no `input`. */
87
+ evidence: PolicyRequestEvidence;
88
+ /** The settings-only parse with the directly decoded conversation in place of its empty one. */
89
+ decode(base: OcxParsedRequest): OcxParsedRequest;
90
+ /** The bridge projection's conversation fields (`input`, `instructions`, `tools`). */
91
+ bridgeConversation(): Record<string, unknown>;
64
92
  }
65
93
 
66
94
  export interface HandleResponsesOptions {
@@ -137,6 +165,13 @@ export interface HandleResponsesOptions {
137
165
  * it. Omitted means a genuine Responses inbound.
138
166
  */
139
167
  inboundWire?: InboundWire;
168
+ /**
169
+ * Set by a translating ingress (Chat / Anthropic) when the client sent an explicit empty
170
+ * tool catalog. Those wires may defer tool delivery, so their translated body carries no
171
+ * readable catalog boundary; an explicit `[]` is still a deny-all declaration and the
172
+ * undeclared-tool guard enforces it. Absent catalogs and partial catalogs stay deferred.
173
+ */
174
+ clientDeclaredEmptyToolCatalog?: boolean;
140
175
  nativeReasoningReplay?: ReadonlyMap<string, string>;
141
176
  nativeReasoningMint?: { owner?: NativeReasoningOwner };
142
177
  /** Droid's per-request effort default; each concrete combo or policy target applies it only if its ladder allows it. */
@@ -202,6 +237,15 @@ export interface HandleResponsesOptions {
202
237
  * rebuilds headers and carries the fact through this flag.
203
238
  */
204
239
  visionDescribeTerminal?: boolean;
240
+ /**
241
+ * Terminal advisor marker: true when the inbound request IS the advisor sidecar's own loopback
242
+ * consultation. The advisor planner then never plans a consultation — a depth cap of 1 that
243
+ * keeps Worker → Advisor → Advisor recursion impossible (same structure as
244
+ * {@linkcode visionDescribeTerminal}). The Chat surface detects the raw
245
+ * `x-opencodex-advisor-internal` header before its bridge rebuilds headers and carries the
246
+ * fact through this flag.
247
+ */
248
+ advisorInternal?: boolean;
205
249
  /**
206
250
  * Set only by the Chat and Messages ingresses when `protocols.rollout.directEncoders` is on and
207
251
  * the settled route is one non-Responses target. Adapter delivery then encodes the adapter
@@ -210,6 +254,8 @@ export interface HandleResponsesOptions {
210
254
  * returning a Responses body, which the ingress converts as before.
211
255
  */
212
256
  clientEncoder?: ClientEncoderOption;
257
+ /** Set only by the Chat or Messages ingress on an eligible Antigravity route; see {@link DirectRequestOption}. */
258
+ directRequest?: DirectRequestOption;
213
259
  }
214
260
 
215
261
  /** Values shared by the call, not a bag of mutable pipeline state. */
@@ -125,11 +125,13 @@ export function bindRouteReasoningReplayScope(args: {
125
125
  if (owner) parsed._nativeReasoningBoundOwner = owner;
126
126
  else delete parsed._nativeReasoningBoundOwner;
127
127
  if (parsed._nativeReasoningReplay?.size) {
128
+ const rejected = new Set<string>();
128
129
  for (const [blob, tag] of parsed._nativeReasoningReplay) {
129
130
  if (!nativeReasoningTagMatches(nativeReasoningTag(owner, blob), tag)) {
130
- stripNativeReasoningBlob(parsed, blob);
131
+ rejected.add(blob);
131
132
  }
132
133
  }
134
+ if (rejected.size) stripNativeReasoningBlobs(parsed, rejected);
133
135
  }
134
136
  const continuationDestinationIdentity = providerContinuationDestinationIdentity(parsed, provider);
135
137
  const continuationOwner = providerContinuationOwnerFromReplayIdentity(
@@ -169,11 +171,11 @@ export function bindRouteReasoningReplayScope(args: {
169
171
  bindProviderContinuationForRoute(parsed, continuationOwner);
170
172
  }
171
173
 
172
- function stripNativeReasoningBlob(parsed: OcxParsedRequest, blob: string): void {
174
+ function stripNativeReasoningBlobs(parsed: OcxParsedRequest, rejected: ReadonlySet<string>): void {
173
175
  const input = (parsed._rawBody as { input?: unknown } | undefined)?.input;
174
176
  if (Array.isArray(input)) {
175
177
  for (const item of input) {
176
- if (item && typeof item === "object" && item.type === "reasoning" && item.encrypted_content === blob) {
178
+ if (item && typeof item === "object" && item.type === "reasoning" && rejected.has(item.encrypted_content)) {
177
179
  delete item.encrypted_content;
178
180
  delete item.id;
179
181
  }
@@ -187,7 +189,8 @@ function stripNativeReasoningBlob(parsed: OcxParsedRequest, blob: string): void
187
189
  const item: unknown = JSON.parse(part.signature);
188
190
  if (!item || typeof item !== "object" || Array.isArray(item)) continue;
189
191
  const reasoning = item as Record<string, unknown>;
190
- if (reasoning.type !== "reasoning" || reasoning.encrypted_content !== blob) continue;
192
+ if (reasoning.type !== "reasoning" || typeof reasoning.encrypted_content !== "string"
193
+ || !rejected.has(reasoning.encrypted_content)) continue;
191
194
  delete reasoning.encrypted_content;
192
195
  delete reasoning.id;
193
196
  part.signature = JSON.stringify(reasoning);
@@ -48,7 +48,7 @@ export async function handleResponses(
48
48
  abortSignal.addEventListener("abort", cancel, { once: true });
49
49
  try {
50
50
  const response = await runWithCompactionRecovery(req, config, logCtx, {
51
- ...options,
51
+ ...options, abortSignal,
52
52
  openAiSidecarAuth: options.openAiSidecarAuth === undefined
53
53
  ? captureExplicitOpenAiCallerAuth(req.headers, config) : options.openAiSidecarAuth,
54
54
  nativeCallerAuth: options.nativeCallerAuth === undefined
@@ -108,6 +108,7 @@ import { createResponsesFunctionToolRepairBlockRewrite } from "../responses-func
108
108
  import {
109
109
  createUndeclaredToolCallGuardBlockRewrite,
110
110
  currentTurnWireToolCatalogBody,
111
+ UNDECLARED_TOOL_CALL_ERROR_CODE,
111
112
  undeclaredToolCallNameInResponse,
112
113
  undeclaredToolCallMessage,
113
114
  normalizeDefaultNamespaceInJson,
@@ -437,8 +438,7 @@ export async function deliverPassthroughResponse(
437
438
  normalizeFunctionCompletionJson,
438
439
  } = nativeExchange;
439
440
  const { commitReasoningReplayServingRoute, recordTerminalOutcomes } = responseEffects;
440
- const { parsed, subagentQuotaFailureModel, clientRequestedStream, translatorBudget, inboundWire } = requestState;
441
- const enforceDeclaredToolNames = inboundWire !== "chat" && inboundWire !== "anthropic";
441
+ const { parsed, subagentQuotaFailureModel, clientRequestedStream, translatorBudget } = requestState;
442
442
  const { openAiSidecar } = sidecarState;
443
443
  const { requestBindings } = transportState;
444
444
 
@@ -857,14 +857,17 @@ export async function deliverPassthroughResponse(
857
857
  : undefined,
858
858
  // Last: every rewrite above can still rename or reshape a call item, so the guard must
859
859
  // compare the names the client will actually receive against the declared catalog.
860
- nativeExchange.undeclaredToolGuardActive
860
+ // Deferred-catalog wires keep the rewrite for its normalization half only: a provider's
861
+ // `default.X` call still collapses to the declared `X`, but an undeclared name is the
862
+ // client's own deferred registry's business, not this proxy's verdict.
863
+ nativeExchange.undeclaredToolGuardActive || declaredWireToolNames.size > 0
861
864
  ? createUndeclaredToolCallGuardBlockRewrite(
862
865
  declaredWireToolNames,
863
866
  declaredNamelessClientCallTypes,
864
867
  providerExecutedCallTypes,
865
868
  declaredBareWireToolNames,
866
869
  recoverableBareCustomWireToolNames,
867
- enforceDeclaredToolNames,
870
+ { refuseUndeclared: nativeExchange.undeclaredToolGuardActive },
868
871
  )
869
872
  : undefined,
870
873
  grokUpstreamEchoEnabled
@@ -1322,10 +1325,7 @@ export async function deliverPassthroughResponse(
1322
1325
  if (grokUpstreamEchoEnabled) {
1323
1326
  clientJson = stripGrokUpstreamEnvelopeEchoFromResponsesJson(clientJson);
1324
1327
  }
1325
- // #1700: same inbound-wire refusal policy as the SSE relay above. Both the plain JSON answer and
1326
- // the reframed-SSE branch below are built from this body, so one check covers them. This
1327
- // runs BEFORE the continuation cache write below: a refused turn must not become state a
1328
- // later `previous_response_id` replay can expand from.
1328
+ // Both response formats use this body, before a refused turn can enter continuation state.
1329
1329
  if (nativeExchange.undeclaredToolGuardActive) {
1330
1330
  const undeclared = (() => {
1331
1331
  try {
@@ -1341,9 +1341,15 @@ export async function deliverPassthroughResponse(
1341
1341
  return undefined;
1342
1342
  }
1343
1343
  })();
1344
- if (enforceDeclaredToolNames && undeclared !== undefined) {
1345
- return formatErrorResponse(502, "upstream_error", undeclaredToolCallMessage(undeclared));
1344
+ if (undeclared !== undefined) {
1345
+ return formatErrorResponse(502, "upstream_error", undeclaredToolCallMessage(undeclared), {
1346
+ code: UNDECLARED_TOOL_CALL_ERROR_CODE,
1347
+ });
1346
1348
  }
1349
+ }
1350
+ // Same split as the SSE rewrite: normalization applies whenever a declared name exists,
1351
+ // even on wires whose deferred catalog stands the refusal down.
1352
+ if (declaredWireToolNames.size > 0) {
1347
1353
  clientJson = normalizeDefaultNamespaceInJson(
1348
1354
  clientJson,
1349
1355
  declaredWireToolNames,
@@ -21,6 +21,7 @@ import { imageGenToolCallAliases } from "../responses-image-gen-repair";
21
21
  import { rememberResponseState, isBodyNonPersistable } from "../../responses/state";
22
22
  import {
23
23
  currentTurnWireToolCatalogBody,
24
+ hasExplicitEmptyToolCatalog,
24
25
  hasExplicitWireToolCatalog,
25
26
  collectDeclaredWireToolNames,
26
27
  collectDeclaredBareWireToolNames,
@@ -187,6 +188,7 @@ export async function preparePassthroughExchange(
187
188
  | "selectedForwardHeaders"
188
189
  | "clientRequestedStream"
189
190
  | "inboundWire"
191
+ | "clientDeclaredEmptyToolCatalog"
190
192
  | "substituteMainCredential"
191
193
  | "callerAuthHeaders"
192
194
  | "subagentFallbackAccountId"
@@ -253,6 +255,7 @@ export async function preparePassthroughExchange(
253
255
  responseStateOptions,
254
256
  clientRequestedStream,
255
257
  inboundWire,
258
+ clientDeclaredEmptyToolCatalog,
256
259
  substituteMainCredential,
257
260
  callerAuthHeaders,
258
261
  } = requestState;
@@ -531,10 +534,18 @@ export async function preparePassthroughExchange(
531
534
  declaredWireToolNames.add(name);
532
535
  }
533
536
  }
537
+ // Chat and Anthropic inbounds may deliver tools deferred/partially: their client registry
538
+ // owns the full catalog, so this guard cannot read the translated body as the authorization
539
+ // boundary. An explicit empty catalog — flagged by the translating ingress or present
540
+ // verbatim in a replayed body — is still a deny-all the caller declared; everything else
541
+ // stays deferred to the caller's own tool handling.
534
542
  undeclaredToolGuardActive = (
535
- declaredWireToolNames.size > 0
536
- || clientDeclaredNamelessCallTypes.size > 0
537
- || clientExplicitWireToolCatalog
543
+ inboundWire === "responses"
544
+ ? declaredWireToolNames.size > 0
545
+ || clientDeclaredNamelessCallTypes.size > 0
546
+ || clientExplicitWireToolCatalog
547
+ : clientDeclaredEmptyToolCatalog
548
+ || hasExplicitEmptyToolCatalog(clientToolAuthorizationBody)
538
549
  ) && route.provider.authMode !== "forward";
539
550
  options.nativeControl?.configureToolAuthorization?.(
540
551
  undeclaredToolGuardActive,
@@ -1,4 +1,8 @@
1
- import type { ResponsesRequestContext, ResponsesAdmissionState, ResponsesDispatchers } from "./core-options";
1
+ import type { HandleResponsesOptions, ResponsesRequestContext, ResponsesAdmissionState, ResponsesDispatchers } from "./core-options";
2
+ import type { PolicyRequestEvidence } from "../../routing/evaluator";
3
+ import type { RequestLogContext } from "../request-log";
4
+ import { markProtocolEntry, protocolMarkFeatures } from "../../protocols/trace";
5
+ import { isAntigravityRoute } from "../antigravity-direct-eligibility";
2
6
  import { capturePolicyRequestRoute, assertPolicyNormalizedRoute, recordPolicyPreparedDestination, policyCandidateRefusalResponse } from "./policy-request-scope";
3
7
  import {
4
8
  agentTaskRecoveryConfig,
@@ -155,6 +159,48 @@ import {
155
159
 
156
160
  import { redactHostedImageDisplayPaths } from "../responses-hosted-image-display";
157
161
 
162
+ /** Policy-routing evidence. The direct lane's settings-only body has no conversation to scan. */
163
+ function routeEvidence(options: Pick<HandleResponsesOptions, "directRequest">, parsed: OcxParsedRequest): PolicyRequestEvidence {
164
+ return options.directRequest?.evidence ?? evidenceFromBody(parsed._rawBody);
165
+ }
166
+
167
+ /**
168
+ * Leave the Antigravity direct lane for this request: put the bridge conversation back on the
169
+ * Responses body so every `_rawBody` reader sees what the bridge would have sent, and trace the
170
+ * request as the bridge. The IR conversation already decoded stays: it equals what
171
+ * `parseRequest` builds from the restored body (`tests/protocols/chat-ir-parity.test.ts`).
172
+ */
173
+ function restoreDirectRequestConversation(options: HandleResponsesOptions, body: unknown, logCtx: RequestLogContext): void {
174
+ const direct = options.directRequest;
175
+ if (!direct) return;
176
+ options.directRequest = undefined;
177
+ if (body && typeof body === "object" && !Array.isArray(body)) Object.assign(body, direct.bridgeConversation());
178
+ if (options.clientEncoder) options.clientEncoder = { ...options.clientEncoder, requestLane: "bridge" };
179
+ markProtocolEntry(logCtx, {
180
+ inbound: direct.protocol,
181
+ lane: "bridge",
182
+ reasonCodes: ["not-migrated"],
183
+ features: protocolMarkFeatures(logCtx) ?? [],
184
+ });
185
+ }
186
+
187
+ /**
188
+ * Leave the direct lane as soon as the route stops being the Antigravity route it was decided
189
+ * for. Called after every route assignment, so readers that run between a reroute and dispatch
190
+ * (canonical sanitization, ciphertext stripping, final normalization) see the bridge body.
191
+ */
192
+ function leaveDirectLaneIfRerouted(
193
+ options: HandleResponsesOptions,
194
+ route: Pick<RouteResult, "providerName" | "modelId" | "provider">,
195
+ parsed: OcxParsedRequest,
196
+ logCtx: RequestLogContext,
197
+ ): void {
198
+ const direct = options.directRequest;
199
+ if (!direct) return;
200
+ if (route.providerName === direct.providerName && route.modelId === direct.modelId && isAntigravityRoute(route)) return;
201
+ restoreDirectRequestConversation(options, parsed._rawBody, logCtx);
202
+ }
203
+
158
204
  /** Parses, selects, and admits one request without changing the dispatch policy. */
159
205
  export async function prepareResponsesRequest(
160
206
  requestContext: Pick<ResponsesRequestContext, "options" | "config" | "req" | "logCtx">,
@@ -306,6 +352,9 @@ export async function prepareResponsesRequest(
306
352
  }
307
353
  if (comboId && Object.hasOwn(config.combos ?? {}, comboId)) {
308
354
  options.onRequestBodyRead?.();
355
+ // The Antigravity direct lane is decided for one concrete route; a body that reached a combo
356
+ // goes through the combo's own child loop with the full bridge conversation.
357
+ restoreDirectRequestConversation(options, body, logCtx);
309
358
  return requestDispatchers.handleComboResponses(req, body, comboId, config, logCtx, {
310
359
  ...options,
311
360
  // Concrete combo child selectors no longer match the shadow source model. Carry the
@@ -383,6 +432,10 @@ export async function prepareResponsesRequest(
383
432
  let toolBridgeMaps: ReturnType<typeof buildToolBridgeMaps>;
384
433
  try {
385
434
  parsed = parseRequest(body);
435
+ // Antigravity direct lane: the body carried only the request settings, so the parse above
436
+ // produced them with an empty conversation; the conversation itself comes straight from the
437
+ // client body. Inside this try so a decode error answers exactly like a parse error.
438
+ if (options.directRequest) parsed = options.directRequest.decode(parsed);
386
439
  if (options.inboundWire === "anthropic") {
387
440
  parsed._nativeReasoningReplay = options.nativeReasoningReplay;
388
441
  parsed._nativeReasoningMint = options.nativeReasoningMint;
@@ -582,8 +635,8 @@ export async function prepareResponsesRequest(
582
635
  const resolved = concreteSelection
583
636
  ? routeConcreteModel(config, modelId)
584
637
  : parsed._compactionRequest === true
585
- ? routeCompactionModel(config, modelId, evidenceFromBody(parsed._rawBody))
586
- : routeModel(config, modelId, evidenceFromBody(parsed._rawBody));
638
+ ? routeCompactionModel(config, modelId, routeEvidence(options, parsed))
639
+ : routeModel(config, modelId, routeEvidence(options, parsed));
587
640
  // A Pool 2 selector may leave Pool 2 only through an operator-configured cross-provider
588
641
  // blocked-model redirect, which the router marks as a credential-domain rewrite. Any other
589
642
  // destination means the selector fell through to default resolution, which stays refused.
@@ -654,6 +707,7 @@ export async function prepareResponsesRequest(
654
707
  if (parsed._compactionRequest === true || options.compactionRoutingOverride) parsed._cursorIsolateConversation = true;
655
708
  route = memoryRoute ?? shadowRoute ?? resolveRoute(options.policyFallbackCandidate
656
709
  ? `${options.policyFallbackCandidate.provider}/${options.policyFallbackCandidate.model}` : parsed.modelId);
710
+ leaveDirectLaneIfRerouted(options, route, parsed, logCtx);
657
711
  // Name the phase in the persisted route decision, so the request log says why this turn went to
658
712
  // the memory destination instead of leaving it looking like a plain user selection. Set here, on
659
713
  // the resolved route, so a combo child's own route carries it too.
@@ -893,8 +947,9 @@ export async function prepareResponsesRequest(
893
947
  if (fallback?.to && !slugsEquivalent(fallback.to, route.modelId)) {
894
948
  try {
895
949
  route = captureInboundRoutePolicy(
896
- routeModel(config, fallback.to, evidenceFromBody(parsed._rawBody)),
950
+ routeModel(config, fallback.to, routeEvidence(options, parsed)),
897
951
  );
952
+ leaveDirectLaneIfRerouted(options, route, parsed, logCtx);
898
953
  credentialDomainWasRewritten = true;
899
954
  logCtx.routeDecision = route.routeDecision;
900
955
  logCtx.policyEligibility = route.policyEligibility;
@@ -1109,8 +1164,9 @@ export async function prepareResponsesRequest(
1109
1164
  if (fallback?.to && !slugsEquivalent(fallback.to, route.modelId)) {
1110
1165
  try {
1111
1166
  route = captureInboundRoutePolicy(
1112
- routeModel(config, fallback.to, evidenceFromBody(parsed._rawBody)),
1167
+ routeModel(config, fallback.to, routeEvidence(options, parsed)),
1113
1168
  );
1169
+ leaveDirectLaneIfRerouted(options, route, parsed, logCtx);
1114
1170
  credentialDomainWasRewritten = true;
1115
1171
  logCtx.routeDecision = route.routeDecision;
1116
1172
  logCtx.policyEligibility = route.policyEligibility;
@@ -1257,6 +1313,8 @@ export async function prepareResponsesRequest(
1257
1313
  // for streaming clients.
1258
1314
  const clientRequestedStream = parsed.stream;
1259
1315
  const admittedPolicyRoute = { providerName: route.providerName, modelId: route.modelId };
1316
+ // Final guard before normalization may inject guidance into the raw body.
1317
+ leaveDirectLaneIfRerouted(options, route, parsed, logCtx);
1260
1318
  await applyFinalRouteRequestNormalization({
1261
1319
  parsed,
1262
1320
  route,
@@ -1452,6 +1510,11 @@ export async function prepareResponsesRequest(
1452
1510
  commitSkillsSnapshot?.();
1453
1511
  return {
1454
1512
  inboundWire,
1513
+ clientDeclaredEmptyToolCatalog: options.clientDeclaredEmptyToolCatalog === true,
1514
+ // Admission facts later route rewrites must not re-derive: the selector the caller sent and
1515
+ // the key scope as it stood when this request was admitted.
1516
+ inboundSelector,
1517
+ admissionScope,
1455
1518
  translatorBudget,
1456
1519
  parsed,
1457
1520
  toolBridgeMaps,
@@ -56,6 +56,11 @@ import { stampOAuthAccountLabel, usesApiKeyAccount } from "../../providers/label
56
56
  import { resolveProviderTransport } from "../../providers/xai-transport";
57
57
  import { captureRouteStaticPolicy } from "../../router";
58
58
  import { CopilotAutoHttpError, resolveCopilotAuto } from "../../providers/github-copilot-auto";
59
+ import {
60
+ AdmissionModelDeniedError,
61
+ admissionModelDeniedResponse,
62
+ assertRouteAllowedByScope,
63
+ } from "../admission-model-scope";
59
64
  import { renameRoutedIdentityInContext } from "../../adapters/identity";
60
65
  import { resolveCopilotApiBaseUrl } from "../../oauth/github-copilot";
61
66
  import {
@@ -109,6 +114,8 @@ export async function prepareResponsesTransport(
109
114
  | "inboundWire"
110
115
  | "selectedForwardHeaders"
111
116
  | "translatorBudget"
117
+ | "inboundSelector"
118
+ | "admissionScope"
112
119
  >,
113
120
  ) {
114
121
  const { config, logCtx, options, req } = requestContext;
@@ -297,6 +304,7 @@ export async function prepareResponsesTransport(
297
304
  const copilotRequestedModel = route.modelId;
298
305
  /** Project only bounded Copilot refusal metadata; upstream bodies and session tokens never reach the client. */
299
306
  const copilotRefusalResponse = (error: unknown): Response | undefined => {
307
+ if (error instanceof AdmissionModelDeniedError) return admissionModelDeniedResponse(error);
300
308
  if (!(error instanceof CopilotAutoHttpError) || ![401, 403, 429].includes(error.status)) return;
301
309
  const response = formatErrorResponse(error.status, error.status === 429 ? "rate_limit_error" : "authentication_error",
302
310
  error.message);
@@ -357,6 +365,13 @@ export async function prepareResponsesTransport(
357
365
  replayOAuthCredentialSnapshot = { accountId: refreshed.accountId, generation: refreshed.generation };
358
366
  selected = await negotiate();
359
367
  }
368
+ assertRouteAllowedByScope(
369
+ // The scope captured at admission: a key edited or removed while negotiation was in
370
+ // flight must not widen this request. The refusal names the caller's own selector.
371
+ requestState.admissionScope,
372
+ requestState.inboundSelector,
373
+ { providerName: route.providerName, modelId: selected.modelId },
374
+ );
360
375
  copilotSessionExpiresAt = selected.expiresAt ?? Infinity;
361
376
  route.provider = selected.provider;
362
377
  route.modelId = selected.modelId;
@@ -725,6 +740,7 @@ export async function prepareResponsesTransport(
725
740
  let nextAdapter: ProviderAdapter;
726
741
  try { nextAdapter = await refreshDispatchAdapter(requestParsed); }
727
742
  catch (error) {
743
+ if (error instanceof AdmissionModelDeniedError) throw error;
728
744
  const refusal = copilotRefusalResponse(error);
729
745
  if (refusal) return refusal;
730
746
  throw error;
@@ -23,6 +23,7 @@ import { normalizeLogConversationId } from "../request-log-conversation";
23
23
  import { normalizeDeclaredToolName, type AdapterEvent, type OcxProviderContinuationState } from "../../types";
24
24
  import { adapterFailureFromMessage, SEND_BUDGET_EXHAUSTED_CODE, SPEND_LEDGER_STORAGE_UNAVAILABLE_CODE, type OcxErrorPayload } from "../../lib/errors";
25
25
  import { SendBudgetExhaustedError, markResponseNonReplayable } from "../../lib/upstream-retry";
26
+ import { hasKeyPoolFailover, rotateProviderTransportOn401, rotateProviderTransportOn429 } from "../../providers/key-failover";
26
27
  import {
27
28
  hasEligibleGenericOAuthFailoverTarget,
28
29
  isGenericOAuthFailoverEnabled,
@@ -224,6 +225,11 @@ export async function executeResponsesRunTurn(
224
225
  // in-flight; only the latest dispatch may merge adapter-written route
225
226
  // state back onto `parsed`, or the superseded attempt would restore the
226
227
  // failed account's cursor/continuation over the rotation's rebind.
228
+ type KeyBinding = Extract<NonNullable<ReturnType<typeof adapterBindings.get>>, { kind: "api-key" }>;
229
+ const failedKeyBindings = new WeakMap<Extract<AdapterEvent, { type: "error" }>, KeyBinding>();
230
+ const failedKeys = new Set<string>();
231
+ const keyAttemptLimit = route.provider.apiKeyPool?.length ?? 0;
232
+ let lastKeyFailure: Extract<AdapterEvent, { type: "error" }> | undefined;
227
233
  let runTurnAttemptSeq = 0;
228
234
  const runTurnAttempt = async (
229
235
  targetQueue: AdapterEventQueue,
@@ -238,7 +244,9 @@ export async function executeResponsesRunTurn(
238
244
  const attemptSeq = ++runTurnAttemptSeq;
239
245
  if (attemptSeq === 1 && options.comboInitialSend) options.comboInitialSend.producerOwned = true;
240
246
  let pacingSlot = preacquiredSlot;
247
+ let sentKeyBinding: KeyBinding | undefined;
241
248
  const emit = (event: AdapterEvent) => {
249
+ if (event.type === "error" && sentKeyBinding) failedKeyBindings.set(event, sentKeyBinding);
242
250
  options.onCompactionRecoveryAdapterEvent?.(event);
243
251
  targetQueue.push(event);
244
252
  };
@@ -249,6 +257,12 @@ export async function executeResponsesRunTurn(
249
257
  pacingSlot = await waitForProviderRequestSlot(route.providerName, route.provider, route.modelId, runTurnAbort.signal);
250
258
  }
251
259
  await refreshRunTurnSelection();
260
+ const selectedBinding = adapterBindings.get(transportState.runTurnAdapter);
261
+ if (lastKeyFailure && selectedBinding?.kind === "api-key"
262
+ && failedKeys.has(selectedBinding.provider.apiKey ?? "")) {
263
+ emit(lastKeyFailure);
264
+ return;
265
+ }
252
266
  // LOCAL PATCH (runturn-websearch): refreshRunTurnSelection binds route
253
267
  // state onto `parsed`; mirror it onto the iteration-local copy the
254
268
  // adapter actually receives.
@@ -293,13 +307,14 @@ export async function executeResponsesRunTurn(
293
307
  // The only way the request budget reaches a transport the adapter owns. Without it
294
308
  // a Cursor turn's inner ladder was three physical sends the cap read as one.
295
309
  ...(adapterDispatchBudget ? { sendBudget: adapterDispatchBudget } : {}),
296
- onPhysicalSend: send => noteAdapterPhysicalSend(
297
- logCtx.usageLogInputTokens,
298
- // The attempt's own recovery kind still labels its first send when the adapter
299
- // does not supply one of its own.
300
- { ...send, ...(send.recovery ?? recovery ? { recovery: send.recovery ?? recovery } : {}) },
301
- { includeFirst: reportsOwnSends },
302
- ),
310
+ onPhysicalSend: send => {
311
+ // Capture once at this attempt's send, before later selection or recovery changes it.
312
+ const binding = adapterBindings.get(transportState.runTurnAdapter);
313
+ if (!sentKeyBinding && binding?.kind === "api-key") sentKeyBinding = binding;
314
+ noteAdapterPhysicalSend(logCtx.usageLogInputTokens,
315
+ { ...send, ...(send.recovery ?? recovery ? { recovery: send.recovery ?? recovery } : {}) },
316
+ { includeFirst: reportsOwnSends });
317
+ },
303
318
  onRecoveryWithheld: noteAdapterRecoveryWithheld,
304
319
  },
305
320
  emit,
@@ -361,6 +376,7 @@ export async function executeResponsesRunTurn(
361
376
  const runTurn = async (): Promise<void> => runTurnAttempt(queue, undefined, true, wsFirstParsed, initialPacingSlot);
362
377
  const runTurnFailoverArmed = () =>
363
378
  route.provider.authMode === "oauth"
379
+ || (transportState.runTurnAdapter.name === "qoder" && hasKeyPoolFailover(route.provider))
364
380
  || !!(transportState.genericFailoverAccountId
365
381
  && isGenericOAuthFailoverEnabled(config, route.providerName));
366
382
  const dispatchSearchIteration = (iterParsed: PreparedResponsesRequest["parsed"]): AsyncIterable<AdapterEvent> => {
@@ -464,9 +480,51 @@ export async function executeResponsesRunTurn(
464
480
  return false;
465
481
  }
466
482
  };
483
+ const recoverRunTurnKey = async (
484
+ error: Extract<AdapterEvent, { type: "error" }>,
485
+ ): Promise<AttemptRecoveryKind | undefined> => {
486
+ const binding = failedKeyBindings.get(error);
487
+ if (error.apiKeyRecovery !== true || (error.status !== 401 && error.status !== 429)
488
+ || !binding || runTurnAbort.signal.aborted || !hasKeyPoolFailover(route.provider)
489
+ || failedKeys.size >= keyAttemptLimit) return undefined;
490
+ const failed = binding.provider;
491
+ if (!failed.apiKey || !failed._apiKeyAttempt || failedKeys.has(failed.apiKey)) return undefined;
492
+ const hop = reserveCredentialHop("auth-recovery", `${route.providerName}|${route.modelId}|runturn-key-${error.status}`);
493
+ if (!hop.allowed) return undefined;
494
+ failedKeys.add(failed.apiKey);
495
+ lastKeyFailure = error;
496
+ try {
497
+ const rotate = error.status === 401 ? rotateProviderTransportOn401 : rotateProviderTransportOn429;
498
+ const rotated = rotate(config, route.providerName, failed, {
499
+ attemptedKey: failed.apiKey, attemptedSelection: failed._apiKeyAttempt,
500
+ promptCacheKey: parsed.options.promptCacheKey,
501
+ });
502
+ if (!rotated || failedKeys.size >= keyAttemptLimit || failedKeys.has(rotated.apiKey ?? "") || runTurnAbort.signal.aborted) {
503
+ hop.permit?.release();
504
+ return undefined;
505
+ }
506
+ route.provider = rotated;
507
+ const adapter = await refreshRunTurnAdapter(parsed);
508
+ if (!adapter.runTurn || failedKeys.has(route.provider.apiKey ?? "") || runTurnAbort.signal.aborted) {
509
+ hop.permit?.release();
510
+ return undefined;
511
+ }
512
+ bindRouteReasoningReplayScope({ parsed, providerName: route.providerName, provider: route.provider,
513
+ adapterName: adapter.name, forwardHeaders: requestState.selectedForwardHeaders });
514
+ sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, adapter.name, logCtx.accountLogLabel);
515
+ recordAttemptCredentialSource(logCtx.activeAttempt, route.providerName, route.provider, adapter.name);
516
+ sendBudgetState.pendingHopPermit = hop.permit;
517
+ return error.status === 401 ? "key-401" : "key-429";
518
+ } catch {
519
+ hop.permit?.release();
520
+ return undefined;
521
+ }
522
+ };
467
523
  const recoverRunTurnAdapterOnPreflightError = async (
468
524
  error: Extract<AdapterEvent, { type: "error" }>,
469
525
  ): Promise<AttemptRecoveryKind | undefined> => {
526
+ const keyRecovery = await recoverRunTurnKey(error);
527
+ if (keyRecovery) return keyRecovery;
470
528
  if (await recoverRunTurnAdapterOnPreflight401(error)) return "oauth-401";
471
529
  if (await rotateRunTurnAdapterOnPreflight429(error)) return "oauth-account-429";
472
530
  return undefined;
@@ -683,7 +741,11 @@ export async function executeResponsesRunTurn(
683
741
  const stallTimeoutSec = wsPlan?.stallTimeoutSec ?? config.stallTimeoutSec;
684
742
  // A disabled watchdog (0) is not a zero preflight: it would commit SSE before Devin's first
685
743
  // event and deliver a pre-output 429 in-stream. Fall back to the default finite bound.
686
- const preflightDeadlineAt = grokDevinPreflight
744
+ // A key-pool preflight must not outlast the stall budget, or a silent CLI holds the SSE
745
+ // Response (heartbeats, stall watchdog) hostage; the OAuth preflight wait is unchanged.
746
+ const keyPoolPreflight = transportState.runTurnAdapter.name === "qoder"
747
+ && route.provider.authMode !== "oauth" && hasKeyPoolFailover(route.provider);
748
+ const preflightDeadlineAt = grokDevinPreflight || keyPoolPreflight
687
749
  ? Date.now() + (resolveStallTimeoutSec(stallTimeoutSec) || DEFAULT_STALL_TIMEOUT_SEC) * 1_000
688
750
  : undefined;
689
751
  if (runTurnFailoverArmed()) {